# 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.