package app import ( "context" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" ) type NodeView struct { ID string `json:"id"` Name string `json:"name"` SubscriptionID string `json:"subscriptionID"` SubscriptionName string `json:"subscriptionName"` Type string `json:"type"` UDP bool `json:"udp"` Delay *int `json:"delay"` Alive *bool `json:"alive"` Selected bool `json:"selected"` } func (a *App) api() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/v1/status", a.status) mux.HandleFunc("GET /api/v1/subscriptions", a.subscriptions) mux.HandleFunc("POST /api/v1/subscriptions", a.saveSubscription) mux.HandleFunc("PUT /api/v1/subscriptions/{id}", a.saveSubscription) mux.HandleFunc("DELETE /api/v1/subscriptions/{id}", a.deleteSubscription) mux.HandleFunc("POST /api/v1/subscriptions/{id}/refresh", a.refreshSubscription) mux.HandleFunc("GET /api/v1/nodes", a.nodes) mux.HandleFunc("POST /api/v1/nodes/select", a.selectNode) mux.HandleFunc("POST /api/v1/nodes/test", a.testNodes) mux.HandleFunc("GET /api/v1/rules", a.rules) mux.HandleFunc("POST /api/v1/rules", a.saveRule) mux.HandleFunc("PUT /api/v1/rules/{id}", a.saveRule) mux.HandleFunc("DELETE /api/v1/rules/{id}", a.deleteRule) mux.HandleFunc("GET /api/v1/settings", func(w http.ResponseWriter, r *http.Request) { jsonResponse(w, 200, a.Snapshot().Settings) }) mux.HandleFunc("PUT /api/v1/settings", a.settings) mux.HandleFunc("POST /api/v1/system/service", a.service) mux.HandleFunc("GET /api/v1/system/network", a.network) mux.HandleFunc("GET /api/v1/system/updates", a.coreUpdates) mux.HandleFunc("GET /api/v1/system/connectivity", a.connectivitySites) mux.HandleFunc("POST /api/v1/system/connectivity/test", a.testConnectivity) mux.HandleFunc("GET /api/v1/system/backup", a.backup) mux.HandleFunc("POST /api/v1/system/restore", a.restore) mux.HandleFunc("GET /api/v1/connections", a.connections) mux.HandleFunc("DELETE /api/v1/connections/{id}", a.closeConnection) mux.HandleFunc("DELETE /api/v1/connections", a.closeConnection) mux.HandleFunc("GET /api/v1/events", a.events) mux.HandleFunc("GET /api/v1/logs", a.logs) return mux } func (a *App) status(w http.ResponseWriter, r *http.Request) { jsonResponse(w, 200, a.getStatus(r.Context())) } func (a *App) getStatus(ctx context.Context) map[string]any { state := a.Snapshot() ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var version map[string]any err := a.core.Request(ctx, "GET", "/version", nil, &version) count := 0 for _, sub := range state.Subscriptions { if sub.Enabled { count += len(sub.Nodes) } } a.mu.RLock() configError := a.lastError a.mu.RUnlock() result := map[string]any{"coreOnline": err == nil, "coreInstalled": a.core.Binary != "", "version": "", "uptimeSeconds": int(time.Since(a.started).Seconds()), "nodeCount": count, "subscriptionCount": len(state.Subscriptions), "settings": state.Settings, "configError": configError, "actualNode": "", "selectedAvailable": false} _, tunErr := net.InterfaceByName("cw-tun") result["tunActive"] = err == nil && state.Settings.TunEnabled && tunErr == nil if err == nil { result["version"] = version["version"] var group map[string]any if a.core.Request(ctx, "GET", "/proxies/"+ProxyGroup, nil, &group) == nil { actual, _ := group["now"].(string) if actual == AutoGroup { var auto map[string]any if a.core.Request(ctx, "GET", "/proxies/"+AutoGroup, nil, &auto) == nil { actual, _ = auto["now"].(string) } } result["actualNode"] = actual result["selectedAvailable"] = actual != "" && actual != "REJECT" } } if err != nil { result["coreError"] = err.Error() } return result } func (a *App) subscriptions(w http.ResponseWriter, r *http.Request) { items := []map[string]any{} for _, s := range a.Snapshot().Subscriptions { a.refreshMu.Lock() refreshing := a.refreshing[s.ID] a.refreshMu.Unlock() items = append(items, map[string]any{"id": s.ID, "name": s.Name, "url": s.URL, "enabled": s.Enabled, "intervalHours": s.IntervalHours, "updatedAt": s.UpdatedAt, "attemptedAt": s.AttemptedAt, "lastError": s.LastError, "usage": s.Usage, "nodeCount": len(s.Nodes), "refreshing": refreshing}) } jsonResponse(w, 200, items) } func (a *App) saveSubscription(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` URL string `json:"url"` Enabled bool `json:"enabled"` IntervalHours int `json:"intervalHours"` } if err := decode(w, r, &input); err != nil { writeError(w, 400, err.Error()) return } id := r.PathValue("id") newSub := id == "" if newSub { id = randomID() } sub := Subscription{ID: id, Name: strings.TrimSpace(input.Name), URL: strings.TrimSpace(input.URL), Enabled: input.Enabled, IntervalHours: input.IntervalHours, Nodes: []map[string]any{}} if err := validateSubscription(sub); err != nil { writeError(w, 400, err.Error()) return } if newSub { // Empty metadata can be saved before installing the core; importing nodes needs validation. a.mu.Lock() defer a.mu.Unlock() candidate := cloneState(a.state) if len(candidate.Subscriptions) >= 50 { writeError(w, 400, "最多允许 50 个订阅") return } candidate.Subscriptions = append(candidate.Subscriptions, sub) if err := a.store.Save(candidate); err != nil { writeError(w, 500, "保存失败") return } a.state = candidate } else { err := a.change(r.Context(), func(s *State) error { for i, old := range s.Subscriptions { if old.ID == id { sub.Nodes = old.Nodes sub.UpdatedAt = old.UpdatedAt sub.AttemptedAt = old.AttemptedAt sub.LastError = old.LastError sub.Usage = old.Usage s.Subscriptions[i] = sub return nil } } return errors.New("订阅不存在") }) if err != nil { writeError(w, 400, err.Error()) return } } jsonResponse(w, 200, map[string]any{"id": id, "ok": true}) } func (a *App) deleteSubscription(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") err := a.change(r.Context(), func(state *State) error { for i, sub := range state.Subscriptions { if sub.ID == id { state.Subscriptions = append(state.Subscriptions[:i], state.Subscriptions[i+1:]...) return nil } } return errors.New("订阅不存在") }) if err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, map[string]bool{"ok": true}) } func (a *App) refreshSubscription(w http.ResponseWriter, r *http.Request) { if err := a.Refresh(r.Context(), r.PathValue("id")); err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, map[string]bool{"ok": true}) } func nodeViews(state State, live map[string]map[string]any) []NodeView { views := []NodeView{} for _, sub := range state.Subscriptions { if !sub.Enabled { continue } for _, node := range sub.Nodes { id, _ := node["name"].(string) parts := strings.SplitN(id, "/", 3) name := id if len(parts) == 3 { name = parts[2] } kind, _ := node["type"].(string) udp, _ := node["udp"].(bool) view := NodeView{ID: id, Name: name, SubscriptionID: sub.ID, SubscriptionName: sub.Name, Type: kind, UDP: udp, Selected: state.Settings.SelectedNode == id && state.Settings.SelectionMode == "manual"} if info, ok := live[id]; ok { if u, ok := info["udp"].(bool); ok { view.UDP = u } if history, ok := info["history"].([]any); ok && len(history) > 0 { if h, ok := history[len(history)-1].(map[string]any); ok { if d, ok := h["delay"].(float64); ok { delay := int(d) view.Delay = &delay alive := delay > 0 view.Alive = &alive } } } } views = append(views, view) } } return views } func (a *App) nodes(w http.ResponseWriter, r *http.Request) { live := struct { Proxies map[string]map[string]any `json:"proxies"` }{Proxies: map[string]map[string]any{}} _ = a.core.Request(r.Context(), "GET", "/proxies", nil, &live) jsonResponse(w, 200, nodeViews(a.Snapshot(), live.Proxies)) } func (a *App) selectNode(w http.ResponseWriter, r *http.Request) { var input struct { ID string `json:"id"` Mode string `json:"mode"` } if err := decode(w, r, &input); err != nil { writeError(w, 400, err.Error()) return } err := a.change(r.Context(), func(s *State) error { if input.Mode == "auto" { s.Settings.SelectionMode = "auto" return nil } if input.Mode != "manual" { return errors.New("无效的选择模式") } for _, node := range nodeViews(*s, nil) { if node.ID == input.ID { s.Settings.SelectionMode = "manual" s.Settings.SelectedNode = input.ID return nil } } return errors.New("节点不存在或订阅未启用") }) if err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, map[string]bool{"ok": true}) } func (a *App) testNodes(w http.ResponseWriter, r *http.Request) { var input struct { IDs []string `json:"ids"` } if err := decode(w, r, &input); err != nil { writeError(w, 400, err.Error()) return } state := a.Snapshot() known := map[string]bool{} for _, n := range nodeViews(state, nil) { known[n.ID] = true } if len(input.IDs) == 0 || len(input.IDs) > 500 { writeError(w, 400, "一次可测试 1–500 个节点") return } for _, id := range input.IDs { if !known[id] { writeError(w, 400, "包含未知节点") return } } results := map[string]any{} for start := 0; start < len(input.IDs); start += 8 { end := start + 8 if end > len(input.IDs) { end = len(input.IDs) } type outcome struct { ID string Delay int Err string } ch := make(chan outcome, end-start) for _, id := range input.IDs[start:end] { go func(id string) { ctx, cancel := context.WithTimeout(r.Context(), time.Duration(state.Settings.TestTimeoutMS+2000)*time.Millisecond) defer cancel() var response struct { Delay int `json:"delay"` } path := "/proxies/" + url.PathEscape(id) + "/delay?url=" + url.QueryEscape(state.Settings.TestURL) + fmt.Sprintf("&timeout=%d", state.Settings.TestTimeoutMS) err := a.core.Request(ctx, "GET", path, nil, &response) message := "" if err != nil { message = err.Error() } ch <- outcome{id, response.Delay, message} }(id) } for i := start; i < end; i++ { v := <-ch results[v.ID] = map[string]any{"delay": v.Delay, "error": v.Err} } if r.Context().Err() != nil { return } } jsonResponse(w, 200, results) } func (a *App) rules(w http.ResponseWriter, r *http.Request) { jsonResponse(w, 200, map[string]any{"custom": a.Snapshot().Rules, "defaults": []map[string]string{{"match": "内网、回环、链路本地及组播地址", "action": "DIRECT"}, {"match": "自定义规则(按列表顺序)", "action": "CUSTOM"}, {"match": "大陆域名 / IP 规则库", "action": "DIRECT"}, {"match": "其余目标", "action": "PROXY"}}}) } func (a *App) saveRule(w http.ResponseWriter, r *http.Request) { var input struct { Type string `json:"type"` Value string `json:"value"` Action string `json:"action"` Enabled bool `json:"enabled"` } if err := decode(w, r, &input); err != nil { writeError(w, 400, err.Error()) return } id := r.PathValue("id") newRule := id == "" if newRule { id = randomID() } rule := Rule{ID: id, Type: input.Type, Value: strings.ToLower(strings.TrimSpace(input.Value)), Action: input.Action, Enabled: input.Enabled} err := a.change(r.Context(), func(s *State) error { if newRule { s.Rules = append(s.Rules, rule) return nil } for i, r := range s.Rules { if r.ID == id { s.Rules[i] = rule return nil } } return errors.New("规则不存在") }) if err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, map[string]string{"id": id}) } func (a *App) deleteRule(w http.ResponseWriter, r *http.Request) { err := a.change(r.Context(), func(s *State) error { for i, rule := range s.Rules { if rule.ID == r.PathValue("id") { s.Rules = append(s.Rules[:i], s.Rules[i+1:]...) return nil } } return errors.New("规则不存在") }) if err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, map[string]bool{"ok": true}) } func (a *App) settings(w http.ResponseWriter, r *http.Request) { var settings Settings if err := decode(w, r, &settings); err != nil { writeError(w, 400, err.Error()) return } if settings.TunEnabled && !interfaceExists(settings.Interface, settings.LANIP) { writeError(w, 400, "VM 地址与网卡不匹配;请在正式 VM 上先配置桥接网卡和固定 IP") return } err := a.change(r.Context(), func(s *State) error { s.Settings = settings; return nil }) if err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, settings) } func (a *App) service(w http.ResponseWriter, r *http.Request) { var input struct { Action string `json:"action"` } if err := decode(w, r, &input); err != nil { writeError(w, 400, err.Error()) return } if input.Action == "start" || input.Action == "restart" { a.mu.Lock() config, _ := compileConfig(a.state, a.core.Secret) err := a.core.Apply(r.Context(), config) a.mu.Unlock() if err != nil { writeError(w, 400, err.Error()) return } } if err := a.core.Service(r.Context(), input.Action); err != nil { writeError(w, 400, err.Error()) return } if input.Action != "stop" { if err := a.core.Select(r.Context(), a.Snapshot()); err != nil { writeError(w, 400, err.Error()) return } } jsonResponse(w, 200, map[string]bool{"ok": true}) } func (a *App) connections(w http.ResponseWriter, r *http.Request) { var result map[string]any if err := a.core.Request(r.Context(), "GET", "/connections", nil, &result); err != nil { writeError(w, 503, err.Error()) return } jsonResponse(w, 200, result) } func (a *App) closeConnection(w http.ResponseWriter, r *http.Request) { path := "/connections" if id := r.PathValue("id"); id != "" { path += "/" + url.PathEscape(id) } if err := a.core.Request(r.Context(), "DELETE", path, nil, nil); err != nil { writeError(w, 503, err.Error()) return } jsonResponse(w, 200, map[string]bool{"ok": true}) } func (a *App) backup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", `attachment; filename="clashweb-backup.json"`) jsonResponse(w, 200, Backup{1, now(), a.Snapshot()}) } func (a *App) restore(w http.ResponseWriter, r *http.Request) { var backup Backup if err := decode(w, r, &backup); err != nil { writeError(w, 400, err.Error()) return } if backup.Version != 1 { writeError(w, 400, "不支持此备份版本") return } if backup.State.Settings.TunEnabled && !interfaceExists(backup.State.Settings.Interface, backup.State.Settings.LANIP) { writeError(w, 400, "备份中的网卡和 VM 地址与当前机器不匹配;请先修改备份中的网络设置或关闭 tunEnabled") return } err := a.change(r.Context(), func(s *State) error { *s = backup.State; return nil }) if err != nil { writeError(w, 400, err.Error()) return } jsonResponse(w, 200, map[string]bool{"ok": true}) } func (a *App) network(w http.ResponseWriter, r *http.Request) { interfaces := []map[string]any{} list, _ := net.Interfaces() for _, i := range list { if i.Flags&net.FlagLoopback != 0 { continue } ips := []string{} addresses, _ := i.Addrs() for _, addr := range addresses { ip, _, err := net.ParseCIDR(addr.String()) if err == nil && ip.To4() != nil { ips = append(ips, addr.String()) } } interfaces = append(interfaces, map[string]any{"name": i.Name, "addresses": ips, "up": i.Flags&net.FlagUp != 0}) } sort.Slice(interfaces, func(i, j int) bool { return interfaces[i]["name"].(string) < interfaces[j]["name"].(string) }) forward, _ := os.ReadFile("/proc/sys/net/ipv4/ip_forward") _, tunErr := os.Stat("/dev/net/tun") _, domainErr := os.Stat(filepath.Join(a.core.Dir, "rules/cn-domain.mrs")) _, ipErr := os.Stat(filepath.Join(a.core.Dir, "rules/cn-ip.mrs")) jsonResponse(w, 200, map[string]any{"interfaces": interfaces, "ipForward": strings.TrimSpace(string(forward)) == "1", "tunDevice": tunErr == nil, "rulesInstalled": domainErr == nil && ipErr == nil, "serviceControl": a.core.Helper != "", "dhcpManaged": false, "guidance": "当前光猫 DHCP 无法关闭。请手动将测试设备的 IPv4 网关和 DNS 设为 PVE VM 的固定地址;不要在 VM 启动第二个 DHCP 服务。"}) } func streamHeaders(w http.ResponseWriter) error { if _, ok := w.(http.Flusher); !ok { return errors.New("不支持流式响应") } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") return nil } func sendEvent(w http.ResponseWriter, event string, value any) error { b, err := json.Marshal(value) if err != nil { return err } _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil { return err } w.(http.Flusher).Flush() return nil } func (a *App) events(w http.ResponseWriter, r *http.Request) { if err := streamHeaders(w); err != nil { writeError(w, 500, err.Error()) return } ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() var prevUp, prevDown float64 last := time.Now() initialized := false for { if _, _, err := a.session(r); err != nil { _ = sendEvent(w, "expired", nil) return } var connections struct { Upload float64 `json:"uploadTotal"` Download float64 `json:"downloadTotal"` Connections []json.RawMessage `json:"connections"` } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) err := a.core.Request(ctx, "GET", "/connections", nil, &connections) cancel() current := time.Now() seconds := current.Sub(last).Seconds() up, down := float64(0), float64(0) if err == nil && initialized && seconds > 0 { up = (connections.Upload - prevUp) / seconds down = (connections.Download - prevDown) / seconds if up < 0 { up = 0 } if down < 0 { down = 0 } } if err == nil { prevUp = connections.Upload prevDown = connections.Download } initialized = err == nil last = current if sendEvent(w, "metrics", map[string]any{"up": up, "down": down, "upTotal": connections.Upload, "downTotal": connections.Download, "connections": len(connections.Connections), "coreOnline": err == nil, "time": now()}) != nil { return } select { case <-r.Context().Done(): return case <-ticker.C: } } } func (a *App) logs(w http.ResponseWriter, r *http.Request) { if err := streamHeaders(w); err != nil { writeError(w, 500, err.Error()) return } ctx, cancel := context.WithCancel(r.Context()) defer cancel() items := make(chan any, 32) finished := make(chan error, 1) go func() { finished <- a.core.Logs(ctx, func(item any) error { select { case items <- item: return nil case <-ctx.Done(): return ctx.Err() } }) }() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() for { select { case item := <-items: if sendEvent(w, "log", item) != nil { return } case err := <-finished: if err != nil { _ = sendEvent(w, "disconnected", map[string]string{"error": "内核日志连接已断开,正在等待重连"}) } return case <-ticker.C: if _, _, err := a.session(r); err != nil { _ = sendEvent(w, "expired", nil) return } if sendEvent(w, "heartbeat", nil) != nil { return } case <-ctx.Done(): return } } }