From fc3ec9906f71fb5e9b9db15711139c7ba58571da Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 17:04:28 +0800 Subject: [PATCH 01/23] feat: remove auto-updater module and upgrade `sing` and `sing-box` dependencies. --- .agent/DIRECTORY_STRUCTURE.md | 100 --------- .agent/task_analyze_memory_leak.md | 18 -- .gitignore | 1 + go.mod | 133 +++++++----- go.sum | 320 +++++++++++++++++------------ internal/cli/root.go | 1 - internal/cli/update.go | 63 ------ internal/daemon/daemon.go | 19 -- internal/updater/const.go | 11 - internal/updater/updater.go | 122 ----------- internal/updater/updater_test.go | 62 ------ 11 files changed, 277 insertions(+), 573 deletions(-) delete mode 100644 .agent/DIRECTORY_STRUCTURE.md delete mode 100644 .agent/task_analyze_memory_leak.md delete mode 100644 internal/cli/update.go delete mode 100644 internal/updater/const.go delete mode 100644 internal/updater/updater.go delete mode 100644 internal/updater/updater_test.go diff --git a/.agent/DIRECTORY_STRUCTURE.md b/.agent/DIRECTORY_STRUCTURE.md deleted file mode 100644 index b69b957..0000000 --- a/.agent/DIRECTORY_STRUCTURE.md +++ /dev/null @@ -1,100 +0,0 @@ -# sing-helm 目录规划 - -## 设计原则 - -1. **Runtime 目录**: 存放运行时临时数据(socket, lock, state, cache) - - 需要持久化(重启不丢失) - - 需要快速访问 - -2. **Log 目录**: 存放日志文件 - - 系统标准位置 - - 需要持久化 - -3. **Home 目录**: 存放用户配置 - - 用户级别的配置文件 - - 订阅配置等 - -## 各平台目录规划 - -### macOS (darwin) - -| 类型 | 路径 | 说明 | -|------|------|------| -| Runtime | `/usr/local/var/run/sing-helm` | 持久化,避免 /var/run 重启清空 | -| Log | `/var/log/sing-helm` | 系统标准日志位置 | -| Home | `~/.sing-helm` | 用户配置目录 | - -**Runtime 目录内容**: -- `ipc.sock` - IPC socket -- `sing-helm.lock` - 进程锁 -- `state.json` - 运行状态 -- `runtime.json` - 运行时元数据 -- `raw.json` - 生成的完整配置 -- `cache.db` - sing-box 缓存 -- `assets/` - geoip.db, geosite.db - -**Log 目录内容**: -- `sing-helm.log` - 应用日志(主要) -- `stdout.log` - launchd 捕获的标准输出(仅自动启动时) -- `stderr.log` - launchd 捕获的错误输出(仅自动启动时) - -**Home 目录内容**: -- `profile.json` - 用户配置 -- `subscriptions/` - 订阅配置 -- `subscriptions/cache/` - 订阅缓存 - -### Linux - -| 类型 | 路径 | 说明 | -|------|------|------| -| Runtime | `/run/sing-helm` 或 `/var/run/sing-helm` | 优先使用 /run | -| Log | `/var/log/sing-helm` | 系统标准日志位置 | -| Home | `~/.sing-helm` | 用户配置目录 | - -**注意**: Linux 上 systemd 会自动管理日志到 journald,可用 `journalctl -u sing-helm` 查看。 - -### Windows - -| 类型 | 路径 | 说明 | -|------|------|------| -| Runtime | `%ProgramData%\sing-helm` | 或 Temp 目录 | -| Log | `%ProgramData%\sing-helm\logs` | 或 Temp 目录 | -| Home | `~/.sing-helm` | 用户配置目录 | - -## 代码实现位置 - -- **Runtime 目录解析**: `internal/env/runtime.go` → `ResolveRuntimeDir()` -- **Log 目录解析**: `internal/logger/log.go` → `ResolveLogDir(runtimeDir)` -- **路径组装**: `internal/env/paths.go` → `GetPath(home, runtimeDir, logDir)` - -## 环境变量覆盖 - -可通过环境变量覆盖默认路径: -- `SINGHELM_RUNTIME_DIR` - 覆盖 runtime 目录 - -## 权限要求 - -- **Runtime 目录**: 需要写权限 -- **Log 目录**: 需要写权限(如果没有权限,会降级到 runtime 目录) -- **Home 目录**: 用户权限即可 - -## 自动启动配置 - -### macOS (launchd) -- Plist 路径: `/Library/LaunchDaemons/com.kyson.sing-helm.plist` -- 二进制路径: 动态获取(`os.Executable()`) -- 日志路径: 动态获取(`logger.ResolveLogDir()`) - -### Linux (systemd) -- Unit 路径: `/etc/systemd/system/sing-helm.service` -- 二进制路径: 动态获取(`os.Executable()`) -- 日志: systemd journald - -## 一致性检查 - -✅ macOS Runtime: `/usr/local/var/run/sing-helm` (持久化) -✅ macOS Log: `/var/log/sing-helm` (系统标准) -✅ Linux Runtime: `/run/sing-helm` (标准) -✅ Linux Log: `/var/log/sing-helm` (系统标准) -✅ 二进制路径: 动态获取,不硬编码 -✅ 日志路径: 动态获取,不硬编码 diff --git a/.agent/task_analyze_memory_leak.md b/.agent/task_analyze_memory_leak.md deleted file mode 100644 index 8ae6bb7..0000000 --- a/.agent/task_analyze_memory_leak.md +++ /dev/null @@ -1,18 +0,0 @@ -# Task: Analyze Memory Leak - -## Status -- [x] Analyze project structure and core components -- [x] Review specific modules (daemon, service, ipc, cli, tui, config, updater) -- [x] Identify potential memory leak sources (sing-box core vs sing-helm code) -- [x] Verify upstream sing-box issues -- [x] Report findings to user (Confirmed Hysteria2 memory leak bug in sing-box) - -## Findings -- The memory leak (~900MB growth) is primarily caused by an upstream bug in `sing-box` regarding the **Hysteria2 protocol** (Issue #3421). -- **Remote Rule Sets**: While they consume memory, they are not the cause of the *growth*. -- **Go Code Analysis**: `sing-helm`'s internal Go code (daemon, TUI, etc.) follows good practices and does not show obvious leak patterns. - -## Resolution -- User decided to reduce usage of Hysteria2 protocol to mitigate the issue. -- No code changes required in `sing-helm` at this moment. -- Recommended to wait for `sing-box` upstream fix before upgrading. diff --git a/.gitignore b/.gitignore index 1799bb9..5782933 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # Build artifacts bin/ + # IDEs and editors .idea/ .vscode/ diff --git a/go.mod b/go.mod index c3c7c38..819f2e7 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/gorilla/websocket v1.5.3 github.com/nxadm/tail v1.4.11 - github.com/sagernet/sing v0.7.13 - github.com/sagernet/sing-box v1.12.13 + github.com/sagernet/sing v0.8.0-beta.16.0.20260227013657-e419e9875a07 + github.com/sagernet/sing-box v1.13.0-rc.7 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -21,10 +21,10 @@ require ( github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/anytls/sing-anytls v0.0.11 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/bits-and-blooms/bitset v1.24.4 // indirect - github.com/caddyserver/certmagic v0.23.0 // indirect + github.com/caddyserver/certmagic v0.25.0 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.3 // indirect @@ -33,118 +33,153 @@ require ( github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/coder/websocket v1.8.13 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect github.com/cretz/bine v0.2.0 // indirect + github.com/database64128/netx-go v0.1.1 // indirect + github.com/database64128/tfo-go/v2 v2.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect - github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect + github.com/ebitengine/purego v0.9.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/gaissmai/bart v0.11.1 // indirect - github.com/go-chi/chi/v5 v5.2.2 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-chi/render v1.0.3 // indirect - github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect - github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/godbus/dbus/v5 v5.2.1 // indirect + github.com/gofrs/uuid/v5 v5.4.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect - github.com/illarion/gonotify/v2 v2.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f // indirect + github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect - github.com/libdns/alidns v1.0.5-libdns.v1.beta1 // indirect - github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 // indirect - github.com/libdns/libdns v1.1.0 // indirect + github.com/keybase/go-keychain v0.0.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/acmedns v0.5.0 // indirect + github.com/libdns/alidns v1.0.6-beta.3 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect - github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 // indirect - github.com/metacubex/utls v1.8.3 // indirect - github.com/mholt/acmez/v3 v3.1.2 // indirect - github.com/miekg/dns v1.1.67 // indirect + github.com/metacubex/utls v1.8.4 // indirect + github.com/mholt/acmez/v3 v3.1.4 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/openai/openai-go/v3 v3.23.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect - github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect + github.com/sagernet/cronet-go v0.0.0-20260227112944-17c7ef18afa6 // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260227112944-17c7ef18afa6 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260227112350-bf468eec914d // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260227112350-bf468eec914d // indirect github.com/sagernet/fswatch v0.1.1 // indirect github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect - github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 // indirect - github.com/sagernet/sing-mux v0.3.3 // indirect - github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb // indirect + github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 // indirect + github.com/sagernet/sing-mux v0.3.4 // indirect + github.com/sagernet/sing-quic v0.6.0-beta.13 // indirect github.com/sagernet/sing-shadowsocks v0.2.8 // indirect github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect - github.com/sagernet/sing-tun v0.7.3 // indirect - github.com/sagernet/sing-vmess v0.2.7 // indirect - github.com/sagernet/smux v1.5.34-mod.2 // indirect - github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 // indirect - github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect + github.com/sagernet/sing-tun v0.8.0-beta.18 // indirect + github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect + github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 // indirect + github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect - github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.44.0 // indirect - golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.40.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect - google.golang.org/grpc v1.73.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index 580039e..07beca2 100644 --- a/go.sum +++ b/go.sum @@ -8,14 +8,14 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= -github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU= -github.com/caddyserver/certmagic v0.23.0/go.mod h1:9mEZIWqqWoI+Gf+4Trh04MOVPD0tGSxtqsxg87hAIH4= +github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= +github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -38,40 +38,47 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= -github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= +github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= +github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= +github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= -github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= -github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= +github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= -github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= +github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= +github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= -github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= -github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -80,10 +87,10 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= -github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= -github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= -github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= +github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= +github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -92,43 +99,36 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= -github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= -github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= -github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= +github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 h1:MEufgJohwIjFi2n3eJv4c/8UdRLQVUwPwSWQPoER+eU= +github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= -github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= -github.com/libdns/alidns v1.0.5-libdns.v1.beta1 h1:txHK7UxDed3WFBDjrTZPuMn8X+WmhjBTTAMW5xdy5pQ= -github.com/libdns/alidns v1.0.5-libdns.v1.beta1/go.mod h1:ystHmPwcGoWjPrGpensQSMY9VoCx4cpR2hXNlwk9H/g= -github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 h1:3MGrVWs2COjMkQR17oUw1zMIPbm2YAzxDC3oGVZvQs8= -github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= -github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= -github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= -github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= +github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= +github.com/libdns/alidns v1.0.6-beta.3 h1:KAmb7FQ1tRzKsaAUGa7ZpGKAMRANwg7+1c7tUbSELq8= +github.com/libdns/alidns v1.0.6-beta.3/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -139,22 +139,16 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= -github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= -github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= -github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 h1:Ui+/2s5Qz0lSnDUBmEL12M5Oi/PzvFxGTNohm8ZcsmE= -github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw= -github.com/metacubex/utls v1.8.3 h1:0m/yCxm3SK6kWve2lKiFb1pue1wHitJ8sQQD4Ikqde4= -github.com/metacubex/utls v1.8.3/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= -github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= -github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= -github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= -github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= +github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ= +github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -167,15 +161,19 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/openai/openai-go/v3 v3.23.0 h1:FRFwTcB4FoWFtIunTY/8fgHvzSHgqbfWjiCwOMVrsvw= +github.com/openai/openai-go/v3 v3.23.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -185,6 +183,68 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= +github.com/sagernet/cronet-go v0.0.0-20260227112944-17c7ef18afa6 h1:Ato+guxmEL4uezcYV1UUUDpAv9HlcJQ7BZt2zpnzjuw= +github.com/sagernet/cronet-go v0.0.0-20260227112944-17c7ef18afa6/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go/all v0.0.0-20260227112944-17c7ef18afa6 h1:0ldSjcR5Gt/o+otTvUAmJ28FCLab9lnlpEhxRCMQpRA= +github.com/sagernet/cronet-go/all v0.0.0-20260227112944-17c7ef18afa6/go.mod h1:xVwYoNCyv9tF7W1RJlUdDbT4bn5tyqtyTe1P1ZY2VP8= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260227112350-bf468eec914d h1:tudlBYdQHIWctKIdf7pceBOFIUIISK6yFivwsxhxDk0= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260227112350-bf468eec914d/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260227112350-bf468eec914d h1:F5EsQlIknj0HlExBFR4EXW69dYj0MpK1HCpKhL/weEs= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260227112350-bf468eec914d h1:9SQ6I2Y2radd6RyWEfV+9s1Q9Kth54B6gBHuJWNzQas= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260227112350-bf468eec914d/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260227112350-bf468eec914d h1:+XoeknBi6+s6epDAS3BkEsp5zGqEJsT9L8JEcaq+0nE= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260227112350-bf468eec914d h1:poqfhHJAg+7BtABn4cue7V4y8Kb2eZ1Cy0j+bhDangw= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260227112350-bf468eec914d h1:nH6rtfqWbui9zQPjd18cpvZncGvn21UcVLtmeUoQKXs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260227112350-bf468eec914d h1:HtnjWZzSQBaP29XJ5NoIps1TVZ7DUC7R0NH7IyhJ5Ag= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260227112350-bf468eec914d/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260227112350-bf468eec914d h1:E2DWx0Agrj8Fi745S+otYW+W0rL2I8+Z2rZCFqGYPvQ= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260227112350-bf468eec914d h1:j7f/rBwPlO1RpFQeM35QVHymVXGVo6d8WTz4i4SjcPo= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260227112350-bf468eec914d/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260227112350-bf468eec914d h1:hz8kkcHGMe7QBTpbqkaw89ZFsfX+UN5F5RIDrroDkx8= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260227112350-bf468eec914d/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260227112350-bf468eec914d h1:TNFaO19ySEyqG79j5+dYb+w4ivusrTXanWuogmC4VM0= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260227112350-bf468eec914d h1:Ewc/wR3yu/hOwG/p49nI9TwYmYv3Llm5DA6fSb1w8hY= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260227112350-bf468eec914d h1:PJ24NkPNpMrLGNRdb6moEqJo8gfhYcIRZmQD8jPPCJk= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260227112350-bf468eec914d h1:IaUghNA8cOmmwvzUPKPsfhiG0KmpWpE0mFZl85T5/Bw= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260227112350-bf468eec914d/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260227112350-bf468eec914d h1:whbeDcr9dDWPr45Is9QV6OHAncrBWLJtPuo4uyEJFBg= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260227112350-bf468eec914d h1:ecHgaGMvikNYjsfULekdXjL/cQJXCS38yvHaKVMWtXc= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260227112350-bf468eec914d h1:no7Cb54+vv1bQ39zFp+JIHKO8Tu3sTwqz8SoOAuV/Ek= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260227112350-bf468eec914d h1:DqBSbam9KAzBgDInOoNy4K0baSJyxGWESxrDewU5aSs= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260227112350-bf468eec914d h1:fOR5i+hRyjG8ZzPSG6URkoTKr5qYOJfxZ58zd8HBteM= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260227112350-bf468eec914d h1:hEQGQI+PfUzYBVas4NWw8WiEUsATco6vwv+t4qTtgtw= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260227112350-bf468eec914d/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260227112350-bf468eec914d h1:AzzJ5AtZlwTbU5QOSixZdPLTjzWKCun3AobQChKy0W8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260227112350-bf468eec914d/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260227112350-bf468eec914d h1:9Tp7s/WX4DZLx4ues8G38G2OV7eQbeuU2COEZEbGcF0= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260227112350-bf468eec914d h1:T9EVZKTyZHOamwevomUZnJ6TQNc09I/BwK+L5HJCJj8= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260227112350-bf468eec914d h1:FZmThI7xScJRPERFiA4L2l9KCwA0oi8/lEOajIKEtUQ= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260227112350-bf468eec914d/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260227112350-bf468eec914d h1:BCC/b8bL0dD9Q4ghgKABV/EsMe0J8GE/l7hcRdPkUXQ= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260227112350-bf468eec914d/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260227112350-bf468eec914d h1:3l463BXnC/X42ow2zqHm9Y/K4GM6aRsKUIZBcFxr2+Q= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260227112350-bf468eec914d h1:+XHEZ/z5NgPfjOAzOwfbQzR+42qaDNB0nv+fAOcd6Pc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260227112350-bf468eec914d/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260227112350-bf468eec914d h1:sYWbP+qCt9Rhb1yGaIRY7HVLtaQZmrHWR0obc5+Q1qc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260227112350-bf468eec914d h1:r6eOVlAfmcUMD5nfz+mPd/aORevUKhcvxA1z1GdPnG8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb h1:pprQtDqNgqXkRsXn+0E8ikKOemzmum8bODjSfDene38= @@ -193,33 +253,32 @@ github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZN github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= -github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 h1:ySqffGm82rPqI1TUPqmtHIYd12pfEGScygnOxjTL56w= -github.com/sagernet/quic-go v0.52.0-sing-box-mod.3/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4= -github.com/sagernet/sing v0.6.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing v0.7.13 h1:XNYgd8e3cxMULs/LLJspdn/deHrnPWyrrglNHeCUAYM= -github.com/sagernet/sing v0.7.13/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-box v1.12.13 h1:vH3Jts1bit46hobvGzh5372JTlJtRcJxbCHCUIZRneU= -github.com/sagernet/sing-box v1.12.13/go.mod h1:ObMeEc1VAcJdXN6B/3SUIJRuK7J38m0W6yLNJ+E5f+0= -github.com/sagernet/sing-mux v0.3.3 h1:YFgt9plMWzH994BMZLmyKL37PdIVaIilwP0Jg+EcLfw= -github.com/sagernet/sing-mux v0.3.3/go.mod h1:pht8iFY4c9Xltj7rhVd208npkNaeCxzyXCgulDPLUDA= -github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb h1:5Wx3XeTiKrrrcrAky7Hc1bO3CGxrvho2Vu5b/adlEIM= -github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb/go.mod h1:evP1e++ZG8TJHVV5HudXV4vWeYzGfCdF4HwSJZcdqkI= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= +github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= +github.com/sagernet/sing v0.8.0-beta.16.0.20260227013657-e419e9875a07 h1:LQqb+xtR5uqF6bePmJQ3sAToF/kMCjxSnz17HnboXA8= +github.com/sagernet/sing v0.8.0-beta.16.0.20260227013657-e419e9875a07/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing-box v1.13.0-rc.7 h1:MpsXx+iWjIwfZzLy8eZ/ajEmLLs1Tdb+13RvwK6vtcc= +github.com/sagernet/sing-box v1.13.0-rc.7/go.mod h1:IPGpsNbjhSNEaIemWGHtIGzWirXZ7gQq2+NN1TAcSfI= +github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= +github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-quic v0.6.0-beta.13 h1:umDr6GC5fVbOIoTvqV4544wY61zEN+ObQwVGNP8sX1M= +github.com/sagernet/sing-quic v0.6.0-beta.13/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-tun v0.7.3 h1:MFnAir+l24ElEyxdfwtY8mqvUUL9nPnL9TDYLkOmVes= -github.com/sagernet/sing-tun v0.7.3/go.mod h1:pUEjh9YHQ2gJT6Lk0TYDklh3WJy7lz+848vleGM3JPM= -github.com/sagernet/sing-vmess v0.2.7 h1:2ee+9kO0xW5P4mfe6TYVWf9VtY8k1JhNysBqsiYj0sk= -github.com/sagernet/sing-vmess v0.2.7/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= -github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4= -github.com/sagernet/smux v1.5.34-mod.2/go.mod h1:0KW0+R+ycvA2INW4gbsd7BNyg+HEfLIAxa5N02/28Zc= -github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 h1:MO7s4ni2bSfAOhcan2rdQSWCztkMXmqyg6jYPZp8bEE= -github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2/go.mod h1:EBxXsWu4OH2ELbQLq32WoBeIubG8KgDrg4/Oaxjs6lI= -github.com/sagernet/wireguard-go v0.0.1-beta.7 h1:ltgBwYHfr+9Wz1eG59NiWnHrYEkDKHG7otNZvu85DXI= -github.com/sagernet/wireguard-go v0.0.1-beta.7/go.mod h1:jGXij2Gn2wbrWuYNUmmNhf1dwcZtvyAvQoe8Xd8MbUo= +github.com/sagernet/sing-tun v0.8.0-beta.18 h1:C6oHxP9BNBVEVdC9ABMTXmKej9mUVtcuw2v+IiBS8yw= +github.com/sagernet/sing-tun v0.8.0-beta.18/go.mod h1:+HAK/y9GZljdT0KYKMYDR8MjjqnqDDQZYp5ZZQoRzS8= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= +github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= +github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= +github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 h1:eYz/OpMqWCvO2++iw3dEuzrlfC2xv78GdlGvprIM6O8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:f9cXNB+IOOPnR8DOLMTpr42jf7naxh5Un5Y09BBf5Cg= +github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -227,22 +286,13 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= -github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw= -github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= @@ -255,6 +305,16 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -270,24 +330,24 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -296,18 +356,20 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wus go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -316,41 +378,43 @@ golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/root.go b/internal/cli/root.go index 48cbc0e..6ca769b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -44,7 +44,6 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(newVersionCommand(), newConfigCommand(), newRunCommand(), - newUpdateCommand(), newStatusCommand(), newHealthCommand(), newReloadCommand(), diff --git a/internal/cli/update.go b/internal/cli/update.go deleted file mode 100644 index 20b089d..0000000 --- a/internal/cli/update.go +++ /dev/null @@ -1,63 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/updater" - "github.com/kyson-dev/sing-helm/internal/env" - "github.com/spf13/cobra" -) - -func newUpdateCommand() *cobra.Command { - return &cobra.Command{ - Use: "update rules", - Short: "Update geoip.db and geosite.db", - RunE: func(cmd *cobra.Command, args []string) error { - if _, err := dispatchToDaemon(cmd.Context(), "update", nil); err != nil { - if errors.Is(err, errDaemonUnavailable) { - logger.Info("Daemon unavailable, updating locally") - return updateRules() - } - return err - } - fmt.Println("Update job submitted to daemon; check logs for progress") - return nil - }, - } -} - -func updateRules() error { - dir := env.Get().AssetDir - logger.Info("Updating rules...", "dir", dir) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := updater.Download(ctx, updater.GeoIPURL, dir, updater.GeoIPFilename, printProgress("GeoIP")); err != nil { - fmt.Println("GeoIP downloaded failed") - } else { - fmt.Println("GeoIP downloaded successfully") - } - - if err := updater.Download(ctx, updater.GeoSiteURL, dir, updater.GeoSiteFilename, printProgress("GeoSite")); err != nil { - fmt.Println("GeoSite downloaded failed") - } else { - fmt.Println("GeoSite downloaded successfully") - } - - return nil -} - -func printProgress(name string) updater.ProgressCallback { - return func(current, total int64) { - if total > 0 { - percent := float64(current) / float64(total) * 100 - fmt.Printf("\rDownloading %s: %.1f%% (%d/%d bytes)", name, percent, current, total) - return - } - fmt.Printf("\rDownloading %s: %d bytes", name, current) - } -} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 042b2a8..53aa9e8 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -12,7 +12,6 @@ import ( "github.com/kyson-dev/sing-helm/internal/config" "github.com/kyson-dev/sing-helm/internal/runtime" "github.com/kyson-dev/sing-helm/internal/service" - "github.com/kyson-dev/sing-helm/internal/updater" "github.com/kyson-dev/sing-helm/internal/env" "github.com/kyson-dev/sing-helm/internal/ipc" ) @@ -131,8 +130,6 @@ func (d *Daemon) Handle(ctx context.Context, cmd ipc.CommandMessage) ipc.Command switch cmd.Name { case "run": return d.handleRun(ctx, cmd.Payload) - case "update": - return d.handleUpdate(ctx) case "stop": return d.handleStop() case "status": @@ -425,14 +422,6 @@ func restoreConfig(backupPath, targetPath string) error { return os.WriteFile(targetPath, data, 0644) } -func (d *Daemon) handleUpdate(ctx context.Context) ipc.CommandResult { - logger.Info("Daemon running rule update") - if err := runUpdate(ctx); err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - return ipc.CommandResult{Status: "ok"} -} - func (d *Daemon) handleStop() ipc.CommandResult { d.mu.Lock() running := d.running @@ -450,14 +439,6 @@ func (d *Daemon) handleStop() ipc.CommandResult { return ipc.CommandResult{Status: "ok"} } -func runUpdate(ctx context.Context) error { - dir := env.Get().AssetDir - if err := updater.Download(ctx, updater.GeoIPURL, dir, updater.GeoIPFilename, nil); err != nil { - return err - } - return updater.Download(ctx, updater.GeoSiteURL, dir, updater.GeoSiteFilename, nil) -} - func (d *Daemon) newService() ServiceRunner { if d.serviceFactory != nil { return d.serviceFactory() diff --git a/internal/updater/const.go b/internal/updater/const.go deleted file mode 100644 index 8bbd70e..0000000 --- a/internal/updater/const.go +++ /dev/null @@ -1,11 +0,0 @@ -package updater - -const( - // 使用 SagerNet 官方发布的资源 - // 注意:如果你在国内,可能需要配置 HTTP_PROXY 环境变量才能顺利下载 - GeoIPURL = "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db" - GeoSiteURL = "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db" - - GeoIPFilename = "geoip.db" - GeoSiteFilename = "geosite.db" -) \ No newline at end of file diff --git a/internal/updater/updater.go b/internal/updater/updater.go deleted file mode 100644 index 545ec9c..0000000 --- a/internal/updater/updater.go +++ /dev/null @@ -1,122 +0,0 @@ -package updater - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - - "github.com/kyson-dev/sing-helm/internal/logger" -) - -var ( - defaultHTTPClientFactory = func() *http.Client { - return &http.Client{} - } - httpClientFactory = defaultHTTPClientFactory -) - -type ProgressCallback func(current, total int64) - -func Download(ctx context.Context, url, destDir, filename string, onProgress ProgressCallback) error { - logger.Info("Starting download", "url", url) - - // 1. 创建Http请求 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return err - } - - // 2. 发送请求 - // 超时控制由 Context 统一管理,不在 Client 层设置 Timeout - client := httpClientFactory() - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("network error: %w", err) - } - defer resp.Body.Close() - - // 3. 检查响应状态 - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // 4. 创建临时文件 - // 模式:先下载到 geoip.db.tmp,成功后再重命名为 geoip.db - // 这样可以避免覆盖了旧文件结果新文件下载一半断了,导致旧文件也没了 - tmpPath := filepath.Join(destDir, filename+".tmp") - destPath := filepath.Join(destDir, filename) - - file, err := os.Create(tmpPath) - if err != nil { - return fmt.Errorf("create temp file error: %w", err) - } - defer file.Close() - - // 5. 准备写入 (带进度监控) - contentLength := resp.ContentLength - - // 定义一个 wrapper 来统计写入字节数 - var reader io.Reader = resp.Body - if onProgress != nil { - reader = &progressReader{ - Reader: resp.Body, - Total: contentLength, - Callback: onProgress, - } - } - - // 6. 数据拷贝 (核心 IO 操作) - // io.Copy 会一直阻塞直到下载完成或报错 - if _, err := io.Copy(file, reader); err != nil { - file.Close() // 必须先关闭文件句柄 - os.Remove(tmpPath) // 删除下载了一半的垃圾文件 - return fmt.Errorf("download failed: %w", err) - } - file.Close() // 写入完成,关闭文件 - - // 7. 原子重命名 (Atomic Rename) - // 在 Linux/macOS 上是原子的,Windows 上如果文件存在可能会报错,先尝试删除 - if err := os.Rename(tmpPath, destPath); err != nil { - // 针对 Windows 的兼容处理:如果重命名失败,可能是目标文件已存在且被占用 - // 这里简单处理:先 Remove 再 Rename - _ = os.Remove(destPath) - if err := os.Rename(tmpPath, destPath); err != nil { - return fmt.Errorf("failed to save file: %w", err) - } - } - - logger.Info("Download saved", "path", destPath) - return nil -} - -// SetHTTPClientFactory 用于测试,用自定义的 HTTP 客户端替换默认实现。 -func SetHTTPClientFactory(factory func() *http.Client) { - if factory == nil { - return - } - httpClientFactory = factory -} - -// ResetHTTPClientFactory 恢复默认的 HTTP 客户端行为。 -func ResetHTTPClientFactory() { - httpClientFactory = defaultHTTPClientFactory -} - -type progressReader struct { - io.Reader - Total int64 - Current int64 - Callback ProgressCallback -} - -func (pr *progressReader) Read(p []byte) (int, error) { - n, err := pr.Reader.Read(p) - pr.Current += int64(n) - if pr.Callback != nil { - pr.Callback(pr.Current, pr.Total) - } - return n, err -} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go deleted file mode 100644 index f66a738..0000000 --- a/internal/updater/updater_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package updater_test - -import ( - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/kyson-dev/sing-helm/internal/updater" - "github.com/stretchr/testify/assert" -) - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestUpdater_DownLoad(t *testing.T) { - // 1. 构造一个 Handler 模拟下载内容 - mockContent := "This is a fake geoip database content" - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", "37") - _, _ = w.Write([]byte(mockContent)) - }) - - trans := roundTripFunc(func(req *http.Request) (*http.Response, error) { - recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, req) - resp := recorder.Result() - resp.Request = req - return resp, nil - }) - - client := &http.Client{Transport: trans} - updater.SetHTTPClientFactory(func() *http.Client { - return client - }) - defer updater.ResetHTTPClientFactory() - - tmpDir := t.TempDir() - downloadedBytes := int64(0) - err := updater.Download(context.Background(), "http://fake.test", tmpDir, "test.db", func(current, total int64) { - downloadedBytes = current - }) - - // 4. 断言 - assert.NoError(t, err) - - // 验证文件存在 - destPath := filepath.Join(tmpDir, "test.db") - assert.FileExists(t, destPath) - - // 验证内容正确 - content, _ := os.ReadFile(destPath) - assert.Equal(t, mockContent, string(content)) - - // 验证回调是否执行 - assert.Equal(t, int64(len(mockContent)), downloadedBytes) -} From cdc7186cdb70584c12db95e053c671d669faff23 Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 17:47:02 +0800 Subject: [PATCH 02/23] refactor: comprehensive project restructuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - Directory Restructuring: - cmd/main.go → cmd/sing-helm/main.go (Go standard practice) - internal/env/ → internal/platform/ (clearer naming) - internal/runtime/ → internal/model/ (avoid stdlib conflict) - internal/client/ → internal/clashapi/ (explicit purpose) Phase 2 - Dependency Injection Foundation: - Add internal/app/app.go with Application struct (DI container) - Add platform.Resolve() pure function (no global state) - Add model.SaveStateTo/LoadStateFrom (path as parameter) - Wire Application into CLI via context (AppFromContext) - Add logger.GetInstance() for DI consumers Phase 3 - Anti-Corruption Layer: - Merge internal/config/ + internal/service/ → internal/engine/ - Move tools/exporter → internal/engine/exporter.go - All sing-box imports now isolated to engine/ package - Future sing-box upgrades only require changes in engine/ Phase 4 - Daemon Slimming: - Split daemon.go (578 lines) into focused files: - daemon.go (~200 lines): core struct, Serve, Handle router - handler_run.go: run/reload logic - handler_node.go: node management via Clash API - handler_mode.go: proxy/route mode switching - handler_status.go: status/health/log/stop/reload --- go.mod | 2 +- go.sum | 4 +- internal/app/app.go | 23 + internal/{client => clashapi}/client.go | 2 +- internal/{client => clashapi}/client_test.go | 2 +- internal/cli/autostart.go | 12 +- internal/cli/config.go | 12 +- internal/cli/dispatcher.go | 6 +- internal/cli/log.go | 4 +- internal/cli/node.go | 6 +- internal/cli/root.go | 27 +- internal/cli/run.go | 4 +- internal/cli/serve.go | 15 +- internal/cli/start.go | 4 +- internal/controller/controller.go | 6 +- internal/daemon/daemon.go | 488 ++---------------- internal/daemon/daemon_test.go | 12 +- internal/daemon/handler_mode.go | 64 +++ internal/daemon/handler_node.go | 71 +++ internal/daemon/handler_run.go | 191 +++++++ internal/daemon/handler_status.go | 80 +++ internal/{config => engine}/builder.go | 22 +- internal/{service => engine}/errors.go | 2 +- .../{tools/exporter => engine}/exporter.go | 2 +- internal/{config => engine}/module.go | 8 +- .../module_experimental.go} | 6 +- .../log_module.go => engine/module_log.go} | 2 +- .../module_mixed.go} | 2 +- .../module_outbound.go} | 2 +- .../module_route.go} | 12 +- .../module_subscription.go} | 6 +- .../tun_module.go => engine/module_tun.go} | 2 +- .../user_module.go => engine/module_user.go} | 6 +- internal/{config => engine}/port_override.go | 2 +- internal/{config => engine}/processor.go | 2 +- .../instance.go => engine/singbox.go} | 5 +- internal/{config => engine}/tags.go | 2 +- internal/{config => engine}/utils.go | 2 +- internal/logger/logger.go | 6 + internal/model/platform_bridge.go | 10 + internal/model/state.go | 49 ++ internal/{runtime => model}/type.go | 2 +- internal/{env => platform}/lock.go | 2 +- internal/{env => platform}/paths.go | 52 +- internal/{env => platform}/runtime.go | 2 +- internal/{env => platform}/setup.go | 2 +- internal/runtime/state.go | 35 -- internal/tui/monitor/commands.go | 12 +- internal/tui/monitor/handlers.go | 4 +- internal/tui/monitor/messages.go | 4 +- internal/tui/monitor/model.go | 12 +- 51 files changed, 709 insertions(+), 603 deletions(-) create mode 100644 internal/app/app.go rename internal/{client => clashapi}/client.go (99%) rename internal/{client => clashapi}/client_test.go (99%) create mode 100644 internal/daemon/handler_mode.go create mode 100644 internal/daemon/handler_node.go create mode 100644 internal/daemon/handler_run.go create mode 100644 internal/daemon/handler_status.go rename internal/{config => engine}/builder.go (87%) rename internal/{service => engine}/errors.go (96%) rename internal/{tools/exporter => engine}/exporter.go (99%) rename internal/{config => engine}/module.go (77%) rename internal/{config/experimental_module.go => engine/module_experimental.go} (91%) rename internal/{config/log_module.go => engine/module_log.go} (96%) rename internal/{config/mixed_module.go => engine/module_mixed.go} (98%) rename internal/{config/outbound_module.go => engine/module_outbound.go} (99%) rename internal/{config/route_module.go => engine/module_route.go} (95%) rename internal/{config/subscription_module.go => engine/module_subscription.go} (95%) rename internal/{config/tun_module.go => engine/module_tun.go} (99%) rename internal/{config/user_module.go => engine/module_user.go} (95%) rename internal/{config => engine}/port_override.go (96%) rename internal/{config => engine}/processor.go (99%) rename internal/{service/instance.go => engine/singbox.go} (95%) rename internal/{config => engine}/tags.go (99%) rename internal/{config => engine}/utils.go (98%) create mode 100644 internal/model/platform_bridge.go create mode 100644 internal/model/state.go rename internal/{runtime => model}/type.go (99%) rename internal/{env => platform}/lock.go (99%) rename internal/{env => platform}/paths.go (76%) rename internal/{env => platform}/runtime.go (99%) rename internal/{env => platform}/setup.go (98%) delete mode 100644 internal/runtime/state.go diff --git a/go.mod b/go.mod index 819f2e7..e3e2fad 100644 --- a/go.mod +++ b/go.mod @@ -127,7 +127,7 @@ require ( github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260227112350-bf468eec914d // indirect github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260227112350-bf468eec914d // indirect github.com/sagernet/fswatch v0.1.1 // indirect - github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect + github.com/sagernet/gvisor v0.0.0-20250811-sing-box-mod.1 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 // indirect diff --git a/go.sum b/go.sum index 07beca2..143a3bc 100644 --- a/go.sum +++ b/go.sum @@ -247,8 +247,8 @@ github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260227112350-bf468eec91 github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260227112350-bf468eec914d/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= -github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb h1:pprQtDqNgqXkRsXn+0E8ikKOemzmum8bODjSfDene38= -github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= +github.com/sagernet/gvisor v0.0.0-20250811-sing-box-mod.1 h1:bYLFFxOBLbmeMjMSCzsXJwNAS1EHoBb+G9GlE5oBgM8= +github.com/sagernet/gvisor v0.0.0-20250811-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..41d2896 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,23 @@ +package app + +import ( + "log/slog" + + "github.com/kyson-dev/sing-helm/internal/platform" +) + +// Application is the central dependency holder for the entire program. +// All business components obtain their dependencies from this struct, +// avoiding global singletons. +type Application struct { + Paths platform.Paths + Logger *slog.Logger +} + +// New creates an Application instance by resolving paths and setting up logging. +func New(paths platform.Paths, logger *slog.Logger) *Application { + return &Application{ + Paths: paths, + Logger: logger, + } +} diff --git a/internal/client/client.go b/internal/clashapi/client.go similarity index 99% rename from internal/client/client.go rename to internal/clashapi/client.go index 80d2a32..5115a31 100644 --- a/internal/client/client.go +++ b/internal/clashapi/client.go @@ -1,4 +1,4 @@ -package client +package clashapi import ( "bytes" diff --git a/internal/client/client_test.go b/internal/clashapi/client_test.go similarity index 99% rename from internal/client/client_test.go rename to internal/clashapi/client_test.go index 7dd1866..0b80a9e 100644 --- a/internal/client/client_test.go +++ b/internal/clashapi/client_test.go @@ -1,4 +1,4 @@ -package client +package clashapi import ( "encoding/json" diff --git a/internal/cli/autostart.go b/internal/cli/autostart.go index c123dd4..46a5eb9 100644 --- a/internal/cli/autostart.go +++ b/internal/cli/autostart.go @@ -7,7 +7,7 @@ import ( "runtime" "strings" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/spf13/cobra" ) @@ -157,8 +157,8 @@ func getSystemdUnitContent() (string, error) { } // Use the environment settings for consistent path handling - appHome := env.Get().HomeDir - appLog := env.Get().LogFile + appHome := platform.Get().HomeDir + appLog := platform.Get().LogFile return `[Unit] Description=SingHelm daemon @@ -206,9 +206,9 @@ func getLaunchdPlistContent() (string, error) { // Check if we are running via a symlink, resolve it if possible, or just use the path as is if valid. // For autostart, using the absolute path to the binary is safest. - // Use the environment settings directly, as env.Setup() now handles sudo users correctly - appHome := env.Get().HomeDir - appLog := env.Get().LogFile + // Use the environment settings directly, as platform.Setup() now handles sudo users correctly + appHome := platform.Get().HomeDir + appLog := platform.Get().LogFile return ` diff --git a/internal/cli/config.go b/internal/cli/config.go index 95bc4f6..d100290 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/subscription" "github.com/spf13/cobra" ) @@ -76,7 +76,7 @@ func newConfigAddCommand() *cobra.Command { return fmt.Errorf("url cannot be empty") } - paths := env.Get() + paths := platform.Get() if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err } @@ -115,7 +115,7 @@ func newConfigEditCommand() *cobra.Command { Short: "Edit base config or a subscription file", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() + paths := platform.Get() target := paths.ConfigFile if len(args) == 1 { if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { @@ -134,7 +134,7 @@ func newConfigRefreshCommand() *cobra.Command { Short: "Refresh subscription cache", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() + paths := platform.Get() if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err } @@ -158,7 +158,7 @@ func newConfigDeleteCommand() *cobra.Command { Short: "Delete a subscription config and cache", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() + paths := platform.Get() // 确保目录存在(虽然我们要删除东西,但如果目录都不存在也就没什么好删的,不过为了路径构建不出错) if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err @@ -178,7 +178,7 @@ func newConfigDeleteCommand() *cobra.Command { } func runConfigList(cmd *cobra.Command, _ []string) error { - paths := env.Get() + paths := platform.Get() out := cmd.OutOrStdout() fmt.Fprintf(out, "Base config: %s\n", paths.ConfigFile) diff --git a/internal/cli/dispatcher.go b/internal/cli/dispatcher.go index 3572185..8e04a77 100644 --- a/internal/cli/dispatcher.go +++ b/internal/cli/dispatcher.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/ipc" ) @@ -19,9 +19,9 @@ var ErrDaemonUnavailable = errDaemonUnavailable var commandSenderFactory = defaultCommandSenderFactory func defaultCommandSenderFactory() ipc.CommandSender { - socket := env.Get().SocketFile + socket := platform.Get().SocketFile if !pathExists(socket) { - legacy := filepath.Join(env.Get().HomeDir, "ipc.sock") + legacy := filepath.Join(platform.Get().HomeDir, "ipc.sock") if legacy != socket && pathExists(legacy) { return ipc.NewUnixSender(legacy) } diff --git a/internal/cli/log.go b/internal/cli/log.go index 751a2a4..979d483 100644 --- a/internal/cli/log.go +++ b/internal/cli/log.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/nxadm/tail" "github.com/spf13/cobra" @@ -65,7 +65,7 @@ func showAppLog(cmd *cobra.Command) { func showSystemLogs(cmd *cobra.Command) { // Resolve log directory dynamically - runtimeDir := env.ResolveRuntimeDir() + runtimeDir := platform.ResolveRuntimeDir() logDir := logger.ResolveLogDir(runtimeDir) stdoutLog := filepath.Join(logDir, "stdout.log") diff --git a/internal/cli/node.go b/internal/cli/node.go index fccf249..209191f 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/clashapi" "github.com/spf13/cobra" ) @@ -110,7 +110,7 @@ func newUseCommand() *cobra.Command { } } -func decodeProxies(raw any) (map[string]client.ProxyData, error) { +func decodeProxies(raw any) (map[string]clashapi.ProxyData, error) { if raw == nil { return nil, fmt.Errorf("missing proxies data") } @@ -118,7 +118,7 @@ func decodeProxies(raw any) (map[string]client.ProxyData, error) { if err != nil { return nil, err } - var proxies map[string]client.ProxyData + var proxies map[string]clashapi.ProxyData if err := json.Unmarshal(data, &proxies); err != nil { return nil, err } diff --git a/internal/cli/root.go b/internal/cli/root.go index 6ca769b..4215fb1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,13 +1,27 @@ package cli import ( + "context" "fmt" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/app" "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/spf13/cobra" ) +// appKey is the context key for the Application instance. +type appKey struct{} + +// AppFromContext retrieves the Application from a command's context. +// Returns nil if not set (should not happen after PersistentPreRunE). +func AppFromContext(ctx context.Context) *app.Application { + if v := ctx.Value(appKey{}); v != nil { + return v.(*app.Application) + } + return nil +} + func NewRootCommand() *cobra.Command { var homeDir string var globalDebug bool @@ -19,7 +33,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := env.Setup(home); err != nil { + if err := platform.Setup(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } @@ -28,6 +42,13 @@ func NewRootCommand() *cobra.Command { } else { logger.Setup(logger.Config{Debug: globalDebug, FilePath: logFile}) } + + // Build the Application and attach to context + paths := platform.Get() + application := app.New(paths, logger.GetInstance()) + ctx := context.WithValue(cmd.Context(), appKey{}, application) + cmd.SetContext(ctx) + return nil }, } @@ -61,7 +82,7 @@ func NewRootCommand() *cobra.Command { return cmd } -// execute command +// Execute runs the root command. func Execute() error { return NewRootCommand().Execute() } diff --git a/internal/cli/run.go b/internal/cli/run.go index 5a60517..5336ae9 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -9,7 +9,7 @@ import ( "github.com/kyson-dev/sing-helm/internal/logger" coredaemon "github.com/kyson-dev/sing-helm/internal/daemon" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/ipc" "github.com/spf13/cobra" ) @@ -83,7 +83,7 @@ func runAsDaemon(ctx context.Context, payload map[string]any) error { // 现在(正确) //TODO: 这里可以改进为等待 IPC 服务器真正启动,然后通过统一的dispatchToDaemon发送命令 - sender := ipc.NewUnixSender(env.Get().SocketFile) + sender := ipc.NewUnixSender(platform.Get().SocketFile) resp, err := sender.Send(context.Background(), ipc.CommandMessage{ Name: "run", Payload: payload, diff --git a/internal/cli/serve.go b/internal/cli/serve.go index a64c37e..d0d8c93 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -10,10 +10,9 @@ import ( "strings" "syscall" - "github.com/kyson-dev/sing-helm/internal/config" + "github.com/kyson-dev/sing-helm/internal/engine" "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/runtime" - "github.com/kyson-dev/sing-helm/internal/tools/exporter" + "github.com/kyson-dev/sing-helm/internal/model" "github.com/spf13/cobra" ) @@ -31,18 +30,18 @@ func newServeCommand() *cobra.Command { Long: `Generates a sing-box configuration file locally and starts a HTTP server to share it via LAN.`, RunE: func(cmd *cobra.Command, args []string) error { // 1. 生成并写入本地文件 - runops := runtime.DefaultRunOptions() - runops.ProxyMode = runtime.ProxyModeTUN - runops.RouteMode = runtime.RouteModeRule + runops := model.DefaultRunOptions() + runops.ProxyMode = model.ProxyModeTUN + runops.RouteMode = model.RouteModeRule logger.Info("Building options...") - opts, err := config.BuildOptions(&runops) + opts, err := engine.BuildOptions(&runops) if err != nil { return err } logger.Info("Exporting config...", "version", targetVersion, "platform", platform) - data, err := exporter.Export(opts, exporter.Target{Version: targetVersion, Platform: platform}) + data, err := engine.Export(opts, engine.Target{Version: targetVersion, Platform: platform}) if err != nil { return err } diff --git a/internal/cli/start.go b/internal/cli/start.go index 0b2fc4c..02164f3 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -6,7 +6,7 @@ import ( "os/exec" "time" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func newStartCommand() *cobra.Command { exePath, _ := os.Executable() // 使用 env 获取路径 - paths := env.Get() + paths := platform.Get() logFile := paths.LogFile // 传递 --home 给子进程,确保子进程使用相同的目录 diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 1718236..4f0ea0b 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -4,9 +4,9 @@ import ( "context" "fmt" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/env" "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/platform" ) // SwitchProxyMode 切换代理模式 @@ -78,7 +78,7 @@ func FetchStatus(ctx context.Context) (*Status, error) { } func sendCommand(ctx context.Context, name string, payload map[string]any) (ipc.CommandResult, error) { - sender := ipc.NewUnixSender(env.Get().SocketFile) + sender := ipc.NewUnixSender(platform.Get().SocketFile) resp, err := sender.Send(ctx, ipc.CommandMessage{Name: name, Payload: payload}) if err != nil { return ipc.CommandResult{}, fmt.Errorf("ipc send failed: %w", err) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 53aa9e8..e67c37c 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -2,43 +2,41 @@ package daemon import ( "context" - "errors" "fmt" "os" "sync" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/client" - "github.com/kyson-dev/sing-helm/internal/config" - "github.com/kyson-dev/sing-helm/internal/runtime" - "github.com/kyson-dev/sing-helm/internal/service" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/engine" "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/platform" ) -// Daemon handles long-running sing-box operations and responds to IPC commands. +// ServiceRunner abstracts the sing-box engine lifecycle. type ServiceRunner interface { StartFromFile(context.Context, string) error ReloadFromFile(context.Context, string) error Stop() } +// Daemon manages the sing-box service lifecycle and responds to IPC commands. type Daemon struct { mu sync.Mutex - cancelFunc context.CancelFunc // 用于取消 daemon context + cancelFunc context.CancelFunc service ServiceRunner serviceFactory func() ServiceRunner - lock *env.DaemonLock + lock *platform.DaemonLock running bool - reloading bool // 防止并发 reload - state *runtime.RuntimeState + reloading bool + state *model.RuntimeState } // NewDaemon builds a daemon controller. func NewDaemon() *Daemon { return &Daemon{ serviceFactory: func() ServiceRunner { - return service.NewInstance() + return engine.NewInstance() }, } } @@ -49,7 +47,7 @@ func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { defer d.mu.Unlock() if factory == nil { d.serviceFactory = func() ServiceRunner { - return service.NewInstance() + return engine.NewInstance() } return } @@ -57,75 +55,41 @@ func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { } // Serve starts the IPC server. Blocks until ctx is cancelled. -// Use "run" IPC command to start sing-box service. func (d *Daemon) Serve(ctx context.Context) error { - if err := env.EnsureRuntimeDirs(env.Get().RuntimeDir, env.Get().LogFile); err != nil { + if err := platform.EnsureRuntimeDirs(platform.Get().RuntimeDir, platform.Get().LogFile); err != nil { if os.IsPermission(err) { return fmt.Errorf("runtime directory not writable (try sudo): %w", err) } return fmt.Errorf("runtime directory not writable: %w", err) } - // 检查是否已有实例在运行(通过尝试获取锁) - lock, err := env.AcquireLock(env.Get().RuntimeDir) + + lock, err := platform.AcquireLock(platform.Get().RuntimeDir) if err != nil { return fmt.Errorf("another instance is already running: %w", err) } d.lock = lock d.loadState() - _ = env.SaveRuntimeMeta(env.Get().RuntimeDir, env.RuntimeMeta{ - ConfigHome: env.Get().HomeDir, + _ = platform.SaveRuntimeMeta(platform.Get().RuntimeDir, platform.RuntimeMeta{ + ConfigHome: platform.Get().HomeDir, }) - // 创建可取消的 context,用于控制所有子服务的生命周期 ctx, cancel := context.WithCancel(ctx) d.cancelFunc = cancel defer func() { - logger.Info("Daemon shutting down defer") + logger.Info("Daemon shutting down") cancel() d.cleanup() }() logger.Info("Daemon started, listening for IPC commands") - // 启动 IPC 服务器(阻塞,直到 ctx 取消) - if err := ipc.Serve(ctx, env.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { + if err := ipc.Serve(ctx, platform.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { return err } - - logger.Info("Daemon shutting down") return nil } -// cleanup 清理资源 -func (d *Daemon) cleanup() { - d.mu.Lock() - state := d.state - defer d.mu.Unlock() - - if d.cancelFunc != nil { - d.cancelFunc() - d.cancelFunc = nil - } - - d.running = false - - if d.lock != nil { - d.lock.Release() - d.lock = nil - } - if d.service != nil { - d.service.Stop() - d.service = nil - } - if state != nil { - state.PID = 0 - if err := runtime.SaveState(state); err != nil { - logger.Error("Failed to save runtime state", "error", err) - } - } -} - -// Handle routes the CLI commands to the proper handlers. +// Handle routes IPC commands to the proper handlers. func (d *Daemon) Handle(ctx context.Context, cmd ipc.CommandMessage) ipc.CommandResult { switch cmd.Name { case "run": @@ -153,304 +117,46 @@ func (d *Daemon) Handle(ctx context.Context, cmd ipc.CommandMessage) ipc.Command } } -// handleRun 处理 IPC run 命令,启动 sing-box 服务 -func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.CommandResult { - runops, err := d.parseRunOptions(payload) - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - - // 检查并设置运行状态(原子操作) - d.mu.Lock() - if d.running { - d.mu.Unlock() - return ipc.CommandResult{Status: "error", Error: "sing-box is already running"} - } - // 立即设置为 running,防止并发请求 - d.running = true - d.mu.Unlock() - - // 如果后续启动失败,需要重置 running 状态 - startFailed := true - defer func() { - if startFailed { - d.mu.Lock() - d.running = false - d.mu.Unlock() - } - }() - - // 1. 构建配置 - logger.Info("Building configuration", "mode", runops.ProxyMode, "route", runops.RouteMode) - if err := config.BuildConfig( env.Get().RawConfigFile, &runops); err != nil { - return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to build config: %w", err).Error()} - } +// --- internal helpers --- - // 3. 启动 sing-box 服务 - svc := d.newService() - rawPath := env.Get().RawConfigFile - logger.Info("Starting sing-box", "config", rawPath) - if err := svc.StartFromFile(ctx, rawPath); err != nil { - return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to start sing-box: %w", err).Error()} - } - - // 启动成功,更新状态 - startFailed = false - d.mu.Lock() - d.service = svc - if d.state == nil { - d.state = &runtime.RuntimeState{} - } - d.state.RunOptions = runops - d.mu.Unlock() - - logger.Info("Sing-box started successfully") - return ipc.CommandResult{Status: "ok", Data: map[string]any{ - "proxy_mode": string(runops.ProxyMode), - "route_mode": string(runops.RouteMode), - }} -} - -// parseRunOptions 解析 run 命令的参数 -func (d *Daemon) parseRunOptions(payload map[string]any) (runtime.RunOptions, error) { - runops := runtime.DefaultRunOptions() +func (d *Daemon) cleanup() { d.mu.Lock() state := d.state - d.mu.Unlock() - if state != nil { - logger.Info("Using state from file", "proxy_mode", state.RunOptions.ProxyMode, "route_mode", state.RunOptions.RouteMode) - runops = state.RunOptions - } else { - logger.Info("No state file, using defaults") - } - if payload == nil { - return runops, nil - } - if mode, ok := payload["mode"].(string); ok && mode != "" { - proxyMode, err := runtime.ParseProxyMode(mode) - if err != nil { - return runops, err - } - runops.ProxyMode = proxyMode - } - if route, ok := payload["route"].(string); ok && route != "" { - routeMode, err := runtime.ParseRouteMode(route) - if err != nil { - return runops, err - } - runops.RouteMode = routeMode - } - if port, ok := asInt(payload["api_port"]); ok && port > 0 { - runops.APIPort = port - } - if port, ok := asInt(payload["mixed_port"]); ok && port > 0 { - runops.MixedPort = port - } - return runops, nil -} + defer d.mu.Unlock() -func asInt(val any) (int, bool) { - switch v := val.(type) { - case float64: - return int(v), true - case int: - return v, true - case int64: - return int(v), true + if d.cancelFunc != nil { + d.cancelFunc() + d.cancelFunc = nil } - return 0, false -} + d.running = false -func (d *Daemon) handleStatus() ipc.CommandResult { - running := d.isRunning() - state, err := d.currentState() - if err != nil && running { - return ipc.CommandResult{Status: "error", Error: err.Error()} + if d.lock != nil { + d.lock.Release() + d.lock = nil } - data := map[string]any{ - "running": running, + if d.service != nil { + d.service.Stop() + d.service = nil } if state != nil { - data["proxy_mode"] = state.RunOptions.ProxyMode - data["route_mode"] = state.RunOptions.RouteMode - data["pid"] = state.PID - data["api_port"] = state.RunOptions.APIPort - data["mixed_port"] = state.RunOptions.MixedPort - data["listen_addr"] = state.RunOptions.ListenAddr - } - return ipc.CommandResult{Status: "ok", Data: data} -} - -func (d *Daemon) handleMode(ctx context.Context, payload map[string]any) ipc.CommandResult { - if !d.isRunning() { - return ipc.CommandResult{Status: "error", Error: "sing-box not running"} - } - modeStr, ok := payload["mode"].(string) - if !ok || modeStr == "" { - return ipc.CommandResult{Status: "error", Error: "missing mode"} - } - proxyMode, err := runtime.ParseProxyMode(modeStr) - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - state, err := d.currentState() - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - if (proxyMode == runtime.ProxyModeTUN || state.RunOptions.ProxyMode == runtime.ProxyModeTUN) && os.Geteuid() != 0 { - return ipc.CommandResult{Status: "error", Error: "operating with TUN mode requires root permission"} - } - if state.RunOptions.ProxyMode == proxyMode { - return ipc.CommandResult{Status: "ok", Data: map[string]any{"proxy_mode": string(proxyMode)}} - } - state.RunOptions.ProxyMode = proxyMode - if err := d.applyRunOptions(ctx, state); err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - return ipc.CommandResult{Status: "ok", Data: map[string]any{"proxy_mode": string(proxyMode)}} -} - -func (d *Daemon) handleRoute(ctx context.Context, payload map[string]any) ipc.CommandResult { - if !d.isRunning() { - return ipc.CommandResult{Status: "error", Error: "sing-box not running"} - } - routeStr, ok := payload["route"].(string) - if !ok || routeStr == "" { - return ipc.CommandResult{Status: "error", Error: "missing route"} - } - routeMode, err := runtime.ParseRouteMode(routeStr) - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - state, err := d.currentState() - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - if state.RunOptions.RouteMode == routeMode { - return ipc.CommandResult{Status: "ok", Data: map[string]any{"route_mode": string(routeMode)}} - } - state.RunOptions.RouteMode = routeMode - if err := d.applyRunOptions(ctx, state); err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - return ipc.CommandResult{Status: "ok", Data: map[string]any{"route_mode": string(routeMode)}} -} - -func (d *Daemon) applyRunOptions(ctx context.Context, state *runtime.RuntimeState) error { - // 检查并设置 reloading 标志,防止并发 reload - d.mu.Lock() - if d.reloading { - d.mu.Unlock() - return errors.New("reload already in progress") - } - d.reloading = true - d.mu.Unlock() - defer func() { - d.mu.Lock() - d.reloading = false - d.mu.Unlock() - }() - - backupPath, _ := backupConfig(env.Get().RawConfigFile) - if err := config.BuildConfig(env.Get().RawConfigFile, &state.RunOptions); err != nil { - return err - } - if d.service == nil { - err := errors.New("service not available") - return err - } - if err := d.service.ReloadFromFile(ctx, env.Get().RawConfigFile); err != nil { - var reloadErr *service.ReloadError - if errors.As(err, &reloadErr) && reloadErr.Stage == service.ReloadStageStart { - if backupPath != "" { - if retryErr := d.service.StartFromFile(ctx, backupPath); retryErr == nil { - if restoreErr := restoreConfig(backupPath, env.Get().RawConfigFile); restoreErr != nil { - return restoreErr - } - d.setRunning(true) - _ = os.Remove(backupPath) - } else { - d.setRunning(false) - } - } else { - d.setRunning(false) - } - } - return err - } - d.mu.Lock() - d.state = state - d.mu.Unlock() - return nil -} - -func (d *Daemon) setRunning(running bool) { - d.mu.Lock() - d.running = running - d.mu.Unlock() -} - -func backupConfig(path string) (string, error) { - if path == "" { - return "", nil - } - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - backup := path + ".bak" - input, err := os.ReadFile(path) - if err != nil { - return "", err - } - if err := os.WriteFile(backup, input, 0644); err != nil { - return "", err - } - return backup, nil -} - -func restoreConfig(backupPath, targetPath string) error { - if backupPath == "" || targetPath == "" { - return nil - } - data, err := os.ReadFile(backupPath) - if err != nil { - return err - } - return os.WriteFile(targetPath, data, 0644) -} - -func (d *Daemon) handleStop() ipc.CommandResult { - d.mu.Lock() - running := d.running - cancel := d.cancelFunc - d.mu.Unlock() - - if cancel == nil { - if running { - return ipc.CommandResult{Status: "error", Error: "daemon not running"} + state.PID = 0 + if err := model.SaveState(state); err != nil { + logger.Error("Failed to save runtime state", "error", err) } - return ipc.CommandResult{Status: "error", Error: "daemon not running"} } - // 取消 daemon context 会触发所有子服务退出 - cancel() - return ipc.CommandResult{Status: "ok"} } func (d *Daemon) newService() ServiceRunner { if d.serviceFactory != nil { return d.serviceFactory() } - return service.NewInstance() + return engine.NewInstance() } -func (d *Daemon) currentState() (*runtime.RuntimeState, error) { +func (d *Daemon) currentState() (*model.RuntimeState, error) { d.mu.Lock() state := d.state d.mu.Unlock() - if state != nil { copyState := *state return ©State, nil @@ -458,112 +164,20 @@ func (d *Daemon) currentState() (*runtime.RuntimeState, error) { return nil, nil } -func (d *Daemon) handleNodeList(payload map[string]any) ipc.CommandResult { - if !d.isRunning() { - return ipc.CommandResult{Status: "error", Error: "sing-box not running"} - } - apiAddr, err := d.resolveAPIAddr(payload) - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - c := client.New(apiAddr) - proxies, err := c.GetProxies() - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - return ipc.CommandResult{Status: "ok", Data: map[string]any{"proxies": proxies}} -} - -func (d *Daemon) handleNodeUse(payload map[string]any) ipc.CommandResult { - if !d.isRunning() { - return ipc.CommandResult{Status: "error", Error: "sing-box not running"} - } - group, ok := payload["group"].(string) - if !ok || group == "" { - return ipc.CommandResult{Status: "error", Error: "missing group"} - } - node, ok := payload["node"].(string) - if !ok || node == "" { - return ipc.CommandResult{Status: "error", Error: "missing node"} - } - apiAddr, err := d.resolveAPIAddr(payload) - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - c := client.New(apiAddr) - if err := c.SelectProxy(group, node); err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - return ipc.CommandResult{Status: "ok", Data: map[string]any{"group": group, "node": node}} -} - -func (d *Daemon) handleLog() ipc.CommandResult { - logPath := env.Get().LogFile - return ipc.CommandResult{Status: "ok", Data: map[string]any{"path": logPath}} -} - -func (d *Daemon) handleHealth() ipc.CommandResult { - running := d.isRunning() - data := map[string]any{"running": running} - state, err := d.currentState() - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - if state != nil { - data["pid"] = state.PID - } - return ipc.CommandResult{Status: "ok", Data: data} -} - -func (d *Daemon) handleReload(ctx context.Context) ipc.CommandResult { - if !d.isRunning() { - return ipc.CommandResult{Status: "error", Error: "daemon not running"} - } - state, err := d.currentState() - if err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - if state == nil { - return ipc.CommandResult{Status: "error", Error: "missing state"} - } - if err := d.applyRunOptions(ctx, state); err != nil { - return ipc.CommandResult{Status: "error", Error: err.Error()} - } - return ipc.CommandResult{Status: "ok"} -} - -func (d *Daemon) resolveAPIAddr(payload map[string]any) (string, error) { - if payload != nil { - if api, ok := payload["api"].(string); ok && api != "" { - return api, nil - } - } - state, err := d.currentState() - if err != nil { - return "", err - } - if state == nil { - return "", errors.New("missing state") - } - if state.RunOptions.APIPort == 0 { - return "", errors.New("api port unavailable") - } - listenAddr := state.RunOptions.ListenAddr - if listenAddr == "" { - listenAddr = "127.0.0.1" - } - return fmt.Sprintf("%s:%d", listenAddr, state.RunOptions.APIPort), nil -} - func (d *Daemon) isRunning() bool { d.mu.Lock() defer d.mu.Unlock() return d.running } -func (d *Daemon) loadState() { +func (d *Daemon) setRunning(running bool) { + d.mu.Lock() + d.running = running + d.mu.Unlock() +} - state, err := runtime.LoadState() +func (d *Daemon) loadState() { + state, err := model.LoadState() if err != nil { if os.IsNotExist(err) { return @@ -576,3 +190,15 @@ func (d *Daemon) loadState() { d.state.PID = os.Getpid() d.mu.Unlock() } + +func asInt(val any) (int, bool) { + switch v := val.(type) { + case float64: + return int(v), true + case int: + return v, true + case int64: + return int(v), true + } + return 0, false +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 76c1582..9bd2022 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/kyson-dev/sing-helm/internal/daemon" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/ipc" ) @@ -165,13 +165,13 @@ func TestDaemonHandleCommands(t *testing.T) { func setupEnv(t *testing.T) { t.Helper() - env.ResetForTest() + platform.ResetForTest() dir := t.TempDir() - env.SetRuntimeDir(dir) - if err := env.Init(dir); err != nil { - t.Fatalf("env.Init failed: %v", err) + platform.SetRuntimeDir(dir) + if err := platform.Init(dir); err != nil { + t.Fatalf("platform.Init failed: %v", err) } - if err := os.WriteFile(env.Get().ConfigFile, []byte(`{}`), 0644); err != nil { + if err := os.WriteFile(platform.Get().ConfigFile, []byte(`{}`), 0644); err != nil { t.Fatalf("write profile.json: %v", err) } } diff --git a/internal/daemon/handler_mode.go b/internal/daemon/handler_mode.go new file mode 100644 index 0000000..9a0f939 --- /dev/null +++ b/internal/daemon/handler_mode.go @@ -0,0 +1,64 @@ +package daemon + +import ( + "context" + "os" + + "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/model" +) + +func (d *Daemon) handleMode(ctx context.Context, payload map[string]any) ipc.CommandResult { + if !d.isRunning() { + return ipc.CommandResult{Status: "error", Error: "sing-box not running"} + } + modeStr, ok := payload["mode"].(string) + if !ok || modeStr == "" { + return ipc.CommandResult{Status: "error", Error: "missing mode"} + } + proxyMode, err := model.ParseProxyMode(modeStr) + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + state, err := d.currentState() + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + if (proxyMode == model.ProxyModeTUN || state.RunOptions.ProxyMode == model.ProxyModeTUN) && os.Geteuid() != 0 { + return ipc.CommandResult{Status: "error", Error: "operating with TUN mode requires root permission"} + } + if state.RunOptions.ProxyMode == proxyMode { + return ipc.CommandResult{Status: "ok", Data: map[string]any{"proxy_mode": string(proxyMode)}} + } + state.RunOptions.ProxyMode = proxyMode + if err := d.applyRunOptions(ctx, state); err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + return ipc.CommandResult{Status: "ok", Data: map[string]any{"proxy_mode": string(proxyMode)}} +} + +func (d *Daemon) handleRoute(ctx context.Context, payload map[string]any) ipc.CommandResult { + if !d.isRunning() { + return ipc.CommandResult{Status: "error", Error: "sing-box not running"} + } + routeStr, ok := payload["route"].(string) + if !ok || routeStr == "" { + return ipc.CommandResult{Status: "error", Error: "missing route"} + } + routeMode, err := model.ParseRouteMode(routeStr) + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + state, err := d.currentState() + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + if state.RunOptions.RouteMode == routeMode { + return ipc.CommandResult{Status: "ok", Data: map[string]any{"route_mode": string(routeMode)}} + } + state.RunOptions.RouteMode = routeMode + if err := d.applyRunOptions(ctx, state); err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + return ipc.CommandResult{Status: "ok", Data: map[string]any{"route_mode": string(routeMode)}} +} diff --git a/internal/daemon/handler_node.go b/internal/daemon/handler_node.go new file mode 100644 index 0000000..5ff65d6 --- /dev/null +++ b/internal/daemon/handler_node.go @@ -0,0 +1,71 @@ +package daemon + +import ( + "errors" + "fmt" + + "github.com/kyson-dev/sing-helm/internal/clashapi" + "github.com/kyson-dev/sing-helm/internal/ipc" +) + +func (d *Daemon) handleNodeList(payload map[string]any) ipc.CommandResult { + if !d.isRunning() { + return ipc.CommandResult{Status: "error", Error: "sing-box not running"} + } + apiAddr, err := d.resolveAPIAddr(payload) + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + c := clashapi.New(apiAddr) + proxies, err := c.GetProxies() + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + return ipc.CommandResult{Status: "ok", Data: map[string]any{"proxies": proxies}} +} + +func (d *Daemon) handleNodeUse(payload map[string]any) ipc.CommandResult { + if !d.isRunning() { + return ipc.CommandResult{Status: "error", Error: "sing-box not running"} + } + group, ok := payload["group"].(string) + if !ok || group == "" { + return ipc.CommandResult{Status: "error", Error: "missing group"} + } + node, ok := payload["node"].(string) + if !ok || node == "" { + return ipc.CommandResult{Status: "error", Error: "missing node"} + } + apiAddr, err := d.resolveAPIAddr(payload) + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + c := clashapi.New(apiAddr) + if err := c.SelectProxy(group, node); err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + return ipc.CommandResult{Status: "ok", Data: map[string]any{"group": group, "node": node}} +} + +func (d *Daemon) resolveAPIAddr(payload map[string]any) (string, error) { + if payload != nil { + if api, ok := payload["api"].(string); ok && api != "" { + return api, nil + } + } + state, err := d.currentState() + if err != nil { + return "", err + } + if state == nil { + return "", errors.New("missing state") + } + if state.RunOptions.APIPort == 0 { + return "", errors.New("api port unavailable") + } + listenAddr := state.RunOptions.ListenAddr + if listenAddr == "" { + listenAddr = "127.0.0.1" + } + return fmt.Sprintf("%s:%d", listenAddr, state.RunOptions.APIPort), nil +} diff --git a/internal/daemon/handler_run.go b/internal/daemon/handler_run.go new file mode 100644 index 0000000..dcb9c11 --- /dev/null +++ b/internal/daemon/handler_run.go @@ -0,0 +1,191 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/kyson-dev/sing-helm/internal/engine" + "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/platform" +) + +// handleRun 处理 IPC run 命令,启动 sing-box 服务 +func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.CommandResult { + runops, err := d.parseRunOptions(payload) + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + + // 检查并设置运行状态(原子操作) + d.mu.Lock() + if d.running { + d.mu.Unlock() + return ipc.CommandResult{Status: "error", Error: "sing-box is already running"} + } + // 立即设置为 running,防止并发请求 + d.running = true + d.mu.Unlock() + + // 如果后续启动失败,需要重置 running 状态 + startFailed := true + defer func() { + if startFailed { + d.mu.Lock() + d.running = false + d.mu.Unlock() + } + }() + + // 1. 构建配置 + logger.Info("Building configuration", "mode", runops.ProxyMode, "route", runops.RouteMode) + if err := engine.BuildConfig(platform.Get().RawConfigFile, &runops); err != nil { + return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to build config: %w", err).Error()} + } + + // 2. 启动 sing-box 服务 + svc := d.newService() + rawPath := platform.Get().RawConfigFile + logger.Info("Starting sing-box", "config", rawPath) + if err := svc.StartFromFile(ctx, rawPath); err != nil { + return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to start sing-box: %w", err).Error()} + } + + // 启动成功,更新状态 + startFailed = false + d.mu.Lock() + d.service = svc + if d.state == nil { + d.state = &model.RuntimeState{} + } + d.state.RunOptions = runops + d.mu.Unlock() + + logger.Info("Sing-box started successfully") + return ipc.CommandResult{Status: "ok", Data: map[string]any{ + "proxy_mode": string(runops.ProxyMode), + "route_mode": string(runops.RouteMode), + }} +} + +// parseRunOptions 解析 run 命令的参数 +func (d *Daemon) parseRunOptions(payload map[string]any) (model.RunOptions, error) { + runops := model.DefaultRunOptions() + d.mu.Lock() + state := d.state + d.mu.Unlock() + if state != nil { + logger.Info("Using state from file", "proxy_mode", state.RunOptions.ProxyMode, "route_mode", state.RunOptions.RouteMode) + runops = state.RunOptions + } else { + logger.Info("No state file, using defaults") + } + if payload == nil { + return runops, nil + } + if mode, ok := payload["mode"].(string); ok && mode != "" { + proxyMode, err := model.ParseProxyMode(mode) + if err != nil { + return runops, err + } + runops.ProxyMode = proxyMode + } + if route, ok := payload["route"].(string); ok && route != "" { + routeMode, err := model.ParseRouteMode(route) + if err != nil { + return runops, err + } + runops.RouteMode = routeMode + } + if port, ok := asInt(payload["api_port"]); ok && port > 0 { + runops.APIPort = port + } + if port, ok := asInt(payload["mixed_port"]); ok && port > 0 { + runops.MixedPort = port + } + return runops, nil +} + +// applyRunOptions 重新构建配置并 reload sing-box +func (d *Daemon) applyRunOptions(ctx context.Context, state *model.RuntimeState) error { + // 检查并设置 reloading 标志,防止并发 reload + d.mu.Lock() + if d.reloading { + d.mu.Unlock() + return errors.New("reload already in progress") + } + d.reloading = true + d.mu.Unlock() + defer func() { + d.mu.Lock() + d.reloading = false + d.mu.Unlock() + }() + + backupPath, _ := backupConfig(platform.Get().RawConfigFile) + if err := engine.BuildConfig(platform.Get().RawConfigFile, &state.RunOptions); err != nil { + return err + } + if d.service == nil { + err := errors.New("service not available") + return err + } + if err := d.service.ReloadFromFile(ctx, platform.Get().RawConfigFile); err != nil { + var reloadErr *engine.ReloadError + if errors.As(err, &reloadErr) && reloadErr.Stage == engine.ReloadStageStart { + if backupPath != "" { + if retryErr := d.service.StartFromFile(ctx, backupPath); retryErr == nil { + if restoreErr := restoreConfig(backupPath, platform.Get().RawConfigFile); restoreErr != nil { + return restoreErr + } + d.setRunning(true) + _ = os.Remove(backupPath) + } else { + d.setRunning(false) + } + } else { + d.setRunning(false) + } + } + return err + } + d.mu.Lock() + d.state = state + d.mu.Unlock() + return nil +} + +func backupConfig(path string) (string, error) { + if path == "" { + return "", nil + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + backup := path + ".bak" + input, err := os.ReadFile(path) + if err != nil { + return "", err + } + if err := os.WriteFile(backup, input, 0644); err != nil { + return "", err + } + return backup, nil +} + +func restoreConfig(backupPath, targetPath string) error { + if backupPath == "" || targetPath == "" { + return nil + } + data, err := os.ReadFile(backupPath) + if err != nil { + return err + } + return os.WriteFile(targetPath, data, 0644) +} diff --git a/internal/daemon/handler_status.go b/internal/daemon/handler_status.go new file mode 100644 index 0000000..d7dc59c --- /dev/null +++ b/internal/daemon/handler_status.go @@ -0,0 +1,80 @@ +package daemon + +import ( + "context" + + "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/platform" +) + +func (d *Daemon) handleStatus() ipc.CommandResult { + running := d.isRunning() + state, err := d.currentState() + if err != nil && running { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + data := map[string]any{ + "running": running, + } + if state != nil { + data["proxy_mode"] = state.RunOptions.ProxyMode + data["route_mode"] = state.RunOptions.RouteMode + data["pid"] = state.PID + data["api_port"] = state.RunOptions.APIPort + data["mixed_port"] = state.RunOptions.MixedPort + data["listen_addr"] = state.RunOptions.ListenAddr + } + return ipc.CommandResult{Status: "ok", Data: data} +} + +func (d *Daemon) handleHealth() ipc.CommandResult { + running := d.isRunning() + data := map[string]any{"running": running} + state, err := d.currentState() + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + if state != nil { + data["pid"] = state.PID + } + return ipc.CommandResult{Status: "ok", Data: data} +} + +func (d *Daemon) handleLog() ipc.CommandResult { + logPath := platform.Get().LogFile + return ipc.CommandResult{Status: "ok", Data: map[string]any{"path": logPath}} +} + +func (d *Daemon) handleReload(ctx context.Context) ipc.CommandResult { + if !d.isRunning() { + return ipc.CommandResult{Status: "error", Error: "daemon not running"} + } + state, err := d.currentState() + if err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + if state == nil { + return ipc.CommandResult{Status: "error", Error: "missing state"} + } + if err := d.applyRunOptions(ctx, state); err != nil { + return ipc.CommandResult{Status: "error", Error: err.Error()} + } + return ipc.CommandResult{Status: "ok"} +} + +func (d *Daemon) handleStop() ipc.CommandResult { + d.mu.Lock() + running := d.running + cancel := d.cancelFunc + d.mu.Unlock() + + if cancel == nil { + if running { + return ipc.CommandResult{Status: "error", Error: "daemon not running"} + } + return ipc.CommandResult{Status: "error", Error: "daemon not running"} + } + // 取消 daemon context 会触发所有子服务退出 + cancel() + return ipc.CommandResult{Status: "ok"} +} diff --git a/internal/config/builder.go b/internal/engine/builder.go similarity index 87% rename from internal/config/builder.go rename to internal/engine/builder.go index 47ab5e4..96a8983 100644 --- a/internal/config/builder.go +++ b/internal/engine/builder.go @@ -1,4 +1,4 @@ -package config +package engine import ( "encoding/json" @@ -6,7 +6,7 @@ import ( "os" "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/runtime" + "github.com/kyson-dev/sing-helm/internal/model" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) @@ -14,13 +14,13 @@ import ( // ConfigBuilder 配置构建器 // 支持链式调用添加模块,灵活组装配置 type ConfigBuilder struct { - opts *runtime.RunOptions // 运行时参数 + opts *model.RunOptions // 运行时参数 modules []ConfigModule // 配置模块列表 ctx *BuildContext // 构建上下文 } // BuildConfig loads the profile, applies runtime modules, and saves raw config. -func BuildConfig(rawPath string, runops *runtime.RunOptions) error { +func BuildConfig(rawPath string, runops *model.RunOptions) error { // 使用新的 API,UserOutboundModule 会自动加载配置文件 builder := newConfigBuilder(runops) for _, m := range defaultModules(runops) { @@ -35,7 +35,7 @@ func BuildConfig(rawPath string, runops *runtime.RunOptions) error { } // BuildOptions builds a sing-box config without writing to disk. -func BuildOptions(runops *runtime.RunOptions) (*option.Options, error) { +func BuildOptions(runops *model.RunOptions) (*option.Options, error) { builder := newConfigBuilder(runops) for _, m := range defaultModules(runops) { builder.with(m) @@ -48,9 +48,9 @@ func BuildOptions(runops *runtime.RunOptions) (*option.Options, error) { // - opts: 运行时参数 // // 注意: 这是向后兼容的方法,推荐使用 NewConfigBuilderFromFile -func newConfigBuilder(opts *runtime.RunOptions) *ConfigBuilder { +func newConfigBuilder(opts *model.RunOptions) *ConfigBuilder { if opts == nil { - defaultOpts := runtime.DefaultRunOptions() + defaultOpts := model.DefaultRunOptions() opts = &defaultOpts } return &ConfigBuilder{ @@ -114,7 +114,7 @@ func (b *ConfigBuilder) saveToFile(path string) error { } // DefaultModules 根据 RunOptions 返回默认模块组合 -func defaultModules(opts *runtime.RunOptions) []ConfigModule { +func defaultModules(opts *model.RunOptions) []ConfigModule { modules := []ConfigModule{ &UserOutboundModule{}, &SubscriptionModule{}, @@ -123,18 +123,18 @@ func defaultModules(opts *runtime.RunOptions) []ConfigModule { // 根据 ProxyMode 选择入站模块 switch opts.ProxyMode { - case runtime.ProxyModeTUN: + case model.ProxyModeTUN: modules = append(modules, &TUNModule{}, &TUNDNSModule{}, ) - case runtime.ProxyModeSystem: + case model.ProxyModeSystem: modules = append(modules, &MixedModule{ SetSystemProxy: true, ListenAddr: opts.ListenAddr, Port: opts.MixedPort, }) - case runtime.ProxyModeDefault: + case model.ProxyModeDefault: modules = append(modules, &MixedModule{ SetSystemProxy: false, ListenAddr: opts.ListenAddr, diff --git a/internal/service/errors.go b/internal/engine/errors.go similarity index 96% rename from internal/service/errors.go rename to internal/engine/errors.go index 0cd8069..3643ddd 100644 --- a/internal/service/errors.go +++ b/internal/engine/errors.go @@ -1,4 +1,4 @@ -package service +package engine type ReloadStage string diff --git a/internal/tools/exporter/exporter.go b/internal/engine/exporter.go similarity index 99% rename from internal/tools/exporter/exporter.go rename to internal/engine/exporter.go index fdaafb8..680aab5 100644 --- a/internal/tools/exporter/exporter.go +++ b/internal/engine/exporter.go @@ -1,4 +1,4 @@ -package exporter +package engine import ( "encoding/json" diff --git a/internal/config/module.go b/internal/engine/module.go similarity index 77% rename from internal/config/module.go rename to internal/engine/module.go index 77fdb5e..c9131bd 100644 --- a/internal/config/module.go +++ b/internal/engine/module.go @@ -1,7 +1,7 @@ -package config +package engine import ( - "github.com/kyson-dev/sing-helm/internal/runtime" + "github.com/kyson-dev/sing-helm/internal/model" "github.com/sagernet/sing-box/option" ) @@ -17,11 +17,11 @@ type ConfigModule interface { // BuildContext 构建上下文,模块间共享数据 type BuildContext struct { // RunOptions 运行时参数 - RunOptions *runtime.RunOptions + RunOptions *model.RunOptions } // NewBuildContext 创建构建上下文 -func NewBuildContext(opts *runtime.RunOptions) *BuildContext { +func NewBuildContext(opts *model.RunOptions) *BuildContext { return &BuildContext{ RunOptions: opts, } diff --git a/internal/config/experimental_module.go b/internal/engine/module_experimental.go similarity index 91% rename from internal/config/experimental_module.go rename to internal/engine/module_experimental.go index f842fe4..febf24a 100644 --- a/internal/config/experimental_module.go +++ b/internal/engine/module_experimental.go @@ -1,9 +1,9 @@ -package config +package engine import ( "fmt" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/pkg/netutil" "github.com/sagernet/sing-box/option" ) @@ -53,7 +53,7 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) erro }, CacheFile: &option.CacheFileOptions{ Enabled: true, - Path: env.Get().CacheFile, + Path: platform.Get().CacheFile, }, } diff --git a/internal/config/log_module.go b/internal/engine/module_log.go similarity index 96% rename from internal/config/log_module.go rename to internal/engine/module_log.go index 32ebee6..fa5ec2f 100644 --- a/internal/config/log_module.go +++ b/internal/engine/module_log.go @@ -1,4 +1,4 @@ -package config +package engine import ( "github.com/sagernet/sing-box/option" diff --git a/internal/config/mixed_module.go b/internal/engine/module_mixed.go similarity index 98% rename from internal/config/mixed_module.go rename to internal/engine/module_mixed.go index 0e994c4..24c5017 100644 --- a/internal/config/mixed_module.go +++ b/internal/engine/module_mixed.go @@ -1,4 +1,4 @@ -package config +package engine import ( "github.com/kyson-dev/sing-helm/internal/pkg/netutil" diff --git a/internal/config/outbound_module.go b/internal/engine/module_outbound.go similarity index 99% rename from internal/config/outbound_module.go rename to internal/engine/module_outbound.go index d5b27ab..8066db7 100644 --- a/internal/config/outbound_module.go +++ b/internal/engine/module_outbound.go @@ -1,4 +1,4 @@ -package config +package engine import ( "github.com/kyson-dev/sing-helm/internal/logger" diff --git a/internal/config/route_module.go b/internal/engine/module_route.go similarity index 95% rename from internal/config/route_module.go rename to internal/engine/module_route.go index 22f77ad..4186410 100644 --- a/internal/config/route_module.go +++ b/internal/engine/module_route.go @@ -1,7 +1,7 @@ -package config +package engine import ( - "github.com/kyson-dev/sing-helm/internal/runtime" + "github.com/kyson-dev/sing-helm/internal/model" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) @@ -9,7 +9,7 @@ import ( // RouteModule 路由模块 // 负责配置路由规则,支持 RouteMode type RouteModule struct { - RouteMode runtime.RouteMode + RouteMode model.RouteMode } func (m *RouteModule) Name() string { @@ -28,17 +28,17 @@ func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { // 根据 RouteMode 调整路由 switch m.RouteMode { - case runtime.RouteModeGlobal: + case model.RouteModeGlobal: // 全局代理:清空所有路由规则,直接走 proxy // 保留 RuleSet 以供 DNS 规则使用 opts.Route.Rules = nil opts.Route.Final = "proxy" - case runtime.RouteModeDirect: + case model.RouteModeDirect: // 全局直连:清空所有路由规则,直接走 direct // 保留 RuleSet 以供 DNS 规则使用 opts.Route.Rules = nil opts.Route.Final = "direct" - case runtime.RouteModeRule, "": + case model.RouteModeRule, "": // rule 模式保持用户配置的路由规则 if opts.Route.Final == "" { opts.Route.Final = "proxy" // 默认 final 走代理 diff --git a/internal/config/subscription_module.go b/internal/engine/module_subscription.go similarity index 95% rename from internal/config/subscription_module.go rename to internal/engine/module_subscription.go index 901915c..637317d 100644 --- a/internal/config/subscription_module.go +++ b/internal/engine/module_subscription.go @@ -1,7 +1,7 @@ -package config +package engine import ( - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/kyson-dev/sing-helm/internal/subscription" "github.com/sagernet/sing-box/option" @@ -15,7 +15,7 @@ func (m *SubscriptionModule) Name() string { } func (m *SubscriptionModule) Apply(opts *option.Options, ctx *BuildContext) error { - paths := env.Get() + paths := platform.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { logger.Error("Failed to load subscription sources", "error", err) diff --git a/internal/config/tun_module.go b/internal/engine/module_tun.go similarity index 99% rename from internal/config/tun_module.go rename to internal/engine/module_tun.go index 5479317..8f6cf43 100644 --- a/internal/config/tun_module.go +++ b/internal/engine/module_tun.go @@ -1,4 +1,4 @@ -package config +package engine import ( "context" diff --git a/internal/config/user_module.go b/internal/engine/module_user.go similarity index 95% rename from internal/config/user_module.go rename to internal/engine/module_user.go index 578fd84..56b9d90 100644 --- a/internal/config/user_module.go +++ b/internal/engine/module_user.go @@ -1,11 +1,11 @@ -package config +package engine import ( "bytes" "encoding/json" "os" - "github.com/kyson-dev/sing-helm/internal/env" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/sagernet/sing-box/option" ) @@ -18,7 +18,7 @@ func (m *UserOutboundModule) Name() string { func (m *UserOutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // 如果没有提供 ProfilePath,说明用户配置已经在 opts 中了(向后兼容) - paths := env.Get() + paths := platform.Get() content, err := os.ReadFile(paths.ConfigFile) if err != nil { diff --git a/internal/config/port_override.go b/internal/engine/port_override.go similarity index 96% rename from internal/config/port_override.go rename to internal/engine/port_override.go index e00177c..e4b2e25 100644 --- a/internal/config/port_override.go +++ b/internal/engine/port_override.go @@ -1,4 +1,4 @@ -package config +package engine import ( "os" diff --git a/internal/config/processor.go b/internal/engine/processor.go similarity index 99% rename from internal/config/processor.go rename to internal/engine/processor.go index 33043d5..9cc661a 100644 --- a/internal/config/processor.go +++ b/internal/engine/processor.go @@ -1,4 +1,4 @@ -package config +package engine import ( "context" diff --git a/internal/service/instance.go b/internal/engine/singbox.go similarity index 95% rename from internal/service/instance.go rename to internal/engine/singbox.go index a049b78..aedcab9 100644 --- a/internal/service/instance.go +++ b/internal/engine/singbox.go @@ -1,11 +1,10 @@ -package service +package engine import ( "context" "fmt" "sync" - "github.com/kyson-dev/sing-helm/internal/config" "github.com/kyson-dev/sing-helm/internal/logger" box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/include" @@ -69,7 +68,7 @@ func isAlreadyClosedError(err error) bool { // StartFromFile 从配置文件启动 sing-box func (s *instance) StartFromFile(ctx context.Context, configPath string) error { // 从文件加载配置 - opts, err := config.LoadOptionsWithContext(ctx, configPath) + opts, err := LoadOptionsWithContext(ctx, configPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/config/tags.go b/internal/engine/tags.go similarity index 99% rename from internal/config/tags.go rename to internal/engine/tags.go index 7dea591..122df89 100644 --- a/internal/config/tags.go +++ b/internal/engine/tags.go @@ -1,4 +1,4 @@ -package config +package engine import ( "fmt" diff --git a/internal/config/utils.go b/internal/engine/utils.go similarity index 98% rename from internal/config/utils.go rename to internal/engine/utils.go index 8b9d6d9..9f536e4 100644 --- a/internal/config/utils.go +++ b/internal/engine/utils.go @@ -1,4 +1,4 @@ -package config +package engine import ( "context" diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 06980b2..a96f6e5 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -70,6 +70,12 @@ func get() *slog.Logger { return instance } +// GetInstance returns the underlying *slog.Logger instance. +// This allows passing the logger to DI containers like app.Application. +func GetInstance() *slog.Logger { + return get() +} + // logInternal is internal helper func logInternal(level slog.Level, msg string, args ...any) { l := get() diff --git a/internal/model/platform_bridge.go b/internal/model/platform_bridge.go new file mode 100644 index 0000000..48b9dbc --- /dev/null +++ b/internal/model/platform_bridge.go @@ -0,0 +1,10 @@ +package model + +import "github.com/kyson-dev/sing-helm/internal/platform" + +// platformGetStateFile returns the state file path from the global platform config. +// This is isolated here so state.go doesn't directly import platform, +// making it easier to eventually remove this dependency. +func platformGetStateFile() string { + return platform.Get().StateFile +} diff --git a/internal/model/state.go b/internal/model/state.go new file mode 100644 index 0000000..333748c --- /dev/null +++ b/internal/model/state.go @@ -0,0 +1,49 @@ +package model + +import ( + "encoding/json" + "os" +) + +type RuntimeState struct { + RunOptions RunOptions `json:"run_options"` + PID int `json:"pid"` +} + +// SaveState saves runtime state to the given path. +func SaveState(s *RuntimeState) error { + return SaveStateTo(defaultStatePath(), s) +} + +// LoadState loads runtime state from the default path. +func LoadState() (*RuntimeState, error) { + return LoadStateFrom(defaultStatePath()) +} + +// SaveStateTo saves runtime state to a specific path (DI-friendly). +func SaveStateTo(path string, s *RuntimeState) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// LoadStateFrom loads runtime state from a specific path (DI-friendly). +func LoadStateFrom(path string) (*RuntimeState, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var s RuntimeState + err = json.Unmarshal(data, &s) + return &s, err +} + +// defaultStatePath returns the state file path from global platform config. +// This is kept for backward compatibility during migration. +func defaultStatePath() string { + // Lazy import to avoid circular dependency at package level. + // Uses the global singleton — callers that want DI should use SaveStateTo/LoadStateFrom. + return platformGetStateFile() +} diff --git a/internal/runtime/type.go b/internal/model/type.go similarity index 99% rename from internal/runtime/type.go rename to internal/model/type.go index 46379cb..a976963 100644 --- a/internal/runtime/type.go +++ b/internal/model/type.go @@ -1,4 +1,4 @@ -package runtime +package model import ( "fmt" ) diff --git a/internal/env/lock.go b/internal/platform/lock.go similarity index 99% rename from internal/env/lock.go rename to internal/platform/lock.go index d37b932..22160e9 100644 --- a/internal/env/lock.go +++ b/internal/platform/lock.go @@ -1,4 +1,4 @@ -package env +package platform import ( "errors" diff --git a/internal/env/paths.go b/internal/platform/paths.go similarity index 76% rename from internal/env/paths.go rename to internal/platform/paths.go index 16fd228..ee38793 100644 --- a/internal/env/paths.go +++ b/internal/platform/paths.go @@ -1,4 +1,4 @@ -package env +package platform import ( "os" @@ -41,35 +41,37 @@ func Get() Paths { func Init(home string) error { var err error once.Do(func() { - if home == "" { - // 兜底默认值 - userHome, _ := os.UserHomeDir() - home = filepath.Join(userHome, ".sing-helm") - } + current, err = Resolve(home) + }) + return err +} - // 转换成绝对路径 - home, err = filepath.Abs(home) - if err != nil { - return - } +// Resolve computes Paths from the given home directory without touching global state. +// This is the preferred way to obtain Paths in DI-based code. +func Resolve(home string) (Paths, error) { + if home == "" { + userHome, _ := os.UserHomeDir() + home = filepath.Join(userHome, ".sing-helm") + } - // 确保主目录存在 - if err = os.MkdirAll(home, 0755); err != nil { - return - } + absHome, err := filepath.Abs(home) + if err != nil { + return Paths{}, err + } - runtimeDir := ResolveRuntimeDir() - runtimeDir, err = filepath.Abs(runtimeDir) - if err != nil { - return - } + if err := os.MkdirAll(absHome, 0755); err != nil { + return Paths{}, err + } - logDir := logger.ResolveLogDir(runtimeDir) - current = GetPath(home, runtimeDir, logDir) - }) - return err -} + runtimeDir := ResolveRuntimeDir() + runtimeDir, err = filepath.Abs(runtimeDir) + if err != nil { + return Paths{}, err + } + logDir := logger.ResolveLogDir(runtimeDir) + return GetPath(absHome, runtimeDir, logDir), nil +} // GetPath 根据主目录生成路径配置 (纯函数) func GetPath(home string, runtimeDir string, logDir string) Paths { diff --git a/internal/env/runtime.go b/internal/platform/runtime.go similarity index 99% rename from internal/env/runtime.go rename to internal/platform/runtime.go index 2b2faff..b918670 100644 --- a/internal/env/runtime.go +++ b/internal/platform/runtime.go @@ -1,4 +1,4 @@ -package env +package platform import ( "encoding/json" diff --git a/internal/env/setup.go b/internal/platform/setup.go similarity index 98% rename from internal/env/setup.go rename to internal/platform/setup.go index bad3e84..10a98fd 100644 --- a/internal/env/setup.go +++ b/internal/platform/setup.go @@ -1,4 +1,4 @@ -package env +package platform import ( "os" diff --git a/internal/runtime/state.go b/internal/runtime/state.go deleted file mode 100644 index 8bcbb6b..0000000 --- a/internal/runtime/state.go +++ /dev/null @@ -1,35 +0,0 @@ -package runtime - -import ( - "encoding/json" - "os" - - "github.com/kyson-dev/sing-helm/internal/env" -) - -type RuntimeState struct { - RunOptions RunOptions `json:"run_options"` - PID int `json:"pid"` -} - -func GetStatePath() string { - return env.Get().StateFile -} - -func SaveState(s *RuntimeState) error { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - return os.WriteFile(GetStatePath(), data, 0644) -} - -func LoadState() (*RuntimeState, error) { - data, err := os.ReadFile(GetStatePath()) - if err != nil { - return nil, err - } - var s RuntimeState - err = json.Unmarshal(data, &s) - return &s, err -} diff --git a/internal/tui/monitor/commands.go b/internal/tui/monitor/commands.go index a5127a3..10977fa 100644 --- a/internal/tui/monitor/commands.go +++ b/internal/tui/monitor/commands.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/gorilla/websocket" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/clashapi" "github.com/kyson-dev/sing-helm/internal/controller" ) @@ -68,7 +68,7 @@ func cmdReadTraffic(conn *websocket.Conn) tea.Cmd { // ----------------------------------------------------------------------------- // cmdFetchStatus 获取状态信息 -func cmdFetchStatus(c *client.Client) tea.Cmd { +func cmdFetchStatus(c *clashapi.Client) tea.Cmd { return func() tea.Msg { // 从 sing-box API 获取连接信息 conns, err := c.GetConnections() @@ -108,7 +108,7 @@ func cmdFetchStatus(c *client.Client) tea.Cmd { } // cmdFetchProxies 获取代理列表 -func cmdFetchProxies(c *client.Client) tea.Cmd { +func cmdFetchProxies(c *clashapi.Client) tea.Cmd { return func() tea.Msg { proxies, err := c.GetProxies() if err != nil { @@ -119,7 +119,7 @@ func cmdFetchProxies(c *client.Client) tea.Cmd { } // cmdTestLatency 测试节点延迟 -func cmdTestLatency(c *client.Client, name string) tea.Cmd { +func cmdTestLatency(c *clashapi.Client, name string) tea.Cmd { return func() tea.Msg { delay, err := c.GetNodeDelay(name, "http://www.gstatic.com/generate_204", 2000) if err != nil { @@ -197,7 +197,7 @@ func cmdSwitchRoute(current string) tea.Cmd { } // cmdSwitchNode 切换节点 -func cmdSwitchNode(c *client.Client, group, node string) tea.Cmd { +func cmdSwitchNode(c *clashapi.Client, group, node string) tea.Cmd { return func() tea.Msg { err := c.SelectProxy(group, node) if err != nil { @@ -212,7 +212,7 @@ func cmdSwitchNode(c *client.Client, group, node string) tea.Cmd { // ----------------------------------------------------------------------------- // extractGroups 从代理列表中提取可切换的组 -func extractGroups(proxies map[string]client.ProxyData) []string { +func extractGroups(proxies map[string]clashapi.ProxyData) []string { var groups []string for name, data := range proxies { if data.Type == "Selector" || data.Type == "URLTest" { diff --git a/internal/tui/monitor/handlers.go b/internal/tui/monitor/handlers.go index 5eb8d44..17201e3 100644 --- a/internal/tui/monitor/handlers.go +++ b/internal/tui/monitor/handlers.go @@ -5,7 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/clashapi" ) // ============================================================================ @@ -85,7 +85,7 @@ func (m *Model) handleStatus(msg statusMsg) (Model, tea.Cmd) { m.statusInFlight = false if msg.APIBase != "" && msg.APIBase != m.apiBase { m.apiBase = msg.APIBase - m.apiClient = client.New(msg.APIBase) + m.apiClient = clashapi.New(msg.APIBase) if m.wsConn != nil { m.wsConn.Close() m.wsConn = nil diff --git a/internal/tui/monitor/messages.go b/internal/tui/monitor/messages.go index 7f8ffd7..eff7ffb 100644 --- a/internal/tui/monitor/messages.go +++ b/internal/tui/monitor/messages.go @@ -2,7 +2,7 @@ package monitor import ( "github.com/gorilla/websocket" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/clashapi" ) // ============================================================================ @@ -54,7 +54,7 @@ type statusTickMsg struct{} // proxiesMsg 代理节点列表 type proxiesMsg struct { - Proxies map[string]client.ProxyData + Proxies map[string]clashapi.ProxyData Err error } diff --git a/internal/tui/monitor/model.go b/internal/tui/monitor/model.go index bf3aaa1..1d31b84 100644 --- a/internal/tui/monitor/model.go +++ b/internal/tui/monitor/model.go @@ -4,7 +4,7 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/clashapi" ) // ============================================================================ @@ -20,7 +20,7 @@ type Model struct { connState ConnectionStateMachine // 连接状态机 wsConn *websocket.Conn // WebSocket 连接 apiBase string // API 地址 - apiClient *client.Client // HTTP 客户端 + apiClient *clashapi.Client // HTTP 客户端 lastError error // 最近的错误 updating bool // mode/route 更新中(防止重复请求) @@ -44,7 +44,7 @@ type Model struct { // --- 节点列表 --- groups []string // 代理组列表 - proxies map[string]client.ProxyData // 代理详情 + proxies map[string]clashapi.ProxyData // 代理详情 latencies map[string]int // 节点延迟 (-1=失败, 0=未测试) testing map[string]bool // 正在测速的节点 @@ -75,14 +75,14 @@ func NewModel(apiHost string) Model { return Model{ // 连接管理 apiBase: apiHost, - apiClient: client.New(apiHost), + apiClient: clashapi.New(apiHost), connState: ConnectionStateMachine{State: ConnStateConnecting}, statusInterval: time.Second, // 业务数据初始化 proxyMode: "unknown", routeMode: "unknown", - proxies: make(map[string]client.ProxyData), + proxies: make(map[string]clashapi.ProxyData), latencies: make(map[string]int), testing: make(map[string]bool), } @@ -128,7 +128,7 @@ func (m *Model) Groups() []string { } // Proxies 获取代理详情 -func (m *Model) Proxies() map[string]client.ProxyData { +func (m *Model) Proxies() map[string]clashapi.ProxyData { return m.proxies } From 1ed2a651bf5a8f5d76ab0ff27a54a397e17dd95f Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 18:11:24 +0800 Subject: [PATCH 03/23] refactor: optimize architecture organization and naming Address secondary review findings: - Delete redundant `controller` package, route TUI IPC calls directly - Consolidate duplicated `asInt` implementations to `ipc.AsInt` - Eliminate `internal/pkg` anti-pattern (move `netutil` to `platform`) - Merge `cli/utils.go` into `dispatcher.go` - Extract 366-line `engine/exporter.go` to independent `internal/export` package - Split 350-line `cli/config.go` by extracting operations to `config_ops.go` - Rename files for better clarity (`lifecycle`->`status`, `type`->`options`, `singbox`->`instance`, etc.) --- internal/cli/autostart.go | 2 +- internal/cli/config.go | 174 ---------------- internal/cli/config_ops.go | 185 ++++++++++++++++++ internal/cli/dispatcher.go | 2 +- internal/cli/log.go | 8 +- internal/cli/monitor.go | 15 +- internal/cli/node.go | 2 +- internal/cli/run.go | 4 +- internal/cli/serve.go | 3 +- internal/cli/start.go | 2 +- internal/cli/{lifecycle.go => status.go} | 7 +- internal/cli/utils.go | 11 -- internal/controller/controller.go | 108 ---------- internal/daemon/daemon.go | 12 -- internal/daemon/daemon_test.go | 2 +- internal/daemon/handler_run.go | 4 +- internal/engine/builder.go | 4 +- internal/engine/{singbox.go => instance.go} | 0 internal/engine/{utils.go => loader.go} | 0 internal/engine/module_experimental.go | 3 +- internal/engine/module_mixed.go | 4 +- internal/engine/module_subscription.go | 2 +- internal/engine/module_tun.go | 6 +- internal/engine/{tags.go => naming.go} | 0 .../engine/{port_override.go => ports.go} | 0 internal/engine/{module.go => types.go} | 0 .../{engine/exporter.go => export/compat.go} | 96 +-------- internal/export/export.go | 48 +++++ internal/export/version.go | 59 ++++++ internal/ipc/{sender.go => client.go} | 2 +- internal/ipc/types.go | 14 ++ internal/logger/{log.go => resolve.go} | 0 internal/model/{type.go => options.go} | 1 + internal/{pkg/netutil => platform}/port.go | 6 +- internal/tui/monitor/commands.go | 87 ++++---- internal/tui/monitor/handlers.go | 2 +- internal/tui/monitor/model.go | 14 +- 37 files changed, 404 insertions(+), 485 deletions(-) create mode 100644 internal/cli/config_ops.go rename internal/cli/{lifecycle.go => status.go} (85%) delete mode 100644 internal/cli/utils.go delete mode 100644 internal/controller/controller.go rename internal/engine/{singbox.go => instance.go} (100%) rename internal/engine/{utils.go => loader.go} (100%) rename internal/engine/{tags.go => naming.go} (100%) rename internal/engine/{port_override.go => ports.go} (100%) rename internal/engine/{module.go => types.go} (100%) rename internal/{engine/exporter.go => export/compat.go} (74%) create mode 100644 internal/export/export.go create mode 100644 internal/export/version.go rename internal/ipc/{sender.go => client.go} (99%) rename internal/logger/{log.go => resolve.go} (100%) rename internal/model/{type.go => options.go} (99%) rename internal/{pkg/netutil => platform}/port.go (95%) diff --git a/internal/cli/autostart.go b/internal/cli/autostart.go index 46a5eb9..04bbf71 100644 --- a/internal/cli/autostart.go +++ b/internal/cli/autostart.go @@ -113,7 +113,7 @@ func showLaunchdStatus(cmd *cobra.Command) error { if err != nil { return err } - if !fileExists(launchdPlistPath) { + if !pathExists(launchdPlistPath) { cmd.Printf("Enabled: false\n") return nil } diff --git a/internal/cli/config.go b/internal/cli/config.go index d100290..942ba13 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -3,8 +3,6 @@ package cli import ( "fmt" "os" - "os/exec" - "path/filepath" "strings" "github.com/kyson-dev/sing-helm/internal/platform" @@ -176,175 +174,3 @@ func newConfigDeleteCommand() *cobra.Command { }, } } - -func runConfigList(cmd *cobra.Command, _ []string) error { - paths := platform.Get() - out := cmd.OutOrStdout() - - fmt.Fprintf(out, "Base config: %s\n", paths.ConfigFile) - if _, err := os.Stat(paths.ConfigFile); os.IsNotExist(err) { - fmt.Fprintln(out, " (missing)") - } - - sources, err := subscription.LoadSources(paths.SubConfigDir) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err) - } - - fmt.Fprintln(out, "Subscriptions:") - if len(sources) == 0 { - fmt.Fprintln(out, " (none)") - return nil - } - - for _, source := range sources { - status := "enabled" - if !source.EnabledValue() { - status = "disabled" - } - cachePath := subscription.CacheFilePath(paths.SubCacheDir, source.Name) - cache, err := subscription.LoadCache(cachePath) - updated := "-" - nodes := 0 - if err == nil { - updated = cache.UpdatedAt - nodes = len(cache.Nodes) - } - format := subscription.NormalizeFormat(source.Format) - fmt.Fprintf(out, "- %s %s format=%s nodes=%d updated=%s url=%s\n", - source.Name, status, format, nodes, updated, source.URL) - } - - return nil -} - -func refreshAllSubscriptions(cmd *cobra.Command, dir, cacheDir string) error { - sources, err := subscription.LoadSources(dir) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err) - } - - if len(sources) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No subscription configs found.") - return nil - } - - var failed []string - for _, source := range sources { - if !source.EnabledValue() { - continue - } - if err := refreshSource(cmd, source, cacheDir); err != nil { - failed = append(failed, source.Name) - fmt.Fprintf(cmd.ErrOrStderr(), "Failed to refresh %s: %v\n", source.Name, err) - } - } - - if len(failed) > 0 { - return fmt.Errorf("refresh failed for: %s", strings.Join(failed, ", ")) - } - return nil -} - -func refreshOneSubscription(cmd *cobra.Command, name, dir, cacheDir string) error { - path := subscription.SourceFilePath(dir, name) - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("subscription not found: %s", name) - } - return err - } - - source, err := subscription.LoadSourceFile(path) - if err != nil { - return err - } - return refreshSource(cmd, source, cacheDir) -} - -func refreshSource(cmd *cobra.Command, source subscription.Source, cacheDir string) error { - cache, err := subscription.RefreshSource(cmd.Context(), source, cacheDir) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Refreshed %s: %d nodes\n", source.Name, len(cache.Nodes)) - fmt.Fprintln(cmd.OutOrStdout(), "Restart sing-box to apply changes.") - return nil -} - -func deleteAllSubscriptions(cmd *cobra.Command, dir, cacheDir string) error { - sources, err := subscription.LoadSources(dir) - if err != nil { - return fmt.Errorf("failed to load sources: %w", err) - } - - if len(sources) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No subscriptions found.") - return nil - } - - var failed []string - for _, source := range sources { - if err := deleteOneSubscription(cmd, source.Name, dir, cacheDir); err != nil { - failed = append(failed, source.Name) - fmt.Fprintf(cmd.ErrOrStderr(), "Failed to delete %s: %v\n", source.Name, err) - } - } - - if len(failed) > 0 { - return fmt.Errorf("delete failed for: %s", strings.Join(failed, ", ")) - } - return nil -} - -func deleteOneSubscription(cmd *cobra.Command, name, dir, cacheDir string) error { - configPath := subscription.SourceFilePath(dir, name) - cachePath := subscription.CacheFilePath(cacheDir, name) - - // Check if exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("subscription not found: %s", name) - } - - // Remove config - if err := os.Remove(configPath); err != nil { - return fmt.Errorf("failed to remove config file: %w", err) - } - - // Remove cache (ignore error if not exists) - if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to remove cache file for %s: %v\n", name, err) - } - - fmt.Fprintf(cmd.OutOrStdout(), "Deleted subscription: %s\n", name) - return nil -} - -func openInEditor(cmd *cobra.Command, path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - fmt.Fprintf(cmd.OutOrStdout(), "Configuration file not found: %s\n", path) - fmt.Fprintln(cmd.OutOrStdout(), "It will be created when you save in the editor.") - } - - editor := os.Getenv("VISUAL") - if editor == "" { - editor = os.Getenv("EDITOR") - } - if editor == "" { - editor = "vi" - } - - fmt.Fprintf(cmd.OutOrStdout(), "Opening: %s\n", path) - fmt.Fprintf(cmd.OutOrStdout(), "Editor: %s\n\n", editor) - - editorArgs := strings.Fields(editor) - editorCmd := exec.Command(editorArgs[0], append(editorArgs[1:], filepath.Clean(path))...) - editorCmd.Stdin = os.Stdin - editorCmd.Stdout = os.Stdout - editorCmd.Stderr = os.Stderr - - if err := editorCmd.Run(); err != nil { - return fmt.Errorf("failed to open editor: %w", err) - } - return nil -} diff --git a/internal/cli/config_ops.go b/internal/cli/config_ops.go new file mode 100644 index 0000000..b637e84 --- /dev/null +++ b/internal/cli/config_ops.go @@ -0,0 +1,185 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/subscription" + "github.com/spf13/cobra" +) + +func runConfigList(cmd *cobra.Command, _ []string) error { + paths := platform.Get() + out := cmd.OutOrStdout() + + fmt.Fprintf(out, "Base config: %s\n", paths.ConfigFile) + if _, err := os.Stat(paths.ConfigFile); os.IsNotExist(err) { + fmt.Fprintln(out, " (missing)") + } + + sources, err := subscription.LoadSources(paths.SubConfigDir) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err) + } + + fmt.Fprintln(out, "Subscriptions:") + if len(sources) == 0 { + fmt.Fprintln(out, " (none)") + return nil + } + + for _, source := range sources { + status := "enabled" + if !source.EnabledValue() { + status = "disabled" + } + cachePath := subscription.CacheFilePath(paths.SubCacheDir, source.Name) + cache, err := subscription.LoadCache(cachePath) + updated := "-" + nodes := 0 + if err == nil { + updated = cache.UpdatedAt + nodes = len(cache.Nodes) + } + format := subscription.NormalizeFormat(source.Format) + fmt.Fprintf(out, "- %s %s format=%s nodes=%d updated=%s url=%s\n", + source.Name, status, format, nodes, updated, source.URL) + } + + return nil +} + +func refreshAllSubscriptions(cmd *cobra.Command, dir, cacheDir string) error { + sources, err := subscription.LoadSources(dir) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err) + } + + if len(sources) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No subscription configs found.") + return nil + } + + var failed []string + for _, source := range sources { + if !source.EnabledValue() { + continue + } + if err := refreshSource(cmd, source, cacheDir); err != nil { + failed = append(failed, source.Name) + fmt.Fprintf(cmd.ErrOrStderr(), "Failed to refresh %s: %v\n", source.Name, err) + } + } + + if len(failed) > 0 { + return fmt.Errorf("refresh failed for: %s", strings.Join(failed, ", ")) + } + return nil +} + +func refreshOneSubscription(cmd *cobra.Command, name, dir, cacheDir string) error { + path := subscription.SourceFilePath(dir, name) + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("subscription not found: %s", name) + } + return err + } + + source, err := subscription.LoadSourceFile(path) + if err != nil { + return err + } + return refreshSource(cmd, source, cacheDir) +} + +func refreshSource(cmd *cobra.Command, source subscription.Source, cacheDir string) error { + cache, err := subscription.RefreshSource(cmd.Context(), source, cacheDir) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Refreshed %s: %d nodes\n", source.Name, len(cache.Nodes)) + fmt.Fprintln(cmd.OutOrStdout(), "Restart sing-box to apply changes.") + return nil +} + +func deleteAllSubscriptions(cmd *cobra.Command, dir, cacheDir string) error { + sources, err := subscription.LoadSources(dir) + if err != nil { + return fmt.Errorf("failed to load sources: %w", err) + } + + if len(sources) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No subscriptions found.") + return nil + } + + var failed []string + for _, source := range sources { + if err := deleteOneSubscription(cmd, source.Name, dir, cacheDir); err != nil { + failed = append(failed, source.Name) + fmt.Fprintf(cmd.ErrOrStderr(), "Failed to delete %s: %v\n", source.Name, err) + } + } + + if len(failed) > 0 { + return fmt.Errorf("delete failed for: %s", strings.Join(failed, ", ")) + } + return nil +} + +func deleteOneSubscription(cmd *cobra.Command, name, dir, cacheDir string) error { + configPath := subscription.SourceFilePath(dir, name) + cachePath := subscription.CacheFilePath(cacheDir, name) + + // Check if exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("subscription not found: %s", name) + } + + // Remove config + if err := os.Remove(configPath); err != nil { + return fmt.Errorf("failed to remove config file: %w", err) + } + + // Remove cache (ignore error if not exists) + if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to remove cache file for %s: %v\n", name, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Deleted subscription: %s\n", name) + return nil +} + +func openInEditor(cmd *cobra.Command, path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + fmt.Fprintf(cmd.OutOrStdout(), "Configuration file not found: %s\n", path) + fmt.Fprintln(cmd.OutOrStdout(), "It will be created when you save in the editor.") + } + + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + editor = "vi" + } + + fmt.Fprintf(cmd.OutOrStdout(), "Opening: %s\n", path) + fmt.Fprintf(cmd.OutOrStdout(), "Editor: %s\n\n", editor) + + editorArgs := strings.Fields(editor) + editorCmd := exec.Command(editorArgs[0], append(editorArgs[1:], filepath.Clean(path))...) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + + if err := editorCmd.Run(); err != nil { + return fmt.Errorf("failed to open editor: %w", err) + } + return nil +} diff --git a/internal/cli/dispatcher.go b/internal/cli/dispatcher.go index 8e04a77..594cd72 100644 --- a/internal/cli/dispatcher.go +++ b/internal/cli/dispatcher.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/platform" ) var errDaemonUnavailable = errors.New("daemon unavailable") diff --git a/internal/cli/log.go b/internal/cli/log.go index 979d483..a32bfa1 100644 --- a/internal/cli/log.go +++ b/internal/cli/log.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/nxadm/tail" "github.com/spf13/cobra" ) @@ -77,7 +77,7 @@ func showSystemLogs(cmd *cobra.Command) { fmt.Println() // Check if files exist - if !fileExists(stdoutLog) && !fileExists(stderrLog) { + if !pathExists(stdoutLog) && !pathExists(stderrLog) { fmt.Println("⚠️ No system logs found. This is normal if:") fmt.Println(" - Service was never started via launchd") fmt.Println(" - No startup failures occurred") @@ -85,13 +85,13 @@ func showSystemLogs(cmd *cobra.Command) { } // Show last 20 lines of each - if fileExists(stdoutLog) { + if pathExists(stdoutLog) { fmt.Println("--- stdout.log (last 20 lines) ---") showLastLines(stdoutLog, 20) fmt.Println() } - if fileExists(stderrLog) { + if pathExists(stderrLog) { fmt.Println("--- stderr.log (last 20 lines) ---") showLastLines(stderrLog, 20) } diff --git a/internal/cli/monitor.go b/internal/cli/monitor.go index ff34268..a4e49e0 100644 --- a/internal/cli/monitor.go +++ b/internal/cli/monitor.go @@ -4,6 +4,7 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" + "github.com/kyson-dev/sing-helm/internal/ipc" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/kyson-dev/sing-helm/internal/tui/monitor" "github.com/spf13/cobra" @@ -24,7 +25,7 @@ func newMonitorCommand() *cobra.Command { return fmt.Errorf("sing-box is not running") } listenAddr, _ := resp.Data["listen_addr"].(string) - apiPort, ok := asInt(resp.Data["api_port"]) + apiPort, ok := ipc.AsInt(resp.Data["api_port"]) if !ok || apiPort == 0 { return fmt.Errorf("failed to resolve API port from daemon status") } @@ -47,15 +48,3 @@ func newMonitorCommand() *cobra.Command { cmd.Flags().StringVarP(&host, "host", "H", "", "Sing-box API host") return cmd } - -func asInt(val any) (int, bool) { - switch v := val.(type) { - case float64: - return int(v), true - case int: - return v, true - case int64: - return int(v), true - } - return 0, false -} diff --git a/internal/cli/node.go b/internal/cli/node.go index 209191f..dd43849 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -6,8 +6,8 @@ import ( "sort" "strings" - "github.com/kyson-dev/sing-helm/internal/logger" "github.com/kyson-dev/sing-helm/internal/clashapi" + "github.com/kyson-dev/sing-helm/internal/logger" "github.com/spf13/cobra" ) diff --git a/internal/cli/run.go b/internal/cli/run.go index 5336ae9..7ca0175 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -7,10 +7,10 @@ import ( "syscall" "time" - "github.com/kyson-dev/sing-helm/internal/logger" coredaemon "github.com/kyson-dev/sing-helm/internal/daemon" - "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/spf13/cobra" ) diff --git a/internal/cli/serve.go b/internal/cli/serve.go index d0d8c93..1e11f05 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -11,6 +11,7 @@ import ( "syscall" "github.com/kyson-dev/sing-helm/internal/engine" + "github.com/kyson-dev/sing-helm/internal/export" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/kyson-dev/sing-helm/internal/model" "github.com/spf13/cobra" @@ -41,7 +42,7 @@ func newServeCommand() *cobra.Command { } logger.Info("Exporting config...", "version", targetVersion, "platform", platform) - data, err := engine.Export(opts, engine.Target{Version: targetVersion, Platform: platform}) + data, err := export.Export(opts, export.Target{Version: targetVersion, Platform: platform}) if err != nil { return err } diff --git a/internal/cli/start.go b/internal/cli/start.go index 02164f3..efd2f7f 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -6,8 +6,8 @@ import ( "os/exec" "time" - "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/spf13/cobra" ) diff --git a/internal/cli/lifecycle.go b/internal/cli/status.go similarity index 85% rename from internal/cli/lifecycle.go rename to internal/cli/status.go index aff861f..320c373 100644 --- a/internal/cli/lifecycle.go +++ b/internal/cli/status.go @@ -3,6 +3,7 @@ package cli import ( "fmt" + "github.com/kyson-dev/sing-helm/internal/ipc" "github.com/spf13/cobra" ) @@ -18,7 +19,7 @@ func newStatusCommand() *cobra.Command { running, _ := resp.Data["running"].(bool) fmt.Printf("Running: %v\n", running) - if pid, ok := asInt(resp.Data["pid"]); ok && pid != 0 { + if pid, ok := ipc.AsInt(resp.Data["pid"]); ok && pid != 0 { fmt.Printf("PID: %d\n", pid) } if mode, ok := resp.Data["proxy_mode"].(string); ok && mode != "" { @@ -28,10 +29,10 @@ func newStatusCommand() *cobra.Command { fmt.Printf("Route mode: %s\n", mode) } if addr, ok := resp.Data["listen_addr"].(string); ok && addr != "" { - if apiPort, ok := asInt(resp.Data["api_port"]); ok && apiPort != 0 { + if apiPort, ok := ipc.AsInt(resp.Data["api_port"]); ok && apiPort != 0 { fmt.Printf("API: %s:%d\n", addr, apiPort) } - if mixedPort, ok := asInt(resp.Data["mixed_port"]); ok && mixedPort != 0 { + if mixedPort, ok := ipc.AsInt(resp.Data["mixed_port"]); ok && mixedPort != 0 { fmt.Printf("Mixed: %s:%d\n", addr, mixedPort) } } diff --git a/internal/cli/utils.go b/internal/cli/utils.go deleted file mode 100644 index d065cb5..0000000 --- a/internal/cli/utils.go +++ /dev/null @@ -1,11 +0,0 @@ -package cli - -import ( - "os" -) - -// fileExists checks if a file exists and is not a directory -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/internal/controller/controller.go b/internal/controller/controller.go deleted file mode 100644 index 4f0ea0b..0000000 --- a/internal/controller/controller.go +++ /dev/null @@ -1,108 +0,0 @@ -package controller - -import ( - "context" - "fmt" - - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/platform" -) - -// SwitchProxyMode 切换代理模式 -func SwitchProxyMode(modeStr string) (string, error) { - resp, err := sendCommand(context.Background(), "mode", map[string]any{"mode": modeStr}) - if err != nil { - return "", err - } - if mode, ok := resp.Data["proxy_mode"].(string); ok && mode != "" { - logger.Debug("Proxy mode switched successfully", "mode", mode) - return mode, nil - } - logger.Debug("Proxy mode switched successfully", "mode", modeStr) - return modeStr, nil -} - -// SwitchRouteMode 切换路由模式 -func SwitchRouteMode(modeStr string) (string, error) { - resp, err := sendCommand(context.Background(), "route", map[string]any{"route": modeStr}) - if err != nil { - return "", err - } - if mode, ok := resp.Data["route_mode"].(string); ok && mode != "" { - logger.Debug("Route mode switched successfully", "mode", mode) - return mode, nil - } - logger.Debug("Route mode switched successfully", "mode", modeStr) - return modeStr, nil -} - -type Status struct { - ProxyMode string - RouteMode string - ListenAddr string - APIPort int - MixedPort int - PID int - Running bool -} - -func FetchStatus(ctx context.Context) (*Status, error) { - resp, err := sendCommand(ctx, "status", nil) - if err != nil { - return nil, err - } - status := &Status{} - if mode, ok := resp.Data["proxy_mode"].(string); ok { - status.ProxyMode = mode - } - if mode, ok := resp.Data["route_mode"].(string); ok { - status.RouteMode = mode - } - if addr, ok := resp.Data["listen_addr"].(string); ok { - status.ListenAddr = addr - } - if port, ok := asInt(resp.Data["api_port"]); ok { - status.APIPort = port - } - if port, ok := asInt(resp.Data["mixed_port"]); ok { - status.MixedPort = port - } - if pid, ok := asInt(resp.Data["pid"]); ok { - status.PID = pid - } - if running, ok := resp.Data["running"].(bool); ok { - status.Running = running - } - return status, nil -} - -func sendCommand(ctx context.Context, name string, payload map[string]any) (ipc.CommandResult, error) { - sender := ipc.NewUnixSender(platform.Get().SocketFile) - resp, err := sender.Send(ctx, ipc.CommandMessage{Name: name, Payload: payload}) - if err != nil { - return ipc.CommandResult{}, fmt.Errorf("ipc send failed: %w", err) - } - if resp.Status == "" { - resp.Status = "ok" - } - if resp.Status != "ok" { - if resp.Error != "" { - return resp, fmt.Errorf("daemon error: %s", resp.Error) - } - return resp, fmt.Errorf("daemon responded with status %s", resp.Status) - } - return resp, nil -} - -func asInt(val any) (int, bool) { - switch v := val.(type) { - case float64: - return int(v), true - case int: - return v, true - case int64: - return int(v), true - } - return 0, false -} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e67c37c..bdfc4af 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -190,15 +190,3 @@ func (d *Daemon) loadState() { d.state.PID = os.Getpid() d.mu.Unlock() } - -func asInt(val any) (int, bool) { - switch v := val.(type) { - case float64: - return int(v), true - case int: - return v, true - case int64: - return int(v), true - } - return 0, false -} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 9bd2022..de07810 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -10,8 +10,8 @@ import ( "time" "github.com/kyson-dev/sing-helm/internal/daemon" - "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/platform" ) type fakeService struct { diff --git a/internal/daemon/handler_run.go b/internal/daemon/handler_run.go index dcb9c11..03c4354 100644 --- a/internal/daemon/handler_run.go +++ b/internal/daemon/handler_run.go @@ -100,10 +100,10 @@ func (d *Daemon) parseRunOptions(payload map[string]any) (model.RunOptions, erro } runops.RouteMode = routeMode } - if port, ok := asInt(payload["api_port"]); ok && port > 0 { + if port, ok := ipc.AsInt(payload["api_port"]); ok && port > 0 { runops.APIPort = port } - if port, ok := asInt(payload["mixed_port"]); ok && port > 0 { + if port, ok := ipc.AsInt(payload["mixed_port"]); ok && port > 0 { runops.MixedPort = port } return runops, nil diff --git a/internal/engine/builder.go b/internal/engine/builder.go index 96a8983..aed0876 100644 --- a/internal/engine/builder.go +++ b/internal/engine/builder.go @@ -15,8 +15,8 @@ import ( // 支持链式调用添加模块,灵活组装配置 type ConfigBuilder struct { opts *model.RunOptions // 运行时参数 - modules []ConfigModule // 配置模块列表 - ctx *BuildContext // 构建上下文 + modules []ConfigModule // 配置模块列表 + ctx *BuildContext // 构建上下文 } // BuildConfig loads the profile, applies runtime modules, and saves raw config. diff --git a/internal/engine/singbox.go b/internal/engine/instance.go similarity index 100% rename from internal/engine/singbox.go rename to internal/engine/instance.go diff --git a/internal/engine/utils.go b/internal/engine/loader.go similarity index 100% rename from internal/engine/utils.go rename to internal/engine/loader.go diff --git a/internal/engine/module_experimental.go b/internal/engine/module_experimental.go index febf24a..e5aeb3b 100644 --- a/internal/engine/module_experimental.go +++ b/internal/engine/module_experimental.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/kyson-dev/sing-helm/internal/platform" - "github.com/kyson-dev/sing-helm/internal/pkg/netutil" "github.com/sagernet/sing-box/option" ) @@ -35,7 +34,7 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) erro apiPort = override } else { var err error - apiPort, err = netutil.GetFreePort() + apiPort, err = platform.GetFreePort() if err != nil { return err } diff --git a/internal/engine/module_mixed.go b/internal/engine/module_mixed.go index 24c5017..6049211 100644 --- a/internal/engine/module_mixed.go +++ b/internal/engine/module_mixed.go @@ -1,7 +1,7 @@ package engine import ( - "github.com/kyson-dev/sing-helm/internal/pkg/netutil" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/sagernet/sing-box/option" ) @@ -33,7 +33,7 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { port = override } else { var err error - port, err = netutil.GetFreePort() + port, err = platform.GetFreePort() if err != nil { return err } diff --git a/internal/engine/module_subscription.go b/internal/engine/module_subscription.go index 637317d..214609b 100644 --- a/internal/engine/module_subscription.go +++ b/internal/engine/module_subscription.go @@ -1,8 +1,8 @@ package engine import ( - "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/subscription" "github.com/sagernet/sing-box/option" ) diff --git a/internal/engine/module_tun.go b/internal/engine/module_tun.go index 8f6cf43..3ce5920 100644 --- a/internal/engine/module_tun.go +++ b/internal/engine/module_tun.go @@ -91,9 +91,9 @@ func (m *TUNDNSModule) Apply(opts *option.Options, ctx *BuildContext) error { }, { "domain_suffix": []string{"wise.com", "schwab.com", "interactivebrokers.com", "cloudflare.com", - "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, - "action": "route", - "server": "local_dns", + "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, + "action": "route", + "server": "local_dns", }, { "rule_set": []string{"geosite-cn", "geoip-cn"}, diff --git a/internal/engine/tags.go b/internal/engine/naming.go similarity index 100% rename from internal/engine/tags.go rename to internal/engine/naming.go diff --git a/internal/engine/port_override.go b/internal/engine/ports.go similarity index 100% rename from internal/engine/port_override.go rename to internal/engine/ports.go diff --git a/internal/engine/module.go b/internal/engine/types.go similarity index 100% rename from internal/engine/module.go rename to internal/engine/types.go diff --git a/internal/engine/exporter.go b/internal/export/compat.go similarity index 74% rename from internal/engine/exporter.go rename to internal/export/compat.go index 680aab5..0eb510d 100644 --- a/internal/engine/exporter.go +++ b/internal/export/compat.go @@ -1,53 +1,11 @@ -package engine +package export import ( "encoding/json" "fmt" - "strconv" "strings" - - "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" ) -// Target controls compatibility transforms for exported configs. -type Target struct { - Version string - Platform string -} - -// Export serializes options and applies compatibility transforms when needed. -func Export(opts *option.Options, target Target) ([]byte, error) { - data, err := singboxjson.Marshal(opts) - if err != nil { - return nil, fmt.Errorf("failed to marshal config: %w", err) - } - - var root map[string]any - if err := json.Unmarshal(data, &root); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) - } - - // No transforms needed if no target specified - if strings.TrimSpace(target.Version) == "" && strings.TrimSpace(target.Platform) == "" { - return json.MarshalIndent(root, "", " ") - } - - // Apply version-specific compatibility transforms - if strings.TrimSpace(target.Version) != "" { - if err := applyVersionCompat(root, target.Version); err != nil { - return nil, err - } - } - - // Apply platform-specific compatibility transforms - if strings.TrimSpace(target.Platform) != "" { - applyPlatformCompat(root, target.Platform) - } - - return json.MarshalIndent(root, "", " ") -} - // applyVersionCompat applies version-specific compatibility transforms func applyVersionCompat(root map[string]any, version string) error { less, err := versionLess(version, "1.12.0") @@ -312,55 +270,3 @@ func intFromAny(value any) int { } return 0 } - -func versionLess(a, b string) (bool, error) { - av, err := parseVersion(a) - if err != nil { - return false, err - } - bv, err := parseVersion(b) - if err != nil { - return false, err - } - - for i := 0; i < 3; i++ { - if av[i] < bv[i] { - return true, nil - } - if av[i] > bv[i] { - return false, nil - } - } - return false, nil -} - -func parseVersion(v string) ([3]int, error) { - var out [3]int - trimmed := strings.TrimSpace(strings.TrimPrefix(v, "v")) - if trimmed == "" { - return out, fmt.Errorf("invalid version: %q", v) - } - - parts := strings.Split(trimmed, ".") - if len(parts) > 3 { - parts = parts[:3] - } - - for i := 0; i < 3; i++ { - if i >= len(parts) { - out[i] = 0 - continue - } - part := strings.TrimSpace(parts[i]) - if part == "" { - return out, fmt.Errorf("invalid version: %q", v) - } - value, err := strconv.Atoi(part) - if err != nil { - return out, fmt.Errorf("invalid version: %q", v) - } - out[i] = value - } - - return out, nil -} diff --git a/internal/export/export.go b/internal/export/export.go new file mode 100644 index 0000000..5d94de1 --- /dev/null +++ b/internal/export/export.go @@ -0,0 +1,48 @@ +package export + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// Target controls compatibility transforms for exported configs. +type Target struct { + Version string + Platform string +} + +// Export serializes options and applies compatibility transforms when needed. +func Export(opts *option.Options, target Target) ([]byte, error) { + data, err := singboxjson.Marshal(opts) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // No transforms needed if no target specified + if strings.TrimSpace(target.Version) == "" && strings.TrimSpace(target.Platform) == "" { + return json.MarshalIndent(root, "", " ") + } + + // Apply version-specific compatibility transforms + if strings.TrimSpace(target.Version) != "" { + if err := applyVersionCompat(root, target.Version); err != nil { + return nil, err + } + } + + // Apply platform-specific compatibility transforms + if strings.TrimSpace(target.Platform) != "" { + applyPlatformCompat(root, target.Platform) + } + + return json.MarshalIndent(root, "", " ") +} diff --git a/internal/export/version.go b/internal/export/version.go new file mode 100644 index 0000000..c4d7fdb --- /dev/null +++ b/internal/export/version.go @@ -0,0 +1,59 @@ +package export + +import ( + "fmt" + "strconv" + "strings" +) + +func versionLess(a, b string) (bool, error) { + av, err := parseVersion(a) + if err != nil { + return false, err + } + bv, err := parseVersion(b) + if err != nil { + return false, err + } + + for i := 0; i < 3; i++ { + if av[i] < bv[i] { + return true, nil + } + if av[i] > bv[i] { + return false, nil + } + } + return false, nil +} + +func parseVersion(v string) ([3]int, error) { + var out [3]int + trimmed := strings.TrimSpace(strings.TrimPrefix(v, "v")) + if trimmed == "" { + return out, fmt.Errorf("invalid version: %q", v) + } + + parts := strings.Split(trimmed, ".") + if len(parts) > 3 { + parts = parts[:3] + } + + for i := 0; i < 3; i++ { + if i >= len(parts) { + out[i] = 0 + continue + } + part := strings.TrimSpace(parts[i]) + if part == "" { + return out, fmt.Errorf("invalid version: %q", v) + } + value, err := strconv.Atoi(part) + if err != nil { + return out, fmt.Errorf("invalid version: %q", v) + } + out[i] = value + } + + return out, nil +} diff --git a/internal/ipc/sender.go b/internal/ipc/client.go similarity index 99% rename from internal/ipc/sender.go rename to internal/ipc/client.go index 875db19..d5fc01d 100644 --- a/internal/ipc/sender.go +++ b/internal/ipc/client.go @@ -23,7 +23,7 @@ type UnixSender struct { // NewUnixSender returns a CommandSender that communicates over a unix socket. func NewUnixSender(socket string) *UnixSender { return &UnixSender{ - Socket: socket, + Socket: socket, //Dial: func(network, address string) (net.Conn, error) { return net.Dial("unix", address) }, Timeout: 2 * time.Second, } diff --git a/internal/ipc/types.go b/internal/ipc/types.go index 2893931..33dd7c0 100644 --- a/internal/ipc/types.go +++ b/internal/ipc/types.go @@ -32,3 +32,17 @@ func (f HandlerFunc) Handle(ctx context.Context, cmd CommandMessage) CommandResu } return f(ctx, cmd) } + +// AsInt extracts an int from an any value (supports float64, int, int64). +// This is commonly needed when parsing CommandResult.Data values from JSON. +func AsInt(val any) (int, bool) { + switch v := val.(type) { + case float64: + return int(v), true + case int: + return v, true + case int64: + return int(v), true + } + return 0, false +} diff --git a/internal/logger/log.go b/internal/logger/resolve.go similarity index 100% rename from internal/logger/log.go rename to internal/logger/resolve.go diff --git a/internal/model/type.go b/internal/model/options.go similarity index 99% rename from internal/model/type.go rename to internal/model/options.go index a976963..600bd02 100644 --- a/internal/model/type.go +++ b/internal/model/options.go @@ -1,4 +1,5 @@ package model + import ( "fmt" ) diff --git a/internal/pkg/netutil/port.go b/internal/platform/port.go similarity index 95% rename from internal/pkg/netutil/port.go rename to internal/platform/port.go index 8cab7e8..b8f11a7 100644 --- a/internal/pkg/netutil/port.go +++ b/internal/platform/port.go @@ -1,4 +1,4 @@ -package netutil +package platform import ( "net" @@ -17,7 +17,7 @@ func GetFreePort() (int, error) { return 0, err } defer l.Close() - + // 返回分配到的端口 return l.Addr().(*net.TCPAddr).Port, nil -} \ No newline at end of file +} diff --git a/internal/tui/monitor/commands.go b/internal/tui/monitor/commands.go index 10977fa..a8b7c70 100644 --- a/internal/tui/monitor/commands.go +++ b/internal/tui/monitor/commands.go @@ -2,16 +2,17 @@ package monitor import ( "context" + "fmt" "net" "sort" - "strconv" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/gorilla/websocket" "github.com/kyson-dev/sing-helm/internal/clashapi" - "github.com/kyson-dev/sing-helm/internal/controller" + "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/platform" ) // ============================================================================ @@ -76,14 +77,10 @@ func cmdFetchStatus(c *clashapi.Client) tea.Cmd { proxyMode := "unknown" routeMode := "unknown" apiBase := "" - if status, err := controller.FetchStatus(context.Background()); err == nil { - if status.ProxyMode != "" { - proxyMode = status.ProxyMode - } - if status.RouteMode != "" { - routeMode = status.RouteMode - } - apiBase = apiBaseFromStatus(status) + if status, fetchErr := fetchDaemonStatus(); fetchErr == nil { + proxyMode = status.proxyMode + routeMode = status.routeMode + apiBase = status.apiBase } if err != nil { @@ -141,14 +138,12 @@ func cmdStatusTick(delay time.Duration) tea.Cmd { // ----------------------------------------------------------------------------- // cmdSwitchMode 切换代理模式 -// 智能跳过 TUN(非 root 时) func cmdSwitchMode(current string) tea.Cmd { return func() tea.Msg { - // 计算下一个模式 var next string switch strings.ToLower(current) { case "system": - next = "tun" + next = "tun" case "tun": next = "default" case "default": @@ -157,20 +152,10 @@ func cmdSwitchMode(current string) tea.Cmd { next = "system" } - // 调用 daemon 切换 - _, err := controller.SwitchProxyMode(next) + _, err := sendDaemonCommand("mode", map[string]any{"mode": next}) if err != nil { return modeChangedMsg{NewMode: current, Err: err} - // TUN 权限错误时自动跳过 - // if strings.Contains(err.Error(), "permission") && next == "tun" { - // next = "default" - // _, err = controller.SwitchProxyMode(next) - // } - // if err != nil { - // return modeChangedMsg{NewMode: current, Err: err} - // } } - return modeChangedMsg{NewMode: next, Err: nil} } } @@ -188,7 +173,7 @@ func cmdSwitchRoute(current string) tea.Cmd { next = "rule" } - _, err := controller.SwitchRouteMode(next) + _, err := sendDaemonCommand("route", map[string]any{"route": next}) if err != nil { return routeChangedMsg{NewRoute: current, Err: err} } @@ -220,7 +205,6 @@ func extractGroups(proxies map[string]clashapi.ProxyData) []string { } } - // 排序:auto 放最后 sort.Slice(groups, func(i, j int) bool { if groups[i] == "auto" { return false @@ -234,13 +218,50 @@ func extractGroups(proxies map[string]clashapi.ProxyData) []string { return groups } -func apiBaseFromStatus(status *controller.Status) string { - if status == nil || status.APIPort == 0 { - return "" +// --- IPC helpers (replace controller package) --- + +type daemonStatus struct { + proxyMode string + routeMode string + apiBase string +} + +func fetchDaemonStatus() (*daemonStatus, error) { + resp, err := sendDaemonCommand("status", nil) + if err != nil { + return nil, err + } + s := &daemonStatus{} + if mode, ok := resp.Data["proxy_mode"].(string); ok { + s.proxyMode = mode + } + if mode, ok := resp.Data["route_mode"].(string); ok { + s.routeMode = mode } - addr := status.ListenAddr - if addr == "" { - addr = "127.0.0.1" + if port, ok := ipc.AsInt(resp.Data["api_port"]); ok && port > 0 { + addr, _ := resp.Data["listen_addr"].(string) + if addr == "" { + addr = "127.0.0.1" + } + s.apiBase = fmt.Sprintf("%s:%d", addr, port) + } + return s, nil +} + +func sendDaemonCommand(name string, payload map[string]any) (ipc.CommandResult, error) { + sender := ipc.NewUnixSender(platform.Get().SocketFile) + resp, err := sender.Send(context.Background(), ipc.CommandMessage{Name: name, Payload: payload}) + if err != nil { + return ipc.CommandResult{}, fmt.Errorf("ipc send failed: %w", err) + } + if resp.Status == "" { + resp.Status = "ok" + } + if resp.Status != "ok" { + if resp.Error != "" { + return resp, fmt.Errorf("daemon error: %s", resp.Error) + } + return resp, fmt.Errorf("daemon responded with status %s", resp.Status) } - return addr + ":" + strconv.Itoa(status.APIPort) + return resp, nil } diff --git a/internal/tui/monitor/handlers.go b/internal/tui/monitor/handlers.go index 17201e3..4f7c6ef 100644 --- a/internal/tui/monitor/handlers.go +++ b/internal/tui/monitor/handlers.go @@ -4,8 +4,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/kyson-dev/sing-helm/internal/logger" "github.com/kyson-dev/sing-helm/internal/clashapi" + "github.com/kyson-dev/sing-helm/internal/logger" ) // ============================================================================ diff --git a/internal/tui/monitor/model.go b/internal/tui/monitor/model.go index 1d31b84..4bb6a37 100644 --- a/internal/tui/monitor/model.go +++ b/internal/tui/monitor/model.go @@ -20,7 +20,7 @@ type Model struct { connState ConnectionStateMachine // 连接状态机 wsConn *websocket.Conn // WebSocket 连接 apiBase string // API 地址 - apiClient *clashapi.Client // HTTP 客户端 + apiClient *clashapi.Client // HTTP 客户端 lastError error // 最近的错误 updating bool // mode/route 更新中(防止重复请求) @@ -43,10 +43,10 @@ type Model struct { routeMode string // 路由模式: rule, global, direct // --- 节点列表 --- - groups []string // 代理组列表 + groups []string // 代理组列表 proxies map[string]clashapi.ProxyData // 代理详情 - latencies map[string]int // 节点延迟 (-1=失败, 0=未测试) - testing map[string]bool // 正在测速的节点 + latencies map[string]int // 节点延迟 (-1=失败, 0=未测试) + testing map[string]bool // 正在测速的节点 // ========================================================================= // 第三层:UI 交互状态 @@ -74,9 +74,9 @@ type CursorState struct { func NewModel(apiHost string) Model { return Model{ // 连接管理 - apiBase: apiHost, - apiClient: clashapi.New(apiHost), - connState: ConnectionStateMachine{State: ConnStateConnecting}, + apiBase: apiHost, + apiClient: clashapi.New(apiHost), + connState: ConnectionStateMachine{State: ConnStateConnecting}, statusInterval: time.Second, // 业务数据初始化 From 2100507e1f4b82c55379ae319ff79f51cd91b156 Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 19:00:12 +0800 Subject: [PATCH 04/23] refactor: restructure engine package into config and module subpackages Create `internal/engine/config` for core building logic (builder, loader, types), and `internal/engine/module` for all `ConfigModule` implementations (tun, route, experimental, outbound, etc.). Move high level exported API to `internal/engine/engine.go` to act as the primary facade, eliminating Go circular imports. --- internal/clashapi/client.go | 37 ----- internal/engine/builder.go | 155 ------------------ internal/engine/config/builder.go | 86 ++++++++++ internal/engine/{ => config}/loader.go | 10 +- internal/engine/{ => config}/types.go | 2 +- internal/engine/engine.go | 79 +++++++++ internal/engine/instance.go | 3 +- .../experimental.go} | 5 +- .../engine/{module_log.go => module/log.go} | 5 +- .../{module_mixed.go => module/mixed.go} | 7 +- internal/engine/{ => module}/naming.go | 2 +- .../outbound.go} | 15 +- internal/engine/{ => module}/ports.go | 2 +- internal/engine/{ => module}/processor.go | 2 +- .../{module_route.go => module/route.go} | 5 +- .../subscription.go} | 5 +- .../engine/{module_tun.go => module/tun.go} | 9 +- .../engine/{module_user.go => module/user.go} | 5 +- 18 files changed, 208 insertions(+), 226 deletions(-) delete mode 100644 internal/engine/builder.go create mode 100644 internal/engine/config/builder.go rename internal/engine/{ => config}/loader.go (80%) rename internal/engine/{ => config}/types.go (97%) create mode 100644 internal/engine/engine.go rename internal/engine/{module_experimental.go => module/experimental.go} (87%) rename internal/engine/{module_log.go => module/log.go} (71%) rename internal/engine/{module_mixed.go => module/mixed.go} (85%) rename internal/engine/{ => module}/naming.go (99%) rename internal/engine/{module_outbound.go => module/outbound.go} (84%) rename internal/engine/{ => module}/ports.go (96%) rename internal/engine/{ => module}/processor.go (99%) rename internal/engine/{module_route.go => module/route.go} (96%) rename internal/engine/{module_subscription.go => module/subscription.go} (93%) rename internal/engine/{module_tun.go => module/tun.go} (89%) rename internal/engine/{module_user.go => module/user.go} (92%) diff --git a/internal/clashapi/client.go b/internal/clashapi/client.go index 5115a31..aaab080 100644 --- a/internal/clashapi/client.go +++ b/internal/clashapi/client.go @@ -130,43 +130,6 @@ func (c *Client) GetNodeDelay(name string, testURL string, timeout int) (int, er return res.Delay, nil } -// ConfigsResponse 对应 GET /configs 的响应 -// type ConfigsResponse struct { -// Port int `json:"port"` -// SocksPort int `json:"socks-port"` -// RedirPort int `json:"redir-port"` -// TProxyPort int `json:"tproxy-port"` -// MixedPort int `json:"mixed-port"` -// AllowLan bool `json:"allow-lan"` -// BindAddress string `json:"bind-address"` -// Mode string `json:"mode"` -// ModeList []string `json:"mode-list"` -// LogLevel string `json:"log-level"` -// IPv6 bool `json:"ipv6"` -// Tun map[string]any `json:"tun"` -// } - -// GetConfigs 获取 sing-box 运行配置 -// func (c *Client) GetConfigs() (*ConfigsResponse, error) { -// url := fmt.Sprintf("%s/configs", c.baseURL) -// resp, err := c.httpClient.Get(url) -// if err != nil { -// return nil, fmt.Errorf("connect api failed: %w", err) -// } -// defer resp.Body.Close() - -// if resp.StatusCode != http.StatusOK { -// return nil, fmt.Errorf("bad status: %s", resp.Status) -// } - -// var configs ConfigsResponse -// if err := json.NewDecoder(resp.Body).Decode(&configs); err != nil { -// return nil, fmt.Errorf("decode json failed: %w", err) -// } - -// return &configs, nil -// } - // ConnectionsResponse 对应 GET /connections 的响应 type ConnectionsResponse struct { DownloadTotal int64 `json:"downloadTotal"` diff --git a/internal/engine/builder.go b/internal/engine/builder.go deleted file mode 100644 index aed0876..0000000 --- a/internal/engine/builder.go +++ /dev/null @@ -1,155 +0,0 @@ -package engine - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/model" - "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" -) - -// ConfigBuilder 配置构建器 -// 支持链式调用添加模块,灵活组装配置 -type ConfigBuilder struct { - opts *model.RunOptions // 运行时参数 - modules []ConfigModule // 配置模块列表 - ctx *BuildContext // 构建上下文 -} - -// BuildConfig loads the profile, applies runtime modules, and saves raw config. -func BuildConfig(rawPath string, runops *model.RunOptions) error { - // 使用新的 API,UserOutboundModule 会自动加载配置文件 - builder := newConfigBuilder(runops) - for _, m := range defaultModules(runops) { - builder.with(m) - } - - if err := builder.saveToFile(rawPath); err != nil { - return fmt.Errorf("failed to save raw config: %w", err) - } - - return nil -} - -// BuildOptions builds a sing-box config without writing to disk. -func BuildOptions(runops *model.RunOptions) (*option.Options, error) { - builder := newConfigBuilder(runops) - for _, m := range defaultModules(runops) { - builder.with(m) - } - return builder.build() -} - -// newConfigBuilder 创建配置构建器(从已加载的配置) -// 参数: -// - opts: 运行时参数 -// -// 注意: 这是向后兼容的方法,推荐使用 NewConfigBuilderFromFile -func newConfigBuilder(opts *model.RunOptions) *ConfigBuilder { - if opts == nil { - defaultOpts := model.DefaultRunOptions() - opts = &defaultOpts - } - return &ConfigBuilder{ - opts: opts, - modules: []ConfigModule{}, - ctx: NewBuildContext(opts), - } -} - -// with 添加一个模块(链式调用) -func (b *ConfigBuilder) with(m ConfigModule) *ConfigBuilder { - b.modules = append(b.modules, m) - return b -} - -// build 构建完整的 sing-box 配置 -func (b *ConfigBuilder) build() (*option.Options, error) { - // 1. 复制用户配置作为基础 - result := &option.Options{} - - // 2. 依次应用各模块 - for _, m := range b.modules { - logger.Debug("Applying config module", "name", m.Name()) - if err := m.Apply(result, b.ctx); err != nil { - return nil, fmt.Errorf("module %s failed: %w", m.Name(), err) - } - } - - return result, nil -} - -// saveToFile 构建配置并保存到文件 -func (b *ConfigBuilder) saveToFile(path string) error { - opts, err := b.build() - if err != nil { - return err - } - - // 使用 sing-box 的 JSON 序列化 - data, err := singboxjson.Marshal(opts) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - // Re-marshal for pretty print - var pretty interface{} - if err := json.Unmarshal(data, &pretty); err != nil { - return fmt.Errorf("failed to unmarshal for pretty print: %w", err) - } - data, err = json.MarshalIndent(pretty, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal indent: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - logger.Info("Config saved", "path", path) - return nil -} - -// DefaultModules 根据 RunOptions 返回默认模块组合 -func defaultModules(opts *model.RunOptions) []ConfigModule { - modules := []ConfigModule{ - &UserOutboundModule{}, - &SubscriptionModule{}, - &OutboundModule{}, - } - - // 根据 ProxyMode 选择入站模块 - switch opts.ProxyMode { - case model.ProxyModeTUN: - modules = append(modules, - &TUNModule{}, - &TUNDNSModule{}, - ) - case model.ProxyModeSystem: - modules = append(modules, &MixedModule{ - SetSystemProxy: true, - ListenAddr: opts.ListenAddr, - Port: opts.MixedPort, - }) - case model.ProxyModeDefault: - modules = append(modules, &MixedModule{ - SetSystemProxy: false, - ListenAddr: opts.ListenAddr, - Port: opts.MixedPort, - }) - } - - modules = append(modules, - &RouteModule{RouteMode: opts.RouteMode}, - &ExperimentalModule{ - ListenAddr: opts.ListenAddr, - APIPort: opts.APIPort, - }, - &LogModule{}, - ) - - return modules -} diff --git a/internal/engine/config/builder.go b/internal/engine/config/builder.go new file mode 100644 index 0000000..75c46c1 --- /dev/null +++ b/internal/engine/config/builder.go @@ -0,0 +1,86 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/model" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// Builder 配置构建器 +// 支持链式调用添加模块,灵活组装配置 +type Builder struct { + opts *model.RunOptions // 运行时参数 + modules []ConfigModule // 配置模块列表 + ctx *BuildContext // 构建上下文 +} + +// NewBuilder 创建配置构建器(从已加载的配置) +func NewBuilder(opts *model.RunOptions) *Builder { + if opts == nil { + defaultOpts := model.DefaultRunOptions() + opts = &defaultOpts + } + return &Builder{ + opts: opts, + modules: []ConfigModule{}, + ctx: NewBuildContext(opts), + } +} + +// With 添加一个模块(链式调用) +func (b *Builder) With(m ConfigModule) *Builder { + b.modules = append(b.modules, m) + return b +} + +// Build 构建完整的 sing-box 配置 +func (b *Builder) Build() (*option.Options, error) { + // 1. 复制用户配置作为基础 + result := &option.Options{} + + // 2. 依次应用各模块 + for _, m := range b.modules { + logger.Debug("Applying config module", "name", m.Name()) + if err := m.Apply(result, b.ctx); err != nil { + return nil, fmt.Errorf("module %s failed: %w", m.Name(), err) + } + } + + return result, nil +} + +// SaveToFile 构建配置并保存到文件 +func (b *Builder) SaveToFile(path string) error { + opts, err := b.Build() + if err != nil { + return err + } + + // 使用 sing-box 的 JSON 序列化 + data, err := singboxjson.Marshal(opts) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Re-marshal for pretty print + var pretty interface{} + if err := json.Unmarshal(data, &pretty); err != nil { + return fmt.Errorf("failed to unmarshal for pretty print: %w", err) + } + data, err = json.MarshalIndent(pretty, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal indent: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + logger.Info("Config saved", "path", path) + return nil +} diff --git a/internal/engine/loader.go b/internal/engine/config/loader.go similarity index 80% rename from internal/engine/loader.go rename to internal/engine/config/loader.go index 9f536e4..f74ab07 100644 --- a/internal/engine/loader.go +++ b/internal/engine/config/loader.go @@ -1,4 +1,4 @@ -package engine +package config import ( "context" @@ -28,8 +28,8 @@ func LoadOptionsWithContext(ctx context.Context, configPath string) (*option.Opt return &opts, nil } -// applyMapToOutbound 将 map 配置应用到 Outbound 结构体 -func applyMapToOutbound(out *option.Outbound, m map[string]any) error { +// ApplyMapToOutbound 将 map 配置应用到 Outbound 结构体 +func ApplyMapToOutbound(out *option.Outbound, m map[string]any) error { data, err := singboxjson.Marshal(m) if err != nil { return err @@ -39,8 +39,8 @@ func applyMapToOutbound(out *option.Outbound, m map[string]any) error { return singboxjson.UnmarshalContext(ctx, data, out) } -// applyMapToInbound 将 map 配置应用到 Inbound 结构体 -func applyMapToInbound(in *option.Inbound, m map[string]any) error { +// ApplyMapToInbound 将 map 配置应用到 Inbound 结构体 +func ApplyMapToInbound(in *option.Inbound, m map[string]any) error { data, err := singboxjson.Marshal(m) if err != nil { return err diff --git a/internal/engine/types.go b/internal/engine/config/types.go similarity index 97% rename from internal/engine/types.go rename to internal/engine/config/types.go index c9131bd..b75bf13 100644 --- a/internal/engine/types.go +++ b/internal/engine/config/types.go @@ -1,4 +1,4 @@ -package engine +package config import ( "github.com/kyson-dev/sing-helm/internal/model" diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..757c571 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,79 @@ +package engine + +import ( + "fmt" + + "github.com/kyson-dev/sing-helm/internal/engine/config" + "github.com/kyson-dev/sing-helm/internal/engine/module" + "github.com/kyson-dev/sing-helm/internal/model" + "github.com/sagernet/sing-box/option" +) + +// BuildConfig loads the profile, applies runtime modules, and saves raw config. +func BuildConfig(rawPath string, runops *model.RunOptions) error { + builder := config.NewBuilder(runops) + for _, m := range DefaultModules(runops) { + builder.With(m) + } + + if err := builder.SaveToFile(rawPath); err != nil { + return fmt.Errorf("failed to save raw config: %w", err) + } + + return nil +} + +// BuildOptions builds a sing-box config without writing to disk. +func BuildOptions(runops *model.RunOptions) (*option.Options, error) { + builder := config.NewBuilder(runops) + for _, m := range DefaultModules(runops) { + builder.With(m) + } + return builder.Build() +} + +// DefaultModules 根据 RunOptions 返回默认模块组合 +func DefaultModules(opts *model.RunOptions) []config.ConfigModule { + if opts == nil { + defaultOpts := model.DefaultRunOptions() + opts = &defaultOpts + } + + modules := []config.ConfigModule{ + &module.UserOutboundModule{}, + &module.SubscriptionModule{}, + &module.OutboundModule{}, + } + + // 根据 ProxyMode 选择入站模块 + switch opts.ProxyMode { + case model.ProxyModeTUN: + modules = append(modules, + &module.TUNModule{}, + &module.TUNDNSModule{}, + ) + case model.ProxyModeSystem: + modules = append(modules, &module.MixedModule{ + SetSystemProxy: true, + ListenAddr: opts.ListenAddr, + Port: opts.MixedPort, + }) + case model.ProxyModeDefault: + modules = append(modules, &module.MixedModule{ + SetSystemProxy: false, + ListenAddr: opts.ListenAddr, + Port: opts.MixedPort, + }) + } + + modules = append(modules, + &module.RouteModule{RouteMode: opts.RouteMode}, + &module.ExperimentalModule{ + ListenAddr: opts.ListenAddr, + APIPort: opts.APIPort, + }, + &module.LogModule{}, + ) + + return modules +} diff --git a/internal/engine/instance.go b/internal/engine/instance.go index aedcab9..28637ef 100644 --- a/internal/engine/instance.go +++ b/internal/engine/instance.go @@ -5,6 +5,7 @@ import ( "fmt" "sync" + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/logger" box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/include" @@ -68,7 +69,7 @@ func isAlreadyClosedError(err error) bool { // StartFromFile 从配置文件启动 sing-box func (s *instance) StartFromFile(ctx context.Context, configPath string) error { // 从文件加载配置 - opts, err := LoadOptionsWithContext(ctx, configPath) + opts, err := config.LoadOptionsWithContext(ctx, configPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/engine/module_experimental.go b/internal/engine/module/experimental.go similarity index 87% rename from internal/engine/module_experimental.go rename to internal/engine/module/experimental.go index e5aeb3b..60c0be4 100644 --- a/internal/engine/module_experimental.go +++ b/internal/engine/module/experimental.go @@ -1,8 +1,9 @@ -package engine +package module import ( "fmt" + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/platform" "github.com/sagernet/sing-box/option" ) @@ -20,7 +21,7 @@ func (m *ExperimentalModule) Name() string { return "experimental" } -func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *ExperimentalModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 确定监听地址 listenAddr := m.ListenAddr if listenAddr == "" { diff --git a/internal/engine/module_log.go b/internal/engine/module/log.go similarity index 71% rename from internal/engine/module_log.go rename to internal/engine/module/log.go index fa5ec2f..20bc1c2 100644 --- a/internal/engine/module_log.go +++ b/internal/engine/module/log.go @@ -1,6 +1,7 @@ -package engine +package module import ( + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/sagernet/sing-box/option" ) @@ -13,7 +14,7 @@ func (m *LogModule) Name() string { return "log" } -func (m *LogModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *LogModule) Apply(opts *option.Options, ctx *config.BuildContext) error { level := m.Level if level == "" { level = "info" diff --git a/internal/engine/module_mixed.go b/internal/engine/module/mixed.go similarity index 85% rename from internal/engine/module_mixed.go rename to internal/engine/module/mixed.go index 6049211..2f230d6 100644 --- a/internal/engine/module_mixed.go +++ b/internal/engine/module/mixed.go @@ -1,6 +1,7 @@ -package engine +package module import ( + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/platform" "github.com/sagernet/sing-box/option" ) @@ -19,7 +20,7 @@ func (m *MixedModule) Name() string { return "mixed" } -func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 确定监听地址 listenAddr := m.ListenAddr if listenAddr == "" { @@ -53,7 +54,7 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { "listen_port": port, "set_system_proxy": m.SetSystemProxy, } - applyMapToInbound(&mixedInbound, mixedMap) + config.ApplyMapToInbound(&mixedInbound, mixedMap) // 添加到配置 opts.Inbounds = append(opts.Inbounds, mixedInbound) diff --git a/internal/engine/naming.go b/internal/engine/module/naming.go similarity index 99% rename from internal/engine/naming.go rename to internal/engine/module/naming.go index 122df89..94edf9c 100644 --- a/internal/engine/naming.go +++ b/internal/engine/module/naming.go @@ -1,4 +1,4 @@ -package engine +package module import ( "fmt" diff --git a/internal/engine/module_outbound.go b/internal/engine/module/outbound.go similarity index 84% rename from internal/engine/module_outbound.go rename to internal/engine/module/outbound.go index 8066db7..89dc5c1 100644 --- a/internal/engine/module_outbound.go +++ b/internal/engine/module/outbound.go @@ -1,6 +1,7 @@ -package engine +package module import ( + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/sagernet/sing-box/option" ) @@ -13,7 +14,7 @@ func (m *OutboundModule) Name() string { return "outbound" } -func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 1. 过滤保留 tag,并统计节点信息 filteredOutbounds := []option.Outbound{} userNodeTags := []string{} @@ -39,7 +40,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { "type": "direct", "tag": "direct", } - applyMapToOutbound(&directOutbound, directOutboundMap) + config.ApplyMapToOutbound(&directOutbound, directOutboundMap) filteredOutbounds = append(filteredOutbounds, directOutbound) // 3. 添加 block 出站 @@ -48,7 +49,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { "type": "block", "tag": "block", } - applyMapToOutbound(&blockOutbound, blockOutboundMap) + config.ApplyMapToOutbound(&blockOutbound, blockOutboundMap) filteredOutbounds = append(filteredOutbounds, blockOutbound) // 4 & 5. 添加 proxy selector 和 auto urltest @@ -66,7 +67,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { "outbounds": proxyNodes, "default": "auto", } - applyMapToOutbound(&proxyOutbound, proxyOutboundMap) + config.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) // 5. 添加 auto urltest @@ -76,7 +77,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { "tag": "auto", "outbounds": actualNodes, } - applyMapToOutbound(&autoOutbound, autoOutboundMap) + config.ApplyMapToOutbound(&autoOutbound, autoOutboundMap) filteredOutbounds = append(filteredOutbounds, autoOutbound) } else { // 无节点时的逻辑: @@ -90,7 +91,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { "outbounds": []string{"direct"}, "default": "direct", } - applyMapToOutbound(&proxyOutbound, proxyOutboundMap) + config.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) } diff --git a/internal/engine/ports.go b/internal/engine/module/ports.go similarity index 96% rename from internal/engine/ports.go rename to internal/engine/module/ports.go index e4b2e25..5589ae6 100644 --- a/internal/engine/ports.go +++ b/internal/engine/module/ports.go @@ -1,4 +1,4 @@ -package engine +package module import ( "os" diff --git a/internal/engine/processor.go b/internal/engine/module/processor.go similarity index 99% rename from internal/engine/processor.go rename to internal/engine/module/processor.go index 9cc661a..be487b0 100644 --- a/internal/engine/processor.go +++ b/internal/engine/module/processor.go @@ -1,4 +1,4 @@ -package engine +package module import ( "context" diff --git a/internal/engine/module_route.go b/internal/engine/module/route.go similarity index 96% rename from internal/engine/module_route.go rename to internal/engine/module/route.go index 4186410..1542190 100644 --- a/internal/engine/module_route.go +++ b/internal/engine/module/route.go @@ -1,6 +1,7 @@ -package engine +package module import ( + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/model" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" @@ -16,7 +17,7 @@ func (m *RouteModule) Name() string { return "route" } -func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *RouteModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 如果用户没有配置路由,使用默认路由 if opts.Route == nil { defaultRoute, err := m.generateDefaultRoute() diff --git a/internal/engine/module_subscription.go b/internal/engine/module/subscription.go similarity index 93% rename from internal/engine/module_subscription.go rename to internal/engine/module/subscription.go index 214609b..a1dd367 100644 --- a/internal/engine/module_subscription.go +++ b/internal/engine/module/subscription.go @@ -1,6 +1,7 @@ -package engine +package module import ( + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/logger" "github.com/kyson-dev/sing-helm/internal/platform" "github.com/kyson-dev/sing-helm/internal/subscription" @@ -14,7 +15,7 @@ func (m *SubscriptionModule) Name() string { return "subscription" } -func (m *SubscriptionModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *SubscriptionModule) Apply(opts *option.Options, ctx *config.BuildContext) error { paths := platform.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { diff --git a/internal/engine/module_tun.go b/internal/engine/module/tun.go similarity index 89% rename from internal/engine/module_tun.go rename to internal/engine/module/tun.go index 3ce5920..8ffc04b 100644 --- a/internal/engine/module_tun.go +++ b/internal/engine/module/tun.go @@ -1,8 +1,9 @@ -package engine +package module import ( "context" + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" @@ -18,7 +19,7 @@ func (m *TUNModule) Name() string { return "tun" } -func (m *TUNModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *TUNModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 默认值 mtu := m.MTU if mtu == 0 { @@ -44,7 +45,7 @@ func (m *TUNModule) Apply(opts *option.Options, ctx *BuildContext) error { "sniff": true, "sniff_override_destination": true, } - applyMapToInbound(&tunInbound, tunMap) + config.ApplyMapToInbound(&tunInbound, tunMap) // 添加到配置 opts.Inbounds = append(opts.Inbounds, tunInbound) @@ -60,7 +61,7 @@ func (m *TUNDNSModule) Name() string { return "tun_dns" } -func (m *TUNDNSModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *TUNDNSModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 使用 map 方式创建 DNS 配置 // local_dns 不需要 detour,默认就是直连 dnsMap := map[string]any{ diff --git a/internal/engine/module_user.go b/internal/engine/module/user.go similarity index 92% rename from internal/engine/module_user.go rename to internal/engine/module/user.go index 56b9d90..5659cdd 100644 --- a/internal/engine/module_user.go +++ b/internal/engine/module/user.go @@ -1,10 +1,11 @@ -package engine +package module import ( "bytes" "encoding/json" "os" + "github.com/kyson-dev/sing-helm/internal/engine/config" "github.com/kyson-dev/sing-helm/internal/platform" "github.com/sagernet/sing-box/option" ) @@ -16,7 +17,7 @@ func (m *UserOutboundModule) Name() string { return "user_outbound" } -func (m *UserOutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { +func (m *UserOutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 如果没有提供 ProfilePath,说明用户配置已经在 opts 中了(向后兼容) paths := platform.Get() From 02afdbe49da1c30de9f712135fdbdffcc40322db Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 19:08:48 +0800 Subject: [PATCH 05/23] refactor: restructure into DDD-inspired core, sys, proxy, and app layers Resolve chaotic top-level "internal/" coupling by introducing strict vertical layering: - `internal/core`: Isolated business definitions (model, version) - `internal/sys`: System abstractions (sys/env, logger, ipc) - `internal/proxy`: Proxy domain boundaries (engine, export, subscription, clashapi) - `internal/app`: Application entrypoints & orchestration (cli, daemon, tui, container) --- cmd/sing-helm/main.go | 2 +- internal/{ => app}/cli/autostart.go | 12 +++++----- internal/{ => app}/cli/config.go | 12 +++++----- internal/{ => app}/cli/config_ops.go | 6 ++--- internal/{ => app}/cli/dispatcher.go | 8 +++---- internal/{ => app}/cli/log.go | 6 ++--- internal/{ => app}/cli/mode.go | 0 internal/{ => app}/cli/monitor.go | 6 ++--- internal/{ => app}/cli/node.go | 4 ++-- internal/{ => app}/cli/root.go | 8 +++---- internal/{ => app}/cli/route.go | 0 internal/{ => app}/cli/run.go | 10 ++++----- internal/{ => app}/cli/serve.go | 8 +++---- internal/{ => app}/cli/start.go | 6 ++--- internal/{ => app}/cli/status.go | 2 +- internal/{ => app}/cli/stop.go | 0 internal/{ => app}/cli/version.go | 4 ++-- internal/app/{app.go => container.go} | 6 ++--- internal/{ => app}/daemon/daemon.go | 22 +++++++++---------- internal/{ => app}/daemon/daemon_test.go | 16 +++++++------- internal/{ => app}/daemon/handler_mode.go | 4 ++-- internal/{ => app}/daemon/handler_node.go | 4 ++-- internal/{ => app}/daemon/handler_run.go | 22 +++++++++---------- internal/{ => app}/daemon/handler_status.go | 6 ++--- internal/{ => app}/tui/monitor/commands.go | 8 +++---- internal/{ => app}/tui/monitor/handlers.go | 4 ++-- internal/{ => app}/tui/monitor/messages.go | 2 +- internal/{ => app}/tui/monitor/model.go | 2 +- .../{ => app}/tui/monitor/monitor_test.go | 0 internal/{ => app}/tui/monitor/state.go | 0 internal/{ => app}/tui/monitor/update.go | 0 internal/{ => app}/tui/monitor/view.go | 0 internal/{ => core}/model/options.go | 0 internal/{ => core}/model/platform_bridge.go | 4 ++-- internal/{ => core}/model/state.go | 0 internal/{ => core}/version/Into_test.go | 2 +- internal/{ => core}/version/info.go | 0 internal/{ => proxy}/clashapi/client.go | 0 internal/{ => proxy}/clashapi/client_test.go | 0 internal/{ => proxy}/engine/config/builder.go | 4 ++-- internal/{ => proxy}/engine/config/loader.go | 0 internal/{ => proxy}/engine/config/types.go | 2 +- internal/{ => proxy}/engine/engine.go | 6 ++--- internal/{ => proxy}/engine/errors.go | 0 internal/{ => proxy}/engine/instance.go | 4 ++-- .../{ => proxy}/engine/module/experimental.go | 8 +++---- internal/{ => proxy}/engine/module/log.go | 2 +- internal/{ => proxy}/engine/module/mixed.go | 6 ++--- internal/{ => proxy}/engine/module/naming.go | 0 .../{ => proxy}/engine/module/outbound.go | 4 ++-- internal/{ => proxy}/engine/module/ports.go | 0 .../{ => proxy}/engine/module/processor.go | 2 +- internal/{ => proxy}/engine/module/route.go | 4 ++-- .../{ => proxy}/engine/module/subscription.go | 10 ++++----- internal/{ => proxy}/engine/module/tun.go | 2 +- internal/{ => proxy}/engine/module/user.go | 6 ++--- internal/{ => proxy}/export/compat.go | 0 internal/{ => proxy}/export/export.go | 0 internal/{ => proxy}/export/version.go | 0 internal/{ => proxy}/subscription/merge.go | 0 internal/{ => proxy}/subscription/parse.go | 2 +- internal/{ => proxy}/subscription/refresh.go | 0 internal/{ => proxy}/subscription/storage.go | 0 internal/{ => proxy}/subscription/types.go | 0 internal/{platform => sys/env}/lock.go | 2 +- internal/{platform => sys/env}/paths.go | 4 ++-- internal/{platform => sys/env}/port.go | 2 +- internal/{platform => sys/env}/runtime.go | 2 +- internal/{platform => sys/env}/setup.go | 2 +- internal/{ => sys}/ipc/client.go | 0 internal/{ => sys}/ipc/ipc_test.go | 0 internal/{ => sys}/ipc/server.go | 0 internal/{ => sys}/ipc/types.go | 0 internal/{ => sys}/logger/bridge.go | 0 internal/{ => sys}/logger/export_test.go | 0 internal/{ => sys}/logger/logger.go | 0 internal/{ => sys}/logger/logger_test.go | 2 +- internal/{ => sys}/logger/resolve.go | 0 78 files changed, 136 insertions(+), 136 deletions(-) rename internal/{ => app}/cli/autostart.go (96%) rename internal/{ => app}/cli/config.go (95%) rename internal/{ => app}/cli/config_ops.go (97%) rename internal/{ => app}/cli/dispatcher.go (91%) rename internal/{ => app}/cli/log.go (95%) rename internal/{ => app}/cli/mode.go (100%) rename internal/{ => app}/cli/monitor.go (89%) rename internal/{ => app}/cli/node.go (96%) rename internal/{ => app}/cli/root.go (92%) rename internal/{ => app}/cli/route.go (100%) rename internal/{ => app}/cli/run.go (91%) rename internal/{ => app}/cli/serve.go (95%) rename internal/{ => app}/cli/start.go (96%) rename internal/{ => app}/cli/status.go (97%) rename internal/{ => app}/cli/stop.go (100%) rename internal/{ => app}/cli/version.go (77%) rename internal/app/{app.go => container.go} (74%) rename internal/{ => app}/daemon/daemon.go (84%) rename internal/{ => app}/daemon/daemon_test.go (93%) rename internal/{ => app}/daemon/handler_mode.go (95%) rename internal/{ => app}/daemon/handler_node.go (94%) rename internal/{ => app}/daemon/handler_run.go (85%) rename internal/{ => app}/daemon/handler_status.go (93%) rename internal/{ => app}/tui/monitor/commands.go (96%) rename internal/{ => app}/tui/monitor/handlers.go (98%) rename internal/{ => app}/tui/monitor/messages.go (97%) rename internal/{ => app}/tui/monitor/model.go (98%) rename internal/{ => app}/tui/monitor/monitor_test.go (100%) rename internal/{ => app}/tui/monitor/state.go (100%) rename internal/{ => app}/tui/monitor/update.go (100%) rename internal/{ => app}/tui/monitor/view.go (100%) rename internal/{ => core}/model/options.go (100%) rename internal/{ => core}/model/platform_bridge.go (74%) rename internal/{ => core}/model/state.go (100%) rename internal/{ => core}/version/Into_test.go (82%) rename internal/{ => core}/version/info.go (100%) rename internal/{ => proxy}/clashapi/client.go (100%) rename internal/{ => proxy}/clashapi/client_test.go (100%) rename internal/{ => proxy}/engine/config/builder.go (95%) rename internal/{ => proxy}/engine/config/loader.go (100%) rename internal/{ => proxy}/engine/config/types.go (92%) rename internal/{ => proxy}/engine/engine.go (91%) rename internal/{ => proxy}/engine/errors.go (100%) rename internal/{ => proxy}/engine/instance.go (95%) rename internal/{ => proxy}/engine/module/experimental.go (85%) rename internal/{ => proxy}/engine/module/log.go (88%) rename internal/{ => proxy}/engine/module/mixed.go (89%) rename internal/{ => proxy}/engine/module/naming.go (100%) rename internal/{ => proxy}/engine/module/outbound.go (96%) rename internal/{ => proxy}/engine/module/ports.go (100%) rename internal/{ => proxy}/engine/module/processor.go (98%) rename internal/{ => proxy}/engine/module/route.go (97%) rename internal/{ => proxy}/engine/module/subscription.go (89%) rename internal/{ => proxy}/engine/module/tun.go (97%) rename internal/{ => proxy}/engine/module/user.go (93%) rename internal/{ => proxy}/export/compat.go (100%) rename internal/{ => proxy}/export/export.go (100%) rename internal/{ => proxy}/export/version.go (100%) rename internal/{ => proxy}/subscription/merge.go (100%) rename internal/{ => proxy}/subscription/parse.go (99%) rename internal/{ => proxy}/subscription/refresh.go (100%) rename internal/{ => proxy}/subscription/storage.go (100%) rename internal/{ => proxy}/subscription/types.go (100%) rename internal/{platform => sys/env}/lock.go (99%) rename internal/{platform => sys/env}/paths.go (97%) rename internal/{platform => sys/env}/port.go (96%) rename internal/{platform => sys/env}/runtime.go (99%) rename internal/{platform => sys/env}/setup.go (98%) rename internal/{ => sys}/ipc/client.go (100%) rename internal/{ => sys}/ipc/ipc_test.go (100%) rename internal/{ => sys}/ipc/server.go (100%) rename internal/{ => sys}/ipc/types.go (100%) rename internal/{ => sys}/logger/bridge.go (100%) rename internal/{ => sys}/logger/export_test.go (100%) rename internal/{ => sys}/logger/logger.go (100%) rename internal/{ => sys}/logger/logger_test.go (95%) rename internal/{ => sys}/logger/resolve.go (100%) diff --git a/cmd/sing-helm/main.go b/cmd/sing-helm/main.go index abe0c85..73615db 100644 --- a/cmd/sing-helm/main.go +++ b/cmd/sing-helm/main.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/kyson-dev/sing-helm/internal/cli" + "github.com/kyson-dev/sing-helm/internal/app/cli" _ "github.com/sagernet/sing-box/include" ) diff --git a/internal/cli/autostart.go b/internal/app/cli/autostart.go similarity index 96% rename from internal/cli/autostart.go rename to internal/app/cli/autostart.go index 04bbf71..27c3f54 100644 --- a/internal/cli/autostart.go +++ b/internal/app/cli/autostart.go @@ -7,7 +7,7 @@ import ( "runtime" "strings" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/spf13/cobra" ) @@ -157,8 +157,8 @@ func getSystemdUnitContent() (string, error) { } // Use the environment settings for consistent path handling - appHome := platform.Get().HomeDir - appLog := platform.Get().LogFile + appHome := env.Get().HomeDir + appLog := env.Get().LogFile return `[Unit] Description=SingHelm daemon @@ -206,9 +206,9 @@ func getLaunchdPlistContent() (string, error) { // Check if we are running via a symlink, resolve it if possible, or just use the path as is if valid. // For autostart, using the absolute path to the binary is safest. - // Use the environment settings directly, as platform.Setup() now handles sudo users correctly - appHome := platform.Get().HomeDir - appLog := platform.Get().LogFile + // Use the environment settings directly, as env.Setup() now handles sudo users correctly + appHome := env.Get().HomeDir + appLog := env.Get().LogFile return ` diff --git a/internal/cli/config.go b/internal/app/cli/config.go similarity index 95% rename from internal/cli/config.go rename to internal/app/cli/config.go index 942ba13..774bbb9 100644 --- a/internal/cli/config.go +++ b/internal/app/cli/config.go @@ -5,8 +5,8 @@ import ( "os" "strings" - "github.com/kyson-dev/sing-helm/internal/platform" - "github.com/kyson-dev/sing-helm/internal/subscription" + "github.com/kyson-dev/sing-helm/internal/proxy/subscription" + "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/spf13/cobra" ) @@ -74,7 +74,7 @@ func newConfigAddCommand() *cobra.Command { return fmt.Errorf("url cannot be empty") } - paths := platform.Get() + paths := env.Get() if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err } @@ -113,7 +113,7 @@ func newConfigEditCommand() *cobra.Command { Short: "Edit base config or a subscription file", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := platform.Get() + paths := env.Get() target := paths.ConfigFile if len(args) == 1 { if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { @@ -132,7 +132,7 @@ func newConfigRefreshCommand() *cobra.Command { Short: "Refresh subscription cache", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := platform.Get() + paths := env.Get() if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err } @@ -156,7 +156,7 @@ func newConfigDeleteCommand() *cobra.Command { Short: "Delete a subscription config and cache", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - paths := platform.Get() + paths := env.Get() // 确保目录存在(虽然我们要删除东西,但如果目录都不存在也就没什么好删的,不过为了路径构建不出错) if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err diff --git a/internal/cli/config_ops.go b/internal/app/cli/config_ops.go similarity index 97% rename from internal/cli/config_ops.go rename to internal/app/cli/config_ops.go index b637e84..084d198 100644 --- a/internal/cli/config_ops.go +++ b/internal/app/cli/config_ops.go @@ -7,13 +7,13 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/platform" - "github.com/kyson-dev/sing-helm/internal/subscription" + "github.com/kyson-dev/sing-helm/internal/proxy/subscription" + "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/spf13/cobra" ) func runConfigList(cmd *cobra.Command, _ []string) error { - paths := platform.Get() + paths := env.Get() out := cmd.OutOrStdout() fmt.Fprintf(out, "Base config: %s\n", paths.ConfigFile) diff --git a/internal/cli/dispatcher.go b/internal/app/cli/dispatcher.go similarity index 91% rename from internal/cli/dispatcher.go rename to internal/app/cli/dispatcher.go index 594cd72..5db92d3 100644 --- a/internal/cli/dispatcher.go +++ b/internal/app/cli/dispatcher.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) var errDaemonUnavailable = errors.New("daemon unavailable") @@ -19,9 +19,9 @@ var ErrDaemonUnavailable = errDaemonUnavailable var commandSenderFactory = defaultCommandSenderFactory func defaultCommandSenderFactory() ipc.CommandSender { - socket := platform.Get().SocketFile + socket := env.Get().SocketFile if !pathExists(socket) { - legacy := filepath.Join(platform.Get().HomeDir, "ipc.sock") + legacy := filepath.Join(env.Get().HomeDir, "ipc.sock") if legacy != socket && pathExists(legacy) { return ipc.NewUnixSender(legacy) } diff --git a/internal/cli/log.go b/internal/app/cli/log.go similarity index 95% rename from internal/cli/log.go rename to internal/app/cli/log.go index a32bfa1..87b92e0 100644 --- a/internal/cli/log.go +++ b/internal/app/cli/log.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/nxadm/tail" "github.com/spf13/cobra" ) @@ -65,7 +65,7 @@ func showAppLog(cmd *cobra.Command) { func showSystemLogs(cmd *cobra.Command) { // Resolve log directory dynamically - runtimeDir := platform.ResolveRuntimeDir() + runtimeDir := env.ResolveRuntimeDir() logDir := logger.ResolveLogDir(runtimeDir) stdoutLog := filepath.Join(logDir, "stdout.log") diff --git a/internal/cli/mode.go b/internal/app/cli/mode.go similarity index 100% rename from internal/cli/mode.go rename to internal/app/cli/mode.go diff --git a/internal/cli/monitor.go b/internal/app/cli/monitor.go similarity index 89% rename from internal/cli/monitor.go rename to internal/app/cli/monitor.go index a4e49e0..c1488c2 100644 --- a/internal/cli/monitor.go +++ b/internal/app/cli/monitor.go @@ -4,9 +4,9 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/tui/monitor" + "github.com/kyson-dev/sing-helm/internal/app/tui/monitor" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) diff --git a/internal/cli/node.go b/internal/app/cli/node.go similarity index 96% rename from internal/cli/node.go rename to internal/app/cli/node.go index dd43849..ac47f4c 100644 --- a/internal/cli/node.go +++ b/internal/app/cli/node.go @@ -6,8 +6,8 @@ import ( "sort" "strings" - "github.com/kyson-dev/sing-helm/internal/clashapi" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) diff --git a/internal/cli/root.go b/internal/app/cli/root.go similarity index 92% rename from internal/cli/root.go rename to internal/app/cli/root.go index 4215fb1..a3576a1 100644 --- a/internal/cli/root.go +++ b/internal/app/cli/root.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/kyson-dev/sing-helm/internal/app" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) @@ -33,7 +33,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := platform.Setup(home); err != nil { + if err := env.Setup(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } @@ -44,7 +44,7 @@ func NewRootCommand() *cobra.Command { } // Build the Application and attach to context - paths := platform.Get() + paths := env.Get() application := app.New(paths, logger.GetInstance()) ctx := context.WithValue(cmd.Context(), appKey{}, application) cmd.SetContext(ctx) diff --git a/internal/cli/route.go b/internal/app/cli/route.go similarity index 100% rename from internal/cli/route.go rename to internal/app/cli/route.go diff --git a/internal/cli/run.go b/internal/app/cli/run.go similarity index 91% rename from internal/cli/run.go rename to internal/app/cli/run.go index 7ca0175..e44d36a 100644 --- a/internal/cli/run.go +++ b/internal/app/cli/run.go @@ -7,10 +7,10 @@ import ( "syscall" "time" - coredaemon "github.com/kyson-dev/sing-helm/internal/daemon" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/platform" + coredaemon "github.com/kyson-dev/sing-helm/internal/app/daemon" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) @@ -83,7 +83,7 @@ func runAsDaemon(ctx context.Context, payload map[string]any) error { // 现在(正确) //TODO: 这里可以改进为等待 IPC 服务器真正启动,然后通过统一的dispatchToDaemon发送命令 - sender := ipc.NewUnixSender(platform.Get().SocketFile) + sender := ipc.NewUnixSender(env.Get().SocketFile) resp, err := sender.Send(context.Background(), ipc.CommandMessage{ Name: "run", Payload: payload, diff --git a/internal/cli/serve.go b/internal/app/cli/serve.go similarity index 95% rename from internal/cli/serve.go rename to internal/app/cli/serve.go index 1e11f05..50620cf 100644 --- a/internal/cli/serve.go +++ b/internal/app/cli/serve.go @@ -10,10 +10,10 @@ import ( "strings" "syscall" - "github.com/kyson-dev/sing-helm/internal/engine" - "github.com/kyson-dev/sing-helm/internal/export" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/engine" + "github.com/kyson-dev/sing-helm/internal/proxy/export" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) diff --git a/internal/cli/start.go b/internal/app/cli/start.go similarity index 96% rename from internal/cli/start.go rename to internal/app/cli/start.go index efd2f7f..d90ac9f 100644 --- a/internal/cli/start.go +++ b/internal/app/cli/start.go @@ -6,8 +6,8 @@ import ( "os/exec" "time" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func newStartCommand() *cobra.Command { exePath, _ := os.Executable() // 使用 env 获取路径 - paths := platform.Get() + paths := env.Get() logFile := paths.LogFile // 传递 --home 给子进程,确保子进程使用相同的目录 diff --git a/internal/cli/status.go b/internal/app/cli/status.go similarity index 97% rename from internal/cli/status.go rename to internal/app/cli/status.go index 320c373..22c2a46 100644 --- a/internal/cli/status.go +++ b/internal/app/cli/status.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/spf13/cobra" ) diff --git a/internal/cli/stop.go b/internal/app/cli/stop.go similarity index 100% rename from internal/cli/stop.go rename to internal/app/cli/stop.go diff --git a/internal/cli/version.go b/internal/app/cli/version.go similarity index 77% rename from internal/cli/version.go rename to internal/app/cli/version.go index c38bcd8..54b98be 100644 --- a/internal/cli/version.go +++ b/internal/app/cli/version.go @@ -1,8 +1,8 @@ package cli import ( - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/version" + "github.com/kyson-dev/sing-helm/internal/core/version" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) diff --git a/internal/app/app.go b/internal/app/container.go similarity index 74% rename from internal/app/app.go rename to internal/app/container.go index 41d2896..c2d3184 100644 --- a/internal/app/app.go +++ b/internal/app/container.go @@ -3,19 +3,19 @@ package app import ( "log/slog" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" ) // Application is the central dependency holder for the entire program. // All business components obtain their dependencies from this struct, // avoiding global singletons. type Application struct { - Paths platform.Paths + Paths env.Paths Logger *slog.Logger } // New creates an Application instance by resolving paths and setting up logging. -func New(paths platform.Paths, logger *slog.Logger) *Application { +func New(paths env.Paths, logger *slog.Logger) *Application { return &Application{ Paths: paths, Logger: logger, diff --git a/internal/daemon/daemon.go b/internal/app/daemon/daemon.go similarity index 84% rename from internal/daemon/daemon.go rename to internal/app/daemon/daemon.go index bdfc4af..23d7a80 100644 --- a/internal/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -6,11 +6,11 @@ import ( "os" "sync" - "github.com/kyson-dev/sing-helm/internal/engine" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/model" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/engine" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // ServiceRunner abstracts the sing-box engine lifecycle. @@ -26,7 +26,7 @@ type Daemon struct { cancelFunc context.CancelFunc service ServiceRunner serviceFactory func() ServiceRunner - lock *platform.DaemonLock + lock *env.DaemonLock running bool reloading bool state *model.RuntimeState @@ -56,21 +56,21 @@ func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { // Serve starts the IPC server. Blocks until ctx is cancelled. func (d *Daemon) Serve(ctx context.Context) error { - if err := platform.EnsureRuntimeDirs(platform.Get().RuntimeDir, platform.Get().LogFile); err != nil { + if err := env.EnsureRuntimeDirs(env.Get().RuntimeDir, env.Get().LogFile); err != nil { if os.IsPermission(err) { return fmt.Errorf("runtime directory not writable (try sudo): %w", err) } return fmt.Errorf("runtime directory not writable: %w", err) } - lock, err := platform.AcquireLock(platform.Get().RuntimeDir) + lock, err := env.AcquireLock(env.Get().RuntimeDir) if err != nil { return fmt.Errorf("another instance is already running: %w", err) } d.lock = lock d.loadState() - _ = platform.SaveRuntimeMeta(platform.Get().RuntimeDir, platform.RuntimeMeta{ - ConfigHome: platform.Get().HomeDir, + _ = env.SaveRuntimeMeta(env.Get().RuntimeDir, env.RuntimeMeta{ + ConfigHome: env.Get().HomeDir, }) ctx, cancel := context.WithCancel(ctx) @@ -83,7 +83,7 @@ func (d *Daemon) Serve(ctx context.Context) error { logger.Info("Daemon started, listening for IPC commands") - if err := ipc.Serve(ctx, platform.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { + if err := ipc.Serve(ctx, env.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { return err } return nil diff --git a/internal/daemon/daemon_test.go b/internal/app/daemon/daemon_test.go similarity index 93% rename from internal/daemon/daemon_test.go rename to internal/app/daemon/daemon_test.go index de07810..4ce803c 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/app/daemon/daemon_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/kyson-dev/sing-helm/internal/daemon" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/app/daemon" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) type fakeService struct { @@ -165,13 +165,13 @@ func TestDaemonHandleCommands(t *testing.T) { func setupEnv(t *testing.T) { t.Helper() - platform.ResetForTest() + env.ResetForTest() dir := t.TempDir() - platform.SetRuntimeDir(dir) - if err := platform.Init(dir); err != nil { - t.Fatalf("platform.Init failed: %v", err) + env.SetRuntimeDir(dir) + if err := env.Init(dir); err != nil { + t.Fatalf("env.Init failed: %v", err) } - if err := os.WriteFile(platform.Get().ConfigFile, []byte(`{}`), 0644); err != nil { + if err := os.WriteFile(env.Get().ConfigFile, []byte(`{}`), 0644); err != nil { t.Fatalf("write profile.json: %v", err) } } diff --git a/internal/daemon/handler_mode.go b/internal/app/daemon/handler_mode.go similarity index 95% rename from internal/daemon/handler_mode.go rename to internal/app/daemon/handler_mode.go index 9a0f939..a0e3900 100644 --- a/internal/daemon/handler_mode.go +++ b/internal/app/daemon/handler_mode.go @@ -4,8 +4,8 @@ import ( "context" "os" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) func (d *Daemon) handleMode(ctx context.Context, payload map[string]any) ipc.CommandResult { diff --git a/internal/daemon/handler_node.go b/internal/app/daemon/handler_node.go similarity index 94% rename from internal/daemon/handler_node.go rename to internal/app/daemon/handler_node.go index 5ff65d6..cb62df2 100644 --- a/internal/daemon/handler_node.go +++ b/internal/app/daemon/handler_node.go @@ -4,8 +4,8 @@ import ( "errors" "fmt" - "github.com/kyson-dev/sing-helm/internal/clashapi" - "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) func (d *Daemon) handleNodeList(payload map[string]any) ipc.CommandResult { diff --git a/internal/daemon/handler_run.go b/internal/app/daemon/handler_run.go similarity index 85% rename from internal/daemon/handler_run.go rename to internal/app/daemon/handler_run.go index 03c4354..fd68808 100644 --- a/internal/daemon/handler_run.go +++ b/internal/app/daemon/handler_run.go @@ -6,11 +6,11 @@ import ( "fmt" "os" - "github.com/kyson-dev/sing-helm/internal/engine" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/model" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/engine" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // handleRun 处理 IPC run 命令,启动 sing-box 服务 @@ -42,13 +42,13 @@ func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.Comm // 1. 构建配置 logger.Info("Building configuration", "mode", runops.ProxyMode, "route", runops.RouteMode) - if err := engine.BuildConfig(platform.Get().RawConfigFile, &runops); err != nil { + if err := engine.BuildConfig(env.Get().RawConfigFile, &runops); err != nil { return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to build config: %w", err).Error()} } // 2. 启动 sing-box 服务 svc := d.newService() - rawPath := platform.Get().RawConfigFile + rawPath := env.Get().RawConfigFile logger.Info("Starting sing-box", "config", rawPath) if err := svc.StartFromFile(ctx, rawPath); err != nil { return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to start sing-box: %w", err).Error()} @@ -125,20 +125,20 @@ func (d *Daemon) applyRunOptions(ctx context.Context, state *model.RuntimeState) d.mu.Unlock() }() - backupPath, _ := backupConfig(platform.Get().RawConfigFile) - if err := engine.BuildConfig(platform.Get().RawConfigFile, &state.RunOptions); err != nil { + backupPath, _ := backupConfig(env.Get().RawConfigFile) + if err := engine.BuildConfig(env.Get().RawConfigFile, &state.RunOptions); err != nil { return err } if d.service == nil { err := errors.New("service not available") return err } - if err := d.service.ReloadFromFile(ctx, platform.Get().RawConfigFile); err != nil { + if err := d.service.ReloadFromFile(ctx, env.Get().RawConfigFile); err != nil { var reloadErr *engine.ReloadError if errors.As(err, &reloadErr) && reloadErr.Stage == engine.ReloadStageStart { if backupPath != "" { if retryErr := d.service.StartFromFile(ctx, backupPath); retryErr == nil { - if restoreErr := restoreConfig(backupPath, platform.Get().RawConfigFile); restoreErr != nil { + if restoreErr := restoreConfig(backupPath, env.Get().RawConfigFile); restoreErr != nil { return restoreErr } d.setRunning(true) diff --git a/internal/daemon/handler_status.go b/internal/app/daemon/handler_status.go similarity index 93% rename from internal/daemon/handler_status.go rename to internal/app/daemon/handler_status.go index d7dc59c..9b3e3e6 100644 --- a/internal/daemon/handler_status.go +++ b/internal/app/daemon/handler_status.go @@ -3,8 +3,8 @@ package daemon import ( "context" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) func (d *Daemon) handleStatus() ipc.CommandResult { @@ -41,7 +41,7 @@ func (d *Daemon) handleHealth() ipc.CommandResult { } func (d *Daemon) handleLog() ipc.CommandResult { - logPath := platform.Get().LogFile + logPath := env.Get().LogFile return ipc.CommandResult{Status: "ok", Data: map[string]any{"path": logPath}} } diff --git a/internal/tui/monitor/commands.go b/internal/app/tui/monitor/commands.go similarity index 96% rename from internal/tui/monitor/commands.go rename to internal/app/tui/monitor/commands.go index a8b7c70..a0c8f68 100644 --- a/internal/tui/monitor/commands.go +++ b/internal/app/tui/monitor/commands.go @@ -10,9 +10,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/gorilla/websocket" - "github.com/kyson-dev/sing-helm/internal/clashapi" - "github.com/kyson-dev/sing-helm/internal/ipc" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) // ============================================================================ @@ -249,7 +249,7 @@ func fetchDaemonStatus() (*daemonStatus, error) { } func sendDaemonCommand(name string, payload map[string]any) (ipc.CommandResult, error) { - sender := ipc.NewUnixSender(platform.Get().SocketFile) + sender := ipc.NewUnixSender(env.Get().SocketFile) resp, err := sender.Send(context.Background(), ipc.CommandMessage{Name: name, Payload: payload}) if err != nil { return ipc.CommandResult{}, fmt.Errorf("ipc send failed: %w", err) diff --git a/internal/tui/monitor/handlers.go b/internal/app/tui/monitor/handlers.go similarity index 98% rename from internal/tui/monitor/handlers.go rename to internal/app/tui/monitor/handlers.go index 4f7c6ef..7c18a21 100644 --- a/internal/tui/monitor/handlers.go +++ b/internal/app/tui/monitor/handlers.go @@ -4,8 +4,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/kyson-dev/sing-helm/internal/clashapi" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // ============================================================================ diff --git a/internal/tui/monitor/messages.go b/internal/app/tui/monitor/messages.go similarity index 97% rename from internal/tui/monitor/messages.go rename to internal/app/tui/monitor/messages.go index eff7ffb..6b09008 100644 --- a/internal/tui/monitor/messages.go +++ b/internal/app/tui/monitor/messages.go @@ -2,7 +2,7 @@ package monitor import ( "github.com/gorilla/websocket" - "github.com/kyson-dev/sing-helm/internal/clashapi" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" ) // ============================================================================ diff --git a/internal/tui/monitor/model.go b/internal/app/tui/monitor/model.go similarity index 98% rename from internal/tui/monitor/model.go rename to internal/app/tui/monitor/model.go index 4bb6a37..b7f79bb 100644 --- a/internal/tui/monitor/model.go +++ b/internal/app/tui/monitor/model.go @@ -4,7 +4,7 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/kyson-dev/sing-helm/internal/clashapi" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" ) // ============================================================================ diff --git a/internal/tui/monitor/monitor_test.go b/internal/app/tui/monitor/monitor_test.go similarity index 100% rename from internal/tui/monitor/monitor_test.go rename to internal/app/tui/monitor/monitor_test.go diff --git a/internal/tui/monitor/state.go b/internal/app/tui/monitor/state.go similarity index 100% rename from internal/tui/monitor/state.go rename to internal/app/tui/monitor/state.go diff --git a/internal/tui/monitor/update.go b/internal/app/tui/monitor/update.go similarity index 100% rename from internal/tui/monitor/update.go rename to internal/app/tui/monitor/update.go diff --git a/internal/tui/monitor/view.go b/internal/app/tui/monitor/view.go similarity index 100% rename from internal/tui/monitor/view.go rename to internal/app/tui/monitor/view.go diff --git a/internal/model/options.go b/internal/core/model/options.go similarity index 100% rename from internal/model/options.go rename to internal/core/model/options.go diff --git a/internal/model/platform_bridge.go b/internal/core/model/platform_bridge.go similarity index 74% rename from internal/model/platform_bridge.go rename to internal/core/model/platform_bridge.go index 48b9dbc..ec56ea5 100644 --- a/internal/model/platform_bridge.go +++ b/internal/core/model/platform_bridge.go @@ -1,10 +1,10 @@ package model -import "github.com/kyson-dev/sing-helm/internal/platform" +import "github.com/kyson-dev/sing-helm/internal/sys/env" // platformGetStateFile returns the state file path from the global platform config. // This is isolated here so state.go doesn't directly import platform, // making it easier to eventually remove this dependency. func platformGetStateFile() string { - return platform.Get().StateFile + return env.Get().StateFile } diff --git a/internal/model/state.go b/internal/core/model/state.go similarity index 100% rename from internal/model/state.go rename to internal/core/model/state.go diff --git a/internal/version/Into_test.go b/internal/core/version/Into_test.go similarity index 82% rename from internal/version/Into_test.go rename to internal/core/version/Into_test.go index b8fd2d0..47d4b01 100644 --- a/internal/version/Into_test.go +++ b/internal/core/version/Into_test.go @@ -3,7 +3,7 @@ package version_test import ( "testing" - "github.com/kyson-dev/sing-helm/internal/version" + "github.com/kyson-dev/sing-helm/internal/core/version" "github.com/stretchr/testify/assert" ) diff --git a/internal/version/info.go b/internal/core/version/info.go similarity index 100% rename from internal/version/info.go rename to internal/core/version/info.go diff --git a/internal/clashapi/client.go b/internal/proxy/clashapi/client.go similarity index 100% rename from internal/clashapi/client.go rename to internal/proxy/clashapi/client.go diff --git a/internal/clashapi/client_test.go b/internal/proxy/clashapi/client_test.go similarity index 100% rename from internal/clashapi/client_test.go rename to internal/proxy/clashapi/client_test.go diff --git a/internal/engine/config/builder.go b/internal/proxy/engine/config/builder.go similarity index 95% rename from internal/engine/config/builder.go rename to internal/proxy/engine/config/builder.go index 75c46c1..83666c4 100644 --- a/internal/engine/config/builder.go +++ b/internal/proxy/engine/config/builder.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) diff --git a/internal/engine/config/loader.go b/internal/proxy/engine/config/loader.go similarity index 100% rename from internal/engine/config/loader.go rename to internal/proxy/engine/config/loader.go diff --git a/internal/engine/config/types.go b/internal/proxy/engine/config/types.go similarity index 92% rename from internal/engine/config/types.go rename to internal/proxy/engine/config/types.go index b75bf13..f1cfed9 100644 --- a/internal/engine/config/types.go +++ b/internal/proxy/engine/config/types.go @@ -1,7 +1,7 @@ package config import ( - "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/sagernet/sing-box/option" ) diff --git a/internal/engine/engine.go b/internal/proxy/engine/engine.go similarity index 91% rename from internal/engine/engine.go rename to internal/proxy/engine/engine.go index 757c571..674f2d9 100644 --- a/internal/engine/engine.go +++ b/internal/proxy/engine/engine.go @@ -3,9 +3,9 @@ package engine import ( "fmt" - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/engine/module" - "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/module" "github.com/sagernet/sing-box/option" ) diff --git a/internal/engine/errors.go b/internal/proxy/engine/errors.go similarity index 100% rename from internal/engine/errors.go rename to internal/proxy/engine/errors.go diff --git a/internal/engine/instance.go b/internal/proxy/engine/instance.go similarity index 95% rename from internal/engine/instance.go rename to internal/proxy/engine/instance.go index 28637ef..4b8838d 100644 --- a/internal/engine/instance.go +++ b/internal/proxy/engine/instance.go @@ -5,8 +5,8 @@ import ( "fmt" "sync" - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/sys/logger" box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" diff --git a/internal/engine/module/experimental.go b/internal/proxy/engine/module/experimental.go similarity index 85% rename from internal/engine/module/experimental.go rename to internal/proxy/engine/module/experimental.go index 60c0be4..6b3e7f6 100644 --- a/internal/engine/module/experimental.go +++ b/internal/proxy/engine/module/experimental.go @@ -3,8 +3,8 @@ package module import ( "fmt" - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/sagernet/sing-box/option" ) @@ -35,7 +35,7 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *config.BuildContex apiPort = override } else { var err error - apiPort, err = platform.GetFreePort() + apiPort, err = env.GetFreePort() if err != nil { return err } @@ -53,7 +53,7 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *config.BuildContex }, CacheFile: &option.CacheFileOptions{ Enabled: true, - Path: platform.Get().CacheFile, + Path: env.Get().CacheFile, }, } diff --git a/internal/engine/module/log.go b/internal/proxy/engine/module/log.go similarity index 88% rename from internal/engine/module/log.go rename to internal/proxy/engine/module/log.go index 20bc1c2..5b92ae0 100644 --- a/internal/engine/module/log.go +++ b/internal/proxy/engine/module/log.go @@ -1,7 +1,7 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/engine/config" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/option" ) diff --git a/internal/engine/module/mixed.go b/internal/proxy/engine/module/mixed.go similarity index 89% rename from internal/engine/module/mixed.go rename to internal/proxy/engine/module/mixed.go index 2f230d6..f3db78e 100644 --- a/internal/engine/module/mixed.go +++ b/internal/proxy/engine/module/mixed.go @@ -1,8 +1,8 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/sagernet/sing-box/option" ) @@ -34,7 +34,7 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) erro port = override } else { var err error - port, err = platform.GetFreePort() + port, err = env.GetFreePort() if err != nil { return err } diff --git a/internal/engine/module/naming.go b/internal/proxy/engine/module/naming.go similarity index 100% rename from internal/engine/module/naming.go rename to internal/proxy/engine/module/naming.go diff --git a/internal/engine/module/outbound.go b/internal/proxy/engine/module/outbound.go similarity index 96% rename from internal/engine/module/outbound.go rename to internal/proxy/engine/module/outbound.go index 89dc5c1..5e1c87c 100644 --- a/internal/engine/module/outbound.go +++ b/internal/proxy/engine/module/outbound.go @@ -1,8 +1,8 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" ) diff --git a/internal/engine/module/ports.go b/internal/proxy/engine/module/ports.go similarity index 100% rename from internal/engine/module/ports.go rename to internal/proxy/engine/module/ports.go diff --git a/internal/engine/module/processor.go b/internal/proxy/engine/module/processor.go similarity index 98% rename from internal/engine/module/processor.go rename to internal/proxy/engine/module/processor.go index be487b0..c3d6c2d 100644 --- a/internal/engine/module/processor.go +++ b/internal/proxy/engine/module/processor.go @@ -3,7 +3,7 @@ package module import ( "context" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" diff --git a/internal/engine/module/route.go b/internal/proxy/engine/module/route.go similarity index 97% rename from internal/engine/module/route.go rename to internal/proxy/engine/module/route.go index 1542190..5ecf7bd 100644 --- a/internal/engine/module/route.go +++ b/internal/proxy/engine/module/route.go @@ -1,8 +1,8 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/model" + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) diff --git a/internal/engine/module/subscription.go b/internal/proxy/engine/module/subscription.go similarity index 89% rename from internal/engine/module/subscription.go rename to internal/proxy/engine/module/subscription.go index a1dd367..a74e93e 100644 --- a/internal/engine/module/subscription.go +++ b/internal/proxy/engine/module/subscription.go @@ -1,10 +1,10 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/platform" - "github.com/kyson-dev/sing-helm/internal/subscription" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/proxy/subscription" + "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" ) @@ -16,7 +16,7 @@ func (m *SubscriptionModule) Name() string { } func (m *SubscriptionModule) Apply(opts *option.Options, ctx *config.BuildContext) error { - paths := platform.Get() + paths := env.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { logger.Error("Failed to load subscription sources", "error", err) diff --git a/internal/engine/module/tun.go b/internal/proxy/engine/module/tun.go similarity index 97% rename from internal/engine/module/tun.go rename to internal/proxy/engine/module/tun.go index 8ffc04b..f32ed35 100644 --- a/internal/engine/module/tun.go +++ b/internal/proxy/engine/module/tun.go @@ -3,7 +3,7 @@ package module import ( "context" - "github.com/kyson-dev/sing-helm/internal/engine/config" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" diff --git a/internal/engine/module/user.go b/internal/proxy/engine/module/user.go similarity index 93% rename from internal/engine/module/user.go rename to internal/proxy/engine/module/user.go index 5659cdd..2819195 100644 --- a/internal/engine/module/user.go +++ b/internal/proxy/engine/module/user.go @@ -5,8 +5,8 @@ import ( "encoding/json" "os" - "github.com/kyson-dev/sing-helm/internal/engine/config" - "github.com/kyson-dev/sing-helm/internal/platform" + "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" + "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/sagernet/sing-box/option" ) @@ -19,7 +19,7 @@ func (m *UserOutboundModule) Name() string { func (m *UserOutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 如果没有提供 ProfilePath,说明用户配置已经在 opts 中了(向后兼容) - paths := platform.Get() + paths := env.Get() content, err := os.ReadFile(paths.ConfigFile) if err != nil { diff --git a/internal/export/compat.go b/internal/proxy/export/compat.go similarity index 100% rename from internal/export/compat.go rename to internal/proxy/export/compat.go diff --git a/internal/export/export.go b/internal/proxy/export/export.go similarity index 100% rename from internal/export/export.go rename to internal/proxy/export/export.go diff --git a/internal/export/version.go b/internal/proxy/export/version.go similarity index 100% rename from internal/export/version.go rename to internal/proxy/export/version.go diff --git a/internal/subscription/merge.go b/internal/proxy/subscription/merge.go similarity index 100% rename from internal/subscription/merge.go rename to internal/proxy/subscription/merge.go diff --git a/internal/subscription/parse.go b/internal/proxy/subscription/parse.go similarity index 99% rename from internal/subscription/parse.go rename to internal/proxy/subscription/parse.go index 0fc1200..04b119d 100644 --- a/internal/subscription/parse.go +++ b/internal/proxy/subscription/parse.go @@ -7,7 +7,7 @@ import ( "net/url" "strings" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "gopkg.in/yaml.v3" ) diff --git a/internal/subscription/refresh.go b/internal/proxy/subscription/refresh.go similarity index 100% rename from internal/subscription/refresh.go rename to internal/proxy/subscription/refresh.go diff --git a/internal/subscription/storage.go b/internal/proxy/subscription/storage.go similarity index 100% rename from internal/subscription/storage.go rename to internal/proxy/subscription/storage.go diff --git a/internal/subscription/types.go b/internal/proxy/subscription/types.go similarity index 100% rename from internal/subscription/types.go rename to internal/proxy/subscription/types.go diff --git a/internal/platform/lock.go b/internal/sys/env/lock.go similarity index 99% rename from internal/platform/lock.go rename to internal/sys/env/lock.go index 22160e9..d37b932 100644 --- a/internal/platform/lock.go +++ b/internal/sys/env/lock.go @@ -1,4 +1,4 @@ -package platform +package env import ( "errors" diff --git a/internal/platform/paths.go b/internal/sys/env/paths.go similarity index 97% rename from internal/platform/paths.go rename to internal/sys/env/paths.go index ee38793..2e59bd5 100644 --- a/internal/platform/paths.go +++ b/internal/sys/env/paths.go @@ -1,11 +1,11 @@ -package platform +package env import ( "os" "path/filepath" "sync" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // Paths 定义了应用所有的关键路径 diff --git a/internal/platform/port.go b/internal/sys/env/port.go similarity index 96% rename from internal/platform/port.go rename to internal/sys/env/port.go index b8f11a7..37e2537 100644 --- a/internal/platform/port.go +++ b/internal/sys/env/port.go @@ -1,4 +1,4 @@ -package platform +package env import ( "net" diff --git a/internal/platform/runtime.go b/internal/sys/env/runtime.go similarity index 99% rename from internal/platform/runtime.go rename to internal/sys/env/runtime.go index b918670..2b2faff 100644 --- a/internal/platform/runtime.go +++ b/internal/sys/env/runtime.go @@ -1,4 +1,4 @@ -package platform +package env import ( "encoding/json" diff --git a/internal/platform/setup.go b/internal/sys/env/setup.go similarity index 98% rename from internal/platform/setup.go rename to internal/sys/env/setup.go index 10a98fd..bad3e84 100644 --- a/internal/platform/setup.go +++ b/internal/sys/env/setup.go @@ -1,4 +1,4 @@ -package platform +package env import ( "os" diff --git a/internal/ipc/client.go b/internal/sys/ipc/client.go similarity index 100% rename from internal/ipc/client.go rename to internal/sys/ipc/client.go diff --git a/internal/ipc/ipc_test.go b/internal/sys/ipc/ipc_test.go similarity index 100% rename from internal/ipc/ipc_test.go rename to internal/sys/ipc/ipc_test.go diff --git a/internal/ipc/server.go b/internal/sys/ipc/server.go similarity index 100% rename from internal/ipc/server.go rename to internal/sys/ipc/server.go diff --git a/internal/ipc/types.go b/internal/sys/ipc/types.go similarity index 100% rename from internal/ipc/types.go rename to internal/sys/ipc/types.go diff --git a/internal/logger/bridge.go b/internal/sys/logger/bridge.go similarity index 100% rename from internal/logger/bridge.go rename to internal/sys/logger/bridge.go diff --git a/internal/logger/export_test.go b/internal/sys/logger/export_test.go similarity index 100% rename from internal/logger/export_test.go rename to internal/sys/logger/export_test.go diff --git a/internal/logger/logger.go b/internal/sys/logger/logger.go similarity index 100% rename from internal/logger/logger.go rename to internal/sys/logger/logger.go diff --git a/internal/logger/logger_test.go b/internal/sys/logger/logger_test.go similarity index 95% rename from internal/logger/logger_test.go rename to internal/sys/logger/logger_test.go index 0e18a13..e211b79 100644 --- a/internal/logger/logger_test.go +++ b/internal/sys/logger/logger_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/logger/resolve.go b/internal/sys/logger/resolve.go similarity index 100% rename from internal/logger/resolve.go rename to internal/sys/logger/resolve.go From 7d91c261052460e364bdf8b92a704e0b7ee45313 Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 19:58:27 +0800 Subject: [PATCH 06/23] refactor: move logger bridge from sys/logger to proxy/engine --- internal/app/cli/log.go | 3 +-- internal/app/daemon/daemon.go | 6 ----- internal/proxy/engine/instance.go | 2 +- .../bridge.go => proxy/engine/logger.go} | 14 ++++++----- internal/proxy/engine/module/experimental.go | 2 +- internal/proxy/engine/module/mixed.go | 5 ++-- internal/proxy/engine/module/ports.go | 22 ++++++++++++++++++ .../sys/{logger/resolve.go => env/logger.go} | 4 ++-- internal/sys/env/paths.go | 8 +++---- internal/sys/env/port.go | 23 ------------------- internal/sys/env/runtime.go | 6 ++--- 11 files changed, 45 insertions(+), 50 deletions(-) rename internal/{sys/logger/bridge.go => proxy/engine/logger.go} (66%) rename internal/sys/{logger/resolve.go => env/logger.go} (94%) delete mode 100644 internal/sys/env/port.go diff --git a/internal/app/cli/log.go b/internal/app/cli/log.go index 87b92e0..550eb03 100644 --- a/internal/app/cli/log.go +++ b/internal/app/cli/log.go @@ -65,8 +65,7 @@ func showAppLog(cmd *cobra.Command) { func showSystemLogs(cmd *cobra.Command) { // Resolve log directory dynamically - runtimeDir := env.ResolveRuntimeDir() - logDir := logger.ResolveLogDir(runtimeDir) + logDir := env.Get().LogDir stdoutLog := filepath.Join(logDir, "stdout.log") stderrLog := filepath.Join(logDir, "stderr.log") diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go index 23d7a80..78ebbab 100644 --- a/internal/app/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -56,12 +56,6 @@ func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { // Serve starts the IPC server. Blocks until ctx is cancelled. func (d *Daemon) Serve(ctx context.Context) error { - if err := env.EnsureRuntimeDirs(env.Get().RuntimeDir, env.Get().LogFile); err != nil { - if os.IsPermission(err) { - return fmt.Errorf("runtime directory not writable (try sudo): %w", err) - } - return fmt.Errorf("runtime directory not writable: %w", err) - } lock, err := env.AcquireLock(env.Get().RuntimeDir) if err != nil { diff --git a/internal/proxy/engine/instance.go b/internal/proxy/engine/instance.go index 4b8838d..4985a84 100644 --- a/internal/proxy/engine/instance.go +++ b/internal/proxy/engine/instance.go @@ -94,7 +94,7 @@ func (s *instance) Start(ctx context.Context, opts *option.Options) error { newBox, err := box.New(box.Options{ Context: tx, Options: *opts, - PlatformLogWriter: logger.NewPlatformWriter(), // 将 sing-box 日志重定向到我们的 logger + PlatformLogWriter: NewPlatformWriter(), // 将 sing-box 日志重定向到我们的 logger }) if err != nil { return fmt.Errorf("failed to create box instance: %w", err) diff --git a/internal/sys/logger/bridge.go b/internal/proxy/engine/logger.go similarity index 66% rename from internal/sys/logger/bridge.go rename to internal/proxy/engine/logger.go index b4e663e..44a3374 100644 --- a/internal/sys/logger/bridge.go +++ b/internal/proxy/engine/logger.go @@ -1,6 +1,7 @@ -package logger +package engine import ( + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/log" ) @@ -20,15 +21,16 @@ func (p *PlatformWriter) DisableColors() bool { func (p *PlatformWriter) WriteMessage(level log.Level, message string) { switch level { case log.LevelTrace, log.LevelDebug: - get().Debug(message, "source", "sing-box") + logger.Debug(message, "source", "sing-box") case log.LevelInfo: - get().Info(message, "source", "sing-box") + logger.Info(message, "source", "sing-box") case log.LevelWarn: - get().Warn(message, "source", "sing-box") + // logger doesn't have Warn exposed, using Info for now + logger.Info("[WARN] "+message, "source", "sing-box") case log.LevelError, log.LevelFatal, log.LevelPanic: - get().Error(message, "source", "sing-box") + logger.Error(message, "source", "sing-box") default: - get().Info(message, "source", "sing-box") + logger.Info(message, "source", "sing-box") } } diff --git a/internal/proxy/engine/module/experimental.go b/internal/proxy/engine/module/experimental.go index 6b3e7f6..1638ac8 100644 --- a/internal/proxy/engine/module/experimental.go +++ b/internal/proxy/engine/module/experimental.go @@ -35,7 +35,7 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *config.BuildContex apiPort = override } else { var err error - apiPort, err = env.GetFreePort() + apiPort, err = getFreePort() if err != nil { return err } diff --git a/internal/proxy/engine/module/mixed.go b/internal/proxy/engine/module/mixed.go index f3db78e..8f09966 100644 --- a/internal/proxy/engine/module/mixed.go +++ b/internal/proxy/engine/module/mixed.go @@ -2,7 +2,6 @@ package module import ( "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/sagernet/sing-box/option" ) @@ -34,7 +33,7 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) erro port = override } else { var err error - port, err = env.GetFreePort() + port, err = getFreePort() if err != nil { return err } @@ -61,3 +60,5 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) erro return nil } + + diff --git a/internal/proxy/engine/module/ports.go b/internal/proxy/engine/module/ports.go index 5589ae6..56c8e51 100644 --- a/internal/proxy/engine/module/ports.go +++ b/internal/proxy/engine/module/ports.go @@ -3,6 +3,7 @@ package module import ( "os" "strconv" + "net" ) // getPortOverride 返回测试期间指定的端口。 @@ -19,3 +20,24 @@ func getPortOverride(envKey string) (int, bool) { } return port, true } + +// GetFreePort 请求内核分配一个空闲端口 +func getFreePort() (int, error) { + // 监听端口 0,内核会自动分配一个空闲端口 + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + if err != nil { + return 0, err + } + + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return 0, err + } + defer l.Close() + + // 返回分配到的端口 + return l.Addr().(*net.TCPAddr).Port, nil +} + + + diff --git a/internal/sys/logger/resolve.go b/internal/sys/env/logger.go similarity index 94% rename from internal/sys/logger/resolve.go rename to internal/sys/env/logger.go index a6b1151..2f981a4 100644 --- a/internal/sys/logger/resolve.go +++ b/internal/sys/env/logger.go @@ -1,4 +1,4 @@ -package logger +package env import ( "os" @@ -7,7 +7,7 @@ import ( ) // ResolveLogDir returns the preferred log directory, falling back to runtimeDir if needed. -func ResolveLogDir(runtimeDir string) string { +func resolveLogDir(runtimeDir string) string { candidate := "" switch runtime.GOOS { case "linux": diff --git a/internal/sys/env/paths.go b/internal/sys/env/paths.go index 2e59bd5..6f9a1cd 100644 --- a/internal/sys/env/paths.go +++ b/internal/sys/env/paths.go @@ -4,8 +4,6 @@ import ( "os" "path/filepath" "sync" - - "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // Paths 定义了应用所有的关键路径 @@ -16,6 +14,7 @@ type Paths struct { RawConfigFile string // raw.json (生成的完整配置) SubConfigDir string // subscriptions 目录 SubCacheDir string // subscriptions cache 目录 + LogDir string // log 目录 LogFile string // sing-helm.log StateFile string // state.json LookFile string // sing-helm.lock @@ -63,13 +62,13 @@ func Resolve(home string) (Paths, error) { return Paths{}, err } - runtimeDir := ResolveRuntimeDir() + runtimeDir := resolveRuntimeDir() runtimeDir, err = filepath.Abs(runtimeDir) if err != nil { return Paths{}, err } - logDir := logger.ResolveLogDir(runtimeDir) + logDir := resolveLogDir(runtimeDir) return GetPath(absHome, runtimeDir, logDir), nil } @@ -86,6 +85,7 @@ func GetPath(home string, runtimeDir string, logDir string) Paths { RawConfigFile: filepath.Join(runtimeDir, "raw.json"), SubConfigDir: filepath.Join(home, "subscriptions"), SubCacheDir: filepath.Join(home, "subscriptions", "cache"), + LogDir: logDir, LogFile: logFile, StateFile: filepath.Join(runtimeDir, "state.json"), LookFile: GetLockPath(runtimeDir), // 使用 lock.go 中的单一事实来源 diff --git a/internal/sys/env/port.go b/internal/sys/env/port.go deleted file mode 100644 index 37e2537..0000000 --- a/internal/sys/env/port.go +++ /dev/null @@ -1,23 +0,0 @@ -package env - -import ( - "net" -) - -// GetFreePort 请求内核分配一个空闲端口 -func GetFreePort() (int, error) { - // 监听端口 0,内核会自动分配一个空闲端口 - addr, err := net.ResolveTCPAddr("tcp", "localhost:0") - if err != nil { - return 0, err - } - - l, err := net.ListenTCP("tcp", addr) - if err != nil { - return 0, err - } - defer l.Close() - - // 返回分配到的端口 - return l.Addr().(*net.TCPAddr).Port, nil -} diff --git a/internal/sys/env/runtime.go b/internal/sys/env/runtime.go index 2b2faff..52407be 100644 --- a/internal/sys/env/runtime.go +++ b/internal/sys/env/runtime.go @@ -12,7 +12,7 @@ const runtimeDirEnv = "SINGHELM_RUNTIME_DIR" var runtimeDirOverride string // ResolveRuntimeDir returns the system-level runtime directory for sockets/locks/logs/state. -func ResolveRuntimeDir() string { +func resolveRuntimeDir() string { if runtimeDirOverride != "" { return runtimeDirOverride } @@ -41,7 +41,7 @@ func ResolveRuntimeDir() string { } // EnsureRuntimeDirs ensures runtime and log directories exist and are writable. -func EnsureRuntimeDirs(runtimeDir, logFile string) error { +func ensureRuntimeDirs(runtimeDir, logFile string) error { if runtimeDir != "" { if err := os.MkdirAll(runtimeDir, 0755); err != nil { return err @@ -91,7 +91,7 @@ func dirExists(path string) bool { // FindRuntimeConfigHome returns the config home from a running system daemon, if any. func FindRuntimeConfigHome() string { - runtimeDir := ResolveRuntimeDir() + runtimeDir := resolveRuntimeDir() if runtimeDir == "" { return "" } From 09fb0cc3fdc7215a5393debdad570025ac5ef40d Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 20:07:13 +0800 Subject: [PATCH 07/23] refactor: extract daemon lock from env and rename env to paths - Move `internal/sys/env/lock.go` to `internal/sys/lock/lock.go` - Rename `internal/sys/env` to `internal/sys/paths` since it mainly models and resolves the application paths instead of generic environments --- internal/app/cli/autostart.go | 12 ++++++------ internal/app/cli/config.go | 10 +++++----- internal/app/cli/config_ops.go | 4 ++-- internal/app/cli/dispatcher.go | 6 +++--- internal/app/cli/log.go | 4 ++-- internal/app/cli/root.go | 6 +++--- internal/app/cli/run.go | 4 ++-- internal/app/cli/start.go | 4 ++-- internal/app/container.go | 6 +++--- internal/app/daemon/daemon.go | 13 +++++++------ internal/app/daemon/daemon_test.go | 12 ++++++------ internal/app/daemon/handler_run.go | 14 +++++++------- internal/app/daemon/handler_status.go | 4 ++-- internal/app/tui/monitor/commands.go | 4 ++-- internal/core/model/platform_bridge.go | 4 ++-- internal/proxy/engine/module/experimental.go | 4 ++-- internal/proxy/engine/module/mixed.go | 2 -- internal/proxy/engine/module/ports.go | 5 +---- internal/proxy/engine/module/subscription.go | 4 ++-- internal/proxy/engine/module/user.go | 4 ++-- internal/sys/{env => lock}/lock.go | 2 +- internal/sys/{env => paths}/logger.go | 2 +- internal/sys/{env => paths}/paths.go | 5 +++-- internal/sys/{env => paths}/runtime.go | 5 +++-- internal/sys/{env => paths}/setup.go | 2 +- 25 files changed, 70 insertions(+), 72 deletions(-) rename internal/sys/{env => lock}/lock.go (99%) rename internal/sys/{env => paths}/logger.go (98%) rename internal/sys/{env => paths}/paths.go (94%) rename internal/sys/{env => paths}/runtime.go (96%) rename internal/sys/{env => paths}/setup.go (98%) diff --git a/internal/app/cli/autostart.go b/internal/app/cli/autostart.go index 27c3f54..b725015 100644 --- a/internal/app/cli/autostart.go +++ b/internal/app/cli/autostart.go @@ -7,7 +7,7 @@ import ( "runtime" "strings" - "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) @@ -157,8 +157,8 @@ func getSystemdUnitContent() (string, error) { } // Use the environment settings for consistent path handling - appHome := env.Get().HomeDir - appLog := env.Get().LogFile + appHome := paths.Get().HomeDir + appLog := paths.Get().LogFile return `[Unit] Description=SingHelm daemon @@ -206,9 +206,9 @@ func getLaunchdPlistContent() (string, error) { // Check if we are running via a symlink, resolve it if possible, or just use the path as is if valid. // For autostart, using the absolute path to the binary is safest. - // Use the environment settings directly, as env.Setup() now handles sudo users correctly - appHome := env.Get().HomeDir - appLog := env.Get().LogFile + // Use the environment settings directly, as paths.Setup() now handles sudo users correctly + appHome := paths.Get().HomeDir + appLog := paths.Get().LogFile return ` diff --git a/internal/app/cli/config.go b/internal/app/cli/config.go index 774bbb9..30934af 100644 --- a/internal/app/cli/config.go +++ b/internal/app/cli/config.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/kyson-dev/sing-helm/internal/proxy/subscription" - "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) @@ -74,7 +74,7 @@ func newConfigAddCommand() *cobra.Command { return fmt.Errorf("url cannot be empty") } - paths := env.Get() + paths := paths.Get() if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err } @@ -113,7 +113,7 @@ func newConfigEditCommand() *cobra.Command { Short: "Edit base config or a subscription file", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() + paths := paths.Get() target := paths.ConfigFile if len(args) == 1 { if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { @@ -132,7 +132,7 @@ func newConfigRefreshCommand() *cobra.Command { Short: "Refresh subscription cache", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() + paths := paths.Get() if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err } @@ -156,7 +156,7 @@ func newConfigDeleteCommand() *cobra.Command { Short: "Delete a subscription config and cache", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() + paths := paths.Get() // 确保目录存在(虽然我们要删除东西,但如果目录都不存在也就没什么好删的,不过为了路径构建不出错) if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { return err diff --git a/internal/app/cli/config_ops.go b/internal/app/cli/config_ops.go index 084d198..9d85071 100644 --- a/internal/app/cli/config_ops.go +++ b/internal/app/cli/config_ops.go @@ -8,12 +8,12 @@ import ( "strings" "github.com/kyson-dev/sing-helm/internal/proxy/subscription" - "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) func runConfigList(cmd *cobra.Command, _ []string) error { - paths := env.Get() + paths := paths.Get() out := cmd.OutOrStdout() fmt.Fprintf(out, "Base config: %s\n", paths.ConfigFile) diff --git a/internal/app/cli/dispatcher.go b/internal/app/cli/dispatcher.go index 5db92d3..f5c3c5b 100644 --- a/internal/app/cli/dispatcher.go +++ b/internal/app/cli/dispatcher.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) var errDaemonUnavailable = errors.New("daemon unavailable") @@ -19,9 +19,9 @@ var ErrDaemonUnavailable = errDaemonUnavailable var commandSenderFactory = defaultCommandSenderFactory func defaultCommandSenderFactory() ipc.CommandSender { - socket := env.Get().SocketFile + socket := paths.Get().SocketFile if !pathExists(socket) { - legacy := filepath.Join(env.Get().HomeDir, "ipc.sock") + legacy := filepath.Join(paths.Get().HomeDir, "ipc.sock") if legacy != socket && pathExists(legacy) { return ipc.NewUnixSender(legacy) } diff --git a/internal/app/cli/log.go b/internal/app/cli/log.go index 550eb03..9be80e9 100644 --- a/internal/app/cli/log.go +++ b/internal/app/cli/log.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/nxadm/tail" "github.com/spf13/cobra" ) @@ -65,7 +65,7 @@ func showAppLog(cmd *cobra.Command) { func showSystemLogs(cmd *cobra.Command) { // Resolve log directory dynamically - logDir := env.Get().LogDir + logDir := paths.Get().LogDir stdoutLog := filepath.Join(logDir, "stdout.log") stderrLog := filepath.Join(logDir, "stderr.log") diff --git a/internal/app/cli/root.go b/internal/app/cli/root.go index a3576a1..f2a08b2 100644 --- a/internal/app/cli/root.go +++ b/internal/app/cli/root.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/kyson-dev/sing-helm/internal/app" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) @@ -33,7 +33,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := env.Setup(home); err != nil { + if err := paths.Setup(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } @@ -44,7 +44,7 @@ func NewRootCommand() *cobra.Command { } // Build the Application and attach to context - paths := env.Get() + paths := paths.Get() application := app.New(paths, logger.GetInstance()) ctx := context.WithValue(cmd.Context(), appKey{}, application) cmd.SetContext(ctx) diff --git a/internal/app/cli/run.go b/internal/app/cli/run.go index e44d36a..6de9cc7 100644 --- a/internal/app/cli/run.go +++ b/internal/app/cli/run.go @@ -8,9 +8,9 @@ import ( "time" coredaemon "github.com/kyson-dev/sing-helm/internal/app/daemon" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) @@ -83,7 +83,7 @@ func runAsDaemon(ctx context.Context, payload map[string]any) error { // 现在(正确) //TODO: 这里可以改进为等待 IPC 服务器真正启动,然后通过统一的dispatchToDaemon发送命令 - sender := ipc.NewUnixSender(env.Get().SocketFile) + sender := ipc.NewUnixSender(paths.Get().SocketFile) resp, err := sender.Send(context.Background(), ipc.CommandMessage{ Name: "run", Payload: payload, diff --git a/internal/app/cli/start.go b/internal/app/cli/start.go index d90ac9f..366b62e 100644 --- a/internal/app/cli/start.go +++ b/internal/app/cli/start.go @@ -6,8 +6,8 @@ import ( "os/exec" "time" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func newStartCommand() *cobra.Command { exePath, _ := os.Executable() // 使用 env 获取路径 - paths := env.Get() + paths := paths.Get() logFile := paths.LogFile // 传递 --home 给子进程,确保子进程使用相同的目录 diff --git a/internal/app/container.go b/internal/app/container.go index c2d3184..618fa64 100644 --- a/internal/app/container.go +++ b/internal/app/container.go @@ -3,19 +3,19 @@ package app import ( "log/slog" - "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) // Application is the central dependency holder for the entire program. // All business components obtain their dependencies from this struct, // avoiding global singletons. type Application struct { - Paths env.Paths + Paths paths.Paths Logger *slog.Logger } // New creates an Application instance by resolving paths and setting up logging. -func New(paths env.Paths, logger *slog.Logger) *Application { +func New(paths paths.Paths, logger *slog.Logger) *Application { return &Application{ Paths: paths, Logger: logger, diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go index 78ebbab..b97c2ed 100644 --- a/internal/app/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -8,9 +8,10 @@ import ( "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/proxy/engine" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/lock" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) // ServiceRunner abstracts the sing-box engine lifecycle. @@ -26,7 +27,7 @@ type Daemon struct { cancelFunc context.CancelFunc service ServiceRunner serviceFactory func() ServiceRunner - lock *env.DaemonLock + lock *lock.DaemonLock running bool reloading bool state *model.RuntimeState @@ -57,14 +58,14 @@ func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { // Serve starts the IPC server. Blocks until ctx is cancelled. func (d *Daemon) Serve(ctx context.Context) error { - lock, err := env.AcquireLock(env.Get().RuntimeDir) + lock, err := lock.AcquireLock(paths.Get().RuntimeDir) if err != nil { return fmt.Errorf("another instance is already running: %w", err) } d.lock = lock d.loadState() - _ = env.SaveRuntimeMeta(env.Get().RuntimeDir, env.RuntimeMeta{ - ConfigHome: env.Get().HomeDir, + _ = paths.SaveRuntimeMeta(paths.Get().RuntimeDir, paths.RuntimeMeta{ + ConfigHome: paths.Get().HomeDir, }) ctx, cancel := context.WithCancel(ctx) @@ -77,7 +78,7 @@ func (d *Daemon) Serve(ctx context.Context) error { logger.Info("Daemon started, listening for IPC commands") - if err := ipc.Serve(ctx, env.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { + if err := ipc.Serve(ctx, paths.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { return err } return nil diff --git a/internal/app/daemon/daemon_test.go b/internal/app/daemon/daemon_test.go index 4ce803c..fee60e6 100644 --- a/internal/app/daemon/daemon_test.go +++ b/internal/app/daemon/daemon_test.go @@ -10,8 +10,8 @@ import ( "time" "github.com/kyson-dev/sing-helm/internal/app/daemon" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) type fakeService struct { @@ -165,13 +165,13 @@ func TestDaemonHandleCommands(t *testing.T) { func setupEnv(t *testing.T) { t.Helper() - env.ResetForTest() + paths.ResetForTest() dir := t.TempDir() - env.SetRuntimeDir(dir) - if err := env.Init(dir); err != nil { - t.Fatalf("env.Init failed: %v", err) + paths.SetRuntimeDir(dir) + if err := paths.Init(dir); err != nil { + t.Fatalf("paths.Init failed: %v", err) } - if err := os.WriteFile(env.Get().ConfigFile, []byte(`{}`), 0644); err != nil { + if err := os.WriteFile(paths.Get().ConfigFile, []byte(`{}`), 0644); err != nil { t.Fatalf("write profile.json: %v", err) } } diff --git a/internal/app/daemon/handler_run.go b/internal/app/daemon/handler_run.go index fd68808..4f2fa38 100644 --- a/internal/app/daemon/handler_run.go +++ b/internal/app/daemon/handler_run.go @@ -8,9 +8,9 @@ import ( "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/proxy/engine" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) // handleRun 处理 IPC run 命令,启动 sing-box 服务 @@ -42,13 +42,13 @@ func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.Comm // 1. 构建配置 logger.Info("Building configuration", "mode", runops.ProxyMode, "route", runops.RouteMode) - if err := engine.BuildConfig(env.Get().RawConfigFile, &runops); err != nil { + if err := engine.BuildConfig(paths.Get().RawConfigFile, &runops); err != nil { return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to build config: %w", err).Error()} } // 2. 启动 sing-box 服务 svc := d.newService() - rawPath := env.Get().RawConfigFile + rawPath := paths.Get().RawConfigFile logger.Info("Starting sing-box", "config", rawPath) if err := svc.StartFromFile(ctx, rawPath); err != nil { return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to start sing-box: %w", err).Error()} @@ -125,20 +125,20 @@ func (d *Daemon) applyRunOptions(ctx context.Context, state *model.RuntimeState) d.mu.Unlock() }() - backupPath, _ := backupConfig(env.Get().RawConfigFile) - if err := engine.BuildConfig(env.Get().RawConfigFile, &state.RunOptions); err != nil { + backupPath, _ := backupConfig(paths.Get().RawConfigFile) + if err := engine.BuildConfig(paths.Get().RawConfigFile, &state.RunOptions); err != nil { return err } if d.service == nil { err := errors.New("service not available") return err } - if err := d.service.ReloadFromFile(ctx, env.Get().RawConfigFile); err != nil { + if err := d.service.ReloadFromFile(ctx, paths.Get().RawConfigFile); err != nil { var reloadErr *engine.ReloadError if errors.As(err, &reloadErr) && reloadErr.Stage == engine.ReloadStageStart { if backupPath != "" { if retryErr := d.service.StartFromFile(ctx, backupPath); retryErr == nil { - if restoreErr := restoreConfig(backupPath, env.Get().RawConfigFile); restoreErr != nil { + if restoreErr := restoreConfig(backupPath, paths.Get().RawConfigFile); restoreErr != nil { return restoreErr } d.setRunning(true) diff --git a/internal/app/daemon/handler_status.go b/internal/app/daemon/handler_status.go index 9b3e3e6..e91a897 100644 --- a/internal/app/daemon/handler_status.go +++ b/internal/app/daemon/handler_status.go @@ -3,8 +3,8 @@ package daemon import ( "context" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) func (d *Daemon) handleStatus() ipc.CommandResult { @@ -41,7 +41,7 @@ func (d *Daemon) handleHealth() ipc.CommandResult { } func (d *Daemon) handleLog() ipc.CommandResult { - logPath := env.Get().LogFile + logPath := paths.Get().LogFile return ipc.CommandResult{Status: "ok", Data: map[string]any{"path": logPath}} } diff --git a/internal/app/tui/monitor/commands.go b/internal/app/tui/monitor/commands.go index a0c8f68..d792b9e 100644 --- a/internal/app/tui/monitor/commands.go +++ b/internal/app/tui/monitor/commands.go @@ -11,8 +11,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/gorilla/websocket" "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) // ============================================================================ @@ -249,7 +249,7 @@ func fetchDaemonStatus() (*daemonStatus, error) { } func sendDaemonCommand(name string, payload map[string]any) (ipc.CommandResult, error) { - sender := ipc.NewUnixSender(env.Get().SocketFile) + sender := ipc.NewUnixSender(paths.Get().SocketFile) resp, err := sender.Send(context.Background(), ipc.CommandMessage{Name: name, Payload: payload}) if err != nil { return ipc.CommandResult{}, fmt.Errorf("ipc send failed: %w", err) diff --git a/internal/core/model/platform_bridge.go b/internal/core/model/platform_bridge.go index ec56ea5..ea07825 100644 --- a/internal/core/model/platform_bridge.go +++ b/internal/core/model/platform_bridge.go @@ -1,10 +1,10 @@ package model -import "github.com/kyson-dev/sing-helm/internal/sys/env" +import "github.com/kyson-dev/sing-helm/internal/sys/paths" // platformGetStateFile returns the state file path from the global platform config. // This is isolated here so state.go doesn't directly import platform, // making it easier to eventually remove this dependency. func platformGetStateFile() string { - return env.Get().StateFile + return paths.Get().StateFile } diff --git a/internal/proxy/engine/module/experimental.go b/internal/proxy/engine/module/experimental.go index 1638ac8..6691218 100644 --- a/internal/proxy/engine/module/experimental.go +++ b/internal/proxy/engine/module/experimental.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" - "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) @@ -53,7 +53,7 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *config.BuildContex }, CacheFile: &option.CacheFileOptions{ Enabled: true, - Path: env.Get().CacheFile, + Path: paths.Get().CacheFile, }, } diff --git a/internal/proxy/engine/module/mixed.go b/internal/proxy/engine/module/mixed.go index 8f09966..6e79755 100644 --- a/internal/proxy/engine/module/mixed.go +++ b/internal/proxy/engine/module/mixed.go @@ -60,5 +60,3 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) erro return nil } - - diff --git a/internal/proxy/engine/module/ports.go b/internal/proxy/engine/module/ports.go index 56c8e51..ed4c3c1 100644 --- a/internal/proxy/engine/module/ports.go +++ b/internal/proxy/engine/module/ports.go @@ -1,9 +1,9 @@ package module import ( + "net" "os" "strconv" - "net" ) // getPortOverride 返回测试期间指定的端口。 @@ -38,6 +38,3 @@ func getFreePort() (int, error) { // 返回分配到的端口 return l.Addr().(*net.TCPAddr).Port, nil } - - - diff --git a/internal/proxy/engine/module/subscription.go b/internal/proxy/engine/module/subscription.go index a74e93e..4a63391 100644 --- a/internal/proxy/engine/module/subscription.go +++ b/internal/proxy/engine/module/subscription.go @@ -3,8 +3,8 @@ package module import ( "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/kyson-dev/sing-helm/internal/proxy/subscription" - "github.com/kyson-dev/sing-helm/internal/sys/env" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) @@ -16,7 +16,7 @@ func (m *SubscriptionModule) Name() string { } func (m *SubscriptionModule) Apply(opts *option.Options, ctx *config.BuildContext) error { - paths := env.Get() + paths := paths.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { logger.Error("Failed to load subscription sources", "error", err) diff --git a/internal/proxy/engine/module/user.go b/internal/proxy/engine/module/user.go index 2819195..f069f34 100644 --- a/internal/proxy/engine/module/user.go +++ b/internal/proxy/engine/module/user.go @@ -6,7 +6,7 @@ import ( "os" "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" - "github.com/kyson-dev/sing-helm/internal/sys/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) @@ -19,7 +19,7 @@ func (m *UserOutboundModule) Name() string { func (m *UserOutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) error { // 如果没有提供 ProfilePath,说明用户配置已经在 opts 中了(向后兼容) - paths := env.Get() + paths := paths.Get() content, err := os.ReadFile(paths.ConfigFile) if err != nil { diff --git a/internal/sys/env/lock.go b/internal/sys/lock/lock.go similarity index 99% rename from internal/sys/env/lock.go rename to internal/sys/lock/lock.go index d37b932..efc3470 100644 --- a/internal/sys/env/lock.go +++ b/internal/sys/lock/lock.go @@ -1,4 +1,4 @@ -package env +package lock import ( "errors" diff --git a/internal/sys/env/logger.go b/internal/sys/paths/logger.go similarity index 98% rename from internal/sys/env/logger.go rename to internal/sys/paths/logger.go index 2f981a4..17373e0 100644 --- a/internal/sys/env/logger.go +++ b/internal/sys/paths/logger.go @@ -1,4 +1,4 @@ -package env +package paths import ( "os" diff --git a/internal/sys/env/paths.go b/internal/sys/paths/paths.go similarity index 94% rename from internal/sys/env/paths.go rename to internal/sys/paths/paths.go index 6f9a1cd..0aa17e2 100644 --- a/internal/sys/env/paths.go +++ b/internal/sys/paths/paths.go @@ -1,6 +1,7 @@ -package env +package paths import ( + "github.com/kyson-dev/sing-helm/internal/sys/lock" "os" "path/filepath" "sync" @@ -88,7 +89,7 @@ func GetPath(home string, runtimeDir string, logDir string) Paths { LogDir: logDir, LogFile: logFile, StateFile: filepath.Join(runtimeDir, "state.json"), - LookFile: GetLockPath(runtimeDir), // 使用 lock.go 中的单一事实来源 + LookFile: lock.GetLockPath(runtimeDir), // 使用 lock.go 中的单一事实来源 SocketFile: filepath.Join(runtimeDir, "ipc.sock"), AssetDir: filepath.Join(runtimeDir, "assets"), CacheFile: filepath.Join(runtimeDir, "cache.db"), diff --git a/internal/sys/env/runtime.go b/internal/sys/paths/runtime.go similarity index 96% rename from internal/sys/env/runtime.go rename to internal/sys/paths/runtime.go index 52407be..d1e6d33 100644 --- a/internal/sys/env/runtime.go +++ b/internal/sys/paths/runtime.go @@ -1,7 +1,8 @@ -package env +package paths import ( "encoding/json" + "github.com/kyson-dev/sing-helm/internal/sys/lock" "os" "path/filepath" "runtime" @@ -95,7 +96,7 @@ func FindRuntimeConfigHome() string { if runtimeDir == "" { return "" } - if err := CheckLock(runtimeDir); err != nil { + if err := lock.CheckLock(runtimeDir); err != nil { return "" } diff --git a/internal/sys/env/setup.go b/internal/sys/paths/setup.go similarity index 98% rename from internal/sys/env/setup.go rename to internal/sys/paths/setup.go index bad3e84..65df62d 100644 --- a/internal/sys/env/setup.go +++ b/internal/sys/paths/setup.go @@ -1,4 +1,4 @@ -package env +package paths import ( "os" From cea831bfd7f14614612bf0a33826f36ab33922be Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 21:00:00 +0800 Subject: [PATCH 08/23] refactor: extract daemon runtime meta out of sys/paths - moving daemon background state and file reading into app/daemon/meta.go - migrating cli bootstrap into app/cli/setup.go - keeping sys/paths purely a utility package for static path computations without JSON serialization logic --- internal/app/cli/root.go | 2 +- internal/app/cli/setup.go | 37 ++++++++++++++ internal/app/daemon/daemon.go | 4 +- internal/app/daemon/daemon_test.go | 4 +- internal/app/daemon/meta.go | 81 ++++++++++++++++++++++++++++++ internal/sys/lock/lock.go | 20 +++----- internal/sys/paths/paths.go | 23 +++++---- internal/sys/paths/runtime.go | 81 +++--------------------------- internal/sys/paths/setup.go | 43 ---------------- 9 files changed, 150 insertions(+), 145 deletions(-) create mode 100644 internal/app/cli/setup.go create mode 100644 internal/app/daemon/meta.go delete mode 100644 internal/sys/paths/setup.go diff --git a/internal/app/cli/root.go b/internal/app/cli/root.go index f2a08b2..a2a71f2 100644 --- a/internal/app/cli/root.go +++ b/internal/app/cli/root.go @@ -33,7 +33,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := paths.Setup(home); err != nil { + if err := SetupEnvironment(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } diff --git a/internal/app/cli/setup.go b/internal/app/cli/setup.go new file mode 100644 index 0000000..6d902fa --- /dev/null +++ b/internal/app/cli/setup.go @@ -0,0 +1,37 @@ +package cli + +import ( + "os" + "path/filepath" + + "github.com/kyson-dev/sing-helm/internal/app/daemon" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +// SetupEnvironment initializes the global paths. +// homeFlag: --home parameter from CLI +// Logic: +// 1. If homeFlag is set, use it. +// 2. Otherwise, prioritize the daemon's configured home. +// 3. Fallback to default ~/.sing-helm +// 4. Initialize paths +func SetupEnvironment(homeFlag string) error { + resolvedHome := "" + + if homeFlag != "" { + resolvedHome = homeFlag + } else { + if runtimeHome := daemon.FindRuntimeConfigHome(); runtimeHome != "" { + resolvedHome = runtimeHome + } else { + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + resolvedHome = filepath.Join("/Users", sudoUser, ".sing-helm") + } else { + userHome, _ := os.UserHomeDir() + resolvedHome = filepath.Join(userHome, ".sing-helm") + } + } + } + + return paths.Init(resolvedHome) +} diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go index b97c2ed..a1b2d7a 100644 --- a/internal/app/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -58,13 +58,13 @@ func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { // Serve starts the IPC server. Blocks until ctx is cancelled. func (d *Daemon) Serve(ctx context.Context) error { - lock, err := lock.AcquireLock(paths.Get().RuntimeDir) + lock, err := lock.AcquireLock(paths.Get().LockFile) if err != nil { return fmt.Errorf("another instance is already running: %w", err) } d.lock = lock d.loadState() - _ = paths.SaveRuntimeMeta(paths.Get().RuntimeDir, paths.RuntimeMeta{ + _ = SaveRuntimeMeta(paths.Get().RuntimeDir, RuntimeMeta{ ConfigHome: paths.Get().HomeDir, }) diff --git a/internal/app/daemon/daemon_test.go b/internal/app/daemon/daemon_test.go index fee60e6..efd3d80 100644 --- a/internal/app/daemon/daemon_test.go +++ b/internal/app/daemon/daemon_test.go @@ -167,8 +167,8 @@ func setupEnv(t *testing.T) { t.Helper() paths.ResetForTest() dir := t.TempDir() - paths.SetRuntimeDir(dir) - if err := paths.Init(dir); err != nil { + paths.ForTestSetRuntimeDir(dir) + if err := paths.ForTestInit(dir); err != nil { t.Fatalf("paths.Init failed: %v", err) } if err := os.WriteFile(paths.Get().ConfigFile, []byte(`{}`), 0644); err != nil { diff --git a/internal/app/daemon/meta.go b/internal/app/daemon/meta.go new file mode 100644 index 0000000..ab1fc47 --- /dev/null +++ b/internal/app/daemon/meta.go @@ -0,0 +1,81 @@ +package daemon + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/kyson-dev/sing-helm/internal/sys/lock" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +// RuntimeMeta holds system status, such as the config path used by the running daemon. +type RuntimeMeta struct { + ConfigHome string `json:"config_home"` +} + +func runtimeMetaPath(runtimeDir string) string { + return filepath.Join(runtimeDir, "runtime.json") +} + +// SaveRuntimeMeta saves daemon configurations metadata to the runtime directory. +func SaveRuntimeMeta(runtimeDir string, meta RuntimeMeta) error { + if runtimeDir == "" { + return os.ErrInvalid + } + if err := os.MkdirAll(runtimeDir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(runtimeMetaPath(runtimeDir), data, 0644) +} + +// LoadRuntimeMeta reads the daemon metadata from the runtime directory. +func LoadRuntimeMeta(runtimeDir string) (*RuntimeMeta, error) { + data, err := os.ReadFile(runtimeMetaPath(runtimeDir)) + if err != nil { + return nil, err + } + var meta RuntimeMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func fileExists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} + +// FindRuntimeConfigHome returns the config home from a running system daemon, if any. +func FindRuntimeConfigHome() string { + runtimeDir := paths.ResolveRuntimeDir() + if runtimeDir == "" { + return "" + } + + lockFile := filepath.Join(runtimeDir, "sing-helm.lock") + if err := lock.CheckLock(lockFile); err != nil { + // daemon not running or lock missing + return "" + } + + meta, err := LoadRuntimeMeta(runtimeDir) + if err != nil || meta == nil { + return "" + } + if meta.ConfigHome == "" { + return "" + } + if !fileExists(filepath.Join(meta.ConfigHome, "profile.json")) { + return "" + } + return meta.ConfigHome +} diff --git a/internal/sys/lock/lock.go b/internal/sys/lock/lock.go index efc3470..d94f8f2 100644 --- a/internal/sys/lock/lock.go +++ b/internal/sys/lock/lock.go @@ -12,20 +12,15 @@ type DaemonLock struct { path string } -func GetLockPath(homeDir string) string { - return filepath.Join(homeDir, "sing-helm.lock") -} - // AcquireLock 获取指定运行时目录的文件锁,非阻塞 // 如果已经被锁定,返回 error -func AcquireLock(runtimeDir string) (*DaemonLock, error) { - path := GetLockPath(runtimeDir) +func AcquireLock(lockPath string) (*DaemonLock, error) { // 确保目录存在 - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil { return nil, err } - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) if err != nil { return nil, err } @@ -38,23 +33,22 @@ func AcquireLock(runtimeDir string) (*DaemonLock, error) { return &DaemonLock{ file: f, - path: path, + path: lockPath, }, nil } // CheckLock 检查指定运行时目录的锁是否被占用 // 如果锁被占用,返回 nil (daemon running) // 如果锁未被占用,返回 error (daemon not running) -func CheckLock(runtimeDir string) error { - path := GetLockPath(runtimeDir) - f, err := os.OpenFile(path, os.O_RDWR, 0644) +func CheckLock(lockPath string) error { + f, err := os.OpenFile(lockPath, os.O_RDWR, 0644) if os.IsNotExist(err) { return errors.New("daemon not running (lock file missing)") } readOnly := false if err != nil { if os.IsPermission(err) { - f, err = os.Open(path) + f, err = os.Open(lockPath) if err != nil { return err } diff --git a/internal/sys/paths/paths.go b/internal/sys/paths/paths.go index 0aa17e2..1910d79 100644 --- a/internal/sys/paths/paths.go +++ b/internal/sys/paths/paths.go @@ -1,7 +1,6 @@ package paths import ( - "github.com/kyson-dev/sing-helm/internal/sys/lock" "os" "path/filepath" "sync" @@ -18,7 +17,7 @@ type Paths struct { LogDir string // log 目录 LogFile string // sing-helm.log StateFile string // state.json - LookFile string // sing-helm.lock + LockFile string // sing-helm.lock SocketFile string // 仅 Linux 用,或存放 API 地址的文件 AssetDir string // 存放 geoip.db/geosite.db CacheFile string // cache.db (sing-box 缓存) @@ -37,18 +36,18 @@ func Get() Paths { // Init 初始化环境 // home: 必须是已解析的绝对路径或相对路径,如果为空则报错(或者使用默认?) // 为了保持兼容性,我们可以让 Init("") 依旧使用默认 ~/.sing-helm, -// 但真正的智能选择逻辑交给 setup.go +// 但真正的智能选择逻辑交给 app/cli/setup.go func Init(home string) error { var err error once.Do(func() { - current, err = Resolve(home) + current, err = resolve(home) }) return err } // Resolve computes Paths from the given home directory without touching global state. // This is the preferred way to obtain Paths in DI-based code. -func Resolve(home string) (Paths, error) { +func resolve(home string) (Paths, error) { if home == "" { userHome, _ := os.UserHomeDir() home = filepath.Join(userHome, ".sing-helm") @@ -63,18 +62,18 @@ func Resolve(home string) (Paths, error) { return Paths{}, err } - runtimeDir := resolveRuntimeDir() + runtimeDir := ResolveRuntimeDir() runtimeDir, err = filepath.Abs(runtimeDir) if err != nil { return Paths{}, err } logDir := resolveLogDir(runtimeDir) - return GetPath(absHome, runtimeDir, logDir), nil + return getPath(absHome, runtimeDir, logDir), nil } // GetPath 根据主目录生成路径配置 (纯函数) -func GetPath(home string, runtimeDir string, logDir string) Paths { +func getPath(home string, runtimeDir string, logDir string) Paths { logFile := "" if logDir != "" { logFile = filepath.Join(logDir, "sing-helm.log") @@ -89,7 +88,7 @@ func GetPath(home string, runtimeDir string, logDir string) Paths { LogDir: logDir, LogFile: logFile, StateFile: filepath.Join(runtimeDir, "state.json"), - LookFile: lock.GetLockPath(runtimeDir), // 使用 lock.go 中的单一事实来源 + LockFile: filepath.Join(runtimeDir, "sing-helm.lock"), SocketFile: filepath.Join(runtimeDir, "ipc.sock"), AssetDir: filepath.Join(runtimeDir, "assets"), CacheFile: filepath.Join(runtimeDir, "cache.db"), @@ -101,5 +100,9 @@ func GetPath(home string, runtimeDir string, logDir string) Paths { func ResetForTest() { current = Paths{} once = sync.Once{} - ResetRuntimeDir() + ForTestResetRuntimeDir() +} + +func ForTestInit(home string) error { + return Init(home) } diff --git a/internal/sys/paths/runtime.go b/internal/sys/paths/runtime.go index d1e6d33..8aca159 100644 --- a/internal/sys/paths/runtime.go +++ b/internal/sys/paths/runtime.go @@ -1,8 +1,6 @@ package paths import ( - "encoding/json" - "github.com/kyson-dev/sing-helm/internal/sys/lock" "os" "path/filepath" "runtime" @@ -13,7 +11,7 @@ const runtimeDirEnv = "SINGHELM_RUNTIME_DIR" var runtimeDirOverride string // ResolveRuntimeDir returns the system-level runtime directory for sockets/locks/logs/state. -func resolveRuntimeDir() string { +func ResolveRuntimeDir() string { if runtimeDirOverride != "" { return runtimeDirOverride } @@ -75,82 +73,17 @@ func ensureWritableLogFile(path string) error { return nil } -// SetRuntimeDir overrides runtime directory resolution (tests only). -func SetRuntimeDir(dir string) { - runtimeDirOverride = dir -} - -// ResetRuntimeDir clears the runtime directory override. -func ResetRuntimeDir() { - runtimeDirOverride = "" -} - func dirExists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() } -// FindRuntimeConfigHome returns the config home from a running system daemon, if any. -func FindRuntimeConfigHome() string { - runtimeDir := resolveRuntimeDir() - if runtimeDir == "" { - return "" - } - if err := lock.CheckLock(runtimeDir); err != nil { - return "" - } - - meta, err := LoadRuntimeMeta(runtimeDir) - if err != nil || meta == nil { - return "" - } - if meta.ConfigHome == "" { - return "" - } - if !fileExists(filepath.Join(meta.ConfigHome, "profile.json")) { - return "" - } - return meta.ConfigHome -} - -type RuntimeMeta struct { - ConfigHome string `json:"config_home"` -} - -func runtimeMetaPath(runtimeDir string) string { - return filepath.Join(runtimeDir, "runtime.json") -} - -func SaveRuntimeMeta(runtimeDir string, meta RuntimeMeta) error { - if runtimeDir == "" { - return os.ErrInvalid - } - if err := os.MkdirAll(runtimeDir, 0755); err != nil { - return err - } - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return err - } - return os.WriteFile(runtimeMetaPath(runtimeDir), data, 0644) -} - -func LoadRuntimeMeta(runtimeDir string) (*RuntimeMeta, error) { - data, err := os.ReadFile(runtimeMetaPath(runtimeDir)) - if err != nil { - return nil, err - } - var meta RuntimeMeta - if err := json.Unmarshal(data, &meta); err != nil { - return nil, err - } - return &meta, nil +// SetRuntimeDir overrides runtime directory resolution (tests only). +func ForTestSetRuntimeDir(dir string) { + runtimeDirOverride = dir } -func fileExists(path string) bool { - if path == "" { - return false - } - _, err := os.Stat(path) - return err == nil +// ResetRuntimeDir clears the runtime directory override. +func ForTestResetRuntimeDir() { + runtimeDirOverride = "" } diff --git a/internal/sys/paths/setup.go b/internal/sys/paths/setup.go deleted file mode 100644 index 65df62d..0000000 --- a/internal/sys/paths/setup.go +++ /dev/null @@ -1,43 +0,0 @@ -package paths - -import ( - "os" - "path/filepath" -) - -// Setup 初始化环境,是应用启动的唯一环境入口 -// homeFlag: 命令行传入的 --home 参数 -// 逻辑: -// 1. 指定了 homeFlag -> 用之 -// 2. 未指定 -> 优先级:系统 daemon 关联的配置 > 活跃实例 > 第一个注册目录 > 默认 ~/.sing-helm -// 3. 无论如何 -> 注册该环境 -func Setup(homeFlag string) error { - resolvedHome := "" - - // 1. 如果指定了 homeFlag,直接使用 (强制模式) - if homeFlag != "" { - resolvedHome = homeFlag - } else { - // 2. 自动探测:优先系统 daemon 关联的配置 - if runtimeHome := FindRuntimeConfigHome(); runtimeHome != "" { - resolvedHome = runtimeHome - } else { - // 使用默认值 - // 如果是 sudo 运行,尝试获取原始用户的 home - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { - resolvedHome = filepath.Join("/Users", sudoUser, ".sing-helm") - } else { - userHome, _ := os.UserHomeDir() - resolvedHome = filepath.Join(userHome, ".sing-helm") - } - } - } - - // 3. 初始化全局路径配置 (创建目录等) - // Init 会确保目录存在并设置全局 current 变量 - if err := Init(resolvedHome); err != nil { - return err - } - - return nil -} From 04b0e2aeb9fe7095bc28877fd569fbb14ff08e61 Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 21:48:05 +0800 Subject: [PATCH 09/23] refactor: simplify runtime meta path management --- internal/app/cli/root.go | 2 +- internal/app/cli/setup.go | 37 ---------------- internal/app/daemon/daemon.go | 2 +- internal/app/daemon/meta.go | 81 ----------------------------------- internal/sys/paths/paths.go | 67 ++++++++++++++++------------- internal/sys/paths/runtime.go | 62 ++++++++++++++++++++++++++- internal/sys/paths/setup.go | 43 +++++++++++++++++++ 7 files changed, 143 insertions(+), 151 deletions(-) delete mode 100644 internal/app/cli/setup.go delete mode 100644 internal/app/daemon/meta.go create mode 100644 internal/sys/paths/setup.go diff --git a/internal/app/cli/root.go b/internal/app/cli/root.go index a2a71f2..f2a08b2 100644 --- a/internal/app/cli/root.go +++ b/internal/app/cli/root.go @@ -33,7 +33,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := SetupEnvironment(home); err != nil { + if err := paths.Setup(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } diff --git a/internal/app/cli/setup.go b/internal/app/cli/setup.go deleted file mode 100644 index 6d902fa..0000000 --- a/internal/app/cli/setup.go +++ /dev/null @@ -1,37 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - - "github.com/kyson-dev/sing-helm/internal/app/daemon" - "github.com/kyson-dev/sing-helm/internal/sys/paths" -) - -// SetupEnvironment initializes the global paths. -// homeFlag: --home parameter from CLI -// Logic: -// 1. If homeFlag is set, use it. -// 2. Otherwise, prioritize the daemon's configured home. -// 3. Fallback to default ~/.sing-helm -// 4. Initialize paths -func SetupEnvironment(homeFlag string) error { - resolvedHome := "" - - if homeFlag != "" { - resolvedHome = homeFlag - } else { - if runtimeHome := daemon.FindRuntimeConfigHome(); runtimeHome != "" { - resolvedHome = runtimeHome - } else { - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { - resolvedHome = filepath.Join("/Users", sudoUser, ".sing-helm") - } else { - userHome, _ := os.UserHomeDir() - resolvedHome = filepath.Join(userHome, ".sing-helm") - } - } - } - - return paths.Init(resolvedHome) -} diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go index a1b2d7a..05f9191 100644 --- a/internal/app/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -64,7 +64,7 @@ func (d *Daemon) Serve(ctx context.Context) error { } d.lock = lock d.loadState() - _ = SaveRuntimeMeta(paths.Get().RuntimeDir, RuntimeMeta{ + _ = paths.SaveRuntimeMeta(paths.Get().RuntimeMetaFile, paths.RuntimeMeta{ ConfigHome: paths.Get().HomeDir, }) diff --git a/internal/app/daemon/meta.go b/internal/app/daemon/meta.go deleted file mode 100644 index ab1fc47..0000000 --- a/internal/app/daemon/meta.go +++ /dev/null @@ -1,81 +0,0 @@ -package daemon - -import ( - "encoding/json" - "os" - "path/filepath" - - "github.com/kyson-dev/sing-helm/internal/sys/lock" - "github.com/kyson-dev/sing-helm/internal/sys/paths" -) - -// RuntimeMeta holds system status, such as the config path used by the running daemon. -type RuntimeMeta struct { - ConfigHome string `json:"config_home"` -} - -func runtimeMetaPath(runtimeDir string) string { - return filepath.Join(runtimeDir, "runtime.json") -} - -// SaveRuntimeMeta saves daemon configurations metadata to the runtime directory. -func SaveRuntimeMeta(runtimeDir string, meta RuntimeMeta) error { - if runtimeDir == "" { - return os.ErrInvalid - } - if err := os.MkdirAll(runtimeDir, 0755); err != nil { - return err - } - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return err - } - return os.WriteFile(runtimeMetaPath(runtimeDir), data, 0644) -} - -// LoadRuntimeMeta reads the daemon metadata from the runtime directory. -func LoadRuntimeMeta(runtimeDir string) (*RuntimeMeta, error) { - data, err := os.ReadFile(runtimeMetaPath(runtimeDir)) - if err != nil { - return nil, err - } - var meta RuntimeMeta - if err := json.Unmarshal(data, &meta); err != nil { - return nil, err - } - return &meta, nil -} - -func fileExists(path string) bool { - if path == "" { - return false - } - _, err := os.Stat(path) - return err == nil -} - -// FindRuntimeConfigHome returns the config home from a running system daemon, if any. -func FindRuntimeConfigHome() string { - runtimeDir := paths.ResolveRuntimeDir() - if runtimeDir == "" { - return "" - } - - lockFile := filepath.Join(runtimeDir, "sing-helm.lock") - if err := lock.CheckLock(lockFile); err != nil { - // daemon not running or lock missing - return "" - } - - meta, err := LoadRuntimeMeta(runtimeDir) - if err != nil || meta == nil { - return "" - } - if meta.ConfigHome == "" { - return "" - } - if !fileExists(filepath.Join(meta.ConfigHome, "profile.json")) { - return "" - } - return meta.ConfigHome -} diff --git a/internal/sys/paths/paths.go b/internal/sys/paths/paths.go index 1910d79..016dbf6 100644 --- a/internal/sys/paths/paths.go +++ b/internal/sys/paths/paths.go @@ -8,19 +8,25 @@ import ( // Paths 定义了应用所有的关键路径 type Paths struct { - HomeDir string // 主目录 - RuntimeDir string // 运行时目录 (socket/lock/log/state) - ConfigFile string // profile.json (用户配置) - RawConfigFile string // raw.json (生成的完整配置) - SubConfigDir string // subscriptions 目录 - SubCacheDir string // subscriptions cache 目录 - LogDir string // log 目录 - LogFile string // sing-helm.log - StateFile string // state.json - LockFile string // sing-helm.lock - SocketFile string // 仅 Linux 用,或存放 API 地址的文件 - AssetDir string // 存放 geoip.db/geosite.db - CacheFile string // cache.db (sing-box 缓存) + HomeDir string // 主目录 + RuntimeDir string // 运行时目录 (socket/lock/log/state) + RuntimeMetaFile string // runtime.json + ConfigFile string // profile.json (用户配置) + RawConfigFile string // raw.json (生成的完整配置) + SubConfigDir string // subscriptions 目录 + SubCacheDir string // subscriptions cache 目录 + LogDir string // log 目录 + LogFile string // sing-helm.log + StateFile string // state.json + LockFile string // sing-helm.lock + SocketFile string // 仅 Linux 用,或存放 API 地址的文件 + AssetDir string // 存放 geoip.db/geosite.db + CacheFile string // cache.db (sing-box 缓存) +} + +// RuntimeMeta holds system status, such as the config path used by the running daemon. +type RuntimeMeta struct { + ConfigHome string `json:"config_home"` } var ( @@ -36,8 +42,8 @@ func Get() Paths { // Init 初始化环境 // home: 必须是已解析的绝对路径或相对路径,如果为空则报错(或者使用默认?) // 为了保持兼容性,我们可以让 Init("") 依旧使用默认 ~/.sing-helm, -// 但真正的智能选择逻辑交给 app/cli/setup.go -func Init(home string) error { +// 但真正的智能选择逻辑交给 setup.go +func path_init(home string) error { var err error once.Do(func() { current, err = resolve(home) @@ -62,7 +68,7 @@ func resolve(home string) (Paths, error) { return Paths{}, err } - runtimeDir := ResolveRuntimeDir() + runtimeDir := resolveRuntimeDir() runtimeDir, err = filepath.Abs(runtimeDir) if err != nil { return Paths{}, err @@ -79,19 +85,20 @@ func getPath(home string, runtimeDir string, logDir string) Paths { logFile = filepath.Join(logDir, "sing-helm.log") } return Paths{ - HomeDir: home, - RuntimeDir: runtimeDir, - ConfigFile: filepath.Join(home, "profile.json"), - RawConfigFile: filepath.Join(runtimeDir, "raw.json"), - SubConfigDir: filepath.Join(home, "subscriptions"), - SubCacheDir: filepath.Join(home, "subscriptions", "cache"), - LogDir: logDir, - LogFile: logFile, - StateFile: filepath.Join(runtimeDir, "state.json"), - LockFile: filepath.Join(runtimeDir, "sing-helm.lock"), - SocketFile: filepath.Join(runtimeDir, "ipc.sock"), - AssetDir: filepath.Join(runtimeDir, "assets"), - CacheFile: filepath.Join(runtimeDir, "cache.db"), + HomeDir: home, + RuntimeDir: runtimeDir, + RuntimeMetaFile: filepath.Join(runtimeDir, "runtime.json"), + ConfigFile: filepath.Join(home, "profile.json"), + RawConfigFile: filepath.Join(runtimeDir, "raw.json"), + SubConfigDir: filepath.Join(home, "subscriptions"), + SubCacheDir: filepath.Join(home, "subscriptions", "cache"), + LogDir: logDir, + LogFile: logFile, + StateFile: filepath.Join(runtimeDir, "state.json"), + LockFile: filepath.Join(runtimeDir, "sing-helm.lock"), + SocketFile: filepath.Join(runtimeDir, "ipc.sock"), + AssetDir: filepath.Join(runtimeDir, "assets"), + CacheFile: filepath.Join(runtimeDir, "cache.db"), } } @@ -104,5 +111,5 @@ func ResetForTest() { } func ForTestInit(home string) error { - return Init(home) + return path_init(home) } diff --git a/internal/sys/paths/runtime.go b/internal/sys/paths/runtime.go index 8aca159..3cfab2f 100644 --- a/internal/sys/paths/runtime.go +++ b/internal/sys/paths/runtime.go @@ -1,9 +1,12 @@ package paths import ( + "encoding/json" "os" "path/filepath" "runtime" + + "github.com/kyson-dev/sing-helm/internal/sys/lock" ) const runtimeDirEnv = "SINGHELM_RUNTIME_DIR" @@ -11,7 +14,7 @@ const runtimeDirEnv = "SINGHELM_RUNTIME_DIR" var runtimeDirOverride string // ResolveRuntimeDir returns the system-level runtime directory for sockets/locks/logs/state. -func ResolveRuntimeDir() string { +func resolveRuntimeDir() string { if runtimeDirOverride != "" { return runtimeDirOverride } @@ -78,6 +81,63 @@ func dirExists(path string) bool { return err == nil && info.IsDir() } +// FindRuntimeConfigHome returns the config home from a running system daemon, if any. +func findRuntimeConfigHome() string { + runtimeDir := resolveRuntimeDir() + if runtimeDir == "" { + return "" + } + if err := lock.CheckLock(filepath.Join(runtimeDir, "sing-helm.lock")); err != nil { + return "" + } + + meta, err := LoadRuntimeMeta(filepath.Join(runtimeDir, "runtime.json")) + if err != nil || meta == nil { + return "" + } + if meta.ConfigHome == "" { + return "" + } + if !fileExists(filepath.Join(meta.ConfigHome, "profile.json")) { + return "" + } + return meta.ConfigHome +} + +func SaveRuntimeMeta(path string, meta RuntimeMeta) error { + if path == "" { + return os.ErrInvalid + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func LoadRuntimeMeta(path string) (*RuntimeMeta, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var meta RuntimeMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func fileExists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} + // SetRuntimeDir overrides runtime directory resolution (tests only). func ForTestSetRuntimeDir(dir string) { runtimeDirOverride = dir diff --git a/internal/sys/paths/setup.go b/internal/sys/paths/setup.go new file mode 100644 index 0000000..b77a010 --- /dev/null +++ b/internal/sys/paths/setup.go @@ -0,0 +1,43 @@ +package paths + +import ( + "os" + "path/filepath" +) + +// Setup 初始化环境,是应用启动的唯一环境入口 +// homeFlag: 命令行传入的 --home 参数 +// 逻辑: +// 1. 指定了 homeFlag -> 用之 +// 2. 未指定 -> 优先级:系统 daemon 关联的配置 > 活跃实例 > 第一个注册目录 > 默认 ~/.sing-helm +// 3. 无论如何 -> 注册该环境 +func Setup(homeFlag string) error { + resolvedHome := "" + + // 1. 如果指定了 homeFlag,直接使用 (强制模式) + if homeFlag != "" { + resolvedHome = homeFlag + } else { + // 2. 自动探测:优先系统 daemon 关联的配置 + if runtimeHome := findRuntimeConfigHome(); runtimeHome != "" { + resolvedHome = runtimeHome + } else { + // 使用默认值 + // 如果是 sudo 运行,尝试获取原始用户的 home + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + resolvedHome = filepath.Join("/Users", sudoUser, ".sing-helm") + } else { + userHome, _ := os.UserHomeDir() + resolvedHome = filepath.Join(userHome, ".sing-helm") + } + } + } + + // 3. 初始化全局路径配置 (创建目录等) + // Init 会确保目录存在并设置全局 current 变量 + if err := path_init(resolvedHome); err != nil { + return err + } + + return nil +} From 30e5da5997928f91c644e9767c37cb4f76fbdec6 Mon Sep 17 00:00:00 2001 From: kyson Date: Sun, 1 Mar 2026 23:46:23 +0800 Subject: [PATCH 10/23] refactor: reorganize proxy configuration into a dedicated `config` package and update CLI and daemon for new structure. --- internal/app/cli/config.go | 2 +- internal/app/cli/config_ops.go | 2 +- internal/app/cli/root.go | 2 +- internal/app/cli/serve.go | 6 +- internal/{sys/paths => app/cli}/setup.go | 24 +-- internal/app/daemon/daemon.go | 2 +- internal/app/daemon/handler_run.go | 5 +- internal/app/daemon/meta.go | 72 +++++++++ internal/proxy/{engine => }/config/builder.go | 11 +- internal/proxy/config/config.go | 78 ++++++++++ internal/proxy/{ => config}/export/compat.go | 0 internal/proxy/{ => config}/export/export.go | 0 internal/proxy/{ => config}/export/version.go | 0 internal/proxy/config/loader.go | 30 ++++ .../{engine => config}/module/experimental.go | 4 +- .../loader.go => config/module/helper.go} | 22 +-- .../proxy/{engine => config}/module/log.go | 3 +- .../proxy/{engine => config}/module/mixed.go | 5 +- .../proxy/{engine => config}/module/naming.go | 0 .../{engine => config}/module/outbound.go | 13 +- .../proxy/{engine => config}/module/ports.go | 0 .../{engine => config}/module/processor.go | 0 .../proxy/{engine => config}/module/route.go | 3 +- .../{engine => config}/module/subscription.go | 5 +- .../proxy/{engine => config}/module/tun.go | 7 +- .../{engine/config => config/module}/types.go | 2 +- .../proxy/{engine => config}/module/user.go | 3 +- .../proxy/{ => config}/subscription/merge.go | 0 .../proxy/{ => config}/subscription/parse.go | 0 .../{ => config}/subscription/refresh.go | 0 .../{ => config}/subscription/storage.go | 0 .../proxy/{ => config}/subscription/types.go | 0 internal/proxy/engine/engine.go | 139 +++++++++++------- internal/proxy/engine/instance.go | 110 -------------- internal/sys/paths/paths.go | 37 +++-- internal/sys/paths/runtime.go | 60 +------- 36 files changed, 334 insertions(+), 313 deletions(-) rename internal/{sys/paths => app/cli}/setup.go (52%) create mode 100644 internal/app/daemon/meta.go rename internal/proxy/{engine => }/config/builder.go (86%) create mode 100644 internal/proxy/config/config.go rename internal/proxy/{ => config}/export/compat.go (100%) rename internal/proxy/{ => config}/export/export.go (100%) rename internal/proxy/{ => config}/export/version.go (100%) create mode 100644 internal/proxy/config/loader.go rename internal/proxy/{engine => config}/module/experimental.go (88%) rename internal/proxy/{engine/config/loader.go => config/module/helper.go} (58%) rename internal/proxy/{engine => config}/module/log.go (73%) rename internal/proxy/{engine => config}/module/mixed.go (85%) rename internal/proxy/{engine => config}/module/naming.go (100%) rename internal/proxy/{engine => config}/module/outbound.go (84%) rename internal/proxy/{engine => config}/module/ports.go (100%) rename internal/proxy/{engine => config}/module/processor.go (100%) rename internal/proxy/{engine => config}/module/route.go (96%) rename internal/proxy/{engine => config}/module/subscription.go (90%) rename internal/proxy/{engine => config}/module/tun.go (90%) rename internal/proxy/{engine/config => config/module}/types.go (97%) rename internal/proxy/{engine => config}/module/user.go (92%) rename internal/proxy/{ => config}/subscription/merge.go (100%) rename internal/proxy/{ => config}/subscription/parse.go (100%) rename internal/proxy/{ => config}/subscription/refresh.go (100%) rename internal/proxy/{ => config}/subscription/storage.go (100%) rename internal/proxy/{ => config}/subscription/types.go (100%) delete mode 100644 internal/proxy/engine/instance.go diff --git a/internal/app/cli/config.go b/internal/app/cli/config.go index 30934af..c14bace 100644 --- a/internal/app/cli/config.go +++ b/internal/app/cli/config.go @@ -5,7 +5,7 @@ import ( "os" "strings" - "github.com/kyson-dev/sing-helm/internal/proxy/subscription" + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) diff --git a/internal/app/cli/config_ops.go b/internal/app/cli/config_ops.go index 9d85071..53de0bc 100644 --- a/internal/app/cli/config_ops.go +++ b/internal/app/cli/config_ops.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/proxy/subscription" + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) diff --git a/internal/app/cli/root.go b/internal/app/cli/root.go index f2a08b2..d50d5ea 100644 --- a/internal/app/cli/root.go +++ b/internal/app/cli/root.go @@ -33,7 +33,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := paths.Setup(home); err != nil { + if err := setupEnvironment(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } diff --git a/internal/app/cli/serve.go b/internal/app/cli/serve.go index 50620cf..c5fad9d 100644 --- a/internal/app/cli/serve.go +++ b/internal/app/cli/serve.go @@ -11,8 +11,8 @@ import ( "syscall" "github.com/kyson-dev/sing-helm/internal/core/model" - "github.com/kyson-dev/sing-helm/internal/proxy/engine" - "github.com/kyson-dev/sing-helm/internal/proxy/export" + "github.com/kyson-dev/sing-helm/internal/proxy/config" + "github.com/kyson-dev/sing-helm/internal/proxy/config/export" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) @@ -36,7 +36,7 @@ func newServeCommand() *cobra.Command { runops.RouteMode = model.RouteModeRule logger.Info("Building options...") - opts, err := engine.BuildOptions(&runops) + opts, err := config.BuildOptions(&runops) if err != nil { return err } diff --git a/internal/sys/paths/setup.go b/internal/app/cli/setup.go similarity index 52% rename from internal/sys/paths/setup.go rename to internal/app/cli/setup.go index b77a010..dd57ae3 100644 --- a/internal/sys/paths/setup.go +++ b/internal/app/cli/setup.go @@ -1,8 +1,8 @@ -package paths +package cli import ( - "os" - "path/filepath" + "github.com/kyson-dev/sing-helm/internal/app/daemon" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) // Setup 初始化环境,是应用启动的唯一环境入口 @@ -11,7 +11,7 @@ import ( // 1. 指定了 homeFlag -> 用之 // 2. 未指定 -> 优先级:系统 daemon 关联的配置 > 活跃实例 > 第一个注册目录 > 默认 ~/.sing-helm // 3. 无论如何 -> 注册该环境 -func Setup(homeFlag string) error { +func setupEnvironment(homeFlag string) error { resolvedHome := "" // 1. 如果指定了 homeFlag,直接使用 (强制模式) @@ -19,23 +19,13 @@ func Setup(homeFlag string) error { resolvedHome = homeFlag } else { // 2. 自动探测:优先系统 daemon 关联的配置 - if runtimeHome := findRuntimeConfigHome(); runtimeHome != "" { + if runtimeHome := daemon.FindRuntimeConfigHome(); runtimeHome != "" { resolvedHome = runtimeHome - } else { - // 使用默认值 - // 如果是 sudo 运行,尝试获取原始用户的 home - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { - resolvedHome = filepath.Join("/Users", sudoUser, ".sing-helm") - } else { - userHome, _ := os.UserHomeDir() - resolvedHome = filepath.Join(userHome, ".sing-helm") - } } } - // 3. 初始化全局路径配置 (创建目录等) - // Init 会确保目录存在并设置全局 current 变量 - if err := path_init(resolvedHome); err != nil { + // Init 会确保目录存在,如果 homeFlag 为空,则使用默认值 + if err := paths.Init(resolvedHome); err != nil { return err } diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go index 05f9191..188f48f 100644 --- a/internal/app/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -64,7 +64,7 @@ func (d *Daemon) Serve(ctx context.Context) error { } d.lock = lock d.loadState() - _ = paths.SaveRuntimeMeta(paths.Get().RuntimeMetaFile, paths.RuntimeMeta{ + _ = saveRuntimeMeta(paths.Get().RuntimeMetaFile, RuntimeMeta{ ConfigHome: paths.Get().HomeDir, }) diff --git a/internal/app/daemon/handler_run.go b/internal/app/daemon/handler_run.go index 4f2fa38..41dc3f7 100644 --- a/internal/app/daemon/handler_run.go +++ b/internal/app/daemon/handler_run.go @@ -7,6 +7,7 @@ import ( "os" "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config" "github.com/kyson-dev/sing-helm/internal/proxy/engine" "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/kyson-dev/sing-helm/internal/sys/logger" @@ -42,7 +43,7 @@ func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.Comm // 1. 构建配置 logger.Info("Building configuration", "mode", runops.ProxyMode, "route", runops.RouteMode) - if err := engine.BuildConfig(paths.Get().RawConfigFile, &runops); err != nil { + if err := config.BuildConfig(paths.Get().RawConfigFile, &runops); err != nil { return ipc.CommandResult{Status: "error", Error: fmt.Errorf("failed to build config: %w", err).Error()} } @@ -126,7 +127,7 @@ func (d *Daemon) applyRunOptions(ctx context.Context, state *model.RuntimeState) }() backupPath, _ := backupConfig(paths.Get().RawConfigFile) - if err := engine.BuildConfig(paths.Get().RawConfigFile, &state.RunOptions); err != nil { + if err := config.BuildConfig(paths.Get().RawConfigFile, &state.RunOptions); err != nil { return err } if d.service == nil { diff --git a/internal/app/daemon/meta.go b/internal/app/daemon/meta.go new file mode 100644 index 0000000..eefc0b7 --- /dev/null +++ b/internal/app/daemon/meta.go @@ -0,0 +1,72 @@ +package daemon + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/kyson-dev/sing-helm/internal/sys/lock" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +// FindRuntimeConfigHome returns the config home from a running system daemon, if any. +func FindRuntimeConfigHome() string { + runtimeDir := paths.ResolveRuntimeDir() + if runtimeDir == "" { + return "" + } + if err := lock.CheckLock(paths.GetRuntimeLockFileWithDir(runtimeDir)); err != nil { + return "" + } + + meta, err := loadRuntimeMeta(paths.GetRuntimeMetaFileWithDir(runtimeDir)) + if err != nil || meta == nil { + return "" + } + if meta.ConfigHome == "" { + return "" + } + if !fileExists(paths.GetProfileFileWithDir(meta.ConfigHome)) { + return "" + } + return meta.ConfigHome +} + +func fileExists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} + +// RuntimeMeta holds system status, such as the config path used by the running daemon. +type RuntimeMeta struct { + ConfigHome string `json:"config_home"` +} + +func saveRuntimeMeta(path string, meta RuntimeMeta) error { + if path == "" { + return os.ErrInvalid + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func loadRuntimeMeta(path string) (*RuntimeMeta, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var meta RuntimeMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} \ No newline at end of file diff --git a/internal/proxy/engine/config/builder.go b/internal/proxy/config/builder.go similarity index 86% rename from internal/proxy/engine/config/builder.go rename to internal/proxy/config/builder.go index 83666c4..4be3f14 100644 --- a/internal/proxy/engine/config/builder.go +++ b/internal/proxy/config/builder.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/kyson-dev/sing-helm/internal/proxy/config/module" "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" @@ -15,8 +16,8 @@ import ( // 支持链式调用添加模块,灵活组装配置 type Builder struct { opts *model.RunOptions // 运行时参数 - modules []ConfigModule // 配置模块列表 - ctx *BuildContext // 构建上下文 + modules []module.ConfigModule // 配置模块列表 + ctx *module.BuildContext // 构建上下文 } // NewBuilder 创建配置构建器(从已加载的配置) @@ -27,13 +28,13 @@ func NewBuilder(opts *model.RunOptions) *Builder { } return &Builder{ opts: opts, - modules: []ConfigModule{}, - ctx: NewBuildContext(opts), + modules: []module.ConfigModule{}, + ctx: module.NewBuildContext(opts), } } // With 添加一个模块(链式调用) -func (b *Builder) With(m ConfigModule) *Builder { +func (b *Builder) With(m module.ConfigModule) *Builder { b.modules = append(b.modules, m) return b } diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go new file mode 100644 index 0000000..30b9f24 --- /dev/null +++ b/internal/proxy/config/config.go @@ -0,0 +1,78 @@ +package config + +import ( + "fmt" + + "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config/module" + "github.com/sagernet/sing-box/option" +) + +// BuildConfig loads the profile, applies runtime modules, and saves raw config. +func BuildConfig(rawPath string, runops *model.RunOptions) error { + builder := NewBuilder(runops) + for _, m := range DefaultModules(runops) { + builder.With(m) + } + + if err := builder.SaveToFile(rawPath); err != nil { + return fmt.Errorf("failed to save raw config: %w", err) + } + + return nil +} + +// BuildOptions builds a sing-box config without writing to disk. +func BuildOptions(runops *model.RunOptions) (*option.Options, error) { + builder := NewBuilder(runops) + for _, m := range DefaultModules(runops) { + builder.With(m) + } + return builder.Build() +} + +// DefaultModules 根据 RunOptions 返回默认模块组合 +func DefaultModules(opts *model.RunOptions) []module.ConfigModule { + if opts == nil { + defaultOpts := model.DefaultRunOptions() + opts = &defaultOpts + } + + modules := []module.ConfigModule{ + &module.UserOutboundModule{}, + &module.SubscriptionModule{}, + &module.OutboundModule{}, + } + + // 根据 ProxyMode 选择入站模块 + switch opts.ProxyMode { + case model.ProxyModeTUN: + modules = append(modules, + &module.TUNModule{}, + &module.TUNDNSModule{}, + ) + case model.ProxyModeSystem: + modules = append(modules, &module.MixedModule{ + SetSystemProxy: true, + ListenAddr: opts.ListenAddr, + Port: opts.MixedPort, + }) + case model.ProxyModeDefault: + modules = append(modules, &module.MixedModule{ + SetSystemProxy: false, + ListenAddr: opts.ListenAddr, + Port: opts.MixedPort, + }) + } + + modules = append(modules, + &module.RouteModule{RouteMode: opts.RouteMode}, + &module.ExperimentalModule{ + ListenAddr: opts.ListenAddr, + APIPort: opts.APIPort, + }, + &module.LogModule{}, + ) + + return modules +} diff --git a/internal/proxy/export/compat.go b/internal/proxy/config/export/compat.go similarity index 100% rename from internal/proxy/export/compat.go rename to internal/proxy/config/export/compat.go diff --git a/internal/proxy/export/export.go b/internal/proxy/config/export/export.go similarity index 100% rename from internal/proxy/export/export.go rename to internal/proxy/config/export/export.go diff --git a/internal/proxy/export/version.go b/internal/proxy/config/export/version.go similarity index 100% rename from internal/proxy/export/version.go rename to internal/proxy/config/export/version.go diff --git a/internal/proxy/config/loader.go b/internal/proxy/config/loader.go new file mode 100644 index 0000000..2e62e65 --- /dev/null +++ b/internal/proxy/config/loader.go @@ -0,0 +1,30 @@ +package config + +import ( + "context" + "fmt" + "os" + + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// LoadOptionsWithContext 从配置文件加载 sing-box 配置 +func LoadOptionsWithContext(ctx context.Context, configPath string) (*option.Options, error) { + // 读取配置文件 + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // 解析配置 + var opts option.Options + includeCtx := include.Context(ctx) + if err := singboxjson.UnmarshalContext(includeCtx, data, &opts); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &opts, nil +} + diff --git a/internal/proxy/engine/module/experimental.go b/internal/proxy/config/module/experimental.go similarity index 88% rename from internal/proxy/engine/module/experimental.go rename to internal/proxy/config/module/experimental.go index 6691218..0678b47 100644 --- a/internal/proxy/engine/module/experimental.go +++ b/internal/proxy/config/module/experimental.go @@ -2,8 +2,6 @@ package module import ( "fmt" - - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) @@ -21,7 +19,7 @@ func (m *ExperimentalModule) Name() string { return "experimental" } -func (m *ExperimentalModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) error { // 确定监听地址 listenAddr := m.ListenAddr if listenAddr == "" { diff --git a/internal/proxy/engine/config/loader.go b/internal/proxy/config/module/helper.go similarity index 58% rename from internal/proxy/engine/config/loader.go rename to internal/proxy/config/module/helper.go index f74ab07..6f8b435 100644 --- a/internal/proxy/engine/config/loader.go +++ b/internal/proxy/config/module/helper.go @@ -1,33 +1,13 @@ -package config +package module import ( "context" - "fmt" - "os" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) -// LoadOptionsWithContext 从配置文件加载 sing-box 配置 -func LoadOptionsWithContext(ctx context.Context, configPath string) (*option.Options, error) { - // 读取配置文件 - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } - - // 解析配置 - var opts option.Options - includeCtx := include.Context(ctx) - if err := singboxjson.UnmarshalContext(includeCtx, data, &opts); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) - } - - return &opts, nil -} - // ApplyMapToOutbound 将 map 配置应用到 Outbound 结构体 func ApplyMapToOutbound(out *option.Outbound, m map[string]any) error { data, err := singboxjson.Marshal(m) diff --git a/internal/proxy/engine/module/log.go b/internal/proxy/config/module/log.go similarity index 73% rename from internal/proxy/engine/module/log.go rename to internal/proxy/config/module/log.go index 5b92ae0..0b61e34 100644 --- a/internal/proxy/engine/module/log.go +++ b/internal/proxy/config/module/log.go @@ -1,7 +1,6 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/option" ) @@ -14,7 +13,7 @@ func (m *LogModule) Name() string { return "log" } -func (m *LogModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *LogModule) Apply(opts *option.Options, ctx *BuildContext) error { level := m.Level if level == "" { level = "info" diff --git a/internal/proxy/engine/module/mixed.go b/internal/proxy/config/module/mixed.go similarity index 85% rename from internal/proxy/engine/module/mixed.go rename to internal/proxy/config/module/mixed.go index 6e79755..e1c191a 100644 --- a/internal/proxy/engine/module/mixed.go +++ b/internal/proxy/config/module/mixed.go @@ -1,7 +1,6 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/option" ) @@ -19,7 +18,7 @@ func (m *MixedModule) Name() string { return "mixed" } -func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { // 确定监听地址 listenAddr := m.ListenAddr if listenAddr == "" { @@ -53,7 +52,7 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *config.BuildContext) erro "listen_port": port, "set_system_proxy": m.SetSystemProxy, } - config.ApplyMapToInbound(&mixedInbound, mixedMap) + ApplyMapToInbound(&mixedInbound, mixedMap) // 添加到配置 opts.Inbounds = append(opts.Inbounds, mixedInbound) diff --git a/internal/proxy/engine/module/naming.go b/internal/proxy/config/module/naming.go similarity index 100% rename from internal/proxy/engine/module/naming.go rename to internal/proxy/config/module/naming.go diff --git a/internal/proxy/engine/module/outbound.go b/internal/proxy/config/module/outbound.go similarity index 84% rename from internal/proxy/engine/module/outbound.go rename to internal/proxy/config/module/outbound.go index 5e1c87c..4a436c2 100644 --- a/internal/proxy/engine/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -1,7 +1,6 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" ) @@ -14,7 +13,7 @@ func (m *OutboundModule) Name() string { return "outbound" } -func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // 1. 过滤保留 tag,并统计节点信息 filteredOutbounds := []option.Outbound{} userNodeTags := []string{} @@ -40,7 +39,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) e "type": "direct", "tag": "direct", } - config.ApplyMapToOutbound(&directOutbound, directOutboundMap) + ApplyMapToOutbound(&directOutbound, directOutboundMap) filteredOutbounds = append(filteredOutbounds, directOutbound) // 3. 添加 block 出站 @@ -49,7 +48,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) e "type": "block", "tag": "block", } - config.ApplyMapToOutbound(&blockOutbound, blockOutboundMap) + ApplyMapToOutbound(&blockOutbound, blockOutboundMap) filteredOutbounds = append(filteredOutbounds, blockOutbound) // 4 & 5. 添加 proxy selector 和 auto urltest @@ -67,7 +66,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) e "outbounds": proxyNodes, "default": "auto", } - config.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) + ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) // 5. 添加 auto urltest @@ -77,7 +76,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) e "tag": "auto", "outbounds": actualNodes, } - config.ApplyMapToOutbound(&autoOutbound, autoOutboundMap) + ApplyMapToOutbound(&autoOutbound, autoOutboundMap) filteredOutbounds = append(filteredOutbounds, autoOutbound) } else { // 无节点时的逻辑: @@ -91,7 +90,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) e "outbounds": []string{"direct"}, "default": "direct", } - config.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) + ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) } diff --git a/internal/proxy/engine/module/ports.go b/internal/proxy/config/module/ports.go similarity index 100% rename from internal/proxy/engine/module/ports.go rename to internal/proxy/config/module/ports.go diff --git a/internal/proxy/engine/module/processor.go b/internal/proxy/config/module/processor.go similarity index 100% rename from internal/proxy/engine/module/processor.go rename to internal/proxy/config/module/processor.go diff --git a/internal/proxy/engine/module/route.go b/internal/proxy/config/module/route.go similarity index 96% rename from internal/proxy/engine/module/route.go rename to internal/proxy/config/module/route.go index 5ecf7bd..f0b81d2 100644 --- a/internal/proxy/engine/module/route.go +++ b/internal/proxy/config/module/route.go @@ -2,7 +2,6 @@ package module import ( "github.com/kyson-dev/sing-helm/internal/core/model" - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) @@ -17,7 +16,7 @@ func (m *RouteModule) Name() string { return "route" } -func (m *RouteModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { // 如果用户没有配置路由,使用默认路由 if opts.Route == nil { defaultRoute, err := m.generateDefaultRoute() diff --git a/internal/proxy/engine/module/subscription.go b/internal/proxy/config/module/subscription.go similarity index 90% rename from internal/proxy/engine/module/subscription.go rename to internal/proxy/config/module/subscription.go index 4a63391..23fba5a 100644 --- a/internal/proxy/engine/module/subscription.go +++ b/internal/proxy/config/module/subscription.go @@ -1,8 +1,7 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" - "github.com/kyson-dev/sing-helm/internal/proxy/subscription" + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" @@ -15,7 +14,7 @@ func (m *SubscriptionModule) Name() string { return "subscription" } -func (m *SubscriptionModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *SubscriptionModule) Apply(opts *option.Options, ctx *BuildContext) error { paths := paths.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { diff --git a/internal/proxy/engine/module/tun.go b/internal/proxy/config/module/tun.go similarity index 90% rename from internal/proxy/engine/module/tun.go rename to internal/proxy/config/module/tun.go index f32ed35..7fd2942 100644 --- a/internal/proxy/engine/module/tun.go +++ b/internal/proxy/config/module/tun.go @@ -3,7 +3,6 @@ package module import ( "context" - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" @@ -19,7 +18,7 @@ func (m *TUNModule) Name() string { return "tun" } -func (m *TUNModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *TUNModule) Apply(opts *option.Options, ctx *BuildContext) error { // 默认值 mtu := m.MTU if mtu == 0 { @@ -45,7 +44,7 @@ func (m *TUNModule) Apply(opts *option.Options, ctx *config.BuildContext) error "sniff": true, "sniff_override_destination": true, } - config.ApplyMapToInbound(&tunInbound, tunMap) + ApplyMapToInbound(&tunInbound, tunMap) // 添加到配置 opts.Inbounds = append(opts.Inbounds, tunInbound) @@ -61,7 +60,7 @@ func (m *TUNDNSModule) Name() string { return "tun_dns" } -func (m *TUNDNSModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *TUNDNSModule) Apply(opts *option.Options, ctx *BuildContext) error { // 使用 map 方式创建 DNS 配置 // local_dns 不需要 detour,默认就是直连 dnsMap := map[string]any{ diff --git a/internal/proxy/engine/config/types.go b/internal/proxy/config/module/types.go similarity index 97% rename from internal/proxy/engine/config/types.go rename to internal/proxy/config/module/types.go index f1cfed9..0d572de 100644 --- a/internal/proxy/engine/config/types.go +++ b/internal/proxy/config/module/types.go @@ -1,4 +1,4 @@ -package config +package module import ( "github.com/kyson-dev/sing-helm/internal/core/model" diff --git a/internal/proxy/engine/module/user.go b/internal/proxy/config/module/user.go similarity index 92% rename from internal/proxy/engine/module/user.go rename to internal/proxy/config/module/user.go index f069f34..1563987 100644 --- a/internal/proxy/engine/module/user.go +++ b/internal/proxy/config/module/user.go @@ -5,7 +5,6 @@ import ( "encoding/json" "os" - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) @@ -17,7 +16,7 @@ func (m *UserOutboundModule) Name() string { return "user_outbound" } -func (m *UserOutboundModule) Apply(opts *option.Options, ctx *config.BuildContext) error { +func (m *UserOutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // 如果没有提供 ProfilePath,说明用户配置已经在 opts 中了(向后兼容) paths := paths.Get() diff --git a/internal/proxy/subscription/merge.go b/internal/proxy/config/subscription/merge.go similarity index 100% rename from internal/proxy/subscription/merge.go rename to internal/proxy/config/subscription/merge.go diff --git a/internal/proxy/subscription/parse.go b/internal/proxy/config/subscription/parse.go similarity index 100% rename from internal/proxy/subscription/parse.go rename to internal/proxy/config/subscription/parse.go diff --git a/internal/proxy/subscription/refresh.go b/internal/proxy/config/subscription/refresh.go similarity index 100% rename from internal/proxy/subscription/refresh.go rename to internal/proxy/config/subscription/refresh.go diff --git a/internal/proxy/subscription/storage.go b/internal/proxy/config/subscription/storage.go similarity index 100% rename from internal/proxy/subscription/storage.go rename to internal/proxy/config/subscription/storage.go diff --git a/internal/proxy/subscription/types.go b/internal/proxy/config/subscription/types.go similarity index 100% rename from internal/proxy/subscription/types.go rename to internal/proxy/config/subscription/types.go diff --git a/internal/proxy/engine/engine.go b/internal/proxy/engine/engine.go index 674f2d9..ff61734 100644 --- a/internal/proxy/engine/engine.go +++ b/internal/proxy/engine/engine.go @@ -1,79 +1,110 @@ package engine import ( + "context" "fmt" + "sync" - "github.com/kyson-dev/sing-helm/internal/core/model" - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" - "github.com/kyson-dev/sing-helm/internal/proxy/engine/module" + "github.com/kyson-dev/sing-helm/internal/proxy/config" + "github.com/kyson-dev/sing-helm/internal/sys/logger" + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" ) -// BuildConfig loads the profile, applies runtime modules, and saves raw config. -func BuildConfig(rawPath string, runops *model.RunOptions) error { - builder := config.NewBuilder(runops) - for _, m := range DefaultModules(runops) { - builder.With(m) - } +type instance struct { + mu sync.Mutex + once sync.Once + box *box.Box +} + +func NewInstance() *instance { + return &instance{} +} - if err := builder.SaveToFile(rawPath); err != nil { - return fmt.Errorf("failed to save raw config: %w", err) +func (s *instance) Stop() { + if s.box != nil { + if err := s.box.Close(); err != nil { + logger.Error("Failed to close box instance", "error", err) + return + } + logger.Info("Sing-box instance closed successfully") + s.box = nil } +} +// ReloadFromFile 从配置文件重新加载 sing-box +func (s *instance) ReloadFromFile(ctx context.Context, configPath string) error { + if s.box != nil { + if err := s.box.Close(); err != nil { + // 忽略 "file already closed" 错误 + if !isAlreadyClosedError(err) { + return &ReloadError{ + Stage: ReloadStageStop, + Err: fmt.Errorf("failed to close box instance: %w", err), + } + } + logger.Info("Box instance already closed, continuing reload") + } + s.box = nil + } + if err := s.StartFromFile(ctx, configPath); err != nil { + return &ReloadError{ + Stage: ReloadStageStart, + Err: err, + } + } return nil } -// BuildOptions builds a sing-box config without writing to disk. -func BuildOptions(runops *model.RunOptions) (*option.Options, error) { - builder := config.NewBuilder(runops) - for _, m := range DefaultModules(runops) { - builder.With(m) +// isAlreadyClosedError 检查是否是 "file already closed" 错误 +func isAlreadyClosedError(err error) bool { + if err == nil { + return false } - return builder.Build() + errStr := err.Error() + return errStr == "file already closed" || errStr == "use of closed file" } -// DefaultModules 根据 RunOptions 返回默认模块组合 -func DefaultModules(opts *model.RunOptions) []config.ConfigModule { - if opts == nil { - defaultOpts := model.DefaultRunOptions() - opts = &defaultOpts +// StartFromFile 从配置文件启动 sing-box +func (s *instance) StartFromFile(ctx context.Context, configPath string) error { + // 从文件加载配置 + opts, err := config.LoadOptionsWithContext(ctx, configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) } + return s.Start(ctx, opts) +} - modules := []config.ConfigModule{ - &module.UserOutboundModule{}, - &module.SubscriptionModule{}, - &module.OutboundModule{}, +// Start 启动 sing-box(接收 option.Options) +func (s *instance) Start(ctx context.Context, opts *option.Options) error { + if s.box != nil { + return fmt.Errorf("box instance already exists") } + s.mu.Lock() + defer s.mu.Unlock() + logger.Info("Initializing sing-box core...") - // 根据 ProxyMode 选择入站模块 - switch opts.ProxyMode { - case model.ProxyModeTUN: - modules = append(modules, - &module.TUNModule{}, - &module.TUNDNSModule{}, - ) - case model.ProxyModeSystem: - modules = append(modules, &module.MixedModule{ - SetSystemProxy: true, - ListenAddr: opts.ListenAddr, - Port: opts.MixedPort, - }) - case model.ProxyModeDefault: - modules = append(modules, &module.MixedModule{ - SetSystemProxy: false, - ListenAddr: opts.ListenAddr, - Port: opts.MixedPort, - }) + // 参数校验 + if opts == nil { + return fmt.Errorf("options cannot be nil") } - modules = append(modules, - &module.RouteModule{RouteMode: opts.RouteMode}, - &module.ExperimentalModule{ - ListenAddr: opts.ListenAddr, - APIPort: opts.APIPort, - }, - &module.LogModule{}, - ) + tx := include.Context(ctx) + newBox, err := box.New(box.Options{ + Context: tx, + Options: *opts, + PlatformLogWriter: NewPlatformWriter(), // 将 sing-box 日志重定向到我们的 logger + }) + if err != nil { + return fmt.Errorf("failed to create box instance: %w", err) + } - return modules + // 2. Start sing-box core + if err := newBox.Start(); err != nil { + return fmt.Errorf("failed to start sing-box core: %w", err) + } + s.box = newBox + logger.Info("Sing-box core started, launching cleanup goroutine") + return nil } diff --git a/internal/proxy/engine/instance.go b/internal/proxy/engine/instance.go deleted file mode 100644 index 4985a84..0000000 --- a/internal/proxy/engine/instance.go +++ /dev/null @@ -1,110 +0,0 @@ -package engine - -import ( - "context" - "fmt" - "sync" - - "github.com/kyson-dev/sing-helm/internal/proxy/engine/config" - "github.com/kyson-dev/sing-helm/internal/sys/logger" - box "github.com/sagernet/sing-box" - "github.com/sagernet/sing-box/include" - "github.com/sagernet/sing-box/option" -) - -type instance struct { - mu sync.Mutex - once sync.Once - box *box.Box -} - -func NewInstance() *instance { - return &instance{} -} - -func (s *instance) Stop() { - if s.box != nil { - if err := s.box.Close(); err != nil { - logger.Error("Failed to close box instance", "error", err) - return - } - logger.Info("Sing-box instance closed successfully") - s.box = nil - } -} - -// ReloadFromFile 从配置文件重新加载 sing-box -func (s *instance) ReloadFromFile(ctx context.Context, configPath string) error { - if s.box != nil { - if err := s.box.Close(); err != nil { - // 忽略 "file already closed" 错误 - if !isAlreadyClosedError(err) { - return &ReloadError{ - Stage: ReloadStageStop, - Err: fmt.Errorf("failed to close box instance: %w", err), - } - } - logger.Info("Box instance already closed, continuing reload") - } - s.box = nil - } - if err := s.StartFromFile(ctx, configPath); err != nil { - return &ReloadError{ - Stage: ReloadStageStart, - Err: err, - } - } - return nil -} - -// isAlreadyClosedError 检查是否是 "file already closed" 错误 -func isAlreadyClosedError(err error) bool { - if err == nil { - return false - } - errStr := err.Error() - return errStr == "file already closed" || errStr == "use of closed file" -} - -// StartFromFile 从配置文件启动 sing-box -func (s *instance) StartFromFile(ctx context.Context, configPath string) error { - // 从文件加载配置 - opts, err := config.LoadOptionsWithContext(ctx, configPath) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - return s.Start(ctx, opts) -} - -// Start 启动 sing-box(接收 option.Options) -func (s *instance) Start(ctx context.Context, opts *option.Options) error { - if s.box != nil { - return fmt.Errorf("box instance already exists") - } - s.mu.Lock() - defer s.mu.Unlock() - logger.Info("Initializing sing-box core...") - - // 参数校验 - if opts == nil { - return fmt.Errorf("options cannot be nil") - } - - tx := include.Context(ctx) - newBox, err := box.New(box.Options{ - Context: tx, - Options: *opts, - PlatformLogWriter: NewPlatformWriter(), // 将 sing-box 日志重定向到我们的 logger - }) - if err != nil { - return fmt.Errorf("failed to create box instance: %w", err) - } - - // 2. Start sing-box core - if err := newBox.Start(); err != nil { - return fmt.Errorf("failed to start sing-box core: %w", err) - } - s.box = newBox - logger.Info("Sing-box core started, launching cleanup goroutine") - return nil -} diff --git a/internal/sys/paths/paths.go b/internal/sys/paths/paths.go index 016dbf6..d88ee95 100644 --- a/internal/sys/paths/paths.go +++ b/internal/sys/paths/paths.go @@ -24,11 +24,6 @@ type Paths struct { CacheFile string // cache.db (sing-box 缓存) } -// RuntimeMeta holds system status, such as the config path used by the running daemon. -type RuntimeMeta struct { - ConfigHome string `json:"config_home"` -} - var ( current Paths once sync.Once @@ -43,7 +38,7 @@ func Get() Paths { // home: 必须是已解析的绝对路径或相对路径,如果为空则报错(或者使用默认?) // 为了保持兼容性,我们可以让 Init("") 依旧使用默认 ~/.sing-helm, // 但真正的智能选择逻辑交给 setup.go -func path_init(home string) error { +func Init(home string) error { var err error once.Do(func() { current, err = resolve(home) @@ -55,8 +50,14 @@ func path_init(home string) error { // This is the preferred way to obtain Paths in DI-based code. func resolve(home string) (Paths, error) { if home == "" { - userHome, _ := os.UserHomeDir() - home = filepath.Join(userHome, ".sing-helm") + // 使用默认值 + // 如果是 sudo 运行,尝试获取原始用户的 home + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + home = filepath.Join("/Users", sudoUser, ".sing-helm") + } else { + userHome, _ := os.UserHomeDir() + home = filepath.Join(userHome, ".sing-helm") + } } absHome, err := filepath.Abs(home) @@ -68,7 +69,7 @@ func resolve(home string) (Paths, error) { return Paths{}, err } - runtimeDir := resolveRuntimeDir() + runtimeDir := ResolveRuntimeDir() runtimeDir, err = filepath.Abs(runtimeDir) if err != nil { return Paths{}, err @@ -87,7 +88,7 @@ func getPath(home string, runtimeDir string, logDir string) Paths { return Paths{ HomeDir: home, RuntimeDir: runtimeDir, - RuntimeMetaFile: filepath.Join(runtimeDir, "runtime.json"), + RuntimeMetaFile: GetRuntimeMetaFileWithDir(runtimeDir), ConfigFile: filepath.Join(home, "profile.json"), RawConfigFile: filepath.Join(runtimeDir, "raw.json"), SubConfigDir: filepath.Join(home, "subscriptions"), @@ -102,6 +103,20 @@ func getPath(home string, runtimeDir string, logDir string) Paths { } } +// GetRuntimeMetaFileWithDir 根据运行时目录获取 runtime.json 路径 +func GetRuntimeMetaFileWithDir(runtimeDir string) string { + return filepath.Join(runtimeDir, "runtime.json") +} +// GetRuntimeLockFileWithDir 根据运行时目录获取 lock 文件路径 +func GetRuntimeLockFileWithDir(runtimeDir string) string { + return filepath.Join(runtimeDir, "sing-helm.lock") +} +// GetProfileFileWithDir 根据主目录获取 profile.json 路径 +func GetProfileFileWithDir(homeDir string) string { + return filepath.Join(homeDir, "profile.json") +} + + // ResetForTest 重置环境单例状态 // ⚠️ 仅供测试使用,生产代码禁止调用 func ResetForTest() { @@ -111,5 +126,5 @@ func ResetForTest() { } func ForTestInit(home string) error { - return path_init(home) + return Init(home) } diff --git a/internal/sys/paths/runtime.go b/internal/sys/paths/runtime.go index 3cfab2f..1594471 100644 --- a/internal/sys/paths/runtime.go +++ b/internal/sys/paths/runtime.go @@ -1,12 +1,9 @@ package paths import ( - "encoding/json" "os" "path/filepath" "runtime" - - "github.com/kyson-dev/sing-helm/internal/sys/lock" ) const runtimeDirEnv = "SINGHELM_RUNTIME_DIR" @@ -14,7 +11,7 @@ const runtimeDirEnv = "SINGHELM_RUNTIME_DIR" var runtimeDirOverride string // ResolveRuntimeDir returns the system-level runtime directory for sockets/locks/logs/state. -func resolveRuntimeDir() string { +func ResolveRuntimeDir() string { if runtimeDirOverride != "" { return runtimeDirOverride } @@ -81,62 +78,7 @@ func dirExists(path string) bool { return err == nil && info.IsDir() } -// FindRuntimeConfigHome returns the config home from a running system daemon, if any. -func findRuntimeConfigHome() string { - runtimeDir := resolveRuntimeDir() - if runtimeDir == "" { - return "" - } - if err := lock.CheckLock(filepath.Join(runtimeDir, "sing-helm.lock")); err != nil { - return "" - } - - meta, err := LoadRuntimeMeta(filepath.Join(runtimeDir, "runtime.json")) - if err != nil || meta == nil { - return "" - } - if meta.ConfigHome == "" { - return "" - } - if !fileExists(filepath.Join(meta.ConfigHome, "profile.json")) { - return "" - } - return meta.ConfigHome -} -func SaveRuntimeMeta(path string, meta RuntimeMeta) error { - if path == "" { - return os.ErrInvalid - } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} - -func LoadRuntimeMeta(path string) (*RuntimeMeta, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var meta RuntimeMeta - if err := json.Unmarshal(data, &meta); err != nil { - return nil, err - } - return &meta, nil -} - -func fileExists(path string) bool { - if path == "" { - return false - } - _, err := os.Stat(path) - return err == nil -} // SetRuntimeDir overrides runtime directory resolution (tests only). func ForTestSetRuntimeDir(dir string) { From 9902ac1edde1c89d1bdda913dc6a4bd56489683f Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 00:35:38 +0800 Subject: [PATCH 11/23] refactor(proxy): abstract outbound nodes into NodeProvider --- internal/proxy/config/config.go | 9 ++- .../proxy/config/module/{helper.go => map.go} | 0 internal/proxy/config/module/outbound.go | 74 ++++++++++++++---- internal/proxy/config/module/provider.go | 15 ++++ .../config/module/provider_subscription.go | 49 ++++++++++++ internal/proxy/config/module/provider_user.go | 63 +++++++++++++++ internal/proxy/config/module/route.go | 14 ---- internal/proxy/config/module/subscription.go | 77 ------------------- internal/proxy/config/module/user.go | 77 ------------------- 9 files changed, 190 insertions(+), 188 deletions(-) rename internal/proxy/config/module/{helper.go => map.go} (100%) create mode 100644 internal/proxy/config/module/provider.go create mode 100644 internal/proxy/config/module/provider_subscription.go create mode 100644 internal/proxy/config/module/provider_user.go delete mode 100644 internal/proxy/config/module/subscription.go delete mode 100644 internal/proxy/config/module/user.go diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 30b9f24..0239d46 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -39,9 +39,12 @@ func DefaultModules(opts *model.RunOptions) []module.ConfigModule { } modules := []module.ConfigModule{ - &module.UserOutboundModule{}, - &module.SubscriptionModule{}, - &module.OutboundModule{}, + &module.OutboundModule{ + Providers: []module.NodeProvider{ + &module.UserNodeProvider{}, + &module.SubscriptionNodeProvider{}, + }, + }, } // 根据 ProxyMode 选择入站模块 diff --git a/internal/proxy/config/module/helper.go b/internal/proxy/config/module/map.go similarity index 100% rename from internal/proxy/config/module/helper.go rename to internal/proxy/config/module/map.go diff --git a/internal/proxy/config/module/outbound.go b/internal/proxy/config/module/outbound.go index 4a436c2..066e848 100644 --- a/internal/proxy/config/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -7,33 +7,73 @@ import ( // OutboundModule 出站模块 // 负责处理所有 outbounds(用户配置 + 订阅节点),并补充系统 outbounds -type OutboundModule struct{} +type OutboundModule struct { + Providers []NodeProvider +} func (m *OutboundModule) Name() string { return "outbound" } func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 1. 过滤保留 tag,并统计节点信息 + // 1. 获取已有的 tags + usedTags := make(map[string]bool) + for _, out := range opts.Outbounds { + if out.Tag != "" { + usedTags[out.Tag] = true + } + } + + processor := NewOutboundProcessor(usedTags) + + // 2. 收集所有节点 + var nodes []Node + for _, p := range m.Providers { + pNodes, err := p.GetNodes() + if err != nil { + logger.Error("Failed to get nodes from provider", "provider", p.Name(), "error", err) + continue + } + nodes = append(nodes, pNodes...) + } + + // 3. 按 Source 分组并放入 Processor + nodesBySource := map[string][]RawOutbound{} + for _, node := range nodes { + if _, ok := nodesBySource[node.Source]; !ok { + nodesBySource[node.Source] = make([]RawOutbound, 0) + } + outboundCopy := node.Outbound + if node.Name != "" { + outboundCopy["tag"] = node.Name + } + nodesBySource[node.Source] = append(nodesBySource[node.Source], RawOutbound(outboundCopy)) + } + + // 4. 处理分组的节点并收集最终生成的所有 outbounds 和真实的节点 tag 列表 filteredOutbounds := []option.Outbound{} - userNodeTags := []string{} actualNodes := []string{} - for _, out := range opts.Outbounds { - if IsReservedOutboundTag(out.Tag) { - logger.Info("Ignoring reserved outbound tag from user config", "tag", out.Tag) + for source, rawOutbounds := range nodesBySource { + processed, err := processor.Process(rawOutbounds, source) + if err != nil { + logger.Error("Failed to process outbounds", "source", source, "error", err) continue } - filteredOutbounds = append(filteredOutbounds, out) - if out.Tag != "" { - userNodeTags = append(userNodeTags, out.Tag) - if IsActualOutboundType(out.Type) { + for _, out := range processed { + if IsReservedOutboundTag(out.Tag) { + logger.Info("Ignoring reserved outbound tag from provider config", "tag", out.Tag, "source", source) + continue + } + filteredOutbounds = append(filteredOutbounds, out) + // 注意,这里的 IsActualOutboundType 需要处理 + if out.Type != "selector" && out.Type != "urltest" && out.Type != "direct" && out.Type != "block" && out.Type != "dns" { actualNodes = append(actualNodes, out.Tag) } } } - // 2. 添加 direct 出站 + // 5. 添加 direct 出站 directOutbound := option.Outbound{} directOutboundMap := map[string]any{ "type": "direct", @@ -42,7 +82,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { ApplyMapToOutbound(&directOutbound, directOutboundMap) filteredOutbounds = append(filteredOutbounds, directOutbound) - // 3. 添加 block 出站 + // 6. 添加 block 出站 blockOutbound := option.Outbound{} blockOutboundMap := map[string]any{ "type": "block", @@ -51,13 +91,13 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { ApplyMapToOutbound(&blockOutbound, blockOutboundMap) filteredOutbounds = append(filteredOutbounds, blockOutbound) - // 4 & 5. 添加 proxy selector 和 auto urltest + // 7 & 8. 添加 proxy selector 和 auto urltest if len(actualNodes) > 0 { // 有节点时的逻辑: // - auto: urltest [all nodes] // - proxy: selector [auto, ...all nodes] - // 4. 添加 proxy selector + // 7. 添加 proxy selector proxyNodes := append([]string{"auto"}, actualNodes...) proxyOutbound := option.Outbound{} proxyOutboundMap := map[string]any{ @@ -69,7 +109,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) - // 5. 添加 auto urltest + // 8. 添加 auto urltest autoOutbound := option.Outbound{} autoOutboundMap := map[string]any{ "type": "urltest", @@ -94,8 +134,8 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { filteredOutbounds = append(filteredOutbounds, proxyOutbound) } - // 6. 更新最终的 outbounds - opts.Outbounds = filteredOutbounds + // 9. 更新最终的 outbounds + opts.Outbounds = append(opts.Outbounds, filteredOutbounds...) return nil } diff --git a/internal/proxy/config/module/provider.go b/internal/proxy/config/module/provider.go new file mode 100644 index 0000000..07bd817 --- /dev/null +++ b/internal/proxy/config/module/provider.go @@ -0,0 +1,15 @@ +package module + +// Node contains the raw struct for outbound before sing-box validation +type Node struct { + Name string + Type string + Source string + Outbound map[string]any +} + +// NodeProvider provides a list of unbound proxy nodes +type NodeProvider interface { + Name() string + GetNodes() ([]Node, error) +} diff --git a/internal/proxy/config/module/provider_subscription.go b/internal/proxy/config/module/provider_subscription.go new file mode 100644 index 0000000..d50b8d5 --- /dev/null +++ b/internal/proxy/config/module/provider_subscription.go @@ -0,0 +1,49 @@ +package module + +import ( + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" + "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +// SubscriptionNodeProvider reads subscription nodes from cache +type SubscriptionNodeProvider struct{} + +func (p *SubscriptionNodeProvider) Name() string { + return "subscription" +} + +func (p *SubscriptionNodeProvider) GetNodes() ([]Node, error) { + paths := paths.Get() + sources, err := subscription.LoadSources(paths.SubConfigDir) + if err != nil { + logger.Error("Failed to load subscription sources", "error", err) + } + + subNodes, err := subscription.LoadNodesFromCache(sources, paths.SubCacheDir) + if err != nil { + logger.Error("Failed to load subscription cache", "error", err) + return nil, nil // Return empty list instead of failing the whole build + } + + var nodes []Node + for _, n := range subNodes { + if n.Outbound == nil || n.Source == "" { + continue + } + + outboundCopy := make(map[string]any, len(n.Outbound)) + for k, v := range n.Outbound { + outboundCopy[k] = v + } + + nodes = append(nodes, Node{ + Name: n.Name, + Type: n.Type, + Source: n.Source, // Provide the sub source name + Outbound: outboundCopy, + }) + } + + return nodes, nil +} diff --git a/internal/proxy/config/module/provider_user.go b/internal/proxy/config/module/provider_user.go new file mode 100644 index 0000000..219586d --- /dev/null +++ b/internal/proxy/config/module/provider_user.go @@ -0,0 +1,63 @@ +package module + +import ( + "bytes" + "encoding/json" + "os" + + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +// UserNodeProvider reads raw nodes from user's profile.json +type UserNodeProvider struct{} + +func (p *UserNodeProvider) Name() string { + return "user" +} + +func (p *UserNodeProvider) GetNodes() ([]Node, error) { + paths := paths.Get() + + content, err := os.ReadFile(paths.ConfigFile) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // Not an error if file doesn't exist + } + return nil, err + } + + if len(bytes.TrimSpace(content)) == 0 { + return nil, nil + } + + var rawConfig map[string]any + if err := json.Unmarshal(content, &rawConfig); err != nil { + return nil, err + } + + var nodes []Node + if rawOutboundsVal, ok := rawConfig["outbounds"]; ok { + if list, ok := rawOutboundsVal.([]any); ok { + for _, item := range list { + if m, ok := item.(map[string]any); ok { + tag := "" + if t, ok := m["tag"].(string); ok { + tag = t + } + outType := "" + if t, ok := m["type"].(string); ok { + outType = t + } + nodes = append(nodes, Node{ + Name: tag, + Type: outType, + Source: "user", // Explicitly mark source + Outbound: m, + }) + } + } + } + } + + return nodes, nil +} diff --git a/internal/proxy/config/module/route.go b/internal/proxy/config/module/route.go index f0b81d2..ee751a2 100644 --- a/internal/proxy/config/module/route.go +++ b/internal/proxy/config/module/route.go @@ -53,20 +53,6 @@ func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { func (m *RouteModule) generateDefaultRoute() (*option.RouteOptions, error) { routeMap := map[string]any{ "rule_set": []map[string]any{ - // { - // "download_detour": "proxy", - // "format": "binary", - // "tag": "geosite-tld-cn", - // "type": "remote", - // "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-tld-cn.srs", - // }, - // { - // "tag": "geosite-google", - // "type": "remote", - // "format": "binary", - // "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs", - // "download_detour": "proxy", - // }, { "tag": "geosite-cn", "type": "remote", diff --git a/internal/proxy/config/module/subscription.go b/internal/proxy/config/module/subscription.go deleted file mode 100644 index 23fba5a..0000000 --- a/internal/proxy/config/module/subscription.go +++ /dev/null @@ -1,77 +0,0 @@ -package module - -import ( - "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" - "github.com/kyson-dev/sing-helm/internal/sys/logger" - "github.com/kyson-dev/sing-helm/internal/sys/paths" - "github.com/sagernet/sing-box/option" -) - -// SubscriptionModule merges cached subscription nodes into outbounds. -type SubscriptionModule struct{} - -func (m *SubscriptionModule) Name() string { - return "subscription" -} - -func (m *SubscriptionModule) Apply(opts *option.Options, ctx *BuildContext) error { - paths := paths.Get() - sources, err := subscription.LoadSources(paths.SubConfigDir) - if err != nil { - logger.Error("Failed to load subscription sources", "error", err) - } - - nodes, err := subscription.LoadNodesFromCache(sources, paths.SubCacheDir) - if err != nil { - logger.Error("Failed to load subscription cache", "error", err) - return nil - } - - if len(nodes) == 0 { - return nil - } - - // 1. 收集已有的 tags - usedTags := make(map[string]bool) - for _, out := range opts.Outbounds { - if out.Tag != "" { - usedTags[out.Tag] = true - } - } - - // 2. 按 Source 分组节点 - nodesBySource := map[string][]RawOutbound{} - // 为了保持顺序(可选),可以维护一个 source 列表,但 map 遍历顺序随机 - // 这里简单处理,因为不同 source 之间无依赖 - for _, node := range nodes { - if node.Outbound == nil || node.Source == "" { - continue - } - if _, ok := nodesBySource[node.Source]; !ok { - nodesBySource[node.Source] = make([]RawOutbound, 0) - } - // 复制 Outbound map 并设置 tag 为节点名 - outboundCopy := make(map[string]any, len(node.Outbound)+1) - for k, v := range node.Outbound { - outboundCopy[k] = v - } - // 使用节点名作为 tag 的 base(Processor 会处理冲突) - if node.Name != "" { - outboundCopy["tag"] = node.Name - } - nodesBySource[node.Source] = append(nodesBySource[node.Source], RawOutbound(outboundCopy)) - } - - // 3. 使用 Processor 处理每个分组 - processor := NewOutboundProcessor(usedTags) - for source, rawOutbounds := range nodesBySource { - processed, err := processor.Process(rawOutbounds, source) - if err != nil { - logger.Error("Failed to process subscription outbounds", "source", source, "error", err) - continue - } - opts.Outbounds = append(opts.Outbounds, processed...) - } - - return nil -} diff --git a/internal/proxy/config/module/user.go b/internal/proxy/config/module/user.go deleted file mode 100644 index 1563987..0000000 --- a/internal/proxy/config/module/user.go +++ /dev/null @@ -1,77 +0,0 @@ -package module - -import ( - "bytes" - "encoding/json" - "os" - - "github.com/kyson-dev/sing-helm/internal/sys/paths" - "github.com/sagernet/sing-box/option" -) - -// UserOutboundModule collects user outbounds into build context. -type UserOutboundModule struct{} - -func (m *UserOutboundModule) Name() string { - return "user_outbound" -} - -func (m *UserOutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 如果没有提供 ProfilePath,说明用户配置已经在 opts 中了(向后兼容) - paths := paths.Get() - - content, err := os.ReadFile(paths.ConfigFile) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - // 如果文件为空或只包含空白字符,直接返回(允许用户不配置任何内容) - if len(bytes.TrimSpace(content)) == 0 { - return nil - } - - // 1. 收集已有的 tags 以避免冲突 - usedTags := make(map[string]bool) - for _, out := range opts.Outbounds { - if out.Tag != "" { - usedTags[out.Tag] = true - } - } - - // 2. 解析为通用 Map 以提取 outbounds - var rawConfig map[string]any - // 使用 singboxjson.Unmarshal 以支持注释等特性 (如果 json 包不支持) - // 但这里我们用标准 json 包即可,profile.json 通常是标准 JSON - if err := json.Unmarshal(content, &rawConfig); err != nil { - return err - } - - // 3. 提取并处理 Outbounds - if rawOutboundsVal, ok := rawConfig["outbounds"]; ok { - var typedOutbounds []RawOutbound - - // 处理 []any 类型 (标准 json 解析结果) - if list, ok := rawOutboundsVal.([]any); ok { - typedOutbounds = make([]RawOutbound, 0, len(list)) - for _, item := range list { - if m, ok := item.(map[string]any); ok { - typedOutbounds = append(typedOutbounds, RawOutbound(m)) - } - } - } - - if len(typedOutbounds) > 0 { - processor := NewOutboundProcessor(usedTags) - outbounds, err := processor.Process(typedOutbounds, "") - if err != nil { - return err - } - opts.Outbounds = append(opts.Outbounds, outbounds...) - } - } - - return nil -} From bad2b3b4f2fe5d02ce85576385abcdcbb4d55124 Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 01:08:48 +0800 Subject: [PATCH 12/23] refactor(proxy): abstract subscription parsing into ProtocolAdapters --- .../config/subscription/adapter_hysteria.go | 183 ++++ .../config/subscription/adapter_registry.go | 31 + .../config/subscription/adapter_ss_trojan.go | 171 ++++ .../config/subscription/adapter_vless.go | 217 +++++ internal/proxy/config/subscription/parse.go | 785 +----------------- internal/proxy/config/subscription/utils.go | 224 +++++ 6 files changed, 858 insertions(+), 753 deletions(-) create mode 100644 internal/proxy/config/subscription/adapter_hysteria.go create mode 100644 internal/proxy/config/subscription/adapter_registry.go create mode 100644 internal/proxy/config/subscription/adapter_ss_trojan.go create mode 100644 internal/proxy/config/subscription/adapter_vless.go create mode 100644 internal/proxy/config/subscription/utils.go diff --git a/internal/proxy/config/subscription/adapter_hysteria.go b/internal/proxy/config/subscription/adapter_hysteria.go new file mode 100644 index 0000000..740b108 --- /dev/null +++ b/internal/proxy/config/subscription/adapter_hysteria.go @@ -0,0 +1,183 @@ +package subscription + +import ( + "fmt" + "net/url" +) + +// HysteriaAdapter handles Hysteria protocol +type HysteriaAdapter struct{} + +func init() { + RegisterAdapter("hysteria", &HysteriaAdapter{}) +} + +func (a *HysteriaAdapter) FromClash(m map[string]any) (Node, error) { + server := readString(m, "server") + port := readInt(m, "port") + if server == "" || port == 0 { + return Node{}, fmt.Errorf("missing server or port") + } + + auth := readString(m, "auth_str", "auth-str", "auth") + protocol := readString(m, "protocol") + if protocol == "" { + protocol = "udp" + } + + outbound := map[string]any{ + "type": "hysteria", + "server": server, + "server_port": port, + "protocol": protocol, + } + + if auth != "" { + outbound["auth_str"] = auth + } + if upMbps := readInt(m, "up", "up-mbps"); upMbps > 0 { + outbound["up_mbps"] = upMbps + } + if downMbps := readInt(m, "down", "down-mbps"); downMbps > 0 { + outbound["down_mbps"] = downMbps + } + + ApplyTLSOptions(outbound, m) + + return Node{ + Type: "hysteria", + Outbound: outbound, + }, nil +} + +func (a *HysteriaAdapter) FromURI(uriStr string) (Node, error) { + u, err := url.Parse("hysteria://" + uriStr) + if err != nil { + return Node{}, err + } + + auth := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if server == "" || port == "" { + return Node{}, fmt.Errorf("missing required fields") + } + + portNum, _ := parseInt(port) + outbound := map[string]any{ + "type": "hysteria", + "server": server, + "server_port": portNum, + } + + if auth != "" { + outbound["auth_str"] = auth + } + + if upMbps := query.Get("up"); upMbps != "" { + if up, _ := parseInt(upMbps); up > 0 { + outbound["up_mbps"] = up + } + } + if downMbps := query.Get("down"); downMbps != "" { + if down, _ := parseInt(downMbps); down > 0 { + outbound["down_mbps"] = down + } + } + + tls := map[string]any{"enabled": true} + if sni := query.Get("sni"); sni != "" { + tls["server_name"] = sni + } + if query.Get("insecure") == "1" { + tls["insecure"] = true + } + outbound["tls"] = tls + + return Node{ + Name: name, + Type: "hysteria", + Outbound: outbound, + }, nil +} + +// Hysteria2Adapter handles Hysteria2 protocol +type Hysteria2Adapter struct{} + +func init() { + RegisterAdapter("hysteria2", &Hysteria2Adapter{}) + RegisterAdapter("hy2", &Hysteria2Adapter{}) +} + +func (a *Hysteria2Adapter) FromClash(m map[string]any) (Node, error) { + server := readString(m, "server") + port := readInt(m, "port") + if server == "" || port == 0 { + return Node{}, fmt.Errorf("missing server or port") + } + + password := readString(m, "password") + outbound := map[string]any{ + "type": "hysteria2", + "server": server, + "server_port": port, + "password": password, + } + + if upMbps := readInt(m, "up", "up-mbps"); upMbps > 0 { + outbound["up_mbps"] = upMbps + } + if downMbps := readInt(m, "down", "down-mbps"); downMbps > 0 { + outbound["down_mbps"] = downMbps + } + + ApplyTLSOptions(outbound, m) + + return Node{ + Type: "hysteria2", + Outbound: outbound, + }, nil +} + +func (a *Hysteria2Adapter) FromURI(uriStr string) (Node, error) { + u, err := url.Parse("hysteria2://" + uriStr) + if err != nil { + return Node{}, err + } + + password := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if server == "" || port == "" { + return Node{}, fmt.Errorf("missing required fields") + } + + portNum, _ := parseInt(port) + outbound := map[string]any{ + "type": "hysteria2", + "server": server, + "server_port": portNum, + "password": password, + } + + tls := map[string]any{"enabled": true} + if sni := query.Get("sni"); sni != "" { + tls["server_name"] = sni + } + if query.Get("insecure") == "1" { + tls["insecure"] = true + } + outbound["tls"] = tls + + return Node{ + Name: name, + Type: "hysteria2", + Outbound: outbound, + }, nil +} diff --git a/internal/proxy/config/subscription/adapter_registry.go b/internal/proxy/config/subscription/adapter_registry.go new file mode 100644 index 0000000..1e0f4de --- /dev/null +++ b/internal/proxy/config/subscription/adapter_registry.go @@ -0,0 +1,31 @@ +package subscription + +import "fmt" + +// ProtocolAdapter describes how to parse different node formats into a standard Node. +type ProtocolAdapter interface { + FromClash(m map[string]any) (Node, error) + FromURI(uri string) (Node, error) +} + +var registry = make(map[string]ProtocolAdapter) + +// RegisterAdapter registers an adapter for a protocol name (e.g., "vmess", "vless"). +func RegisterAdapter(name string, adapter ProtocolAdapter) { + registry[name] = adapter +} + +// GetAdapter returns the protocol adapter. +func GetAdapter(name string) (ProtocolAdapter, error) { + adapter, ok := registry[name] + if !ok { + return nil, fmt.Errorf("unsupported protocol: %s", name) + } + return adapter, nil +} + +// HasAdapter checks if an adapter exists. +func HasAdapter(name string) bool { + _, ok := registry[name] + return ok +} diff --git a/internal/proxy/config/subscription/adapter_ss_trojan.go b/internal/proxy/config/subscription/adapter_ss_trojan.go new file mode 100644 index 0000000..f2137b7 --- /dev/null +++ b/internal/proxy/config/subscription/adapter_ss_trojan.go @@ -0,0 +1,171 @@ +package subscription + +import ( + "encoding/base64" + "fmt" + "net/url" + "strings" +) + +// ShadowsocksAdapter handles Shadowsocks protocol +type ShadowsocksAdapter struct{} + +func init() { + RegisterAdapter("ss", &ShadowsocksAdapter{}) + RegisterAdapter("shadowsocks", &ShadowsocksAdapter{}) +} + +func (a *ShadowsocksAdapter) FromClash(m map[string]any) (Node, error) { + server := readString(m, "server") + port := readInt(m, "port") + if server == "" || port == 0 { + return Node{}, fmt.Errorf("missing server or port") + } + + password := readString(m, "password") + cipher := readString(m, "cipher") + + outbound := map[string]any{ + "type": "shadowsocks", + "server": server, + "server_port": port, + "password": password, + "method": cipher, + } + + if plugin := readString(m, "plugin"); plugin != "" { + outbound["plugin"] = plugin + } + if pluginOpts := readString(m, "plugin-opts", "plugin_opts"); pluginOpts != "" { + outbound["plugin_opts"] = pluginOpts + } + + return Node{ + Type: "shadowsocks", + Outbound: outbound, + }, nil +} + +func (a *ShadowsocksAdapter) FromURI(uriStr string) (Node, error) { + parts := strings.SplitN(uriStr, "@", 2) + if len(parts) != 2 { + return Node{}, fmt.Errorf("invalid ss URI format") + } + + methodPassword := parts[0] + decoded, err := base64.URLEncoding.DecodeString(methodPassword) + if err == nil { + methodPassword = string(decoded) + } + + mpParts := strings.SplitN(methodPassword, ":", 2) + if len(mpParts) != 2 { + return Node{}, fmt.Errorf("invalid method:password format") + } + + method := mpParts[0] + password := mpParts[1] + + serverPart := parts[1] + hashIdx := strings.Index(serverPart, "#") + name := "" + if hashIdx >= 0 { + name, _ = url.QueryUnescape(serverPart[hashIdx+1:]) + serverPart = serverPart[:hashIdx] + } + + spParts := strings.SplitN(serverPart, ":", 2) + if len(spParts) != 2 { + return Node{}, fmt.Errorf("invalid server:port format") + } + + server := spParts[0] + port, _ := parseInt(spParts[1]) + + return Node{ + Name: name, + Type: "shadowsocks", + Outbound: map[string]any{ + "type": "shadowsocks", + "server": server, + "server_port": port, + "method": method, + "password": password, + }, + }, nil +} + +// TrojanAdapter handles Trojan protocol +type TrojanAdapter struct{} + +func init() { + RegisterAdapter("trojan", &TrojanAdapter{}) +} + +func (a *TrojanAdapter) FromClash(m map[string]any) (Node, error) { + server := readString(m, "server") + port := readInt(m, "port") + if server == "" || port == 0 { + return Node{}, fmt.Errorf("missing server or port") + } + + password := readString(m, "password") + + outbound := map[string]any{ + "type": "trojan", + "server": server, + "server_port": port, + "password": password, + } + + ApplyTLSOptions(outbound, m) + ApplyTransportOptions(outbound, m) + + return Node{ + Type: "trojan", + Outbound: outbound, + }, nil +} + +func (a *TrojanAdapter) FromURI(uriStr string) (Node, error) { + u, err := url.Parse("trojan://" + uriStr) + if err != nil { + return Node{}, err + } + + password := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if password == "" || server == "" || port == "" { + return Node{}, fmt.Errorf("missing required fields") + } + + portNum, _ := parseInt(port) + outbound := map[string]any{ + "type": "trojan", + "server": server, + "server_port": portNum, + "password": password, + } + + if security := query.Get("security"); security == "tls" || query.Get("tls") == "1" { + tls := map[string]any{"enabled": true} + if sni := query.Get("sni"); sni != "" { + tls["server_name"] = sni + } + outbound["tls"] = tls + } + + if network := query.Get("type"); network != "" { + ApplyURITransport(outbound, network, query) + } + + return Node{ + Name: name, + Type: "trojan", + Outbound: outbound, + }, nil +} diff --git a/internal/proxy/config/subscription/adapter_vless.go b/internal/proxy/config/subscription/adapter_vless.go new file mode 100644 index 0000000..5b6ff89 --- /dev/null +++ b/internal/proxy/config/subscription/adapter_vless.go @@ -0,0 +1,217 @@ +package subscription + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" +) + +// VMessAdapter handles VMess protocol in Clash and URI formats. +type VMessAdapter struct{} + +func init() { + RegisterAdapter("vmess", &VMessAdapter{}) +} + +func (a *VMessAdapter) FromClash(m map[string]any) (Node, error) { + server := readString(m, "server") + port := readInt(m, "port") + if server == "" || port == 0 { + return Node{}, fmt.Errorf("missing server or port") + } + + uuid := readString(m, "uuid") + cipher := readString(m, "cipher", "security") + if cipher == "" { + cipher = "auto" + } + + outbound := map[string]any{ + "type": "vmess", + "server": server, + "server_port": port, + "uuid": uuid, + "security": cipher, + } + + if alterID := readInt(m, "alterId", "alter-id"); alterID > 0 { + outbound["alter_id"] = alterID + } + + ApplyTLSOptions(outbound, m) + ApplyTransportOptions(outbound, m) + + return Node{ + Type: "vmess", + Outbound: outbound, + }, nil +} + +func (a *VMessAdapter) FromURI(uri string) (Node, error) { + decoded, err := base64.StdEncoding.DecodeString(uri) + if err != nil { + return Node{}, fmt.Errorf("invalid vmess URI: %w", err) + } + + var m map[string]any + if err := json.Unmarshal(decoded, &m); err != nil { + return Node{}, fmt.Errorf("invalid vmess config: %w", err) + } + + server := readString(m, "add", "address") + port := readInt(m, "port") + uuid := readString(m, "id", "uuid") + name := readString(m, "ps", "name") + + if server == "" || port == 0 || uuid == "" { + return Node{}, fmt.Errorf("missing required fields") + } + + outbound := map[string]any{ + "type": "vmess", + "server": server, + "server_port": port, + "uuid": uuid, + "security": readString(m, "scy", "security"), + } + + if outbound["security"] == "" { + outbound["security"] = "auto" + } + + if alterID := readInt(m, "aid", "alterId"); alterID > 0 { + outbound["alter_id"] = alterID + } + + if tls := readString(m, "tls"); tls == "tls" { + tlsConfig := map[string]any{"enabled": true} + if sni := readString(m, "sni"); sni != "" { + tlsConfig["server_name"] = sni + } + outbound["tls"] = tlsConfig + } + + if network := readString(m, "net", "network"); network != "" { + transport := map[string]any{"type": network} + switch network { + case "ws": + if path := readString(m, "path"); path != "" { + transport["path"] = path + } + if host := readString(m, "host"); host != "" { + transport["headers"] = map[string]string{"Host": host} + } + case "grpc": + if serviceName := readString(m, "path", "serviceName"); serviceName != "" { + transport["service_name"] = serviceName + } + } + outbound["transport"] = transport + } + + return Node{ + Name: name, + Type: "vmess", + Outbound: outbound, + }, nil +} + +// VLessAdapter handles VLess protocol in Clash and URI formats. +type VLessAdapter struct{} + +func init() { + RegisterAdapter("vless", &VLessAdapter{}) +} + +func (a *VLessAdapter) FromClash(m map[string]any) (Node, error) { + server := readString(m, "server") + port := readInt(m, "port") + if server == "" || port == 0 { + return Node{}, fmt.Errorf("missing server or port") + } + + uuid := readString(m, "uuid") + outbound := map[string]any{ + "type": "vless", + "server": server, + "server_port": port, + "uuid": uuid, + } + + if flow := readString(m, "flow"); flow != "" { + outbound["flow"] = flow + } + + ApplyTLSOptions(outbound, m) + ApplyTransportOptions(outbound, m) + + return Node{ + Type: "vless", + Outbound: outbound, + }, nil +} + +func (a *VLessAdapter) FromURI(uriStr string) (Node, error) { + u, err := url.Parse("vless://" + uriStr) + if err != nil { + return Node{}, err + } + + uuid := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if uuid == "" || server == "" || port == "" { + return Node{}, fmt.Errorf("missing required fields") + } + + portNum, _ := parseInt(port) + outbound := map[string]any{ + "type": "vless", + "server": server, + "server_port": portNum, + "uuid": uuid, + } + + if flow := query.Get("flow"); flow != "" { + outbound["flow"] = flow + } + + if security := query.Get("security"); security == "tls" || security == "reality" { + tls := map[string]any{"enabled": true} + if sni := query.Get("sni"); sni != "" { + tls["server_name"] = sni + } + if fp := query.Get("fp"); fp != "" { + tls["utls"] = map[string]any{ + "enabled": true, + "fingerprint": fp, + } + } + + if security == "reality" { + reality := map[string]any{"enabled": true} + if pbk := query.Get("pbk"); pbk != "" { + reality["public_key"] = pbk + } + if sid := query.Get("sid"); sid != "" { + reality["short_id"] = sid + } + tls["reality"] = reality + } + outbound["tls"] = tls + } + + if network := query.Get("type"); network != "" { + ApplyURITransport(outbound, network, query) + } + + return Node{ + Name: name, + Type: "vless", + Outbound: outbound, + }, nil +} diff --git a/internal/proxy/config/subscription/parse.go b/internal/proxy/config/subscription/parse.go index 04b119d..0a1be18 100644 --- a/internal/proxy/config/subscription/parse.go +++ b/internal/proxy/config/subscription/parse.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "net/url" "strings" "github.com/kyson-dev/sing-helm/internal/sys/logger" @@ -21,7 +20,6 @@ func Parse(content []byte, format string) ([]Node, error) { if nodes, err := parseClash(content); err == nil { return nodes, nil } - // 尝试 base64 URI 格式 if nodes, err := parseBase64URI(content); err == nil { return nodes, nil } @@ -68,6 +66,7 @@ func parseSingBox(content []byte) ([]Node, error) { name = fmt.Sprintf("%s-%d", outType, i+1) } delete(outMap, "tag") + nodes = append(nodes, Node{ Name: name, Type: outType, @@ -103,336 +102,42 @@ func parseClash(content []byte) ([]Node, error) { if proxyMap == nil { continue } - node, err := clashProxyToNode(proxyMap) + + proxyType := strings.ToLower(readString(proxyMap, "type")) + a, err := GetAdapter(proxyType) if err != nil { - // 记录跳过的节点,帮助调试 - name := readString(proxyMap, "name") - proxyType := readString(proxyMap, "type") - logger.Debug("Skipping proxy node", "name", name, "type", proxyType, "error", err.Error()) + logger.Debug("Skipping proxy node", "type", proxyType, "error", err.Error()) continue } - nodes = append(nodes, node) - } - - if len(nodes) == 0 { - return nil, fmt.Errorf("no supported proxies found") - } - return nodes, nil -} - -func clashProxyToNode(m map[string]any) (Node, error) { - name := strings.TrimSpace(readString(m, "name")) - proxyType := strings.ToLower(readString(m, "type")) - server := readString(m, "server") - port := readInt(m, "port") - if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") - } - outbound := map[string]any{ - "server": server, - "server_port": port, - } - - switch proxyType { - case "vmess": - uuid := readString(m, "uuid") - cipher := readString(m, "cipher", "security") - if cipher == "" { - cipher = "auto" - } - outbound["type"] = "vmess" - outbound["uuid"] = uuid - outbound["security"] = cipher - if alterID := readInt(m, "alterId", "alter-id"); alterID > 0 { - outbound["alter_id"] = alterID - } - case "vless": - uuid := readString(m, "uuid") - outbound["type"] = "vless" - outbound["uuid"] = uuid - if flow := readString(m, "flow"); flow != "" { - outbound["flow"] = flow - } - case "trojan": - password := readString(m, "password") - outbound["type"] = "trojan" - outbound["password"] = password - case "ss", "shadowsocks": - password := readString(m, "password") - cipher := readString(m, "cipher") - outbound["type"] = "shadowsocks" - outbound["password"] = password - outbound["method"] = cipher - if plugin := readString(m, "plugin"); plugin != "" { - outbound["plugin"] = plugin - } - if pluginOpts := readString(m, "plugin-opts", "plugin_opts"); pluginOpts != "" { - outbound["plugin_opts"] = pluginOpts - } - case "hysteria": - auth := readString(m, "auth_str", "auth-str", "auth") - protocol := readString(m, "protocol") - if protocol == "" { - protocol = "udp" - } - outbound["type"] = "hysteria" - if auth != "" { - outbound["auth_str"] = auth - } - if upMbps := readInt(m, "up", "up-mbps"); upMbps > 0 { - outbound["up_mbps"] = upMbps - } - if downMbps := readInt(m, "down", "down-mbps"); downMbps > 0 { - outbound["down_mbps"] = downMbps - } - outbound["protocol"] = protocol - case "hysteria2", "hy2": - password := readString(m, "password") - outbound["type"] = "hysteria2" - outbound["password"] = password - if upMbps := readInt(m, "up", "up-mbps"); upMbps > 0 { - outbound["up_mbps"] = upMbps - } - if downMbps := readInt(m, "down", "down-mbps"); downMbps > 0 { - outbound["down_mbps"] = downMbps - } - default: - return Node{}, fmt.Errorf("unsupported proxy type: %s", proxyType) - } - - switch proxyType { - case "vmess", "vless", "trojan", "hysteria", "hysteria2", "hy2": - applyTLSOptions(outbound, m) - applyTransportOptions(outbound, m) - } - - outType := readString(outbound, "type") - if name == "" { - name = fmt.Sprintf("%s-%s:%d", outType, server, port) - } - - return Node{ - Name: name, - Type: outType, - Outbound: outbound, - }, nil -} - -func applyTLSOptions(outbound map[string]any, m map[string]any) { - tlsEnabled := readBool(m, "tls") - sni := readString(m, "sni", "servername", "server_name") - skipVerify := readBool(m, "skip-cert-verify") - alpn := readStringList(m, "alpn") - clientFingerprint := readString(m, "client-fingerprint") - - // 检查是否有 reality 配置 - realityOpts := asStringMap(m["reality-opts"]) - - if !tlsEnabled && sni == "" && len(alpn) == 0 && realityOpts == nil && clientFingerprint == "" { - return - } - - tls := map[string]any{ - "enabled": true, - } - if sni != "" { - tls["server_name"] = sni - } - if skipVerify { - tls["insecure"] = true - } - if len(alpn) > 0 { - tls["alpn"] = alpn - } - if clientFingerprint != "" { - tls["utls"] = map[string]any{ - "enabled": true, - "fingerprint": clientFingerprint, - } - } - - // 处理 reality 配置 - if realityOpts != nil { - reality := map[string]any{ - "enabled": true, - } - if publicKey := readString(realityOpts, "public-key", "public_key"); publicKey != "" { - reality["public_key"] = publicKey - } - if shortID := readString(realityOpts, "short-id", "short_id"); shortID != "" { - reality["short_id"] = shortID - } - tls["reality"] = reality - } - - outbound["tls"] = tls -} - -func applyTransportOptions(outbound map[string]any, m map[string]any) { - network := strings.ToLower(readString(m, "network")) - switch network { - case "ws", "websocket": - wsOpts := asStringMap(m["ws-opts"]) - transport := map[string]any{ - "type": "ws", - } - if wsOpts != nil { - if path := readString(wsOpts, "path"); path != "" { - transport["path"] = path - } - headers := asStringMap(wsOpts["headers"]) - if len(headers) > 0 { - transport["headers"] = normalizeStringMap(headers) - } - } - outbound["transport"] = transport - case "grpc": - grpcOpts := asStringMap(m["grpc-opts"]) - transport := map[string]any{ - "type": "grpc", - } - if grpcOpts != nil { - if service := readString(grpcOpts, "grpc-service-name", "service-name"); service != "" { - transport["service_name"] = service - } - } - outbound["transport"] = transport - } -} - -func readString(m map[string]any, keys ...string) string { - for _, key := range keys { - if key == "" { + node, err := a.FromClash(proxyMap) + if err != nil { + logger.Debug("Failed to parse clash node", "type", proxyType, "error", err.Error()) continue } - if val, ok := m[key]; ok { - switch v := val.(type) { - case string: - return v - case fmt.Stringer: - return v.String() - } - } - } - return "" -} - -func readInt(m map[string]any, keys ...string) int { - for _, key := range keys { - if val, ok := m[key]; ok { - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case uint64: - return int(v) - case uint32: - return int(v) - case float32: - return int(v) - case string: - if parsed, err := parseInt(v); err == nil { - return parsed - } - } - } - } - return 0 -} -func readBool(m map[string]any, key string) bool { - val, ok := m[key] - if !ok { - return false - } - switch v := val.(type) { - case bool: - return v - case string: - return strings.EqualFold(v, "true") || v == "1" - default: - return false - } -} - -func readStringList(m map[string]any, key string) []string { - val, ok := m[key] - if !ok { - return nil - } - switch v := val.(type) { - case []string: - return v - case []any: - var out []string - for _, item := range v { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - case string: - if v == "" { - return nil + name := readString(proxyMap, "name") + if name != "" { + node.Name = name + } else if node.Name == "" { + node.Name = fmt.Sprintf("%s-%v:%v", node.Type, proxyMap["server"], proxyMap["port"]) } - return []string{v} - default: - return nil - } -} -func asStringMap(val any) map[string]any { - switch v := val.(type) { - case map[string]any: - return v - case map[any]any: - out := make(map[string]any, len(v)) - for key, value := range v { - out[fmt.Sprint(key)] = value - } - return out - default: - return nil + nodes = append(nodes, node) } -} -func normalizeStringMap(input map[string]any) map[string]string { - if len(input) == 0 { - return nil - } - out := make(map[string]string, len(input)) - for key, val := range input { - switch v := val.(type) { - case string: - out[key] = v - default: - out[key] = fmt.Sprint(v) - } + if len(nodes) == 0 { + return nil, fmt.Errorf("no supported proxies found") } - return out -} - -func parseInt(value string) (int, error) { - var out int - _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &out) - return out, err + return nodes, nil } -// parseBase64URI 解析 base64 编码的 URI 订阅格式 -// 格式: base64(vmess://...\nvless://...\n...) func parseBase64URI(content []byte) ([]Node, error) { - // 尝试 base64 解码 decoded, err := base64.StdEncoding.DecodeString(string(content)) if err != nil { - // 可能已经是解码后的内容 decoded = content } - // 按行分割 lines := strings.Split(string(decoded), "\n") var nodes []Node @@ -442,455 +147,29 @@ func parseBase64URI(content []byte) ([]Node, error) { continue } - node, err := parseProxyURI(line) - if err != nil { - logger.Debug("Skipping invalid URI", "uri", line[:min(len(line), 50)], "error", err.Error()) + idx := strings.Index(line, "://") + if idx < 0 { continue } - nodes = append(nodes, node) - } - - if len(nodes) == 0 { - return nil, fmt.Errorf("no valid proxy URIs found") - } - return nodes, nil -} - -// parseProxyURI 解析单个代理 URI -func parseProxyURI(uri string) (Node, error) { - // 解析 scheme - idx := strings.Index(uri, "://") - if idx < 0 { - return Node{}, fmt.Errorf("invalid URI format") - } - - scheme := strings.ToLower(uri[:idx]) - rest := uri[idx+3:] - - switch scheme { - case "vmess": - return parseVMessURI(rest) - case "vless": - return parseVLessURI(rest) - case "trojan": - return parseTrojanURI(rest) - case "ss", "shadowsocks": - return parseShadowsocksURI(rest) - case "hysteria": - return parseHysteriaURI(rest) - case "hysteria2", "hy2": - return parseHysteria2URI(rest) - default: - return Node{}, fmt.Errorf("unsupported URI scheme: %s", scheme) - } -} - -// parseVLessURI 解析 vless:// URI -func parseVLessURI(uri string) (Node, error) { - // 格式: vless://uuid@server:port?params#name - u, err := url.Parse("vless://" + uri) - if err != nil { - return Node{}, err - } - - uuid := u.User.Username() - server := u.Hostname() - port := u.Port() - name := u.Fragment - query := u.Query() - - if uuid == "" || server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") - } - - portNum, _ := parseInt(port) - outbound := map[string]any{ - "type": "vless", - "server": server, - "server_port": portNum, - "uuid": uuid, - } - - // 解析参数 - if flow := query.Get("flow"); flow != "" { - outbound["flow"] = flow - } - - // TLS 配置 - if security := query.Get("security"); security == "tls" || security == "reality" { - tls := map[string]any{"enabled": true} - if sni := query.Get("sni"); sni != "" { - tls["server_name"] = sni - } - if fp := query.Get("fp"); fp != "" { - tls["utls"] = map[string]any{ - "enabled": true, - "fingerprint": fp, - } - } - - // Reality 配置 - if security == "reality" { - reality := map[string]any{"enabled": true} - if pbk := query.Get("pbk"); pbk != "" { - reality["public_key"] = pbk - } - if sid := query.Get("sid"); sid != "" { - reality["short_id"] = sid - } - tls["reality"] = reality - } - outbound["tls"] = tls - } - - // 传输配置 - if network := query.Get("type"); network != "" { - applyURITransport(outbound, network, query) - } - - if name == "" { - name = fmt.Sprintf("vless-%s:%s", server, port) - } - - return Node{ - Name: name, - Type: "vless", - Outbound: outbound, - }, nil -} - -// parseVMessURI 解析 vmess:// URI (通常是 base64 编码的 JSON) -func parseVMessURI(uri string) (Node, error) { - // VMess URI 通常是 base64 编码的 JSON - decoded, err := base64.StdEncoding.DecodeString(uri) - if err != nil { - return Node{}, fmt.Errorf("invalid vmess URI: %w", err) - } - - var vmessConfig map[string]any - if err := json.Unmarshal(decoded, &vmessConfig); err != nil { - return Node{}, fmt.Errorf("invalid vmess config: %w", err) - } - - server := readString(vmessConfig, "add", "address") - port := readInt(vmessConfig, "port") - uuid := readString(vmessConfig, "id", "uuid") - name := readString(vmessConfig, "ps", "name") - - if server == "" || port == 0 || uuid == "" { - return Node{}, fmt.Errorf("missing required fields") - } - - outbound := map[string]any{ - "type": "vmess", - "server": server, - "server_port": port, - "uuid": uuid, - "security": readString(vmessConfig, "scy", "security"), - } - - if outbound["security"] == "" { - outbound["security"] = "auto" - } - - if alterID := readInt(vmessConfig, "aid", "alterId"); alterID > 0 { - outbound["alter_id"] = alterID - } - - // TLS - if tls := readString(vmessConfig, "tls"); tls == "tls" { - tlsConfig := map[string]any{"enabled": true} - if sni := readString(vmessConfig, "sni"); sni != "" { - tlsConfig["server_name"] = sni - } - outbound["tls"] = tlsConfig - } - - // 传输 - if network := readString(vmessConfig, "net", "network"); network != "" { - transport := map[string]any{"type": network} - - switch network { - case "ws": - if path := readString(vmessConfig, "path"); path != "" { - transport["path"] = path - } - if host := readString(vmessConfig, "host"); host != "" { - transport["headers"] = map[string]string{"Host": host} - } - case "grpc": - if serviceName := readString(vmessConfig, "path", "serviceName"); serviceName != "" { - transport["service_name"] = serviceName - } - } - - outbound["transport"] = transport - } - - if name == "" { - name = fmt.Sprintf("vmess-%s:%d", server, port) - } - - return Node{ - Name: name, - Type: "vmess", - Outbound: outbound, - }, nil -} - -// parseTrojanURI 解析 trojan:// URI -func parseTrojanURI(uri string) (Node, error) { - // 格式: trojan://password@server:port?params#name - u, err := url.Parse("trojan://" + uri) - if err != nil { - return Node{}, err - } - - password := u.User.Username() - server := u.Hostname() - port := u.Port() - name := u.Fragment - query := u.Query() - - if password == "" || server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") - } - - portNum, _ := parseInt(port) - outbound := map[string]any{ - "type": "trojan", - "server": server, - "server_port": portNum, - "password": password, - } - - // TLS 配置 - if security := query.Get("security"); security == "tls" || query.Get("tls") == "1" { - tls := map[string]any{"enabled": true} - if sni := query.Get("sni"); sni != "" { - tls["server_name"] = sni - } - outbound["tls"] = tls - } - - // 传输配置 - if network := query.Get("type"); network != "" { - applyURITransport(outbound, network, query) - } - - if name == "" { - name = fmt.Sprintf("trojan-%s:%s", server, port) - } - - return Node{ - Name: name, - Type: "trojan", - Outbound: outbound, - }, nil -} - -// parseShadowsocksURI 解析 ss:// URI -func parseShadowsocksURI(uri string) (Node, error) { - // 格式: ss://base64(method:password)@server:port#name - // 或: ss://method:password@server:port#name - - parts := strings.SplitN(uri, "@", 2) - if len(parts) != 2 { - return Node{}, fmt.Errorf("invalid ss URI format") - } - - // 尝试解码第一部分 - methodPassword := parts[0] - decoded, err := base64.URLEncoding.DecodeString(methodPassword) - if err == nil { - methodPassword = string(decoded) - } - - mpParts := strings.SplitN(methodPassword, ":", 2) - if len(mpParts) != 2 { - return Node{}, fmt.Errorf("invalid method:password format") - } - - method := mpParts[0] - password := mpParts[1] - - // 解析服务器和端口 - serverPart := parts[1] - hashIdx := strings.Index(serverPart, "#") - name := "" - if hashIdx >= 0 { - name, _ = url.QueryUnescape(serverPart[hashIdx+1:]) - serverPart = serverPart[:hashIdx] - } - - spParts := strings.SplitN(serverPart, ":", 2) - if len(spParts) != 2 { - return Node{}, fmt.Errorf("invalid server:port format") - } - - server := spParts[0] - port, _ := parseInt(spParts[1]) - - if name == "" { - name = fmt.Sprintf("ss-%s:%d", server, port) - } - - return Node{ - Name: name, - Type: "shadowsocks", - Outbound: map[string]any{ - "type": "shadowsocks", - "server": server, - "server_port": port, - "method": method, - "password": password, - }, - }, nil -} - -// parseHysteriaURI 解析 hysteria:// URI -func parseHysteriaURI(uri string) (Node, error) { - u, err := url.Parse("hysteria://" + uri) - if err != nil { - return Node{}, err - } - - auth := u.User.Username() - server := u.Hostname() - port := u.Port() - name := u.Fragment - query := u.Query() - - if server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") - } - - portNum, _ := parseInt(port) - outbound := map[string]any{ - "type": "hysteria", - "server": server, - "server_port": portNum, - } - - if auth != "" { - outbound["auth_str"] = auth - } - - if upMbps := query.Get("up"); upMbps != "" { - if up, _ := parseInt(upMbps); up > 0 { - outbound["up_mbps"] = up - } - } - if downMbps := query.Get("down"); downMbps != "" { - if down, _ := parseInt(downMbps); down > 0 { - outbound["down_mbps"] = down + scheme := strings.ToLower(line[:idx]) + a, err := GetAdapter(scheme) + if err != nil { + logger.Debug("Skipping proxy node", "scheme", scheme, "error", err.Error()) + continue } - } - - // TLS - tls := map[string]any{"enabled": true} - if sni := query.Get("sni"); sni != "" { - tls["server_name"] = sni - } - if query.Get("insecure") == "1" { - tls["insecure"] = true - } - outbound["tls"] = tls - - if name == "" { - name = fmt.Sprintf("hysteria-%s:%s", server, port) - } - - return Node{ - Name: name, - Type: "hysteria", - Outbound: outbound, - }, nil -} -// parseHysteria2URI 解析 hysteria2:// URI -func parseHysteria2URI(uri string) (Node, error) { - u, err := url.Parse("hysteria2://" + uri) - if err != nil { - return Node{}, err - } - - password := u.User.Username() - server := u.Hostname() - port := u.Port() - name := u.Fragment - query := u.Query() - - if password == "" || server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") - } - - portNum, _ := parseInt(port) - outbound := map[string]any{ - "type": "hysteria2", - "server": server, - "server_port": portNum, - "password": password, - } - - if upMbps := query.Get("up"); upMbps != "" { - if up, _ := parseInt(upMbps); up > 0 { - outbound["up_mbps"] = up - } - } - if downMbps := query.Get("down"); downMbps != "" { - if down, _ := parseInt(downMbps); down > 0 { - outbound["down_mbps"] = down + node, err := a.FromURI(line[idx+3:]) + if err != nil { + logger.Debug("Failed to parse URI node", "scheme", scheme, "error", err.Error()) + continue } - } - - // TLS - tls := map[string]any{"enabled": true} - if sni := query.Get("sni"); sni != "" { - tls["server_name"] = sni - } - if query.Get("insecure") == "1" { - tls["insecure"] = true - } - outbound["tls"] = tls - - if name == "" { - name = fmt.Sprintf("hysteria2-%s:%s", server, port) - } - - return Node{ - Name: name, - Type: "hysteria2", - Outbound: outbound, - }, nil -} - -// applyURITransport 应用 URI 查询参数中的传输配置 -func applyURITransport(outbound map[string]any, network string, query url.Values) { - transport := map[string]any{"type": network} - switch network { - case "ws": - if path := query.Get("path"); path != "" { - transport["path"] = path - } - if host := query.Get("host"); host != "" { - transport["headers"] = map[string]string{"Host": host} - } - case "grpc": - if serviceName := query.Get("serviceName"); serviceName != "" { - transport["service_name"] = serviceName - } + nodes = append(nodes, node) } - outbound["transport"] = transport -} - -// min helper function -func min(a, b int) int { - if a < b { - return a + if len(nodes) == 0 { + return nil, fmt.Errorf("no valid proxy URIs found") } - return b + return nodes, nil } diff --git a/internal/proxy/config/subscription/utils.go b/internal/proxy/config/subscription/utils.go new file mode 100644 index 0000000..e2dda41 --- /dev/null +++ b/internal/proxy/config/subscription/utils.go @@ -0,0 +1,224 @@ +package subscription + +import ( + "fmt" + "strings" +) + +func readString(m map[string]any, keys ...string) string { + for _, key := range keys { + if key == "" { + continue + } + if val, ok := m[key]; ok { + switch v := val.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + } + } + } + return "" +} + +func readInt(m map[string]any, keys ...string) int { + for _, key := range keys { + if val, ok := m[key]; ok { + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case uint64: + return int(v) + case uint32: + return int(v) + case float32: + return int(v) + case string: + if parsed, err := parseInt(v); err == nil { + return parsed + } + } + } + } + return 0 +} + +func readBool(m map[string]any, key string) bool { + val, ok := m[key] + if !ok { + return false + } + switch v := val.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") || v == "1" + default: + return false + } +} + +func readStringList(m map[string]any, key string) []string { + val, ok := m[key] + if !ok { + return nil + } + switch v := val.(type) { + case []string: + return v + case []any: + var out []string + for _, item := range v { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + case string: + if v == "" { + return nil + } + return []string{v} + default: + return nil + } +} + +func asStringMap(val any) map[string]any { + switch v := val.(type) { + case map[string]any: + return v + case map[any]any: + out := make(map[string]any, len(v)) + for key, value := range v { + out[fmt.Sprint(key)] = value + } + return out + default: + return nil + } +} + +func normalizeStringMap(input map[string]any) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for key, val := range input { + switch v := val.(type) { + case string: + out[key] = v + default: + out[key] = fmt.Sprint(v) + } + } + return out +} + +func parseInt(value string) (int, error) { + var out int + _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &out) + return out, err +} + +func ApplyTLSOptions(outbound map[string]any, m map[string]any) { + tlsEnabled := readBool(m, "tls") + sni := readString(m, "sni", "servername", "server_name") + skipVerify := readBool(m, "skip-cert-verify") + alpn := readStringList(m, "alpn") + clientFingerprint := readString(m, "client-fingerprint") + + realityOpts := asStringMap(m["reality-opts"]) + + if !tlsEnabled && sni == "" && len(alpn) == 0 && realityOpts == nil && clientFingerprint == "" { + return + } + + tls := map[string]any{"enabled": true} + if sni != "" { + tls["server_name"] = sni + } + if skipVerify { + tls["insecure"] = true + } + if len(alpn) > 0 { + tls["alpn"] = alpn + } + if clientFingerprint != "" { + tls["utls"] = map[string]any{ + "enabled": true, + "fingerprint": clientFingerprint, + } + } + + if realityOpts != nil { + reality := map[string]any{"enabled": true} + if publicKey := readString(realityOpts, "public-key", "public_key"); publicKey != "" { + reality["public_key"] = publicKey + } + if shortID := readString(realityOpts, "short-id", "short_id"); shortID != "" { + reality["short_id"] = shortID + } + tls["reality"] = reality + } + + outbound["tls"] = tls +} + +func ApplyTransportOptions(outbound map[string]any, m map[string]any) { + network := strings.ToLower(readString(m, "network")) + switch network { + case "ws", "websocket": + wsOpts := asStringMap(m["ws-opts"]) + transport := map[string]any{"type": "ws"} + if wsOpts != nil { + if path := readString(wsOpts, "path"); path != "" { + transport["path"] = path + } + headers := asStringMap(wsOpts["headers"]) + if len(headers) > 0 { + transport["headers"] = normalizeStringMap(headers) + } + } + outbound["transport"] = transport + case "grpc": + grpcOpts := asStringMap(m["grpc-opts"]) + transport := map[string]any{"type": "grpc"} + if grpcOpts != nil { + if service := readString(grpcOpts, "grpc-service-name", "service-name"); service != "" { + transport["service_name"] = service + } + } + outbound["transport"] = transport + } +} + +func ApplyURITransport(outbound map[string]any, network string, query map[string][]string) { + getQuery := func(k string) string { + if v := query[k]; len(v) > 0 { + return v[0] + } + return "" + } + + transport := map[string]any{"type": network} + switch network { + case "ws": + if path := getQuery("path"); path != "" { + transport["path"] = path + } + if host := getQuery("host"); host != "" && host != outbound["server"] { + transport["headers"] = map[string]string{"Host": host} + } + case "grpc": + if serviceName := getQuery("serviceName"); serviceName != "" { + transport["service_name"] = serviceName + } + } + outbound["transport"] = transport +} From c65214625ac661dce3a3ecb24335e98f951b20f5 Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 01:11:41 +0800 Subject: [PATCH 13/23] refactor(proxy): abstract routing constants and default rule fragments --- internal/proxy/config/module/constants.go | 10 ++ internal/proxy/config/module/naming.go | 9 +- internal/proxy/config/module/outbound.go | 22 +-- internal/proxy/config/module/route.go | 188 ++++++++++++---------- internal/proxy/config/module/tun.go | 2 +- 5 files changed, 130 insertions(+), 101 deletions(-) create mode 100644 internal/proxy/config/module/constants.go diff --git a/internal/proxy/config/module/constants.go b/internal/proxy/config/module/constants.go new file mode 100644 index 0000000..ddefeca --- /dev/null +++ b/internal/proxy/config/module/constants.go @@ -0,0 +1,10 @@ +package module + +// Well-known outbound tags +const ( + TagDirect = "direct" + TagBlock = "block" + TagProxy = "proxy" + TagAuto = "auto" + TagDNS = "dns-out" +) diff --git a/internal/proxy/config/module/naming.go b/internal/proxy/config/module/naming.go index 94edf9c..3f20566 100644 --- a/internal/proxy/config/module/naming.go +++ b/internal/proxy/config/module/naming.go @@ -7,10 +7,11 @@ import ( ) var reservedOutboundTags = map[string]bool{ - "direct": true, - "block": true, - "proxy": true, - "auto": true, + TagDirect: true, + TagBlock: true, + TagProxy: true, + TagAuto: true, + TagDNS: true, } func IsReservedOutboundTag(tag string) bool { diff --git a/internal/proxy/config/module/outbound.go b/internal/proxy/config/module/outbound.go index 066e848..cd7db82 100644 --- a/internal/proxy/config/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -76,8 +76,8 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // 5. 添加 direct 出站 directOutbound := option.Outbound{} directOutboundMap := map[string]any{ - "type": "direct", - "tag": "direct", + "type": TagDirect, + "tag": TagDirect, } ApplyMapToOutbound(&directOutbound, directOutboundMap) filteredOutbounds = append(filteredOutbounds, directOutbound) @@ -85,8 +85,8 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // 6. 添加 block 出站 blockOutbound := option.Outbound{} blockOutboundMap := map[string]any{ - "type": "block", - "tag": "block", + "type": TagBlock, + "tag": TagBlock, } ApplyMapToOutbound(&blockOutbound, blockOutboundMap) filteredOutbounds = append(filteredOutbounds, blockOutbound) @@ -98,13 +98,13 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // - proxy: selector [auto, ...all nodes] // 7. 添加 proxy selector - proxyNodes := append([]string{"auto"}, actualNodes...) + proxyNodes := append([]string{TagAuto}, actualNodes...) proxyOutbound := option.Outbound{} proxyOutboundMap := map[string]any{ "type": "selector", - "tag": "proxy", + "tag": TagProxy, "outbounds": proxyNodes, - "default": "auto", + "default": TagAuto, } ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) @@ -113,7 +113,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { autoOutbound := option.Outbound{} autoOutboundMap := map[string]any{ "type": "urltest", - "tag": "auto", + "tag": TagAuto, "outbounds": actualNodes, } ApplyMapToOutbound(&autoOutbound, autoOutboundMap) @@ -126,9 +126,9 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { proxyOutbound := option.Outbound{} proxyOutboundMap := map[string]any{ "type": "selector", - "tag": "proxy", - "outbounds": []string{"direct"}, - "default": "direct", + "tag": TagProxy, + "outbounds": []string{TagDirect}, + "default": TagDirect, } ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) diff --git a/internal/proxy/config/module/route.go b/internal/proxy/config/module/route.go index ee751a2..4df0d94 100644 --- a/internal/proxy/config/module/route.go +++ b/internal/proxy/config/module/route.go @@ -7,7 +7,7 @@ import ( ) // RouteModule 路由模块 -// 负责配置路由规则,支持 RouteMode +// 负责组装和构建动态路由协议栈,通过拼装不同的 RouteFragment 来实现灵活的规则扩展 type RouteModule struct { RouteMode model.RouteMode } @@ -17,110 +17,128 @@ func (m *RouteModule) Name() string { } func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 如果用户没有配置路由,使用默认路由 if opts.Route == nil { - defaultRoute, err := m.generateDefaultRoute() - if err != nil { - return err - } - opts.Route = defaultRoute + opts.Route = &option.RouteOptions{} } - // 根据 RouteMode 调整路由 + // 1. 如果用户没有自定义 final 出站,设置默认 + if opts.Route.Final == "" { + opts.Route.Final = TagProxy + } + + // 2. 将全局/直连模式转化为更高级别的劫持 switch m.RouteMode { case model.RouteModeGlobal: - // 全局代理:清空所有路由规则,直接走 proxy - // 保留 RuleSet 以供 DNS 规则使用 - opts.Route.Rules = nil - opts.Route.Final = "proxy" + // 全局代理:覆盖前面的默认 Final + opts.Route.Final = TagProxy + // 但我们需要保留 DNS 和局域网绕过的规则,因此我们仍然应用 default 规则 case model.RouteModeDirect: - // 全局直连:清空所有路由规则,直接走 direct - // 保留 RuleSet 以供 DNS 规则使用 + // 全局直连:所有流量默认直连 + opts.Route.Final = TagDirect + } + + // 3. 构建并应用默认扩展拼图 (当用户没有完全接管路由时) + // 如果用户自己配了 rule_set,我们尽量把系统必备的加到后面。 + if len(opts.Route.Rules) == 0 { + m.applyDefaultFragments(opts) + } + + // 4. 清空特定模式下的所有非必要规则 + // 对于全局/直连,我们可以强制清空普通路由 + if m.RouteMode == model.RouteModeGlobal || m.RouteMode == model.RouteModeDirect { opts.Route.Rules = nil - opts.Route.Final = "direct" - case model.RouteModeRule, "": - // rule 模式保持用户配置的路由规则 - if opts.Route.Final == "" { - opts.Route.Final = "proxy" // 默认 final 走代理 - } } return nil } -// generateDefaultRoute 生成默认路由规则 -// 当用户没有配置 Route 时使用 -func (m *RouteModule) generateDefaultRoute() (*option.RouteOptions, error) { +// applyDefaultFragments 组装默认的开箱即用路由规则 +func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { + var ruleSets []map[string]any + var rules []map[string]any + + // 片段 1: 局域网直连 (必须最优先) + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": TagDirect}) + + // 片段 2: NTP 直连 + rules = append(rules, map[string]any{"protocol": []string{"ntp"}, "outbound": TagDirect}) + + // 片段 3: DNS 流量专门劫持 (在 TUN/Mixed 模式中,由 sing-box 本地解析) + rules = append(rules, map[string]any{"protocol": []string{"dns"}, "action": "hijack-dns"}) + // 针对 ali dns 放行(因为在 DNS 模块中配置了国内直接去 ali 解析,避免循环) + rules = append(rules, map[string]any{"ip_cidr": []string{"223.5.5.5/32", "223.6.6.6/32", "2400:3200::/32"}, "outbound": TagDirect}) + + // 片段 4: 去广告模块 + ruleSets = append(ruleSets, map[string]any{ + "tag": "geosite-ads", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", + "download_detour": TagProxy, + }) + ruleSets = append(ruleSets, map[string]any{ + "tag": "anti-ad", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs", + "download_detour": TagProxy, + }) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-ads", "anti-ad"}, "outbound": TagBlock}) + + // 片段 5: 直连白名单 + rules = append(rules, map[string]any{ + "domain_suffix": []string{"wise.com", "schwab.com", "interactivebrokers.com", "cloudflare.com", + "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, + "outbound": TagDirect, + }) + + // 片段 6: Apple 流量直连 + ruleSets = append(ruleSets, map[string]any{ + "tag": "geosite-apple", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs", + "download_detour": TagProxy, + }) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-apple"}, "outbound": TagDirect}) + + // 片段 7: 国内直连 (CN 路由分流) + ruleSets = append(ruleSets, map[string]any{ + "tag": "geosite-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", + "download_detour": TagProxy, + }) + ruleSets = append(ruleSets, map[string]any{ + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": TagProxy, + }) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-cn", "geoip-cn"}, "outbound": TagDirect}) + + // 此时组合成一个整体 map 进行反序列化 (为了兼容 sing-box 的 rule 抽象类型) routeMap := map[string]any{ - "rule_set": []map[string]any{ - { - "tag": "geosite-cn", - "type": "remote", - "format": "binary", - "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", - "download_detour": "proxy", - }, - { - "tag": "geoip-cn", - "type": "remote", - "format": "binary", - "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", - "download_detour": "proxy", - }, - { - "tag": "geosite-apple", - "type": "remote", - "format": "binary", - "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs", - "download_detour": "proxy", - }, - { - "tag": "geosite-ads", - "type": "remote", - "format": "binary", - "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", - "download_detour": "proxy", - }, - { - "tag": "anti-ad", - "type": "remote", - "format": "binary", - "url": "https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs", - "download_detour": "proxy", - }, - }, - "rules": []map[string]any{ - // 直连白名单 - {"domain_suffix": []string{"wise.com", "schwab.com", "interactivebrokers.com", "cloudflare.com", - "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, "outbound": "direct"}, - // 广告屏蔽 - {"rule_set": []string{"geosite-ads", "anti-ad"}, "outbound": "block"}, - // 1. DNS 劫持 - {"protocol": []string{"dns"}, "action": "hijack-dns"}, - // 1.1 AliDNS upstream (avoid proxy latency) - {"ip_cidr": []string{"223.5.5.5/32", "223.6.6.6/32", "2400:3200::/32"}, "outbound": "direct"}, - // 2. NTP 直连 - {"protocol": []string{"ntp"}, "outbound": "direct"}, - // 4. 私有 IP 直连 - {"ip_is_private": true, "outbound": "direct"}, - // 7. Apple 直连 - {"rule_set": []string{"geosite-apple"}, "outbound": "direct"}, - // 6. CN 直连 (含媒体、社交、娱乐) - {"rule_set": []string{"geosite-cn", "geoip-cn"}, "outbound": "direct"}, - }, - "final": "proxy", + "rule_set": ruleSets, + "rules": rules, "auto_detect_interface": true, } data, err := singboxjson.Marshal(routeMap) if err != nil { - return nil, err + return err } - var routeOpts option.RouteOptions - if err := singboxjson.Unmarshal(data, &routeOpts); err != nil { - return nil, err + var generatedRoute option.RouteOptions + if err := singboxjson.Unmarshal(data, &generatedRoute); err != nil { + return err } - return &routeOpts, nil + opts.Route.RuleSet = append(opts.Route.RuleSet, generatedRoute.RuleSet...) + opts.Route.Rules = append(opts.Route.Rules, generatedRoute.Rules...) + opts.Route.AutoDetectInterface = generatedRoute.AutoDetectInterface + + return nil } diff --git a/internal/proxy/config/module/tun.go b/internal/proxy/config/module/tun.go index 7fd2942..c75a537 100644 --- a/internal/proxy/config/module/tun.go +++ b/internal/proxy/config/module/tun.go @@ -76,7 +76,7 @@ func (m *TUNDNSModule) Apply(opts *option.Options, ctx *BuildContext) error { "type": "https", "server": "dns.google", "domain_resolver": "resolver_dns", - "detour": "proxy", + "detour": TagProxy, }, { "tag": "resolver_dns", From 6dccd17365f91d8b76f756992554689f7fd05a6e Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 01:27:05 +0800 Subject: [PATCH 14/23] refactor(proxy): decouple adapter from subscription to solve import cycles & clean up module design --- internal/app/cli/config.go | 71 +++--- internal/app/cli/config_ops.go | 204 ++++++++---------- internal/proxy/config/config.go | 10 +- internal/proxy/config/module/outbound.go | 79 ++----- internal/proxy/config/module/processor.go | 199 +++++++---------- internal/proxy/config/module/provider.go | 15 +- .../config/module/provider_subscription.go | 7 +- internal/proxy/config/module/provider_user.go | 73 ++++--- internal/proxy/config/node/node.go | 9 + .../adapter/hysteria.go} | 68 +++--- .../proxy/config/parser/adapter/registry.go | 35 +++ .../adapter/ss_trojan.go} | 62 +++--- .../{subscription => parser/adapter}/utils.go | 48 ++--- .../adapter_vless.go => parser/adapter/v.go} | 82 +++---- .../config/{subscription => parser}/parse.go | 80 ++++--- .../config/subscription/adapter_registry.go | 31 --- internal/proxy/config/subscription/merge.go | 127 ++++++----- internal/proxy/config/subscription/refresh.go | 70 +++--- internal/proxy/config/subscription/storage.go | 142 +++++------- internal/proxy/config/subscription/types.go | 55 +---- 20 files changed, 702 insertions(+), 765 deletions(-) create mode 100644 internal/proxy/config/node/node.go rename internal/proxy/config/{subscription/adapter_hysteria.go => parser/adapter/hysteria.go} (62%) create mode 100644 internal/proxy/config/parser/adapter/registry.go rename internal/proxy/config/{subscription/adapter_ss_trojan.go => parser/adapter/ss_trojan.go} (64%) rename internal/proxy/config/{subscription => parser/adapter}/utils.go (74%) rename internal/proxy/config/{subscription/adapter_vless.go => parser/adapter/v.go} (63%) rename internal/proxy/config/{subscription => parser}/parse.go (63%) delete mode 100644 internal/proxy/config/subscription/adapter_registry.go diff --git a/internal/app/cli/config.go b/internal/app/cli/config.go index c14bace..5e413b5 100644 --- a/internal/app/cli/config.go +++ b/internal/app/cli/config.go @@ -3,8 +3,10 @@ package cli import ( "fmt" "os" + "path/filepath" "strings" + "github.com/kyson-dev/sing-helm/internal/proxy/config/parser" "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" @@ -74,29 +76,40 @@ func newConfigAddCommand() *cobra.Command { return fmt.Errorf("url cannot be empty") } - paths := paths.Get() - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { + p := paths.Get() + if err := os.MkdirAll(p.SubConfigDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.MkdirAll(p.SubCacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + sources, err := subscription.LoadSources(p.SubConfigDir) + if err != nil && !os.IsNotExist(err) { return err } + for _, s := range sources { + if s.Name == name { + return fmt.Errorf("subscription already exists: %s", name) + } + } + source := subscription.Source{ Name: name, URL: url, - Format: format, + Format: parser.NormalizeFormat(format), Priority: priority, Enabled: &enabled, Dedupe: &dedupe, } + sources = append(sources, source) - path := subscription.SourceFilePath(paths.SubConfigDir, name) - if _, err := os.Stat(path); err == nil { - return fmt.Errorf("subscription already exists: %s", name) - } - if err := subscription.SaveSourceFile(path, source); err != nil { + if err := subscription.SaveSources(p.SubConfigDir, sources); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Saved: %s\n", path) + fmt.Fprintf(cmd.OutOrStdout(), "Saved: %s\n", filepath.Join(p.SubConfigDir, "sources.yaml")) return nil }, } @@ -113,13 +126,16 @@ func newConfigEditCommand() *cobra.Command { Short: "Edit base config or a subscription file", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := paths.Get() - target := paths.ConfigFile + p := paths.Get() + target := p.ConfigFile if len(args) == 1 { - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err + if err := os.MkdirAll(p.SubConfigDir, 0755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + if err := os.MkdirAll(p.SubCacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache dir: %w", err) } - target = subscription.SourceFilePath(paths.SubConfigDir, strings.TrimSpace(args[0])) + target = filepath.Join(p.SubConfigDir, "sources.yaml") } return openInEditor(cmd, target) }, @@ -132,20 +148,23 @@ func newConfigRefreshCommand() *cobra.Command { Short: "Refresh subscription cache", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - paths := paths.Get() - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err + p := paths.Get() + if err := os.MkdirAll(p.SubConfigDir, 0755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + if err := os.MkdirAll(p.SubCacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache dir: %w", err) } if len(args) == 0 || strings.EqualFold(args[0], "all") { - return refreshAllSubscriptions(cmd, paths.SubConfigDir, paths.SubCacheDir) + return refreshAllSubscriptions(cmd, p.SubConfigDir, p.SubCacheDir) } name := strings.TrimSpace(args[0]) if name == "" { return fmt.Errorf("name cannot be empty") } - return refreshOneSubscription(cmd, name, paths.SubConfigDir, paths.SubCacheDir) + return refreshOneSubscription(cmd, name, p.SubConfigDir, p.SubCacheDir) }, } } @@ -156,21 +175,23 @@ func newConfigDeleteCommand() *cobra.Command { Short: "Delete a subscription config and cache", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - paths := paths.Get() - // 确保目录存在(虽然我们要删除东西,但如果目录都不存在也就没什么好删的,不过为了路径构建不出错) - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err + p := paths.Get() + if err := os.MkdirAll(p.SubConfigDir, 0755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + if err := os.MkdirAll(p.SubCacheDir, 0755); err != nil { + return fmt.Errorf("failed to create cache dir: %w", err) } if strings.EqualFold(args[0], "all") { - return deleteAllSubscriptions(cmd, paths.SubConfigDir, paths.SubCacheDir) + return deleteAllSubscriptions(cmd, p.SubConfigDir, p.SubCacheDir) } name := strings.TrimSpace(args[0]) if name == "" { return fmt.Errorf("name cannot be empty") } - return deleteOneSubscription(cmd, name, paths.SubConfigDir, paths.SubCacheDir) + return deleteOneSubscription(cmd, name, p.SubConfigDir, p.SubCacheDir) }, } } diff --git a/internal/app/cli/config_ops.go b/internal/app/cli/config_ops.go index 53de0bc..a4eaace 100644 --- a/internal/app/cli/config_ops.go +++ b/internal/app/cli/config_ops.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "os" "os/exec" @@ -12,174 +13,147 @@ import ( "github.com/spf13/cobra" ) -func runConfigList(cmd *cobra.Command, _ []string) error { +func runConfigList(cmd *cobra.Command, args []string) error { //nolint:unparam paths := paths.Get() - out := cmd.OutOrStdout() + fmt.Fprintf(cmd.OutOrStdout(), "Base Config: %s\n", paths.ConfigFile) - fmt.Fprintf(out, "Base config: %s\n", paths.ConfigFile) - if _, err := os.Stat(paths.ConfigFile); os.IsNotExist(err) { - fmt.Fprintln(out, " (missing)") - } - - sources, err := subscription.LoadSources(paths.SubConfigDir) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err) - } - - fmt.Fprintln(out, "Subscriptions:") + sources, _ := subscription.LoadSources(paths.SubConfigDir) if len(sources) == 0 { - fmt.Fprintln(out, " (none)") + fmt.Fprintf(cmd.OutOrStdout(), "\nNo subscriptions found.\n") return nil } + fmt.Fprintf(cmd.OutOrStdout(), "\nSubscriptions (%d):\n", len(sources)) for _, source := range sources { status := "enabled" if !source.EnabledValue() { status = "disabled" } - cachePath := subscription.CacheFilePath(paths.SubCacheDir, source.Name) - cache, err := subscription.LoadCache(cachePath) - updated := "-" - nodes := 0 - if err == nil { - updated = cache.UpdatedAt - nodes = len(cache.Nodes) + var cacheInfo string + cachePath := filepath.Join(paths.SubCacheDir, source.Name+".json") + if cache, err := subscription.LoadCache(cachePath); err == nil { + cacheInfo = fmt.Sprintf("%d nodes, updated: %s", len(cache.Nodes), cache.UpdatedAt) + } else { + cacheInfo = "not cached" } - format := subscription.NormalizeFormat(source.Format) - fmt.Fprintf(out, "- %s %s format=%s nodes=%d updated=%s url=%s\n", - source.Name, status, format, nodes, updated, source.URL) + + fmt.Fprintf(cmd.OutOrStdout(), " - %s (%s, P%d): %s [%s]\n", + source.Name, status, source.Priority, source.URL, cacheInfo) } return nil } -func refreshAllSubscriptions(cmd *cobra.Command, dir, cacheDir string) error { - sources, err := subscription.LoadSources(dir) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %v\n", err) - } - - if len(sources) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No subscription configs found.") - return nil - } - - var failed []string - for _, source := range sources { - if !source.EnabledValue() { - continue - } - if err := refreshSource(cmd, source, cacheDir); err != nil { - failed = append(failed, source.Name) - fmt.Fprintf(cmd.ErrOrStderr(), "Failed to refresh %s: %v\n", source.Name, err) - } +func openInEditor(cmd *cobra.Command, path string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vim" // Default to vim if EDITOR not set } - if len(failed) > 0 { - return fmt.Errorf("refresh failed for: %s", strings.Join(failed, ", ")) + // 检查目标文件所属的 sources.yaml 中的 name + // 但是因为现在是 sources.yaml 了,不再是一个文件一个源码,所以我们应该打开 sources.yaml + if strings.Contains(path, "sources.yaml") { + // allow edit } - return nil -} -func refreshOneSubscription(cmd *cobra.Command, name, dir, cacheDir string) error { - path := subscription.SourceFilePath(dir, name) - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("subscription not found: %s", name) - } - return err - } + execCmd := exec.Command(editor, path) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr - source, err := subscription.LoadSourceFile(path) - if err != nil { - return err + if err := execCmd.Run(); err != nil { + return fmt.Errorf("failed to open editor %s: %w", editor, err) } - return refreshSource(cmd, source, cacheDir) -} -func refreshSource(cmd *cobra.Command, source subscription.Source, cacheDir string) error { - cache, err := subscription.RefreshSource(cmd.Context(), source, cacheDir) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Refreshed %s: %d nodes\n", source.Name, len(cache.Nodes)) - fmt.Fprintln(cmd.OutOrStdout(), "Restart sing-box to apply changes.") return nil } -func deleteAllSubscriptions(cmd *cobra.Command, dir, cacheDir string) error { - sources, err := subscription.LoadSources(dir) +func refreshAllSubscriptions(cmd *cobra.Command, configDir, cacheDir string) error { + sources, err := subscription.LoadSources(configDir) if err != nil { - return fmt.Errorf("failed to load sources: %w", err) + return err } - if len(sources) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No subscriptions found.") + fmt.Fprintf(cmd.OutOrStdout(), "No subscriptions to refresh.\n") return nil } - var failed []string for _, source := range sources { - if err := deleteOneSubscription(cmd, source.Name, dir, cacheDir); err != nil { - failed = append(failed, source.Name) - fmt.Fprintf(cmd.ErrOrStderr(), "Failed to delete %s: %v\n", source.Name, err) + if !source.EnabledValue() { + fmt.Fprintf(cmd.OutOrStdout(), "Skipping disabled subscription: %s\n", source.Name) + continue } - } - - if len(failed) > 0 { - return fmt.Errorf("delete failed for: %s", strings.Join(failed, ", ")) + if err := subscription.Refresh(context.Background(), source, cacheDir); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Failed to refresh %s: %v\n", source.Name, err) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), "Refreshed: %s\n", source.Name) } return nil } -func deleteOneSubscription(cmd *cobra.Command, name, dir, cacheDir string) error { - configPath := subscription.SourceFilePath(dir, name) - cachePath := subscription.CacheFilePath(cacheDir, name) - - // Check if exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("subscription not found: %s", name) +func refreshOneSubscription(cmd *cobra.Command, name, configDir, cacheDir string) error { + sources, err := subscription.LoadSources(configDir) + if err != nil { + return err } - - // Remove config - if err := os.Remove(configPath); err != nil { - return fmt.Errorf("failed to remove config file: %w", err) + var targetSource *subscription.Source + for _, s := range sources { + if s.Name == name { + targetSource = &s + break + } + } + if targetSource == nil { + return fmt.Errorf("subscription not found: %s", name) } - // Remove cache (ignore error if not exists) - if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to remove cache file for %s: %v\n", name, err) + if err := subscription.Refresh(context.Background(), *targetSource, cacheDir); err != nil { + return fmt.Errorf("failed to refresh %s: %w", name, err) } + fmt.Fprintf(cmd.OutOrStdout(), "Refreshed: %s\n", name) + return nil +} - fmt.Fprintf(cmd.OutOrStdout(), "Deleted subscription: %s\n", name) +func deleteAllSubscriptions(cmd *cobra.Command, configDir, cacheDir string) error { + _ = os.Remove(filepath.Join(configDir, "sources.yaml")) + _ = os.RemoveAll(cacheDir) + fmt.Fprintf(cmd.OutOrStdout(), "Deleted all subscriptions.\n") return nil } -func openInEditor(cmd *cobra.Command, path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - fmt.Fprintf(cmd.OutOrStdout(), "Configuration file not found: %s\n", path) - fmt.Fprintln(cmd.OutOrStdout(), "It will be created when you save in the editor.") +func deleteOneSubscription(cmd *cobra.Command, name, configDir, cacheDir string) error { + sources, err := subscription.LoadSources(configDir) + if err != nil { // Ignore not exist + return err } - editor := os.Getenv("VISUAL") - if editor == "" { - editor = os.Getenv("EDITOR") + var newSources []subscription.Source + found := false + for _, s := range sources { + if s.Name == name { + found = true + continue + } + newSources = append(newSources, s) } - if editor == "" { - editor = "vi" + + if !found { + return fmt.Errorf("subscription not found: %s", name) } - fmt.Fprintf(cmd.OutOrStdout(), "Opening: %s\n", path) - fmt.Fprintf(cmd.OutOrStdout(), "Editor: %s\n\n", editor) + // Update sources.yaml + if len(newSources) == 0 { + _ = os.Remove(filepath.Join(configDir, "sources.yaml")) + } else { + if err := subscription.SaveSources(configDir, newSources); err != nil { + return err + } + } - editorArgs := strings.Fields(editor) - editorCmd := exec.Command(editorArgs[0], append(editorArgs[1:], filepath.Clean(path))...) - editorCmd.Stdin = os.Stdin - editorCmd.Stdout = os.Stdout - editorCmd.Stderr = os.Stderr + // Delete cache + _ = os.Remove(filepath.Join(cacheDir, name+".json")) - if err := editorCmd.Run(); err != nil { - return fmt.Errorf("failed to open editor: %w", err) - } + fmt.Fprintf(cmd.OutOrStdout(), "Deleted subscription: %s\n", name) return nil } diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 0239d46..63a430a 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -39,12 +39,10 @@ func DefaultModules(opts *model.RunOptions) []module.ConfigModule { } modules := []module.ConfigModule{ - &module.OutboundModule{ - Providers: []module.NodeProvider{ - &module.UserNodeProvider{}, - &module.SubscriptionNodeProvider{}, - }, - }, + module.NewOutboundModule( + &module.UserNodeProvider{}, + &module.SubscriptionNodeProvider{}, + ), } // 根据 ProxyMode 选择入站模块 diff --git a/internal/proxy/config/module/outbound.go b/internal/proxy/config/module/outbound.go index cd7db82..a7d8522 100644 --- a/internal/proxy/config/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -1,14 +1,18 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" ) // OutboundModule 出站模块 -// 负责处理所有 outbounds(用户配置 + 订阅节点),并补充系统 outbounds +// 负责组装和构建 proxy, direct, block 以及各种出站节点群 type OutboundModule struct { - Providers []NodeProvider + providers []NodeProvider +} + +// NewOutboundModule creates a new outbound module with the given providers. +func NewOutboundModule(providers ...NodeProvider) *OutboundModule { + return &OutboundModule{providers: providers} } func (m *OutboundModule) Name() string { @@ -16,63 +20,24 @@ func (m *OutboundModule) Name() string { } func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 1. 获取已有的 tags - usedTags := make(map[string]bool) - for _, out := range opts.Outbounds { - if out.Tag != "" { - usedTags[out.Tag] = true - } - } + processor := NewOutboundProcessor() - processor := NewOutboundProcessor(usedTags) - - // 2. 收集所有节点 - var nodes []Node - for _, p := range m.Providers { - pNodes, err := p.GetNodes() + // 1. 从所有 Provider 获取节点 + for _, provider := range m.providers { + nodes, err := provider.GetNodes() if err != nil { - logger.Error("Failed to get nodes from provider", "provider", p.Name(), "error", err) - continue - } - nodes = append(nodes, pNodes...) - } - - // 3. 按 Source 分组并放入 Processor - nodesBySource := map[string][]RawOutbound{} - for _, node := range nodes { - if _, ok := nodesBySource[node.Source]; !ok { - nodesBySource[node.Source] = make([]RawOutbound, 0) - } - outboundCopy := node.Outbound - if node.Name != "" { - outboundCopy["tag"] = node.Name + return err } - nodesBySource[node.Source] = append(nodesBySource[node.Source], RawOutbound(outboundCopy)) + processor.AddNodes(nodes) } - // 4. 处理分组的节点并收集最终生成的所有 outbounds 和真实的节点 tag 列表 - filteredOutbounds := []option.Outbound{} - actualNodes := []string{} + // 2. 获取去重且正确命名后的 proxy 出站节点 + filteredOutbounds := make([]option.Outbound, 0) + filteredOutbounds = append(filteredOutbounds, processor.GetProcessedOutbounds()...) - for source, rawOutbounds := range nodesBySource { - processed, err := processor.Process(rawOutbounds, source) - if err != nil { - logger.Error("Failed to process outbounds", "source", source, "error", err) - continue - } - for _, out := range processed { - if IsReservedOutboundTag(out.Tag) { - logger.Info("Ignoring reserved outbound tag from provider config", "tag", out.Tag, "source", source) - continue - } - filteredOutbounds = append(filteredOutbounds, out) - // 注意,这里的 IsActualOutboundType 需要处理 - if out.Type != "selector" && out.Type != "urltest" && out.Type != "direct" && out.Type != "block" && out.Type != "dns" { - actualNodes = append(actualNodes, out.Tag) - } - } - } + actualNodes := processor.GetActualTags() + // 3. 构建内置出站 // 5. 添加 direct 出站 directOutbound := option.Outbound{} directOutboundMap := map[string]any{ @@ -91,7 +56,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { ApplyMapToOutbound(&blockOutbound, blockOutboundMap) filteredOutbounds = append(filteredOutbounds, blockOutbound) - // 7 & 8. 添加 proxy selector 和 auto urltest + // 根据是否有实际节点决定如何配置 auto 和 proxy 策略组 if len(actualNodes) > 0 { // 有节点时的逻辑: // - auto: urltest [all nodes] @@ -120,9 +85,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { filteredOutbounds = append(filteredOutbounds, autoOutbound) } else { // 无节点时的逻辑: - // - proxy: selector [direct] (降级为直连) - // - 不创建 auto 组 (因为没有节点可以测速) - + // - proxy: selector [direct] proxyOutbound := option.Outbound{} proxyOutboundMap := map[string]any{ "type": "selector", @@ -134,7 +97,7 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { filteredOutbounds = append(filteredOutbounds, proxyOutbound) } - // 9. 更新最终的 outbounds + // 4. 将合并后的出站回填 opts.Outbounds = append(opts.Outbounds, filteredOutbounds...) return nil diff --git a/internal/proxy/config/module/processor.go b/internal/proxy/config/module/processor.go index c3d6c2d..45891a7 100644 --- a/internal/proxy/config/module/processor.go +++ b/internal/proxy/config/module/processor.go @@ -1,147 +1,114 @@ package module import ( - "context" + "strings" - "github.com/kyson-dev/sing-helm/internal/sys/logger" - "github.com/sagernet/sing-box/include" + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" ) -// OutboundProcessor 处理出站节点的通用逻辑 +// OutboundProcessor processes raw outbounds, manages tags, and prevents duplication globally. type OutboundProcessor struct { - UsedTags map[string]bool + usedTags map[string]bool + originalToTag map[string]map[string]string // source -> original name -> unique tag + processedNodes []option.Outbound + actualTags []string // purely the tags of actual nodes (vless, trojan, etc.) + + // sourceGroups maps source names (or 'user') to their nodes' tags. Useful for grouping. + sourceGroups map[string][]string } -// NewOutboundProcessor 创建处理器 -func NewOutboundProcessor(existingTags map[string]bool) *OutboundProcessor { - if existingTags == nil { - existingTags = make(map[string]bool) - } +func NewOutboundProcessor() *OutboundProcessor { return &OutboundProcessor{ - UsedTags: existingTags, + usedTags: make(map[string]bool), + originalToTag: make(map[string]map[string]string), + sourceGroups: make(map[string][]string), } } -// RawOutbound 表示通用的出站配置 map -type RawOutbound map[string]any +// AddNodes processes a list of raw nodes gathered from a provider +func (p *OutboundProcessor) AddNodes(nodes []node.Node) { + for _, n := range nodes { + source := strings.TrimSpace(n.Source) + if source == "" { + source = "unknown" + } -// ProcessStandard 处理标准出站列表 (例如来自用户配置) -// source: 用于标识这批节点的来源,用于生成 Tag (例如 "user" 或订阅名) -func (p *OutboundProcessor) Process(outbounds []RawOutbound, source string) ([]option.Outbound, error) { - if len(outbounds) == 0 { - return nil, nil - } + // Ensure uniqueness of tag + uniqueTag := MakeUniqueOutboundTag(n.Name, source, p.usedTags) + p.recordMapping(source, n.Name, uniqueTag) - // 1. Pass 1: 分配唯一 Tag,建立 Old -> New 映射 - tagMapping := make(map[string]string) - newTags := make([]string, len(outbounds)) + // Create the option.Outbound structure + outbound := p.mapToOutbound(n.Type, uniqueTag, n.Outbound) - for i, out := range outbounds { - // 获取原始 Tag - oldTag := "" - if v, ok := out["tag"].(string); ok { - oldTag = v - } + p.processedNodes = append(p.processedNodes, outbound) + p.actualTags = append(p.actualTags, uniqueTag) + p.sourceGroups[source] = append(p.sourceGroups[source], uniqueTag) + } +} - // 生成唯一 Tag (如果是用户配置,source 可以传空或是 "user") - // 对于订阅,我们可能需要更复杂的命名逻辑,这里我们复用 MakeUniqueTag - // 如果需要包含 source,可以在调用前把 oldTag 格式化好,或者修改 MakeUniqueTag - - // 为了兼容现有的订阅命名逻辑 (Name + Source),我们需要更灵活的入参 - // 这里假设 out["tag"] 已经是基础名字了 - - newTag := "" - if source != "" && source != "user" { - // 订阅模式:使用 Base + Source - newTag = MakeUniqueOutboundTag(oldTag, source, p.UsedTags) - } else { - // 用户模式:直接使用 Base,冲突加后缀 - if oldTag == "" { - oldTag = "node" - } - newTag = MakeUniqueTag(oldTag, p.UsedTags) - } +// GetProcessedOutbounds returns all properly mapped and tagged outbounds +func (p *OutboundProcessor) GetProcessedOutbounds() []option.Outbound { + return p.processedNodes +} - newTags[i] = newTag - if oldTag != "" { - tagMapping[oldTag] = newTag - } - } +// GetActualTags returns the tags of all registered proxy nodes +func (p *OutboundProcessor) GetActualTags() []string { + return p.actualTags +} - // 2. Pass 2: 应用 Tag 并修正 Detour,转换为对象 - results := make([]option.Outbound, 0, len(outbounds)) +// GetGroups returns tags grouped by their source origin +func (p *OutboundProcessor) GetGroups() map[string][]string { + return p.sourceGroups +} - for i, outMap := range outbounds { - // 1. 修正 Detour 字段 (单引用) - if detour, ok := outMap["detour"].(string); ok && detour != "" { - if mapped, exists := tagMapping[detour]; exists { - outMap["detour"] = mapped - } - } +// --- Internal helpers --- - // 2. 修正 Outbounds 列表 (多引用,用于 selector/urltest/chain) - // 注意:JSON 解析出来可能是 []any 或 []string - if rawList, ok := outMap["outbounds"]; ok { - var newList []string - changed := false - - // 处理 []string - if strList, ok := rawList.([]string); ok { - newList = make([]string, len(strList)) - for j, tag := range strList { - if mapped, exists := tagMapping[tag]; exists { - newList[j] = mapped - changed = true - } else { - newList[j] = tag - } - } - } else if anyList, ok := rawList.([]any); ok { - // 处理 []any - newList = make([]string, len(anyList)) - for j, v := range anyList { - if tag, ok := v.(string); ok { - if mapped, exists := tagMapping[tag]; exists { - newList[j] = mapped - changed = true - } else { - newList[j] = tag - } - } - } - } - - if changed { - outMap["outbounds"] = newList - } - } +func (p *OutboundProcessor) recordMapping(source, original, unique string) { + if p.originalToTag[source] == nil { + p.originalToTag[source] = make(map[string]string) + } + p.originalToTag[source][original] = unique +} - // 3. 应用新 Tag - outMap["tag"] = newTags[i] +func (p *OutboundProcessor) mapToOutbound(outType, tag string, raw map[string]any) option.Outbound { + var outbound option.Outbound - // 转换为 option.Outbound - var out option.Outbound - if err := p.applyMapToOutbound(&out, outMap); err != nil { - logger.Error("Failed to convert outbound", "tag", newTags[i], "error", err) - continue + // Ensure tag matches our uniqueness guarantee + rawCopy := make(map[string]any, len(raw)) + for k, v := range raw { + rawCopy[k] = v + } + rawCopy["tag"] = tag + rawCopy["type"] = outType + + // Handle internal detour logic if it references other nodes + // e.g. wireguard nodes detour via another proxy + if detour, ok := rawCopy["detour"].(string); ok && detour != "" { + if mapped, found := p.resolveDetour(detour); found { + rawCopy["detour"] = mapped } - results = append(results, out) } - return results, nil + ApplyMapToOutbound(&outbound, rawCopy) + return outbound } -// applyMapToOutbound 辅助函数:Map -> Outbound -func (p *OutboundProcessor) applyMapToOutbound(out *option.Outbound, m RawOutbound) error { - // 先序列化回 JSON - data, err := singboxjson.Marshal(m) - if err != nil { - return err +func (p *OutboundProcessor) resolveDetour(target string) (string, bool) { + // 1. check globally reserved tags + if IsReservedOutboundTag(target) { + return target, true } - // 再反序列化为 Struct - // 必须使用 include.Context,否则 interface{} 类型的字段无法正确解析 - ctx := include.Context(context.Background()) - return singboxjson.UnmarshalContext(ctx, data, out) + + // 2. linear search across all mappings + // A more robust implementation would require knowing the source of the reference, + // but usually users reference by the original name of a user node. + for _, mapping := range p.originalToTag { + if mapped, exists := mapping[target]; exists { + return mapped, true + } + } + + // 3. direct use (assume user knows what they're doing) + return target, false } diff --git a/internal/proxy/config/module/provider.go b/internal/proxy/config/module/provider.go index 07bd817..00189f2 100644 --- a/internal/proxy/config/module/provider.go +++ b/internal/proxy/config/module/provider.go @@ -1,15 +1,12 @@ package module -// Node contains the raw struct for outbound before sing-box validation -type Node struct { - Name string - Type string - Source string - Outbound map[string]any -} +import "github.com/kyson-dev/sing-helm/internal/proxy/config/node" -// NodeProvider provides a list of unbound proxy nodes +// NodeProvider is an interface for modules that provide proxy nodes. +// Examples: parsing from local user config, or reading from a subscription cache. type NodeProvider interface { + // Name returns the provider's logic name. Name() string - GetNodes() ([]Node, error) + // GetNodes fetches a list of normalized outbound nodes. + GetNodes() ([]node.Node, error) } diff --git a/internal/proxy/config/module/provider_subscription.go b/internal/proxy/config/module/provider_subscription.go index d50b8d5..cbf6ed4 100644 --- a/internal/proxy/config/module/provider_subscription.go +++ b/internal/proxy/config/module/provider_subscription.go @@ -1,6 +1,7 @@ package module import ( + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/kyson-dev/sing-helm/internal/sys/paths" @@ -13,7 +14,7 @@ func (p *SubscriptionNodeProvider) Name() string { return "subscription" } -func (p *SubscriptionNodeProvider) GetNodes() ([]Node, error) { +func (p *SubscriptionNodeProvider) GetNodes() ([]node.Node, error) { paths := paths.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { @@ -26,7 +27,7 @@ func (p *SubscriptionNodeProvider) GetNodes() ([]Node, error) { return nil, nil // Return empty list instead of failing the whole build } - var nodes []Node + var nodes []node.Node for _, n := range subNodes { if n.Outbound == nil || n.Source == "" { continue @@ -37,7 +38,7 @@ func (p *SubscriptionNodeProvider) GetNodes() ([]Node, error) { outboundCopy[k] = v } - nodes = append(nodes, Node{ + nodes = append(nodes, node.Node{ Name: n.Name, Type: n.Type, Source: n.Source, // Provide the sub source name diff --git a/internal/proxy/config/module/provider_user.go b/internal/proxy/config/module/provider_user.go index 219586d..9d7da5e 100644 --- a/internal/proxy/config/module/provider_user.go +++ b/internal/proxy/config/module/provider_user.go @@ -1,62 +1,71 @@ package module import ( - "bytes" "encoding/json" + "fmt" "os" + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/kyson-dev/sing-helm/internal/sys/paths" ) -// UserNodeProvider reads raw nodes from user's profile.json +// UserNodeProvider reads nodes directly from the user's config. type UserNodeProvider struct{} func (p *UserNodeProvider) Name() string { return "user" } -func (p *UserNodeProvider) GetNodes() ([]Node, error) { +func (p *UserNodeProvider) GetNodes() ([]node.Node, error) { paths := paths.Get() - - content, err := os.ReadFile(paths.ConfigFile) + profileData, err := os.ReadFile(paths.ConfigFile) if err != nil { if os.IsNotExist(err) { - return nil, nil // Not an error if file doesn't exist + return nil, nil // Return empty if profile does not exist yet } - return nil, err + return nil, fmt.Errorf("read profile error: %w", err) + } + + var root map[string]any + if err := json.Unmarshal(profileData, &root); err != nil { + return nil, fmt.Errorf("unmarshal profile error: %w", err) } - if len(bytes.TrimSpace(content)) == 0 { + outboundsRaw, ok := root["outbounds"] + if !ok { return nil, nil } - var rawConfig map[string]any - if err := json.Unmarshal(content, &rawConfig); err != nil { - return nil, err + list, ok := outboundsRaw.([]any) + if !ok { + logger.Info("user outbounds is not a list") + return nil, nil } - var nodes []Node - if rawOutboundsVal, ok := rawConfig["outbounds"]; ok { - if list, ok := rawOutboundsVal.([]any); ok { - for _, item := range list { - if m, ok := item.(map[string]any); ok { - tag := "" - if t, ok := m["tag"].(string); ok { - tag = t - } - outType := "" - if t, ok := m["type"].(string); ok { - outType = t - } - nodes = append(nodes, Node{ - Name: tag, - Type: outType, - Source: "user", // Explicitly mark source - Outbound: m, - }) - } - } + var nodes []node.Node + for i, raw := range list { + outMap, ok := raw.(map[string]any) + if !ok { + continue } + outType, _ := outMap["type"].(string) + if outType == "" || !IsActualOutboundType(outType) { + continue // skip direct, block, dns, etc. They are handled globally. + } + + name, _ := outMap["tag"].(string) + if name == "" { + name = fmt.Sprintf("user-%s-%d", outType, i+1) + } + delete(outMap, "tag") + + nodes = append(nodes, node.Node{ + Name: name, + Type: outType, + Source: "user", // Indicates it came from user config + Outbound: outMap, + }) } return nodes, nil diff --git a/internal/proxy/config/node/node.go b/internal/proxy/config/node/node.go new file mode 100644 index 0000000..df1d697 --- /dev/null +++ b/internal/proxy/config/node/node.go @@ -0,0 +1,9 @@ +package node + +// Node is a normalized outbound entry representing a proxy node in a universal format. +type Node struct { + Name string `json:"name"` + Type string `json:"type"` + Source string `json:"source,omitempty"` + Outbound map[string]any `json:"outbound"` +} diff --git a/internal/proxy/config/subscription/adapter_hysteria.go b/internal/proxy/config/parser/adapter/hysteria.go similarity index 62% rename from internal/proxy/config/subscription/adapter_hysteria.go rename to internal/proxy/config/parser/adapter/hysteria.go index 740b108..3e06021 100644 --- a/internal/proxy/config/subscription/adapter_hysteria.go +++ b/internal/proxy/config/parser/adapter/hysteria.go @@ -1,26 +1,28 @@ -package subscription +package adapter import ( "fmt" "net/url" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" ) // HysteriaAdapter handles Hysteria protocol type HysteriaAdapter struct{} func init() { - RegisterAdapter("hysteria", &HysteriaAdapter{}) + Register("hysteria", &HysteriaAdapter{}) } -func (a *HysteriaAdapter) FromClash(m map[string]any) (Node, error) { - server := readString(m, "server") - port := readInt(m, "port") +func (a *HysteriaAdapter) FromClash(m map[string]any) (node.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") + return node.Node{}, fmt.Errorf("missing server or port") } - auth := readString(m, "auth_str", "auth-str", "auth") - protocol := readString(m, "protocol") + auth := ReadString(m, "auth_str", "auth-str", "auth") + protocol := ReadString(m, "protocol") if protocol == "" { protocol = "udp" } @@ -35,25 +37,25 @@ func (a *HysteriaAdapter) FromClash(m map[string]any) (Node, error) { if auth != "" { outbound["auth_str"] = auth } - if upMbps := readInt(m, "up", "up-mbps"); upMbps > 0 { + if upMbps := ReadInt(m, "up", "up-mbps"); upMbps > 0 { outbound["up_mbps"] = upMbps } - if downMbps := readInt(m, "down", "down-mbps"); downMbps > 0 { + if downMbps := ReadInt(m, "down", "down-mbps"); downMbps > 0 { outbound["down_mbps"] = downMbps } ApplyTLSOptions(outbound, m) - return Node{ + return node.Node{ Type: "hysteria", Outbound: outbound, }, nil } -func (a *HysteriaAdapter) FromURI(uriStr string) (Node, error) { +func (a *HysteriaAdapter) FromURI(uriStr string) (node.Node, error) { u, err := url.Parse("hysteria://" + uriStr) if err != nil { - return Node{}, err + return node.Node{}, err } auth := u.User.Username() @@ -63,10 +65,10 @@ func (a *HysteriaAdapter) FromURI(uriStr string) (Node, error) { query := u.Query() if server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") + return node.Node{}, fmt.Errorf("missing required fields") } - portNum, _ := parseInt(port) + portNum, _ := ParseInt(port) outbound := map[string]any{ "type": "hysteria", "server": server, @@ -78,12 +80,12 @@ func (a *HysteriaAdapter) FromURI(uriStr string) (Node, error) { } if upMbps := query.Get("up"); upMbps != "" { - if up, _ := parseInt(upMbps); up > 0 { + if up, _ := ParseInt(upMbps); up > 0 { outbound["up_mbps"] = up } } if downMbps := query.Get("down"); downMbps != "" { - if down, _ := parseInt(downMbps); down > 0 { + if down, _ := ParseInt(downMbps); down > 0 { outbound["down_mbps"] = down } } @@ -97,7 +99,7 @@ func (a *HysteriaAdapter) FromURI(uriStr string) (Node, error) { } outbound["tls"] = tls - return Node{ + return node.Node{ Name: name, Type: "hysteria", Outbound: outbound, @@ -108,18 +110,18 @@ func (a *HysteriaAdapter) FromURI(uriStr string) (Node, error) { type Hysteria2Adapter struct{} func init() { - RegisterAdapter("hysteria2", &Hysteria2Adapter{}) - RegisterAdapter("hy2", &Hysteria2Adapter{}) + Register("hysteria2", &Hysteria2Adapter{}) + Register("hy2", &Hysteria2Adapter{}) } -func (a *Hysteria2Adapter) FromClash(m map[string]any) (Node, error) { - server := readString(m, "server") - port := readInt(m, "port") +func (a *Hysteria2Adapter) FromClash(m map[string]any) (node.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") + return node.Node{}, fmt.Errorf("missing server or port") } - password := readString(m, "password") + password := ReadString(m, "password") outbound := map[string]any{ "type": "hysteria2", "server": server, @@ -127,25 +129,25 @@ func (a *Hysteria2Adapter) FromClash(m map[string]any) (Node, error) { "password": password, } - if upMbps := readInt(m, "up", "up-mbps"); upMbps > 0 { + if upMbps := ReadInt(m, "up", "up-mbps"); upMbps > 0 { outbound["up_mbps"] = upMbps } - if downMbps := readInt(m, "down", "down-mbps"); downMbps > 0 { + if downMbps := ReadInt(m, "down", "down-mbps"); downMbps > 0 { outbound["down_mbps"] = downMbps } ApplyTLSOptions(outbound, m) - return Node{ + return node.Node{ Type: "hysteria2", Outbound: outbound, }, nil } -func (a *Hysteria2Adapter) FromURI(uriStr string) (Node, error) { +func (a *Hysteria2Adapter) FromURI(uriStr string) (node.Node, error) { u, err := url.Parse("hysteria2://" + uriStr) if err != nil { - return Node{}, err + return node.Node{}, err } password := u.User.Username() @@ -155,10 +157,10 @@ func (a *Hysteria2Adapter) FromURI(uriStr string) (Node, error) { query := u.Query() if server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") + return node.Node{}, fmt.Errorf("missing required fields") } - portNum, _ := parseInt(port) + portNum, _ := ParseInt(port) outbound := map[string]any{ "type": "hysteria2", "server": server, @@ -175,7 +177,7 @@ func (a *Hysteria2Adapter) FromURI(uriStr string) (Node, error) { } outbound["tls"] = tls - return Node{ + return node.Node{ Name: name, Type: "hysteria2", Outbound: outbound, diff --git a/internal/proxy/config/parser/adapter/registry.go b/internal/proxy/config/parser/adapter/registry.go new file mode 100644 index 0000000..0664c49 --- /dev/null +++ b/internal/proxy/config/parser/adapter/registry.go @@ -0,0 +1,35 @@ +package adapter + +import ( + "fmt" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" +) + +// ProtocolAdapter describes how to parse different node formats into a standard Node. +type ProtocolAdapter interface { + FromClash(m map[string]any) (node.Node, error) + FromURI(uri string) (node.Node, error) +} + +var registry = make(map[string]ProtocolAdapter) + +// Register registers an adapter for a protocol name (e.g., "vmess", "vless"). +func Register(name string, adapter ProtocolAdapter) { + registry[name] = adapter +} + +// Get returns the protocol adapter. +func Get(name string) (ProtocolAdapter, error) { + adapter, ok := registry[name] + if !ok { + return nil, fmt.Errorf("unsupported protocol: %s", name) + } + return adapter, nil +} + +// Has checks if an adapter exists. +func Has(name string) bool { + _, ok := registry[name] + return ok +} diff --git a/internal/proxy/config/subscription/adapter_ss_trojan.go b/internal/proxy/config/parser/adapter/ss_trojan.go similarity index 64% rename from internal/proxy/config/subscription/adapter_ss_trojan.go rename to internal/proxy/config/parser/adapter/ss_trojan.go index f2137b7..b7efc4b 100644 --- a/internal/proxy/config/subscription/adapter_ss_trojan.go +++ b/internal/proxy/config/parser/adapter/ss_trojan.go @@ -1,29 +1,31 @@ -package subscription +package adapter import ( "encoding/base64" "fmt" "net/url" "strings" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" ) // ShadowsocksAdapter handles Shadowsocks protocol type ShadowsocksAdapter struct{} func init() { - RegisterAdapter("ss", &ShadowsocksAdapter{}) - RegisterAdapter("shadowsocks", &ShadowsocksAdapter{}) + Register("ss", &ShadowsocksAdapter{}) + Register("shadowsocks", &ShadowsocksAdapter{}) } -func (a *ShadowsocksAdapter) FromClash(m map[string]any) (Node, error) { - server := readString(m, "server") - port := readInt(m, "port") +func (a *ShadowsocksAdapter) FromClash(m map[string]any) (node.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") + return node.Node{}, fmt.Errorf("missing server or port") } - password := readString(m, "password") - cipher := readString(m, "cipher") + password := ReadString(m, "password") + cipher := ReadString(m, "cipher") outbound := map[string]any{ "type": "shadowsocks", @@ -33,23 +35,23 @@ func (a *ShadowsocksAdapter) FromClash(m map[string]any) (Node, error) { "method": cipher, } - if plugin := readString(m, "plugin"); plugin != "" { + if plugin := ReadString(m, "plugin"); plugin != "" { outbound["plugin"] = plugin } - if pluginOpts := readString(m, "plugin-opts", "plugin_opts"); pluginOpts != "" { + if pluginOpts := ReadString(m, "plugin-opts", "plugin_opts"); pluginOpts != "" { outbound["plugin_opts"] = pluginOpts } - return Node{ + return node.Node{ Type: "shadowsocks", Outbound: outbound, }, nil } -func (a *ShadowsocksAdapter) FromURI(uriStr string) (Node, error) { +func (a *ShadowsocksAdapter) FromURI(uriStr string) (node.Node, error) { parts := strings.SplitN(uriStr, "@", 2) if len(parts) != 2 { - return Node{}, fmt.Errorf("invalid ss URI format") + return node.Node{}, fmt.Errorf("invalid ss URI format") } methodPassword := parts[0] @@ -60,7 +62,7 @@ func (a *ShadowsocksAdapter) FromURI(uriStr string) (Node, error) { mpParts := strings.SplitN(methodPassword, ":", 2) if len(mpParts) != 2 { - return Node{}, fmt.Errorf("invalid method:password format") + return node.Node{}, fmt.Errorf("invalid method:password format") } method := mpParts[0] @@ -76,13 +78,13 @@ func (a *ShadowsocksAdapter) FromURI(uriStr string) (Node, error) { spParts := strings.SplitN(serverPart, ":", 2) if len(spParts) != 2 { - return Node{}, fmt.Errorf("invalid server:port format") + return node.Node{}, fmt.Errorf("invalid server:port format") } server := spParts[0] - port, _ := parseInt(spParts[1]) + port, _ := ParseInt(spParts[1]) - return Node{ + return node.Node{ Name: name, Type: "shadowsocks", Outbound: map[string]any{ @@ -99,17 +101,17 @@ func (a *ShadowsocksAdapter) FromURI(uriStr string) (Node, error) { type TrojanAdapter struct{} func init() { - RegisterAdapter("trojan", &TrojanAdapter{}) + Register("trojan", &TrojanAdapter{}) } -func (a *TrojanAdapter) FromClash(m map[string]any) (Node, error) { - server := readString(m, "server") - port := readInt(m, "port") +func (a *TrojanAdapter) FromClash(m map[string]any) (node.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") + return node.Node{}, fmt.Errorf("missing server or port") } - password := readString(m, "password") + password := ReadString(m, "password") outbound := map[string]any{ "type": "trojan", @@ -121,16 +123,16 @@ func (a *TrojanAdapter) FromClash(m map[string]any) (Node, error) { ApplyTLSOptions(outbound, m) ApplyTransportOptions(outbound, m) - return Node{ + return node.Node{ Type: "trojan", Outbound: outbound, }, nil } -func (a *TrojanAdapter) FromURI(uriStr string) (Node, error) { +func (a *TrojanAdapter) FromURI(uriStr string) (node.Node, error) { u, err := url.Parse("trojan://" + uriStr) if err != nil { - return Node{}, err + return node.Node{}, err } password := u.User.Username() @@ -140,10 +142,10 @@ func (a *TrojanAdapter) FromURI(uriStr string) (Node, error) { query := u.Query() if password == "" || server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") + return node.Node{}, fmt.Errorf("missing required fields") } - portNum, _ := parseInt(port) + portNum, _ := ParseInt(port) outbound := map[string]any{ "type": "trojan", "server": server, @@ -163,7 +165,7 @@ func (a *TrojanAdapter) FromURI(uriStr string) (Node, error) { ApplyURITransport(outbound, network, query) } - return Node{ + return node.Node{ Name: name, Type: "trojan", Outbound: outbound, diff --git a/internal/proxy/config/subscription/utils.go b/internal/proxy/config/parser/adapter/utils.go similarity index 74% rename from internal/proxy/config/subscription/utils.go rename to internal/proxy/config/parser/adapter/utils.go index e2dda41..36ce930 100644 --- a/internal/proxy/config/subscription/utils.go +++ b/internal/proxy/config/parser/adapter/utils.go @@ -1,11 +1,11 @@ -package subscription +package adapter import ( "fmt" "strings" ) -func readString(m map[string]any, keys ...string) string { +func ReadString(m map[string]any, keys ...string) string { for _, key := range keys { if key == "" { continue @@ -22,7 +22,7 @@ func readString(m map[string]any, keys ...string) string { return "" } -func readInt(m map[string]any, keys ...string) int { +func ReadInt(m map[string]any, keys ...string) int { for _, key := range keys { if val, ok := m[key]; ok { switch v := val.(type) { @@ -39,7 +39,7 @@ func readInt(m map[string]any, keys ...string) int { case float32: return int(v) case string: - if parsed, err := parseInt(v); err == nil { + if parsed, err := ParseInt(v); err == nil { return parsed } } @@ -48,7 +48,7 @@ func readInt(m map[string]any, keys ...string) int { return 0 } -func readBool(m map[string]any, key string) bool { +func ReadBool(m map[string]any, key string) bool { val, ok := m[key] if !ok { return false @@ -63,7 +63,7 @@ func readBool(m map[string]any, key string) bool { } } -func readStringList(m map[string]any, key string) []string { +func ReadStringList(m map[string]any, key string) []string { val, ok := m[key] if !ok { return nil @@ -89,7 +89,7 @@ func readStringList(m map[string]any, key string) []string { } } -func asStringMap(val any) map[string]any { +func AsStringMap(val any) map[string]any { switch v := val.(type) { case map[string]any: return v @@ -104,7 +104,7 @@ func asStringMap(val any) map[string]any { } } -func normalizeStringMap(input map[string]any) map[string]string { +func NormalizeStringMap(input map[string]any) map[string]string { if len(input) == 0 { return nil } @@ -120,20 +120,20 @@ func normalizeStringMap(input map[string]any) map[string]string { return out } -func parseInt(value string) (int, error) { +func ParseInt(value string) (int, error) { var out int _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &out) return out, err } func ApplyTLSOptions(outbound map[string]any, m map[string]any) { - tlsEnabled := readBool(m, "tls") - sni := readString(m, "sni", "servername", "server_name") - skipVerify := readBool(m, "skip-cert-verify") - alpn := readStringList(m, "alpn") - clientFingerprint := readString(m, "client-fingerprint") + tlsEnabled := ReadBool(m, "tls") + sni := ReadString(m, "sni", "servername", "server_name") + skipVerify := ReadBool(m, "skip-cert-verify") + alpn := ReadStringList(m, "alpn") + clientFingerprint := ReadString(m, "client-fingerprint") - realityOpts := asStringMap(m["reality-opts"]) + realityOpts := AsStringMap(m["reality-opts"]) if !tlsEnabled && sni == "" && len(alpn) == 0 && realityOpts == nil && clientFingerprint == "" { return @@ -158,10 +158,10 @@ func ApplyTLSOptions(outbound map[string]any, m map[string]any) { if realityOpts != nil { reality := map[string]any{"enabled": true} - if publicKey := readString(realityOpts, "public-key", "public_key"); publicKey != "" { + if publicKey := ReadString(realityOpts, "public-key", "public_key"); publicKey != "" { reality["public_key"] = publicKey } - if shortID := readString(realityOpts, "short-id", "short_id"); shortID != "" { + if shortID := ReadString(realityOpts, "short-id", "short_id"); shortID != "" { reality["short_id"] = shortID } tls["reality"] = reality @@ -171,26 +171,26 @@ func ApplyTLSOptions(outbound map[string]any, m map[string]any) { } func ApplyTransportOptions(outbound map[string]any, m map[string]any) { - network := strings.ToLower(readString(m, "network")) + network := strings.ToLower(ReadString(m, "network")) switch network { case "ws", "websocket": - wsOpts := asStringMap(m["ws-opts"]) + wsOpts := AsStringMap(m["ws-opts"]) transport := map[string]any{"type": "ws"} if wsOpts != nil { - if path := readString(wsOpts, "path"); path != "" { + if path := ReadString(wsOpts, "path"); path != "" { transport["path"] = path } - headers := asStringMap(wsOpts["headers"]) + headers := AsStringMap(wsOpts["headers"]) if len(headers) > 0 { - transport["headers"] = normalizeStringMap(headers) + transport["headers"] = NormalizeStringMap(headers) } } outbound["transport"] = transport case "grpc": - grpcOpts := asStringMap(m["grpc-opts"]) + grpcOpts := AsStringMap(m["grpc-opts"]) transport := map[string]any{"type": "grpc"} if grpcOpts != nil { - if service := readString(grpcOpts, "grpc-service-name", "service-name"); service != "" { + if service := ReadString(grpcOpts, "grpc-service-name", "service-name"); service != "" { transport["service_name"] = service } } diff --git a/internal/proxy/config/subscription/adapter_vless.go b/internal/proxy/config/parser/adapter/v.go similarity index 63% rename from internal/proxy/config/subscription/adapter_vless.go rename to internal/proxy/config/parser/adapter/v.go index 5b6ff89..5df6a81 100644 --- a/internal/proxy/config/subscription/adapter_vless.go +++ b/internal/proxy/config/parser/adapter/v.go @@ -1,28 +1,30 @@ -package subscription +package adapter import ( "encoding/base64" "encoding/json" "fmt" "net/url" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" ) // VMessAdapter handles VMess protocol in Clash and URI formats. type VMessAdapter struct{} func init() { - RegisterAdapter("vmess", &VMessAdapter{}) + Register("vmess", &VMessAdapter{}) } -func (a *VMessAdapter) FromClash(m map[string]any) (Node, error) { - server := readString(m, "server") - port := readInt(m, "port") +func (a *VMessAdapter) FromClash(m map[string]any) (node.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") + return node.Node{}, fmt.Errorf("missing server or port") } - uuid := readString(m, "uuid") - cipher := readString(m, "cipher", "security") + uuid := ReadString(m, "uuid") + cipher := ReadString(m, "cipher", "security") if cipher == "" { cipher = "auto" } @@ -35,37 +37,37 @@ func (a *VMessAdapter) FromClash(m map[string]any) (Node, error) { "security": cipher, } - if alterID := readInt(m, "alterId", "alter-id"); alterID > 0 { + if alterID := ReadInt(m, "alterId", "alter-id"); alterID > 0 { outbound["alter_id"] = alterID } ApplyTLSOptions(outbound, m) ApplyTransportOptions(outbound, m) - return Node{ + return node.Node{ Type: "vmess", Outbound: outbound, }, nil } -func (a *VMessAdapter) FromURI(uri string) (Node, error) { +func (a *VMessAdapter) FromURI(uri string) (node.Node, error) { decoded, err := base64.StdEncoding.DecodeString(uri) if err != nil { - return Node{}, fmt.Errorf("invalid vmess URI: %w", err) + return node.Node{}, fmt.Errorf("invalid vmess URI: %w", err) } var m map[string]any if err := json.Unmarshal(decoded, &m); err != nil { - return Node{}, fmt.Errorf("invalid vmess config: %w", err) + return node.Node{}, fmt.Errorf("invalid vmess config: %w", err) } - server := readString(m, "add", "address") - port := readInt(m, "port") - uuid := readString(m, "id", "uuid") - name := readString(m, "ps", "name") + server := ReadString(m, "add", "address") + port := ReadInt(m, "port") + uuid := ReadString(m, "id", "uuid") + name := ReadString(m, "ps", "name") if server == "" || port == 0 || uuid == "" { - return Node{}, fmt.Errorf("missing required fields") + return node.Node{}, fmt.Errorf("missing required fields") } outbound := map[string]any{ @@ -73,44 +75,44 @@ func (a *VMessAdapter) FromURI(uri string) (Node, error) { "server": server, "server_port": port, "uuid": uuid, - "security": readString(m, "scy", "security"), + "security": ReadString(m, "scy", "security"), } if outbound["security"] == "" { outbound["security"] = "auto" } - if alterID := readInt(m, "aid", "alterId"); alterID > 0 { + if alterID := ReadInt(m, "aid", "alterId"); alterID > 0 { outbound["alter_id"] = alterID } - if tls := readString(m, "tls"); tls == "tls" { + if tls := ReadString(m, "tls"); tls == "tls" { tlsConfig := map[string]any{"enabled": true} - if sni := readString(m, "sni"); sni != "" { + if sni := ReadString(m, "sni"); sni != "" { tlsConfig["server_name"] = sni } outbound["tls"] = tlsConfig } - if network := readString(m, "net", "network"); network != "" { + if network := ReadString(m, "net", "network"); network != "" { transport := map[string]any{"type": network} switch network { case "ws": - if path := readString(m, "path"); path != "" { + if path := ReadString(m, "path"); path != "" { transport["path"] = path } - if host := readString(m, "host"); host != "" { + if host := ReadString(m, "host"); host != "" { transport["headers"] = map[string]string{"Host": host} } case "grpc": - if serviceName := readString(m, "path", "serviceName"); serviceName != "" { + if serviceName := ReadString(m, "path", "serviceName"); serviceName != "" { transport["service_name"] = serviceName } } outbound["transport"] = transport } - return Node{ + return node.Node{ Name: name, Type: "vmess", Outbound: outbound, @@ -121,17 +123,17 @@ func (a *VMessAdapter) FromURI(uri string) (Node, error) { type VLessAdapter struct{} func init() { - RegisterAdapter("vless", &VLessAdapter{}) + Register("vless", &VLessAdapter{}) } -func (a *VLessAdapter) FromClash(m map[string]any) (Node, error) { - server := readString(m, "server") - port := readInt(m, "port") +func (a *VLessAdapter) FromClash(m map[string]any) (node.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") if server == "" || port == 0 { - return Node{}, fmt.Errorf("missing server or port") + return node.Node{}, fmt.Errorf("missing server or port") } - uuid := readString(m, "uuid") + uuid := ReadString(m, "uuid") outbound := map[string]any{ "type": "vless", "server": server, @@ -139,23 +141,23 @@ func (a *VLessAdapter) FromClash(m map[string]any) (Node, error) { "uuid": uuid, } - if flow := readString(m, "flow"); flow != "" { + if flow := ReadString(m, "flow"); flow != "" { outbound["flow"] = flow } ApplyTLSOptions(outbound, m) ApplyTransportOptions(outbound, m) - return Node{ + return node.Node{ Type: "vless", Outbound: outbound, }, nil } -func (a *VLessAdapter) FromURI(uriStr string) (Node, error) { +func (a *VLessAdapter) FromURI(uriStr string) (node.Node, error) { u, err := url.Parse("vless://" + uriStr) if err != nil { - return Node{}, err + return node.Node{}, err } uuid := u.User.Username() @@ -165,10 +167,10 @@ func (a *VLessAdapter) FromURI(uriStr string) (Node, error) { query := u.Query() if uuid == "" || server == "" || port == "" { - return Node{}, fmt.Errorf("missing required fields") + return node.Node{}, fmt.Errorf("missing required fields") } - portNum, _ := parseInt(port) + portNum, _ := ParseInt(port) outbound := map[string]any{ "type": "vless", "server": server, @@ -209,7 +211,7 @@ func (a *VLessAdapter) FromURI(uriStr string) (Node, error) { ApplyURITransport(outbound, network, query) } - return Node{ + return node.Node{ Name: name, Type: "vless", Outbound: outbound, diff --git a/internal/proxy/config/subscription/parse.go b/internal/proxy/config/parser/parse.go similarity index 63% rename from internal/proxy/config/subscription/parse.go rename to internal/proxy/config/parser/parse.go index 0a1be18..ba06de1 100644 --- a/internal/proxy/config/subscription/parse.go +++ b/internal/proxy/config/parser/parse.go @@ -1,4 +1,4 @@ -package subscription +package parser import ( "encoding/base64" @@ -6,11 +6,34 @@ import ( "fmt" "strings" + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/parser/adapter" "github.com/kyson-dev/sing-helm/internal/sys/logger" "gopkg.in/yaml.v3" ) -func Parse(content []byte, format string) ([]Node, error) { +const ( + FormatAuto = "auto" + FormatSingBox = "singbox" + FormatClash = "clash" + FormatBase64 = "base64" +) + +func NormalizeFormat(format string) string { + switch format { + case "", "auto", "json": + return FormatAuto + case "sing-box", "singbox": + return FormatSingBox + case "clash": + return FormatClash + default: + return format + } +} + +// Parse parses subscription content into a standard Node list. +func Parse(content []byte, format string) ([]node.Node, error) { format = NormalizeFormat(strings.ToLower(strings.TrimSpace(format))) switch format { case FormatAuto: @@ -35,7 +58,7 @@ func Parse(content []byte, format string) ([]Node, error) { } } -func parseSingBox(content []byte) ([]Node, error) { +func parseSingBox(content []byte) ([]node.Node, error) { var root map[string]any if err := json.Unmarshal(content, &root); err != nil { return nil, err @@ -51,23 +74,23 @@ func parseSingBox(content []byte) ([]Node, error) { return nil, fmt.Errorf("invalid outbounds format") } - var nodes []Node + var nodes []node.Node for i, raw := range list { outMap, ok := raw.(map[string]any) if !ok { continue } - outType := readString(outMap, "type") - if outType == "" || !IsActualOutboundType(outType) { + outType := adapter.ReadString(outMap, "type") + if outType == "" || !isActualOutboundType(outType) { continue } - name := readString(outMap, "tag") + name := adapter.ReadString(outMap, "tag") if name == "" { name = fmt.Sprintf("%s-%d", outType, i+1) } delete(outMap, "tag") - nodes = append(nodes, Node{ + nodes = append(nodes, node.Node{ Name: name, Type: outType, Outbound: outMap, @@ -80,7 +103,7 @@ func parseSingBox(content []byte) ([]Node, error) { return nodes, nil } -func parseClash(content []byte) ([]Node, error) { +func parseClash(content []byte) ([]node.Node, error) { var root map[string]any if err := yaml.Unmarshal(content, &root); err != nil { return nil, err @@ -96,34 +119,34 @@ func parseClash(content []byte) ([]Node, error) { return nil, fmt.Errorf("invalid proxies format") } - var nodes []Node + var nodes []node.Node for _, raw := range list { - proxyMap := asStringMap(raw) + proxyMap := adapter.AsStringMap(raw) if proxyMap == nil { continue } - proxyType := strings.ToLower(readString(proxyMap, "type")) - a, err := GetAdapter(proxyType) + proxyType := strings.ToLower(adapter.ReadString(proxyMap, "type")) + a, err := adapter.Get(proxyType) if err != nil { logger.Debug("Skipping proxy node", "type", proxyType, "error", err.Error()) continue } - node, err := a.FromClash(proxyMap) + n, err := a.FromClash(proxyMap) if err != nil { logger.Debug("Failed to parse clash node", "type", proxyType, "error", err.Error()) continue } - name := readString(proxyMap, "name") + name := adapter.ReadString(proxyMap, "name") if name != "" { - node.Name = name - } else if node.Name == "" { - node.Name = fmt.Sprintf("%s-%v:%v", node.Type, proxyMap["server"], proxyMap["port"]) + n.Name = name + } else if n.Name == "" { + n.Name = fmt.Sprintf("%s-%v:%v", n.Type, proxyMap["server"], proxyMap["port"]) } - nodes = append(nodes, node) + nodes = append(nodes, n) } if len(nodes) == 0 { @@ -132,14 +155,14 @@ func parseClash(content []byte) ([]Node, error) { return nodes, nil } -func parseBase64URI(content []byte) ([]Node, error) { +func parseBase64URI(content []byte) ([]node.Node, error) { decoded, err := base64.StdEncoding.DecodeString(string(content)) if err != nil { decoded = content } lines := strings.Split(string(decoded), "\n") - var nodes []Node + var nodes []node.Node for _, line := range lines { line = strings.TrimSpace(line) @@ -153,19 +176,19 @@ func parseBase64URI(content []byte) ([]Node, error) { } scheme := strings.ToLower(line[:idx]) - a, err := GetAdapter(scheme) + a, err := adapter.Get(scheme) if err != nil { logger.Debug("Skipping proxy node", "scheme", scheme, "error", err.Error()) continue } - node, err := a.FromURI(line[idx+3:]) + n, err := a.FromURI(line[idx+3:]) if err != nil { logger.Debug("Failed to parse URI node", "scheme", scheme, "error", err.Error()) continue } - nodes = append(nodes, node) + nodes = append(nodes, n) } if len(nodes) == 0 { @@ -173,3 +196,12 @@ func parseBase64URI(content []byte) ([]Node, error) { } return nodes, nil } + +func isActualOutboundType(outType string) bool { + switch outType { + case "selector", "urltest", "direct", "block", "dns": + return false + default: + return true + } +} diff --git a/internal/proxy/config/subscription/adapter_registry.go b/internal/proxy/config/subscription/adapter_registry.go deleted file mode 100644 index 1e0f4de..0000000 --- a/internal/proxy/config/subscription/adapter_registry.go +++ /dev/null @@ -1,31 +0,0 @@ -package subscription - -import "fmt" - -// ProtocolAdapter describes how to parse different node formats into a standard Node. -type ProtocolAdapter interface { - FromClash(m map[string]any) (Node, error) - FromURI(uri string) (Node, error) -} - -var registry = make(map[string]ProtocolAdapter) - -// RegisterAdapter registers an adapter for a protocol name (e.g., "vmess", "vless"). -func RegisterAdapter(name string, adapter ProtocolAdapter) { - registry[name] = adapter -} - -// GetAdapter returns the protocol adapter. -func GetAdapter(name string) (ProtocolAdapter, error) { - adapter, ok := registry[name] - if !ok { - return nil, fmt.Errorf("unsupported protocol: %s", name) - } - return adapter, nil -} - -// HasAdapter checks if an adapter exists. -func HasAdapter(name string) bool { - _, ok := registry[name] - return ok -} diff --git a/internal/proxy/config/subscription/merge.go b/internal/proxy/config/subscription/merge.go index b0109b4..f8f51ec 100644 --- a/internal/proxy/config/subscription/merge.go +++ b/internal/proxy/config/subscription/merge.go @@ -1,79 +1,102 @@ package subscription import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" "fmt" + "path/filepath" + "strings" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) -func LoadNodesFromCache(sources []Source, cacheDir string) ([]Node, error) { - seen := make(map[string]bool) - var nodes []Node +// LoadNodesFromCache reads from cache files honoring priority and enablement +func LoadNodesFromCache(sources []Source, cacheDir string) ([]node.Node, error) { + var finalNodes []node.Node + globalSeen := make(map[string]bool) - for _, source := range sources { - if !source.EnabledValue() { + for _, s := range sources { + if !s.EnabledValue() { + logger.Debug("Skipping disabled source", "name", s.Name) continue } - cachePath := CacheFilePath(cacheDir, source.Name) + + cachePath := filepath.Join(cacheDir, s.Name+".json") cache, err := LoadCache(cachePath) if err != nil { + logger.Error("Failed to load cache for source", "name", s.Name, "error", err) continue } - for _, node := range cache.Nodes { - normalizeNode(&node, source) - if node.Type == "" { - continue - } - if source.DedupeValue() { - hash, err := outboundHash(node.Outbound) - if err != nil { - continue - } - if seen[hash] { - continue - } - seen[hash] = true + nodes := cache.Nodes + if len(nodes) == 0 { + continue + } + + // Apply tags to nodes + if len(s.Tags) > 0 { + nodes = appendTags(nodes, s.Tags) + } + + // Source deduplication + if s.DedupeValue() { + nodes = dedupeWithinSource(nodes) + } + + // Global deduplication (across sources) + for _, n := range nodes { + // use standard signature for global dedupe + sig := globalSignature(n) + if !globalSeen[sig] { + globalSeen[sig] = true + n.Source = s.Name + finalNodes = append(finalNodes, n) } - nodes = append(nodes, node) } } - return nodes, nil + return finalNodes, nil } -func normalizeNode(node *Node, source Source) { - if node.Source == "" { - node.Source = source.Name - } - if node.Type == "" { - node.Type = readString(node.Outbound, "type") - } - if node.Name == "" { - node.Name = readString(node.Outbound, "tag") - } - if node.Name == "" { - node.Name = fmt.Sprintf("%s-%s", node.Type, node.Source) +func appendTags(nodes []node.Node, tags []string) []node.Node { + for i := range nodes { + for _, tag := range tags { + if !strings.Contains(nodes[i].Name, tag) { + nodes[i].Name = nodes[i].Name + " " + tag + } + } } - delete(node.Outbound, "tag") + return nodes } -func outboundHash(outbound map[string]any) (string, error) { - if outbound == nil { - return "", fmt.Errorf("empty outbound") - } - cloned := make(map[string]any, len(outbound)) - for key, value := range outbound { - if key == "tag" { - continue +func dedupeWithinSource(nodes []node.Node) []node.Node { + seen := make(map[string]bool) + var deduped []node.Node + for _, n := range nodes { + sig := localSignature(n) + if !seen[sig] { + seen[sig] = true + deduped = append(deduped, n) } - cloned[key] = value } - data, err := json.Marshal(cloned) - if err != nil { - return "", err + return deduped +} + +// localSignature is used for deduplication within the same source. +// We use name + type as signature since many users only have one server +// but use name to distinguish them. +func localSignature(n node.Node) string { + return n.Name + "|" + n.Type +} + +// globalSignature is used for cross-source deduplication. +// We try to use server+port combination if possible, fallback to localSignature. +func globalSignature(n node.Node) string { + if n.Outbound != nil { + server, hasServer := n.Outbound["server"].(string) + port, hasPort := n.Outbound["server_port"] + if hasServer && hasPort { + return fmt.Sprintf("%s:%v|%s", server, port, n.Type) + } } - sum := sha1.Sum(data) - return hex.EncodeToString(sum[:]), nil + return localSignature(n) } diff --git a/internal/proxy/config/subscription/refresh.go b/internal/proxy/config/subscription/refresh.go index 9c321cc..a83298d 100644 --- a/internal/proxy/config/subscription/refresh.go +++ b/internal/proxy/config/subscription/refresh.go @@ -5,62 +5,60 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "time" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/parser" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) -func RefreshSource(ctx context.Context, source Source, cacheDir string) (Cache, error) { - if source.URL == "" { - return Cache{}, fmt.Errorf("missing subscription url for %s", source.Name) - } +// Refresh downloads a subscription and updates its cache +func Refresh(ctx context.Context, source Source, cacheDir string) error { + logger.Info("Refreshing subscription", "name", source.Name, "url", source.URL) - content, err := fetchURL(ctx, source.URL) + req, err := http.NewRequestWithContext(ctx, "GET", source.URL, nil) if err != nil { - return Cache{}, err + return fmt.Errorf("create request failed: %w", err) } - nodes, err := Parse(content, source.Format) + // Some providers block standard go user agent + req.Header.Set("User-Agent", "sing-box/1.10.x") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) if err != nil { - return Cache{}, err + return fmt.Errorf("download failed: %w", err) } + defer resp.Body.Close() - for i := range nodes { - if nodes[i].Source == "" { - nodes[i].Source = source.Name - } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status code: %d", resp.StatusCode) } - cache := Cache{ - Source: source, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - Nodes: nodes, + content, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read body failed: %w", err) } - if err := SaveCache(CacheFilePath(cacheDir, source.Name), cache); err != nil { - return Cache{}, err + nodes, err := parser.Parse(content, source.Format) + if err != nil { + return fmt.Errorf("parse subscription failed: %w", err) } - return cache, nil -} + logger.Info("Successfully parsed nodes", "count", len(nodes)) -func fetchURL(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err + cache := Cache{ + Source: source, + UpdatedAt: time.Now().Format(time.RFC3339), + Nodes: nodes, } - req.Header.Set("User-Agent", "sing-helm/1.0") - client := &http.Client{ - Timeout: 20 * time.Second, - } - resp, err := client.Do(req) + err = os.MkdirAll(cacheDir, 0755) if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status: %s", resp.Status) + return fmt.Errorf("create cache dir failed: %w", err) } - return io.ReadAll(resp.Body) + cachePath := filepath.Join(cacheDir, source.Name+".json") + return SaveCache(cachePath, cache) } diff --git a/internal/proxy/config/subscription/storage.go b/internal/proxy/config/subscription/storage.go index 83aefa9..95bd03a 100644 --- a/internal/proxy/config/subscription/storage.go +++ b/internal/proxy/config/subscription/storage.go @@ -6,126 +6,96 @@ import ( "os" "path/filepath" "sort" - "strings" -) - -func EnsureDirs(configDir, cacheDir string) error { - if err := os.MkdirAll(configDir, 0755); err != nil { - return err - } - return os.MkdirAll(cacheDir, 0755) -} -func SourceFilePath(dir, name string) string { - return filepath.Join(dir, name+".json") -} - -func CacheFilePath(dir, name string) string { - return filepath.Join(dir, name+".json") -} + "gopkg.in/yaml.v3" +) -func LoadSources(dir string) ([]Source, error) { - entries, err := os.ReadDir(dir) +// Storage handles loading and saving subscription sources +func LoadSources(configDir string) ([]Source, error) { + configPath := filepath.Join(configDir, "sources.yaml") + file, err := os.Open(configPath) if err != nil { if os.IsNotExist(err) { - return []Source{}, nil + return nil, nil } - return nil, err + return nil, fmt.Errorf("open sources.yaml failed: %w", err) } + defer file.Close() - var sources []Source - var loadErrs []error - for _, entry := range entries { - if entry.IsDir() { - continue - } - if filepath.Ext(entry.Name()) != ".json" { - continue - } - path := filepath.Join(dir, entry.Name()) - source, err := LoadSourceFile(path) - if err != nil { - loadErrs = append(loadErrs, err) - continue - } - sources = append(sources, source) + var doc struct { + Sources []Source `yaml:"sources"` } - sort.Slice(sources, func(i, j int) bool { - if sources[i].Priority == sources[j].Priority { - return sources[i].Name < sources[j].Name - } - return sources[i].Priority > sources[j].Priority - }) - - if len(loadErrs) > 0 { - return sources, fmt.Errorf("failed to load %d subscription file(s)", len(loadErrs)) + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(&doc); err != nil { + return nil, fmt.Errorf("decode sources.yaml failed: %w", err) } - return sources, nil -} -func LoadSourceFile(path string) (Source, error) { - content, err := os.ReadFile(path) - if err != nil { - return Source{}, err - } - - var source Source - if err := json.Unmarshal(content, &source); err != nil { - return Source{}, fmt.Errorf("invalid subscription file %s: %w", path, err) + // Set default values if not explicitly configured + for i := range doc.Sources { + doc.Sources[i].NormalizeDefaults(fmt.Sprintf("source-%d", i+1)) } - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - source.NormalizeDefaults(name) - if source.Name != name { - source.Name = name - } + // Sort sources by priority descending (higher integer = higher priority) + sort.SliceStable(doc.Sources, func(i, j int) bool { + return doc.Sources[i].Priority > doc.Sources[j].Priority + }) - return source, nil + return doc.Sources, nil } -func SaveSourceFile(path string, source Source) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err +// SaveSources saves the given list of sources to the configuration directory +func SaveSources(configDir string, sources []Source) error { + configPath := filepath.Join(configDir, "sources.yaml") + + doc := struct { + Sources []Source `yaml:"sources"` + }{ + Sources: sources, } - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - source.NormalizeDefaults(name) - source.Name = name + data, err := yaml.Marshal(&doc) + if err != nil { + return fmt.Errorf("marshal sources.yaml failed: %w", err) + } - data, err := json.MarshalIndent(source, "", " ") + err = os.MkdirAll(configDir, 0755) if err != nil { - return err + return fmt.Errorf("create config dir failed: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("write sources.yaml failed: %w", err) } - return os.WriteFile(path, data, 0644) + return nil } -func LoadCache(path string) (Cache, error) { - content, err := os.ReadFile(path) +// LoadCache loads parsed nodes strictly from cache file without verification +func LoadCache(cachePath string) (*Cache, error) { + data, err := os.ReadFile(cachePath) if err != nil { - return Cache{}, err + return nil, fmt.Errorf("read cache file failed: %w", err) } var cache Cache - if err := json.Unmarshal(content, &cache); err != nil { - return Cache{}, fmt.Errorf("invalid cache file %s: %w", path, err) + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("unmarshal cache file failed: %w", err) } - return cache, nil + return &cache, nil } -func SaveCache(path string, cache Cache) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - +// SaveCache saves parsed nodes back into the cache +func SaveCache(cachePath string, cache Cache) error { data, err := json.MarshalIndent(cache, "", " ") if err != nil { - return err + return fmt.Errorf("marshal cache file failed: %w", err) + } + + if err := os.WriteFile(cachePath, data, 0644); err != nil { + return fmt.Errorf("write cache file failed: %w", err) } - return os.WriteFile(path, data, 0644) + return nil } diff --git a/internal/proxy/config/subscription/types.go b/internal/proxy/config/subscription/types.go index 9dc70fc..b2ad5ed 100644 --- a/internal/proxy/config/subscription/types.go +++ b/internal/proxy/config/subscription/types.go @@ -1,5 +1,10 @@ package subscription +import ( + "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/parser" +) + // Source describes a subscription config file. type Source struct { Name string `json:"name"` @@ -11,51 +16,20 @@ type Source struct { Tags []string `json:"tags,omitempty"` } -// Node is a normalized outbound entry derived from subscriptions. -// Outbound contains sing-box outbound fields without tag. -type Node struct { - Name string `json:"name"` - Type string `json:"type"` - Source string `json:"source,omitempty"` - Outbound map[string]any `json:"outbound"` -} - // Cache stores parsed nodes from a subscription source. type Cache struct { - Source Source `json:"source"` - UpdatedAt string `json:"updated_at"` - Nodes []Node `json:"nodes"` -} - -const ( - FormatAuto = "auto" - FormatSingBox = "singbox" - FormatClash = "clash" - FormatBase64 = "base64" -) - -func NormalizeFormat(format string) string { - switch format { - case "", "auto": - return FormatAuto - case "json": - return FormatAuto - case "sing-box", "singbox": - return FormatSingBox - case "clash": - return FormatClash - default: - return format - } + Source Source `json:"source"` + UpdatedAt string `json:"updated_at"` + Nodes []node.Node `json:"nodes"` } func (s *Source) NormalizeDefaults(name string) { if s.Name == "" { s.Name = name } - s.Format = NormalizeFormat(s.Format) + s.Format = parser.NormalizeFormat(s.Format) if s.Format == "" { - s.Format = FormatAuto + s.Format = parser.FormatAuto } if s.Enabled == nil { enabled := true @@ -67,15 +41,6 @@ func (s *Source) NormalizeDefaults(name string) { } } -func IsActualOutboundType(outType string) bool { - switch outType { - case "selector", "urltest", "direct", "block", "dns": - return false - default: - return true - } -} - func (s Source) EnabledValue() bool { if s.Enabled == nil { return true From ce7db185487b0fde2263b1d7e864de920e8642b5 Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 02:15:43 +0800 Subject: [PATCH 15/23] refactor: Restructure config parsing and module utilities, moving parsing logic to a dedicated subscription package and consolidating module utilities under `node` and `utils` subdirectories. --- internal/app/cli/config.go | 3 +- internal/proxy/config/builder.go | 32 -------------- internal/proxy/config/config.go | 44 +++++++++++++++++-- internal/proxy/config/module/experimental.go | 5 ++- internal/proxy/config/module/mixed.go | 7 +-- .../proxy/config/module/{ => node}/naming.go | 13 +++--- .../config/module/{ => node}/processor.go | 5 ++- .../config/module/{ => node}/provider.go | 3 +- .../{ => node}/provider_subscription.go | 2 +- .../config/module/{ => node}/provider_user.go | 2 +- internal/proxy/config/module/outbound.go | 43 +++++++++--------- internal/proxy/config/module/route.go | 31 ++++++------- internal/proxy/config/module/tun.go | 5 ++- .../config/module/{ => utils}/constants.go | 0 .../proxy/config/module/{ => utils}/map.go | 0 .../proxy/config/module/{ => utils}/ports.go | 6 +-- .../adapter/hysteria.go | 0 .../adapter/registry.go | 0 .../adapter/ss_trojan.go | 0 .../{parser => subscription}/adapter/utils.go | 0 .../v.go => subscription/adapter/vless.go} | 0 .../config/{parser => subscription}/parse.go | 4 +- internal/proxy/config/subscription/refresh.go | 3 +- internal/proxy/config/subscription/types.go | 5 +-- 24 files changed, 109 insertions(+), 104 deletions(-) rename internal/proxy/config/module/{ => node}/naming.go (88%) rename internal/proxy/config/module/{ => node}/processor.go (95%) rename internal/proxy/config/module/{ => node}/provider.go (96%) rename internal/proxy/config/module/{ => node}/provider_subscription.go (98%) rename internal/proxy/config/module/{ => node}/provider_user.go (99%) rename internal/proxy/config/module/{ => utils}/constants.go (100%) rename internal/proxy/config/module/{ => utils}/map.go (100%) rename internal/proxy/config/module/{ => utils}/ports.go (83%) rename internal/proxy/config/{parser => subscription}/adapter/hysteria.go (100%) rename internal/proxy/config/{parser => subscription}/adapter/registry.go (100%) rename internal/proxy/config/{parser => subscription}/adapter/ss_trojan.go (100%) rename internal/proxy/config/{parser => subscription}/adapter/utils.go (100%) rename internal/proxy/config/{parser/adapter/v.go => subscription/adapter/vless.go} (100%) rename internal/proxy/config/{parser => subscription}/parse.go (97%) diff --git a/internal/app/cli/config.go b/internal/app/cli/config.go index 5e413b5..cb55c56 100644 --- a/internal/app/cli/config.go +++ b/internal/app/cli/config.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/proxy/config/parser" "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" @@ -98,7 +97,7 @@ func newConfigAddCommand() *cobra.Command { source := subscription.Source{ Name: name, URL: url, - Format: parser.NormalizeFormat(format), + Format: subscription.NormalizeFormat(format), Priority: priority, Enabled: &enabled, Dedupe: &dedupe, diff --git a/internal/proxy/config/builder.go b/internal/proxy/config/builder.go index 4be3f14..f504a8d 100644 --- a/internal/proxy/config/builder.go +++ b/internal/proxy/config/builder.go @@ -1,15 +1,12 @@ package config import ( - "encoding/json" "fmt" - "os" "github.com/kyson-dev/sing-helm/internal/proxy/config/module" "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" ) // Builder 配置构建器 @@ -55,33 +52,4 @@ func (b *Builder) Build() (*option.Options, error) { return result, nil } -// SaveToFile 构建配置并保存到文件 -func (b *Builder) SaveToFile(path string) error { - opts, err := b.Build() - if err != nil { - return err - } - - // 使用 sing-box 的 JSON 序列化 - data, err := singboxjson.Marshal(opts) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - // Re-marshal for pretty print - var pretty interface{} - if err := json.Unmarshal(data, &pretty); err != nil { - return fmt.Errorf("failed to unmarshal for pretty print: %w", err) - } - data, err = json.MarshalIndent(pretty, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal indent: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - logger.Info("Config saved", "path", path) - return nil -} diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 63a430a..2d59425 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -1,11 +1,16 @@ package config import ( + "encoding/json" "fmt" + "os" "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/module" + nodeProvider "github.com/kyson-dev/sing-helm/internal/proxy/config/module/node" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" ) // BuildConfig loads the profile, applies runtime modules, and saves raw config. @@ -15,8 +20,13 @@ func BuildConfig(rawPath string, runops *model.RunOptions) error { builder.With(m) } - if err := builder.SaveToFile(rawPath); err != nil { - return fmt.Errorf("failed to save raw config: %w", err) + opts, err := builder.Build() + if err != nil { + return fmt.Errorf("failed to build config: %w", err) + } + + if err := SaveToFile(rawPath, opts); err != nil { + return fmt.Errorf("failed to save config: %w", err) } return nil @@ -40,8 +50,8 @@ func DefaultModules(opts *model.RunOptions) []module.ConfigModule { modules := []module.ConfigModule{ module.NewOutboundModule( - &module.UserNodeProvider{}, - &module.SubscriptionNodeProvider{}, + &nodeProvider.UserNodeProvider{}, + &nodeProvider.SubscriptionNodeProvider{}, ), } @@ -77,3 +87,29 @@ func DefaultModules(opts *model.RunOptions) []module.ConfigModule { return modules } + +// SaveToFile 构建配置并保存到文件 +func SaveToFile(path string, opts *option.Options) error { + // 使用 sing-box 的 JSON 序列化 + data, err := singboxjson.Marshal(opts) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Re-marshal for pretty print + var pretty interface{} + if err := json.Unmarshal(data, &pretty); err != nil { + return fmt.Errorf("failed to unmarshal for pretty print: %w", err) + } + data, err = json.MarshalIndent(pretty, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal indent: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + logger.Info("Config saved", "path", path) + return nil +} diff --git a/internal/proxy/config/module/experimental.go b/internal/proxy/config/module/experimental.go index 0678b47..516b5c3 100644 --- a/internal/proxy/config/module/experimental.go +++ b/internal/proxy/config/module/experimental.go @@ -2,6 +2,7 @@ package module import ( "fmt" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) @@ -29,11 +30,11 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) erro // 确定 API 端口 apiPort := m.APIPort if apiPort == 0 { - if override, ok := getPortOverride(testAPIPortEnv); ok { + if override, ok := moduleUtils.GetPortOverride(testAPIPortEnv); ok { apiPort = override } else { var err error - apiPort, err = getFreePort() + apiPort, err = moduleUtils.GetFreePort() if err != nil { return err } diff --git a/internal/proxy/config/module/mixed.go b/internal/proxy/config/module/mixed.go index e1c191a..f147dff 100644 --- a/internal/proxy/config/module/mixed.go +++ b/internal/proxy/config/module/mixed.go @@ -1,6 +1,7 @@ package module import ( + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/sagernet/sing-box/option" ) @@ -28,11 +29,11 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { // 确定端口 port := m.Port if port == 0 { - if override, ok := getPortOverride(testMixedPortEnv); ok { + if override, ok := moduleUtils.GetPortOverride(testMixedPortEnv); ok { port = override } else { var err error - port, err = getFreePort() + port, err = moduleUtils.GetFreePort() if err != nil { return err } @@ -52,7 +53,7 @@ func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { "listen_port": port, "set_system_proxy": m.SetSystemProxy, } - ApplyMapToInbound(&mixedInbound, mixedMap) + moduleUtils.ApplyMapToInbound(&mixedInbound, mixedMap) // 添加到配置 opts.Inbounds = append(opts.Inbounds, mixedInbound) diff --git a/internal/proxy/config/module/naming.go b/internal/proxy/config/module/node/naming.go similarity index 88% rename from internal/proxy/config/module/naming.go rename to internal/proxy/config/module/node/naming.go index 3f20566..0004c13 100644 --- a/internal/proxy/config/module/naming.go +++ b/internal/proxy/config/module/node/naming.go @@ -1,17 +1,18 @@ -package module +package node import ( "fmt" "strconv" "strings" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" ) var reservedOutboundTags = map[string]bool{ - TagDirect: true, - TagBlock: true, - TagProxy: true, - TagAuto: true, - TagDNS: true, + moduleUtils.TagDirect: true, + moduleUtils.TagBlock: true, + moduleUtils.TagProxy: true, + moduleUtils.TagAuto: true, + moduleUtils.TagDNS: true, } func IsReservedOutboundTag(tag string) bool { diff --git a/internal/proxy/config/module/processor.go b/internal/proxy/config/module/node/processor.go similarity index 95% rename from internal/proxy/config/module/processor.go rename to internal/proxy/config/module/node/processor.go index 45891a7..4d2ad09 100644 --- a/internal/proxy/config/module/processor.go +++ b/internal/proxy/config/module/node/processor.go @@ -1,10 +1,11 @@ -package module +package node import ( "strings" "github.com/kyson-dev/sing-helm/internal/proxy/config/node" "github.com/sagernet/sing-box/option" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" ) // OutboundProcessor processes raw outbounds, manages tags, and prevents duplication globally. @@ -90,7 +91,7 @@ func (p *OutboundProcessor) mapToOutbound(outType, tag string, raw map[string]an } } - ApplyMapToOutbound(&outbound, rawCopy) + moduleUtils.ApplyMapToOutbound(&outbound, rawCopy) return outbound } diff --git a/internal/proxy/config/module/provider.go b/internal/proxy/config/module/node/provider.go similarity index 96% rename from internal/proxy/config/module/provider.go rename to internal/proxy/config/module/node/provider.go index 00189f2..89ede5c 100644 --- a/internal/proxy/config/module/provider.go +++ b/internal/proxy/config/module/node/provider.go @@ -1,5 +1,4 @@ -package module - +package node import "github.com/kyson-dev/sing-helm/internal/proxy/config/node" // NodeProvider is an interface for modules that provide proxy nodes. diff --git a/internal/proxy/config/module/provider_subscription.go b/internal/proxy/config/module/node/provider_subscription.go similarity index 98% rename from internal/proxy/config/module/provider_subscription.go rename to internal/proxy/config/module/node/provider_subscription.go index cbf6ed4..a587351 100644 --- a/internal/proxy/config/module/provider_subscription.go +++ b/internal/proxy/config/module/node/provider_subscription.go @@ -1,4 +1,4 @@ -package module +package node import ( "github.com/kyson-dev/sing-helm/internal/proxy/config/node" diff --git a/internal/proxy/config/module/provider_user.go b/internal/proxy/config/module/node/provider_user.go similarity index 99% rename from internal/proxy/config/module/provider_user.go rename to internal/proxy/config/module/node/provider_user.go index 9d7da5e..d6e1d1c 100644 --- a/internal/proxy/config/module/provider_user.go +++ b/internal/proxy/config/module/node/provider_user.go @@ -1,4 +1,4 @@ -package module +package node import ( "encoding/json" diff --git a/internal/proxy/config/module/outbound.go b/internal/proxy/config/module/outbound.go index a7d8522..a736a16 100644 --- a/internal/proxy/config/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -2,16 +2,18 @@ package module import ( "github.com/sagernet/sing-box/option" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + nodeProvider "github.com/kyson-dev/sing-helm/internal/proxy/config/module/node" ) // OutboundModule 出站模块 // 负责组装和构建 proxy, direct, block 以及各种出站节点群 type OutboundModule struct { - providers []NodeProvider + providers []nodeProvider.NodeProvider } // NewOutboundModule creates a new outbound module with the given providers. -func NewOutboundModule(providers ...NodeProvider) *OutboundModule { +func NewOutboundModule(providers ...nodeProvider.NodeProvider) *OutboundModule { return &OutboundModule{providers: providers} } @@ -20,7 +22,7 @@ func (m *OutboundModule) Name() string { } func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { - processor := NewOutboundProcessor() + processor := nodeProvider.NewOutboundProcessor() // 1. 从所有 Provider 获取节点 for _, provider := range m.providers { @@ -41,47 +43,44 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { // 5. 添加 direct 出站 directOutbound := option.Outbound{} directOutboundMap := map[string]any{ - "type": TagDirect, - "tag": TagDirect, + "type": moduleUtils.TagDirect, + "tag": moduleUtils.TagDirect, } - ApplyMapToOutbound(&directOutbound, directOutboundMap) + moduleUtils.ApplyMapToOutbound(&directOutbound, directOutboundMap) filteredOutbounds = append(filteredOutbounds, directOutbound) // 6. 添加 block 出站 blockOutbound := option.Outbound{} blockOutboundMap := map[string]any{ - "type": TagBlock, - "tag": TagBlock, + "type": moduleUtils.TagBlock, + "tag": moduleUtils.TagBlock, } - ApplyMapToOutbound(&blockOutbound, blockOutboundMap) + moduleUtils.ApplyMapToOutbound(&blockOutbound, blockOutboundMap) filteredOutbounds = append(filteredOutbounds, blockOutbound) // 根据是否有实际节点决定如何配置 auto 和 proxy 策略组 if len(actualNodes) > 0 { - // 有节点时的逻辑: - // - auto: urltest [all nodes] - // - proxy: selector [auto, ...all nodes] // 7. 添加 proxy selector - proxyNodes := append([]string{TagAuto}, actualNodes...) + proxyNodes := append([]string{moduleUtils.TagAuto}, actualNodes...) proxyOutbound := option.Outbound{} proxyOutboundMap := map[string]any{ "type": "selector", - "tag": TagProxy, + "tag": moduleUtils.TagProxy, "outbounds": proxyNodes, - "default": TagAuto, + "default": moduleUtils.TagAuto, } - ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) + moduleUtils.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) // 8. 添加 auto urltest autoOutbound := option.Outbound{} autoOutboundMap := map[string]any{ "type": "urltest", - "tag": TagAuto, + "tag": moduleUtils.TagAuto, "outbounds": actualNodes, } - ApplyMapToOutbound(&autoOutbound, autoOutboundMap) + moduleUtils.ApplyMapToOutbound(&autoOutbound, autoOutboundMap) filteredOutbounds = append(filteredOutbounds, autoOutbound) } else { // 无节点时的逻辑: @@ -89,11 +88,11 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { proxyOutbound := option.Outbound{} proxyOutboundMap := map[string]any{ "type": "selector", - "tag": TagProxy, - "outbounds": []string{TagDirect}, - "default": TagDirect, + "tag": moduleUtils.TagProxy, + "outbounds": []string{moduleUtils.TagDirect}, + "default": moduleUtils.TagDirect, } - ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) + moduleUtils.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) filteredOutbounds = append(filteredOutbounds, proxyOutbound) } diff --git a/internal/proxy/config/module/route.go b/internal/proxy/config/module/route.go index 4df0d94..fe139a4 100644 --- a/internal/proxy/config/module/route.go +++ b/internal/proxy/config/module/route.go @@ -4,6 +4,7 @@ import ( "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" ) // RouteModule 路由模块 @@ -23,18 +24,18 @@ func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { // 1. 如果用户没有自定义 final 出站,设置默认 if opts.Route.Final == "" { - opts.Route.Final = TagProxy + opts.Route.Final = moduleUtils.TagProxy } // 2. 将全局/直连模式转化为更高级别的劫持 switch m.RouteMode { case model.RouteModeGlobal: // 全局代理:覆盖前面的默认 Final - opts.Route.Final = TagProxy + opts.Route.Final = moduleUtils.TagProxy // 但我们需要保留 DNS 和局域网绕过的规则,因此我们仍然应用 default 规则 case model.RouteModeDirect: // 全局直连:所有流量默认直连 - opts.Route.Final = TagDirect + opts.Route.Final = moduleUtils.TagDirect } // 3. 构建并应用默认扩展拼图 (当用户没有完全接管路由时) @@ -58,15 +59,15 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { var rules []map[string]any // 片段 1: 局域网直连 (必须最优先) - rules = append(rules, map[string]any{"ip_is_private": true, "outbound": TagDirect}) + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": moduleUtils.TagDirect}) // 片段 2: NTP 直连 - rules = append(rules, map[string]any{"protocol": []string{"ntp"}, "outbound": TagDirect}) + rules = append(rules, map[string]any{"protocol": []string{"ntp"}, "outbound": moduleUtils.TagDirect}) // 片段 3: DNS 流量专门劫持 (在 TUN/Mixed 模式中,由 sing-box 本地解析) rules = append(rules, map[string]any{"protocol": []string{"dns"}, "action": "hijack-dns"}) // 针对 ali dns 放行(因为在 DNS 模块中配置了国内直接去 ali 解析,避免循环) - rules = append(rules, map[string]any{"ip_cidr": []string{"223.5.5.5/32", "223.6.6.6/32", "2400:3200::/32"}, "outbound": TagDirect}) + rules = append(rules, map[string]any{"ip_cidr": []string{"223.5.5.5/32", "223.6.6.6/32", "2400:3200::/32"}, "outbound": moduleUtils.TagDirect}) // 片段 4: 去广告模块 ruleSets = append(ruleSets, map[string]any{ @@ -74,22 +75,22 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { "type": "remote", "format": "binary", "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", - "download_detour": TagProxy, + "download_detour": moduleUtils.TagProxy, }) ruleSets = append(ruleSets, map[string]any{ "tag": "anti-ad", "type": "remote", "format": "binary", "url": "https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs", - "download_detour": TagProxy, + "download_detour": moduleUtils.TagProxy, }) - rules = append(rules, map[string]any{"rule_set": []string{"geosite-ads", "anti-ad"}, "outbound": TagBlock}) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-ads", "anti-ad"}, "outbound": moduleUtils.TagBlock}) // 片段 5: 直连白名单 rules = append(rules, map[string]any{ "domain_suffix": []string{"wise.com", "schwab.com", "interactivebrokers.com", "cloudflare.com", "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, - "outbound": TagDirect, + "outbound": moduleUtils.TagDirect, }) // 片段 6: Apple 流量直连 @@ -98,9 +99,9 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { "type": "remote", "format": "binary", "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs", - "download_detour": TagProxy, + "download_detour": moduleUtils.TagProxy, }) - rules = append(rules, map[string]any{"rule_set": []string{"geosite-apple"}, "outbound": TagDirect}) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-apple"}, "outbound": moduleUtils.TagDirect}) // 片段 7: 国内直连 (CN 路由分流) ruleSets = append(ruleSets, map[string]any{ @@ -108,16 +109,16 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { "type": "remote", "format": "binary", "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", - "download_detour": TagProxy, + "download_detour": moduleUtils.TagProxy, }) ruleSets = append(ruleSets, map[string]any{ "tag": "geoip-cn", "type": "remote", "format": "binary", "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", - "download_detour": TagProxy, + "download_detour": moduleUtils.TagProxy, }) - rules = append(rules, map[string]any{"rule_set": []string{"geosite-cn", "geoip-cn"}, "outbound": TagDirect}) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-cn", "geoip-cn"}, "outbound": moduleUtils.TagDirect}) // 此时组合成一个整体 map 进行反序列化 (为了兼容 sing-box 的 rule 抽象类型) routeMap := map[string]any{ diff --git a/internal/proxy/config/module/tun.go b/internal/proxy/config/module/tun.go index c75a537..e7a59ae 100644 --- a/internal/proxy/config/module/tun.go +++ b/internal/proxy/config/module/tun.go @@ -5,6 +5,7 @@ import ( "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" singboxjson "github.com/sagernet/sing/common/json" ) @@ -44,7 +45,7 @@ func (m *TUNModule) Apply(opts *option.Options, ctx *BuildContext) error { "sniff": true, "sniff_override_destination": true, } - ApplyMapToInbound(&tunInbound, tunMap) + moduleUtils.ApplyMapToInbound(&tunInbound, tunMap) // 添加到配置 opts.Inbounds = append(opts.Inbounds, tunInbound) @@ -76,7 +77,7 @@ func (m *TUNDNSModule) Apply(opts *option.Options, ctx *BuildContext) error { "type": "https", "server": "dns.google", "domain_resolver": "resolver_dns", - "detour": TagProxy, + "detour": moduleUtils.TagProxy, }, { "tag": "resolver_dns", diff --git a/internal/proxy/config/module/constants.go b/internal/proxy/config/module/utils/constants.go similarity index 100% rename from internal/proxy/config/module/constants.go rename to internal/proxy/config/module/utils/constants.go diff --git a/internal/proxy/config/module/map.go b/internal/proxy/config/module/utils/map.go similarity index 100% rename from internal/proxy/config/module/map.go rename to internal/proxy/config/module/utils/map.go diff --git a/internal/proxy/config/module/ports.go b/internal/proxy/config/module/utils/ports.go similarity index 83% rename from internal/proxy/config/module/ports.go rename to internal/proxy/config/module/utils/ports.go index ed4c3c1..7077002 100644 --- a/internal/proxy/config/module/ports.go +++ b/internal/proxy/config/module/utils/ports.go @@ -6,9 +6,9 @@ import ( "strconv" ) -// getPortOverride 返回测试期间指定的端口。 +// GetPortOverride 返回测试期间指定的端口。 // 如果对应环境变量有效(正整数),返回该端口并标记为存在。 -func getPortOverride(envKey string) (int, bool) { +func GetPortOverride(envKey string) (int, bool) { value := os.Getenv(envKey) if value == "" { return 0, false @@ -22,7 +22,7 @@ func getPortOverride(envKey string) (int, bool) { } // GetFreePort 请求内核分配一个空闲端口 -func getFreePort() (int, error) { +func GetFreePort() (int, error) { // 监听端口 0,内核会自动分配一个空闲端口 addr, err := net.ResolveTCPAddr("tcp", "localhost:0") if err != nil { diff --git a/internal/proxy/config/parser/adapter/hysteria.go b/internal/proxy/config/subscription/adapter/hysteria.go similarity index 100% rename from internal/proxy/config/parser/adapter/hysteria.go rename to internal/proxy/config/subscription/adapter/hysteria.go diff --git a/internal/proxy/config/parser/adapter/registry.go b/internal/proxy/config/subscription/adapter/registry.go similarity index 100% rename from internal/proxy/config/parser/adapter/registry.go rename to internal/proxy/config/subscription/adapter/registry.go diff --git a/internal/proxy/config/parser/adapter/ss_trojan.go b/internal/proxy/config/subscription/adapter/ss_trojan.go similarity index 100% rename from internal/proxy/config/parser/adapter/ss_trojan.go rename to internal/proxy/config/subscription/adapter/ss_trojan.go diff --git a/internal/proxy/config/parser/adapter/utils.go b/internal/proxy/config/subscription/adapter/utils.go similarity index 100% rename from internal/proxy/config/parser/adapter/utils.go rename to internal/proxy/config/subscription/adapter/utils.go diff --git a/internal/proxy/config/parser/adapter/v.go b/internal/proxy/config/subscription/adapter/vless.go similarity index 100% rename from internal/proxy/config/parser/adapter/v.go rename to internal/proxy/config/subscription/adapter/vless.go diff --git a/internal/proxy/config/parser/parse.go b/internal/proxy/config/subscription/parse.go similarity index 97% rename from internal/proxy/config/parser/parse.go rename to internal/proxy/config/subscription/parse.go index ba06de1..246ff83 100644 --- a/internal/proxy/config/parser/parse.go +++ b/internal/proxy/config/subscription/parse.go @@ -1,4 +1,4 @@ -package parser +package subscription import ( "encoding/base64" @@ -7,7 +7,7 @@ import ( "strings" "github.com/kyson-dev/sing-helm/internal/proxy/config/node" - "github.com/kyson-dev/sing-helm/internal/proxy/config/parser/adapter" + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription/adapter" "github.com/kyson-dev/sing-helm/internal/sys/logger" "gopkg.in/yaml.v3" ) diff --git a/internal/proxy/config/subscription/refresh.go b/internal/proxy/config/subscription/refresh.go index a83298d..99100f1 100644 --- a/internal/proxy/config/subscription/refresh.go +++ b/internal/proxy/config/subscription/refresh.go @@ -9,7 +9,6 @@ import ( "path/filepath" "time" - "github.com/kyson-dev/sing-helm/internal/proxy/config/parser" "github.com/kyson-dev/sing-helm/internal/sys/logger" ) @@ -41,7 +40,7 @@ func Refresh(ctx context.Context, source Source, cacheDir string) error { return fmt.Errorf("read body failed: %w", err) } - nodes, err := parser.Parse(content, source.Format) + nodes, err := Parse(content, source.Format) if err != nil { return fmt.Errorf("parse subscription failed: %w", err) } diff --git a/internal/proxy/config/subscription/types.go b/internal/proxy/config/subscription/types.go index b2ad5ed..6ca97de 100644 --- a/internal/proxy/config/subscription/types.go +++ b/internal/proxy/config/subscription/types.go @@ -2,7 +2,6 @@ package subscription import ( "github.com/kyson-dev/sing-helm/internal/proxy/config/node" - "github.com/kyson-dev/sing-helm/internal/proxy/config/parser" ) // Source describes a subscription config file. @@ -27,9 +26,9 @@ func (s *Source) NormalizeDefaults(name string) { if s.Name == "" { s.Name = name } - s.Format = parser.NormalizeFormat(s.Format) + s.Format = NormalizeFormat(s.Format) if s.Format == "" { - s.Format = parser.FormatAuto + s.Format = FormatAuto } if s.Enabled == nil { enabled := true From 1c8f39b10f7f954d602073bc47ecd3ddb4af7069 Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 02:22:33 +0800 Subject: [PATCH 16/23] refactor(proxy): centralize global deduplication pipeline in OutboundProcessor --- .../proxy/config/module/node/processor.go | 38 +++++++++++-- .../proxy/config/module/utils/constants.go | 2 +- internal/proxy/config/node/node.go | 9 ++-- internal/proxy/config/subscription/merge.go | 53 ++----------------- 4 files changed, 44 insertions(+), 58 deletions(-) diff --git a/internal/proxy/config/module/node/processor.go b/internal/proxy/config/module/node/processor.go index 4d2ad09..2eee869 100644 --- a/internal/proxy/config/module/node/processor.go +++ b/internal/proxy/config/module/node/processor.go @@ -1,11 +1,12 @@ package node import ( + "fmt" "strings" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/kyson-dev/sing-helm/internal/proxy/config/node" "github.com/sagernet/sing-box/option" - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" ) // OutboundProcessor processes raw outbounds, manages tags, and prevents duplication globally. @@ -17,13 +18,17 @@ type OutboundProcessor struct { // sourceGroups maps source names (or 'user') to their nodes' tags. Useful for grouping. sourceGroups map[string][]string + + // globalFingerprints prevents identical nodes (same IP:Port+Type) across all sources. + globalFingerprints map[string]bool } func NewOutboundProcessor() *OutboundProcessor { return &OutboundProcessor{ - usedTags: make(map[string]bool), - originalToTag: make(map[string]map[string]string), - sourceGroups: make(map[string][]string), + usedTags: make(map[string]bool), + originalToTag: make(map[string]map[string]string), + sourceGroups: make(map[string][]string), + globalFingerprints: make(map[string]bool), } } @@ -35,6 +40,19 @@ func (p *OutboundProcessor) AddNodes(nodes []node.Node) { source = "unknown" } + // 1. Global deduplication + if !n.SkipDedupe { + fp := p.fingerprint(n) + if p.globalFingerprints[fp] { + // Record mapping anyway so detour references still work + // We map the duplicate's original name to whatever tag we gave to the FIRST seen node. + // Wait, we don't know the first seen node's name easily. + // But we can just skip it here. + continue + } + p.globalFingerprints[fp] = true + } + // Ensure uniqueness of tag uniqueTag := MakeUniqueOutboundTag(n.Name, source, p.usedTags) p.recordMapping(source, n.Name, uniqueTag) @@ -65,6 +83,18 @@ func (p *OutboundProcessor) GetGroups() map[string][]string { // --- Internal helpers --- +func (p *OutboundProcessor) fingerprint(n node.Node) string { + if n.Outbound != nil { + server, hasServer := n.Outbound["server"].(string) + port, hasPort := n.Outbound["server_port"] + if hasServer && hasPort { + return fmt.Sprintf("%s:%v|%s", server, port, n.Type) + } + } + // Fallback to name+type if no server/port + return n.Name + "|" + n.Type +} + func (p *OutboundProcessor) recordMapping(source, original, unique string) { if p.originalToTag[source] == nil { p.originalToTag[source] = make(map[string]string) diff --git a/internal/proxy/config/module/utils/constants.go b/internal/proxy/config/module/utils/constants.go index ddefeca..790d4c2 100644 --- a/internal/proxy/config/module/utils/constants.go +++ b/internal/proxy/config/module/utils/constants.go @@ -6,5 +6,5 @@ const ( TagBlock = "block" TagProxy = "proxy" TagAuto = "auto" - TagDNS = "dns-out" + //TagDNS = "dns-out" ) diff --git a/internal/proxy/config/node/node.go b/internal/proxy/config/node/node.go index df1d697..51cb28f 100644 --- a/internal/proxy/config/node/node.go +++ b/internal/proxy/config/node/node.go @@ -2,8 +2,9 @@ package node // Node is a normalized outbound entry representing a proxy node in a universal format. type Node struct { - Name string `json:"name"` - Type string `json:"type"` - Source string `json:"source,omitempty"` - Outbound map[string]any `json:"outbound"` + Name string `json:"name"` + Type string `json:"type"` + Source string `json:"source,omitempty"` + SkipDedupe bool `json:"-"` + Outbound map[string]any `json:"outbound"` } diff --git a/internal/proxy/config/subscription/merge.go b/internal/proxy/config/subscription/merge.go index f8f51ec..937d35d 100644 --- a/internal/proxy/config/subscription/merge.go +++ b/internal/proxy/config/subscription/merge.go @@ -1,7 +1,6 @@ package subscription import ( - "fmt" "path/filepath" "strings" @@ -12,8 +11,6 @@ import ( // LoadNodesFromCache reads from cache files honoring priority and enablement func LoadNodesFromCache(sources []Source, cacheDir string) ([]node.Node, error) { var finalNodes []node.Node - globalSeen := make(map[string]bool) - for _, s := range sources { if !s.EnabledValue() { logger.Debug("Skipping disabled source", "name", s.Name) @@ -37,20 +34,11 @@ func LoadNodesFromCache(sources []Source, cacheDir string) ([]node.Node, error) nodes = appendTags(nodes, s.Tags) } - // Source deduplication - if s.DedupeValue() { - nodes = dedupeWithinSource(nodes) - } - - // Global deduplication (across sources) + // Pass dedupe intention to the node level for _, n := range nodes { - // use standard signature for global dedupe - sig := globalSignature(n) - if !globalSeen[sig] { - globalSeen[sig] = true - n.Source = s.Name - finalNodes = append(finalNodes, n) - } + n.Source = s.Name + n.SkipDedupe = !s.DedupeValue() + finalNodes = append(finalNodes, n) } } @@ -67,36 +55,3 @@ func appendTags(nodes []node.Node, tags []string) []node.Node { } return nodes } - -func dedupeWithinSource(nodes []node.Node) []node.Node { - seen := make(map[string]bool) - var deduped []node.Node - for _, n := range nodes { - sig := localSignature(n) - if !seen[sig] { - seen[sig] = true - deduped = append(deduped, n) - } - } - return deduped -} - -// localSignature is used for deduplication within the same source. -// We use name + type as signature since many users only have one server -// but use name to distinguish them. -func localSignature(n node.Node) string { - return n.Name + "|" + n.Type -} - -// globalSignature is used for cross-source deduplication. -// We try to use server+port combination if possible, fallback to localSignature. -func globalSignature(n node.Node) string { - if n.Outbound != nil { - server, hasServer := n.Outbound["server"].(string) - port, hasPort := n.Outbound["server_port"] - if hasServer && hasPort { - return fmt.Sprintf("%s:%v|%s", server, port, n.Type) - } - } - return localSignature(n) -} From 6210ff7173e16b685ff472d6c34fb71a103bb79b Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 02:49:50 +0800 Subject: [PATCH 17/23] fix(proxy): inject rule_set format for backward compatibility with sing-box v1.11.x --- internal/proxy/config/config.go | 32 ++++++++++++++++++- .../proxy/config/module/node/provider_user.go | 7 +++- .../proxy/config/module/utils/constants.go | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 2d59425..6bc231b 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/module" @@ -97,10 +98,39 @@ func SaveToFile(path string, opts *option.Options) error { } // Re-marshal for pretty print - var pretty interface{} + // Re-marshal for pretty print + var pretty map[string]any if err := json.Unmarshal(data, &pretty); err != nil { return fmt.Errorf("failed to unmarshal for pretty print: %w", err) } + + // Hotfix for sing-box v1.13.0-rc.7 removing 'format' from output JSON. + // We inject 'format' back inside 'rule_set' to maintain compat with v1.11.x + if routeStruct, ok := pretty["route"].(map[string]any); ok { + if ruleSets, ok := routeStruct["rule_set"].([]any); ok { + for idx, rs := range ruleSets { + if rsMap, ok := rs.(map[string]any); ok { + if _, hasFormat := rsMap["format"]; !hasFormat { + // guess format by url/path extension + urlStr, _ := rsMap["url"].(string) + if urlStr == "" { + urlStr, _ = rsMap["path"].(string) + } + if urlStr != "" { + if strings.HasSuffix(urlStr, ".srs") { + rsMap["format"] = "binary" + } else if strings.HasSuffix(urlStr, ".json") { + rsMap["format"] = "source" + } + ruleSets[idx] = rsMap + } + } + } + } + routeStruct["rule_set"] = ruleSets + } + } + data, err = json.MarshalIndent(pretty, "", " ") if err != nil { return fmt.Errorf("failed to marshal indent: %w", err) diff --git a/internal/proxy/config/module/node/provider_user.go b/internal/proxy/config/module/node/provider_user.go index d6e1d1c..076888e 100644 --- a/internal/proxy/config/module/node/provider_user.go +++ b/internal/proxy/config/module/node/provider_user.go @@ -27,9 +27,14 @@ func (p *UserNodeProvider) GetNodes() ([]node.Node, error) { return nil, fmt.Errorf("read profile error: %w", err) } + if len(profileData) == 0 { + return nil, nil // Return empty if profile is a 0-byte file + } + var root map[string]any if err := json.Unmarshal(profileData, &root); err != nil { - return nil, fmt.Errorf("unmarshal profile error: %w", err) + logger.Error("Failed to parse profile.json, skipping user nodes", "error", err) + return nil, nil } outboundsRaw, ok := root["outbounds"] diff --git a/internal/proxy/config/module/utils/constants.go b/internal/proxy/config/module/utils/constants.go index 790d4c2..ddefeca 100644 --- a/internal/proxy/config/module/utils/constants.go +++ b/internal/proxy/config/module/utils/constants.go @@ -6,5 +6,5 @@ const ( TagBlock = "block" TagProxy = "proxy" TagAuto = "auto" - //TagDNS = "dns-out" + TagDNS = "dns-out" ) From 44c7ab4f641ff98b8ad0e0def6b6dc8b9ccb21ee Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 10:59:57 +0800 Subject: [PATCH 18/23] refactor: Migrate core models and versioning to `sys` and `proxy/config/model`, and refactor subscription storage to use JSON files. --- internal/app/cli/config.go | 8 +- internal/app/cli/config_ops.go | 21 ++--- internal/app/cli/monitor.go | 14 +++- internal/app/cli/root.go | 21 ----- internal/app/cli/serve.go | 2 +- internal/app/cli/version.go | 2 +- internal/app/container.go | 23 ------ internal/app/daemon/daemon.go | 9 +-- internal/app/daemon/handler_mode.go | 2 +- internal/app/daemon/handler_run.go | 6 +- internal/app/daemon/{meta.go => runtime.go} | 0 internal/{core/model => app/daemon}/state.go | 11 ++- internal/core/model/platform_bridge.go | 10 --- internal/proxy/config/builder.go | 2 +- internal/proxy/config/config.go | 52 ++++++------ internal/proxy/config/export/export.go | 4 + internal/proxy/config/{node => model}/node.go | 2 +- .../{core => proxy/config}/model/options.go | 0 internal/proxy/config/module/experimental.go | 1 + .../proxy/config/module/node/processor.go | 10 +-- internal/proxy/config/module/node/provider.go | 4 +- .../module/node/provider_subscription.go | 8 +- .../proxy/config/module/node/provider_user.go | 8 +- internal/proxy/config/module/outbound.go | 4 +- internal/proxy/config/module/route.go | 4 +- internal/proxy/config/module/tun.go | 2 +- internal/proxy/config/module/types.go | 2 +- .../config/subscription/adapter/hysteria.go | 30 +++---- .../config/subscription/adapter/registry.go | 6 +- .../config/subscription/adapter/ss_trojan.go | 32 ++++---- .../config/subscription/adapter/vless.go | 32 ++++---- internal/proxy/config/subscription/merge.go | 8 +- internal/proxy/config/subscription/parse.go | 18 ++--- internal/proxy/config/subscription/storage.go | 79 +++++++++---------- internal/proxy/config/subscription/types.go | 4 +- internal/{core => sys}/version/Into_test.go | 2 +- internal/{core => sys}/version/info.go | 0 serve_log.txt | 13 +++ 38 files changed, 212 insertions(+), 244 deletions(-) delete mode 100644 internal/app/container.go rename internal/app/daemon/{meta.go => runtime.go} (100%) rename internal/{core/model => app/daemon}/state.go (82%) delete mode 100644 internal/core/model/platform_bridge.go rename internal/proxy/config/{node => model}/node.go (95%) rename internal/{core => proxy/config}/model/options.go (100%) rename internal/{core => sys}/version/Into_test.go (82%) rename internal/{core => sys}/version/info.go (100%) create mode 100644 serve_log.txt diff --git a/internal/app/cli/config.go b/internal/app/cli/config.go index cb55c56..bec5e65 100644 --- a/internal/app/cli/config.go +++ b/internal/app/cli/config.go @@ -102,13 +102,11 @@ func newConfigAddCommand() *cobra.Command { Enabled: &enabled, Dedupe: &dedupe, } - sources = append(sources, source) - - if err := subscription.SaveSources(p.SubConfigDir, sources); err != nil { + if err := subscription.SaveSource(p.SubConfigDir, source); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Saved: %s\n", filepath.Join(p.SubConfigDir, "sources.yaml")) + fmt.Fprintf(cmd.OutOrStdout(), "Saved: %s\n", filepath.Join(p.SubConfigDir, source.Name+".json")) return nil }, } @@ -134,7 +132,7 @@ func newConfigEditCommand() *cobra.Command { if err := os.MkdirAll(p.SubCacheDir, 0755); err != nil { return fmt.Errorf("failed to create cache dir: %w", err) } - target = filepath.Join(p.SubConfigDir, "sources.yaml") + target = filepath.Join(p.SubConfigDir, args[0]+".json") } return openInEditor(cmd, target) }, diff --git a/internal/app/cli/config_ops.go b/internal/app/cli/config_ops.go index a4eaace..a1fdfeb 100644 --- a/internal/app/cli/config_ops.go +++ b/internal/app/cli/config_ops.go @@ -50,10 +50,9 @@ func openInEditor(cmd *cobra.Command, path string) error { editor = "vim" // Default to vim if EDITOR not set } - // 检查目标文件所属的 sources.yaml 中的 name - // 但是因为现在是 sources.yaml 了,不再是一个文件一个源码,所以我们应该打开 sources.yaml - if strings.Contains(path, "sources.yaml") { - // allow edit + // Simple editor opener + if strings.HasSuffix(path, ".json") { + // allow edit json files } execCmd := exec.Command(editor, path) @@ -116,7 +115,10 @@ func refreshOneSubscription(cmd *cobra.Command, name, configDir, cacheDir string } func deleteAllSubscriptions(cmd *cobra.Command, configDir, cacheDir string) error { - _ = os.Remove(filepath.Join(configDir, "sources.yaml")) + sources, _ := subscription.LoadSources(configDir) + for _, s := range sources { + _ = subscription.DeleteSource(configDir, s.Name) + } _ = os.RemoveAll(cacheDir) fmt.Fprintf(cmd.OutOrStdout(), "Deleted all subscriptions.\n") return nil @@ -142,14 +144,7 @@ func deleteOneSubscription(cmd *cobra.Command, name, configDir, cacheDir string) return fmt.Errorf("subscription not found: %s", name) } - // Update sources.yaml - if len(newSources) == 0 { - _ = os.Remove(filepath.Join(configDir, "sources.yaml")) - } else { - if err := subscription.SaveSources(configDir, newSources); err != nil { - return err - } - } + _ = subscription.DeleteSource(configDir, name) // Delete cache _ = os.Remove(filepath.Join(cacheDir, name+".json")) diff --git a/internal/app/cli/monitor.go b/internal/app/cli/monitor.go index c1488c2..2e82513 100644 --- a/internal/app/cli/monitor.go +++ b/internal/app/cli/monitor.go @@ -5,7 +5,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/kyson-dev/sing-helm/internal/app/tui/monitor" - "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) @@ -25,7 +24,7 @@ func newMonitorCommand() *cobra.Command { return fmt.Errorf("sing-box is not running") } listenAddr, _ := resp.Data["listen_addr"].(string) - apiPort, ok := ipc.AsInt(resp.Data["api_port"]) + apiPort, ok := asInt(resp.Data["api_port"]) if !ok || apiPort == 0 { return fmt.Errorf("failed to resolve API port from daemon status") } @@ -48,3 +47,14 @@ func newMonitorCommand() *cobra.Command { cmd.Flags().StringVarP(&host, "host", "H", "", "Sing-box API host") return cmd } +func asInt(val any) (int, bool) { + switch v := val.(type) { + case float64: + return int(v), true + case int: + return v, true + case int64: + return int(v), true + } + return 0, false +} diff --git a/internal/app/cli/root.go b/internal/app/cli/root.go index d50d5ea..f54ede1 100644 --- a/internal/app/cli/root.go +++ b/internal/app/cli/root.go @@ -1,27 +1,12 @@ package cli import ( - "context" "fmt" - "github.com/kyson-dev/sing-helm/internal/app" "github.com/kyson-dev/sing-helm/internal/sys/logger" - "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) -// appKey is the context key for the Application instance. -type appKey struct{} - -// AppFromContext retrieves the Application from a command's context. -// Returns nil if not set (should not happen after PersistentPreRunE). -func AppFromContext(ctx context.Context) *app.Application { - if v := ctx.Value(appKey{}); v != nil { - return v.(*app.Application) - } - return nil -} - func NewRootCommand() *cobra.Command { var homeDir string var globalDebug bool @@ -43,12 +28,6 @@ func NewRootCommand() *cobra.Command { logger.Setup(logger.Config{Debug: globalDebug, FilePath: logFile}) } - // Build the Application and attach to context - paths := paths.Get() - application := app.New(paths, logger.GetInstance()) - ctx := context.WithValue(cmd.Context(), appKey{}, application) - cmd.SetContext(ctx) - return nil }, } diff --git a/internal/app/cli/serve.go b/internal/app/cli/serve.go index c5fad9d..b16e0ed 100644 --- a/internal/app/cli/serve.go +++ b/internal/app/cli/serve.go @@ -10,9 +10,9 @@ import ( "strings" "syscall" - "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/proxy/config" "github.com/kyson-dev/sing-helm/internal/proxy/config/export" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) diff --git a/internal/app/cli/version.go b/internal/app/cli/version.go index 54b98be..75a126b 100644 --- a/internal/app/cli/version.go +++ b/internal/app/cli/version.go @@ -1,8 +1,8 @@ package cli import ( - "github.com/kyson-dev/sing-helm/internal/core/version" "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/version" "github.com/spf13/cobra" ) diff --git a/internal/app/container.go b/internal/app/container.go deleted file mode 100644 index 618fa64..0000000 --- a/internal/app/container.go +++ /dev/null @@ -1,23 +0,0 @@ -package app - -import ( - "log/slog" - - "github.com/kyson-dev/sing-helm/internal/sys/paths" -) - -// Application is the central dependency holder for the entire program. -// All business components obtain their dependencies from this struct, -// avoiding global singletons. -type Application struct { - Paths paths.Paths - Logger *slog.Logger -} - -// New creates an Application instance by resolving paths and setting up logging. -func New(paths paths.Paths, logger *slog.Logger) *Application { - return &Application{ - Paths: paths, - Logger: logger, - } -} diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go index 188f48f..faaa57c 100644 --- a/internal/app/daemon/daemon.go +++ b/internal/app/daemon/daemon.go @@ -6,7 +6,6 @@ import ( "os" "sync" - "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/proxy/engine" "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/kyson-dev/sing-helm/internal/sys/lock" @@ -30,7 +29,7 @@ type Daemon struct { lock *lock.DaemonLock running bool reloading bool - state *model.RuntimeState + state *RuntimeState } // NewDaemon builds a daemon controller. @@ -135,7 +134,7 @@ func (d *Daemon) cleanup() { } if state != nil { state.PID = 0 - if err := model.SaveState(state); err != nil { + if err := SaveState(state); err != nil { logger.Error("Failed to save runtime state", "error", err) } } @@ -148,7 +147,7 @@ func (d *Daemon) newService() ServiceRunner { return engine.NewInstance() } -func (d *Daemon) currentState() (*model.RuntimeState, error) { +func (d *Daemon) currentState() (*RuntimeState, error) { d.mu.Lock() state := d.state d.mu.Unlock() @@ -172,7 +171,7 @@ func (d *Daemon) setRunning(running bool) { } func (d *Daemon) loadState() { - state, err := model.LoadState() + state, err := LoadState() if err != nil { if os.IsNotExist(err) { return diff --git a/internal/app/daemon/handler_mode.go b/internal/app/daemon/handler_mode.go index a0e3900..ab1f550 100644 --- a/internal/app/daemon/handler_mode.go +++ b/internal/app/daemon/handler_mode.go @@ -4,7 +4,7 @@ import ( "context" "os" - "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/sys/ipc" ) diff --git a/internal/app/daemon/handler_run.go b/internal/app/daemon/handler_run.go index 41dc3f7..eaf26ec 100644 --- a/internal/app/daemon/handler_run.go +++ b/internal/app/daemon/handler_run.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config" "github.com/kyson-dev/sing-helm/internal/proxy/engine" "github.com/kyson-dev/sing-helm/internal/sys/ipc" @@ -60,7 +60,7 @@ func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.Comm d.mu.Lock() d.service = svc if d.state == nil { - d.state = &model.RuntimeState{} + d.state = &RuntimeState{} } d.state.RunOptions = runops d.mu.Unlock() @@ -111,7 +111,7 @@ func (d *Daemon) parseRunOptions(payload map[string]any) (model.RunOptions, erro } // applyRunOptions 重新构建配置并 reload sing-box -func (d *Daemon) applyRunOptions(ctx context.Context, state *model.RuntimeState) error { +func (d *Daemon) applyRunOptions(ctx context.Context, state *RuntimeState) error { // 检查并设置 reloading 标志,防止并发 reload d.mu.Lock() if d.reloading { diff --git a/internal/app/daemon/meta.go b/internal/app/daemon/runtime.go similarity index 100% rename from internal/app/daemon/meta.go rename to internal/app/daemon/runtime.go diff --git a/internal/core/model/state.go b/internal/app/daemon/state.go similarity index 82% rename from internal/core/model/state.go rename to internal/app/daemon/state.go index 333748c..49275c4 100644 --- a/internal/core/model/state.go +++ b/internal/app/daemon/state.go @@ -1,13 +1,16 @@ -package model +package daemon import ( "encoding/json" "os" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) type RuntimeState struct { - RunOptions RunOptions `json:"run_options"` - PID int `json:"pid"` + RunOptions model.RunOptions `json:"run_options"` + PID int `json:"pid"` } // SaveState saves runtime state to the given path. @@ -45,5 +48,5 @@ func LoadStateFrom(path string) (*RuntimeState, error) { func defaultStatePath() string { // Lazy import to avoid circular dependency at package level. // Uses the global singleton — callers that want DI should use SaveStateTo/LoadStateFrom. - return platformGetStateFile() + return paths.Get().StateFile } diff --git a/internal/core/model/platform_bridge.go b/internal/core/model/platform_bridge.go deleted file mode 100644 index ea07825..0000000 --- a/internal/core/model/platform_bridge.go +++ /dev/null @@ -1,10 +0,0 @@ -package model - -import "github.com/kyson-dev/sing-helm/internal/sys/paths" - -// platformGetStateFile returns the state file path from the global platform config. -// This is isolated here so state.go doesn't directly import platform, -// making it easier to eventually remove this dependency. -func platformGetStateFile() string { - return paths.Get().StateFile -} diff --git a/internal/proxy/config/builder.go b/internal/proxy/config/builder.go index f504a8d..e116fed 100644 --- a/internal/proxy/config/builder.go +++ b/internal/proxy/config/builder.go @@ -3,8 +3,8 @@ package config import ( "fmt" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/module" - "github.com/kyson-dev/sing-helm/internal/core/model" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/option" ) diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 6bc231b..09fa63a 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -4,9 +4,9 @@ import ( "encoding/json" "fmt" "os" - "strings" + //"strings" - "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/module" nodeProvider "github.com/kyson-dev/sing-helm/internal/proxy/config/module/node" "github.com/kyson-dev/sing-helm/internal/sys/logger" @@ -106,30 +106,30 @@ func SaveToFile(path string, opts *option.Options) error { // Hotfix for sing-box v1.13.0-rc.7 removing 'format' from output JSON. // We inject 'format' back inside 'rule_set' to maintain compat with v1.11.x - if routeStruct, ok := pretty["route"].(map[string]any); ok { - if ruleSets, ok := routeStruct["rule_set"].([]any); ok { - for idx, rs := range ruleSets { - if rsMap, ok := rs.(map[string]any); ok { - if _, hasFormat := rsMap["format"]; !hasFormat { - // guess format by url/path extension - urlStr, _ := rsMap["url"].(string) - if urlStr == "" { - urlStr, _ = rsMap["path"].(string) - } - if urlStr != "" { - if strings.HasSuffix(urlStr, ".srs") { - rsMap["format"] = "binary" - } else if strings.HasSuffix(urlStr, ".json") { - rsMap["format"] = "source" - } - ruleSets[idx] = rsMap - } - } - } - } - routeStruct["rule_set"] = ruleSets - } - } + // if routeStruct, ok := pretty["route"].(map[string]any); ok { + // if ruleSets, ok := routeStruct["rule_set"].([]any); ok { + // for idx, rs := range ruleSets { + // if rsMap, ok := rs.(map[string]any); ok { + // if _, hasFormat := rsMap["format"]; !hasFormat { + // // guess format by url/path extension + // urlStr, _ := rsMap["url"].(string) + // if urlStr == "" { + // urlStr, _ = rsMap["path"].(string) + // } + // if urlStr != "" { + // if strings.HasSuffix(urlStr, ".srs") { + // rsMap["format"] = "binary" + // } else if strings.HasSuffix(urlStr, ".json") { + // rsMap["format"] = "source" + // } + // ruleSets[idx] = rsMap + // } + // } + // } + // } + // routeStruct["rule_set"] = ruleSets + // } + // } data, err = json.MarshalIndent(pretty, "", " ") if err != nil { diff --git a/internal/proxy/config/export/export.go b/internal/proxy/config/export/export.go index 5d94de1..427c7ee 100644 --- a/internal/proxy/config/export/export.go +++ b/internal/proxy/config/export/export.go @@ -22,11 +22,15 @@ func Export(opts *option.Options, target Target) ([]byte, error) { return nil, fmt.Errorf("failed to marshal config: %w", err) } + fmt.Printf("DEBUG Raw JSON: %s\n", string(data)) // 检查这里的 JSON 是否有 + var root map[string]any if err := json.Unmarshal(data, &root); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } + fmt.Printf("DEBUG Map Root: %+v\n", root) + // No transforms needed if no target specified if strings.TrimSpace(target.Version) == "" && strings.TrimSpace(target.Platform) == "" { return json.MarshalIndent(root, "", " ") diff --git a/internal/proxy/config/node/node.go b/internal/proxy/config/model/node.go similarity index 95% rename from internal/proxy/config/node/node.go rename to internal/proxy/config/model/node.go index 51cb28f..36d9763 100644 --- a/internal/proxy/config/node/node.go +++ b/internal/proxy/config/model/node.go @@ -1,4 +1,4 @@ -package node +package model // Node is a normalized outbound entry representing a proxy node in a universal format. type Node struct { diff --git a/internal/core/model/options.go b/internal/proxy/config/model/options.go similarity index 100% rename from internal/core/model/options.go rename to internal/proxy/config/model/options.go diff --git a/internal/proxy/config/module/experimental.go b/internal/proxy/config/module/experimental.go index 516b5c3..8b47d05 100644 --- a/internal/proxy/config/module/experimental.go +++ b/internal/proxy/config/module/experimental.go @@ -2,6 +2,7 @@ package module import ( "fmt" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" diff --git a/internal/proxy/config/module/node/processor.go b/internal/proxy/config/module/node/processor.go index 2eee869..9b7e1c6 100644 --- a/internal/proxy/config/module/node/processor.go +++ b/internal/proxy/config/module/node/processor.go @@ -5,7 +5,7 @@ import ( "strings" moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/sagernet/sing-box/option" ) @@ -33,7 +33,7 @@ func NewOutboundProcessor() *OutboundProcessor { } // AddNodes processes a list of raw nodes gathered from a provider -func (p *OutboundProcessor) AddNodes(nodes []node.Node) { +func (p *OutboundProcessor) AddNodes(nodes []model.Node) { for _, n := range nodes { source := strings.TrimSpace(n.Source) if source == "" { @@ -45,7 +45,7 @@ func (p *OutboundProcessor) AddNodes(nodes []node.Node) { fp := p.fingerprint(n) if p.globalFingerprints[fp] { // Record mapping anyway so detour references still work - // We map the duplicate's original name to whatever tag we gave to the FIRST seen node. + // We map the duplicate's original name to whatever tag we gave to the FIRST seen model. // Wait, we don't know the first seen node's name easily. // But we can just skip it here. continue @@ -83,7 +83,7 @@ func (p *OutboundProcessor) GetGroups() map[string][]string { // --- Internal helpers --- -func (p *OutboundProcessor) fingerprint(n node.Node) string { +func (p *OutboundProcessor) fingerprint(n model.Node) string { if n.Outbound != nil { server, hasServer := n.Outbound["server"].(string) port, hasPort := n.Outbound["server_port"] @@ -133,7 +133,7 @@ func (p *OutboundProcessor) resolveDetour(target string) (string, bool) { // 2. linear search across all mappings // A more robust implementation would require knowing the source of the reference, - // but usually users reference by the original name of a user node. + // but usually users reference by the original name of a user model. for _, mapping := range p.originalToTag { if mapped, exists := mapping[target]; exists { return mapped, true diff --git a/internal/proxy/config/module/node/provider.go b/internal/proxy/config/module/node/provider.go index 89ede5c..7c910ee 100644 --- a/internal/proxy/config/module/node/provider.go +++ b/internal/proxy/config/module/node/provider.go @@ -1,5 +1,5 @@ package node -import "github.com/kyson-dev/sing-helm/internal/proxy/config/node" +import "github.com/kyson-dev/sing-helm/internal/proxy/config/model" // NodeProvider is an interface for modules that provide proxy nodes. // Examples: parsing from local user config, or reading from a subscription cache. @@ -7,5 +7,5 @@ type NodeProvider interface { // Name returns the provider's logic name. Name() string // GetNodes fetches a list of normalized outbound nodes. - GetNodes() ([]node.Node, error) + GetNodes() ([]model.Node, error) } diff --git a/internal/proxy/config/module/node/provider_subscription.go b/internal/proxy/config/module/node/provider_subscription.go index a587351..91b586a 100644 --- a/internal/proxy/config/module/node/provider_subscription.go +++ b/internal/proxy/config/module/node/provider_subscription.go @@ -1,7 +1,7 @@ package node import ( - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/kyson-dev/sing-helm/internal/sys/paths" @@ -14,7 +14,7 @@ func (p *SubscriptionNodeProvider) Name() string { return "subscription" } -func (p *SubscriptionNodeProvider) GetNodes() ([]node.Node, error) { +func (p *SubscriptionNodeProvider) GetNodes() ([]model.Node, error) { paths := paths.Get() sources, err := subscription.LoadSources(paths.SubConfigDir) if err != nil { @@ -27,7 +27,7 @@ func (p *SubscriptionNodeProvider) GetNodes() ([]node.Node, error) { return nil, nil // Return empty list instead of failing the whole build } - var nodes []node.Node + var nodes []model.Node for _, n := range subNodes { if n.Outbound == nil || n.Source == "" { continue @@ -38,7 +38,7 @@ func (p *SubscriptionNodeProvider) GetNodes() ([]node.Node, error) { outboundCopy[k] = v } - nodes = append(nodes, node.Node{ + nodes = append(nodes, model.Node{ Name: n.Name, Type: n.Type, Source: n.Source, // Provide the sub source name diff --git a/internal/proxy/config/module/node/provider_user.go b/internal/proxy/config/module/node/provider_user.go index 076888e..4cee177 100644 --- a/internal/proxy/config/module/node/provider_user.go +++ b/internal/proxy/config/module/node/provider_user.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/kyson-dev/sing-helm/internal/sys/paths" ) @@ -17,7 +17,7 @@ func (p *UserNodeProvider) Name() string { return "user" } -func (p *UserNodeProvider) GetNodes() ([]node.Node, error) { +func (p *UserNodeProvider) GetNodes() ([]model.Node, error) { paths := paths.Get() profileData, err := os.ReadFile(paths.ConfigFile) if err != nil { @@ -48,7 +48,7 @@ func (p *UserNodeProvider) GetNodes() ([]node.Node, error) { return nil, nil } - var nodes []node.Node + var nodes []model.Node for i, raw := range list { outMap, ok := raw.(map[string]any) if !ok { @@ -65,7 +65,7 @@ func (p *UserNodeProvider) GetNodes() ([]node.Node, error) { } delete(outMap, "tag") - nodes = append(nodes, node.Node{ + nodes = append(nodes, model.Node{ Name: name, Type: outType, Source: "user", // Indicates it came from user config diff --git a/internal/proxy/config/module/outbound.go b/internal/proxy/config/module/outbound.go index a736a16..fca2b99 100644 --- a/internal/proxy/config/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -1,9 +1,9 @@ package module import ( - "github.com/sagernet/sing-box/option" - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" nodeProvider "github.com/kyson-dev/sing-helm/internal/proxy/config/module/node" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/option" ) // OutboundModule 出站模块 diff --git a/internal/proxy/config/module/route.go b/internal/proxy/config/module/route.go index fe139a4..c29f1aa 100644 --- a/internal/proxy/config/module/route.go +++ b/internal/proxy/config/module/route.go @@ -1,10 +1,10 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" ) // RouteModule 路由模块 diff --git a/internal/proxy/config/module/tun.go b/internal/proxy/config/module/tun.go index e7a59ae..04ca939 100644 --- a/internal/proxy/config/module/tun.go +++ b/internal/proxy/config/module/tun.go @@ -3,9 +3,9 @@ package module import ( "context" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" singboxjson "github.com/sagernet/sing/common/json" ) diff --git a/internal/proxy/config/module/types.go b/internal/proxy/config/module/types.go index 0d572de..54b7235 100644 --- a/internal/proxy/config/module/types.go +++ b/internal/proxy/config/module/types.go @@ -1,7 +1,7 @@ package module import ( - "github.com/kyson-dev/sing-helm/internal/core/model" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/sagernet/sing-box/option" ) diff --git a/internal/proxy/config/subscription/adapter/hysteria.go b/internal/proxy/config/subscription/adapter/hysteria.go index 3e06021..e8bd882 100644 --- a/internal/proxy/config/subscription/adapter/hysteria.go +++ b/internal/proxy/config/subscription/adapter/hysteria.go @@ -4,7 +4,7 @@ import ( "fmt" "net/url" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" ) // HysteriaAdapter handles Hysteria protocol @@ -14,11 +14,11 @@ func init() { Register("hysteria", &HysteriaAdapter{}) } -func (a *HysteriaAdapter) FromClash(m map[string]any) (node.Node, error) { +func (a *HysteriaAdapter) FromClash(m map[string]any) (model.Node, error) { server := ReadString(m, "server") port := ReadInt(m, "port") if server == "" || port == 0 { - return node.Node{}, fmt.Errorf("missing server or port") + return model.Node{}, fmt.Errorf("missing server or port") } auth := ReadString(m, "auth_str", "auth-str", "auth") @@ -46,16 +46,16 @@ func (a *HysteriaAdapter) FromClash(m map[string]any) (node.Node, error) { ApplyTLSOptions(outbound, m) - return node.Node{ + return model.Node{ Type: "hysteria", Outbound: outbound, }, nil } -func (a *HysteriaAdapter) FromURI(uriStr string) (node.Node, error) { +func (a *HysteriaAdapter) FromURI(uriStr string) (model.Node, error) { u, err := url.Parse("hysteria://" + uriStr) if err != nil { - return node.Node{}, err + return model.Node{}, err } auth := u.User.Username() @@ -65,7 +65,7 @@ func (a *HysteriaAdapter) FromURI(uriStr string) (node.Node, error) { query := u.Query() if server == "" || port == "" { - return node.Node{}, fmt.Errorf("missing required fields") + return model.Node{}, fmt.Errorf("missing required fields") } portNum, _ := ParseInt(port) @@ -99,7 +99,7 @@ func (a *HysteriaAdapter) FromURI(uriStr string) (node.Node, error) { } outbound["tls"] = tls - return node.Node{ + return model.Node{ Name: name, Type: "hysteria", Outbound: outbound, @@ -114,11 +114,11 @@ func init() { Register("hy2", &Hysteria2Adapter{}) } -func (a *Hysteria2Adapter) FromClash(m map[string]any) (node.Node, error) { +func (a *Hysteria2Adapter) FromClash(m map[string]any) (model.Node, error) { server := ReadString(m, "server") port := ReadInt(m, "port") if server == "" || port == 0 { - return node.Node{}, fmt.Errorf("missing server or port") + return model.Node{}, fmt.Errorf("missing server or port") } password := ReadString(m, "password") @@ -138,16 +138,16 @@ func (a *Hysteria2Adapter) FromClash(m map[string]any) (node.Node, error) { ApplyTLSOptions(outbound, m) - return node.Node{ + return model.Node{ Type: "hysteria2", Outbound: outbound, }, nil } -func (a *Hysteria2Adapter) FromURI(uriStr string) (node.Node, error) { +func (a *Hysteria2Adapter) FromURI(uriStr string) (model.Node, error) { u, err := url.Parse("hysteria2://" + uriStr) if err != nil { - return node.Node{}, err + return model.Node{}, err } password := u.User.Username() @@ -157,7 +157,7 @@ func (a *Hysteria2Adapter) FromURI(uriStr string) (node.Node, error) { query := u.Query() if server == "" || port == "" { - return node.Node{}, fmt.Errorf("missing required fields") + return model.Node{}, fmt.Errorf("missing required fields") } portNum, _ := ParseInt(port) @@ -177,7 +177,7 @@ func (a *Hysteria2Adapter) FromURI(uriStr string) (node.Node, error) { } outbound["tls"] = tls - return node.Node{ + return model.Node{ Name: name, Type: "hysteria2", Outbound: outbound, diff --git a/internal/proxy/config/subscription/adapter/registry.go b/internal/proxy/config/subscription/adapter/registry.go index 0664c49..a473d2c 100644 --- a/internal/proxy/config/subscription/adapter/registry.go +++ b/internal/proxy/config/subscription/adapter/registry.go @@ -3,13 +3,13 @@ package adapter import ( "fmt" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" ) // ProtocolAdapter describes how to parse different node formats into a standard Node. type ProtocolAdapter interface { - FromClash(m map[string]any) (node.Node, error) - FromURI(uri string) (node.Node, error) + FromClash(m map[string]any) (model.Node, error) + FromURI(uri string) (model.Node, error) } var registry = make(map[string]ProtocolAdapter) diff --git a/internal/proxy/config/subscription/adapter/ss_trojan.go b/internal/proxy/config/subscription/adapter/ss_trojan.go index b7efc4b..09dd49f 100644 --- a/internal/proxy/config/subscription/adapter/ss_trojan.go +++ b/internal/proxy/config/subscription/adapter/ss_trojan.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" ) // ShadowsocksAdapter handles Shadowsocks protocol @@ -17,11 +17,11 @@ func init() { Register("shadowsocks", &ShadowsocksAdapter{}) } -func (a *ShadowsocksAdapter) FromClash(m map[string]any) (node.Node, error) { +func (a *ShadowsocksAdapter) FromClash(m map[string]any) (model.Node, error) { server := ReadString(m, "server") port := ReadInt(m, "port") if server == "" || port == 0 { - return node.Node{}, fmt.Errorf("missing server or port") + return model.Node{}, fmt.Errorf("missing server or port") } password := ReadString(m, "password") @@ -42,16 +42,16 @@ func (a *ShadowsocksAdapter) FromClash(m map[string]any) (node.Node, error) { outbound["plugin_opts"] = pluginOpts } - return node.Node{ + return model.Node{ Type: "shadowsocks", Outbound: outbound, }, nil } -func (a *ShadowsocksAdapter) FromURI(uriStr string) (node.Node, error) { +func (a *ShadowsocksAdapter) FromURI(uriStr string) (model.Node, error) { parts := strings.SplitN(uriStr, "@", 2) if len(parts) != 2 { - return node.Node{}, fmt.Errorf("invalid ss URI format") + return model.Node{}, fmt.Errorf("invalid ss URI format") } methodPassword := parts[0] @@ -62,7 +62,7 @@ func (a *ShadowsocksAdapter) FromURI(uriStr string) (node.Node, error) { mpParts := strings.SplitN(methodPassword, ":", 2) if len(mpParts) != 2 { - return node.Node{}, fmt.Errorf("invalid method:password format") + return model.Node{}, fmt.Errorf("invalid method:password format") } method := mpParts[0] @@ -78,13 +78,13 @@ func (a *ShadowsocksAdapter) FromURI(uriStr string) (node.Node, error) { spParts := strings.SplitN(serverPart, ":", 2) if len(spParts) != 2 { - return node.Node{}, fmt.Errorf("invalid server:port format") + return model.Node{}, fmt.Errorf("invalid server:port format") } server := spParts[0] port, _ := ParseInt(spParts[1]) - return node.Node{ + return model.Node{ Name: name, Type: "shadowsocks", Outbound: map[string]any{ @@ -104,11 +104,11 @@ func init() { Register("trojan", &TrojanAdapter{}) } -func (a *TrojanAdapter) FromClash(m map[string]any) (node.Node, error) { +func (a *TrojanAdapter) FromClash(m map[string]any) (model.Node, error) { server := ReadString(m, "server") port := ReadInt(m, "port") if server == "" || port == 0 { - return node.Node{}, fmt.Errorf("missing server or port") + return model.Node{}, fmt.Errorf("missing server or port") } password := ReadString(m, "password") @@ -123,16 +123,16 @@ func (a *TrojanAdapter) FromClash(m map[string]any) (node.Node, error) { ApplyTLSOptions(outbound, m) ApplyTransportOptions(outbound, m) - return node.Node{ + return model.Node{ Type: "trojan", Outbound: outbound, }, nil } -func (a *TrojanAdapter) FromURI(uriStr string) (node.Node, error) { +func (a *TrojanAdapter) FromURI(uriStr string) (model.Node, error) { u, err := url.Parse("trojan://" + uriStr) if err != nil { - return node.Node{}, err + return model.Node{}, err } password := u.User.Username() @@ -142,7 +142,7 @@ func (a *TrojanAdapter) FromURI(uriStr string) (node.Node, error) { query := u.Query() if password == "" || server == "" || port == "" { - return node.Node{}, fmt.Errorf("missing required fields") + return model.Node{}, fmt.Errorf("missing required fields") } portNum, _ := ParseInt(port) @@ -165,7 +165,7 @@ func (a *TrojanAdapter) FromURI(uriStr string) (node.Node, error) { ApplyURITransport(outbound, network, query) } - return node.Node{ + return model.Node{ Name: name, Type: "trojan", Outbound: outbound, diff --git a/internal/proxy/config/subscription/adapter/vless.go b/internal/proxy/config/subscription/adapter/vless.go index 5df6a81..9e0f980 100644 --- a/internal/proxy/config/subscription/adapter/vless.go +++ b/internal/proxy/config/subscription/adapter/vless.go @@ -6,7 +6,7 @@ import ( "fmt" "net/url" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" ) // VMessAdapter handles VMess protocol in Clash and URI formats. @@ -16,11 +16,11 @@ func init() { Register("vmess", &VMessAdapter{}) } -func (a *VMessAdapter) FromClash(m map[string]any) (node.Node, error) { +func (a *VMessAdapter) FromClash(m map[string]any) (model.Node, error) { server := ReadString(m, "server") port := ReadInt(m, "port") if server == "" || port == 0 { - return node.Node{}, fmt.Errorf("missing server or port") + return model.Node{}, fmt.Errorf("missing server or port") } uuid := ReadString(m, "uuid") @@ -44,21 +44,21 @@ func (a *VMessAdapter) FromClash(m map[string]any) (node.Node, error) { ApplyTLSOptions(outbound, m) ApplyTransportOptions(outbound, m) - return node.Node{ + return model.Node{ Type: "vmess", Outbound: outbound, }, nil } -func (a *VMessAdapter) FromURI(uri string) (node.Node, error) { +func (a *VMessAdapter) FromURI(uri string) (model.Node, error) { decoded, err := base64.StdEncoding.DecodeString(uri) if err != nil { - return node.Node{}, fmt.Errorf("invalid vmess URI: %w", err) + return model.Node{}, fmt.Errorf("invalid vmess URI: %w", err) } var m map[string]any if err := json.Unmarshal(decoded, &m); err != nil { - return node.Node{}, fmt.Errorf("invalid vmess config: %w", err) + return model.Node{}, fmt.Errorf("invalid vmess config: %w", err) } server := ReadString(m, "add", "address") @@ -67,7 +67,7 @@ func (a *VMessAdapter) FromURI(uri string) (node.Node, error) { name := ReadString(m, "ps", "name") if server == "" || port == 0 || uuid == "" { - return node.Node{}, fmt.Errorf("missing required fields") + return model.Node{}, fmt.Errorf("missing required fields") } outbound := map[string]any{ @@ -112,7 +112,7 @@ func (a *VMessAdapter) FromURI(uri string) (node.Node, error) { outbound["transport"] = transport } - return node.Node{ + return model.Node{ Name: name, Type: "vmess", Outbound: outbound, @@ -126,11 +126,11 @@ func init() { Register("vless", &VLessAdapter{}) } -func (a *VLessAdapter) FromClash(m map[string]any) (node.Node, error) { +func (a *VLessAdapter) FromClash(m map[string]any) (model.Node, error) { server := ReadString(m, "server") port := ReadInt(m, "port") if server == "" || port == 0 { - return node.Node{}, fmt.Errorf("missing server or port") + return model.Node{}, fmt.Errorf("missing server or port") } uuid := ReadString(m, "uuid") @@ -148,16 +148,16 @@ func (a *VLessAdapter) FromClash(m map[string]any) (node.Node, error) { ApplyTLSOptions(outbound, m) ApplyTransportOptions(outbound, m) - return node.Node{ + return model.Node{ Type: "vless", Outbound: outbound, }, nil } -func (a *VLessAdapter) FromURI(uriStr string) (node.Node, error) { +func (a *VLessAdapter) FromURI(uriStr string) (model.Node, error) { u, err := url.Parse("vless://" + uriStr) if err != nil { - return node.Node{}, err + return model.Node{}, err } uuid := u.User.Username() @@ -167,7 +167,7 @@ func (a *VLessAdapter) FromURI(uriStr string) (node.Node, error) { query := u.Query() if uuid == "" || server == "" || port == "" { - return node.Node{}, fmt.Errorf("missing required fields") + return model.Node{}, fmt.Errorf("missing required fields") } portNum, _ := ParseInt(port) @@ -211,7 +211,7 @@ func (a *VLessAdapter) FromURI(uriStr string) (node.Node, error) { ApplyURITransport(outbound, network, query) } - return node.Node{ + return model.Node{ Name: name, Type: "vless", Outbound: outbound, diff --git a/internal/proxy/config/subscription/merge.go b/internal/proxy/config/subscription/merge.go index 937d35d..581c8d8 100644 --- a/internal/proxy/config/subscription/merge.go +++ b/internal/proxy/config/subscription/merge.go @@ -4,13 +4,13 @@ import ( "path/filepath" "strings" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // LoadNodesFromCache reads from cache files honoring priority and enablement -func LoadNodesFromCache(sources []Source, cacheDir string) ([]node.Node, error) { - var finalNodes []node.Node +func LoadNodesFromCache(sources []Source, cacheDir string) ([]model.Node, error) { + var finalNodes []model.Node for _, s := range sources { if !s.EnabledValue() { logger.Debug("Skipping disabled source", "name", s.Name) @@ -45,7 +45,7 @@ func LoadNodesFromCache(sources []Source, cacheDir string) ([]node.Node, error) return finalNodes, nil } -func appendTags(nodes []node.Node, tags []string) []node.Node { +func appendTags(nodes []model.Node, tags []string) []model.Node { for i := range nodes { for _, tag := range tags { if !strings.Contains(nodes[i].Name, tag) { diff --git a/internal/proxy/config/subscription/parse.go b/internal/proxy/config/subscription/parse.go index 246ff83..6f19958 100644 --- a/internal/proxy/config/subscription/parse.go +++ b/internal/proxy/config/subscription/parse.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription/adapter" "github.com/kyson-dev/sing-helm/internal/sys/logger" "gopkg.in/yaml.v3" @@ -33,7 +33,7 @@ func NormalizeFormat(format string) string { } // Parse parses subscription content into a standard Node list. -func Parse(content []byte, format string) ([]node.Node, error) { +func Parse(content []byte, format string) ([]model.Node, error) { format = NormalizeFormat(strings.ToLower(strings.TrimSpace(format))) switch format { case FormatAuto: @@ -58,7 +58,7 @@ func Parse(content []byte, format string) ([]node.Node, error) { } } -func parseSingBox(content []byte) ([]node.Node, error) { +func parseSingBox(content []byte) ([]model.Node, error) { var root map[string]any if err := json.Unmarshal(content, &root); err != nil { return nil, err @@ -74,7 +74,7 @@ func parseSingBox(content []byte) ([]node.Node, error) { return nil, fmt.Errorf("invalid outbounds format") } - var nodes []node.Node + var nodes []model.Node for i, raw := range list { outMap, ok := raw.(map[string]any) if !ok { @@ -90,7 +90,7 @@ func parseSingBox(content []byte) ([]node.Node, error) { } delete(outMap, "tag") - nodes = append(nodes, node.Node{ + nodes = append(nodes, model.Node{ Name: name, Type: outType, Outbound: outMap, @@ -103,7 +103,7 @@ func parseSingBox(content []byte) ([]node.Node, error) { return nodes, nil } -func parseClash(content []byte) ([]node.Node, error) { +func parseClash(content []byte) ([]model.Node, error) { var root map[string]any if err := yaml.Unmarshal(content, &root); err != nil { return nil, err @@ -119,7 +119,7 @@ func parseClash(content []byte) ([]node.Node, error) { return nil, fmt.Errorf("invalid proxies format") } - var nodes []node.Node + var nodes []model.Node for _, raw := range list { proxyMap := adapter.AsStringMap(raw) if proxyMap == nil { @@ -155,14 +155,14 @@ func parseClash(content []byte) ([]node.Node, error) { return nodes, nil } -func parseBase64URI(content []byte) ([]node.Node, error) { +func parseBase64URI(content []byte) ([]model.Node, error) { decoded, err := base64.StdEncoding.DecodeString(string(content)) if err != nil { decoded = content } lines := strings.Split(string(decoded), "\n") - var nodes []node.Node + var nodes []model.Node for _, line := range lines { line = strings.TrimSpace(line) diff --git a/internal/proxy/config/subscription/storage.go b/internal/proxy/config/subscription/storage.go index 95bd03a..096adb1 100644 --- a/internal/proxy/config/subscription/storage.go +++ b/internal/proxy/config/subscription/storage.go @@ -6,69 +6,68 @@ import ( "os" "path/filepath" "sort" - - "gopkg.in/yaml.v3" + "strings" ) -// Storage handles loading and saving subscription sources +// LoadSources reads all .json subscription definitions from the config directory func LoadSources(configDir string) ([]Source, error) { - configPath := filepath.Join(configDir, "sources.yaml") - file, err := os.Open(configPath) + var sources []Source + + entries, err := os.ReadDir(configDir) if err != nil { if os.IsNotExist(err) { return nil, nil } - return nil, fmt.Errorf("open sources.yaml failed: %w", err) + return nil, fmt.Errorf("read subscription config dir failed: %w", err) } - defer file.Close() - var doc struct { - Sources []Source `yaml:"sources"` - } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } - decoder := yaml.NewDecoder(file) - if err := decoder.Decode(&doc); err != nil { - return nil, fmt.Errorf("decode sources.yaml failed: %w", err) - } + name := strings.TrimSuffix(entry.Name(), ".json") + data, err := os.ReadFile(filepath.Join(configDir, entry.Name())) + if err != nil { + continue + } + + var s Source + if err := json.Unmarshal(data, &s); err != nil { + continue + } - // Set default values if not explicitly configured - for i := range doc.Sources { - doc.Sources[i].NormalizeDefaults(fmt.Sprintf("source-%d", i+1)) + s.NormalizeDefaults(name) + sources = append(sources, s) } - // Sort sources by priority descending (higher integer = higher priority) - sort.SliceStable(doc.Sources, func(i, j int) bool { - return doc.Sources[i].Priority > doc.Sources[j].Priority + // Sort sources by priority descending + sort.SliceStable(sources, func(i, j int) bool { + return sources[i].Priority > sources[j].Priority }) - return doc.Sources, nil + return sources, nil } -// SaveSources saves the given list of sources to the configuration directory -func SaveSources(configDir string, sources []Source) error { - configPath := filepath.Join(configDir, "sources.yaml") - - doc := struct { - Sources []Source `yaml:"sources"` - }{ - Sources: sources, - } - - data, err := yaml.Marshal(&doc) - if err != nil { - return fmt.Errorf("marshal sources.yaml failed: %w", err) +// SaveSource saves a single subscription source to its own .json file +func SaveSource(configDir string, source Source) error { + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("create config dir failed: %w", err) } - err = os.MkdirAll(configDir, 0755) + configPath := filepath.Join(configDir, source.Name+".json") + data, err := json.MarshalIndent(source, "", " ") if err != nil { - return fmt.Errorf("create config dir failed: %w", err) + return fmt.Errorf("marshal subscription source failed: %w", err) } - if err := os.WriteFile(configPath, data, 0644); err != nil { - return fmt.Errorf("write sources.yaml failed: %w", err) - } + return os.WriteFile(configPath, data, 0644) +} - return nil +// DeleteSource removes a subscription source's .json definition +func DeleteSource(configDir string, name string) error { + configPath := filepath.Join(configDir, name+".json") + return os.Remove(configPath) } // LoadCache loads parsed nodes strictly from cache file without verification diff --git a/internal/proxy/config/subscription/types.go b/internal/proxy/config/subscription/types.go index 6ca97de..a78e64c 100644 --- a/internal/proxy/config/subscription/types.go +++ b/internal/proxy/config/subscription/types.go @@ -1,7 +1,7 @@ package subscription import ( - "github.com/kyson-dev/sing-helm/internal/proxy/config/node" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" ) // Source describes a subscription config file. @@ -19,7 +19,7 @@ type Source struct { type Cache struct { Source Source `json:"source"` UpdatedAt string `json:"updated_at"` - Nodes []node.Node `json:"nodes"` + Nodes []model.Node `json:"nodes"` } func (s *Source) NormalizeDefaults(name string) { diff --git a/internal/core/version/Into_test.go b/internal/sys/version/Into_test.go similarity index 82% rename from internal/core/version/Into_test.go rename to internal/sys/version/Into_test.go index 47d4b01..91b80af 100644 --- a/internal/core/version/Into_test.go +++ b/internal/sys/version/Into_test.go @@ -3,7 +3,7 @@ package version_test import ( "testing" - "github.com/kyson-dev/sing-helm/internal/core/version" + "github.com/kyson-dev/sing-helm/internal/sys/version" "github.com/stretchr/testify/assert" ) diff --git a/internal/core/version/info.go b/internal/sys/version/info.go similarity index 100% rename from internal/core/version/info.go rename to internal/sys/version/info.go diff --git a/serve_log.txt b/serve_log.txt new file mode 100644 index 0000000..c04bedb --- /dev/null +++ b/serve_log.txt @@ -0,0 +1,13 @@ +time=2026-03-02T09:40:06.651+08:00 level=INFO msg="Building options..." +time=2026-03-02T09:40:06.657+08:00 level=INFO msg="Exporting config..." version=1.11.4 platform=ios +DEBUG Raw JSON: {"log":{"level":"info"},"dns":{"servers":[{"type":"https","tag":"local_dns","domain_resolver":"resolver_dns","server":"dns.alidns.com"},{"type":"https","tag":"proxy_dns","detour":"proxy","domain_resolver":"resolver_dns","server":"dns.google"},{"type":"udp","tag":"resolver_dns","server":"223.5.5.5"}],"rules":[{"rule_set":["geosite-ads","anti-ad"],"action":"reject"},{"domain_suffix":["wise.com","schwab.com","interactivebrokers.com","cloudflare.com","5e1f8y2z3l9.shop","sky.money","ethena.fi"],"server":"local_dns"},{"rule_set":["geosite-cn","geoip-cn"],"server":"local_dns"}],"final":"proxy_dns","strategy":"ipv4_only"},"inbounds":[{"type":"tun","tag":"tun-in","mtu":9000,"address":"172.19.0.1/30","auto_route":true,"strict_route":true,"sniff":true,"sniff_override_destination":true}],"outbounds":[{"type":"hysteria2","tag":"hysteria2","server":"kyson.site","server_port":443,"password":"aroDEmw0fk","tls":{"enabled":true,"insecure":true}},{"type":"vless","tag":"vless-reality-ms","server":"35.212.163.89","server_port":443,"uuid":"5ea3da9a-06e6-48db-9a29-b7b483719d57","flow":"xtls-rprx-vision","tls":{"enabled":true,"server_name":"www.microsoft.com","utls":{"enabled":true,"fingerprint":"chrome"},"reality":{"enabled":true,"public_key":"NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ","short_id":"13dd9419"}}},{"type":"vless","tag":"KYSON-VLESS","server":"35.206.84.121","server_port":443,"uuid":"5ea3da9a-06e6-48db-9a29-b7b483719d57","flow":"xtls-rprx-vision","tls":{"enabled":true,"server_name":"www.microsoft.com","utls":{"enabled":true,"fingerprint":"chrome"},"reality":{"enabled":true,"public_key":"NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ","short_id":"d8762d"}}},{"type":"hysteria2","tag":"KYSON-HY2","server":"35.206.84.121","server_port":443,"password":"aroDEmw0fk","tls":{"enabled":true,"server_name":"www.microsoft.com","insecure":true}},{"type":"direct","tag":"direct"},{"type":"block","tag":"block"},{"type":"selector","tag":"proxy","outbounds":["auto","hysteria2","vless-reality-ms","KYSON-VLESS","KYSON-HY2"],"default":"auto"},{"type":"urltest","tag":"auto","outbounds":["hysteria2","vless-reality-ms","KYSON-VLESS","KYSON-HY2"]}],"route":{"rules":[{"ip_is_private":true,"outbound":"direct"},{"protocol":"ntp","outbound":"direct"},{"protocol":"dns","action":"hijack-dns"},{"ip_cidr":["223.5.5.5/32","223.6.6.6/32","2400:3200::/32"],"outbound":"direct"},{"rule_set":["geosite-ads","anti-ad"],"outbound":"block"},{"domain_suffix":["wise.com","schwab.com","interactivebrokers.com","cloudflare.com","5e1f8y2z3l9.shop","sky.money","ethena.fi"],"outbound":"direct"},{"rule_set":"geosite-apple","outbound":"direct"},{"rule_set":["geosite-cn","geoip-cn"],"outbound":"direct"}],"rule_set":[{"type":"remote","tag":"geosite-ads","url":"https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs","download_detour":"proxy"},{"type":"remote","tag":"anti-ad","url":"https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs","download_detour":"proxy"},{"type":"remote","tag":"geosite-apple","url":"https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs","download_detour":"proxy"},{"type":"remote","tag":"geosite-cn","url":"https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs","download_detour":"proxy"},{"type":"remote","tag":"geoip-cn","url":"https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs","download_detour":"proxy"}],"final":"proxy","auto_detect_interface":true},"experimental":{"cache_file":{"enabled":true,"path":"/usr/local/var/run/sing-helm/cache.db"},"clash_api":{"external_controller":"127.0.0.1:62093"}}} +DEBUG Map Root: map[dns:map[final:proxy_dns rules:[map[action:reject rule_set:[geosite-ads anti-ad]] map[domain_suffix:[wise.com schwab.com interactivebrokers.com cloudflare.com 5e1f8y2z3l9.shop sky.money ethena.fi] server:local_dns] map[rule_set:[geosite-cn geoip-cn] server:local_dns]] servers:[map[domain_resolver:resolver_dns server:dns.alidns.com tag:local_dns type:https] map[detour:proxy domain_resolver:resolver_dns server:dns.google tag:proxy_dns type:https] map[server:223.5.5.5 tag:resolver_dns type:udp]] strategy:ipv4_only] experimental:map[cache_file:map[enabled:true path:/usr/local/var/run/sing-helm/cache.db] clash_api:map[external_controller:127.0.0.1:62093]] inbounds:[map[address:172.19.0.1/30 auto_route:true mtu:9000 sniff:true sniff_override_destination:true strict_route:true tag:tun-in type:tun]] log:map[level:info] outbounds:[map[password:aroDEmw0fk server:kyson.site server_port:443 tag:hysteria2 tls:map[enabled:true insecure:true] type:hysteria2] map[flow:xtls-rprx-vision server:35.212.163.89 server_port:443 tag:vless-reality-ms tls:map[enabled:true reality:map[enabled:true public_key:NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ short_id:13dd9419] server_name:www.microsoft.com utls:map[enabled:true fingerprint:chrome]] type:vless uuid:5ea3da9a-06e6-48db-9a29-b7b483719d57] map[flow:xtls-rprx-vision server:35.206.84.121 server_port:443 tag:KYSON-VLESS tls:map[enabled:true reality:map[enabled:true public_key:NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ short_id:d8762d] server_name:www.microsoft.com utls:map[enabled:true fingerprint:chrome]] type:vless uuid:5ea3da9a-06e6-48db-9a29-b7b483719d57] map[password:aroDEmw0fk server:35.206.84.121 server_port:443 tag:KYSON-HY2 tls:map[enabled:true insecure:true server_name:www.microsoft.com] type:hysteria2] map[tag:direct type:direct] map[tag:block type:block] map[default:auto outbounds:[auto hysteria2 vless-reality-ms KYSON-VLESS KYSON-HY2] tag:proxy type:selector] map[outbounds:[hysteria2 vless-reality-ms KYSON-VLESS KYSON-HY2] tag:auto type:urltest]] route:map[auto_detect_interface:true final:proxy rule_set:[map[download_detour:proxy tag:geosite-ads type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs] map[download_detour:proxy tag:anti-ad type:remote url:https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs] map[download_detour:proxy tag:geosite-apple type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs] map[download_detour:proxy tag:geosite-cn type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs] map[download_detour:proxy tag:geoip-cn type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs]] rules:[map[ip_is_private:true outbound:direct] map[outbound:direct protocol:ntp] map[action:hijack-dns protocol:dns] map[ip_cidr:[223.5.5.5/32 223.6.6.6/32 2400:3200::/32] outbound:direct] map[outbound:block rule_set:[geosite-ads anti-ad]] map[domain_suffix:[wise.com schwab.com interactivebrokers.com cloudflare.com 5e1f8y2z3l9.shop sky.money ethena.fi] outbound:direct] map[outbound:direct rule_set:geosite-apple] map[outbound:direct rule_set:[geosite-cn geoip-cn]]]]] +✅ Config exported to: bin/singbox-config-1.11.4-ios.json + +🚀 SingHelm LAN Server Running on :8094 +Primary Subscription URL: http://192.168.0.101:8094/config + +Alternative IPs detected: + - http://169.254.227.238:8094/config + - http://172.19.0.1:8094/config +time=2026-03-02T09:40:07.740+08:00 level=INFO msg="Received subscription request" remote=127.0.0.1:62096 From 958bd9c910f574bd95d7bf4452be3a316e519dbc Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 21:49:59 +0800 Subject: [PATCH 19/23] refactor: Consolidate mixed and tun inbound logic into a new inbounds module, enhance node processing with improved deduplication and detour resolution, and add comprehensive test coverage across various modules. --- internal/proxy/config/config.go | 28 +--- .../proxy/config/module/node/processor.go | 86 +++++++++--- .../config/module/node/processor_test.go | 125 ++++++++++++++++++ 3 files changed, 194 insertions(+), 45 deletions(-) create mode 100644 internal/proxy/config/module/node/processor_test.go diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 09fa63a..98443d4 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + //"strings" "github.com/kyson-dev/sing-helm/internal/proxy/config/model" @@ -104,33 +105,6 @@ func SaveToFile(path string, opts *option.Options) error { return fmt.Errorf("failed to unmarshal for pretty print: %w", err) } - // Hotfix for sing-box v1.13.0-rc.7 removing 'format' from output JSON. - // We inject 'format' back inside 'rule_set' to maintain compat with v1.11.x - // if routeStruct, ok := pretty["route"].(map[string]any); ok { - // if ruleSets, ok := routeStruct["rule_set"].([]any); ok { - // for idx, rs := range ruleSets { - // if rsMap, ok := rs.(map[string]any); ok { - // if _, hasFormat := rsMap["format"]; !hasFormat { - // // guess format by url/path extension - // urlStr, _ := rsMap["url"].(string) - // if urlStr == "" { - // urlStr, _ = rsMap["path"].(string) - // } - // if urlStr != "" { - // if strings.HasSuffix(urlStr, ".srs") { - // rsMap["format"] = "binary" - // } else if strings.HasSuffix(urlStr, ".json") { - // rsMap["format"] = "source" - // } - // ruleSets[idx] = rsMap - // } - // } - // } - // } - // routeStruct["rule_set"] = ruleSets - // } - // } - data, err = json.MarshalIndent(pretty, "", " ") if err != nil { return fmt.Errorf("failed to marshal indent: %w", err) diff --git a/internal/proxy/config/module/node/processor.go b/internal/proxy/config/module/node/processor.go index 9b7e1c6..5859d31 100644 --- a/internal/proxy/config/module/node/processor.go +++ b/internal/proxy/config/module/node/processor.go @@ -1,11 +1,12 @@ package node import ( + "encoding/json" "fmt" "strings" - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/sagernet/sing-box/option" ) @@ -21,6 +22,9 @@ type OutboundProcessor struct { // globalFingerprints prevents identical nodes (same IP:Port+Type) across all sources. globalFingerprints map[string]bool + fingerprintToTag map[string]string + globalNameToTag map[string]string // original name -> unique tag (only when globally unambiguous) + ambiguousNames map[string]bool // original names that map to multiple unique tags } func NewOutboundProcessor() *OutboundProcessor { @@ -29,6 +33,9 @@ func NewOutboundProcessor() *OutboundProcessor { originalToTag: make(map[string]map[string]string), sourceGroups: make(map[string][]string), globalFingerprints: make(map[string]bool), + fingerprintToTag: make(map[string]string), + globalNameToTag: make(map[string]string), + ambiguousNames: make(map[string]bool), } } @@ -41,13 +48,14 @@ func (p *OutboundProcessor) AddNodes(nodes []model.Node) { } // 1. Global deduplication + var fp string if !n.SkipDedupe { - fp := p.fingerprint(n) + fp = p.fingerprint(n) if p.globalFingerprints[fp] { - // Record mapping anyway so detour references still work - // We map the duplicate's original name to whatever tag we gave to the FIRST seen model. - // Wait, we don't know the first seen node's name easily. - // But we can just skip it here. + // Keep duplicate-name mapping to canonical tag so detour references remain valid. + if canonicalTag, ok := p.fingerprintToTag[fp]; ok { + p.recordMapping(source, n.Name, canonicalTag) + } continue } p.globalFingerprints[fp] = true @@ -56,6 +64,9 @@ func (p *OutboundProcessor) AddNodes(nodes []model.Node) { // Ensure uniqueness of tag uniqueTag := MakeUniqueOutboundTag(n.Name, source, p.usedTags) p.recordMapping(source, n.Name, uniqueTag) + if !n.SkipDedupe { + p.fingerprintToTag[fp] = uniqueTag + } // Create the option.Outbound structure outbound := p.mapToOutbound(n.Type, uniqueTag, n.Outbound) @@ -84,14 +95,32 @@ func (p *OutboundProcessor) GetGroups() map[string][]string { // --- Internal helpers --- func (p *OutboundProcessor) fingerprint(n model.Node) string { - if n.Outbound != nil { - server, hasServer := n.Outbound["server"].(string) - port, hasPort := n.Outbound["server_port"] - if hasServer && hasPort { + if n.Outbound == nil { + return n.Name + "|" + n.Type + } + + identity := make(map[string]any, len(n.Outbound)+1) + identity["type"] = n.Type + for k, v := range n.Outbound { + switch k { + case "tag", "detour": + continue + default: + identity[k] = v + } + } + + raw, err := json.Marshal(identity) + if err == nil { + return string(raw) + } + + // Fallback to a coarse key only if marshal unexpectedly fails. + if server, hasServer := n.Outbound["server"].(string); hasServer { + if port, hasPort := n.Outbound["server_port"]; hasPort { return fmt.Sprintf("%s:%v|%s", server, port, n.Type) } } - // Fallback to name+type if no server/port return n.Name + "|" + n.Type } @@ -100,6 +129,17 @@ func (p *OutboundProcessor) recordMapping(source, original, unique string) { p.originalToTag[source] = make(map[string]string) } p.originalToTag[source][original] = unique + + // Keep a deterministic global-name mapping only when unambiguous. + if original == "" || p.ambiguousNames[original] { + return + } + if existing, ok := p.globalNameToTag[original]; ok && existing != unique { + delete(p.globalNameToTag, original) + p.ambiguousNames[original] = true + return + } + p.globalNameToTag[original] = unique } func (p *OutboundProcessor) mapToOutbound(outType, tag string, raw map[string]any) option.Outbound { @@ -131,15 +171,25 @@ func (p *OutboundProcessor) resolveDetour(target string) (string, bool) { return target, true } - // 2. linear search across all mappings - // A more robust implementation would require knowing the source of the reference, - // but usually users reference by the original name of a user model. - for _, mapping := range p.originalToTag { - if mapped, exists := mapping[target]; exists { - return mapped, true + // 2. already a concrete generated tag + if p.usedTags[target] { + return target, true + } + + // 3. source-qualified lookup: "/" + if source, name, ok := strings.Cut(target, "/"); ok && source != "" && name != "" { + if mapping := p.originalToTag[source]; mapping != nil { + if mapped, exists := mapping[name]; exists { + return mapped, true + } } } - // 3. direct use (assume user knows what they're doing) + // 4. deterministic global-name lookup (only for unambiguous names) + if mapped, ok := p.globalNameToTag[target]; ok { + return mapped, true + } + + // 5. direct use (assume user knows what they're doing) return target, false } diff --git a/internal/proxy/config/module/node/processor_test.go b/internal/proxy/config/module/node/processor_test.go new file mode 100644 index 0000000..4a2850f --- /dev/null +++ b/internal/proxy/config/module/node/processor_test.go @@ -0,0 +1,125 @@ +package node + +import ( + "testing" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" +) + +func TestAddNodes_DedupeKeepsAliasMapping(t *testing.T) { + p := NewOutboundProcessor() + p.AddNodes([]model.Node{ + { + Name: "first", + Source: "s1", + Type: "vless", + Outbound: map[string]any{ + "server": "1.1.1.1", + "server_port": 443, + "uuid": "u-1", + }, + }, + { + Name: "alias", + Source: "s2", + Type: "vless", + Outbound: map[string]any{ + "server": "1.1.1.1", + "server_port": 443, + "uuid": "u-1", + }, + }, + }) + + outbounds := p.GetProcessedOutbounds() + if len(outbounds) != 1 { + t.Fatalf("expected 1 outbound after dedupe, got %d", len(outbounds)) + } + + firstTag := p.originalToTag["s1"]["first"] + aliasTag := p.originalToTag["s2"]["alias"] + if firstTag == "" || aliasTag == "" { + t.Fatalf("expected both original names to have mappings, got first=%q alias=%q", firstTag, aliasTag) + } + if firstTag != aliasTag { + t.Fatalf("expected alias to map to canonical tag %q, got %q", firstTag, aliasTag) + } + + mapped, ok := p.resolveDetour("alias") + if !ok || mapped != firstTag { + t.Fatalf("expected detour alias to resolve to %q, got %q (ok=%v)", firstTag, mapped, ok) + } +} + +func TestResolveDetour_AmbiguousGlobalNameRequiresSourceQualifier(t *testing.T) { + p := NewOutboundProcessor() + p.AddNodes([]model.Node{ + { + Name: "same", + Source: "alpha", + Type: "vless", + Outbound: map[string]any{ + "server": "1.1.1.1", + "server_port": 443, + "uuid": "u-1", + }, + }, + { + Name: "same", + Source: "beta", + Type: "vless", + Outbound: map[string]any{ + "server": "2.2.2.2", + "server_port": 443, + "uuid": "u-2", + }, + }, + }) + + alphaTag := p.originalToTag["alpha"]["same"] + betaTag := p.originalToTag["beta"]["same"] + if alphaTag == "" || betaTag == "" || alphaTag == betaTag { + t.Fatalf("expected distinct resolved tags for qualified lookup, alpha=%q beta=%q", alphaTag, betaTag) + } + if resolved, ok := p.resolveDetour("same"); !ok || resolved != alphaTag { + t.Fatalf("expected unqualified detour to resolve concrete existing tag %q, got %q (ok=%v)", alphaTag, resolved, ok) + } + + if resolved, ok := p.resolveDetour("alpha/same"); !ok || resolved != alphaTag { + t.Fatalf("expected alpha/same -> %q, got %q (ok=%v)", alphaTag, resolved, ok) + } + if resolved, ok := p.resolveDetour("beta/same"); !ok || resolved != betaTag { + t.Fatalf("expected beta/same -> %q, got %q (ok=%v)", betaTag, resolved, ok) + } +} + +func TestFingerprint_DifferentCredentialsAreNotDeduped(t *testing.T) { + p := NewOutboundProcessor() + p.AddNodes([]model.Node{ + { + Name: "node-a", + Source: "s1", + Type: "vless", + Outbound: map[string]any{ + "server": "3.3.3.3", + "server_port": 443, + "uuid": "uuid-a", + }, + }, + { + Name: "node-b", + Source: "s2", + Type: "vless", + Outbound: map[string]any{ + "server": "3.3.3.3", + "server_port": 443, + "uuid": "uuid-b", + }, + }, + }) + + outbounds := p.GetProcessedOutbounds() + if len(outbounds) != 2 { + t.Fatalf("expected 2 outbounds when credentials differ, got %d", len(outbounds)) + } +} From a89caa41618bfa2b18fe144f392956479c45970e Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 21:50:18 +0800 Subject: [PATCH 20/23] feat: introduce dedicated DNS and Inbounds modules, refactor Outbound module, and improve logger output. --- .tmp/check_serve_config.go | 40 ++++ .tmp/validate_export.go | 15 ++ internal/app/daemon/handler_run.go | 8 +- internal/proxy/config/config.go | 7 +- internal/proxy/config/export/export.go | 4 - internal/proxy/config/module/dns.go | 98 ++++++++++ internal/proxy/config/module/dns_test.go | 86 ++++++++ internal/proxy/config/module/experimental.go | 78 ++++++-- .../proxy/config/module/experimental_test.go | 40 ++++ internal/proxy/config/module/inbounds.go | 185 ++++++++++++++++++ internal/proxy/config/module/inbounds_test.go | 121 ++++++++++++ internal/proxy/config/module/mixed.go | 62 ------ .../proxy/config/module/node/provider_user.go | 85 +++----- .../config/module/node/provider_user_test.go | 53 +++++ internal/proxy/config/module/outbound.go | 51 ++++- internal/proxy/config/module/outbound_test.go | 150 ++++++++++++++ internal/proxy/config/module/route.go | 22 +-- internal/proxy/config/module/template.go | 63 ++++++ internal/proxy/config/module/tun.go | 123 ------------ internal/proxy/config/module/utils/ports.go | 18 -- internal/proxy/engine/logger.go | 15 +- 21 files changed, 1022 insertions(+), 302 deletions(-) create mode 100644 .tmp/check_serve_config.go create mode 100644 .tmp/validate_export.go create mode 100644 internal/proxy/config/module/dns.go create mode 100644 internal/proxy/config/module/dns_test.go create mode 100644 internal/proxy/config/module/experimental_test.go create mode 100644 internal/proxy/config/module/inbounds.go create mode 100644 internal/proxy/config/module/inbounds_test.go delete mode 100644 internal/proxy/config/module/mixed.go create mode 100644 internal/proxy/config/module/node/provider_user_test.go create mode 100644 internal/proxy/config/module/outbound_test.go create mode 100644 internal/proxy/config/module/template.go delete mode 100644 internal/proxy/config/module/tun.go diff --git a/.tmp/check_serve_config.go b/.tmp/check_serve_config.go new file mode 100644 index 0000000..7f6956a --- /dev/null +++ b/.tmp/check_serve_config.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/kyson-dev/sing-helm/internal/proxy/config" + "github.com/kyson-dev/sing-helm/internal/proxy/config/export" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +func main() { + _ = paths.Init("") + runops := model.DefaultRunOptions() + runops.ProxyMode = model.ProxyModeTUN + runops.RouteMode = model.RouteModeRule + runops.APIPort = 9090 + runops.MixedPort = 7890 + opts, err := config.BuildOptions(&runops) + if err != nil { panic(err) } + data, err := export.Export(opts, export.Target{Version: "1.11.4", Platform: "ios"}) + if err != nil { panic(err) } + + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { panic(err) } + + route, _ := root["route"].(map[string]any) + fmt.Println("route.final=", route["final"]) + + outbounds, _ := root["outbounds"].([]any) + for _, o := range outbounds { + ob, _ := o.(map[string]any) + tag, _ := ob["tag"].(string) + if tag == "proxy" || tag == "auto" || tag == "direct" || tag == "block" { + b, _ := json.Marshal(ob) + fmt.Println(string(b)) + } + } +} diff --git a/.tmp/validate_export.go b/.tmp/validate_export.go new file mode 100644 index 0000000..a19ebb5 --- /dev/null +++ b/.tmp/validate_export.go @@ -0,0 +1,15 @@ +package main + +import ( + "context" + "fmt" + "github.com/kyson-dev/sing-helm/internal/proxy/config" +) + +func main() { + _, err := config.LoadOptionsWithContext(context.Background(), "bin/singbox-config-1.11.4-ios.json") + if err != nil { + panic(err) + } + fmt.Println("ok") +} diff --git a/internal/app/daemon/handler_run.go b/internal/app/daemon/handler_run.go index eaf26ec..0ea4076 100644 --- a/internal/app/daemon/handler_run.go +++ b/internal/app/daemon/handler_run.go @@ -6,8 +6,8 @@ import ( "fmt" "os" - "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/engine" "github.com/kyson-dev/sing-helm/internal/sys/ipc" "github.com/kyson-dev/sing-helm/internal/sys/logger" @@ -74,16 +74,22 @@ func (d *Daemon) handleRun(ctx context.Context, payload map[string]any) ipc.Comm // parseRunOptions 解析 run 命令的参数 func (d *Daemon) parseRunOptions(payload map[string]any) (model.RunOptions, error) { + // 1. 底层:硬编码默认值 runops := model.DefaultRunOptions() d.mu.Lock() state := d.state d.mu.Unlock() + + // 2. 第二层覆盖:上一次的 RuntimeState (通常在 restart 时) if state != nil { logger.Info("Using state from file", "proxy_mode", state.RunOptions.ProxyMode, "route_mode", state.RunOptions.RouteMode) runops = state.RunOptions } else { logger.Info("No state file, using defaults") } + + // 3. 最顶端层覆盖:本次 IPC 的 Payload (比如 sing-helm run --mode global) + // 这是最高优先级的动态指定 if payload == nil { return runops, nil } diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go index 98443d4..a9331bf 100644 --- a/internal/proxy/config/config.go +++ b/internal/proxy/config/config.go @@ -5,8 +5,6 @@ import ( "fmt" "os" - //"strings" - "github.com/kyson-dev/sing-helm/internal/proxy/config/model" "github.com/kyson-dev/sing-helm/internal/proxy/config/module" nodeProvider "github.com/kyson-dev/sing-helm/internal/proxy/config/module/node" @@ -51,8 +49,8 @@ func DefaultModules(opts *model.RunOptions) []module.ConfigModule { } modules := []module.ConfigModule{ + &module.TemplateModule{}, module.NewOutboundModule( - &nodeProvider.UserNodeProvider{}, &nodeProvider.SubscriptionNodeProvider{}, ), } @@ -62,7 +60,7 @@ func DefaultModules(opts *model.RunOptions) []module.ConfigModule { case model.ProxyModeTUN: modules = append(modules, &module.TUNModule{}, - &module.TUNDNSModule{}, + &module.DNSModule{}, ) case model.ProxyModeSystem: modules = append(modules, &module.MixedModule{ @@ -98,7 +96,6 @@ func SaveToFile(path string, opts *option.Options) error { return fmt.Errorf("failed to marshal config: %w", err) } - // Re-marshal for pretty print // Re-marshal for pretty print var pretty map[string]any if err := json.Unmarshal(data, &pretty); err != nil { diff --git a/internal/proxy/config/export/export.go b/internal/proxy/config/export/export.go index 427c7ee..5d94de1 100644 --- a/internal/proxy/config/export/export.go +++ b/internal/proxy/config/export/export.go @@ -22,15 +22,11 @@ func Export(opts *option.Options, target Target) ([]byte, error) { return nil, fmt.Errorf("failed to marshal config: %w", err) } - fmt.Printf("DEBUG Raw JSON: %s\n", string(data)) // 检查这里的 JSON 是否有 - var root map[string]any if err := json.Unmarshal(data, &root); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } - fmt.Printf("DEBUG Map Root: %+v\n", root) - // No transforms needed if no target specified if strings.TrimSpace(target.Version) == "" && strings.TrimSpace(target.Platform) == "" { return json.MarshalIndent(root, "", " ") diff --git a/internal/proxy/config/module/dns.go b/internal/proxy/config/module/dns.go new file mode 100644 index 0000000..0b3e349 --- /dev/null +++ b/internal/proxy/config/module/dns.go @@ -0,0 +1,98 @@ +package module + +import ( + "context" + + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// DNSModule TUN DNS 模块 +// TUN 模式需要特殊的 DNS 配置 +type DNSModule struct{} + +func (m *DNSModule) Name() string { + return "dns" +} + +func (m *DNSModule) Apply(opts *option.Options, ctx *BuildContext) error { + if opts.DNS == nil { + opts.DNS = &option.DNSOptions{} + } + + // 使用 map 方式创建 DNS 配置 + // local_dns 不需要 detour,默认就是直连 + dnsMap := map[string]any{ + "servers": []map[string]any{ + { + "tag": "local_dns", + "type": "https", + "server": "dns.alidns.com", + "domain_resolver": "resolver_dns", + }, + { + "tag": "proxy_dns", + "type": "https", + "server": "dns.google", + "domain_resolver": "resolver_dns", + "detour": moduleUtils.TagProxy, + }, + { + "tag": "resolver_dns", + "type": "udp", + "server": "223.5.5.5", + }, + }, + "rules": []map[string]any{ + { + "rule_set": []string{"geosite-ads", "anti-ad"}, + "action": "reject", + }, + { + "rule_set": []string{"geosite-cn", "geoip-cn"}, + "action": "route", + "server": "local_dns", + }, + }, + "final": "proxy_dns", + "strategy": "ipv4_only", + } + + data, err := singboxjson.Marshal(dnsMap) + if err != nil { + return err + } + + var defaultDnsOpts option.DNSOptions + // 必须使用 include.Context 来正确解析 DNS 类型 + tx := include.Context(context.Background()) + if err := singboxjson.UnmarshalContext(tx, data, &defaultDnsOpts); err != nil { + return err + } + + // 1. 合并 Servers: 系统硬编码优先,同 tag 的用户定义会被丢弃;用户其它 server 保留。 + systemServerTags := make(map[string]bool, len(defaultDnsOpts.Servers)) + mergedServers := make([]option.DNSServerOptions, 0, len(defaultDnsOpts.Servers)+len(opts.DNS.Servers)) + for _, ds := range defaultDnsOpts.Servers { + systemServerTags[ds.Tag] = true + mergedServers = append(mergedServers, ds) + } + for _, us := range opts.DNS.Servers { + if us.Tag != "" && systemServerTags[us.Tag] { + continue + } + mergedServers = append(mergedServers, us) + } + opts.DNS.Servers = mergedServers + + // 2. 合并 Rules: 用户规则前置,系统规则后置。 + userRules := append([]option.DNSRule(nil), opts.DNS.Rules...) + opts.DNS.Rules = append(userRules, defaultDnsOpts.Rules...) + + // 3. 基础设置: 强制使用系统硬编码,不能被用户覆盖。 + opts.DNS.Final = defaultDnsOpts.Final + opts.DNS.Strategy = defaultDnsOpts.Strategy + return nil +} diff --git a/internal/proxy/config/module/dns_test.go b/internal/proxy/config/module/dns_test.go new file mode 100644 index 0000000..abff378 --- /dev/null +++ b/internal/proxy/config/module/dns_test.go @@ -0,0 +1,86 @@ +package module + +import ( + "context" + "encoding/json" + "testing" + + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +func TestDNSApply_SystemServerPriorityUserRulesFirst(t *testing.T) { + opts := &option.Options{} + opts.DNS = &option.DNSOptions{} + + // user local_dns should be dropped because system local_dns is authoritative. + if err := applyDNSFromMap(opts.DNS, map[string]any{ + "servers": []map[string]any{ + {"tag": "local_dns", "type": "udp", "server": "8.8.8.8"}, + {"tag": "user_dns", "type": "udp", "server": "9.9.9.9"}, + }, + "rules": []map[string]any{ + {"domain_suffix": []string{"example.com"}, "action": "route", "server": "user_dns"}, + }, + "final": "user_dns", + "strategy": "prefer_ipv6", + }); err != nil { + t.Fatalf("build user dns: %v", err) + } + + if err := (&DNSModule{}).Apply(opts, NewBuildContext(nil)); err != nil { + t.Fatalf("apply dns: %v", err) + } + + raw, err := singboxjson.Marshal(opts.DNS) + if err != nil { + t.Fatalf("marshal dns: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode dns: %v", err) + } + + servers := m["servers"].([]any) + if len(servers) < 4 { + t.Fatalf("expected system(3)+user(1) servers, got %d", len(servers)) + } + firstTag := servers[0].(map[string]any)["tag"].(string) + if firstTag != "local_dns" { + t.Fatalf("expected system local_dns first, got %q", firstTag) + } + userDNSCount := 0 + for _, s := range servers { + if s.(map[string]any)["tag"] == "user_dns" { + userDNSCount++ + } + } + if userDNSCount != 1 { + t.Fatalf("expected user_dns kept once, got %d", userDNSCount) + } + + rules := m["rules"].([]any) + if len(rules) < 4 { + t.Fatalf("expected user rules + default rules, got %d", len(rules)) + } + firstRule := rules[0].(map[string]any) + if firstRule["server"] != "user_dns" { + t.Fatalf("expected user rule first, got %v", firstRule) + } + + if m["final"] != "proxy_dns" { + t.Fatalf("expected final forced to proxy_dns, got %v", m["final"]) + } + if m["strategy"] != "ipv4_only" { + t.Fatalf("expected strategy forced to ipv4_only, got %v", m["strategy"]) + } +} + +func applyDNSFromMap(d *option.DNSOptions, m map[string]any) error { + data, err := singboxjson.Marshal(m) + if err != nil { + return err + } + return singboxjson.UnmarshalContext(include.Context(context.Background()), data, d) +} diff --git a/internal/proxy/config/module/experimental.go b/internal/proxy/config/module/experimental.go index 8b47d05..fa979fc 100644 --- a/internal/proxy/config/module/experimental.go +++ b/internal/proxy/config/module/experimental.go @@ -2,14 +2,15 @@ package module import ( "fmt" + "net" + "strconv" + "strings" moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/sagernet/sing-box/option" ) -const testAPIPortEnv = "MINIBOX_TEST_API_PORT" - // ExperimentalModule 实验性模块 // 负责配置 Clash API 和缓存 type ExperimentalModule struct { @@ -22,6 +23,19 @@ func (m *ExperimentalModule) Name() string { } func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) error { + // 如果用户已经在 profile.json 中完全配置了 experimental,尤其是 clash_api + // 我们就直接跳过(依赖 TemplateModule 前置早已完成了参数提取) + if opts.Experimental != nil && opts.Experimental.ClashAPI != nil && opts.Experimental.ClashAPI.ExternalController != "" { + if ctx != nil && ctx.RunOptions != nil { + listenAddr, apiPort, ok := parseExternalController(opts.Experimental.ClashAPI.ExternalController) + if !ok { + return fmt.Errorf("invalid experimental.clash_api.external_controller: %q", opts.Experimental.ClashAPI.ExternalController) + } + ctx.RunOptions.ListenAddr = listenAddr + ctx.RunOptions.APIPort = apiPort + } + return nil + } // 确定监听地址 listenAddr := m.ListenAddr if listenAddr == "" { @@ -31,14 +45,10 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) erro // 确定 API 端口 apiPort := m.APIPort if apiPort == 0 { - if override, ok := moduleUtils.GetPortOverride(testAPIPortEnv); ok { - apiPort = override - } else { - var err error - apiPort, err = moduleUtils.GetFreePort() - if err != nil { - return err - } + var err error + apiPort, err = moduleUtils.GetFreePort() + if err != nil { + return err } } @@ -46,16 +56,50 @@ func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) erro ctx.RunOptions.APIPort = apiPort ctx.RunOptions.ListenAddr = listenAddr - // 创建 Clash API 配置 - opts.Experimental = &option.ExperimentalOptions{ - ClashAPI: &option.ClashAPIOptions{ - ExternalController: fmt.Sprintf("%s:%d", listenAddr, apiPort), - }, - CacheFile: &option.CacheFileOptions{ + // 创建或追加 Clash API 配置 + if opts.Experimental == nil { + opts.Experimental = &option.ExperimentalOptions{} + } + opts.Experimental.ClashAPI = &option.ClashAPIOptions{ + ExternalController: fmt.Sprintf("%s:%d", listenAddr, apiPort), + } + + if opts.Experimental.CacheFile == nil { + opts.Experimental.CacheFile = &option.CacheFileOptions{ Enabled: true, Path: paths.Get().CacheFile, - }, + } } return nil } + +func parseExternalController(externalController string) (string, int, bool) { + controller := strings.TrimSpace(externalController) + if controller == "" { + return "", 0, false + } + + host, portStr, err := net.SplitHostPort(controller) + if err != nil { + // Backward-compatible fallback for plain "host:port" forms. + lastColon := strings.LastIndex(controller, ":") + if lastColon <= 0 || lastColon == len(controller)-1 { + return "", 0, false + } + host = controller[:lastColon] + portStr = controller[lastColon+1:] + } + + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + return "", 0, false + } + + host = strings.TrimSpace(host) + if host == "" { + return "", 0, false + } + + return host, port, true +} diff --git a/internal/proxy/config/module/experimental_test.go b/internal/proxy/config/module/experimental_test.go new file mode 100644 index 0000000..e2183f0 --- /dev/null +++ b/internal/proxy/config/module/experimental_test.go @@ -0,0 +1,40 @@ +package module + +import ( + "testing" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + "github.com/sagernet/sing-box/option" +) + +func TestExperimentalApply_InvalidExternalControllerFails(t *testing.T) { + opts := &option.Options{ + Experimental: &option.ExperimentalOptions{ + ClashAPI: &option.ClashAPIOptions{ + ExternalController: "invalid-controller", + }, + }, + } + err := (&ExperimentalModule{}).Apply(opts, NewBuildContext(&model.RunOptions{})) + if err == nil { + t.Fatalf("expected parse error for invalid external_controller") + } +} + +func TestExperimentalApply_BackfillFromExternalController(t *testing.T) { + run := &model.RunOptions{ListenAddr: "0.0.0.0", APIPort: 1} + opts := &option.Options{ + Experimental: &option.ExperimentalOptions{ + ClashAPI: &option.ClashAPIOptions{ + ExternalController: "127.0.0.1:9090", + }, + }, + } + err := (&ExperimentalModule{ListenAddr: "10.0.0.1", APIPort: 9999}).Apply(opts, NewBuildContext(run)) + if err != nil { + t.Fatalf("apply experimental: %v", err) + } + if run.ListenAddr != "127.0.0.1" || run.APIPort != 9090 { + t.Fatalf("expected backfill from external_controller, got %+v", run) + } +} diff --git a/internal/proxy/config/module/inbounds.go b/internal/proxy/config/module/inbounds.go new file mode 100644 index 0000000..a30ed31 --- /dev/null +++ b/internal/proxy/config/module/inbounds.go @@ -0,0 +1,185 @@ +package module + +import ( + "fmt" + "net/netip" + + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/option" +) + +// MixedModule Mixed 入站模块 +// 支持设置系统代理 +type MixedModule struct { + SetSystemProxy bool + ListenAddr string + Port int +} + +func (m *MixedModule) Name() string { + return "mixed" +} + +func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { + // mixed 模式下,先清理 tun 相关入站,避免模式切换后冲突残留。 + opts.Inbounds = filterInbounds(opts.Inbounds, func(in option.Inbound) bool { + return !isTUNInbound(in) + }) + + // 如用户已有 mixed 入站,复用并强制修正 set_system_proxy,然后回填 RunOptions。 + for _, in := range opts.Inbounds { + if isMixedInbound(in) { + if mixedOpts, ok := in.Options.(*option.HTTPMixedInboundOptions); ok { + mixedOpts.SetSystemProxy = m.SetSystemProxy + if err := backfillRunOptionsFromMixed(ctx, mixedOpts); err != nil { + return err + } + } else { + return fmt.Errorf("invalid mixed inbound options type for tag %q", in.Tag) + } + return nil + } + } + + // 仅在需要新建 mixed 时,才解析默认监听参数。 + resolvedListenAddr := m.ListenAddr + if resolvedListenAddr == "" { + resolvedListenAddr = "127.0.0.1" + } + resolvedPort := m.Port + if resolvedPort == 0 { + var err error + resolvedPort, err = moduleUtils.GetFreePort() + if err != nil { + return err + } + } + + // 更新 context 中的端口信息 + backfillRunOptionsFromValues(ctx, resolvedListenAddr, resolvedPort) + + // 创建 Mixed 入站配置 + mixedInbound := option.Inbound{} + mixedMap := map[string]any{ + "type": "mixed", + "tag": "mixed-in", + "listen": resolvedListenAddr, + "listen_port": resolvedPort, + "set_system_proxy": m.SetSystemProxy, + } + if err := moduleUtils.ApplyMapToInbound(&mixedInbound, mixedMap); err != nil { + return err + } + + // 添加到配置 + opts.Inbounds = append(opts.Inbounds, mixedInbound) + + return nil +} + +// TUNModule TUN 入站模块 +type TUNModule struct { + MTU int + Stack string +} + +func (m *TUNModule) Name() string { + return "tun" +} + +func (m *TUNModule) Apply(opts *option.Options, ctx *BuildContext) error { + // tun 模式下,先清理 mixed/socks/http,避免模式切换后冲突残留。 + opts.Inbounds = filterInbounds(opts.Inbounds, func(in option.Inbound) bool { + return !isMixedLikeInbound(in) + }) + + // 如果用户已经在 profile 中配了 tun 设备入站,复用并回填 RunOptions。 + for _, in := range opts.Inbounds { + if isTUNInbound(in) { + return nil + } + } + + // 默认值 + mtu := m.MTU + if mtu == 0 { + mtu = 1500 + } + + stack := m.Stack + if stack == "" { + stack = "mixed" // mixed 兼顾性能和兼容性 + } + + // 创建 TUN 入站配置 + tunInbound := option.Inbound{} + tunMap := map[string]any{ + "type": "tun", + "tag": "tun-in", + "mtu": mtu, + "auto_route": true, + "strict_route": true, + //"stack": stack, + "address": []string{"172.19.0.1/30"}, + //"inet6_address": "fd00::1/126", + "sniff": true, + "sniff_override_destination": true, + } + if err := moduleUtils.ApplyMapToInbound(&tunInbound, tunMap); err != nil { + return err + } + + // 添加到配置 + opts.Inbounds = append(opts.Inbounds, tunInbound) + + return nil +} + +func filterInbounds(inbounds []option.Inbound, keep func(option.Inbound) bool) []option.Inbound { + filtered := make([]option.Inbound, 0, len(inbounds)) + for _, in := range inbounds { + if keep(in) { + filtered = append(filtered, in) + } + } + return filtered +} + +func isTUNInbound(in option.Inbound) bool { + return in.Type == "tun" || in.Tag == "tun-in" +} + +func isMixedInbound(in option.Inbound) bool { + return in.Type == "mixed" || in.Tag == "mixed-in" +} + +func isMixedLikeInbound(in option.Inbound) bool { + return in.Type == "mixed" || in.Type == "socks" || in.Type == "http" || in.Tag == "mixed-in" +} + +func backfillRunOptionsFromValues(ctx *BuildContext, listenAddr string, port int) { + if ctx == nil || ctx.RunOptions == nil { + return + } + if listenAddr != "" { + ctx.RunOptions.ListenAddr = listenAddr + } + if port > 0 { + ctx.RunOptions.MixedPort = port + } +} + +func backfillRunOptionsFromMixed(ctx *BuildContext, mixedOpts *option.HTTPMixedInboundOptions) error { + if mixedOpts == nil { + return fmt.Errorf("mixed inbound options is nil") + } + if mixedOpts.Listen == nil { + return fmt.Errorf("user mixed inbound requires explicit listen") + } + if mixedOpts.ListenPort == 0 { + return fmt.Errorf("user mixed inbound requires explicit listen_port") + } + + backfillRunOptionsFromValues(ctx, netip.Addr(*mixedOpts.Listen).String(), int(mixedOpts.ListenPort)) + return nil +} diff --git a/internal/proxy/config/module/inbounds_test.go b/internal/proxy/config/module/inbounds_test.go new file mode 100644 index 0000000..00be446 --- /dev/null +++ b/internal/proxy/config/module/inbounds_test.go @@ -0,0 +1,121 @@ +package module + +import ( + "testing" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/option" +) + +func TestMixedApply_RemovesTunAndCreatesMixed(t *testing.T) { + opts := &option.Options{} + var tun option.Inbound + if err := moduleUtils.ApplyMapToInbound(&tun, map[string]any{ + "type": "tun", "tag": "tun-in", "address": []string{"172.19.0.1/30"}, + }); err != nil { + t.Fatalf("build tun: %v", err) + } + opts.Inbounds = []option.Inbound{tun} + + run := &model.RunOptions{ProxyMode: model.ProxyModeTUN} + ctx := NewBuildContext(run) + mod := &MixedModule{SetSystemProxy: true, ListenAddr: "127.0.0.1", Port: 7890} + if err := mod.Apply(opts, ctx); err != nil { + t.Fatalf("apply mixed: %v", err) + } + + for _, in := range opts.Inbounds { + if in.Type == "tun" || in.Tag == "tun-in" { + t.Fatalf("tun inbound should be removed in mixed mode") + } + } + if run.ListenAddr != "127.0.0.1" || run.MixedPort != 7890 { + t.Fatalf("run options not backfilled correctly: %+v", run) + } + if run.ProxyMode != model.ProxyModeTUN { + t.Fatalf("ProxyMode must not be backfilled, got %q", run.ProxyMode) + } +} + +func TestMixedApply_UserMixedIncompleteMustFail(t *testing.T) { + opts := &option.Options{} + var mixed option.Inbound + if err := moduleUtils.ApplyMapToInbound(&mixed, map[string]any{ + "type": "mixed", "tag": "mixed-in", "set_system_proxy": false, + }); err != nil { + t.Fatalf("build mixed: %v", err) + } + opts.Inbounds = []option.Inbound{mixed} + + err := (&MixedModule{SetSystemProxy: true}).Apply(opts, NewBuildContext(&model.RunOptions{})) + if err == nil { + t.Fatalf("expected error for incomplete user mixed config") + } +} + +func TestMixedApply_UserMixedCompleteForceSetSystemProxyAndBackfill(t *testing.T) { + opts := &option.Options{} + var mixed option.Inbound + if err := moduleUtils.ApplyMapToInbound(&mixed, map[string]any{ + "type": "mixed", + "tag": "mixed-in", + "listen": "127.0.0.9", + "listen_port": 19090, + "set_system_proxy": false, + }); err != nil { + t.Fatalf("build mixed: %v", err) + } + opts.Inbounds = []option.Inbound{mixed} + + run := &model.RunOptions{ProxyMode: model.ProxyModeDefault} + ctx := NewBuildContext(run) + err := (&MixedModule{SetSystemProxy: true, ListenAddr: "127.0.0.1", Port: 7890}).Apply(opts, ctx) + if err != nil { + t.Fatalf("apply mixed: %v", err) + } + + mixedOpts := opts.Inbounds[0].Options.(*option.HTTPMixedInboundOptions) + if !mixedOpts.SetSystemProxy { + t.Fatalf("expected set_system_proxy forced to true") + } + if run.ListenAddr != "127.0.0.9" || run.MixedPort != 19090 { + t.Fatalf("expected backfill from user mixed, got %+v", run) + } + if run.ProxyMode != model.ProxyModeDefault { + t.Fatalf("ProxyMode must not be backfilled, got %q", run.ProxyMode) + } +} + +func TestTUNApply_RemovesMixedLikeInbounds(t *testing.T) { + opts := &option.Options{} + var mixed option.Inbound + if err := moduleUtils.ApplyMapToInbound(&mixed, map[string]any{ + "type": "mixed", "tag": "mixed-in", "listen": "127.0.0.1", "listen_port": 7890, + }); err != nil { + t.Fatalf("build mixed: %v", err) + } + var httpIn option.Inbound + if err := moduleUtils.ApplyMapToInbound(&httpIn, map[string]any{ + "type": "http", "tag": "http-in", "listen": "127.0.0.1", "listen_port": 8080, + }); err != nil { + t.Fatalf("build http: %v", err) + } + opts.Inbounds = []option.Inbound{mixed, httpIn} + + if err := (&TUNModule{}).Apply(opts, NewBuildContext(&model.RunOptions{})); err != nil { + t.Fatalf("apply tun: %v", err) + } + hasTun := false + for _, in := range opts.Inbounds { + if in.Type == "mixed" || in.Type == "http" || in.Type == "socks" || in.Tag == "mixed-in" { + t.Fatalf("mixed-like inbound should be removed in tun mode: %s/%s", in.Type, in.Tag) + } + if in.Type == "tun" || in.Tag == "tun-in" { + hasTun = true + } + } + if !hasTun { + t.Fatalf("expected tun inbound created") + } +} diff --git a/internal/proxy/config/module/mixed.go b/internal/proxy/config/module/mixed.go deleted file mode 100644 index f147dff..0000000 --- a/internal/proxy/config/module/mixed.go +++ /dev/null @@ -1,62 +0,0 @@ -package module - -import ( - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" - "github.com/sagernet/sing-box/option" -) - -const testMixedPortEnv = "MINIBOX_TEST_MIXED_PORT" - -// MixedModule Mixed 入站模块 -// 支持设置系统代理 -type MixedModule struct { - SetSystemProxy bool - ListenAddr string - Port int -} - -func (m *MixedModule) Name() string { - return "mixed" -} - -func (m *MixedModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 确定监听地址 - listenAddr := m.ListenAddr - if listenAddr == "" { - listenAddr = "127.0.0.1" - } - - // 确定端口 - port := m.Port - if port == 0 { - if override, ok := moduleUtils.GetPortOverride(testMixedPortEnv); ok { - port = override - } else { - var err error - port, err = moduleUtils.GetFreePort() - if err != nil { - return err - } - } - } - - // 更新 context 中的端口信息 - ctx.RunOptions.MixedPort = port - ctx.RunOptions.ListenAddr = listenAddr - - // 创建 Mixed 入站配置 - mixedInbound := option.Inbound{} - mixedMap := map[string]any{ - "type": "mixed", - "tag": "mixed-in", - "listen": listenAddr, - "listen_port": port, - "set_system_proxy": m.SetSystemProxy, - } - moduleUtils.ApplyMapToInbound(&mixedInbound, mixedMap) - - // 添加到配置 - opts.Inbounds = append(opts.Inbounds, mixedInbound) - - return nil -} diff --git a/internal/proxy/config/module/node/provider_user.go b/internal/proxy/config/module/node/provider_user.go index 4cee177..f2c3702 100644 --- a/internal/proxy/config/module/node/provider_user.go +++ b/internal/proxy/config/module/node/provider_user.go @@ -2,76 +2,55 @@ package node import ( "encoding/json" - "fmt" - "os" "github.com/kyson-dev/sing-helm/internal/proxy/config/model" - "github.com/kyson-dev/sing-helm/internal/sys/logger" - "github.com/kyson-dev/sing-helm/internal/sys/paths" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" ) -// UserNodeProvider reads nodes directly from the user's config. -type UserNodeProvider struct{} +// UserNodeProvider extracts actual proxy nodes from user-injected outbounds. +type UserNodeProvider struct { + outbounds []option.Outbound +} + +func NewUserNodeProvider(outbounds []option.Outbound) *UserNodeProvider { + return &UserNodeProvider{outbounds: outbounds} +} func (p *UserNodeProvider) Name() string { return "user" } func (p *UserNodeProvider) GetNodes() ([]model.Node, error) { - paths := paths.Get() - profileData, err := os.ReadFile(paths.ConfigFile) - if err != nil { - if os.IsNotExist(err) { - return nil, nil // Return empty if profile does not exist yet - } - return nil, fmt.Errorf("read profile error: %w", err) - } - - if len(profileData) == 0 { - return nil, nil // Return empty if profile is a 0-byte file - } - - var root map[string]any - if err := json.Unmarshal(profileData, &root); err != nil { - logger.Error("Failed to parse profile.json, skipping user nodes", "error", err) - return nil, nil - } - - outboundsRaw, ok := root["outbounds"] - if !ok { - return nil, nil - } - - list, ok := outboundsRaw.([]any) - if !ok { - logger.Info("user outbounds is not a list") - return nil, nil - } - - var nodes []model.Node - for i, raw := range list { - outMap, ok := raw.(map[string]any) - if !ok { + nodes := make([]model.Node, 0, len(p.outbounds)) + for _, out := range p.outbounds { + if out.Tag == "" || !IsActualOutboundType(out.Type) { continue } - outType, _ := outMap["type"].(string) - if outType == "" || !IsActualOutboundType(outType) { - continue // skip direct, block, dns, etc. They are handled globally. - } - name, _ := outMap["tag"].(string) - if name == "" { - name = fmt.Sprintf("user-%s-%d", outType, i+1) + outboundMap, err := outboundToMap(out) + if err != nil { + return nil, err } - delete(outMap, "tag") nodes = append(nodes, model.Node{ - Name: name, - Type: outType, - Source: "user", // Indicates it came from user config - Outbound: outMap, + Name: out.Tag, + Type: out.Type, + Source: "user", + Outbound: outboundMap, }) } - return nodes, nil } + +func outboundToMap(out option.Outbound) (map[string]any, error) { + data, err := singboxjson.Marshal(out) + if err != nil { + return nil, err + } + result := make(map[string]any) + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/proxy/config/module/node/provider_user_test.go b/internal/proxy/config/module/node/provider_user_test.go new file mode 100644 index 0000000..42f38f4 --- /dev/null +++ b/internal/proxy/config/module/node/provider_user_test.go @@ -0,0 +1,53 @@ +package node + +import ( + "testing" + + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/option" +) + +func TestUserNodeProvider_GetNodes_OnlyActualOutbounds(t *testing.T) { + var userNode option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&userNode, map[string]any{ + "type": "vless", + "tag": "user-vless", + "server": "1.1.1.1", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111", + }); err != nil { + t.Fatalf("build vless outbound: %v", err) + } + + var selector option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&selector, map[string]any{ + "type": "selector", + "tag": "group", + "outbounds": []string{"user-vless"}, + }); err != nil { + t.Fatalf("build selector outbound: %v", err) + } + + var direct option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&direct, map[string]any{ + "type": "direct", + "tag": "direct", + }); err != nil { + t.Fatalf("build direct outbound: %v", err) + } + + provider := NewUserNodeProvider([]option.Outbound{userNode, selector, direct}) + nodes, err := provider.GetNodes() + if err != nil { + t.Fatalf("GetNodes failed: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 actual node, got %d", len(nodes)) + } + if nodes[0].Name != "user-vless" || nodes[0].Type != "vless" || nodes[0].Source != "user" { + t.Fatalf("unexpected node identity: %+v", nodes[0]) + } + if gotTag := nodes[0].Outbound["tag"]; gotTag != "user-vless" { + t.Fatalf("expected outbound tag user-vless, got %v", gotTag) + } +} diff --git a/internal/proxy/config/module/outbound.go b/internal/proxy/config/module/outbound.go index fca2b99..7f52bf9 100644 --- a/internal/proxy/config/module/outbound.go +++ b/internal/proxy/config/module/outbound.go @@ -23,9 +23,12 @@ func (m *OutboundModule) Name() string { func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { processor := nodeProvider.NewOutboundProcessor() + providers := make([]nodeProvider.NodeProvider, 0, len(m.providers)+1) + providers = append(providers, nodeProvider.NewUserNodeProvider(opts.Outbounds)) + providers = append(providers, m.providers...) // 1. 从所有 Provider 获取节点 - for _, provider := range m.providers { + for _, provider := range providers { nodes, err := provider.GetNodes() if err != nil { return err @@ -97,7 +100,51 @@ func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { } // 4. 将合并后的出站回填 - opts.Outbounds = append(opts.Outbounds, filteredOutbounds...) + // 规则: + // - 硬编码/生成出站优先:与用户同名时舍弃用户定义 + // - 用户其他自定义出站保留 + userGeneratedTags := make(map[string]bool) + for _, tag := range processor.GetGroups()["user"] { + userGeneratedTags[tag] = true + } + + generatedByTag := make(map[string]bool, len(filteredOutbounds)) + for _, fo := range filteredOutbounds { + generatedByTag[fo.Tag] = true + } + + preservedUserOutbounds := make([]option.Outbound, 0, len(opts.Outbounds)) + for _, out := range opts.Outbounds { + // 同名时硬编码优先,丢弃用户定义 + if generatedByTag[out.Tag] && !userGeneratedTags[out.Tag] { + continue + } + + // 用户 selector/urltest 的 outbounds 为空数组时,自动填充全部实际节点。 + if len(actualNodes) > 0 { + switch outOpts := out.Options.(type) { + case *option.SelectorOutboundOptions: + if len(outOpts.Outbounds) == 0 { + outOpts.Outbounds = append([]string(nil), actualNodes...) + } + case *option.URLTestOutboundOptions: + if len(outOpts.Outbounds) == 0 { + outOpts.Outbounds = append([]string(nil), actualNodes...) + } + } + } + + preservedUserOutbounds = append(preservedUserOutbounds, out) + } + + // 先保留用户剩余配置,再追加硬编码/生成出站(同名已在上面剔除用户项)。 + opts.Outbounds = preservedUserOutbounds + for _, fo := range filteredOutbounds { + if userGeneratedTags[fo.Tag] { + continue + } + opts.Outbounds = append(opts.Outbounds, fo) + } return nil } diff --git a/internal/proxy/config/module/outbound_test.go b/internal/proxy/config/module/outbound_test.go new file mode 100644 index 0000000..fbfe286 --- /dev/null +++ b/internal/proxy/config/module/outbound_test.go @@ -0,0 +1,150 @@ +package module + +import ( + "testing" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + nodeProvider "github.com/kyson-dev/sing-helm/internal/proxy/config/module/node" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/option" +) + +type stubNodeProvider struct { + name string + nodes []model.Node + err error +} + +func (p *stubNodeProvider) Name() string { return p.name } +func (p *stubNodeProvider) GetNodes() ([]model.Node, error) { + return p.nodes, p.err +} + +func TestOutboundApply_GeneratedOverridesUserAndEmptyGroupsAutoFill(t *testing.T) { + opts := &option.Options{} + + var userNode option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&userNode, map[string]any{ + "type": "vless", + "tag": "user-node", + "server": "1.1.1.1", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111", + }); err != nil { + t.Fatalf("user node: %v", err) + } + var userProxy option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&userProxy, map[string]any{ + "type": "selector", + "tag": moduleUtils.TagProxy, + "outbounds": []string{"user-node"}, + "default": "user-node", + }); err != nil { + t.Fatalf("user proxy group: %v", err) + } + var emptySelector option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&emptySelector, map[string]any{ + "type": "selector", + "tag": "my-empty-group", + "outbounds": []string{}, + }); err != nil { + t.Fatalf("empty group: %v", err) + } + var keepSelector option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&keepSelector, map[string]any{ + "type": "selector", + "tag": "my-keep-group", + "outbounds": []string{"{all}"}, + }); err != nil { + t.Fatalf("keep group: %v", err) + } + + opts.Outbounds = []option.Outbound{userNode, userProxy, emptySelector, keepSelector} + + provider := &stubNodeProvider{ + name: "sub", + nodes: []model.Node{ + { + Name: "sub-node", + Type: "vless", + Source: "sub", + Outbound: map[string]any{ + "server": "2.2.2.2", + "server_port": 443, + "uuid": "22222222-2222-2222-2222-222222222222", + }, + }, + }, + } + + mod := NewOutboundModule(provider) + if err := mod.Apply(opts, NewBuildContext(&model.RunOptions{})); err != nil { + t.Fatalf("apply outbound: %v", err) + } + + var proxy *option.SelectorOutboundOptions + var auto *option.URLTestOutboundOptions + var empty *option.SelectorOutboundOptions + var keep *option.SelectorOutboundOptions + for i := range opts.Outbounds { + out := &opts.Outbounds[i] + switch out.Tag { + case moduleUtils.TagProxy: + proxy = out.Options.(*option.SelectorOutboundOptions) + case moduleUtils.TagAuto: + auto = out.Options.(*option.URLTestOutboundOptions) + case "my-empty-group": + empty = out.Options.(*option.SelectorOutboundOptions) + case "my-keep-group": + keep = out.Options.(*option.SelectorOutboundOptions) + } + } + + if proxy == nil || auto == nil || empty == nil || keep == nil { + t.Fatalf("missing expected groups after apply") + } + if proxy.Default != moduleUtils.TagAuto { + t.Fatalf("expected generated proxy default=auto, got %q", proxy.Default) + } + if len(auto.Outbounds) != 2 { + t.Fatalf("expected auto contains user+sub nodes, got %v", auto.Outbounds) + } + if len(empty.Outbounds) != 2 { + t.Fatalf("expected empty selector auto-filled with all nodes, got %v", empty.Outbounds) + } + if len(keep.Outbounds) != 1 || keep.Outbounds[0] != "{all}" { + t.Fatalf("expected non-empty selector untouched, got %v", keep.Outbounds) + } +} + +func TestOutboundApply_UserActualOutboundsAreNotDuplicated(t *testing.T) { + opts := &option.Options{} + var userNode option.Outbound + if err := moduleUtils.ApplyMapToOutbound(&userNode, map[string]any{ + "type": "vless", + "tag": "user-node", + "server": "1.1.1.1", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111", + }); err != nil { + t.Fatalf("user node: %v", err) + } + opts.Outbounds = []option.Outbound{userNode} + + mod := NewOutboundModule(&stubNodeProvider{name: "sub"}) + if err := mod.Apply(opts, NewBuildContext(&model.RunOptions{})); err != nil { + t.Fatalf("apply outbound: %v", err) + } + + count := 0 + for _, out := range opts.Outbounds { + if out.Tag == "user-node" { + count++ + } + } + if count != 1 { + t.Fatalf("expected user node kept once, got %d", count) + } +} + +var _ nodeProvider.NodeProvider = (*stubNodeProvider)(nil) diff --git a/internal/proxy/config/module/route.go b/internal/proxy/config/module/route.go index c29f1aa..1a1167f 100644 --- a/internal/proxy/config/module/route.go +++ b/internal/proxy/config/module/route.go @@ -1,8 +1,11 @@ package module import ( + "context" + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" + "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/option" singboxjson "github.com/sagernet/sing/common/json" ) @@ -38,10 +41,11 @@ func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { opts.Route.Final = moduleUtils.TagDirect } - // 3. 构建并应用默认扩展拼图 (当用户没有完全接管路由时) - // 如果用户自己配了 rule_set,我们尽量把系统必备的加到后面。 - if len(opts.Route.Rules) == 0 { - m.applyDefaultFragments(opts) + // 3. 构建并应用默认扩展拼图 (作为无条件兜底) + // 我们将生成的保底规则直接追加到用户自定义规则的后面。 + // 这样用户在 profile.json 中配置的分流优先级最高。 + if err := m.applyDefaultFragments(opts); err != nil { + return err } // 4. 清空特定模式下的所有非必要规则 @@ -86,13 +90,6 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { }) rules = append(rules, map[string]any{"rule_set": []string{"geosite-ads", "anti-ad"}, "outbound": moduleUtils.TagBlock}) - // 片段 5: 直连白名单 - rules = append(rules, map[string]any{ - "domain_suffix": []string{"wise.com", "schwab.com", "interactivebrokers.com", "cloudflare.com", - "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, - "outbound": moduleUtils.TagDirect, - }) - // 片段 6: Apple 流量直连 ruleSets = append(ruleSets, map[string]any{ "tag": "geosite-apple", @@ -133,7 +130,8 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { } var generatedRoute option.RouteOptions - if err := singboxjson.Unmarshal(data, &generatedRoute); err != nil { + tx := include.Context(context.Background()) + if err := singboxjson.UnmarshalContext(tx, data, &generatedRoute); err != nil { return err } diff --git a/internal/proxy/config/module/template.go b/internal/proxy/config/module/template.go new file mode 100644 index 0000000..b79987e --- /dev/null +++ b/internal/proxy/config/module/template.go @@ -0,0 +1,63 @@ +package module + +import ( + "context" + "encoding/json" + "os" + + "github.com/kyson-dev/sing-helm/internal/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/paths" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// TemplateModule is responsible for loading the user's custom profile.json +// and setting it as the structural foundation of the configuration options. +type TemplateModule struct{} + +func (m *TemplateModule) Name() string { + return "template" +} + +func (m *TemplateModule) Apply(opts *option.Options, ctx *BuildContext) error { + profilePath := paths.Get().ConfigFile + + data, err := os.ReadFile(profilePath) + if err != nil { + if os.IsNotExist(err) { + logger.Debug("No profile.json found, skipping template injection") + return nil + } + return err + } + + if len(data) == 0 { + return nil + } + + // Read into a map first to selectively strip fields if necessary. + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + logger.Error("Failed to parse profile.json", "error", err) + return nil // Non-fatal, fallback to generated + } + + // We KEEP "outbounds" so users can define extra custom groups in profile.json. + // The OutboundModule will append the actual proxy nodes and system groups later. + + // Convert back to bytes for unmarshaling into option.Options with context + cleanData, err := singboxjson.Marshal(raw) + if err != nil { + return err + } + + tx := include.Context(context.Background()) + if err := singboxjson.UnmarshalContext(tx, cleanData, opts); err != nil { + logger.Error("Failed to unmarshal profile.json into Sing-box options", "error", err) + return nil // Non-fatal + } + + logger.Info("Injected user profile template", "path", profilePath) + return nil +} diff --git a/internal/proxy/config/module/tun.go b/internal/proxy/config/module/tun.go deleted file mode 100644 index 04ca939..0000000 --- a/internal/proxy/config/module/tun.go +++ /dev/null @@ -1,123 +0,0 @@ -package module - -import ( - "context" - - moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" - "github.com/sagernet/sing-box/include" - "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" -) - -// TUNModule TUN 入站模块 -type TUNModule struct { - MTU int - Stack string -} - -func (m *TUNModule) Name() string { - return "tun" -} - -func (m *TUNModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 默认值 - mtu := m.MTU - if mtu == 0 { - mtu = 9000 - } - - stack := m.Stack - if stack == "" { - stack = "mixed" // mixed 兼顾性能和兼容性 - } - - // 创建 TUN 入站配置 - tunInbound := option.Inbound{} - tunMap := map[string]any{ - "type": "tun", - "tag": "tun-in", - "mtu": mtu, - "auto_route": true, - "strict_route": true, - //"stack": stack, - "address": []string{"172.19.0.1/30"}, - //"inet6_address": "fd00::1/126", - "sniff": true, - "sniff_override_destination": true, - } - moduleUtils.ApplyMapToInbound(&tunInbound, tunMap) - - // 添加到配置 - opts.Inbounds = append(opts.Inbounds, tunInbound) - - return nil -} - -// TUNDNSModule TUN DNS 模块 -// TUN 模式需要特殊的 DNS 配置 -type TUNDNSModule struct{} - -func (m *TUNDNSModule) Name() string { - return "tun_dns" -} - -func (m *TUNDNSModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 使用 map 方式创建 DNS 配置 - // local_dns 不需要 detour,默认就是直连 - dnsMap := map[string]any{ - "servers": []map[string]any{ - { - "tag": "local_dns", - "type": "https", - "server": "dns.alidns.com", - "domain_resolver": "resolver_dns", - }, - { - "tag": "proxy_dns", - "type": "https", - "server": "dns.google", - "domain_resolver": "resolver_dns", - "detour": moduleUtils.TagProxy, - }, - { - "tag": "resolver_dns", - "type": "udp", - "server": "223.5.5.5", - }, - }, - "rules": []map[string]any{ - { - "rule_set": []string{"geosite-ads", "anti-ad"}, - "action": "reject", - }, - { - "domain_suffix": []string{"wise.com", "schwab.com", "interactivebrokers.com", "cloudflare.com", - "5e1f8y2z3l9.shop", "sky.money", "ethena.fi"}, - "action": "route", - "server": "local_dns", - }, - { - "rule_set": []string{"geosite-cn", "geoip-cn"}, - "action": "route", - "server": "local_dns", - }, - }, - "final": "proxy_dns", - "strategy": "ipv4_only", - } - - data, err := singboxjson.Marshal(dnsMap) - if err != nil { - return err - } - - var dnsOpts option.DNSOptions - // 必须使用 include.Context 来正确解析 DNS 类型 - tx := include.Context(context.Background()) - if err := singboxjson.UnmarshalContext(tx, data, &dnsOpts); err != nil { - return err - } - - opts.DNS = &dnsOpts - return nil -} diff --git a/internal/proxy/config/module/utils/ports.go b/internal/proxy/config/module/utils/ports.go index 7077002..1965564 100644 --- a/internal/proxy/config/module/utils/ports.go +++ b/internal/proxy/config/module/utils/ports.go @@ -2,25 +2,7 @@ package module import ( "net" - "os" - "strconv" ) - -// GetPortOverride 返回测试期间指定的端口。 -// 如果对应环境变量有效(正整数),返回该端口并标记为存在。 -func GetPortOverride(envKey string) (int, bool) { - value := os.Getenv(envKey) - if value == "" { - return 0, false - } - - port, err := strconv.Atoi(value) - if err != nil || port <= 0 { - return 0, false - } - return port, true -} - // GetFreePort 请求内核分配一个空闲端口 func GetFreePort() (int, error) { // 监听端口 0,内核会自动分配一个空闲端口 diff --git a/internal/proxy/engine/logger.go b/internal/proxy/engine/logger.go index 44a3374..2436ec5 100644 --- a/internal/proxy/engine/logger.go +++ b/internal/proxy/engine/logger.go @@ -1,6 +1,8 @@ package engine import ( + "regexp" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/log" ) @@ -9,6 +11,8 @@ import ( // 将 sing-box 的日志重定向到我们的 slog logger type PlatformWriter struct{} +var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + func NewPlatformWriter() log.PlatformWriter { return &PlatformWriter{} } @@ -19,18 +23,19 @@ func (p *PlatformWriter) DisableColors() bool { } func (p *PlatformWriter) WriteMessage(level log.Level, message string) { + clean := ansiEscapePattern.ReplaceAllString(message, "") switch level { case log.LevelTrace, log.LevelDebug: - logger.Debug(message, "source", "sing-box") + logger.Debug(clean, "source", "sing-box") case log.LevelInfo: - logger.Info(message, "source", "sing-box") + logger.Info(clean, "source", "sing-box") case log.LevelWarn: // logger doesn't have Warn exposed, using Info for now - logger.Info("[WARN] "+message, "source", "sing-box") + logger.Info("[WARN] "+clean, "source", "sing-box") case log.LevelError, log.LevelFatal, log.LevelPanic: - logger.Error(message, "source", "sing-box") + logger.Error(clean, "source", "sing-box") default: - logger.Info(message, "source", "sing-box") + logger.Info(clean, "source", "sing-box") } } From 6f437120860ea4e4613e29f5799a60ae696ad727 Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 22:31:26 +0800 Subject: [PATCH 21/23] feat: Implement conditional `tun.address` downgrade for version compatibility and ensure DNS hijack rules precede private direct rules. --- internal/proxy/config/export/compat.go | 11 ++- internal/proxy/config/export/compat_test.go | 65 +++++++++++++++++ internal/proxy/config/module/dns_test.go | 2 +- internal/proxy/config/module/route.go | 15 ++-- internal/proxy/config/module/route_test.go | 79 +++++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 internal/proxy/config/export/compat_test.go create mode 100644 internal/proxy/config/module/route_test.go diff --git a/internal/proxy/config/export/compat.go b/internal/proxy/config/export/compat.go index 0eb510d..b985f88 100644 --- a/internal/proxy/config/export/compat.go +++ b/internal/proxy/config/export/compat.go @@ -18,8 +18,17 @@ func applyVersionCompat(root map[string]any, version string) error { downgradeDNSServers(root) downgradeDNSDetour(root) // Add detour: direct for DNS servers downgradeRuleSets(root) - downgradeTunInbounds(root) downgradeSelectorOutbounds(root) + + // tun.address was introduced in v1.11.0. + // Only downgrade to inet4_address for v1.10.x and earlier. + less111, err := versionLess(version, "1.11.0") + if err != nil { + return err + } + if less111 { + downgradeTunInbounds(root) + } } return nil diff --git a/internal/proxy/config/export/compat_test.go b/internal/proxy/config/export/compat_test.go new file mode 100644 index 0000000..d067915 --- /dev/null +++ b/internal/proxy/config/export/compat_test.go @@ -0,0 +1,65 @@ +package export + +import "testing" + +func TestApplyVersionCompat_KeepTunAddressOnV111(t *testing.T) { + root := map[string]any{ + "inbounds": []any{ + map[string]any{ + "type": "tun", + "tag": "tun-in", + "address": "172.19.0.1/30", + }, + }, + } + + if err := applyVersionCompat(root, "1.11.4"); err != nil { + t.Fatalf("apply version compat failed: %v", err) + } + + tun := firstInboundAsMap(t, root) + if _, ok := tun["address"]; !ok { + t.Fatalf("expected address to be kept for v1.11.x") + } + if _, ok := tun["inet4_address"]; ok { + t.Fatalf("expected inet4_address not to be set for v1.11.x") + } +} + +func TestApplyVersionCompat_DowngradeTunAddressOnV110(t *testing.T) { + root := map[string]any{ + "inbounds": []any{ + map[string]any{ + "type": "tun", + "tag": "tun-in", + "address": "172.19.0.1/30", + }, + }, + } + + if err := applyVersionCompat(root, "1.10.9"); err != nil { + t.Fatalf("apply version compat failed: %v", err) + } + + tun := firstInboundAsMap(t, root) + if _, ok := tun["address"]; ok { + t.Fatalf("expected address to be removed for v1.10.x") + } + if v, ok := tun["inet4_address"].(string); !ok || v != "172.19.0.1/30" { + t.Fatalf("expected inet4_address to be set for v1.10.x, got %v", tun["inet4_address"]) + } +} + +func firstInboundAsMap(t *testing.T, root map[string]any) map[string]any { + t.Helper() + + inbounds, ok := root["inbounds"].([]any) + if !ok || len(inbounds) == 0 { + t.Fatalf("inbounds missing") + } + tun, ok := inbounds[0].(map[string]any) + if !ok { + t.Fatalf("inbound[0] is not an object") + } + return tun +} diff --git a/internal/proxy/config/module/dns_test.go b/internal/proxy/config/module/dns_test.go index abff378..0861d60 100644 --- a/internal/proxy/config/module/dns_test.go +++ b/internal/proxy/config/module/dns_test.go @@ -61,7 +61,7 @@ func TestDNSApply_SystemServerPriorityUserRulesFirst(t *testing.T) { } rules := m["rules"].([]any) - if len(rules) < 4 { + if len(rules) != 3 { t.Fatalf("expected user rules + default rules, got %d", len(rules)) } firstRule := rules[0].(map[string]any) diff --git a/internal/proxy/config/module/route.go b/internal/proxy/config/module/route.go index 1a1167f..abd46cb 100644 --- a/internal/proxy/config/module/route.go +++ b/internal/proxy/config/module/route.go @@ -62,17 +62,18 @@ func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { var ruleSets []map[string]any var rules []map[string]any - // 片段 1: 局域网直连 (必须最优先) - rules = append(rules, map[string]any{"ip_is_private": true, "outbound": moduleUtils.TagDirect}) - - // 片段 2: NTP 直连 - rules = append(rules, map[string]any{"protocol": []string{"ntp"}, "outbound": moduleUtils.TagDirect}) - - // 片段 3: DNS 流量专门劫持 (在 TUN/Mixed 模式中,由 sing-box 本地解析) + // 片段 1: DNS 流量专门劫持 (在 TUN/Mixed 模式中,由 sing-box 本地解析) + // 必须在 ip_is_private 之前,否则会把 172.19.0.2:53 等 DNS 包提前放行到 direct,导致 DNS 劫持失效。 rules = append(rules, map[string]any{"protocol": []string{"dns"}, "action": "hijack-dns"}) // 针对 ali dns 放行(因为在 DNS 模块中配置了国内直接去 ali 解析,避免循环) rules = append(rules, map[string]any{"ip_cidr": []string{"223.5.5.5/32", "223.6.6.6/32", "2400:3200::/32"}, "outbound": moduleUtils.TagDirect}) + // 片段 2: 局域网直连 + rules = append(rules, map[string]any{"ip_is_private": true, "outbound": moduleUtils.TagDirect}) + + // 片段 3: NTP 直连 + rules = append(rules, map[string]any{"protocol": []string{"ntp"}, "outbound": moduleUtils.TagDirect}) + // 片段 4: 去广告模块 ruleSets = append(ruleSets, map[string]any{ "tag": "geosite-ads", diff --git a/internal/proxy/config/module/route_test.go b/internal/proxy/config/module/route_test.go new file mode 100644 index 0000000..54eaadb --- /dev/null +++ b/internal/proxy/config/module/route_test.go @@ -0,0 +1,79 @@ +package module + +import ( + "encoding/json" + "testing" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +func TestRouteApply_DNSHijackBeforePrivateDirect(t *testing.T) { + opts := &option.Options{} + m := &RouteModule{RouteMode: model.RouteModeRule} + if err := m.Apply(opts, NewBuildContext(nil)); err != nil { + t.Fatalf("apply route: %v", err) + } + + raw, err := singboxjson.Marshal(opts.Route) + if err != nil { + t.Fatalf("marshal route: %v", err) + } + var routeMap map[string]any + if err := json.Unmarshal(raw, &routeMap); err != nil { + t.Fatalf("decode route: %v", err) + } + + rules, ok := routeMap["rules"].([]any) + if !ok || len(rules) == 0 { + t.Fatalf("route rules missing") + } + + dnsHijackIdx := -1 + privateDirectIdx := -1 + + for i, rule := range rules { + rm, ok := rule.(map[string]any) + if !ok { + continue + } + if protocolHasDNS(rm["protocol"]) && rm["action"] == "hijack-dns" && dnsHijackIdx < 0 { + dnsHijackIdx = i + } + if v, ok := rm["ip_is_private"].(bool); ok && v && privateDirectIdx < 0 { + privateDirectIdx = i + } + } + + if dnsHijackIdx < 0 { + t.Fatalf("dns hijack rule not found") + } + if privateDirectIdx < 0 { + t.Fatalf("ip_is_private direct rule not found") + } + if dnsHijackIdx >= privateDirectIdx { + t.Fatalf("dns hijack must be before ip_is_private: hijack=%d private=%d", dnsHijackIdx, privateDirectIdx) + } +} + +func protocolHasDNS(v any) bool { + switch p := v.(type) { + case string: + return p == "dns" + case []any: + for _, item := range p { + s, ok := item.(string) + if ok && s == "dns" { + return true + } + } + case []string: + for _, s := range p { + if s == "dns" { + return true + } + } + } + return false +} From 390ae6162a3d92f8e121ecb20f5d97f5ca35d7dc Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 2 Mar 2026 22:43:39 +0800 Subject: [PATCH 22/23] refactor: explicitly support sing-box 1.11.4 and latest for config export, removing generic version compatibility. --- internal/app/cli/serve.go | 26 +++++++-- internal/proxy/config/export/compat.go | 56 +++---------------- internal/proxy/config/export/compat_test.go | 34 ++---------- internal/proxy/config/export/export.go | 18 +++---- internal/proxy/config/export/export_test.go | 21 ++++++++ internal/proxy/config/export/version.go | 59 --------------------- 6 files changed, 62 insertions(+), 152 deletions(-) create mode 100644 internal/proxy/config/export/export_test.go delete mode 100644 internal/proxy/config/export/version.go diff --git a/internal/app/cli/serve.go b/internal/app/cli/serve.go index b16e0ed..55059b2 100644 --- a/internal/app/cli/serve.go +++ b/internal/app/cli/serve.go @@ -30,6 +30,12 @@ func newServeCommand() *cobra.Command { Short: "Generate config file and start local HTTP server", Long: `Generates a sing-box configuration file locally and starts a HTTP server to share it via LAN.`, RunE: func(cmd *cobra.Command, args []string) error { + resolvedVersion, err := resolveServeTargetVersion(targetVersion) + if err != nil { + fmt.Println(err) + return err + } + // 1. 生成并写入本地文件 runops := model.DefaultRunOptions() runops.ProxyMode = model.ProxyModeTUN @@ -38,12 +44,14 @@ func newServeCommand() *cobra.Command { logger.Info("Building options...") opts, err := config.BuildOptions(&runops) if err != nil { + fmt.Println(err) return err } - logger.Info("Exporting config...", "version", targetVersion, "platform", platform) - data, err := export.Export(opts, export.Target{Version: targetVersion, Platform: platform}) + logger.Info("Exporting config...", "version", targetVersion, "resolved_version", resolvedVersion, "platform", platform) + data, err := export.Export(opts, export.Target{Version: resolvedVersion, Platform: platform}) if err != nil { + fmt.Println(err) return err } @@ -111,12 +119,24 @@ func newServeCommand() *cobra.Command { cmd.Flags().IntVarP(&port, "port", "p", 8090, "HTTP server port") cmd.Flags().StringVar(&platform, "platform", "ios", "Target platform (e.g. ios)") - cmd.Flags().StringVar(&targetVersion, "target-version", "1.11.4", "Target sing-box version") + cmd.Flags().StringVar(&targetVersion, "target-version", "1.11.4", "Target sing-box version: 1.11.4 or latest") cmd.Flags().StringVarP(&output, "output", "o", "", "Output local file path") return cmd } +func resolveServeTargetVersion(v string) (string, error) { + version := strings.ToLower(strings.TrimSpace(v)) + switch version { + case "1.11.4": + return "1.11.4", nil + case "latest": + return "latest", nil + default: + return "", fmt.Errorf("unsupported --target-version %q, only supports: 1.11.4, latest", v) + } +} + func defaultExportPath(version, platform string) string { name := "singbox-config" ver := strings.TrimSpace(strings.TrimPrefix(version, "v")) diff --git a/internal/proxy/config/export/compat.go b/internal/proxy/config/export/compat.go index b985f88..84f8f30 100644 --- a/internal/proxy/config/export/compat.go +++ b/internal/proxy/config/export/compat.go @@ -6,32 +6,12 @@ import ( "strings" ) -// applyVersionCompat applies version-specific compatibility transforms -func applyVersionCompat(root map[string]any, version string) error { - less, err := versionLess(version, "1.12.0") - if err != nil { - return err - } - - if less { - // v1.11.x compatibility transforms - downgradeDNSServers(root) - downgradeDNSDetour(root) // Add detour: direct for DNS servers - downgradeRuleSets(root) - downgradeSelectorOutbounds(root) - - // tun.address was introduced in v1.11.0. - // Only downgrade to inet4_address for v1.10.x and earlier. - less111, err := versionLess(version, "1.11.0") - if err != nil { - return err - } - if less111 { - downgradeTunInbounds(root) - } - } - - return nil +// applyCompatForV1114 applies explicit compatibility transforms for sing-box 1.11.4. +func applyCompatForV1114(root map[string]any) { + downgradeDNSServers(root) + downgradeDNSDetour(root) + downgradeRuleSets(root) + downgradeSelectorOutbounds(root) } // downgradeDNSServers converts v1.12+ DNS server format to v1.11.x format @@ -152,30 +132,6 @@ func downgradeRuleSets(root map[string]any) { } } -// downgradeTunInbounds converts tun inbound address field for v1.11.x -func downgradeTunInbounds(root map[string]any) { - inbounds, ok := root["inbounds"].([]any) - if !ok { - return - } - - for _, entry := range inbounds { - inbound, ok := entry.(map[string]any) - if !ok { - continue - } - - typ, _ := inbound["type"].(string) - if typ == "tun" { - // Convert address to inet4_address for v1.11.4 - if address, ok := inbound["address"].(string); ok { - inbound["inet4_address"] = address - delete(inbound, "address") - } - } - } -} - // downgradeSelectorOutbounds removes v1.12+ fields from selector/urltest outbounds func downgradeSelectorOutbounds(root map[string]any) { outbounds, ok := root["outbounds"].([]any) diff --git a/internal/proxy/config/export/compat_test.go b/internal/proxy/config/export/compat_test.go index d067915..6de5890 100644 --- a/internal/proxy/config/export/compat_test.go +++ b/internal/proxy/config/export/compat_test.go @@ -2,7 +2,7 @@ package export import "testing" -func TestApplyVersionCompat_KeepTunAddressOnV111(t *testing.T) { +func TestApplyCompatForV1114_KeepTunAddress(t *testing.T) { root := map[string]any{ "inbounds": []any{ map[string]any{ @@ -13,40 +13,14 @@ func TestApplyVersionCompat_KeepTunAddressOnV111(t *testing.T) { }, } - if err := applyVersionCompat(root, "1.11.4"); err != nil { - t.Fatalf("apply version compat failed: %v", err) - } + applyCompatForV1114(root) tun := firstInboundAsMap(t, root) if _, ok := tun["address"]; !ok { - t.Fatalf("expected address to be kept for v1.11.x") + t.Fatalf("expected address to be kept for v1.11.4") } if _, ok := tun["inet4_address"]; ok { - t.Fatalf("expected inet4_address not to be set for v1.11.x") - } -} - -func TestApplyVersionCompat_DowngradeTunAddressOnV110(t *testing.T) { - root := map[string]any{ - "inbounds": []any{ - map[string]any{ - "type": "tun", - "tag": "tun-in", - "address": "172.19.0.1/30", - }, - }, - } - - if err := applyVersionCompat(root, "1.10.9"); err != nil { - t.Fatalf("apply version compat failed: %v", err) - } - - tun := firstInboundAsMap(t, root) - if _, ok := tun["address"]; ok { - t.Fatalf("expected address to be removed for v1.10.x") - } - if v, ok := tun["inet4_address"].(string); !ok || v != "172.19.0.1/30" { - t.Fatalf("expected inet4_address to be set for v1.10.x, got %v", tun["inet4_address"]) + t.Fatalf("expected inet4_address not to be set for v1.11.4") } } diff --git a/internal/proxy/config/export/export.go b/internal/proxy/config/export/export.go index 5d94de1..56054d9 100644 --- a/internal/proxy/config/export/export.go +++ b/internal/proxy/config/export/export.go @@ -27,16 +27,14 @@ func Export(opts *option.Options, target Target) ([]byte, error) { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } - // No transforms needed if no target specified - if strings.TrimSpace(target.Version) == "" && strings.TrimSpace(target.Platform) == "" { - return json.MarshalIndent(root, "", " ") - } - - // Apply version-specific compatibility transforms - if strings.TrimSpace(target.Version) != "" { - if err := applyVersionCompat(root, target.Version); err != nil { - return nil, err - } + version := strings.ToLower(strings.TrimSpace(target.Version)) + switch version { + case "", "latest": + // Latest uses current schema directly. + case "1.11.4": + applyCompatForV1114(root) + default: + return nil, fmt.Errorf("unsupported target version %q, only supports: 1.11.4, latest", target.Version) } // Apply platform-specific compatibility transforms diff --git a/internal/proxy/config/export/export_test.go b/internal/proxy/config/export/export_test.go new file mode 100644 index 0000000..7e04b83 --- /dev/null +++ b/internal/proxy/config/export/export_test.go @@ -0,0 +1,21 @@ +package export + +import ( + "testing" + + "github.com/sagernet/sing-box/option" +) + +func TestExport_OnlySupportsTwoTargetVersions(t *testing.T) { + opts := &option.Options{} + + if _, err := Export(opts, Target{Version: "latest"}); err != nil { + t.Fatalf("latest should be supported: %v", err) + } + if _, err := Export(opts, Target{Version: "1.11.4"}); err != nil { + t.Fatalf("1.11.4 should be supported: %v", err) + } + if _, err := Export(opts, Target{Version: "1.12.3"}); err == nil { + t.Fatalf("unexpected success for unsupported version") + } +} diff --git a/internal/proxy/config/export/version.go b/internal/proxy/config/export/version.go deleted file mode 100644 index c4d7fdb..0000000 --- a/internal/proxy/config/export/version.go +++ /dev/null @@ -1,59 +0,0 @@ -package export - -import ( - "fmt" - "strconv" - "strings" -) - -func versionLess(a, b string) (bool, error) { - av, err := parseVersion(a) - if err != nil { - return false, err - } - bv, err := parseVersion(b) - if err != nil { - return false, err - } - - for i := 0; i < 3; i++ { - if av[i] < bv[i] { - return true, nil - } - if av[i] > bv[i] { - return false, nil - } - } - return false, nil -} - -func parseVersion(v string) ([3]int, error) { - var out [3]int - trimmed := strings.TrimSpace(strings.TrimPrefix(v, "v")) - if trimmed == "" { - return out, fmt.Errorf("invalid version: %q", v) - } - - parts := strings.Split(trimmed, ".") - if len(parts) > 3 { - parts = parts[:3] - } - - for i := 0; i < 3; i++ { - if i >= len(parts) { - out[i] = 0 - continue - } - part := strings.TrimSpace(parts[i]) - if part == "" { - return out, fmt.Errorf("invalid version: %q", v) - } - value, err := strconv.Atoi(part) - if err != nil { - return out, fmt.Errorf("invalid version: %q", v) - } - out[i] = value - } - - return out, nil -} From b4f0b8d1716ad9759ff2cccbb134bc756ee38d10 Mon Sep 17 00:00:00 2001 From: kyson Date: Mon, 23 Mar 2026 16:55:50 +0800 Subject: [PATCH 23/23] chore: update .gitignore and remove temporary log file - Added refactor.md, serve_log.txt, .gemini/ and gha-creds-*.json to .gitignore - Removed serve_log.txt from the repository --- .gitignore | 5 +++++ serve_log.txt | 13 ------------- 2 files changed, 5 insertions(+), 13 deletions(-) delete mode 100644 serve_log.txt diff --git a/.gitignore b/.gitignore index 5782933..18d6dea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # Test binary, built with `go test -c` *.test +refactor.md +serve_log.txt # Output of the go coverage tool, specifically when used with Litmus *.out @@ -47,3 +49,6 @@ geoip.db geosite.db cache.db sing-helm + +.gemini/ +gha-creds-*.json diff --git a/serve_log.txt b/serve_log.txt deleted file mode 100644 index c04bedb..0000000 --- a/serve_log.txt +++ /dev/null @@ -1,13 +0,0 @@ -time=2026-03-02T09:40:06.651+08:00 level=INFO msg="Building options..." -time=2026-03-02T09:40:06.657+08:00 level=INFO msg="Exporting config..." version=1.11.4 platform=ios -DEBUG Raw JSON: {"log":{"level":"info"},"dns":{"servers":[{"type":"https","tag":"local_dns","domain_resolver":"resolver_dns","server":"dns.alidns.com"},{"type":"https","tag":"proxy_dns","detour":"proxy","domain_resolver":"resolver_dns","server":"dns.google"},{"type":"udp","tag":"resolver_dns","server":"223.5.5.5"}],"rules":[{"rule_set":["geosite-ads","anti-ad"],"action":"reject"},{"domain_suffix":["wise.com","schwab.com","interactivebrokers.com","cloudflare.com","5e1f8y2z3l9.shop","sky.money","ethena.fi"],"server":"local_dns"},{"rule_set":["geosite-cn","geoip-cn"],"server":"local_dns"}],"final":"proxy_dns","strategy":"ipv4_only"},"inbounds":[{"type":"tun","tag":"tun-in","mtu":9000,"address":"172.19.0.1/30","auto_route":true,"strict_route":true,"sniff":true,"sniff_override_destination":true}],"outbounds":[{"type":"hysteria2","tag":"hysteria2","server":"kyson.site","server_port":443,"password":"aroDEmw0fk","tls":{"enabled":true,"insecure":true}},{"type":"vless","tag":"vless-reality-ms","server":"35.212.163.89","server_port":443,"uuid":"5ea3da9a-06e6-48db-9a29-b7b483719d57","flow":"xtls-rprx-vision","tls":{"enabled":true,"server_name":"www.microsoft.com","utls":{"enabled":true,"fingerprint":"chrome"},"reality":{"enabled":true,"public_key":"NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ","short_id":"13dd9419"}}},{"type":"vless","tag":"KYSON-VLESS","server":"35.206.84.121","server_port":443,"uuid":"5ea3da9a-06e6-48db-9a29-b7b483719d57","flow":"xtls-rprx-vision","tls":{"enabled":true,"server_name":"www.microsoft.com","utls":{"enabled":true,"fingerprint":"chrome"},"reality":{"enabled":true,"public_key":"NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ","short_id":"d8762d"}}},{"type":"hysteria2","tag":"KYSON-HY2","server":"35.206.84.121","server_port":443,"password":"aroDEmw0fk","tls":{"enabled":true,"server_name":"www.microsoft.com","insecure":true}},{"type":"direct","tag":"direct"},{"type":"block","tag":"block"},{"type":"selector","tag":"proxy","outbounds":["auto","hysteria2","vless-reality-ms","KYSON-VLESS","KYSON-HY2"],"default":"auto"},{"type":"urltest","tag":"auto","outbounds":["hysteria2","vless-reality-ms","KYSON-VLESS","KYSON-HY2"]}],"route":{"rules":[{"ip_is_private":true,"outbound":"direct"},{"protocol":"ntp","outbound":"direct"},{"protocol":"dns","action":"hijack-dns"},{"ip_cidr":["223.5.5.5/32","223.6.6.6/32","2400:3200::/32"],"outbound":"direct"},{"rule_set":["geosite-ads","anti-ad"],"outbound":"block"},{"domain_suffix":["wise.com","schwab.com","interactivebrokers.com","cloudflare.com","5e1f8y2z3l9.shop","sky.money","ethena.fi"],"outbound":"direct"},{"rule_set":"geosite-apple","outbound":"direct"},{"rule_set":["geosite-cn","geoip-cn"],"outbound":"direct"}],"rule_set":[{"type":"remote","tag":"geosite-ads","url":"https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs","download_detour":"proxy"},{"type":"remote","tag":"anti-ad","url":"https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs","download_detour":"proxy"},{"type":"remote","tag":"geosite-apple","url":"https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs","download_detour":"proxy"},{"type":"remote","tag":"geosite-cn","url":"https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs","download_detour":"proxy"},{"type":"remote","tag":"geoip-cn","url":"https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs","download_detour":"proxy"}],"final":"proxy","auto_detect_interface":true},"experimental":{"cache_file":{"enabled":true,"path":"/usr/local/var/run/sing-helm/cache.db"},"clash_api":{"external_controller":"127.0.0.1:62093"}}} -DEBUG Map Root: map[dns:map[final:proxy_dns rules:[map[action:reject rule_set:[geosite-ads anti-ad]] map[domain_suffix:[wise.com schwab.com interactivebrokers.com cloudflare.com 5e1f8y2z3l9.shop sky.money ethena.fi] server:local_dns] map[rule_set:[geosite-cn geoip-cn] server:local_dns]] servers:[map[domain_resolver:resolver_dns server:dns.alidns.com tag:local_dns type:https] map[detour:proxy domain_resolver:resolver_dns server:dns.google tag:proxy_dns type:https] map[server:223.5.5.5 tag:resolver_dns type:udp]] strategy:ipv4_only] experimental:map[cache_file:map[enabled:true path:/usr/local/var/run/sing-helm/cache.db] clash_api:map[external_controller:127.0.0.1:62093]] inbounds:[map[address:172.19.0.1/30 auto_route:true mtu:9000 sniff:true sniff_override_destination:true strict_route:true tag:tun-in type:tun]] log:map[level:info] outbounds:[map[password:aroDEmw0fk server:kyson.site server_port:443 tag:hysteria2 tls:map[enabled:true insecure:true] type:hysteria2] map[flow:xtls-rprx-vision server:35.212.163.89 server_port:443 tag:vless-reality-ms tls:map[enabled:true reality:map[enabled:true public_key:NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ short_id:13dd9419] server_name:www.microsoft.com utls:map[enabled:true fingerprint:chrome]] type:vless uuid:5ea3da9a-06e6-48db-9a29-b7b483719d57] map[flow:xtls-rprx-vision server:35.206.84.121 server_port:443 tag:KYSON-VLESS tls:map[enabled:true reality:map[enabled:true public_key:NCD2aGnBwhQbjqnGU24LlkVNQtgqLlvsUKJMc3SxxVQ short_id:d8762d] server_name:www.microsoft.com utls:map[enabled:true fingerprint:chrome]] type:vless uuid:5ea3da9a-06e6-48db-9a29-b7b483719d57] map[password:aroDEmw0fk server:35.206.84.121 server_port:443 tag:KYSON-HY2 tls:map[enabled:true insecure:true server_name:www.microsoft.com] type:hysteria2] map[tag:direct type:direct] map[tag:block type:block] map[default:auto outbounds:[auto hysteria2 vless-reality-ms KYSON-VLESS KYSON-HY2] tag:proxy type:selector] map[outbounds:[hysteria2 vless-reality-ms KYSON-VLESS KYSON-HY2] tag:auto type:urltest]] route:map[auto_detect_interface:true final:proxy rule_set:[map[download_detour:proxy tag:geosite-ads type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs] map[download_detour:proxy tag:anti-ad type:remote url:https://raw.githubusercontent.com/privacy-protection-tools/anti-ad.github.io/master/docs/anti-ad-sing-box.srs] map[download_detour:proxy tag:geosite-apple type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-apple.srs] map[download_detour:proxy tag:geosite-cn type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs] map[download_detour:proxy tag:geoip-cn type:remote url:https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs]] rules:[map[ip_is_private:true outbound:direct] map[outbound:direct protocol:ntp] map[action:hijack-dns protocol:dns] map[ip_cidr:[223.5.5.5/32 223.6.6.6/32 2400:3200::/32] outbound:direct] map[outbound:block rule_set:[geosite-ads anti-ad]] map[domain_suffix:[wise.com schwab.com interactivebrokers.com cloudflare.com 5e1f8y2z3l9.shop sky.money ethena.fi] outbound:direct] map[outbound:direct rule_set:geosite-apple] map[outbound:direct rule_set:[geosite-cn geoip-cn]]]]] -✅ Config exported to: bin/singbox-config-1.11.4-ios.json - -🚀 SingHelm LAN Server Running on :8094 -Primary Subscription URL: http://192.168.0.101:8094/config - -Alternative IPs detected: - - http://169.254.227.238:8094/config - - http://172.19.0.1:8094/config -time=2026-03-02T09:40:07.740+08:00 level=INFO msg="Received subscription request" remote=127.0.0.1:62096