feat: migrate to golang

This commit is contained in:
2026-07-19 20:01:38 +08:00
parent 302d4e6bb5
commit a2630df9e0
69 changed files with 4750 additions and 3369 deletions

View File

@ -0,0 +1,11 @@
//go:build !windows
package service
import "os"
// isExecutable checks the Unix executable bit, matching Python's
// os.access(v, os.X_OK) check in ServiceConfig.validate_executable_path.
func isExecutable(info os.FileInfo) bool {
return info.Mode()&0o111 != 0
}

View File

@ -0,0 +1,13 @@
//go:build windows
package service
import "os"
// isExecutable is a no-op on Windows, which has no POSIX executable bit —
// executability there is determined by file extension/PATHEXT, not mode
// bits, matching the practical effect of Python's os.access(..., os.X_OK)
// which is nearly always true for regular files on Windows.
func isExecutable(info os.FileInfo) bool {
return !info.IsDir()
}

122
internal/service/service.go Normal file
View File

@ -0,0 +1,122 @@
// Package service provides a cross-platform interface for installing,
// starting, stopping, and uninstalling mihomo as a system service —
// systemd on Linux, launchd on macOS, and a native Windows Service (via
// golang.org/x/sys/windows/svc) on Windows.
package service
import (
"fmt"
"os"
"strings"
)
// Config describes a service to install. Mirrors service_manager.py's
// pydantic ServiceConfig model.
type Config struct {
Name string
ExecutablePath string
Description string
Args string
}
func validateConfig(cfg Config) error {
name := strings.TrimSpace(cfg.Name)
if name == "" {
return fmt.Errorf("service name cannot be empty")
}
if strings.Contains(name, " ") {
return fmt.Errorf("service name cannot contain spaces")
}
info, err := os.Stat(cfg.ExecutablePath)
if err != nil {
return fmt.Errorf("executable path does not exist: %s", cfg.ExecutablePath)
}
if info.IsDir() {
return fmt.Errorf("path is not a file: %s", cfg.ExecutablePath)
}
if !isExecutable(info) {
return fmt.Errorf("file is not executable: %s", cfg.ExecutablePath)
}
return nil
}
// platformManager is implemented once per OS (service_linux.go,
// service_darwin.go, service_windows.go).
type platformManager interface {
Install(cfg Config) error
Uninstall(name string) error
Start(name string) error
Stop(name string) error
Restart(name string) error
}
// Manager is the main service manager that delegates to a platform-specific
// implementation, mirroring service_manager.py's ServiceManager class.
type Manager struct {
configDir string
impl platformManager
}
// New creates a Manager using the appropriate platform implementation.
func New(configDir string) (*Manager, error) {
impl, err := newPlatformManager(configDir)
if err != nil {
return nil, err
}
return &Manager{configDir: configDir, impl: impl}, nil
}
// Install installs a service with the given name and executable path.
func (m *Manager) Install(name, executablePath, description, args string) error {
cfg := Config{
Name: name,
ExecutablePath: executablePath,
Description: description,
Args: args,
}
if err := validateConfig(cfg); err != nil {
return err
}
return m.impl.Install(cfg)
}
// Uninstall uninstalls a service by name.
func (m *Manager) Uninstall(name string) error {
if err := requireName(name); err != nil {
return err
}
return m.impl.Uninstall(strings.TrimSpace(name))
}
// Start starts a service by name.
func (m *Manager) Start(name string) error {
if err := requireName(name); err != nil {
return err
}
return m.impl.Start(strings.TrimSpace(name))
}
// Stop stops a service by name.
func (m *Manager) Stop(name string) error {
if err := requireName(name); err != nil {
return err
}
return m.impl.Stop(strings.TrimSpace(name))
}
// Restart restarts a service by name.
func (m *Manager) Restart(name string) error {
if err := requireName(name); err != nil {
return err
}
return m.impl.Restart(strings.TrimSpace(name))
}
func requireName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("service name cannot be empty")
}
return nil
}

View File

