//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 `ssm service run-macos [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 }