// Package core manages the mihomo core binary (download/update) and its // system service lifecycle (install/start/stop/reload/status). package core import ( "archive/zip" "compress/gzip" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time" "gitea.epss.net.cn/klesh/ss/internal/corecfg" "gitea.epss.net.cn/klesh/ss/internal/service" "gitea.epss.net.cn/klesh/ss/internal/yamlutil" ) // Manager manages the mihomo core binary and its OS service. type Manager struct { CoreConfigManager *corecfg.Manager } // New creates a core Manager. func New(coreConfigManager *corecfg.Manager) *Manager { return &Manager{CoreConfigManager: coreConfigManager} } func (m *Manager) binDir() string { return filepath.Join(m.CoreConfigManager.Storage.ConfigDir, "bin") } // GetBinaryPath returns the path to the mihomo executable for this platform. func (m *Manager) GetBinaryPath() string { return binaryPath(m.binDir()) } type githubAsset struct { Name string `json:"name"` Size int64 `json:"size"` BrowserDownloadURL string `json:"browser_download_url"` } type githubRelease struct { TagName string `json:"tag_name"` Assets []githubAsset `json:"assets"` } // Update downloads and installs the mihomo binary from GitHub releases. // Direct port of CoreManager.update. func (m *Manager) Update(version string, force bool) bool { system, arch, binaryName, ok := platformInfo() if !ok { fmt.Printf("❌ Unsupported operating system/architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) return false } binDir := m.binDir() if err := os.MkdirAll(binDir, 0o755); err != nil { fmt.Printf("[ERROR] Failed to create bin directory: %v\n", err) return false } path := binaryPath(binDir) if _, err := os.Stat(path); err == nil && !force { fmt.Printf("ℹ️ Binary already exists at: %s\n", path) fmt.Println(" Use --force to overwrite") return true } var releaseURL string if version != "" { releaseURL = fmt.Sprintf("https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/%s", version) } else { releaseURL = "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest" } fmt.Printf("[INFO] Fetching release info from: %s\n", releaseURL) release, err := fetchRelease(releaseURL) if err != nil { fmt.Printf("[ERROR] %v\n", err) return false } fmt.Printf("[INFO] Found release: %s\n", release.TagName) fileExtension := ".gz" if system == "windows" { fileExtension = ".zip" } expectedFilename := fmt.Sprintf("%s-%s%s", binaryName, release.TagName, fileExtension) var targetAsset *githubAsset for i := range release.Assets { if release.Assets[i].Name == expectedFilename { targetAsset = &release.Assets[i] break } } if targetAsset == nil { prefix := fmt.Sprintf("%s-%s", binaryName, release.TagName) for i := range release.Assets { name := release.Assets[i].Name if strings.HasPrefix(name, prefix) && (strings.HasSuffix(name, ".gz") || strings.HasSuffix(name, ".zip")) { targetAsset = &release.Assets[i] break } } } if targetAsset == nil { fmt.Printf("[ERROR] Binary not found for %s/%s: %s\n", system, arch, expectedFilename) fmt.Println("Available binaries:") for _, asset := range release.Assets { if strings.Contains(asset.Name, "mihomo") && (strings.HasSuffix(asset.Name, ".gz") || strings.HasSuffix(asset.Name, ".zip")) { fmt.Printf(" - %s\n", asset.Name) } } return false } fmt.Printf("[DOWNLOAD] Downloading: %s\n", targetAsset.Name) fmt.Printf(" Size: %d bytes\n", targetAsset.Size) tempCompressedPath := path + ".tmp" + fileExtension tempExtractedPath := path + ".tmp" if err := downloadFile(targetAsset.BrowserDownloadURL, tempCompressedPath); err != nil { fmt.Printf("[ERROR] %v\n", err) return false } if info, err := os.Stat(tempCompressedPath); err != nil || info.Size() != targetAsset.Size { os.Remove(tempCompressedPath) fmt.Println("[ERROR] Download verification failed - size mismatch") return false } if err := extractBinary(tempCompressedPath, tempExtractedPath, fileExtension); err != nil { os.Remove(tempCompressedPath) os.Remove(tempExtractedPath) fmt.Printf("[ERROR] Failed to extract binary: %v\n", err) return false } os.Remove(tempCompressedPath) if system != "windows" { os.Chmod(tempExtractedPath, 0o755) } if _, err := os.Stat(path); err == nil { backupPath := path + ".backup" if err := os.Rename(path, backupPath); err != nil { fmt.Printf("[ERROR] Failed to back up existing binary: %v\n", err) return false } fmt.Printf("[INFO] Backed up existing binary to: %s\n", backupPath) } if err := os.Rename(tempExtractedPath, path); err != nil { fmt.Printf("[ERROR] Failed to install binary: %v\n", err) return false } fmt.Printf("[SUCCESS] Successfully updated mihomo %s\n", release.TagName) fmt.Printf(" Location: %s\n", path) if info, err := os.Stat(path); err == nil { fmt.Printf(" Size: %d bytes\n", info.Size()) } return true } func fetchRelease(url string) (*githubRelease, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "scientific-surfing/1.0") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("network error: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("network error: HTTP %d", resp.StatusCode) } var release githubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf("failed to parse release info: %w", err) } return &release, nil } func downloadFile(url, destPath string) error { client := &http.Client{Timeout: 5 * time.Minute} resp, err := client.Get(url) if err != nil { return fmt.Errorf("network error: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("network error: HTTP %d", resp.StatusCode) } out, err := os.Create(destPath) if err != nil { return err } defer out.Close() _, err = io.Copy(out, resp.Body) return err } func extractBinary(compressedPath, extractedPath, fileExtension string) error { switch fileExtension { case ".gz": f, err := os.Open(compressedPath) if err != nil { return err } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { return err } defer gz.Close() out, err := os.Create(extractedPath) if err != nil { return err } defer out.Close() _, err = io.Copy(out, gz) return err case ".zip": zr, err := zip.OpenReader(compressedPath) if err != nil { return err } defer zr.Close() if len(zr.File) == 0 { return fmt.Errorf("zip archive is empty") } fileInfo := zr.File[0] rc, err := fileInfo.Open() if err != nil { return err } defer rc.Close() out, err := os.Create(extractedPath) if err != nil { return err } defer out.Close() _, err = io.Copy(out, rc) return err default: return fmt.Errorf("unsupported file format: %s", fileExtension) } } // InstallService installs mihomo as a system service. func (m *Manager) InstallService(serviceName, description string) bool { binaryPath := m.GetBinaryPath() if _, err := os.Stat(binaryPath); err != nil { fmt.Printf("❌ Mihomo binary not found at: %s\n", binaryPath) fmt.Println(" Please run 'core update' first to download the binary") return false } configDir := m.CoreConfigManager.Storage.ConfigDir configFile := filepath.Join(configDir, "generated_config.yaml") if _, err := os.Stat(configFile); err != nil { m.CoreConfigManager.Apply() fmt.Printf("✅ Generated initial configuration: %s\n", configFile) } serviceArgs := fmt.Sprintf("-d %s -f %s", configDir, configFile) svcMgr, err := service.New(configDir) if err != nil { fmt.Printf("❌ Failed to install service: %v\n", err) return false } if err := svcMgr.Install(serviceName, binaryPath, description, serviceArgs); err != nil { fmt.Printf("❌ Failed to install service: %v\n", err) return false } fmt.Printf("✅ Service '%s' installed successfully\n", serviceName) fmt.Printf(" Config directory: %s\n", configDir) fmt.Printf(" Config file: %s\n", configFile) return true } // UninstallService uninstalls the mihomo system service. func (m *Manager) UninstallService(serviceName string) bool { svcMgr, err := service.New(m.CoreConfigManager.Storage.ConfigDir) if err != nil { fmt.Printf("❌ Failed to uninstall service: %v\n", err) return false } if err := svcMgr.Uninstall(serviceName); err != nil { fmt.Printf("❌ Failed to uninstall service: %v\n", err) return false } fmt.Printf("✅ Service '%s' uninstalled successfully\n", serviceName) return true } // StartService starts the mihomo system service. func (m *Manager) StartService(serviceName string) bool { svcMgr, err := service.New(m.CoreConfigManager.Storage.ConfigDir) if err != nil { fmt.Printf("❌ Failed to start service: %v\n", err) return false } if err := svcMgr.Start(serviceName); err != nil { fmt.Printf("❌ Failed to start service: %v\n", err) return false } fmt.Printf("✅ Service '%s' started successfully\n", serviceName) return true } // StopService stops the mihomo system service. func (m *Manager) StopService(serviceName string) bool { svcMgr, err := service.New(m.CoreConfigManager.Storage.ConfigDir) if err != nil { fmt.Printf("❌ Failed to stop service: %v\n", err) return false } if err := svcMgr.Stop(serviceName); err != nil { fmt.Printf("❌ Failed to stop service: %v\n", err) return false } fmt.Printf("✅ Service '%s' stopped successfully\n", serviceName) return true } // RestartService restarts the mihomo system service. func (m *Manager) RestartService(serviceName string) bool { svcMgr, err := service.New(m.CoreConfigManager.Storage.ConfigDir) if err != nil { fmt.Printf("❌ Failed to restart service: %v\n", err) return false } if err := svcMgr.Restart(serviceName); err != nil { fmt.Printf("❌ Failed to restart service: %v\n", err) return false } fmt.Printf("✅ Service '%s' restarted successfully\n", serviceName) return true } // GetServiceStatus returns a human-readable status string for the service. func (m *Manager) GetServiceStatus(serviceName string) string { return service.Status(serviceName) } // ReloadService reloads mihomo configuration via its external-controller API // without restarting the service. func (m *Manager) ReloadService() bool { configPath := filepath.Join(m.CoreConfigManager.Storage.ConfigDir, "generated_config.yaml") if _, err := os.Stat(configPath); err != nil { fmt.Printf("❌ Configuration file not found: %s\n", configPath) return false } raw, err := os.ReadFile(configPath) if err != nil { fmt.Printf("❌ Error reloading service: %v\n", err) return false } var config map[string]interface{} if err := yamlutil.Unmarshal(raw, &config); err != nil { fmt.Printf("❌ Error reloading service: %v\n", err) return false } externalController := "127.0.0.1:9090" if v, ok := config["external-controller"].(string); ok && v != "" { externalController = v } secret, _ := config["secret"].(string) baseURL := externalController if !strings.HasPrefix(baseURL, "http") { baseURL = "http://" + baseURL } url := baseURL + "/configs?force=true" payload := map[string]interface{}{ "path": configPath, "payload": "", } body, err := json.Marshal(payload) if err != nil { fmt.Printf("❌ Error reloading service: %v\n", err) return false } req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(body))) if err != nil { fmt.Printf("❌ Error reloading service: %v\n", err) return false } req.Header.Set("Content-Type", "application/json") if secret != "" { req.Header.Set("Authorization", "Bearer "+secret) } fmt.Printf("🔄 Reloading configuration via API: %s\n", url) client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Do(req) if err != nil { fmt.Printf("❌ Failed to connect to external controller: %v\n", err) fmt.Println(" Is the service running?") return false } defer resp.Body.Close() if resp.StatusCode == http.StatusNoContent { fmt.Println("✅ Configuration reloaded successfully") return true } respBody, _ := io.ReadAll(resp.Body) fmt.Printf("❌ Failed to reload configuration: HTTP %d\n", resp.StatusCode) fmt.Printf(" Response: %s\n", string(respBody)) return false }