18 KiB
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_DIRor~/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=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
StorageManagerstruct holdingConfigDir,ConfigFile,SubscriptionsFile string/Path.SF_CONFIG_DIRenv var still takes precedence; default becomesfilepath.Join(os.UserHomeDir(), "basicfiles", "cli", "ss").LoadSubscriptions/SaveSubscriptions/LoadConfig/SaveConfig/GetStorageInfoare direct ports.SaveSubscriptionsno longer needs themodel_dump(mode='json')vs.dict()branch — one struct, one marshal path.
models.py → internal/model
SubscriptionStatusbecomes a typed string const (Active,Inactive).Subscriptionstruct with yaml tags;LastRefresh *time.Time,FileSize *int64, etc. as pointers to mirrorOptional[...].SubscriptionsDatakeepsAddSubscription/RemoveSubscription/RenameSubscription/SetSubscriptionURL/SetActive/GetActiveSubscriptionas methods — logic is a direct line-by-line port ofmodels.py:65-116.Configstruct withValidate() errorcovering the two@validatorchecks.
subscription_manager.py → internal/subscription
- Largest single porting risk area because of the manual URI parsers (
_parse_ss,_parse_trojan,_parse_vless, ~180 lines atsubscription_manager.py:40-199). Plan: port byte-for-byte logic first, then add table-driven Go tests using real-worldss:///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'sencoding/base64.URLEncoding/StdEncodingwith manual padding matches the manual padding logic in the Python.refresh_subscription:requests.get→http.Getwith explicitcontext.WithTimeout; backup-by-birthtime logic (subscription_manager.py:270-285) — Go'sos.Statdoesn't expose creation time portably. Plan: usesyscall-based creation time on Windows/macOS where available and fall back to mtime on Linux, matching the Python's ownst_birthtime→st_ctimefallback intent.
corecfg_manager.py → internal/corecfg
- Import/export/edit/reset/show/apply, plus
_execute_hook(s)anddeep_merge(also duplicated incore_manager.py:504-512— the Go version should have exactly onedeep_mergein 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 toos/execwith the same suffix switch. - Default config template and the two hook template files move from
ss/templates/**intogo/templates/**and get embedded with//go:embed, exposed as[]byteconstants — replacingPath(__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/GOARCHreplace the two duplicatedarch_mapdicts (core_manager.py:58-67and246-257). reload_service(PUT to mihomo's external-controller API) ports directly withnet/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:
initcopies embedded template hook files into$CONFIG_DIR/hooks(skip if exists),list/edit/rmoperate on that directory.editreusesinternal/editor.
utils.py → internal/editor
get_editor_command/open_file_in_editor:EDITOR/VISUALenv lookup, fallback listcode/subl/atom/vim/nano/notepadviaexec.LookPath, thenexec.Command(editor, path).Run()with stdio attached to the parent process (so the editor is interactive, matching Python'ssubprocess.rundefault of inherited stdio).
cli.py → internal/cli
- One cobra command per subparser;
handle_*_commandfunctions become cobraRunEclosures with the same branching. RootPersistentPreRunEwires upStorageManager→SubscriptionManager→CoreConfigManager→CoreManager→HookManager, mirroringmain()incli.py:280-290. - Error/exit handling: Python's top-level
try/exceptinmain()(cli.py:305-310) that prints❌ Error: {e}and re-raises maps to cobra'sSilenceUsage+ a top-level error print inmain.go, preserving the same exit code 1 behavior onKeyboardInterrupt/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() errormethods 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*Xwithomitempty. SubscriptionStatusenum 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:
ssgains a hidden command, e.g.ss service run --name mihomo --bin <path> --args "..."(not shown in--help), which is whatsc create'sbinPath=points at.- When invoked in that mode,
internal/service/service_windows.gocallssvc.Run(name, handler), wherehandler.Executestarts the mihomo subprocess (os/exec), forwards stdout/stderr to a log file (mirrorswindows_service_wrapper.py'slog_stream), and onsvc.Stop/svc.Shutdownterminates the child process with the same terminate→wait(30s)→kill fallback as today (windows_service_wrapper.py:125-155). install_serviceon Windows becomes:sc create <name> binPath= "\"<ss.exe>\" service run --name ... --bin ... --args ..." start= auto, no more per-service JSON config file, no more separatepywin32runtime dependency for end users.- Admin-elevation check/error messaging (
_is_admin,_format_error_message,_run_as_admininservice_manager.py:83-134) ports over usinggolang.org/x/sys/windowstoken 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_vlessand_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 forSubscriptionsDatamethods (add/remove/rename/set-active/set-url) andConfig.Validate().internal/corecfg: testdeep_merge(dict+dict merge, list+list extend, scalar overwrite —corecfg_manager.py:356-364) andapply()'s essential-defaults/DNS-defaults injection.internal/core: platform/arch → expected binary filename mapping table, usingruntime.GOOS/GOARCHoverridden 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.tomlpoints attestpaths = ["tests"]but notests/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/ssperGOOS/GOARCHpair matching the existing mihomo binary matrix (windows/linux/darwin×amd64/386/arm64/arm) soss's own release matrix lines up with whatss core updatedownloads.- Recommend
goreleaserfor cross-compiling + GitHub/Gitea release artifacts, given the target host is already Gitea. go:embedremoves the need to shiptemplates/alongside the binary — single-file distribution per platform.
10. Suggested implementation order
internal/model+internal/storage(no external deps, foundational, easy to test in isolation).internal/subscriptionURI parsers +_convert_content, with tests, before wiring up HTTP refresh — this is the highest-risk logic to get subtly wrong.internal/subscriptionmanager (refresh/add/rm/rename/activate/list/storage) +internal/editor.internal/corecfg(import/export/edit/reset/show/apply + hook execution) +internal/hook.internal/core(binary update/download + reload_service).internal/cliwiring everything above behind cobra, matching current command surface — gives a usable Linux/macOS-only build early.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).- Cross-platform smoke test: install/refresh/activate/apply/service-install on each OS side-by-side with the Python version, diffing generated
generated_config.yamloutput byte-for-byte where possible.
11. Open questions before I start
- Windows service redesign (§6): single self-hosting
ss.exeviagolang.org/x/sys/windows/svc(recommended, drops the pywin32 requirement) vs. literal port keeping a separate wrapper process? content_hashfield (§3 table): switch to a stablefnv64ahash, or keep portinghash()'s (non-portable, non-stable) behavior as-is for strict parity?- Should the Go binary keep reading/writing the same
subscriptions.yaml/config.yamlfiles 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.