196 lines
18 KiB
Markdown
196 lines
18 KiB
Markdown
# 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.
|