package app import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "github.com/gorilla/websocket" "gopkg.in/yaml.v3" ) type Core struct { URL string Secret string Binary string Dir string Helper string HTTP *http.Client } var errCoreUnavailable = errors.New("Mihomo 内核未连接,请检查服务状态和控制端口") func (c *Core) Request(ctx context.Context, method, path string, body any, target any) error { var payload io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } payload = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.URL+path, payload) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Secret) req.Header.Set("Content-Type", "application/json") resp, err := c.HTTP.Do(req) if err != nil { if ctx.Err() != nil { return ctx.Err() } if errors.Is(err, syscall.ECONNREFUSED) { return errCoreUnavailable } return errors.New("Mihomo 请求超时或通信失败,请检查内核状态") } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("内核请求失败(HTTP %d)", resp.StatusCode) } if target != nil { return json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(target) } return nil } func (c *Core) Validate(ctx context.Context, config []byte) error { if c.Binary == "" { return errors.New("未找到 Mihomo,设置 CLASHWEB_MIHOMO_BIN 后才能应用配置") } file, err := os.CreateTemp(c.Dir, "validate-*.yaml") if err != nil { return err } defer os.Remove(file.Name()) if err := file.Chmod(0640); err != nil { file.Close() return err } if _, err = file.Write(config); err != nil { file.Close() return err } file.Close() ctx, cancel := context.WithTimeout(ctx, 25*time.Second) defer cancel() cmd := exec.CommandContext(ctx, c.Binary, "-t", "-d", c.Dir, "-f", file.Name()) if _, err := cmd.CombinedOutput(); err != nil { return errors.New("Mihomo 配置校验失败,请检查节点格式及规则资源是否已安装;原配置已保留") } return nil } func atomicWrite(path string, b []byte) error { f, err := os.CreateTemp(filepath.Dir(path), ".config-*") if err != nil { return err } defer os.Remove(f.Name()) if err := f.Chmod(0640); err != nil { f.Close() return err } if _, err := f.Write(b); err != nil { f.Close() return err } if err := f.Sync(); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } return os.Rename(f.Name(), path) } func (c *Core) Apply(ctx context.Context, config []byte) error { if err := c.Validate(ctx, config); err != nil { return err } var version map[string]any probeErr := c.Request(ctx, "GET", "/version", nil, &version) if probeErr != nil && !errors.Is(probeErr, errCoreUnavailable) { return probeErr } path := filepath.Join(c.Dir, "config.yaml") old, readErr := os.ReadFile(path) if err := atomicWrite(path, config); err != nil { return err } if probeErr != nil { // A validated configuration can be staged while the service is stopped. return nil } if err := c.Request(ctx, "PUT", "/configs?force=true", map[string]any{"path": path}, nil); err != nil { if readErr == nil { if writeErr := atomicWrite(path, old); writeErr != nil { return fmt.Errorf("加载失败且恢复配置文件失败: %w", writeErr) } rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if rollbackErr := c.Request(rollbackCtx, "PUT", "/configs?force=true", map[string]any{"path": path}, nil); rollbackErr != nil { return errors.New("配置加载失败,旧文件已恢复,但内核恢复失败;请重启 Mihomo 服务") } } else { _ = os.Remove(path) } return errors.New("配置加载失败,已恢复上一个配置") } return nil } func (c *Core) Service(ctx context.Context, action string) error { if action != "start" && action != "stop" && action != "restart" { return errors.New("不支持的服务操作") } if c.Helper == "" { return errors.New("尚未安装服务控制辅助程序,请使用 Linux 安装脚本") } ctx, cancel := context.WithTimeout(ctx, 25*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "/usr/bin/sudo", "-n", c.Helper, action) if _, err := cmd.CombinedOutput(); err != nil { return errors.New("服务操作失败,请检查 systemd 与辅助程序权限") } return nil } func (c *Core) Logs(ctx context.Context, send func(any) error) error { u, err := url.Parse(c.URL) if err != nil { return err } u.Scheme = "ws" u.Path = "/logs" u.RawQuery = "level=info" headers := http.Header{} headers.Set("Authorization", "Bearer "+c.Secret) dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second, Proxy: nil} conn, resp, err := dialer.DialContext(ctx, u.String(), headers) if resp != nil && resp.Body != nil { defer resp.Body.Close() } if err != nil { return errors.New("日志流未连接") } defer conn.Close() conn.SetReadLimit(1 << 20) done := make(chan struct{}) defer close(done) go func() { select { case <-ctx.Done(): conn.Close() case <-done: } }() for { var item map[string]any if err := conn.ReadJSON(&item); err != nil { return err } if text, ok := item["payload"].(string); ok { item["payload"] = redactLog(text) } if err := send(item); err != nil { return err } } } func compileConfig(state State, secret string) ([]byte, error) { settings := state.Settings proxies := []map[string]any{} names := []string{} for _, sub := range state.Subscriptions { if !sub.Enabled { continue } for _, node := range sub.Nodes { proxies = append(proxies, node) name, _ := node["name"].(string) names = append(names, name) } } autoNames := names if len(autoNames) == 0 { autoNames = []string{"REJECT"} } manualNames := append([]string{}, names...) manualNames = append(manualNames, AutoGroup, "REJECT") first := "REJECT" if settings.SelectionMode == "auto" { first = AutoGroup } else { for _, n := range names { if n == settings.SelectedNode { first = n break } } } ordered := []string{first} for _, n := range manualNames { if n != first { ordered = append(ordered, n) } } manualNames = ordered rules := []string{"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,172.16.0.0/12,DIRECT,no-resolve", "IP-CIDR,192.168.0.0/16,DIRECT,no-resolve", "IP-CIDR,169.254.0.0/16,DIRECT,no-resolve", "IP-CIDR,224.0.0.0/4,DIRECT,no-resolve"} for _, rule := range state.Rules { if !rule.Enabled { continue } action := rule.Action if action == "PROXY" { action = ProxyGroup } s := rule.Type + "," + rule.Value + "," + action if rule.Type == "IP-CIDR" { s += ",no-resolve" } rules = append(rules, s) } // Rule sets are installed and cached locally; the core does not download them at boot. providers := map[string]any{} for _, r := range []struct{ Name, Path, Behavior string }{{"cn-domain", "rules/cn-domain.mrs", "domain"}, {"cn-ip", "rules/cn-ip.mrs", "ipcidr"}} { providers[r.Name] = map[string]any{"type": "file", "path": r.Path, "behavior": r.Behavior, "format": "mrs"} } rules = append(rules, "RULE-SET,cn-domain,DIRECT", "RULE-SET,cn-ip,DIRECT", "MATCH,"+ProxyGroup) mode := "rule" if !settings.ProxyEnabled { mode = "direct" } listen := "127.0.0.1" if settings.LANIP != "" { listen = settings.LANIP } config := map[string]any{ "mixed-port": settings.MixedPort, "allow-lan": settings.LANIP != "", "bind-address": listen, "lan-allowed-ips": []string{settings.LANCIDR}, "mode": mode, "log-level": "info", "ipv6": false, "external-controller": "127.0.0.1:9090", "secret": secret, "external-controller-cors": map[string]any{"allow-origins": []string{}, "allow-private-network": false}, "profile": map[string]any{"store-selected": false, "store-fake-ip": true}, "unified-delay": true, "proxies": proxies, "proxy-groups": []any{ map[string]any{"name": ProxyGroup, "type": "select", "proxies": manualNames}, map[string]any{"name": AutoGroup, "type": "url-test", "proxies": autoNames, "url": settings.TestURL, "interval": 300, "tolerance": 50, "lazy": true}, }, "rule-providers": providers, "rules": rules, "dns": map[string]any{ "enable": true, "listen": fmt.Sprintf("%s:%d", listen, settings.DNSPort), "ipv6": false, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16", "fake-ip-filter": []string{"*.lan", "*.local", "localhost", "+.home.arpa"}, "default-nameserver": []string{"223.5.5.5", "119.29.29.29"}, "proxy-server-nameserver": []string{"https://dns.alidns.com/dns-query"}, "direct-nameserver": []string{"https://dns.alidns.com/dns-query"}, "nameserver-policy": map[string]any{"rule-set:cn-domain": []string{"https://dns.alidns.com/dns-query"}}, "nameserver": []string{"https://cloudflare-dns.com/dns-query#" + ProxyGroup}, }, "tun": map[string]any{"enable": settings.TunEnabled, "device": "cw-tun", "stack": "mixed", "auto-route": true, "auto-redirect": true, "auto-detect-interface": settings.Interface == "", "dns-hijack": []string{"any:53", "tcp://any:53"}, "mtu": 1500, "route-exclude-address": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}}, } if settings.LANCIDR == "" { delete(config, "lan-allowed-ips") } if settings.Interface != "" { config["interface-name"] = settings.Interface } if !settings.ProxyEnabled { config["dns"].(map[string]any)["nameserver"] = []string{"https://dns.alidns.com/dns-query"} } return yaml.Marshal(config) } func (c *Core) Select(ctx context.Context, state State) error { var version map[string]any if err := c.Request(ctx, "GET", "/version", nil, &version); err != nil { if errors.Is(err, errCoreUnavailable) { return nil } return err } selected := "REJECT" if state.Settings.SelectionMode == "auto" { selected = AutoGroup } else { for _, sub := range state.Subscriptions { if !sub.Enabled { continue } for _, node := range sub.Nodes { if node["name"] == state.Settings.SelectedNode { selected = state.Settings.SelectedNode } } } } return c.Request(ctx, "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": selected}, nil) } func redactLog(text string) string { words := strings.Fields(text) for _, word := range words { start := strings.Index(word, "http") if start < 0 { continue } raw := strings.TrimRight(word[start:], "\"'(),;") u, err := url.Parse(raw) if err == nil && u.Host != "" { u.User = nil if u.RawQuery != "" { u.RawQuery = "REDACTED" } text = strings.ReplaceAll(text, raw, u.String()) } } return text }