463 lines
13 KiB
Go
463 lines
13 KiB
Go
// 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/automation"
|
|
"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, "config", "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(filepath.Dir(m.ConfigFile), 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.MkdirAll(filepath.Dir(m.ConfigFile), 0o755); err != nil {
|
|
fmt.Printf("Error: Failed to create config directory: %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.MkdirAll(filepath.Dir(m.ConfigFile), 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 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 every active (or the explicitly --subscription-selected)
|
|
// subscription to generate the final config file: merging all of them
|
|
// together (each proxy name prefixed with its source subscription's name to
|
|
// avoid collisions), merging that with user configuration, filling in
|
|
// essential defaults, running any `ssm:` automation rules, and finally
|
|
// executing any registered hooks.
|
|
func (m *Manager) Apply() bool {
|
|
config := m.LoadConfig()
|
|
|
|
// The "ssm" key is this tool's own automation metadata, not a real
|
|
// mihomo/clash config key — pull it out before anything else touches
|
|
// config, so it never leaks into the generated file.
|
|
ssmRaw, hasSSM := config["ssm"]
|
|
delete(config, "ssm")
|
|
|
|
subs := m.resolveSubscriptions()
|
|
if len(subs) == 0 {
|
|
return false
|
|
}
|
|
|
|
subscriptionData, proxies, ok := m.mergeSubscriptions(subs)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
if hasSSM {
|
|
ssmConfig, err := automation.ParseConfig(ssmRaw)
|
|
if err != nil {
|
|
fmt.Printf("❌ Invalid ssm automation config: %v\n", err)
|
|
} else {
|
|
for _, err := range automation.Apply(finalConfig, ssmConfig, proxies) {
|
|
fmt.Printf("⚠️ ssm automation: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
names := make([]string, len(subs))
|
|
for i, sub := range subs {
|
|
names[i] = sub.Name
|
|
}
|
|
fmt.Printf("✅ Generated final configuration: %s\n", m.GeneratedPath)
|
|
fmt.Printf(" Active subscription(s): %s\n", strings.Join(names, ", "))
|
|
|
|
m.executeHooks(m.GeneratedPath)
|
|
|
|
return true
|
|
}
|
|
|
|
// resolveSubscriptions returns the subscription(s) to apply: just the
|
|
// explicitly --subscription-selected one if set, otherwise every currently
|
|
// active subscription.
|
|
func (m *Manager) resolveSubscriptions() []*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 []*model.Subscription{sub}
|
|
}
|
|
subs := m.SubscriptionManager.Data.GetActiveSubscriptions()
|
|
if len(subs) == 0 {
|
|
fmt.Println("❌ No active subscription found")
|
|
return nil
|
|
}
|
|
return subs
|
|
}
|
|
|
|
// mergeSubscriptions reads and parses every resolved subscription's
|
|
// downloaded YAML, prefixes each proxy's name with its source subscription's
|
|
// name (avoiding collisions when multiple subscriptions are merged), and
|
|
// deep-merges them all together (later subscriptions' scalar/map values win
|
|
// over earlier ones on conflicting keys; lists — notably "proxies" — are
|
|
// concatenated). Returns the combined data, the full proxy pool for ssm
|
|
// automation matching, and false if any resolved subscription's file is
|
|
// missing or invalid.
|
|
func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]interface{}, []automation.ProxyRef, bool) {
|
|
combined := map[string]interface{}{}
|
|
var proxies []automation.ProxyRef
|
|
var selectionGroups []map[string]interface{}
|
|
|
|
for _, sub := range subs {
|
|
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 nil, nil, false
|
|
}
|
|
|
|
content, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
fmt.Printf("❌ Failed to read subscription '%s': %v\n", sub.Name, err)
|
|
return nil, nil, false
|
|
}
|
|
|
|
var data map[string]interface{}
|
|
if err := yamlutil.Unmarshal(content, &data); err != nil {
|
|
fmt.Printf("❌ Invalid YAML in subscription '%s': %v\n", sub.Name, err)
|
|
return nil, nil, false
|
|
}
|
|
if data == nil {
|
|
data = map[string]interface{}{}
|
|
}
|
|
|
|
proxies = append(proxies, automation.PrefixProxyNames(sub.Name, data)...)
|
|
if group := popFirstProxyGroup(data); group != nil {
|
|
selectionGroups = append(selectionGroups, group)
|
|
}
|
|
combined = deepMerge(combined, data)
|
|
}
|
|
|
|
combined["rules"] = dedupeMatchRule(dedupeGeoIPRule(combined["rules"]))
|
|
mergeProxySelection(combined, selectionGroups)
|
|
|
|
return combined, proxies, true
|
|
}
|
|
|
|
// dedupeMatchRule drops every "MATCH,..." catch-all rule contributed by the
|
|
// merged subscriptions — each one covers all their own proxies, so once
|
|
// concatenated they'd shadow all subsequent subscriptions' rules — and
|
|
// appends a single "MATCH,all proxies" catch-all in their place.
|
|
func dedupeMatchRule(rulesRaw interface{}) interface{} {
|
|
rules, ok := rulesRaw.([]interface{})
|
|
if !ok {
|
|
return rulesRaw
|
|
}
|
|
|
|
filtered := make([]interface{}, 0, len(rules)+1)
|
|
for _, item := range rules {
|
|
if line, ok := item.(string); ok && strings.HasPrefix(line, "MATCH,") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
|
|
return append(filtered, "MATCH,all proxies")
|
|
}
|
|
|
|
// dedupeGeoIPRule keeps only the last occurrence of each "GEOIP,<payload>"
|
|
// rule contributed by the merged subscriptions — several subscriptions
|
|
// commonly ship their own copy of the same rule (e.g. "GEOIP,CN,DIRECT"),
|
|
// and concatenating them would leave redundant earlier copies ahead of the
|
|
// one that should actually apply.
|
|
func dedupeGeoIPRule(rulesRaw interface{}) interface{} {
|
|
rules, ok := rulesRaw.([]interface{})
|
|
if !ok {
|
|
return rulesRaw
|
|
}
|
|
|
|
lastIndex := make(map[string]int)
|
|
for i, item := range rules {
|
|
if key, ok := geoIPRuleKey(item); ok {
|
|
lastIndex[key] = i
|
|
}
|
|
}
|
|
|
|
filtered := make([]interface{}, 0, len(rules))
|
|
for i, item := range rules {
|
|
if key, ok := geoIPRuleKey(item); ok && lastIndex[key] != i {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// geoIPRuleKey returns the "GEOIP,<payload>" portion of a rule line (i.e.
|
|
// everything but its target and any trailing "no-resolve" modifier), and
|
|
// whether the line is a GEOIP rule at all.
|
|
func geoIPRuleKey(item interface{}) (string, bool) {
|
|
line, ok := item.(string)
|
|
if !ok || !strings.HasPrefix(line, "GEOIP,") {
|
|
return "", false
|
|
}
|
|
|
|
body := line
|
|
if idx := strings.LastIndex(body, ","); idx != -1 && strings.EqualFold(body[idx+1:], "no-resolve") {
|
|
body = body[:idx]
|
|
}
|
|
|
|
idx := strings.LastIndex(body, ",")
|
|
if idx == -1 {
|
|
return line, true
|
|
}
|
|
return body[:idx], true
|
|
}
|
|
|
|
func openInEditor(path string) bool {
|
|
return editor.OpenFileInEditor(path)
|
|
}
|