Initialize Clash Web source and deployment documentation

This commit is contained in:
lhc committed 2026-10-05 17:32:00 +08:00
commit 8e0fa00a73
66 files changed
+8532

No files matched your search

+626
View File
@@ -0,0 +1,626 @@
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
}
}
}