@ -0,0 +1,147 @@
//go:build darwin
package service
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func newPlatformManager(configDir string) (platformManager, error) {
return &macosServiceManager{configDir: configDir}, nil
}
// macosServiceManager manages services via launchd. Direct port of
// MacOSServiceManager in service_manager.py. Unlike the Python version
// (which shells out to a separate macos_service_wrapper.py for DNS
// handling), the generated plist invokes this same ss binary in
// "service run-macos" mode (see wrapper_darwin.go) — no second script file
// is needed.
type macosServiceManager struct {
configDir string
}
func (d *macosServiceManager) launchdPath(name string) string {
return filepath.Join("/Library/LaunchDaemons", "com."+name+".plist")
}
func (d *macosServiceManager) createLaunchdPlist(cfg Config) error {
selfPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine ss executable path: %w", err)
}
workingDir := filepath.Dir(cfg.ExecutablePath)
programArgs := []string{
selfPath,
"service", "run-macos",
cfg.ExecutablePath,
workingDir,
}
if cfg.Args != "" {
extra, err := shlexSplit(cfg.Args)
if err != nil {
return fmt.Errorf("failed to parse service args: %w", err)
}
programArgs = append(programArgs, extra...)
}
programArgsXML := ""
for _, arg := range programArgs {
programArgsXML += fmt.Sprintf(" <string>%s</string>\n", xmlEscape(arg))
}
plistContent := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.%s</string>
<key>ProgramArguments</key>
<array>
%s </array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/%s.log</string>
<key>StandardErrorPath</key>
<string>/var/log/%s.error.log</string>
</dict>
</plist>
`, cfg.Name, programArgsXML, cfg.Name, cfg.Name)
plistPath := d.launchdPath(cfg.Name)
if err := os.WriteFile(plistPath, []byte(plistContent), 0o644); err != nil {
return fmt.Errorf("failed to create launchd plist: %w", err)
}
return nil
}
var xmlReplacer = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
func xmlEscape(s string) string {
return xmlReplacer.Replace(s)
}
func (d *macosServiceManager) Install(cfg Config) error {
if err := d.createLaunchdPlist(cfg); err != nil {
return err
}
plistPath := d.launchdPath(cfg.Name)
if out, err := exec.Command("launchctl", "load", plistPath).CombinedOutput(); err != nil {
return fmt.Errorf("failed to install service: %s", out)
}
if out, err := exec.Command("launchctl", "start", "com."+cfg.Name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to install service: %s", out)
}
return nil
}
func (d *macosServiceManager) Uninstall(name string) error {
exec.Command("launchctl", "stop", "com."+name).Run()
plistPath := d.launchdPath(name)
if _, err := os.Stat(plistPath); err == nil {
if out, err := exec.Command("launchctl", "unload", plistPath).CombinedOutput(); err != nil {
return fmt.Errorf("failed to uninstall service: %s", out)
}
os.Remove(plistPath)
}
return nil
}
func (d *macosServiceManager) Start(name string) error {
if out, err := exec.Command("launchctl", "start", "com."+name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to start service: %s", out)
}
return nil
}
func (d *macosServiceManager) Stop(name string) error {
if out, err := exec.Command("launchctl", "stop", "com."+name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to stop service: %s", out)
}
return nil
}
func (d *macosServiceManager) Restart(name string) error {
exec.Command("launchctl", "stop", "com."+name).Run()
if out, err := exec.Command("launchctl", "start", "com."+name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to restart service: %s", out)
}
return nil
}

View File

@ -0,0 +1,113 @@
//go:build linux
package service
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func newPlatformManager(configDir string) (platformManager, error) {
return &linuxServiceManager{configDir: configDir}, nil
}
// linuxServiceManager manages services via systemd. Direct port of
// LinuxServiceManager in service_manager.py.
type linuxServiceManager struct {
configDir string
}
func (l *linuxServiceManager) serviceFilePath(name string) string {
return filepath.Join("/etc/systemd/system", name+".service")
}
func (l *linuxServiceManager) createServiceFile(cfg Config) error {
execStart := cfg.ExecutablePath
if cfg.Args != "" {
execStart += " " + cfg.Args
}
description := cfg.Description
if description == "" {
description = cfg.Name
}
content := fmt.Sprintf(`[Unit]
Description=%s
After=network.target
[Service]
Type=simple
ExecStart=%s
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
`, description, execStart)
servicePath := l.serviceFilePath(cfg.Name)
if err := os.WriteFile(servicePath, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to create service file: %w", err)
}
return nil
}
func (l *linuxServiceManager) Install(cfg Config) error {
if err := l.createServiceFile(cfg); err != nil {
return err
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("failed to install service: %s", out)
}
if out, err := exec.Command("systemctl", "enable", cfg.Name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to install service: %s", out)
}
if out, err := exec.Command("systemctl", "start", cfg.Name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to install service: %s", out)
}
return nil
}
func (l *linuxServiceManager) Uninstall(name string) error {
exec.Command("systemctl", "stop", name).Run()
if out, err := exec.Command("systemctl", "disable", name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to uninstall service: %s", out)
}
servicePath := l.serviceFilePath(name)
if _, err := os.Stat(servicePath); err == nil {
os.Remove(servicePath)
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("failed to uninstall service: %s", out)
}
return nil
}
func (l *linuxServiceManager) Start(name string) error {
if out, err := exec.Command("systemctl", "start", name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to start service: %s", out)
}
return nil
}
func (l *linuxServiceManager) Stop(name string) error {
if out, err := exec.Command("systemctl", "stop", name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to stop service: %s", out)
}
return nil
}
func (l *linuxServiceManager) Restart(name string) error {
if out, err := exec.Command("systemctl", "restart", name).CombinedOutput(); err != nil {
return fmt.Errorf("failed to restart service: %s", out)
}
return nil
}

View File

@ -0,0 +1,142 @@
//go:build windows
package service
import (
"fmt"
"os"
"os/exec"
"strings"
"golang.org/x/sys/windows"
)
func newPlatformManager(configDir string) (platformManager, error) {
return &windowsServiceManager{configDir: configDir}, nil
}
// windowsServiceManager manages services via sc.exe, with the service
// binPath pointing back at this same ss executable in
// "service run-windows" mode (see wrapper_windows.go) instead of a separate
// pywin32-based wrapper process. Direct port of WindowsServiceManager in
// service_manager.py, minus the pywin32/JSON-config-file indirection.
type windowsServiceManager struct {
configDir string
}
func isAdmin() bool {
token := windows.GetCurrentProcessToken()
return token.IsElevated()
}
// formatErrorMessage mirrors _format_error_message in service_manager.py.
func formatErrorMessage(operation, serviceName, errText string) string {
lower := strings.ToLower(errText)
switch {
case strings.Contains(lower, "access is denied") || strings.Contains(errText, "5"):
return fmt.Sprintf(
"Failed to %s service '%s': Access denied.\n\n"+
"Administrator privileges are required to %s Windows services.\n\n"+
"Solutions:\n"+
"• Run this program as administrator (right-click → 'Run as administrator')\n"+
"• Open an elevated Command Prompt and run the command manually\n"+
"• Ensure User Account Control (UAC) is enabled and accept the prompt",
operation, serviceName, operation)
case strings.Contains(errText, "1060"):
return fmt.Sprintf(
"Failed to %s service '%s': Service not found.\n\n"+
"The specified service does not exist. Check the service name and try again.",
operation, serviceName)
case strings.Contains(errText, "1062") && (operation == "stop" || operation == "restart"):
return fmt.Sprintf(
"Service '%s' is not currently running.\n\n"+
"This is not an error - the service was already stopped.",
serviceName)
default:
return fmt.Sprintf("Failed to %s service '%s': %s", operation, serviceName, errText)
}
}
// runAsAdmin runs cmd directly if already elevated, otherwise returns an
// error with manual-elevation instructions. Direct port of
// WindowsServiceManager._run_as_admin (service_manager.py:116-134); the
// Python version doesn't self-elevate via UAC either, it only checks and
// instructs.
func runAsAdmin(cmdArgs []string, description string) error {
if !isAdmin() {
commandStr := strings.Join(cmdArgs, " ")
return fmt.Errorf(
"administrator privileges required to %s.\n\n"+
"Command: %s\n\n"+
"Please do one of the following:\n"+
"1. Run this program as administrator (right-click → 'Run as administrator')\n"+
"2. Open an elevated Command Prompt and run: %s\n"+
"3. Accept the UAC prompt when it appears",
strings.ToLower(description), commandStr, commandStr)
}
out, err := exec.Command(cmdArgs[0], cmdArgs[1:]...).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to %s: %s", strings.ToLower(description), string(out))
}
return nil
}
func (w *windowsServiceManager) Install(cfg Config) error {
selfPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine ss executable path: %w", err)
}
serviceCmd := fmt.Sprintf(`"%s" service run-windows --name "%s" --bin "%s" --args "%s"`,
selfPath, cfg.Name, cfg.ExecutablePath, cfg.Args)
cmdArgs := []string{
"sc", "create", cfg.Name,
"binPath=", serviceCmd,
"start=", "auto",
}
if cfg.Description != "" {
cmdArgs = append(cmdArgs, "DisplayName=", cfg.Description)
}
if err := runAsAdmin(cmdArgs, fmt.Sprintf("install service '%s'", cfg.Name)); err != nil {
return fmt.Errorf("%s", formatErrorMessage("install", cfg.Name, err.Error()))
}
return nil
}
func (w *windowsServiceManager) Uninstall(name string) error {
runAsAdmin([]string{"sc", "stop", name}, fmt.Sprintf("stop service '%s'", name))
if err := runAsAdmin([]string{"sc", "delete", name}, fmt.Sprintf("uninstall service '%s'", name)); err != nil {
return fmt.Errorf("%s", formatErrorMessage("uninstall", name, err.Error()))
}
return nil
}
func (w *windowsServiceManager) Start(name string) error {
if err := runAsAdmin([]string{"sc", "start", name}, fmt.Sprintf("start service '%s'", name)); err != nil {
return fmt.Errorf("%s", formatErrorMessage("start", name, err.Error()))
}
return nil
}
func (w *windowsServiceManager) Stop(name string) error {
if err := runAsAdmin([]string{"sc", "stop", name}, fmt.Sprintf("stop service '%s'", name)); err != nil {
return fmt.Errorf("%s", formatErrorMessage("stop", name, err.Error()))
}
return nil
}
func (w *windowsServiceManager) Restart(name string) error {
if err := runAsAdmin([]string{"sc", "stop", name}, fmt.Sprintf("stop service '%s'", name)); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "1062") {
return fmt.Errorf("%s", formatErrorMessage("restart", name, err.Error()))
}
}
if err := runAsAdmin([]string{"sc", "start", name}, fmt.Sprintf("start service '%s'", name)); err != nil {
return fmt.Errorf("%s", formatErrorMessage("restart", name, err.Error()))
}
return nil
}

57
internal/service/shlex.go Normal file
View File

@ -0,0 +1,57 @@
package service
import (
"fmt"
"strings"
"unicode"
)
// shlexSplit splits s into shell-like words, honoring single and double
// quotes. Small hand-rolled equivalent of Python's shlex.split (used by
// macos_service_wrapper.py / service_manager.py to parse the free-form
// service Args string), avoiding an extra third-party dependency.
func shlexSplit(s string) ([]string, error) {
var words []string
var current strings.Builder
inWord := false
runes := []rune(s)
i := 0
for i < len(runes) {
r := runes[i]
switch {
case r == '\'' || r == '"':
quote := r
i++
closed := false
for i < len(runes) {
if runes[i] == quote {
closed = true
i++
break
}
current.WriteRune(runes[i])
i++
}
if !closed {
return nil, fmt.Errorf("no closing quotation")
}
inWord = true
case unicode.IsSpace(r):
if inWord {
words = append(words, current.String())
current.Reset()
inWord = false
}
i++
default:
current.WriteRune(r)
inWord = true
i++
}
}
if inWord {
words = append(words, current.String())
}
return words, nil
}

View File

@ -0,0 +1,50 @@
package service
import (
"os/exec"
"runtime"
"strings"
)
// Status returns a human-readable status string for the named service.
// Direct port of CoreManager.get_service_status (core_manager.py:389-434).
func Status(name string) string {
switch runtime.GOOS {
case "windows":
out, err := exec.Command("sc", "query", name).CombinedOutput()
if err != nil {
return "Service not installed or not found"
}
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "STATE") {
return strings.TrimSpace(line)
}
}
return "Service exists but status unknown"
case "linux":
out, err := exec.Command("systemctl", "is-active", name).CombinedOutput()
status := strings.TrimSpace(string(out))
if err != nil {
return "Service not installed or not found"
}
if status == "active" {
return "Service is running"
}
return "Service is " + status
case "darwin":
out, err := exec.Command("launchctl", "list").CombinedOutput()
if err != nil {
return "Error checking service status: " + err.Error()
}
output := string(out)
if strings.Contains(output, name) || strings.Contains(output, "com."+name) {
return "Service is loaded (check launchctl status for details)"
}
return "Service not installed or not loaded"
default:
return "Unsupported system: " + runtime.GOOS
}
}

View File

@ -0,0 +1,184 @@
//go:build darwin
package service
import (
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
)
// dnsManager manages macOS DNS settings via networksetup(8). Direct port of
// MacOSDNSManager in macos_service_wrapper.py.
type dnsManager struct{}
func (dnsManager) getNetworkServices() []string {
out, err := exec.Command("networksetup", "-listallnetworkservices").CombinedOutput()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to list network services: %v\n", err)
return nil
}
lines := strings.Split(string(out), "\n")
var services []string
for _, line := range lines[1:] { // skip header
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "*") {
services = append(services, line)
}
}
return services
}
func (dnsManager) getCurrentDNS(service string) []string {
out, err := exec.Command("networksetup", "-getdnsservers", service).CombinedOutput()
if err != nil {
return []string{"Empty"}
}
var servers []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.Contains(line, " ") {
servers = append(servers, line)
}
}
return servers
}
func (dnsManager) setDNS(service string, servers []string) bool {
var err error
if len(servers) == 0 {
_, err = exec.Command("networksetup", "-setdnsservers", service, "empty").CombinedOutput()
} else {
args := append([]string{"-setdnsservers", service}, servers...)
_, err = exec.Command("networksetup", args...).CombinedOutput()
}
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to set DNS for %s: %v\n", service, err)
return false
}
return true
}
// mihomoWrapper wraps the mihomo process on macOS, managing DNS settings
// around its lifecycle. Direct port of MihomoServiceWrapper in
// macos_service_wrapper.py.
type mihomoWrapper struct {
executable string
args []string
configDir string
process *exec.Cmd
dns dnsManager
originalDNS map[string][]string
shuttingDown bool
}
func (w *mihomoWrapper) start() bool {
cmd := exec.Command(w.executable, w.args...)
cmd.Dir = w.configDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to start service: %v\n", err)
return false
}
w.process = cmd
fmt.Fprintf(os.Stderr, "Started mihomo service (PID: %d)\n", cmd.Process.Pid)
w.originalDNS = make(map[string][]string)
dnsServer := "223.6.6.6"
for _, svc := range w.dns.getNetworkServices() {
original := w.dns.getCurrentDNS(svc)
w.originalDNS[svc] = original
if w.dns.setDNS(svc, []string{dnsServer}) {
fmt.Fprintf(os.Stderr, "Set DNS for %s: %s\n", svc, dnsServer)
} else {
fmt.Fprintf(os.Stderr, "Warning: Failed to set DNS for %s\n", svc)
}
}
return true
}
func (w *mihomoWrapper) stop() bool {
for svc, servers := range w.originalDNS {
if w.dns.setDNS(svc, servers) {
if len(servers) > 0 && servers[0] != "Empty" {
fmt.Fprintf(os.Stderr, "Restored DNS for %s: %s\n", svc, strings.Join(servers, ", "))
} else {
fmt.Fprintf(os.Stderr, "Reset DNS for %s to automatic\n", svc)
}
} else {
fmt.Fprintf(os.Stderr, "Warning: Failed to restore DNS for %s\n", svc)
}
}
if w.process != nil && w.process.Process != nil {
w.process.Process.Signal(syscall.SIGTERM)
done := make(chan error, 1)
go func() { done <- w.process.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
w.process.Process.Kill()
<-done
}
fmt.Fprintf(os.Stderr, "Stopped mihomo service (PID: %d)\n", w.process.Process.Pid)
}
return true
}
func (w *mihomoWrapper) run() {
w.shuttingDown = false
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
if !w.start() {
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "Service started, press Ctrl+C to stop")
waitCh := make(chan error, 1)
go func() { waitCh <- w.process.Wait() }()
select {
case <-waitCh:
fmt.Fprintf(os.Stderr, "Process exited with code: %d\n", w.process.ProcessState.ExitCode())
w.stop()
case sig := <-sigCh:
fmt.Fprintf(os.Stderr, "Received signal %v, shutting down...\n", sig)
w.shuttingDown = true
w.stop()
os.Exit(0)
}
}
// RunMacOSWrapper runs the mihomo process with DNS management, blocking
// until it exits or a termination signal is received. Invoked by the
// hidden `ss service run-macos <executable> <configDir> [args...]` command
// that the generated launchd plist points at (see service_darwin.go),
// replacing the standalone macos_service_wrapper.py script.
func RunMacOSWrapper(executable, configDir string, args []string) error {
if _, err := os.Stat(executable); err != nil {
return fmt.Errorf("executable not found: %s", executable)
}
if _, err := os.Stat(configDir); err != nil {
return fmt.Errorf("config directory not found: %s", configDir)
}
w := &mihomoWrapper{
executable: executable,
args: args,
configDir: configDir,
}
w.run()
return nil
}

View File

@ -0,0 +1,11 @@
//go:build !darwin
package service
import "fmt"
// RunMacOSWrapper is only meaningful on macOS; this stub lets the CLI
// package build on other platforms.
func RunMacOSWrapper(executable, configDir string, args []string) error {
return fmt.Errorf("service run-macos is only supported on macOS")
}

View File

@ -0,0 +1,137 @@
//go:build windows
package service
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"golang.org/x/sys/windows/svc"
)
// windowsServiceHandler implements svc.Handler, hosting the mihomo
// subprocess directly inside the ss binary. Replaces
// windows_service_wrapper.py + pywin32 + the per-service JSON config file:
// the equivalent configuration (bin path + args) is now passed as command
// line flags to `ss service run-windows`, and this same binary registers
// itself as the service via golang.org/x/sys/windows/svc.
type windowsServiceHandler struct {
name string
binPath string
args string
logger *log.Logger
}
func (h *windowsServiceHandler) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
const accepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
var cmdArgs []string
if h.args != "" {
split, err := shlexSplit(h.args)
if err != nil {
h.logger.Printf("failed to parse service args %q: %v", h.args, err)
return false, 1
}
cmdArgs = split
}
cmd := exec.Command(h.binPath, cmdArgs...)
cmd.Dir = filepath.Dir(h.binPath)
cmd.Stdout = &logWriter{h.logger, "stdout"}
cmd.Stderr = &logWriter{h.logger, "stderr"}
if err := cmd.Start(); err != nil {
h.logger.Printf("failed to launch %s: %v", h.binPath, err)
return false, 1
}
h.logger.Printf("started process PID %d", cmd.Process.Pid)
changes <- svc.Status{State: svc.Running, Accepts: accepted}
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
loop:
for {
select {
case err := <-exited:
h.logger.Printf("process exited: %v", err)
break loop
case req := <-r:
switch req.Cmd {
case svc.Interrogate:
changes <- req.CurrentStatus
case svc.Stop, svc.Shutdown:
h.logger.Printf("stop requested")
changes <- svc.Status{State: svc.StopPending}
h.terminate(cmd)
<-exited
break loop
}
}
}
changes <- svc.Status{State: svc.Stopped}
return false, 0
}
func (h *windowsServiceHandler) terminate(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
if err := cmd.Process.Kill(); err != nil {
h.logger.Printf("error terminating process: %v", err)
return
}
done := make(chan struct{})
go func() {
cmd.Wait()
close(done)
}()
select {
case <-done:
h.logger.Printf("process terminated")
case <-time.After(30 * time.Second):
h.logger.Printf("process did not terminate within timeout")
}
}
type logWriter struct {
logger *log.Logger
stream string
}
func (w *logWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(strings.TrimRight(string(p), "\r\n"), "\n") {
if strings.TrimSpace(line) != "" {
w.logger.Printf("[%s] %s", w.stream, line)
}
}
return len(p), nil
}
// RunWindowsService registers this process as the named Windows service and
// blocks until the service control manager stops it. Invoked via the hidden
// `ss service run-windows --name <name> --bin <binPath> --args <args>`
// command that Install() points sc.exe's binPath= at.
func RunWindowsService(name, binPath, args string) error {
logPath := filepath.Join(filepath.Dir(binPath), name+"_service.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("failed to open service log file %s: %w", logPath, err)
}
defer logFile.Close()
logger := log.New(logFile, "", log.LstdFlags)
logger.Printf("service %q starting, bin=%s args=%q", name, binPath, args)
handler := &windowsServiceHandler{name: name, binPath: binPath, args: args, logger: logger}
return svc.Run(name, handler)
}

View File

@ -0,0 +1,11 @@
//go:build !windows
package service
import "fmt"
// RunWindowsService is only meaningful on Windows; this stub lets the CLI
// package build on other platforms.
func RunWindowsService(name, binPath, args string) error {
return fmt.Errorf("service run-windows is only supported on Windows")
}