Files
clash-web/internal/app/api.go
T

627 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}