feat: migrate to golang
This commit is contained in:
11
internal/service/exec_unix.go
Normal file
11
internal/service/exec_unix.go
Normal 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
|
||||
}
|
||||
13
internal/service/exec_windows.go
Normal file
13
internal/service/exec_windows.go
Normal 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
122
internal/service/service.go
Normal 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
|
||||
}
|
||||
147
internal/service/service_darwin.go
Normal file
147
internal/service/service_darwin.go
Normal 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
113
internal/service/service_linux.go
Normal file
113
internal/service/service_linux.go
Normal 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
|
||||
}
|
||||
142
internal/service/service_windows.go
Normal file
142
internal/service/service_windows.go
Normal 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
57
internal/service/shlex.go
Normal 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
|
||||
}
|
||||
50
internal/service/status.go
Normal file
50
internal/service/status.go
Normal 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
|
||||
}
|
||||
}
|
||||
184
internal/service/wrapper_darwin.go
Normal file
184
internal/service/wrapper_darwin.go
Normal 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
|
||||
}
|
||||
11
internal/service/wrapper_macos_stub.go
Normal file
11
internal/service/wrapper_macos_stub.go
Normal 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")
|
||||
}
|
||||
137
internal/service/wrapper_windows.go
Normal file
137
internal/service/wrapper_windows.go
Normal 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)
|
||||
}
|
||||
11
internal/service/wrapper_windows_svc_stub.go
Normal file
11
internal/service/wrapper_windows_svc_stub.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user