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

18 KiB
Raw Blame History

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 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=Truecmd.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.pyinternal/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.pyinternal/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.pyinternal/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.gethttp.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_birthtimest_ctime fallback intent.

corecfg_manager.pyinternal/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, .jsnode, .nunu, 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.pyinternal/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.pyinternal/service

This is the module with the biggest architectural opportunity, not just a line-for-line port — see §6.

hook_manager.pyinternal/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.pyinternal/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.pyinternal/cli

  • One cobra command per subparser; handle_*_command functions become cobra RunE closures with the same branching. Root PersistentPreRunE wires up StorageManagerSubscriptionManagerCoreConfigManagerCoreManagerHookManager, 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.

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.