feat: migrate to golang
This commit is contained in:
121
internal/corecfg/hooks_exec.go
Normal file
121
internal/corecfg/hooks_exec.go
Normal file
@ -0,0 +1,121 @@
|
||||
package corecfg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// executeHooks runs every core_config_generated.* script found in the hooks
|
||||
// directory, in sorted order. Direct port of _execute_hooks.
|
||||
func (m *Manager) executeHooks(configFilePath string) {
|
||||
hooksDir := filepath.Join(m.Storage.ConfigDir, "hooks")
|
||||
if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(hooksDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var hookFiles []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entry.Name(), "core_config_generated.") {
|
||||
hookFiles = append(hookFiles, filepath.Join(hooksDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
if len(hookFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("🔧 Found %d hook(s) to execute\n", len(hookFiles))
|
||||
sort.Strings(hookFiles)
|
||||
|
||||
for _, hookFile := range hookFiles {
|
||||
executeHook(hookFile, configFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// executeHook runs a single hook script with the generated config file path
|
||||
// as its argument. Direct port of _execute_hook, adapted for the fact that
|
||||
// the Go binary has no bundled interpreter of its own (unlike Python's
|
||||
// sys.executable) — .py hooks are run via a system "python3"/"python"
|
||||
// found on PATH.
|
||||
func executeHook(hookPath, configFilePath string) bool {
|
||||
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
|
||||
var cmdArgs []string
|
||||
switch strings.ToLower(filepath.Ext(hookPath)) {
|
||||
case ".py":
|
||||
python := "python3"
|
||||
if runtime.GOOS == "windows" {
|
||||
python = "python"
|
||||
}
|
||||
if _, err := exec.LookPath(python); err != nil {
|
||||
alt := "python"
|
||||
if runtime.GOOS == "windows" {
|
||||
alt = "python3"
|
||||
}
|
||||
if _, err := exec.LookPath(alt); err == nil {
|
||||
python = alt
|
||||
}
|
||||
}
|
||||
cmdArgs = []string{python, hookPath, configFilePath}
|
||||
case ".js":
|
||||
cmdArgs = []string{"node", hookPath, configFilePath}
|
||||
case ".nu":
|
||||
cmdArgs = []string{"nu", hookPath, configFilePath}
|
||||
default:
|
||||
if runtime.GOOS != "windows" {
|
||||
os.Chmod(hookPath, 0o755)
|
||||
}
|
||||
cmdArgs = []string{hookPath, configFilePath}
|
||||
}
|
||||
|
||||
fmt.Printf("🔧 Executing hook: %s\n", hookPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...)
|
||||
cmd.Dir = filepath.Dir(hookPath)
|
||||
cmd.Env = append(os.Environ(), "PYTHONIOENCODING=utf-8")
|
||||
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
fmt.Printf("⏰ Hook timed out: %s\n", filepath.Base(hookPath))
|
||||
return false
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
fmt.Printf("✅ Hook executed successfully: %s\n", filepath.Base(hookPath))
|
||||
if out := strings.TrimSpace(stdout.String()); out != "" {
|
||||
fmt.Printf(" Output: %s\n", out)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fmt.Printf("❌ Hook failed: %s\n", filepath.Base(hookPath))
|
||||
if errOut := strings.TrimSpace(stderr.String()); errOut != "" {
|
||||
fmt.Printf(" Error: %s\n", errOut)
|
||||
}
|
||||
return false
|
||||
}
|
||||
324
internal/corecfg/manager.go
Normal file
324
internal/corecfg/manager.go
Normal file
@ -0,0 +1,324 @@
|
||||
// Package corecfg manages the user's core (mihomo/clash) configuration:
|
||||
// import, export, edit, reset, show, and applying a subscription to produce
|
||||
// the final generated config file.
|
||||
package corecfg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"gitea.epss.net.cn/klesh/ss/internal/editor"
|
||||
"gitea.epss.net.cn/klesh/ss/internal/model"
|
||||
"gitea.epss.net.cn/klesh/ss/internal/storage"
|
||||
"gitea.epss.net.cn/klesh/ss/internal/subscription"
|
||||
"gitea.epss.net.cn/klesh/ss/internal/yamlutil"
|
||||
"gitea.epss.net.cn/klesh/ss/templates"
|
||||
)
|
||||
|
||||
// Manager manages user configuration with import, export, and edit operations.
|
||||
type Manager struct {
|
||||
SubscriptionManager *subscription.Manager
|
||||
Storage *storage.Manager
|
||||
ConfigFile string
|
||||
GeneratedPath string
|
||||
SubscriptionName string
|
||||
}
|
||||
|
||||
// New creates a CoreConfigManager. configFile/outputFile/subscriptionName may
|
||||
// be empty to use the defaults (mirrors CoreConfigManager.__init__).
|
||||
func New(subMgr *subscription.Manager, configFile, outputFile, subscriptionName string) (*Manager, error) {
|
||||
m := &Manager{
|
||||
SubscriptionManager: subMgr,
|
||||
Storage: subMgr.Storage,
|
||||
SubscriptionName: subscriptionName,
|
||||
}
|
||||
|
||||
if configFile != "" {
|
||||
abs, err := resolvePath(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.ConfigFile = abs
|
||||
} else {
|
||||
m.ConfigFile = filepath.Join(subMgr.Storage.ConfigDir, "core-config.yaml")
|
||||
}
|
||||
|
||||
if outputFile != "" {
|
||||
abs, err := resolvePath(outputFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.GeneratedPath = abs
|
||||
} else {
|
||||
m.GeneratedPath = filepath.Join(subMgr.Storage.ConfigDir, "generated_config.yaml")
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func resolvePath(p string) (string, error) {
|
||||
if strings.HasPrefix(p, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p = filepath.Join(home, strings.TrimPrefix(p, "~"))
|
||||
}
|
||||
return filepath.Abs(p)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureConfigExists() bool {
|
||||
if _, err := os.Stat(m.ConfigFile); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.Storage.ConfigDir, 0o755); err != nil {
|
||||
fmt.Printf("❌ Failed to create config directory: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if err := os.WriteFile(m.ConfigFile, templates.DefaultCoreConfig, 0o644); err != nil {
|
||||
fmt.Printf("❌ Failed to create default config: %v\n", err)
|
||||
return false
|
||||
}
|
||||
fmt.Printf("✅ Created default config at: %s\n", m.ConfigFile)
|
||||
return true
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from the YAML file as a raw map.
|
||||
func (m *Manager) LoadConfig() map[string]interface{} {
|
||||
raw, err := os.ReadFile(m.ConfigFile)
|
||||
if err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := yamlutil.Unmarshal(raw, &data); err != nil {
|
||||
fmt.Printf("Warning: Failed to load config: %v\n", err)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
if data == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// SaveConfig writes configuration to the YAML file.
|
||||
func (m *Manager) SaveConfig(config map[string]interface{}) bool {
|
||||
out, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: Failed to save config: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if err := os.WriteFile(m.ConfigFile, out, 0o644); err != nil {
|
||||
fmt.Printf("Error: Failed to save config: %v\n", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ImportConfig imports configuration from a YAML file, validating it before
|
||||
// persisting.
|
||||
func (m *Manager) ImportConfig(sourcePath string) bool {
|
||||
raw, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Source file not found: %s\n", sourcePath)
|
||||
return false
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := yamlutil.Unmarshal(raw, &data); err != nil {
|
||||
fmt.Printf("❌ Invalid YAML: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if data == nil {
|
||||
fmt.Println("❌ Invalid YAML format")
|
||||
return false
|
||||
}
|
||||
|
||||
if err := validateConfigMap(data); err != nil {
|
||||
fmt.Printf("❌ Failed to import: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
m.SaveConfig(data)
|
||||
fmt.Printf("✅ Imported configuration from: %s\n", sourcePath)
|
||||
return true
|
||||
}
|
||||
|
||||
// ExportConfig exports the current configuration to a YAML file.
|
||||
func (m *Manager) ExportConfig(destinationPath string) bool {
|
||||
config := m.LoadConfig()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil {
|
||||
fmt.Printf("❌ Failed to export: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
out, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to export: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if err := os.WriteFile(destinationPath, out, 0o644); err != nil {
|
||||
fmt.Printf("❌ Failed to export: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Exported configuration to: %s\n", destinationPath)
|
||||
return true
|
||||
}
|
||||
|
||||
// EditConfig opens the configuration in the system default editor, backing
|
||||
// it up first and restoring on failure to open the editor.
|
||||
func (m *Manager) EditConfig() bool {
|
||||
if !m.ensureConfigExists() {
|
||||
return false
|
||||
}
|
||||
|
||||
backupPath := strings.TrimSuffix(m.ConfigFile, filepath.Ext(m.ConfigFile)) + ".yaml.backup"
|
||||
if _, err := os.Stat(m.ConfigFile); err == nil {
|
||||
if data, err := os.ReadFile(m.ConfigFile); err == nil {
|
||||
os.WriteFile(backupPath, data, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
if !openInEditor(m.ConfigFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
m.LoadConfig()
|
||||
fmt.Println("✅ Configuration edited successfully")
|
||||
return true
|
||||
}
|
||||
|
||||
// ResetConfig resets configuration to default values.
|
||||
func (m *Manager) ResetConfig() bool {
|
||||
if err := os.WriteFile(m.ConfigFile, templates.DefaultCoreConfig, 0o644); err != nil {
|
||||
fmt.Printf("❌ Failed to reset configuration: %v\n", err)
|
||||
return false
|
||||
}
|
||||
fmt.Println("✅ Configuration reset to default values")
|
||||
return true
|
||||
}
|
||||
|
||||
// ShowConfig prints the current configuration.
|
||||
func (m *Manager) ShowConfig() {
|
||||
config := m.LoadConfig()
|
||||
out, _ := yaml.Marshal(config)
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
var essentialDefaults = map[string]interface{}{
|
||||
"port": 7890,
|
||||
"socks-port": 7891,
|
||||
"mixed-port": 7890,
|
||||
"allow-lan": false,
|
||||
"mode": "rule",
|
||||
"log-level": "info",
|
||||
"external-controller": "127.0.0.1:9090",
|
||||
"ipv6": true,
|
||||
}
|
||||
|
||||
func defaultDNSConfig() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"enable": true,
|
||||
"listen": "0.0.0.0:53",
|
||||
"enhanced-mode": "fake-ip",
|
||||
"fake-ip-range": "198.18.0.1/16",
|
||||
"nameserver": []interface{}{
|
||||
"https://doh.pub/dns-query",
|
||||
"https://dns.alidns.com/dns-query",
|
||||
},
|
||||
"fallback": []interface{}{
|
||||
"https://1.1.1.1/dns-query",
|
||||
"https://8.8.8.8/dns-query",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Apply applies the active (or explicitly selected) subscription to generate
|
||||
// the final config file, merging it with user configuration and filling in
|
||||
// essential defaults, then executes any registered hooks.
|
||||
func (m *Manager) Apply() bool {
|
||||
config := m.LoadConfig()
|
||||
|
||||
var sub = m.resolveSubscription()
|
||||
if sub == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
filePath := sub.GetFilePath(m.Storage.ConfigDir)
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
fmt.Printf("❌ Subscription file not found for '%s'. Please refresh the subscription first.\n", sub.Name)
|
||||
return false
|
||||
}
|
||||
|
||||
subscriptionContent, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to apply configuration: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
var subscriptionData map[string]interface{}
|
||||
if err := yamlutil.Unmarshal(subscriptionContent, &subscriptionData); err != nil {
|
||||
fmt.Printf("❌ Invalid YAML in subscription: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if subscriptionData == nil {
|
||||
subscriptionData = map[string]interface{}{}
|
||||
}
|
||||
|
||||
finalConfig := deepMerge(subscriptionData, config)
|
||||
|
||||
for key, defaultValue := range essentialDefaults {
|
||||
if _, ok := finalConfig[key]; !ok {
|
||||
finalConfig[key] = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := finalConfig["dns"]; !ok {
|
||||
finalConfig["dns"] = defaultDNSConfig()
|
||||
}
|
||||
|
||||
out, err := yaml.Marshal(finalConfig)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to apply configuration: %v\n", err)
|
||||
return false
|
||||
}
|
||||
if err := os.WriteFile(m.GeneratedPath, out, 0o644); err != nil {
|
||||
fmt.Printf("❌ Failed to apply configuration: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Generated final configuration: %s\n", m.GeneratedPath)
|
||||
fmt.Printf(" Active subscription: %s\n", sub.Name)
|
||||
|
||||
m.executeHooks(m.GeneratedPath)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Manager) resolveSubscription() *model.Subscription {
|
||||
if m.SubscriptionName != "" {
|
||||
sub, ok := m.SubscriptionManager.Data.Subscriptions[m.SubscriptionName]
|
||||
if !ok {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", m.SubscriptionName)
|
||||
return nil
|
||||
}
|
||||
return sub
|
||||
}
|
||||
sub := m.SubscriptionManager.Data.GetActiveSubscription()
|
||||
if sub == nil {
|
||||
fmt.Println("❌ No active subscription found")
|
||||
return nil
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func openInEditor(path string) bool {
|
||||
return editor.OpenFileInEditor(path)
|
||||
}
|
||||
27
internal/corecfg/merge.go
Normal file
27
internal/corecfg/merge.go
Normal file
@ -0,0 +1,27 @@
|
||||
package corecfg
|
||||
|
||||
// deepMerge merges dict2 into dict1 in place and returns dict1, mirroring
|
||||
// corecfg_manager.py's module-level deep_merge(): nested maps recurse,
|
||||
// matching lists are concatenated, everything else is overwritten by dict2.
|
||||
func deepMerge(dict1, dict2 map[string]interface{}) map[string]interface{} {
|
||||
for k, v := range dict2 {
|
||||
existing, exists := dict1[k]
|
||||
if exists {
|
||||
existingMap, existingIsMap := existing.(map[string]interface{})
|
||||
vMap, vIsMap := v.(map[string]interface{})
|
||||
if existingIsMap && vIsMap {
|
||||
dict1[k] = deepMerge(existingMap, vMap)
|
||||
continue
|
||||
}
|
||||
|
||||
existingList, existingIsList := existing.([]interface{})
|
||||
vList, vIsList := v.([]interface{})
|
||||
if existingIsList && vIsList {
|
||||
dict1[k] = append(existingList, vList...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dict1[k] = v
|
||||
}
|
||||
return dict1
|
||||
}
|
||||
36
internal/corecfg/validate.go
Normal file
36
internal/corecfg/validate.go
Normal file
@ -0,0 +1,36 @@
|
||||
package corecfg
|
||||
|
||||
import "gitea.epss.net.cn/klesh/ss/internal/model"
|
||||
|
||||
// validateConfigMap applies the same field constraints as the Pydantic
|
||||
// Config model's validators (models.py) to a raw config map, checking only
|
||||
// the fields that are present.
|
||||
func validateConfigMap(data map[string]interface{}) error {
|
||||
cfg := model.DefaultConfig()
|
||||
|
||||
if v, ok := data["refresh_interval_hours"]; ok {
|
||||
if n, ok := toInt(v); ok {
|
||||
cfg.RefreshIntervalHours = n
|
||||
}
|
||||
}
|
||||
if v, ok := data["timeout_seconds"]; ok {
|
||||
if n, ok := toInt(v); ok {
|
||||
cfg.TimeoutSeconds = n
|
||||
}
|
||||
}
|
||||
|
||||
return cfg.Validate()
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case float64:
|
||||
return int(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user