459 lines
12 KiB
Go
459 lines
12 KiB
Go
// 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
|
||
}
|