Files
ss/GOLANG_MIGRATION_PLAN.md
2026-07-19 20:01:38 +08:00

196 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <path> --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 <name> binPath= "\"<ss.exe>\" 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.