From a2630df9e0d40b4b693b50f7f1bf1fde684318df Mon Sep 17 00:00:00 2001 From: Klesh Wong Date: Sun, 19 Jul 2026 20:01:38 +0800 Subject: [PATCH] feat: migrate to golang --- .gitignore | 139 +--- AGENTS.md | 84 +-- GOLANG_MIGRATION_PLAN.md | 195 ++++++ README.md | 91 ++- cmd/ss/main.go | 8 + go.mod | 14 + go.sum | 15 + internal/cli/config.go | 102 +++ internal/cli/core.go | 33 + internal/cli/hook.go | 67 ++ internal/cli/root.go | 85 +++ internal/cli/service.go | 222 +++++++ internal/cli/subscription.go | 175 +++++ internal/core/manager.go | 458 +++++++++++++ internal/core/platform.go | 61 ++ internal/corecfg/hooks_exec.go | 121 ++++ internal/corecfg/manager.go | 324 +++++++++ internal/corecfg/merge.go | 27 + internal/corecfg/validate.go | 36 + internal/editor/editor.go | 57 ++ internal/hook/manager.go | 148 +++++ internal/model/config.go | 31 + internal/model/subscription.go | 140 ++++ internal/model/time.go | 62 ++ internal/model/time_test.go | 91 +++ internal/service/exec_unix.go | 11 + internal/service/exec_windows.go | 13 + internal/service/service.go | 122 ++++ internal/service/service_darwin.go | 147 ++++ internal/service/service_linux.go | 113 ++++ internal/service/service_windows.go | 142 ++++ internal/service/shlex.go | 57 ++ internal/service/status.go | 50 ++ internal/service/wrapper_darwin.go | 184 +++++ internal/service/wrapper_macos_stub.go | 11 + internal/service/wrapper_windows.go | 137 ++++ internal/service/wrapper_windows_svc_stub.go | 11 + internal/storage/storage.go | 156 +++++ internal/subscription/convert.go | 81 +++ internal/subscription/ctime_darwin.go | 18 + internal/subscription/ctime_linux.go | 16 + internal/subscription/ctime_windows.go | 19 + internal/subscription/manager.go | 236 +++++++ internal/subscription/uri_parse.go | 275 ++++++++ internal/subscription/uri_parse_test.go | 178 +++++ internal/yamlutil/yamlutil.go | 82 +++ internal/yamlutil/yamlutil_test.go | 103 +++ pyproject.toml | 9 - requirements-dev.txt | 4 - requirements-win32.txt | 1 - requirements.txt | 4 - ss/__init__.py | 11 - ss/__main__.py | 8 - ss/cli.py | 314 --------- ss/core_manager.py | 512 -------------- ss/corecfg_manager.py | 364 ---------- ss/hook_manager.py | 122 ---- ss/macos_service_wrapper.py | 257 ------- ss/models.py | 116 ---- ss/service_manager.py | 628 ------------------ ss/storage.py | 102 --- ss/subscription_manager.py | 384 ----------- ss/utils.py | 49 -- ss/windows_service_wrapper.py | 261 -------- .../default-core-config.yaml | 2 - templates/embed.go | 16 + .../hooks/core_config_generated.js | 0 .../hooks/core_config_generated.py | 0 windows.md | 7 - 69 files changed, 4750 insertions(+), 3369 deletions(-) create mode 100644 GOLANG_MIGRATION_PLAN.md create mode 100644 cmd/ss/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/config.go create mode 100644 internal/cli/core.go create mode 100644 internal/cli/hook.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/service.go create mode 100644 internal/cli/subscription.go create mode 100644 internal/core/manager.go create mode 100644 internal/core/platform.go create mode 100644 internal/corecfg/hooks_exec.go create mode 100644 internal/corecfg/manager.go create mode 100644 internal/corecfg/merge.go create mode 100644 internal/corecfg/validate.go create mode 100644 internal/editor/editor.go create mode 100644 internal/hook/manager.go create mode 100644 internal/model/config.go create mode 100644 internal/model/subscription.go create mode 100644 internal/model/time.go create mode 100644 internal/model/time_test.go create mode 100644 internal/service/exec_unix.go create mode 100644 internal/service/exec_windows.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_darwin.go create mode 100644 internal/service/service_linux.go create mode 100644 internal/service/service_windows.go create mode 100644 internal/service/shlex.go create mode 100644 internal/service/status.go create mode 100644 internal/service/wrapper_darwin.go create mode 100644 internal/service/wrapper_macos_stub.go create mode 100644 internal/service/wrapper_windows.go create mode 100644 internal/service/wrapper_windows_svc_stub.go create mode 100644 internal/storage/storage.go create mode 100644 internal/subscription/convert.go create mode 100644 internal/subscription/ctime_darwin.go create mode 100644 internal/subscription/ctime_linux.go create mode 100644 internal/subscription/ctime_windows.go create mode 100644 internal/subscription/manager.go create mode 100644 internal/subscription/uri_parse.go create mode 100644 internal/subscription/uri_parse_test.go create mode 100644 internal/yamlutil/yamlutil.go create mode 100644 internal/yamlutil/yamlutil_test.go delete mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements-win32.txt delete mode 100644 requirements.txt delete mode 100644 ss/__init__.py delete mode 100644 ss/__main__.py delete mode 100644 ss/cli.py delete mode 100644 ss/core_manager.py delete mode 100644 ss/corecfg_manager.py delete mode 100644 ss/hook_manager.py delete mode 100755 ss/macos_service_wrapper.py delete mode 100644 ss/models.py delete mode 100644 ss/service_manager.py delete mode 100644 ss/storage.py delete mode 100644 ss/subscription_manager.py delete mode 100644 ss/utils.py delete mode 100644 ss/windows_service_wrapper.py rename {ss/templates => templates}/default-core-config.yaml (99%) create mode 100644 templates/embed.go rename {ss/templates => templates}/hooks/core_config_generated.js (100%) rename {ss/templates => templates}/hooks/core_config_generated.py (100%) delete mode 100644 windows.md diff --git a/.gitignore b/.gitignore index 627de84..c5b0ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,132 +1,13 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Build artifacts +/ss +/ss.exe +/bin/ +/dist/ -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ +# Go +*.test +*.out +vendor/ # IDE .idea/ @@ -136,4 +17,4 @@ dmypy.json # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index 3a69fca..21748e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,75 +2,61 @@ ## Overview -This document describes the autonomous agents, subagents, and their roles within the scientific-surfing project. +This document describes the internal "agents" (manager components) of the +scientific-surfing (`ss`) CLI and their roles. The CLI is a single Go binary +(`cmd/ss`); each agent below is a package under `internal/`. --- ## Agent List -### 1. SubscriptionManager Agent -- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, and activating subscriptions. -- **Key Methods:** - - `add_subscription` - - `refresh_subscription` - - `delete_subscription` - - `rename_subscription` - - `set_subscription_url` - - `activate_subscription` - - `list_subscriptions` +### 1. Subscription Manager (`internal/subscription`) +- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, and activating subscriptions. Also parses `ss://`/`trojan://`/`vless://` links and base64-encoded link lists into Clash proxy YAML. +- **Key Methods:** `AddSubscription`, `RefreshSubscription`, `DeleteSubscription`, `RenameSubscription`, `SetSubscriptionURL`, `GetSubscriptionURL`, `ActivateSubscription`, `ListSubscriptions`, `ShowStorageInfo` - **Notes:** Supports backup option on refresh. -### 2. StorageManager Agent -- **Purpose:** Manages persistent storage for configuration and subscription data. -- **Key Methods:** - - `load_subscriptions` - - `save_subscriptions` - - `load_config` - - `get_storage_info` +### 2. Storage Manager (`internal/storage`) +- **Purpose:** Manages persistent storage for configuration and subscription data as YAML files under the per-user config directory (`$SF_CONFIG_DIR` or `~/basicfiles/cli/ss`). +- **Key Methods:** `LoadSubscriptions`, `SaveSubscriptions`, `LoadConfig`, `SaveConfig`, `GetStorageInfo` -### 3. CoreConfigManager Agent -- **Purpose:** Handles core configuration management, including import/export, editing, resetting, and applying configuration. -- **Key Methods:** - - `import_config` - - `export_config` - - `edit_config` - - `reset_config` - - `show_config` - - `apply` +### 3. Core Config Manager (`internal/corecfg`) +- **Purpose:** Handles core (mihomo) configuration management, including import/export, editing, resetting, and applying a subscription to produce the final generated config file. Also executes post-apply hook scripts. +- **Key Methods:** `ImportConfig`, `ExportConfig`, `EditConfig`, `ResetConfig`, `ShowConfig`, `Apply` -### 4. CoreManager Agent -- **Purpose:** Manages core components and system service operations (install, uninstall, start, stop, restart, reload, status, update). -- **Key Methods:** - - `install_service` - - `uninstall_service` - - `start_service` - - `stop_service` - - `restart_service` - - `reload_service` - - `get_service_status` - - `update` +### 4. Core Manager (`internal/core`) +- **Purpose:** Manages the mihomo core binary (download/update from GitHub releases) and delegates system service operations to the Service Manager. +- **Key Methods:** `Update`, `InstallService`, `UninstallService`, `StartService`, `StopService`, `RestartService`, `GetServiceStatus`, `ReloadService` -### 5. HookManager Agent -- **Purpose:** Manages hook scripts for automation and customization. -- **Key Methods:** - - `init` - - `list_hooks` - - `edit` - - `rm` +### 5. Service Manager (`internal/service`) +- **Purpose:** Cross-platform system service integration — systemd on Linux, launchd on macOS, and a native Windows Service (via `golang.org/x/sys/windows/svc`) on Windows. On macOS and Windows, this same `ss` binary is what the generated service definition invokes (hidden `service run-macos` / `service run-windows` commands), rather than a separate wrapper process. +- **Key Methods:** `Install`, `Uninstall`, `Start`, `Stop`, `Restart`, `Status` + +### 6. Hook Manager (`internal/hook`) +- **Purpose:** Manages hook scripts for automation and customization, initialized from templates embedded in the binary. +- **Key Methods:** `Init`, `ListHooks`, `Edit`, `Rm` + +### 7. Model (`internal/model`) +- **Purpose:** Persisted data structures — `Subscription`, `SubscriptionsData`, `Config` — plus a `Time` wrapper that tolerates both this implementation's own timestamps and legacy timezone-naive timestamps. + +### 8. YAML Util (`internal/yamlutil`) +- **Purpose:** A duplicate-mapping-key-tolerant YAML `Unmarshal`, since real-world mihomo/clash configs are frequently hand-merged and contain duplicate keys that the underlying YAML library rejects by default. + +### 9. Editor (`internal/editor`) +- **Purpose:** Opens a file in the user's configured (`$EDITOR`/`$VISUAL`) or best-guess system editor. --- ## Agent Interactions -- The CLI acts as the orchestrator, parsing user commands and delegating tasks to the appropriate agent. -- Agents interact via method calls and shared data models. +- The CLI (`internal/cli`) acts as the orchestrator, parsing user commands (via cobra) and delegating tasks to the appropriate agent. +- Agents interact via direct method calls and shared data models (`internal/model`); there is no message-passing or independent agent lifecycle — everything runs synchronously within a single CLI invocation. --- ## Extending Agents -- To add a new agent, implement a new manager class and update the CLI to route commands. +- To add a new agent, implement a new package under `internal/`, wire it into `internal/cli/root.go`'s `newManagers`, and add cobra subcommands under `internal/cli/`. - Document new agents and their responsibilities in this file. --- ## Last updated -May 26, 2026 +2026-07-19 diff --git a/GOLANG_MIGRATION_PLAN.md b/GOLANG_MIGRATION_PLAN.md new file mode 100644 index 0000000..b6528cc --- /dev/null +++ b/GOLANG_MIGRATION_PLAN.md @@ -0,0 +1,195 @@ +# Go Migration Plan — `ss` (Scientific Surfing) + +Status: **draft for review** — nothing has been executed yet. + +## 0. Decisions already made + +| Question | Decision | +|---|---| +| Where does the Go code live? | New top-level `go/` directory. The existing `ss/` Python package stays untouched until parity is confirmed and the user decides to retire it. | +| Go module path | `gitea.epss.net.cn/klesh/ss` (matches the existing git remote). | + +Everything else below is a recommendation open for feedback before I start writing code. + +## 1. Scope + +Convert the entire `ss` Python CLI (3,126 LOC across 12 modules) to Go, preserving: +- The exact CLI surface (`ss subscription ...`, `ss core ...`, `ss config ...`, `ss service ...`, `ss hook ...`) so existing muscle memory / scripts / README instructions keep working. +- The on-disk layout (`$SF_CONFIG_DIR` or `~/basicfiles/cli/ss`, `subscriptions.yaml`, `config.yaml`, `core-config.yaml`, `generated_config.yaml`, `subscriptions/`, `bin/`, `hooks/`) so users don't lose existing subscriptions/config on upgrade. +- Cross-platform behavior on Windows / Linux / macOS, including the mihomo binary downloader and the OS service integration (systemd / launchd / Windows Service). + +Output artifact: a single static Go binary (`ss` / `ss.exe`) per platform, replacing the `python -m ss` invocation. Config templates and hook templates are embedded into the binary via `go:embed` instead of being read relative to `__file__` — this removes the Python package's dependency on being installed alongside its templates. + +## 2. Proposed layout + +``` +go/ + cmd/ss/main.go # entry point, calls internal/cli.Execute() + internal/ + cli/ # cobra command tree (replaces cli.py) + root.go + subscription.go + core.go + config.go + service.go + hook.go + storage/ # storage.go (StorageManager) + storage.go + model/ # models.py -> Go structs + validation + subscription.go + config.go + subscription/ # subscription_manager.py + manager.go + uri_parse.go # _parse_ss / _parse_trojan / _parse_vless + uri_parse_test.go + convert.go # _convert_content + corecfg/ # corecfg_manager.py + manager.go + merge.go # deep_merge + hooks_exec.go # _execute_hook(s) + core/ # core_manager.py (binary download/update) + manager.go + platform.go # _get_platform_info / arch normalization + download.go + service/ # service_manager.py + *_service_wrapper.py + service.go # ServiceManager facade + ServiceConfig + service_linux.go # systemd unit file mgmt + service_darwin.go # launchd plist mgmt + DNS wrapper logic + service_windows.go # golang.org/x/sys/windows/svc based service + hook/ # hook_manager.py + manager.go + editor/ # utils.py + editor.go + templates/ + default-core-config.yaml + hooks/core_config_generated.py + hooks/core_config_generated.js + go.mod + go.sum +``` + +Each Go package below maps 1:1 to a Python module so review is easy to cross-check. + +## 3. Dependency mapping + +| Python | Go | Notes | +|---|---|---| +| `argparse` | [`spf13/cobra`](https://github.com/spf13/cobra) | Nested subcommands map naturally onto cobra's command tree. `--config-file` / `--output-file` / `--subscription` become persistent flags on the `config` command, same as today. | +| `pydantic` (`BaseModel`, validators) | plain structs + small hand-written `Validate()` methods | Go has no runtime schema validation library as ergonomic as pydantic for this scope; manual checks are ~10 lines total (`refresh_interval_hours >= 1`, `timeout_seconds >= 1`, service name non-empty/no-spaces, executable exists+executable). | +| `yaml` (PyYAML) | `gopkg.in/yaml.v3` | Straightforward; note Go's yaml.v3 preserves key order differently than PyYAML — acceptable since output is machine-generated config, not diffed by humans. | +| `requests` | stdlib `net/http` | Need explicit timeout, `io.Copy` for streaming downloads (mirrors `iter_content`). | +| `gzip`, `zipfile` | stdlib `compress/gzip`, `archive/zip` | Direct equivalents. | +| `subprocess` | stdlib `os/exec` | Direct equivalent; `capture_output=True, text=True` → `cmd.Output()` / `CombinedOutput()`. | +| `shutil.copy2` | `io.Copy` + `os.Chtimes`/`os.Chmod` helper | Small helper needed to match "copy2 preserves metadata" semantics (mtime); acceptable to drop exact mtime preservation since it isn't relied upon anywhere. | +| `platform.system()/machine()` | `runtime.GOOS` / `runtime.GOARCH` | **Simplification**: Go's GOARCH already gives us `amd64`, `386`, `arm64`, `arm` directly — the `arch_map` normalization table in `core_manager.py:58-67` and `service_manager` duplication becomes unnecessary. | +| `ctypes.windll` (admin check), `sc.exe` shell-out | `golang.org/x/sys/windows` (`ShellExecute` with `runas`, or check via `golang.org/x/sys/windows/registry`/token elevation APIs) | See §6 for the bigger Windows service redesign. | +| `win32serviceutil`, `servicemanager`, `win32event` (pywin32) | `golang.org/x/sys/windows/svc` | Replaces the entire `windows_service_wrapper.py` + pywin32 dependency. No separate Python wrapper process needed — the `ss` binary itself becomes the service host (see §6). | +| `launchctl` shell-out | stdlib `os/exec` | Same approach, just re-implemented; `macos_service_wrapper.py`'s DNS management logic (`networksetup`) ports directly to `os/exec` calls. | +| `hash(content)` (`models.py`/`subscription_manager.py:298`) | `hash/fnv` (`fnv.New64a`) or `crypto/sha256` | **Behavior note**: Python's builtin `hash()` is randomized per-process (`PYTHONHASHSEED`) for strings, so `content_hash` stored today is not stable across runs — it's really only useful as "did this change since last run in the same process," which it can't even do since it's reloaded from YAML each run. This looks like a latent bug rather than intentional behavior. Recommend switching to a real stable hash (`fnv64a` is fine, doesn't need to be cryptographic) in the Go version. **Flagging for confirmation before implementing.** | + +## 4. Per-module conversion notes + +### `storage.py` → `internal/storage` +- `StorageManager` struct holding `ConfigDir`, `ConfigFile`, `SubscriptionsFile string`/`Path`. +- `SF_CONFIG_DIR` env var still takes precedence; default becomes `filepath.Join(os.UserHomeDir(), "basicfiles", "cli", "ss")`. +- `LoadSubscriptions`/`SaveSubscriptions`/`LoadConfig`/`SaveConfig`/`GetStorageInfo` are direct ports. `SaveSubscriptions` no longer needs the `model_dump(mode='json')` vs `.dict()` branch — one struct, one marshal path. + +### `models.py` → `internal/model` +- `SubscriptionStatus` becomes a typed string const (`Active`, `Inactive`). +- `Subscription` struct with yaml tags; `LastRefresh *time.Time`, `FileSize *int64`, etc. as pointers to mirror `Optional[...]`. +- `SubscriptionsData` keeps `AddSubscription/RemoveSubscription/RenameSubscription/SetSubscriptionURL/SetActive/GetActiveSubscription` as methods — logic is a direct line-by-line port of `models.py:65-116`. +- `Config` struct with `Validate() error` covering the two `@validator` checks. + +### `subscription_manager.py` → `internal/subscription` +- Largest single porting risk area because of the manual URI parsers (`_parse_ss`, `_parse_trojan`, `_parse_vless`, ~180 lines at `subscription_manager.py:40-199`). Plan: port byte-for-byte logic first, then add table-driven Go tests using real-world `ss://`/`trojan://`/`vless://` sample URIs (including base64-whole-body SS links and SS links with base64 userinfo) to lock in identical output before doing anything else with this package. +- `_convert_content` (`subscription_manager.py:201-244`): same fallback chain (already-YAML → whole-body base64 → line-by-line) ports directly; Go's `encoding/base64.URLEncoding`/`StdEncoding` with manual padding matches the manual padding logic in the Python. +- `refresh_subscription`: `requests.get` → `http.Get` with explicit `context.WithTimeout`; backup-by-birthtime logic (`subscription_manager.py:270-285`) — Go's `os.Stat` doesn't expose creation time portably. Plan: use `syscall`-based creation time on Windows/macOS where available and fall back to mtime on Linux, matching the Python's own `st_birthtime`→`st_ctime` fallback intent. + +### `corecfg_manager.py` → `internal/corecfg` +- Import/export/edit/reset/show/apply, plus `_execute_hook(s)` and `deep_merge` (also duplicated in `core_manager.py:504-512` — the Go version should have exactly one `deep_merge` in a shared place, e.g. `internal/corecfg/merge.go`, imported wherever needed. This is a small dedup of the existing Python duplication, not a redesign). +- Interpreter dispatch for hooks (`.py`→same interpreter that's running, `.js`→`node`, `.nu`→`nu`, else exec directly + chmod on Unix) ports directly to `os/exec` with the same suffix switch. +- Default config template and the two hook template files move from `ss/templates/**` into `go/templates/**` and get embedded with `//go:embed`, exposed as `[]byte` constants — replacing `Path(__file__).parent / "templates"`. + +### `core_manager.py` → `internal/core` +- `update()` (GitHub release fetch → asset match → stream download → verify size → gzip/zip extract → chmod → backup+swap) ports directly using stdlib only (`net/http`, `compress/gzip`, `archive/zip`, `os`). +- Platform/arch mapping simplifies as noted in §3 — `runtime.GOOS`/`GOARCH` replace the two duplicated `arch_map` dicts (`core_manager.py:58-67` and `246-257`). +- `reload_service` (PUT to mihomo's external-controller API) ports directly with `net/http`. + +### `service_manager.py` + `windows_service_wrapper.py` + `macos_service_wrapper.py` → `internal/service` +This is the module with the biggest *architectural* opportunity, not just a line-for-line port — see §6. + +### `hook_manager.py` → `internal/hook` +- Direct port: `init` copies embedded template hook files into `$CONFIG_DIR/hooks` (skip if exists), `list`/`edit`/`rm` operate on that directory. `edit` reuses `internal/editor`. + +### `utils.py` → `internal/editor` +- `get_editor_command`/`open_file_in_editor`: `EDITOR`/`VISUAL` env lookup, fallback list `code/subl/atom/vim/nano/notepad` via `exec.LookPath`, then `exec.Command(editor, path).Run()` with stdio attached to the parent process (so the editor is interactive, matching Python's `subprocess.run` default of inherited stdio). + +### `cli.py` → `internal/cli` +- One cobra command per subparser; `handle_*_command` functions become cobra `RunE` closures with the same branching. Root `PersistentPreRunE` wires up `StorageManager` → `SubscriptionManager` → `CoreConfigManager` → `CoreManager` → `HookManager`, mirroring `main()` in `cli.py:280-290`. +- Error/exit handling: Python's top-level `try/except` in `main()` (`cli.py:305-310`) that prints `❌ Error: {e}` and re-raises maps to cobra's `SilenceUsage` + a top-level error print in `main.go`, preserving the same exit code 1 behavior on `KeyboardInterrupt`/errors. + +## 5. Data model / validation differences + +Pydantic gave the Python version free JSON-schema-style validation and `.model_dump(mode='json')` serialization. In Go: +- Validation becomes explicit `Validate() error` methods called at the same call sites where pydantic would have raised (config import, service install). +- YAML (de)serialization uses struct tags (`yaml:"name"`) instead of pydantic's field aliasing; `Optional[X] = Field(default=None)` becomes `*X` with `omitempty`. +- `SubscriptionStatus` enum becomes a Go string type with two constants — no runtime enum validation needed since it's fully controlled internally. + +## 6. Service management — recommended redesign for Windows + +Today, Windows service support requires **pywin32** plus a second standalone script (`windows_service_wrapper.py`) that `sc.exe` points at via a JSON config file dropped in `%USERPROFILE%`. That split exists only because Python needs the pywin32 `ServiceFramework` glue. + +Go's `golang.org/x/sys/windows/svc` package lets a single binary *be* the service host directly — no wrapper process, no JSON hand-off file. Recommended design: + +- `ss` gains a hidden command, e.g. `ss service run --name mihomo --bin --args "..."` (not shown in `--help`), which is what `sc create`'s `binPath=` points at. +- When invoked in that mode, `internal/service/service_windows.go` calls `svc.Run(name, handler)`, where `handler.Execute` starts the mihomo subprocess (`os/exec`), forwards stdout/stderr to a log file (mirrors `windows_service_wrapper.py`'s `log_stream`), and on `svc.Stop`/`svc.Shutdown` terminates the child process with the same terminate→wait(30s)→kill fallback as today (`windows_service_wrapper.py:125-155`). +- `install_service` on Windows becomes: `sc create binPath= "\"\" service run --name ... --bin ... --args ..." start= auto`, no more per-service JSON config file, no more separate `pywin32` runtime dependency for end users. +- Admin-elevation check/error messaging (`_is_admin`, `_format_error_message`, `_run_as_admin` in `service_manager.py:83-134`) ports over using `golang.org/x/sys/windows` token checks, same friendly error text. + +Linux (`systemd` unit file generation, `service_manager.py:277-372`) and macOS (`launchd` plist generation + DNS set/restore wrapper, `service_manager.py:374-487` and all of `macos_service_wrapper.py`) port essentially line-for-line into `service_linux.go` / `service_darwin.go`, since those already shell out to `systemctl`/`launchctl` rather than needing a language-specific service framework. The macOS DNS wrapper logic becomes a `ss service run` mode analogous to the Windows one, invoked from the generated launchd plist instead of `macos_service_wrapper.py`. + +**This changes the on-disk service integration shape (no more `windows_service_wrapper.py`, no more per-service JSON file, no more pywin32 requirement).** Flagging explicitly for confirmation since it's the one place where "convert" becomes "redesign" — happy to do a pure literal port instead (keep shelling out to a separate wrapper binary/script) if you'd rather minimize behavioral change during migration. + +## 7. CLI command tree (unchanged surface) + +``` +ss subscription {add,refresh,rm,rename,set-url,activate,list,storage} +ss core update [--version] [--force] +ss core service {install,uninstall,start,stop,restart,reload,status} [--name] +ss config {import,export,edit,reset,show,apply} [--config-file] [--output-file] [--subscription] +ss hook {init,list,edit,rm} +``` + +Note: the README documents `ss core service install ...` and `ss core update`, but `cli.py` actually registers `service` as its own **top-level** subcommand (`ss service install`, not `ss core service install`) — see `cli.py:96` vs README line 107. The Go version should match the actual `cli.py` behavior (top-level `service`) and I'll flag the README discrepancy as a doc fix, not carry the inconsistency forward silently. + +## 8. Testing strategy + +- `internal/subscription`: table-driven tests for `_parse_ss/_parse_trojan/_parse_vless` and `_convert_content` — highest-value tests since this logic is fragile (nested base64/padding fallbacks) and has zero existing tests to diff against. +- `internal/model`: unit tests for `SubscriptionsData` methods (add/remove/rename/set-active/set-url) and `Config.Validate()`. +- `internal/corecfg`: test `deep_merge` (dict+dict merge, list+list extend, scalar overwrite — `corecfg_manager.py:356-364`) and `apply()`'s essential-defaults/DNS-defaults injection. +- `internal/core`: platform/arch → expected binary filename mapping table, using `runtime.GOOS`/`GOARCH` overridden via a small interface for testability. +- `internal/service`: no existing tests today (shells out to real OS service managers) — plan is to keep these thin and rely on manual smoke-testing per OS rather than heavy mocking, matching current Python test coverage (none) so this isn't a regression. +- No Python tests exist today (`pyproject.toml` points at `testpaths = ["tests"]` but no `tests/` directory exists in the repo) — so there's no existing suite to port; all Go tests above are net-new coverage. + +## 9. Build & release + +- `go build ./cmd/ss` per `GOOS`/`GOARCH` pair matching the existing mihomo binary matrix (`windows/linux/darwin` × `amd64/386/arm64/arm`) so `ss`'s own release matrix lines up with what `ss core update` downloads. +- Recommend `goreleaser` for cross-compiling + GitHub/Gitea release artifacts, given the target host is already Gitea. +- `go:embed` removes the need to ship `templates/` alongside the binary — single-file distribution per platform. + +## 10. Suggested implementation order + +1. `internal/model` + `internal/storage` (no external deps, foundational, easy to test in isolation). +2. `internal/subscription` URI parsers + `_convert_content`, with tests, **before** wiring up HTTP refresh — this is the highest-risk logic to get subtly wrong. +3. `internal/subscription` manager (refresh/add/rm/rename/activate/list/storage) + `internal/editor`. +4. `internal/corecfg` (import/export/edit/reset/show/apply + hook execution) + `internal/hook`. +5. `internal/core` (binary update/download + reload_service). +6. `internal/cli` wiring everything above behind cobra, matching current command surface — gives a usable Linux/macOS-only build early. +7. `internal/service` — Linux (systemd) and macOS (launchd) first since they're pure shell-out; Windows (`golang.org/x/sys/windows/svc`) last since it's the one genuinely new subsystem (§6). +8. Cross-platform smoke test: install/refresh/activate/apply/service-install on each OS side-by-side with the Python version, diffing generated `generated_config.yaml` output byte-for-byte where possible. + +## 11. Open questions before I start + +1. **Windows service redesign (§6)**: single self-hosting `ss.exe` via `golang.org/x/sys/windows/svc` (recommended, drops the pywin32 requirement) vs. literal port keeping a separate wrapper process? +2. **`content_hash` field (§3 table)**: switch to a stable `fnv64a` hash, or keep porting `hash()`'s (non-portable, non-stable) behavior as-is for strict parity? +3. Should the Go binary keep reading/writing the **same** `subscriptions.yaml`/`config.yaml` files the Python version uses (so a user can switch between the two during transition), or is a clean cutover (stop using the Python version once the Go build works) acceptable? This affects how strictly the YAML field layout must match. diff --git a/README.md b/README.md index 9929945..40bc118 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,138 @@ # Scientific Surfing -A Python package for surfing internet scientifically. +A CLI for surfing the internet scientifically, written in Go. ## Features -- **Clash RSS Subscription Support**: Download and transform clash rss subscription +- **Clash RSS Subscription Support**: Download and transform clash rss subscriptions - **Hook System**: Customizable scripts for extending functionality - **Core Configuration Management**: Import, export, and manage core configurations -- **Binary Management**: Automatic updates for core components +- **Binary Management**: Automatic updates for core (mihomo) components +- **Service Management**: Install/start/stop/reload mihomo as a system service on Linux (systemd), macOS (launchd), and Windows (native Service Control Manager) ## Installation -### 1. Clone into local +### 1. Clone and build + ```bash git clone ssh://git@gitea.epss.net.cn:2223/klesh/ss.git cd ss -python -m venv .venv -./.venv/Scripts/activate -pip install -r requirements.txt +go build -o ss ./cmd/ss ``` -### 2. Add the root directory to system PATH +This produces a single `ss` (or `ss.exe` on Windows) binary with no runtime dependencies — templates and hook scripts are embedded in the binary. + +### 2. Add the binary to your system PATH ## Quick Start ### Subscription Management ```bash # add a subscription -python -m ss subscription add +ss subscription add # refresh a subscription (with optional backup) -python -m ss subscription refresh [--backup] +ss subscription refresh [--backup] # delete a subscription -python -m ss subscription rm +ss subscription rm # rename a subscription -python -m ss subscription rename +ss subscription rename # update subscription URL -python -m ss subscription set-url +ss subscription set-url + +# show the URL for a subscription +ss subscription get-url # activate a subscription -python -m ss subscription activate +ss subscription activate # list all subscriptions -python -m ss subscription list +ss subscription list # show storage information -python -m ss subscription storage +ss subscription storage ``` ### Hook Management ```bash # initialize hooks directory with template scripts -python -m ss hook init +ss hook init # show hooks directory location and list all scripts -python -m ss hook list +ss hook list # edit a hook script with system editor -python -m ss hook edit +ss hook edit # remove a hook script -python -m ss hook rm +ss hook rm ``` ### Core Configuration Management ```bash # import configuration from file -python -m ss core config import [--config-file ] +ss config import [--config-file ] # export configuration to file -python -m ss core config export [--config-file ] +ss config export [--config-file ] # edit configuration with system editor -python -m ss core config edit [--config-file ] +ss config edit [--config-file ] # reset configuration to default values -python -m ss core config reset [--config-file ] +ss config reset [--config-file ] # show current configuration -python -m ss core config show [--config-file ] +ss config show [--config-file ] # apply subscription to generate final config (with advanced options) -python -m ss core config apply \ +ss config apply \ [--config-file ] \ [--output-file ] \ [--subscription ] ``` -**Options:** +**Options** (available on every `ss config` subcommand): - `--config-file `: Use a custom config file instead of the default. - `--output-file `: Specify the output path for the generated config file. - `--subscription `: Use a specific subscription (not just the active one) for config generation. ### Core Management ```bash -# update scientific-surfing core components -python -m ss core update [--version ] [--force] +# update mihomo core binary +ss core update [--version ] [--force] ``` ### Service Management +```bash +ss service install [--name ] [--description ] +ss service uninstall [--name ] +ss service start [--name ] +ss service stop [--name ] +ss service restart [--name ] +ss service reload [--name ] # reload config via mihomo's API, no restart +ss service status [--name ] +``` -Linux / macOS -```nushell -sudo env SF_CONFIG_DIR=$HOME/basicfiles/cli/ss python3 -m ss core service install +Linux / macOS (installing the service needs root): +```bash +sudo env SF_CONFIG_DIR=$HOME/basicfiles/cli/ss ss service install +``` + +Windows (run from an elevated/Administrator shell): +```powershell +$env:SF_CONFIG_DIR = "$HOME\basicfiles\cli\ss" +ss.exe service install ``` ## Development -This project uses Poetry for dependency management: - ```bash -poetry install -poetry run pytest +go build ./... +go vet ./... +go test ./... ``` ## License diff --git a/cmd/ss/main.go b/cmd/ss/main.go new file mode 100644 index 0000000..cd49039 --- /dev/null +++ b/cmd/ss/main.go @@ -0,0 +1,8 @@ +// Command ss is the CLI for scientific-surfing. +package main + +import "gitea.epss.net.cn/klesh/ss/internal/cli" + +func main() { + cli.Execute() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3e008a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module gitea.epss.net.cn/klesh/ss + +go 1.26.5 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.47.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..684f058 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.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/config.go b/internal/cli/config.go new file mode 100644 index 0000000..5f83fa3 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,102 @@ +package cli + +import "github.com/spf13/cobra" + +func newConfigCmd() *cobra.Command { + var configFile, outputFile, subscriptionName string + + cmd := &cobra.Command{ + Use: "config", + Short: "Manage core configuration", + } + cmd.PersistentFlags().StringVar(&configFile, "config-file", "", "Path to the user config YAML file (default: core-config.yaml in config dir)") + cmd.PersistentFlags().StringVar(&outputFile, "output-file", "", "Path to the generated config file (default: generated_config.yaml in config dir)") + cmd.PersistentFlags().StringVar(&subscriptionName, "subscription", "", "Name of the subscription to use for config generation (default: active subscription)") + + build := func() (*managers, error) { + return newManagers(configFile, outputFile, subscriptionName) + } + + cmd.AddCommand( + &cobra.Command{ + Use: "import ", + Short: "Import configuration from file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := build() + if err != nil { + return err + } + m.CoreConfig.ImportConfig(args[0]) + return nil + }, + }, + &cobra.Command{ + Use: "export ", + Short: "Export configuration to file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := build() + if err != nil { + return err + } + m.CoreConfig.ExportConfig(args[0]) + return nil + }, + }, + &cobra.Command{ + Use: "edit", + Short: "Edit configuration with system editor", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := build() + if err != nil { + return err + } + m.CoreConfig.EditConfig() + return nil + }, + }, + &cobra.Command{ + Use: "reset", + Short: "Reset configuration to default values", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := build() + if err != nil { + return err + } + m.CoreConfig.ResetConfig() + return nil + }, + }, + &cobra.Command{ + Use: "show", + Short: "Show current configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := build() + if err != nil { + return err + } + m.CoreConfig.ShowConfig() + return nil + }, + }, + &cobra.Command{ + Use: "apply", + Short: "Apply active subscription to generate final config", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := build() + if err != nil { + return err + } + m.CoreConfig.Apply() + return nil + }, + }, + ) + + return cmd +} diff --git a/internal/cli/core.go b/internal/cli/core.go new file mode 100644 index 0000000..eb0ce92 --- /dev/null +++ b/internal/cli/core.go @@ -0,0 +1,33 @@ +package cli + +import "github.com/spf13/cobra" + +func newCoreCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "core", + Short: "Manage scientific-surfing core components", + } + cmd.AddCommand(newCoreUpdateCmd()) + return cmd +} + +func newCoreUpdateCmd() *cobra.Command { + var version string + var force bool + c := &cobra.Command{ + Use: "update", + Short: "Update scientific-surfing core components", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := newManagers("", "", "") + if err != nil { + return err + } + m.Core.Update(version, force) + return nil + }, + } + c.Flags().StringVar(&version, "version", "", "Specific version to download (e.g., v1.18.5). If not specified, downloads latest") + c.Flags().BoolVar(&force, "force", false, "Force update even if binary already exists") + return c +} diff --git a/internal/cli/hook.go b/internal/cli/hook.go new file mode 100644 index 0000000..5ced7e2 --- /dev/null +++ b/internal/cli/hook.go @@ -0,0 +1,67 @@ +package cli + +import "github.com/spf13/cobra" + +func newHookCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hook", + Short: "Manage hook scripts", + } + + cmd.AddCommand( + &cobra.Command{ + Use: "init", + Short: "Initialize hooks directory with template scripts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := newManagers("", "", "") + if err != nil { + return err + } + m.Hook.Init() + return nil + }, + }, + &cobra.Command{ + Use: "list", + Short: "Show hooks directory location and list scripts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + m, err := newManagers("", "", "") + if err != nil { + return err + } + m.Hook.ListHooks() + return nil + }, + }, + &cobra.Command{ + Use: "edit