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..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 @@ -19,6 +21,7 @@ # Build artifacts bin/ + # IDEs and editors .idea/ .vscode/ @@ -46,3 +49,6 @@ geoip.db geosite.db cache.db sing-helm + +.gemini/ +gha-creds-*.json 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/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/go.mod b/go.mod index c3c7c38..e3e2fad 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/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.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..143a3bc 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,41 +183,102 @@ 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= -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= 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/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 c123dd4..b725015 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/env" + "github.com/kyson-dev/sing-helm/internal/sys/paths" "github.com/spf13/cobra" ) @@ -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 } @@ -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 new file mode 100644 index 0000000..bec5e65 --- /dev/null +++ b/internal/app/cli/config.go @@ -0,0 +1,194 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" + "github.com/kyson-dev/sing-helm/internal/sys/paths" + "github.com/spf13/cobra" +) + +func newConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration files", + Long: `Manage configuration files. + +Available subcommands: + list - List base and subscription configs + add - Add a subscription config + edit - Edit base config or a subscription file + refresh - Refresh subscription cache`, + // 不设置 RunE,让 cobra 在没有子命令时显示帮助 + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + // 启用命令建议(当输入错误时会提示相似的命令) + cmd.SuggestionsMinimumDistance = 2 + + cmd.AddCommand( + newConfigListCommand(), + newConfigAddCommand(), + newConfigEditCommand(), + newConfigRefreshCommand(), + newConfigDeleteCommand(), + ) + + return cmd +} + +func newConfigListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List base and subscription configs", + RunE: runConfigList, + } +} + +func newConfigAddCommand() *cobra.Command { + var ( + format string + priority int + enabled bool + dedupe bool + ) + cmd := &cobra.Command{ + Use: "add [name] [url]", + Short: "Add a subscription config", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + name := strings.TrimSpace(args[0]) + url := strings.TrimSpace(args[1]) + if name == "" { + return fmt.Errorf("name cannot be empty") + } + if strings.Contains(name, string(os.PathSeparator)) { + return fmt.Errorf("name cannot contain path separators") + } + if url == "" { + return fmt.Errorf("url cannot be empty") + } + + 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: subscription.NormalizeFormat(format), + Priority: priority, + Enabled: &enabled, + Dedupe: &dedupe, + } + if err := subscription.SaveSource(p.SubConfigDir, source); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Saved: %s\n", filepath.Join(p.SubConfigDir, source.Name+".json")) + return nil + }, + } + cmd.Flags().StringVar(&format, "format", "auto", "Subscription format: auto, singbox, clash") + cmd.Flags().IntVar(&priority, "priority", 0, "Priority for dedupe (higher wins)") + cmd.Flags().BoolVar(&enabled, "enabled", true, "Enable this subscription") + cmd.Flags().BoolVar(&dedupe, "dedupe", true, "Enable dedupe for this subscription") + return cmd +} + +func newConfigEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit [name]", + Short: "Edit base config or a subscription file", + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + p := paths.Get() + target := p.ConfigFile + if len(args) == 1 { + 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 = filepath.Join(p.SubConfigDir, args[0]+".json") + } + return openInEditor(cmd, target) + }, + } +} + +func newConfigRefreshCommand() *cobra.Command { + return &cobra.Command{ + Use: "refresh [name|all]", + Short: "Refresh subscription cache", + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + 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, p.SubConfigDir, p.SubCacheDir) + } + + name := strings.TrimSpace(args[0]) + if name == "" { + return fmt.Errorf("name cannot be empty") + } + return refreshOneSubscription(cmd, name, p.SubConfigDir, p.SubCacheDir) + }, + } +} + +func newConfigDeleteCommand() *cobra.Command { + return &cobra.Command{ + Use: "delete [name|all]", + Short: "Delete a subscription config and cache", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + 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, p.SubConfigDir, p.SubCacheDir) + } + + name := strings.TrimSpace(args[0]) + if name == "" { + return fmt.Errorf("name cannot be empty") + } + return deleteOneSubscription(cmd, name, p.SubConfigDir, p.SubCacheDir) + }, + } +} diff --git a/internal/app/cli/config_ops.go b/internal/app/cli/config_ops.go new file mode 100644 index 0000000..a1fdfeb --- /dev/null +++ b/internal/app/cli/config_ops.go @@ -0,0 +1,154 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/subscription" + "github.com/kyson-dev/sing-helm/internal/sys/paths" + "github.com/spf13/cobra" +) + +func runConfigList(cmd *cobra.Command, args []string) error { //nolint:unparam + paths := paths.Get() + fmt.Fprintf(cmd.OutOrStdout(), "Base Config: %s\n", paths.ConfigFile) + + sources, _ := subscription.LoadSources(paths.SubConfigDir) + if len(sources) == 0 { + 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" + } + 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" + } + + fmt.Fprintf(cmd.OutOrStdout(), " - %s (%s, P%d): %s [%s]\n", + source.Name, status, source.Priority, source.URL, cacheInfo) + } + + return nil +} + +func openInEditor(cmd *cobra.Command, path string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vim" // Default to vim if EDITOR not set + } + + // Simple editor opener + if strings.HasSuffix(path, ".json") { + // allow edit json files + } + + execCmd := exec.Command(editor, path) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + + if err := execCmd.Run(); err != nil { + return fmt.Errorf("failed to open editor %s: %w", editor, err) + } + + return nil +} + +func refreshAllSubscriptions(cmd *cobra.Command, configDir, cacheDir string) error { + sources, err := subscription.LoadSources(configDir) + if err != nil { + return err + } + if len(sources) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No subscriptions to refresh.\n") + return nil + } + + for _, source := range sources { + if !source.EnabledValue() { + fmt.Fprintf(cmd.OutOrStdout(), "Skipping disabled subscription: %s\n", source.Name) + continue + } + 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 refreshOneSubscription(cmd *cobra.Command, name, configDir, cacheDir string) error { + sources, err := subscription.LoadSources(configDir) + if err != nil { + return 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) + } + + 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 +} + +func deleteAllSubscriptions(cmd *cobra.Command, configDir, cacheDir string) error { + 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 +} + +func deleteOneSubscription(cmd *cobra.Command, name, configDir, cacheDir string) error { + sources, err := subscription.LoadSources(configDir) + if err != nil { // Ignore not exist + return err + } + + var newSources []subscription.Source + found := false + for _, s := range sources { + if s.Name == name { + found = true + continue + } + newSources = append(newSources, s) + } + + if !found { + return fmt.Errorf("subscription not found: %s", name) + } + + _ = subscription.DeleteSource(configDir, name) + + // Delete cache + _ = os.Remove(filepath.Join(cacheDir, name+".json")) + + fmt.Fprintf(cmd.OutOrStdout(), "Deleted subscription: %s\n", name) + return nil +} 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 3572185..f5c3c5b 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/env" - "github.com/kyson-dev/sing-helm/internal/ipc" + "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/cli/log.go b/internal/app/cli/log.go similarity index 91% rename from internal/cli/log.go rename to internal/app/cli/log.go index 751a2a4..9be80e9 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/env" - "github.com/kyson-dev/sing-helm/internal/logger" + "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,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 := paths.Get().LogDir stdoutLog := filepath.Join(logDir, "stdout.log") stderrLog := filepath.Join(logDir, "stderr.log") @@ -77,7 +76,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 +84,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/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 93% rename from internal/cli/monitor.go rename to internal/app/cli/monitor.go index ff34268..2e82513 100644 --- a/internal/cli/monitor.go +++ b/internal/app/cli/monitor.go @@ -4,8 +4,8 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "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/logger" "github.com/spf13/cobra" ) @@ -47,7 +47,6 @@ 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: diff --git a/internal/cli/node.go b/internal/app/cli/node.go similarity index 93% rename from internal/cli/node.go rename to internal/app/cli/node.go index fccf249..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/logger" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "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/app/cli/root.go similarity index 89% rename from internal/cli/root.go rename to internal/app/cli/root.go index 48cbc0e..f54ede1 100644 --- a/internal/cli/root.go +++ b/internal/app/cli/root.go @@ -3,8 +3,7 @@ package cli import ( "fmt" - "github.com/kyson-dev/sing-helm/internal/env" - "github.com/kyson-dev/sing-helm/internal/logger" + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/spf13/cobra" ) @@ -19,7 +18,7 @@ func NewRootCommand() *cobra.Command { home, _ := cmd.Flags().GetString("home") // 使用 setup 初始化环境,支持智能探测和注册 - if err := env.Setup(home); err != nil { + if err := setupEnvironment(home); err != nil { return fmt.Errorf("environment setup failed: %w", err) } @@ -28,6 +27,7 @@ func NewRootCommand() *cobra.Command { } else { logger.Setup(logger.Config{Debug: globalDebug, FilePath: logFile}) } + return nil }, } @@ -44,7 +44,6 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(newVersionCommand(), newConfigCommand(), newRunCommand(), - newUpdateCommand(), newStatusCommand(), newHealthCommand(), newReloadCommand(), @@ -62,7 +61,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/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 5a60517..6de9cc7 100644 --- a/internal/cli/run.go +++ b/internal/app/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/env" - "github.com/kyson-dev/sing-helm/internal/ipc" + coredaemon "github.com/kyson-dev/sing-helm/internal/app/daemon" + "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/cli/serve.go b/internal/app/cli/serve.go similarity index 80% rename from internal/cli/serve.go rename to internal/app/cli/serve.go index a64c37e..55059b2 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/config" - "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/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" ) @@ -30,20 +30,28 @@ 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 := 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) if err != nil { + fmt.Println(err) return err } - logger.Info("Exporting config...", "version", targetVersion, "platform", platform) - data, err := exporter.Export(opts, exporter.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/env/setup.go b/internal/app/cli/setup.go similarity index 53% rename from internal/env/setup.go rename to internal/app/cli/setup.go index bad3e84..dd57ae3 100644 --- a/internal/env/setup.go +++ b/internal/app/cli/setup.go @@ -1,8 +1,8 @@ -package env +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 := Init(resolvedHome); err != nil { + // Init 会确保目录存在,如果 homeFlag 为空,则使用默认值 + if err := paths.Init(resolvedHome); err != nil { return err } diff --git a/internal/cli/start.go b/internal/app/cli/start.go similarity index 95% rename from internal/cli/start.go rename to internal/app/cli/start.go index 0b2fc4c..366b62e 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/env" - "github.com/kyson-dev/sing-helm/internal/logger" + "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/cli/lifecycle.go b/internal/app/cli/status.go similarity index 84% rename from internal/cli/lifecycle.go rename to internal/app/cli/status.go index aff861f..22c2a46 100644 --- a/internal/cli/lifecycle.go +++ b/internal/app/cli/status.go @@ -3,6 +3,7 @@ package cli import ( "fmt" + "github.com/kyson-dev/sing-helm/internal/sys/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/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..75a126b 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/sys/logger" + "github.com/kyson-dev/sing-helm/internal/sys/version" "github.com/spf13/cobra" ) diff --git a/internal/app/daemon/daemon.go b/internal/app/daemon/daemon.go new file mode 100644 index 0000000..faaa57c --- /dev/null +++ b/internal/app/daemon/daemon.go @@ -0,0 +1,186 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "sync" + + "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" + "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. +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 + service ServiceRunner + serviceFactory func() ServiceRunner + lock *lock.DaemonLock + running bool + reloading bool + state *RuntimeState +} + +// NewDaemon builds a daemon controller. +func NewDaemon() *Daemon { + return &Daemon{ + serviceFactory: func() ServiceRunner { + return engine.NewInstance() + }, + } +} + +// SetServiceFactory overrides the service factory (useful for tests). +func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { + d.mu.Lock() + defer d.mu.Unlock() + if factory == nil { + d.serviceFactory = func() ServiceRunner { + return engine.NewInstance() + } + return + } + d.serviceFactory = factory +} + +// Serve starts the IPC server. Blocks until ctx is cancelled. +func (d *Daemon) Serve(ctx context.Context) error { + + 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() + _ = saveRuntimeMeta(paths.Get().RuntimeMetaFile, RuntimeMeta{ + ConfigHome: paths.Get().HomeDir, + }) + + ctx, cancel := context.WithCancel(ctx) + d.cancelFunc = cancel + defer func() { + logger.Info("Daemon shutting down") + cancel() + d.cleanup() + }() + + logger.Info("Daemon started, listening for IPC commands") + + if err := ipc.Serve(ctx, paths.Get().SocketFile, d, &ipc.ServerOptions{}); err != nil { + return err + } + return nil +} + +// 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": + return d.handleRun(ctx, cmd.Payload) + case "stop": + return d.handleStop() + case "status": + return d.handleStatus() + case "mode": + return d.handleMode(ctx, cmd.Payload) + case "route": + return d.handleRoute(ctx, cmd.Payload) + case "node.list": + return d.handleNodeList(cmd.Payload) + case "node.use": + return d.handleNodeUse(cmd.Payload) + case "log": + return d.handleLog() + case "health": + return d.handleHealth() + case "reload": + return d.handleReload(ctx) + default: + return ipc.CommandResult{Status: "error", Error: fmt.Sprintf("unknown command: %s", cmd.Name)} + } +} + +// --- internal helpers --- + +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 := SaveState(state); err != nil { + logger.Error("Failed to save runtime state", "error", err) + } + } +} + +func (d *Daemon) newService() ServiceRunner { + if d.serviceFactory != nil { + return d.serviceFactory() + } + return engine.NewInstance() +} + +func (d *Daemon) currentState() (*RuntimeState, error) { + d.mu.Lock() + state := d.state + d.mu.Unlock() + if state != nil { + copyState := *state + return ©State, nil + } + return nil, nil +} + +func (d *Daemon) isRunning() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.running +} + +func (d *Daemon) setRunning(running bool) { + d.mu.Lock() + d.running = running + d.mu.Unlock() +} + +func (d *Daemon) loadState() { + state, err := LoadState() + if err != nil { + if os.IsNotExist(err) { + return + } + logger.Error("Failed to load runtime state", "error", err) + return + } + d.mu.Lock() + d.state = state + d.state.PID = os.Getpid() + d.mu.Unlock() +} diff --git a/internal/daemon/daemon_test.go b/internal/app/daemon/daemon_test.go similarity index 92% rename from internal/daemon/daemon_test.go rename to internal/app/daemon/daemon_test.go index 76c1582..efd3d80 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/env" - "github.com/kyson-dev/sing-helm/internal/ipc" + "github.com/kyson-dev/sing-helm/internal/app/daemon" + "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.ForTestSetRuntimeDir(dir) + if err := paths.ForTestInit(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_mode.go b/internal/app/daemon/handler_mode.go new file mode 100644 index 0000000..ab1f550 --- /dev/null +++ b/internal/app/daemon/handler_mode.go @@ -0,0 +1,64 @@ +package daemon + +import ( + "context" + "os" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" +) + +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/app/daemon/handler_node.go b/internal/app/daemon/handler_node.go new file mode 100644 index 0000000..cb62df2 --- /dev/null +++ b/internal/app/daemon/handler_node.go @@ -0,0 +1,71 @@ +package daemon + +import ( + "errors" + "fmt" + + "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 { + 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/app/daemon/handler_run.go b/internal/app/daemon/handler_run.go new file mode 100644 index 0000000..0ea4076 --- /dev/null +++ b/internal/app/daemon/handler_run.go @@ -0,0 +1,198 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "os" + + "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" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +// 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(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 := 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()} + } + + // 启动成功,更新状态 + startFailed = false + d.mu.Lock() + d.service = svc + if d.state == nil { + d.state = &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) { + // 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 + } + 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 := ipc.AsInt(payload["api_port"]); ok && port > 0 { + runops.APIPort = port + } + if port, ok := ipc.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 *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(paths.Get().RawConfigFile) + if err := config.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, 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, paths.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/app/daemon/handler_status.go b/internal/app/daemon/handler_status.go new file mode 100644 index 0000000..e91a897 --- /dev/null +++ b/internal/app/daemon/handler_status.go @@ -0,0 +1,80 @@ +package daemon + +import ( + "context" + + "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/paths" +) + +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 := paths.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/app/daemon/runtime.go b/internal/app/daemon/runtime.go new file mode 100644 index 0000000..eefc0b7 --- /dev/null +++ b/internal/app/daemon/runtime.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/app/daemon/state.go b/internal/app/daemon/state.go new file mode 100644 index 0000000..49275c4 --- /dev/null +++ b/internal/app/daemon/state.go @@ -0,0 +1,52 @@ +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 model.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 paths.Get().StateFile +} diff --git a/internal/tui/monitor/commands.go b/internal/app/tui/monitor/commands.go similarity index 70% rename from internal/tui/monitor/commands.go rename to internal/app/tui/monitor/commands.go index a5127a3..d792b9e 100644 --- a/internal/tui/monitor/commands.go +++ b/internal/app/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/client" - "github.com/kyson-dev/sing-helm/internal/controller" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/ipc" + "github.com/kyson-dev/sing-helm/internal/sys/paths" ) // ============================================================================ @@ -68,7 +69,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() @@ -76,14 +77,10 @@ func cmdFetchStatus(c *client.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 { @@ -108,7 +105,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 +116,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 { @@ -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} } @@ -197,7 +182,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 +197,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" { @@ -220,7 +205,6 @@ func extractGroups(proxies map[string]client.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]client.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(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) + } + 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/app/tui/monitor/handlers.go similarity index 98% rename from internal/tui/monitor/handlers.go rename to internal/app/tui/monitor/handlers.go index 5eb8d44..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/logger" - "github.com/kyson-dev/sing-helm/internal/client" + "github.com/kyson-dev/sing-helm/internal/proxy/clashapi" + "github.com/kyson-dev/sing-helm/internal/sys/logger" ) // ============================================================================ @@ -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/app/tui/monitor/messages.go similarity index 95% rename from internal/tui/monitor/messages.go rename to internal/app/tui/monitor/messages.go index 7f8ffd7..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/client" + "github.com/kyson-dev/sing-helm/internal/proxy/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/app/tui/monitor/model.go similarity index 86% rename from internal/tui/monitor/model.go rename to internal/app/tui/monitor/model.go index bf3aaa1..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/client" + "github.com/kyson-dev/sing-helm/internal/proxy/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 更新中(防止重复请求) @@ -43,10 +43,10 @@ type Model struct { routeMode string // 路由模式: rule, global, direct // --- 节点列表 --- - groups []string // 代理组列表 - proxies map[string]client.ProxyData // 代理详情 - latencies map[string]int // 节点延迟 (-1=失败, 0=未测试) - testing map[string]bool // 正在测速的节点 + groups []string // 代理组列表 + proxies map[string]clashapi.ProxyData // 代理详情 + latencies map[string]int // 节点延迟 (-1=失败, 0=未测试) + testing map[string]bool // 正在测速的节点 // ========================================================================= // 第三层:UI 交互状态 @@ -74,15 +74,15 @@ type CursorState struct { func NewModel(apiHost string) Model { return Model{ // 连接管理 - apiBase: apiHost, - apiClient: client.New(apiHost), - connState: ConnectionStateMachine{State: ConnStateConnecting}, + apiBase: 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 } 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/cli/config.go b/internal/cli/config.go deleted file mode 100644 index 95bc4f6..0000000 --- a/internal/cli/config.go +++ /dev/null @@ -1,350 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/kyson-dev/sing-helm/internal/env" - "github.com/kyson-dev/sing-helm/internal/subscription" - "github.com/spf13/cobra" -) - -func newConfigCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "config", - Short: "Manage configuration files", - Long: `Manage configuration files. - -Available subcommands: - list - List base and subscription configs - add - Add a subscription config - edit - Edit base config or a subscription file - refresh - Refresh subscription cache`, - // 不设置 RunE,让 cobra 在没有子命令时显示帮助 - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, - } - - // 启用命令建议(当输入错误时会提示相似的命令) - cmd.SuggestionsMinimumDistance = 2 - - cmd.AddCommand( - newConfigListCommand(), - newConfigAddCommand(), - newConfigEditCommand(), - newConfigRefreshCommand(), - newConfigDeleteCommand(), - ) - - return cmd -} - -func newConfigListCommand() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List base and subscription configs", - RunE: runConfigList, - } -} - -func newConfigAddCommand() *cobra.Command { - var ( - format string - priority int - enabled bool - dedupe bool - ) - cmd := &cobra.Command{ - Use: "add [name] [url]", - Short: "Add a subscription config", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - name := strings.TrimSpace(args[0]) - url := strings.TrimSpace(args[1]) - if name == "" { - return fmt.Errorf("name cannot be empty") - } - if strings.Contains(name, string(os.PathSeparator)) { - return fmt.Errorf("name cannot contain path separators") - } - if url == "" { - return fmt.Errorf("url cannot be empty") - } - - paths := env.Get() - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err - } - - source := subscription.Source{ - Name: name, - URL: url, - Format: format, - Priority: priority, - Enabled: &enabled, - Dedupe: &dedupe, - } - - 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 { - return err - } - - fmt.Fprintf(cmd.OutOrStdout(), "Saved: %s\n", path) - return nil - }, - } - cmd.Flags().StringVar(&format, "format", "auto", "Subscription format: auto, singbox, clash") - cmd.Flags().IntVar(&priority, "priority", 0, "Priority for dedupe (higher wins)") - cmd.Flags().BoolVar(&enabled, "enabled", true, "Enable this subscription") - cmd.Flags().BoolVar(&dedupe, "dedupe", true, "Enable dedupe for this subscription") - return cmd -} - -func newConfigEditCommand() *cobra.Command { - return &cobra.Command{ - Use: "edit [name]", - Short: "Edit base config or a subscription file", - Args: cobra.RangeArgs(0, 1), - RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() - target := paths.ConfigFile - if len(args) == 1 { - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err - } - target = subscription.SourceFilePath(paths.SubConfigDir, strings.TrimSpace(args[0])) - } - return openInEditor(cmd, target) - }, - } -} - -func newConfigRefreshCommand() *cobra.Command { - return &cobra.Command{ - Use: "refresh [name|all]", - Short: "Refresh subscription cache", - Args: cobra.RangeArgs(0, 1), - RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err - } - - if len(args) == 0 || strings.EqualFold(args[0], "all") { - return refreshAllSubscriptions(cmd, paths.SubConfigDir, paths.SubCacheDir) - } - - name := strings.TrimSpace(args[0]) - if name == "" { - return fmt.Errorf("name cannot be empty") - } - return refreshOneSubscription(cmd, name, paths.SubConfigDir, paths.SubCacheDir) - }, - } -} - -func newConfigDeleteCommand() *cobra.Command { - return &cobra.Command{ - Use: "delete [name|all]", - Short: "Delete a subscription config and cache", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - paths := env.Get() - // 确保目录存在(虽然我们要删除东西,但如果目录都不存在也就没什么好删的,不过为了路径构建不出错) - if err := subscription.EnsureDirs(paths.SubConfigDir, paths.SubCacheDir); err != nil { - return err - } - - if strings.EqualFold(args[0], "all") { - return deleteAllSubscriptions(cmd, paths.SubConfigDir, paths.SubCacheDir) - } - - name := strings.TrimSpace(args[0]) - if name == "" { - return fmt.Errorf("name cannot be empty") - } - return deleteOneSubscription(cmd, name, paths.SubConfigDir, paths.SubCacheDir) - }, - } -} - -func runConfigList(cmd *cobra.Command, _ []string) error { - paths := env.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/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/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/config/builder.go b/internal/config/builder.go deleted file mode 100644 index 47ab5e4..0000000 --- a/internal/config/builder.go +++ /dev/null @@ -1,155 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/runtime" - "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" -) - -// ConfigBuilder 配置构建器 -// 支持链式调用添加模块,灵活组装配置 -type ConfigBuilder struct { - opts *runtime.RunOptions // 运行时参数 - modules []ConfigModule // 配置模块列表 - ctx *BuildContext // 构建上下文 -} - -// BuildConfig loads the profile, applies runtime modules, and saves raw config. -func BuildConfig(rawPath string, runops *runtime.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 *runtime.RunOptions) (*option.Options, error) { - builder := newConfigBuilder(runops) - for _, m := range defaultModules(runops) { - builder.with(m) - } - return builder.build() -} - -// newConfigBuilder 创建配置构建器(从已加载的配置) -// 参数: -// - opts: 运行时参数 -// -// 注意: 这是向后兼容的方法,推荐使用 NewConfigBuilderFromFile -func newConfigBuilder(opts *runtime.RunOptions) *ConfigBuilder { - if opts == nil { - defaultOpts := runtime.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 *runtime.RunOptions) []ConfigModule { - modules := []ConfigModule{ - &UserOutboundModule{}, - &SubscriptionModule{}, - &OutboundModule{}, - } - - // 根据 ProxyMode 选择入站模块 - switch opts.ProxyMode { - case runtime.ProxyModeTUN: - modules = append(modules, - &TUNModule{}, - &TUNDNSModule{}, - ) - case runtime.ProxyModeSystem: - modules = append(modules, &MixedModule{ - SetSystemProxy: true, - ListenAddr: opts.ListenAddr, - Port: opts.MixedPort, - }) - case runtime.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/config/experimental_module.go b/internal/config/experimental_module.go deleted file mode 100644 index f842fe4..0000000 --- a/internal/config/experimental_module.go +++ /dev/null @@ -1,61 +0,0 @@ -package config - -import ( - "fmt" - - "github.com/kyson-dev/sing-helm/internal/env" - "github.com/kyson-dev/sing-helm/internal/pkg/netutil" - "github.com/sagernet/sing-box/option" -) - -const testAPIPortEnv = "MINIBOX_TEST_API_PORT" - -// ExperimentalModule 实验性模块 -// 负责配置 Clash API 和缓存 -type ExperimentalModule struct { - ListenAddr string - APIPort int -} - -func (m *ExperimentalModule) Name() string { - return "experimental" -} - -func (m *ExperimentalModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 确定监听地址 - listenAddr := m.ListenAddr - if listenAddr == "" { - listenAddr = "127.0.0.1" - } - - // 确定 API 端口 - apiPort := m.APIPort - if apiPort == 0 { - if override, ok := getPortOverride(testAPIPortEnv); ok { - apiPort = override - } else { - var err error - apiPort, err = netutil.GetFreePort() - if err != nil { - return err - } - } - } - - // 更新 context 中的端口信息 - 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{ - Enabled: true, - Path: env.Get().CacheFile, - }, - } - - return nil -} diff --git a/internal/config/mixed_module.go b/internal/config/mixed_module.go deleted file mode 100644 index 0e994c4..0000000 --- a/internal/config/mixed_module.go +++ /dev/null @@ -1,62 +0,0 @@ -package config - -import ( - "github.com/kyson-dev/sing-helm/internal/pkg/netutil" - "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 := getPortOverride(testMixedPortEnv); ok { - port = override - } else { - var err error - port, err = netutil.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, - } - applyMapToInbound(&mixedInbound, mixedMap) - - // 添加到配置 - opts.Inbounds = append(opts.Inbounds, mixedInbound) - - return nil -} diff --git a/internal/config/outbound_module.go b/internal/config/outbound_module.go deleted file mode 100644 index d5b27ab..0000000 --- a/internal/config/outbound_module.go +++ /dev/null @@ -1,101 +0,0 @@ -package config - -import ( - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/sagernet/sing-box/option" -) - -// OutboundModule 出站模块 -// 负责处理所有 outbounds(用户配置 + 订阅节点),并补充系统 outbounds -type OutboundModule struct{} - -func (m *OutboundModule) Name() string { - return "outbound" -} - -func (m *OutboundModule) Apply(opts *option.Options, ctx *BuildContext) error { - // 1. 过滤保留 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) - continue - } - filteredOutbounds = append(filteredOutbounds, out) - if out.Tag != "" { - userNodeTags = append(userNodeTags, out.Tag) - if IsActualOutboundType(out.Type) { - actualNodes = append(actualNodes, out.Tag) - } - } - } - - // 2. 添加 direct 出站 - directOutbound := option.Outbound{} - directOutboundMap := map[string]any{ - "type": "direct", - "tag": "direct", - } - applyMapToOutbound(&directOutbound, directOutboundMap) - filteredOutbounds = append(filteredOutbounds, directOutbound) - - // 3. 添加 block 出站 - blockOutbound := option.Outbound{} - blockOutboundMap := map[string]any{ - "type": "block", - "tag": "block", - } - applyMapToOutbound(&blockOutbound, blockOutboundMap) - filteredOutbounds = append(filteredOutbounds, blockOutbound) - - // 4 & 5. 添加 proxy selector 和 auto urltest - if len(actualNodes) > 0 { - // 有节点时的逻辑: - // - auto: urltest [all nodes] - // - proxy: selector [auto, ...all nodes] - - // 4. 添加 proxy selector - proxyNodes := append([]string{"auto"}, actualNodes...) - proxyOutbound := option.Outbound{} - proxyOutboundMap := map[string]any{ - "type": "selector", - "tag": "proxy", - "outbounds": proxyNodes, - "default": "auto", - } - applyMapToOutbound(&proxyOutbound, proxyOutboundMap) - filteredOutbounds = append(filteredOutbounds, proxyOutbound) - - // 5. 添加 auto urltest - autoOutbound := option.Outbound{} - autoOutboundMap := map[string]any{ - "type": "urltest", - "tag": "auto", - "outbounds": actualNodes, - } - applyMapToOutbound(&autoOutbound, autoOutboundMap) - filteredOutbounds = append(filteredOutbounds, autoOutbound) - } else { - // 无节点时的逻辑: - // - proxy: selector [direct] (降级为直连) - // - 不创建 auto 组 (因为没有节点可以测速) - - proxyOutbound := option.Outbound{} - proxyOutboundMap := map[string]any{ - "type": "selector", - "tag": "proxy", - "outbounds": []string{"direct"}, - "default": "direct", - } - applyMapToOutbound(&proxyOutbound, proxyOutboundMap) - filteredOutbounds = append(filteredOutbounds, proxyOutbound) - } - - // 6. 更新最终的 outbounds - opts.Outbounds = filteredOutbounds - - return nil -} diff --git a/internal/config/port_override.go b/internal/config/port_override.go deleted file mode 100644 index e00177c..0000000 --- a/internal/config/port_override.go +++ /dev/null @@ -1,21 +0,0 @@ -package config - -import ( - "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 -} diff --git a/internal/config/processor.go b/internal/config/processor.go deleted file mode 100644 index 33043d5..0000000 --- a/internal/config/processor.go +++ /dev/null @@ -1,147 +0,0 @@ -package config - -import ( - "context" - - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/sagernet/sing-box/include" - "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" -) - -// OutboundProcessor 处理出站节点的通用逻辑 -type OutboundProcessor struct { - UsedTags map[string]bool -} - -// NewOutboundProcessor 创建处理器 -func NewOutboundProcessor(existingTags map[string]bool) *OutboundProcessor { - if existingTags == nil { - existingTags = make(map[string]bool) - } - return &OutboundProcessor{ - UsedTags: existingTags, - } -} - -// RawOutbound 表示通用的出站配置 map -type RawOutbound map[string]any - -// ProcessStandard 处理标准出站列表 (例如来自用户配置) -// source: 用于标识这批节点的来源,用于生成 Tag (例如 "user" 或订阅名) -func (p *OutboundProcessor) Process(outbounds []RawOutbound, source string) ([]option.Outbound, error) { - if len(outbounds) == 0 { - return nil, nil - } - - // 1. Pass 1: 分配唯一 Tag,建立 Old -> New 映射 - tagMapping := make(map[string]string) - newTags := make([]string, len(outbounds)) - - for i, out := range outbounds { - // 获取原始 Tag - oldTag := "" - if v, ok := out["tag"].(string); ok { - oldTag = v - } - - // 生成唯一 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) - } - - newTags[i] = newTag - if oldTag != "" { - tagMapping[oldTag] = newTag - } - } - - // 2. Pass 2: 应用 Tag 并修正 Detour,转换为对象 - results := make([]option.Outbound, 0, len(outbounds)) - - for i, outMap := range outbounds { - // 1. 修正 Detour 字段 (单引用) - if detour, ok := outMap["detour"].(string); ok && detour != "" { - if mapped, exists := tagMapping[detour]; exists { - outMap["detour"] = mapped - } - } - - // 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 - } - } - - // 3. 应用新 Tag - outMap["tag"] = newTags[i] - - // 转换为 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 - } - results = append(results, out) - } - - return results, nil -} - -// applyMapToOutbound 辅助函数:Map -> Outbound -func (p *OutboundProcessor) applyMapToOutbound(out *option.Outbound, m RawOutbound) error { - // 先序列化回 JSON - data, err := singboxjson.Marshal(m) - if err != nil { - return err - } - // 再反序列化为 Struct - // 必须使用 include.Context,否则 interface{} 类型的字段无法正确解析 - ctx := include.Context(context.Background()) - return singboxjson.UnmarshalContext(ctx, data, out) -} diff --git a/internal/config/route_module.go b/internal/config/route_module.go deleted file mode 100644 index 22f77ad..0000000 --- a/internal/config/route_module.go +++ /dev/null @@ -1,140 +0,0 @@ -package config - -import ( - "github.com/kyson-dev/sing-helm/internal/runtime" - "github.com/sagernet/sing-box/option" - singboxjson "github.com/sagernet/sing/common/json" -) - -// RouteModule 路由模块 -// 负责配置路由规则,支持 RouteMode -type RouteModule struct { - RouteMode runtime.RouteMode -} - -func (m *RouteModule) Name() string { - return "route" -} - -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 - } - - // 根据 RouteMode 调整路由 - switch m.RouteMode { - case runtime.RouteModeGlobal: - // 全局代理:清空所有路由规则,直接走 proxy - // 保留 RuleSet 以供 DNS 规则使用 - opts.Route.Rules = nil - opts.Route.Final = "proxy" - case runtime.RouteModeDirect: - // 全局直连:清空所有路由规则,直接走 direct - // 保留 RuleSet 以供 DNS 规则使用 - opts.Route.Rules = nil - opts.Route.Final = "direct" - case runtime.RouteModeRule, "": - // rule 模式保持用户配置的路由规则 - if opts.Route.Final == "" { - opts.Route.Final = "proxy" // 默认 final 走代理 - } - } - - return nil -} - -// generateDefaultRoute 生成默认路由规则 -// 当用户没有配置 Route 时使用 -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", - "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", - "auto_detect_interface": true, - } - - data, err := singboxjson.Marshal(routeMap) - if err != nil { - return nil, err - } - - var routeOpts option.RouteOptions - if err := singboxjson.Unmarshal(data, &routeOpts); err != nil { - return nil, err - } - - return &routeOpts, nil -} diff --git a/internal/config/subscription_module.go b/internal/config/subscription_module.go deleted file mode 100644 index 901915c..0000000 --- a/internal/config/subscription_module.go +++ /dev/null @@ -1,77 +0,0 @@ -package config - -import ( - "github.com/kyson-dev/sing-helm/internal/env" - "github.com/kyson-dev/sing-helm/internal/logger" - "github.com/kyson-dev/sing-helm/internal/subscription" - "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 := env.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/config/tun_module.go b/internal/config/tun_module.go deleted file mode 100644 index 5479317..0000000 --- a/internal/config/tun_module.go +++ /dev/null @@ -1,122 +0,0 @@ -package config - -import ( - "context" - - "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, - } - 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": "proxy", - }, - { - "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/config/user_module.go b/internal/config/user_module.go deleted file mode 100644 index 578fd84..0000000 --- a/internal/config/user_module.go +++ /dev/null @@ -1,77 +0,0 @@ -package config - -import ( - "bytes" - "encoding/json" - "os" - - "github.com/kyson-dev/sing-helm/internal/env" - "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 := env.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 -} diff --git a/internal/controller/controller.go b/internal/controller/controller.go deleted file mode 100644 index 1718236..0000000 --- a/internal/controller/controller.go +++ /dev/null @@ -1,108 +0,0 @@ -package controller - -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" -) - -// 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(env.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 deleted file mode 100644 index 042b2a8..0000000 --- a/internal/daemon/daemon.go +++ /dev/null @@ -1,597 +0,0 @@ -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/updater" - "github.com/kyson-dev/sing-helm/internal/env" - "github.com/kyson-dev/sing-helm/internal/ipc" -) - -// Daemon handles long-running sing-box operations and responds to IPC commands. -type ServiceRunner interface { - StartFromFile(context.Context, string) error - ReloadFromFile(context.Context, string) error - Stop() -} - -type Daemon struct { - mu sync.Mutex - cancelFunc context.CancelFunc // 用于取消 daemon context - service ServiceRunner - serviceFactory func() ServiceRunner - lock *env.DaemonLock - running bool - reloading bool // 防止并发 reload - state *runtime.RuntimeState -} - -// NewDaemon builds a daemon controller. -func NewDaemon() *Daemon { - return &Daemon{ - serviceFactory: func() ServiceRunner { - return service.NewInstance() - }, - } -} - -// SetServiceFactory overrides the service factory (useful for tests). -func (d *Daemon) SetServiceFactory(factory func() ServiceRunner) { - d.mu.Lock() - defer d.mu.Unlock() - if factory == nil { - d.serviceFactory = func() ServiceRunner { - return service.NewInstance() - } - return - } - d.serviceFactory = factory -} - -// 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 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 { - 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, - }) - - // 创建可取消的 context,用于控制所有子服务的生命周期 - ctx, cancel := context.WithCancel(ctx) - d.cancelFunc = cancel - defer func() { - logger.Info("Daemon shutting down defer") - 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 { - 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. -func (d *Daemon) Handle(ctx context.Context, cmd ipc.CommandMessage) ipc.CommandResult { - switch cmd.Name { - case "run": - return d.handleRun(ctx, cmd.Payload) - case "update": - return d.handleUpdate(ctx) - case "stop": - return d.handleStop() - case "status": - return d.handleStatus() - case "mode": - return d.handleMode(ctx, cmd.Payload) - case "route": - return d.handleRoute(ctx, cmd.Payload) - case "node.list": - return d.handleNodeList(cmd.Payload) - case "node.use": - return d.handleNodeUse(cmd.Payload) - case "log": - return d.handleLog() - case "health": - return d.handleHealth() - case "reload": - return d.handleReload(ctx) - default: - return ipc.CommandResult{Status: "error", Error: fmt.Sprintf("unknown command: %s", cmd.Name)} - } -} - -// 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()} - } - - // 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() - 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 -} - -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 -} - -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) 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) 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 - 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"} -} - -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() - } - return service.NewInstance() -} - -func (d *Daemon) currentState() (*runtime.RuntimeState, error) { - d.mu.Lock() - state := d.state - d.mu.Unlock() - - if state != nil { - copyState := *state - return ©State, nil - } - 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() { - - state, err := runtime.LoadState() - if err != nil { - if os.IsNotExist(err) { - return - } - logger.Error("Failed to load runtime state", "error", err) - return - } - d.mu.Lock() - d.state = state - d.state.PID = os.Getpid() - d.mu.Unlock() -} diff --git a/internal/env/paths.go b/internal/env/paths.go deleted file mode 100644 index 16fd228..0000000 --- a/internal/env/paths.go +++ /dev/null @@ -1,102 +0,0 @@ -package env - -import ( - "os" - "path/filepath" - "sync" - - "github.com/kyson-dev/sing-helm/internal/logger" -) - -// 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 目录 - LogFile string // sing-helm.log - StateFile string // state.json - LookFile string // sing-helm.lock - SocketFile string // 仅 Linux 用,或存放 API 地址的文件 - AssetDir string // 存放 geoip.db/geosite.db - CacheFile string // cache.db (sing-box 缓存) -} - -var ( - current Paths - once sync.Once -) - -// Get 获取全局路径配置 -func Get() Paths { - return current -} - -// Init 初始化环境 -// home: 必须是已解析的绝对路径或相对路径,如果为空则报错(或者使用默认?) -// 为了保持兼容性,我们可以让 Init("") 依旧使用默认 ~/.sing-helm, -// 但真正的智能选择逻辑交给 setup.go -func Init(home string) error { - var err error - once.Do(func() { - if home == "" { - // 兜底默认值 - userHome, _ := os.UserHomeDir() - home = filepath.Join(userHome, ".sing-helm") - } - - // 转换成绝对路径 - home, err = filepath.Abs(home) - if err != nil { - return - } - - // 确保主目录存在 - if err = os.MkdirAll(home, 0755); err != nil { - return - } - - runtimeDir := ResolveRuntimeDir() - runtimeDir, err = filepath.Abs(runtimeDir) - if err != nil { - return - } - - logDir := logger.ResolveLogDir(runtimeDir) - current = GetPath(home, runtimeDir, logDir) - }) - return err -} - - -// GetPath 根据主目录生成路径配置 (纯函数) -func GetPath(home string, runtimeDir string, logDir string) Paths { - logFile := "" - if logDir != "" { - 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"), - LogFile: logFile, - StateFile: filepath.Join(runtimeDir, "state.json"), - LookFile: GetLockPath(runtimeDir), // 使用 lock.go 中的单一事实来源 - SocketFile: filepath.Join(runtimeDir, "ipc.sock"), - AssetDir: filepath.Join(runtimeDir, "assets"), - CacheFile: filepath.Join(runtimeDir, "cache.db"), - } -} - -// ResetForTest 重置环境单例状态 -// ⚠️ 仅供测试使用,生产代码禁止调用 -func ResetForTest() { - current = Paths{} - once = sync.Once{} - ResetRuntimeDir() -} diff --git a/internal/client/client.go b/internal/proxy/clashapi/client.go similarity index 82% rename from internal/client/client.go rename to internal/proxy/clashapi/client.go index 80d2a32..aaab080 100644 --- a/internal/client/client.go +++ b/internal/proxy/clashapi/client.go @@ -1,4 +1,4 @@ -package client +package clashapi import ( "bytes" @@ -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/client/client_test.go b/internal/proxy/clashapi/client_test.go similarity index 99% rename from internal/client/client_test.go rename to internal/proxy/clashapi/client_test.go index 7dd1866..0b80a9e 100644 --- a/internal/client/client_test.go +++ b/internal/proxy/clashapi/client_test.go @@ -1,4 +1,4 @@ -package client +package clashapi import ( "encoding/json" diff --git a/internal/proxy/config/builder.go b/internal/proxy/config/builder.go new file mode 100644 index 0000000..e116fed --- /dev/null +++ b/internal/proxy/config/builder.go @@ -0,0 +1,55 @@ +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/sys/logger" + "github.com/sagernet/sing-box/option" +) + +// Builder 配置构建器 +// 支持链式调用添加模块,灵活组装配置 +type Builder struct { + opts *model.RunOptions // 运行时参数 + modules []module.ConfigModule // 配置模块列表 + ctx *module.BuildContext // 构建上下文 +} + +// NewBuilder 创建配置构建器(从已加载的配置) +func NewBuilder(opts *model.RunOptions) *Builder { + if opts == nil { + defaultOpts := model.DefaultRunOptions() + opts = &defaultOpts + } + return &Builder{ + opts: opts, + modules: []module.ConfigModule{}, + ctx: module.NewBuildContext(opts), + } +} + +// With 添加一个模块(链式调用) +func (b *Builder) With(m module.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 +} + + diff --git a/internal/proxy/config/config.go b/internal/proxy/config/config.go new file mode 100644 index 0000000..a9331bf --- /dev/null +++ b/internal/proxy/config/config.go @@ -0,0 +1,116 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + + "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" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// 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) + } + + 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 +} + +// 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.TemplateModule{}, + module.NewOutboundModule( + &nodeProvider.SubscriptionNodeProvider{}, + ), + } + + // 根据 ProxyMode 选择入站模块 + switch opts.ProxyMode { + case model.ProxyModeTUN: + modules = append(modules, + &module.TUNModule{}, + &module.DNSModule{}, + ) + 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 +} + +// 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 map[string]any + 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/tools/exporter/exporter.go b/internal/proxy/config/export/compat.go similarity index 62% rename from internal/tools/exporter/exporter.go rename to internal/proxy/config/export/compat.go index fdaafb8..84f8f30 100644 --- a/internal/tools/exporter/exporter.go +++ b/internal/proxy/config/export/compat.go @@ -1,70 +1,17 @@ -package exporter +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") - if err != nil { - return err - } - - if less { - // v1.11.x compatibility transforms - downgradeDNSServers(root) - downgradeDNSDetour(root) // Add detour: direct for DNS servers - downgradeRuleSets(root) - downgradeTunInbounds(root) - downgradeSelectorOutbounds(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 @@ -185,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) @@ -312,55 +235,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/proxy/config/export/compat_test.go b/internal/proxy/config/export/compat_test.go new file mode 100644 index 0000000..6de5890 --- /dev/null +++ b/internal/proxy/config/export/compat_test.go @@ -0,0 +1,39 @@ +package export + +import "testing" + +func TestApplyCompatForV1114_KeepTunAddress(t *testing.T) { + root := map[string]any{ + "inbounds": []any{ + map[string]any{ + "type": "tun", + "tag": "tun-in", + "address": "172.19.0.1/30", + }, + }, + } + + applyCompatForV1114(root) + + tun := firstInboundAsMap(t, root) + if _, ok := tun["address"]; !ok { + 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.4") + } +} + +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/export/export.go b/internal/proxy/config/export/export.go new file mode 100644 index 0000000..56054d9 --- /dev/null +++ b/internal/proxy/config/export/export.go @@ -0,0 +1,46 @@ +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) + } + + 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 + if strings.TrimSpace(target.Platform) != "" { + applyPlatformCompat(root, target.Platform) + } + + return json.MarshalIndent(root, "", " ") +} 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/config/utils.go b/internal/proxy/config/loader.go similarity index 53% rename from internal/config/utils.go rename to internal/proxy/config/loader.go index 8b9d6d9..2e62e65 100644 --- a/internal/config/utils.go +++ b/internal/proxy/config/loader.go @@ -28,23 +28,3 @@ 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 { - data, err := singboxjson.Marshal(m) - if err != nil { - return err - } - // 使用 context 确保类型注册 - ctx := include.Context(context.Background()) - return singboxjson.UnmarshalContext(ctx, data, out) -} - -// applyMapToInbound 将 map 配置应用到 Inbound 结构体 -func applyMapToInbound(in *option.Inbound, m map[string]any) error { - data, err := singboxjson.Marshal(m) - if err != nil { - return err - } - ctx := include.Context(context.Background()) - return singboxjson.UnmarshalContext(ctx, data, in) -} diff --git a/internal/proxy/config/model/node.go b/internal/proxy/config/model/node.go new file mode 100644 index 0000000..36d9763 --- /dev/null +++ b/internal/proxy/config/model/node.go @@ -0,0 +1,10 @@ +package model + +// 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"` + SkipDedupe bool `json:"-"` + Outbound map[string]any `json:"outbound"` +} diff --git a/internal/runtime/type.go b/internal/proxy/config/model/options.go similarity index 99% rename from internal/runtime/type.go rename to internal/proxy/config/model/options.go index 46379cb..600bd02 100644 --- a/internal/runtime/type.go +++ b/internal/proxy/config/model/options.go @@ -1,4 +1,5 @@ -package runtime +package model + import ( "fmt" ) 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..0861d60 --- /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) != 3 { + 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 new file mode 100644 index 0000000..fa979fc --- /dev/null +++ b/internal/proxy/config/module/experimental.go @@ -0,0 +1,105 @@ +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" +) + +// ExperimentalModule 实验性模块 +// 负责配置 Clash API 和缓存 +type ExperimentalModule struct { + ListenAddr string + APIPort int +} + +func (m *ExperimentalModule) Name() string { + return "experimental" +} + +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 == "" { + listenAddr = "127.0.0.1" + } + + // 确定 API 端口 + apiPort := m.APIPort + if apiPort == 0 { + var err error + apiPort, err = moduleUtils.GetFreePort() + if err != nil { + return err + } + } + + // 更新 context 中的端口信息 + ctx.RunOptions.APIPort = apiPort + ctx.RunOptions.ListenAddr = listenAddr + + // 创建或追加 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/config/log_module.go b/internal/proxy/config/module/log.go similarity index 96% rename from internal/config/log_module.go rename to internal/proxy/config/module/log.go index 32ebee6..0b61e34 100644 --- a/internal/config/log_module.go +++ b/internal/proxy/config/module/log.go @@ -1,4 +1,4 @@ -package config +package module import ( "github.com/sagernet/sing-box/option" diff --git a/internal/config/tags.go b/internal/proxy/config/module/node/naming.go similarity index 88% rename from internal/config/tags.go rename to internal/proxy/config/module/node/naming.go index 7dea591..0004c13 100644 --- a/internal/config/tags.go +++ b/internal/proxy/config/module/node/naming.go @@ -1,16 +1,18 @@ -package config +package node import ( "fmt" "strconv" "strings" + moduleUtils "github.com/kyson-dev/sing-helm/internal/proxy/config/module/utils" ) var reservedOutboundTags = map[string]bool{ - "direct": true, - "block": true, - "proxy": true, - "auto": 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/node/processor.go b/internal/proxy/config/module/node/processor.go new file mode 100644 index 0000000..5859d31 --- /dev/null +++ b/internal/proxy/config/module/node/processor.go @@ -0,0 +1,195 @@ +package node + +import ( + "encoding/json" + "fmt" + "strings" + + "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" +) + +// OutboundProcessor processes raw outbounds, manages tags, and prevents duplication globally. +type OutboundProcessor struct { + 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 + + // 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 { + return &OutboundProcessor{ + usedTags: make(map[string]bool), + 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), + } +} + +// AddNodes processes a list of raw nodes gathered from a provider +func (p *OutboundProcessor) AddNodes(nodes []model.Node) { + for _, n := range nodes { + source := strings.TrimSpace(n.Source) + if source == "" { + source = "unknown" + } + + // 1. Global deduplication + var fp string + if !n.SkipDedupe { + fp = p.fingerprint(n) + if p.globalFingerprints[fp] { + // 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 + } + + // 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) + + p.processedNodes = append(p.processedNodes, outbound) + p.actualTags = append(p.actualTags, uniqueTag) + p.sourceGroups[source] = append(p.sourceGroups[source], uniqueTag) + } +} + +// GetProcessedOutbounds returns all properly mapped and tagged outbounds +func (p *OutboundProcessor) GetProcessedOutbounds() []option.Outbound { + return p.processedNodes +} + +// GetActualTags returns the tags of all registered proxy nodes +func (p *OutboundProcessor) GetActualTags() []string { + return p.actualTags +} + +// GetGroups returns tags grouped by their source origin +func (p *OutboundProcessor) GetGroups() map[string][]string { + return p.sourceGroups +} + +// --- Internal helpers --- + +func (p *OutboundProcessor) fingerprint(n model.Node) string { + 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) + } + } + 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) + } + 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 { + var outbound option.Outbound + + // 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 + } + } + + moduleUtils.ApplyMapToOutbound(&outbound, rawCopy) + return outbound +} + +func (p *OutboundProcessor) resolveDetour(target string) (string, bool) { + // 1. check globally reserved tags + if IsReservedOutboundTag(target) { + return target, 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 + } + } + } + + // 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)) + } +} diff --git a/internal/proxy/config/module/node/provider.go b/internal/proxy/config/module/node/provider.go new file mode 100644 index 0000000..7c910ee --- /dev/null +++ b/internal/proxy/config/module/node/provider.go @@ -0,0 +1,11 @@ +package 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. +type NodeProvider interface { + // Name returns the provider's logic name. + Name() string + // GetNodes fetches a list of normalized outbound nodes. + GetNodes() ([]model.Node, error) +} diff --git a/internal/proxy/config/module/node/provider_subscription.go b/internal/proxy/config/module/node/provider_subscription.go new file mode 100644 index 0000000..91b586a --- /dev/null +++ b/internal/proxy/config/module/node/provider_subscription.go @@ -0,0 +1,50 @@ +package node + +import ( + "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" +) + +// SubscriptionNodeProvider reads subscription nodes from cache +type SubscriptionNodeProvider struct{} + +func (p *SubscriptionNodeProvider) Name() string { + return "subscription" +} + +func (p *SubscriptionNodeProvider) GetNodes() ([]model.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 []model.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, model.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/node/provider_user.go b/internal/proxy/config/module/node/provider_user.go new file mode 100644 index 0000000..f2c3702 --- /dev/null +++ b/internal/proxy/config/module/node/provider_user.go @@ -0,0 +1,56 @@ +package node + +import ( + "encoding/json" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// 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) { + nodes := make([]model.Node, 0, len(p.outbounds)) + for _, out := range p.outbounds { + if out.Tag == "" || !IsActualOutboundType(out.Type) { + continue + } + + outboundMap, err := outboundToMap(out) + if err != nil { + return nil, err + } + + nodes = append(nodes, model.Node{ + 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 new file mode 100644 index 0000000..7f52bf9 --- /dev/null +++ b/internal/proxy/config/module/outbound.go @@ -0,0 +1,150 @@ +package module + +import ( + 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 出站模块 +// 负责组装和构建 proxy, direct, block 以及各种出站节点群 +type OutboundModule struct { + providers []nodeProvider.NodeProvider +} + +// NewOutboundModule creates a new outbound module with the given providers. +func NewOutboundModule(providers ...nodeProvider.NodeProvider) *OutboundModule { + return &OutboundModule{providers: providers} +} + +func (m *OutboundModule) Name() string { + return "outbound" +} + +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 providers { + nodes, err := provider.GetNodes() + if err != nil { + return err + } + processor.AddNodes(nodes) + } + + // 2. 获取去重且正确命名后的 proxy 出站节点 + filteredOutbounds := make([]option.Outbound, 0) + filteredOutbounds = append(filteredOutbounds, processor.GetProcessedOutbounds()...) + + actualNodes := processor.GetActualTags() + + // 3. 构建内置出站 + // 5. 添加 direct 出站 + directOutbound := option.Outbound{} + directOutboundMap := map[string]any{ + "type": moduleUtils.TagDirect, + "tag": moduleUtils.TagDirect, + } + moduleUtils.ApplyMapToOutbound(&directOutbound, directOutboundMap) + filteredOutbounds = append(filteredOutbounds, directOutbound) + + // 6. 添加 block 出站 + blockOutbound := option.Outbound{} + blockOutboundMap := map[string]any{ + "type": moduleUtils.TagBlock, + "tag": moduleUtils.TagBlock, + } + moduleUtils.ApplyMapToOutbound(&blockOutbound, blockOutboundMap) + filteredOutbounds = append(filteredOutbounds, blockOutbound) + + // 根据是否有实际节点决定如何配置 auto 和 proxy 策略组 + if len(actualNodes) > 0 { + + // 7. 添加 proxy selector + proxyNodes := append([]string{moduleUtils.TagAuto}, actualNodes...) + proxyOutbound := option.Outbound{} + proxyOutboundMap := map[string]any{ + "type": "selector", + "tag": moduleUtils.TagProxy, + "outbounds": proxyNodes, + "default": moduleUtils.TagAuto, + } + moduleUtils.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) + filteredOutbounds = append(filteredOutbounds, proxyOutbound) + + // 8. 添加 auto urltest + autoOutbound := option.Outbound{} + autoOutboundMap := map[string]any{ + "type": "urltest", + "tag": moduleUtils.TagAuto, + "outbounds": actualNodes, + } + moduleUtils.ApplyMapToOutbound(&autoOutbound, autoOutboundMap) + filteredOutbounds = append(filteredOutbounds, autoOutbound) + } else { + // 无节点时的逻辑: + // - proxy: selector [direct] + proxyOutbound := option.Outbound{} + proxyOutboundMap := map[string]any{ + "type": "selector", + "tag": moduleUtils.TagProxy, + "outbounds": []string{moduleUtils.TagDirect}, + "default": moduleUtils.TagDirect, + } + moduleUtils.ApplyMapToOutbound(&proxyOutbound, proxyOutboundMap) + filteredOutbounds = append(filteredOutbounds, proxyOutbound) + } + + // 4. 将合并后的出站回填 + // 规则: + // - 硬编码/生成出站优先:与用户同名时舍弃用户定义 + // - 用户其他自定义出站保留 + 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 new file mode 100644 index 0000000..abd46cb --- /dev/null +++ b/internal/proxy/config/module/route.go @@ -0,0 +1,144 @@ +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" +) + +// RouteModule 路由模块 +// 负责组装和构建动态路由协议栈,通过拼装不同的 RouteFragment 来实现灵活的规则扩展 +type RouteModule struct { + RouteMode model.RouteMode +} + +func (m *RouteModule) Name() string { + return "route" +} + +func (m *RouteModule) Apply(opts *option.Options, ctx *BuildContext) error { + if opts.Route == nil { + opts.Route = &option.RouteOptions{} + } + + // 1. 如果用户没有自定义 final 出站,设置默认 + if opts.Route.Final == "" { + opts.Route.Final = moduleUtils.TagProxy + } + + // 2. 将全局/直连模式转化为更高级别的劫持 + switch m.RouteMode { + case model.RouteModeGlobal: + // 全局代理:覆盖前面的默认 Final + opts.Route.Final = moduleUtils.TagProxy + // 但我们需要保留 DNS 和局域网绕过的规则,因此我们仍然应用 default 规则 + case model.RouteModeDirect: + // 全局直连:所有流量默认直连 + opts.Route.Final = moduleUtils.TagDirect + } + + // 3. 构建并应用默认扩展拼图 (作为无条件兜底) + // 我们将生成的保底规则直接追加到用户自定义规则的后面。 + // 这样用户在 profile.json 中配置的分流优先级最高。 + if err := m.applyDefaultFragments(opts); err != nil { + return err + } + + // 4. 清空特定模式下的所有非必要规则 + // 对于全局/直连,我们可以强制清空普通路由 + if m.RouteMode == model.RouteModeGlobal || m.RouteMode == model.RouteModeDirect { + opts.Route.Rules = nil + } + + return nil +} + +// applyDefaultFragments 组装默认的开箱即用路由规则 +func (m *RouteModule) applyDefaultFragments(opts *option.Options) error { + var ruleSets []map[string]any + var rules []map[string]any + + // 片段 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", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs", + "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": moduleUtils.TagProxy, + }) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-ads", "anti-ad"}, "outbound": moduleUtils.TagBlock}) + + // 片段 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": moduleUtils.TagProxy, + }) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-apple"}, "outbound": moduleUtils.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": 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": moduleUtils.TagProxy, + }) + rules = append(rules, map[string]any{"rule_set": []string{"geosite-cn", "geoip-cn"}, "outbound": moduleUtils.TagDirect}) + + // 此时组合成一个整体 map 进行反序列化 (为了兼容 sing-box 的 rule 抽象类型) + routeMap := map[string]any{ + "rule_set": ruleSets, + "rules": rules, + "auto_detect_interface": true, + } + + data, err := singboxjson.Marshal(routeMap) + if err != nil { + return err + } + + var generatedRoute option.RouteOptions + tx := include.Context(context.Background()) + if err := singboxjson.UnmarshalContext(tx, data, &generatedRoute); err != nil { + return err + } + + 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/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 +} 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/config/module.go b/internal/proxy/config/module/types.go similarity index 76% rename from internal/config/module.go rename to internal/proxy/config/module/types.go index 77fdb5e..54b7235 100644 --- a/internal/config/module.go +++ b/internal/proxy/config/module/types.go @@ -1,7 +1,7 @@ -package config +package module import ( - "github.com/kyson-dev/sing-helm/internal/runtime" + "github.com/kyson-dev/sing-helm/internal/proxy/config/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/proxy/config/module/utils/constants.go b/internal/proxy/config/module/utils/constants.go new file mode 100644 index 0000000..ddefeca --- /dev/null +++ b/internal/proxy/config/module/utils/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/utils/map.go b/internal/proxy/config/module/utils/map.go new file mode 100644 index 0000000..6f8b435 --- /dev/null +++ b/internal/proxy/config/module/utils/map.go @@ -0,0 +1,30 @@ +package module + +import ( + "context" + + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + singboxjson "github.com/sagernet/sing/common/json" +) + +// ApplyMapToOutbound 将 map 配置应用到 Outbound 结构体 +func ApplyMapToOutbound(out *option.Outbound, m map[string]any) error { + data, err := singboxjson.Marshal(m) + if err != nil { + return err + } + // 使用 context 确保类型注册 + ctx := include.Context(context.Background()) + return singboxjson.UnmarshalContext(ctx, data, out) +} + +// ApplyMapToInbound 将 map 配置应用到 Inbound 结构体 +func ApplyMapToInbound(in *option.Inbound, m map[string]any) error { + data, err := singboxjson.Marshal(m) + if err != nil { + return err + } + ctx := include.Context(context.Background()) + return singboxjson.UnmarshalContext(ctx, data, in) +} diff --git a/internal/pkg/netutil/port.go b/internal/proxy/config/module/utils/ports.go similarity index 95% rename from internal/pkg/netutil/port.go rename to internal/proxy/config/module/utils/ports.go index 8cab7e8..1965564 100644 --- a/internal/pkg/netutil/port.go +++ b/internal/proxy/config/module/utils/ports.go @@ -1,9 +1,8 @@ -package netutil +package module import ( "net" ) - // GetFreePort 请求内核分配一个空闲端口 func GetFreePort() (int, error) { // 监听端口 0,内核会自动分配一个空闲端口 @@ -17,7 +16,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/proxy/config/subscription/adapter/hysteria.go b/internal/proxy/config/subscription/adapter/hysteria.go new file mode 100644 index 0000000..e8bd882 --- /dev/null +++ b/internal/proxy/config/subscription/adapter/hysteria.go @@ -0,0 +1,185 @@ +package adapter + +import ( + "fmt" + "net/url" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" +) + +// HysteriaAdapter handles Hysteria protocol +type HysteriaAdapter struct{} + +func init() { + Register("hysteria", &HysteriaAdapter{}) +} + +func (a *HysteriaAdapter) FromClash(m map[string]any) (model.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") + if server == "" || port == 0 { + return model.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 model.Node{ + Type: "hysteria", + Outbound: outbound, + }, nil +} + +func (a *HysteriaAdapter) FromURI(uriStr string) (model.Node, error) { + u, err := url.Parse("hysteria://" + uriStr) + if err != nil { + return model.Node{}, err + } + + auth := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if server == "" || port == "" { + return model.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 model.Node{ + Name: name, + Type: "hysteria", + Outbound: outbound, + }, nil +} + +// Hysteria2Adapter handles Hysteria2 protocol +type Hysteria2Adapter struct{} + +func init() { + Register("hysteria2", &Hysteria2Adapter{}) + Register("hy2", &Hysteria2Adapter{}) +} + +func (a *Hysteria2Adapter) FromClash(m map[string]any) (model.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") + if server == "" || port == 0 { + return model.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 model.Node{ + Type: "hysteria2", + Outbound: outbound, + }, nil +} + +func (a *Hysteria2Adapter) FromURI(uriStr string) (model.Node, error) { + u, err := url.Parse("hysteria2://" + uriStr) + if err != nil { + return model.Node{}, err + } + + password := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if server == "" || port == "" { + return model.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 model.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..a473d2c --- /dev/null +++ b/internal/proxy/config/subscription/adapter/registry.go @@ -0,0 +1,35 @@ +package adapter + +import ( + "fmt" + + "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) (model.Node, error) + FromURI(uri string) (model.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/subscription/adapter/ss_trojan.go new file mode 100644 index 0000000..09dd49f --- /dev/null +++ b/internal/proxy/config/subscription/adapter/ss_trojan.go @@ -0,0 +1,173 @@ +package adapter + +import ( + "encoding/base64" + "fmt" + "net/url" + "strings" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" +) + +// ShadowsocksAdapter handles Shadowsocks protocol +type ShadowsocksAdapter struct{} + +func init() { + Register("ss", &ShadowsocksAdapter{}) + Register("shadowsocks", &ShadowsocksAdapter{}) +} + +func (a *ShadowsocksAdapter) FromClash(m map[string]any) (model.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") + if server == "" || port == 0 { + return model.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 model.Node{ + Type: "shadowsocks", + Outbound: outbound, + }, nil +} + +func (a *ShadowsocksAdapter) FromURI(uriStr string) (model.Node, error) { + parts := strings.SplitN(uriStr, "@", 2) + if len(parts) != 2 { + return model.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 model.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 model.Node{}, fmt.Errorf("invalid server:port format") + } + + server := spParts[0] + port, _ := ParseInt(spParts[1]) + + return model.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() { + Register("trojan", &TrojanAdapter{}) +} + +func (a *TrojanAdapter) FromClash(m map[string]any) (model.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") + if server == "" || port == 0 { + return model.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 model.Node{ + Type: "trojan", + Outbound: outbound, + }, nil +} + +func (a *TrojanAdapter) FromURI(uriStr string) (model.Node, error) { + u, err := url.Parse("trojan://" + uriStr) + if err != nil { + return model.Node{}, err + } + + password := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if password == "" || server == "" || port == "" { + return model.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 model.Node{ + Name: name, + Type: "trojan", + Outbound: outbound, + }, nil +} diff --git a/internal/proxy/config/subscription/adapter/utils.go b/internal/proxy/config/subscription/adapter/utils.go new file mode 100644 index 0000000..36ce930 --- /dev/null +++ b/internal/proxy/config/subscription/adapter/utils.go @@ -0,0 +1,224 @@ +package adapter + +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 +} diff --git a/internal/proxy/config/subscription/adapter/vless.go b/internal/proxy/config/subscription/adapter/vless.go new file mode 100644 index 0000000..9e0f980 --- /dev/null +++ b/internal/proxy/config/subscription/adapter/vless.go @@ -0,0 +1,219 @@ +package adapter + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" +) + +// VMessAdapter handles VMess protocol in Clash and URI formats. +type VMessAdapter struct{} + +func init() { + Register("vmess", &VMessAdapter{}) +} + +func (a *VMessAdapter) FromClash(m map[string]any) (model.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") + if server == "" || port == 0 { + return model.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 model.Node{ + Type: "vmess", + Outbound: outbound, + }, nil +} + +func (a *VMessAdapter) FromURI(uri string) (model.Node, error) { + decoded, err := base64.StdEncoding.DecodeString(uri) + if err != nil { + return model.Node{}, fmt.Errorf("invalid vmess URI: %w", err) + } + + var m map[string]any + if err := json.Unmarshal(decoded, &m); err != nil { + return model.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 model.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 model.Node{ + Name: name, + Type: "vmess", + Outbound: outbound, + }, nil +} + +// VLessAdapter handles VLess protocol in Clash and URI formats. +type VLessAdapter struct{} + +func init() { + Register("vless", &VLessAdapter{}) +} + +func (a *VLessAdapter) FromClash(m map[string]any) (model.Node, error) { + server := ReadString(m, "server") + port := ReadInt(m, "port") + if server == "" || port == 0 { + return model.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 model.Node{ + Type: "vless", + Outbound: outbound, + }, nil +} + +func (a *VLessAdapter) FromURI(uriStr string) (model.Node, error) { + u, err := url.Parse("vless://" + uriStr) + if err != nil { + return model.Node{}, err + } + + uuid := u.User.Username() + server := u.Hostname() + port := u.Port() + name := u.Fragment + query := u.Query() + + if uuid == "" || server == "" || port == "" { + return model.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 model.Node{ + Name: name, + Type: "vless", + Outbound: outbound, + }, nil +} diff --git a/internal/proxy/config/subscription/merge.go b/internal/proxy/config/subscription/merge.go new file mode 100644 index 0000000..581c8d8 --- /dev/null +++ b/internal/proxy/config/subscription/merge.go @@ -0,0 +1,57 @@ +package subscription + +import ( + "path/filepath" + "strings" + + "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) ([]model.Node, error) { + var finalNodes []model.Node + for _, s := range sources { + if !s.EnabledValue() { + logger.Debug("Skipping disabled source", "name", s.Name) + continue + } + + 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 + } + + nodes := cache.Nodes + if len(nodes) == 0 { + continue + } + + // Apply tags to nodes + if len(s.Tags) > 0 { + nodes = appendTags(nodes, s.Tags) + } + + // Pass dedupe intention to the node level + for _, n := range nodes { + n.Source = s.Name + n.SkipDedupe = !s.DedupeValue() + finalNodes = append(finalNodes, n) + } + } + + return finalNodes, nil +} + +func appendTags(nodes []model.Node, tags []string) []model.Node { + for i := range nodes { + for _, tag := range tags { + if !strings.Contains(nodes[i].Name, tag) { + nodes[i].Name = nodes[i].Name + " " + tag + } + } + } + return nodes +} diff --git a/internal/proxy/config/subscription/parse.go b/internal/proxy/config/subscription/parse.go new file mode 100644 index 0000000..6f19958 --- /dev/null +++ b/internal/proxy/config/subscription/parse.go @@ -0,0 +1,207 @@ +package subscription + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "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" +) + +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) ([]model.Node, error) { + format = NormalizeFormat(strings.ToLower(strings.TrimSpace(format))) + switch format { + case FormatAuto: + if nodes, err := parseSingBox(content); err == nil { + return nodes, nil + } + if nodes, err := parseClash(content); err == nil { + return nodes, nil + } + if nodes, err := parseBase64URI(content); err == nil { + return nodes, nil + } + return nil, fmt.Errorf("unable to detect subscription format") + case FormatSingBox: + return parseSingBox(content) + case FormatClash: + return parseClash(content) + case FormatBase64, "uri": + return parseBase64URI(content) + default: + return nil, fmt.Errorf("unsupported subscription format: %s", format) + } +} + +func parseSingBox(content []byte) ([]model.Node, error) { + var root map[string]any + if err := json.Unmarshal(content, &root); err != nil { + return nil, err + } + + outboundsRaw, ok := root["outbounds"] + if !ok { + return nil, fmt.Errorf("missing outbounds") + } + + list, ok := outboundsRaw.([]any) + if !ok { + return nil, fmt.Errorf("invalid outbounds format") + } + + var nodes []model.Node + for i, raw := range list { + outMap, ok := raw.(map[string]any) + if !ok { + continue + } + outType := adapter.ReadString(outMap, "type") + if outType == "" || !isActualOutboundType(outType) { + continue + } + name := adapter.ReadString(outMap, "tag") + if name == "" { + name = fmt.Sprintf("%s-%d", outType, i+1) + } + delete(outMap, "tag") + + nodes = append(nodes, model.Node{ + Name: name, + Type: outType, + Outbound: outMap, + }) + } + + if len(nodes) == 0 { + return nil, fmt.Errorf("no supported outbounds found") + } + return nodes, nil +} + +func parseClash(content []byte) ([]model.Node, error) { + var root map[string]any + if err := yaml.Unmarshal(content, &root); err != nil { + return nil, err + } + + proxiesRaw, ok := root["proxies"] + if !ok { + return nil, fmt.Errorf("missing proxies") + } + + list, ok := proxiesRaw.([]any) + if !ok { + return nil, fmt.Errorf("invalid proxies format") + } + + var nodes []model.Node + for _, raw := range list { + proxyMap := adapter.AsStringMap(raw) + if proxyMap == nil { + continue + } + + 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 + } + + n, err := a.FromClash(proxyMap) + if err != nil { + logger.Debug("Failed to parse clash node", "type", proxyType, "error", err.Error()) + continue + } + + name := adapter.ReadString(proxyMap, "name") + if name != "" { + n.Name = name + } else if n.Name == "" { + n.Name = fmt.Sprintf("%s-%v:%v", n.Type, proxyMap["server"], proxyMap["port"]) + } + + nodes = append(nodes, n) + } + + if len(nodes) == 0 { + return nil, fmt.Errorf("no supported proxies found") + } + return nodes, nil +} + +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 []model.Node + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + idx := strings.Index(line, "://") + if idx < 0 { + continue + } + + scheme := strings.ToLower(line[:idx]) + a, err := adapter.Get(scheme) + if err != nil { + logger.Debug("Skipping proxy node", "scheme", scheme, "error", err.Error()) + continue + } + + 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, n) + } + + if len(nodes) == 0 { + return nil, fmt.Errorf("no valid proxy URIs found") + } + 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/refresh.go b/internal/proxy/config/subscription/refresh.go new file mode 100644 index 0000000..99100f1 --- /dev/null +++ b/internal/proxy/config/subscription/refresh.go @@ -0,0 +1,63 @@ +package subscription + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/kyson-dev/sing-helm/internal/sys/logger" +) + +// 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) + + req, err := http.NewRequestWithContext(ctx, "GET", source.URL, nil) + if err != nil { + return fmt.Errorf("create request failed: %w", err) + } + + // 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 fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status code: %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read body failed: %w", err) + } + + nodes, err := Parse(content, source.Format) + if err != nil { + return fmt.Errorf("parse subscription failed: %w", err) + } + + logger.Info("Successfully parsed nodes", "count", len(nodes)) + + cache := Cache{ + Source: source, + UpdatedAt: time.Now().Format(time.RFC3339), + Nodes: nodes, + } + + err = os.MkdirAll(cacheDir, 0755) + if err != nil { + return fmt.Errorf("create cache dir failed: %w", err) + } + + 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 new file mode 100644 index 0000000..096adb1 --- /dev/null +++ b/internal/proxy/config/subscription/storage.go @@ -0,0 +1,100 @@ +package subscription + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// LoadSources reads all .json subscription definitions from the config directory +func LoadSources(configDir string) ([]Source, error) { + var sources []Source + + entries, err := os.ReadDir(configDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read subscription config dir failed: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + 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 + } + + s.NormalizeDefaults(name) + sources = append(sources, s) + } + + // Sort sources by priority descending + sort.SliceStable(sources, func(i, j int) bool { + return sources[i].Priority > sources[j].Priority + }) + + return sources, nil +} + +// 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) + } + + configPath := filepath.Join(configDir, source.Name+".json") + data, err := json.MarshalIndent(source, "", " ") + if err != nil { + return fmt.Errorf("marshal subscription source failed: %w", err) + } + + return os.WriteFile(configPath, data, 0644) +} + +// 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 +func LoadCache(cachePath string) (*Cache, error) { + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, fmt.Errorf("read cache file failed: %w", err) + } + + var cache Cache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("unmarshal cache file failed: %w", err) + } + + return &cache, nil +} + +// SaveCache saves parsed nodes back into the cache +func SaveCache(cachePath string, cache Cache) error { + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + 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 nil +} diff --git a/internal/proxy/config/subscription/types.go b/internal/proxy/config/subscription/types.go new file mode 100644 index 0000000..a78e64c --- /dev/null +++ b/internal/proxy/config/subscription/types.go @@ -0,0 +1,55 @@ +package subscription + +import ( + "github.com/kyson-dev/sing-helm/internal/proxy/config/model" +) + +// Source describes a subscription config file. +type Source struct { + Name string `json:"name"` + URL string `json:"url"` + Format string `json:"format"` // auto, singbox, clash + Enabled *bool `json:"enabled"` + Priority int `json:"priority"` + Dedupe *bool `json:"dedupe"` + Tags []string `json:"tags,omitempty"` +} + +// Cache stores parsed nodes from a subscription source. +type Cache struct { + Source Source `json:"source"` + UpdatedAt string `json:"updated_at"` + Nodes []model.Node `json:"nodes"` +} + +func (s *Source) NormalizeDefaults(name string) { + if s.Name == "" { + s.Name = name + } + s.Format = NormalizeFormat(s.Format) + if s.Format == "" { + s.Format = FormatAuto + } + if s.Enabled == nil { + enabled := true + s.Enabled = &enabled + } + if s.Dedupe == nil { + dedupe := true + s.Dedupe = &dedupe + } +} + +func (s Source) EnabledValue() bool { + if s.Enabled == nil { + return true + } + return *s.Enabled +} + +func (s Source) DedupeValue() bool { + if s.Dedupe == nil { + return true + } + return *s.Dedupe +} diff --git a/internal/service/instance.go b/internal/proxy/engine/engine.go similarity index 92% rename from internal/service/instance.go rename to internal/proxy/engine/engine.go index a049b78..ff61734 100644 --- a/internal/service/instance.go +++ b/internal/proxy/engine/engine.go @@ -1,12 +1,12 @@ -package service +package engine import ( "context" "fmt" "sync" - "github.com/kyson-dev/sing-helm/internal/config" - "github.com/kyson-dev/sing-helm/internal/logger" + "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" @@ -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/service/errors.go b/internal/proxy/engine/errors.go similarity index 96% rename from internal/service/errors.go rename to internal/proxy/engine/errors.go index 0cd8069..3643ddd 100644 --- a/internal/service/errors.go +++ b/internal/proxy/engine/errors.go @@ -1,4 +1,4 @@ -package service +package engine type ReloadStage string diff --git a/internal/logger/bridge.go b/internal/proxy/engine/logger.go similarity index 59% rename from internal/logger/bridge.go rename to internal/proxy/engine/logger.go index b4e663e..2436ec5 100644 --- a/internal/logger/bridge.go +++ b/internal/proxy/engine/logger.go @@ -1,6 +1,9 @@ -package logger +package engine import ( + "regexp" + + "github.com/kyson-dev/sing-helm/internal/sys/logger" "github.com/sagernet/sing-box/log" ) @@ -8,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{} } @@ -18,17 +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: - get().Debug(message, "source", "sing-box") + logger.Debug(clean, "source", "sing-box") case log.LevelInfo: - get().Info(message, "source", "sing-box") + logger.Info(clean, "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] "+clean, "source", "sing-box") case log.LevelError, log.LevelFatal, log.LevelPanic: - get().Error(message, "source", "sing-box") + logger.Error(clean, "source", "sing-box") default: - get().Info(message, "source", "sing-box") + logger.Info(clean, "source", "sing-box") } } 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/subscription/merge.go b/internal/subscription/merge.go deleted file mode 100644 index b0109b4..0000000 --- a/internal/subscription/merge.go +++ /dev/null @@ -1,79 +0,0 @@ -package subscription - -import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" - "fmt" -) - -func LoadNodesFromCache(sources []Source, cacheDir string) ([]Node, error) { - seen := make(map[string]bool) - var nodes []Node - - for _, source := range sources { - if !source.EnabledValue() { - continue - } - cachePath := CacheFilePath(cacheDir, source.Name) - cache, err := LoadCache(cachePath) - if err != nil { - 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 = append(nodes, node) - } - } - - return nodes, 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) - } - delete(node.Outbound, "tag") -} - -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 - } - cloned[key] = value - } - data, err := json.Marshal(cloned) - if err != nil { - return "", err - } - sum := sha1.Sum(data) - return hex.EncodeToString(sum[:]), nil -} diff --git a/internal/subscription/parse.go b/internal/subscription/parse.go deleted file mode 100644 index 0fc1200..0000000 --- a/internal/subscription/parse.go +++ /dev/null @@ -1,896 +0,0 @@ -package subscription - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/url" - "strings" - - "github.com/kyson-dev/sing-helm/internal/logger" - "gopkg.in/yaml.v3" -) - -func Parse(content []byte, format string) ([]Node, error) { - format = NormalizeFormat(strings.ToLower(strings.TrimSpace(format))) - switch format { - case FormatAuto: - if nodes, err := parseSingBox(content); err == nil { - return nodes, nil - } - if nodes, err := parseClash(content); err == nil { - return nodes, nil - } - // 尝试 base64 URI 格式 - if nodes, err := parseBase64URI(content); err == nil { - return nodes, nil - } - return nil, fmt.Errorf("unable to detect subscription format") - case FormatSingBox: - return parseSingBox(content) - case FormatClash: - return parseClash(content) - case FormatBase64, "uri": - return parseBase64URI(content) - default: - return nil, fmt.Errorf("unsupported subscription format: %s", format) - } -} - -func parseSingBox(content []byte) ([]Node, error) { - var root map[string]any - if err := json.Unmarshal(content, &root); err != nil { - return nil, err - } - - outboundsRaw, ok := root["outbounds"] - if !ok { - return nil, fmt.Errorf("missing outbounds") - } - - list, ok := outboundsRaw.([]any) - if !ok { - return nil, fmt.Errorf("invalid outbounds format") - } - - var nodes []Node - for i, raw := range list { - outMap, ok := raw.(map[string]any) - if !ok { - continue - } - outType := readString(outMap, "type") - if outType == "" || !IsActualOutboundType(outType) { - continue - } - name := readString(outMap, "tag") - if name == "" { - name = fmt.Sprintf("%s-%d", outType, i+1) - } - delete(outMap, "tag") - nodes = append(nodes, Node{ - Name: name, - Type: outType, - Outbound: outMap, - }) - } - - if len(nodes) == 0 { - return nil, fmt.Errorf("no supported outbounds found") - } - return nodes, nil -} - -func parseClash(content []byte) ([]Node, error) { - var root map[string]any - if err := yaml.Unmarshal(content, &root); err != nil { - return nil, err - } - - proxiesRaw, ok := root["proxies"] - if !ok { - return nil, fmt.Errorf("missing proxies") - } - - list, ok := proxiesRaw.([]any) - if !ok { - return nil, fmt.Errorf("invalid proxies format") - } - - var nodes []Node - for _, raw := range list { - proxyMap := asStringMap(raw) - if proxyMap == nil { - continue - } - node, err := clashProxyToNode(proxyMap) - if err != nil { - // 记录跳过的节点,帮助调试 - name := readString(proxyMap, "name") - proxyType := readString(proxyMap, "type") - logger.Debug("Skipping proxy node", "name", name, "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 == "" { - 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 -} - -// 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 - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - node, err := parseProxyURI(line) - if err != nil { - logger.Debug("Skipping invalid URI", "uri", line[:min(len(line), 50)], "error", err.Error()) - 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 - } - } - - // 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 - } - } - - // 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 - } - } - - outbound["transport"] = transport -} - -// min helper function -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/subscription/refresh.go b/internal/subscription/refresh.go deleted file mode 100644 index 9c321cc..0000000 --- a/internal/subscription/refresh.go +++ /dev/null @@ -1,66 +0,0 @@ -package subscription - -import ( - "context" - "fmt" - "io" - "net/http" - "time" -) - -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) - } - - content, err := fetchURL(ctx, source.URL) - if err != nil { - return Cache{}, err - } - - nodes, err := Parse(content, source.Format) - if err != nil { - return Cache{}, err - } - - for i := range nodes { - if nodes[i].Source == "" { - nodes[i].Source = source.Name - } - } - - cache := Cache{ - Source: source, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - Nodes: nodes, - } - - if err := SaveCache(CacheFilePath(cacheDir, source.Name), cache); err != nil { - return Cache{}, err - } - - return cache, nil -} - -func fetchURL(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "sing-helm/1.0") - - client := &http.Client{ - Timeout: 20 * time.Second, - } - resp, err := client.Do(req) - 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 io.ReadAll(resp.Body) -} diff --git a/internal/subscription/storage.go b/internal/subscription/storage.go deleted file mode 100644 index 83aefa9..0000000 --- a/internal/subscription/storage.go +++ /dev/null @@ -1,131 +0,0 @@ -package subscription - -import ( - "encoding/json" - "fmt" - "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") -} - -func LoadSources(dir string) ([]Source, error) { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return []Source{}, nil - } - return nil, err - } - - 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) - } - - 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)) - } - 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) - } - - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - source.NormalizeDefaults(name) - if source.Name != name { - source.Name = name - } - - return source, nil -} - -func SaveSourceFile(path string, source Source) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - source.NormalizeDefaults(name) - source.Name = name - - data, err := json.MarshalIndent(source, "", " ") - if err != nil { - return err - } - - return os.WriteFile(path, data, 0644) -} - -func LoadCache(path string) (Cache, error) { - content, err := os.ReadFile(path) - if err != nil { - return Cache{}, err - } - - var cache Cache - if err := json.Unmarshal(content, &cache); err != nil { - return Cache{}, fmt.Errorf("invalid cache file %s: %w", path, err) - } - - return cache, nil -} - -func SaveCache(path string, cache Cache) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - data, err := json.MarshalIndent(cache, "", " ") - if err != nil { - return err - } - - return os.WriteFile(path, data, 0644) -} diff --git a/internal/subscription/types.go b/internal/subscription/types.go deleted file mode 100644 index 9dc70fc..0000000 --- a/internal/subscription/types.go +++ /dev/null @@ -1,91 +0,0 @@ -package subscription - -// Source describes a subscription config file. -type Source struct { - Name string `json:"name"` - URL string `json:"url"` - Format string `json:"format"` // auto, singbox, clash - Enabled *bool `json:"enabled"` - Priority int `json:"priority"` - Dedupe *bool `json:"dedupe"` - 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 - } -} - -func (s *Source) NormalizeDefaults(name string) { - if s.Name == "" { - s.Name = name - } - s.Format = NormalizeFormat(s.Format) - if s.Format == "" { - s.Format = FormatAuto - } - if s.Enabled == nil { - enabled := true - s.Enabled = &enabled - } - if s.Dedupe == nil { - dedupe := true - s.Dedupe = &dedupe - } -} - -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 - } - return *s.Enabled -} - -func (s Source) DedupeValue() bool { - if s.Dedupe == nil { - return true - } - return *s.Dedupe -} diff --git a/internal/ipc/sender.go b/internal/sys/ipc/client.go similarity index 99% rename from internal/ipc/sender.go rename to internal/sys/ipc/client.go index 875db19..d5fc01d 100644 --- a/internal/ipc/sender.go +++ b/internal/sys/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/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 78% rename from internal/ipc/types.go rename to internal/sys/ipc/types.go index 2893931..33dd7c0 100644 --- a/internal/ipc/types.go +++ b/internal/sys/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/env/lock.go b/internal/sys/lock/lock.go similarity index 77% rename from internal/env/lock.go rename to internal/sys/lock/lock.go index d37b932..d94f8f2 100644 --- a/internal/env/lock.go +++ b/internal/sys/lock/lock.go @@ -1,4 +1,4 @@ -package env +package lock import ( "errors" @@ -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/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 93% rename from internal/logger/logger.go rename to internal/sys/logger/logger.go index 06980b2..a96f6e5 100644 --- a/internal/logger/logger.go +++ b/internal/sys/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/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/log.go b/internal/sys/paths/logger.go similarity index 94% rename from internal/logger/log.go rename to internal/sys/paths/logger.go index a6b1151..17373e0 100644 --- a/internal/logger/log.go +++ b/internal/sys/paths/logger.go @@ -1,4 +1,4 @@ -package logger +package paths 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/paths/paths.go b/internal/sys/paths/paths.go new file mode 100644 index 0000000..d88ee95 --- /dev/null +++ b/internal/sys/paths/paths.go @@ -0,0 +1,130 @@ +package paths + +import ( + "os" + "path/filepath" + "sync" +) + +// Paths 定义了应用所有的关键路径 +type Paths struct { + 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 缓存) +} + +var ( + current Paths + once sync.Once +) + +// Get 获取全局路径配置 +func Get() Paths { + return current +} + +// Init 初始化环境 +// home: 必须是已解析的绝对路径或相对路径,如果为空则报错(或者使用默认?) +// 为了保持兼容性,我们可以让 Init("") 依旧使用默认 ~/.sing-helm, +// 但真正的智能选择逻辑交给 setup.go +func Init(home string) error { + var err error + once.Do(func() { + 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) { + if home == "" { + // 使用默认值 + // 如果是 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) + if err != nil { + return Paths{}, err + } + + if err := os.MkdirAll(absHome, 0755); err != nil { + return Paths{}, err + } + + runtimeDir := ResolveRuntimeDir() + runtimeDir, err = filepath.Abs(runtimeDir) + if err != nil { + return Paths{}, err + } + + logDir := resolveLogDir(runtimeDir) + return getPath(absHome, runtimeDir, logDir), nil +} + +// GetPath 根据主目录生成路径配置 (纯函数) +func getPath(home string, runtimeDir string, logDir string) Paths { + logFile := "" + if logDir != "" { + logFile = filepath.Join(logDir, "sing-helm.log") + } + return Paths{ + HomeDir: home, + RuntimeDir: runtimeDir, + RuntimeMetaFile: GetRuntimeMetaFileWithDir(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"), + } +} + +// 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() { + current = Paths{} + once = sync.Once{} + ForTestResetRuntimeDir() +} + +func ForTestInit(home string) error { + return Init(home) +} diff --git a/internal/env/runtime.go b/internal/sys/paths/runtime.go similarity index 54% rename from internal/env/runtime.go rename to internal/sys/paths/runtime.go index 2b2faff..1594471 100644 --- a/internal/env/runtime.go +++ b/internal/sys/paths/runtime.go @@ -1,7 +1,6 @@ -package env +package paths import ( - "encoding/json" "os" "path/filepath" "runtime" @@ -41,7 +40,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 @@ -74,82 +73,19 @@ 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 := 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/version/Into_test.go b/internal/sys/version/Into_test.go similarity index 82% rename from internal/version/Into_test.go rename to internal/sys/version/Into_test.go index b8fd2d0..91b80af 100644 --- a/internal/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/version" + "github.com/kyson-dev/sing-helm/internal/sys/version" "github.com/stretchr/testify/assert" ) diff --git a/internal/version/info.go b/internal/sys/version/info.go similarity index 100% rename from internal/version/info.go rename to internal/sys/version/info.go 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) -}