feat: migrate to golang
This commit is contained in:
458
internal/core/manager.go
Normal file
458
internal/core/manager.go
Normal file
@ -0,0 +1,458 @@
|
||||
// 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(serviceName string) 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
|
||||
}
|
||||
61
internal/core/platform.go
Normal file
61
internal/core/platform.go
Normal file
@ -0,0 +1,61 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// binaryNames maps GOOS -> GOARCH -> mihomo release asset base name.
|
||||
// Mirrors core_manager.py's platform_map (core_manager.py:38-55). Unlike the
|
||||
// Python version, no separate arch-normalization table is needed since
|
||||
// runtime.GOARCH already reports "amd64"/"386"/"arm64"/"arm".
|
||||
var binaryNames = map[string]map[string]string{
|
||||
"windows": {
|
||||
"amd64": "mihomo-windows-amd64",
|
||||
"386": "mihomo-windows-386",
|
||||
"arm64": "mihomo-windows-arm64",
|
||||
"arm": "mihomo-windows-arm32v7",
|
||||
},
|
||||
"linux": {
|
||||
"amd64": "mihomo-linux-amd64",
|
||||
"386": "mihomo-linux-386",
|
||||
"arm64": "mihomo-linux-arm64",
|
||||
"arm": "mihomo-linux-armv7",
|
||||
},
|
||||
"darwin": {
|
||||
"amd64": "mihomo-darwin-amd64",
|
||||
"arm64": "mihomo-darwin-arm64",
|
||||
},
|
||||
}
|
||||
|
||||
// platformInfo returns (system, arch, binaryNameBase), or ok=false if the
|
||||
// current platform/arch combination is unsupported.
|
||||
func platformInfo() (system, arch, binaryName string, ok bool) {
|
||||
system = runtime.GOOS
|
||||
arch = runtime.GOARCH
|
||||
|
||||
archs, systemSupported := binaryNames[system]
|
||||
if !systemSupported {
|
||||
return "", "", "", false
|
||||
}
|
||||
name, archSupported := archs[arch]
|
||||
if !archSupported {
|
||||
return "", "", "", false
|
||||
}
|
||||
return system, arch, name, true
|
||||
}
|
||||
|
||||
// binaryPath returns the local path where the mihomo executable for the
|
||||
// current platform is (or will be) stored.
|
||||
func binaryPath(binDir string) string {
|
||||
system, arch, _, ok := platformInfo()
|
||||
if !ok {
|
||||
system, arch = runtime.GOOS, runtime.GOARCH
|
||||
}
|
||||
suffix := "-" + system + "-" + arch
|
||||
name := "mihomo" + suffix
|
||||
if system == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
return filepath.Join(binDir, name)
|
||||
}
|
||||
Reference in New Issue
Block a user