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
}
}
}
+316
View File
@@ -0,0 +1,316 @@
package app
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type Options struct {
DataDir string
CoreDir string
CoreURL string
CoreBinary string
Helper string
SecureCookie bool
Web http.Handler
}
type App struct {
store *Store
core *Core
state State
mu sync.RWMutex
setupToken string
secureCookie bool
web http.Handler
download *http.Client
started time.Time
lastError string
authMu sync.Mutex
failures map[string]loginLimit
refreshMu sync.Mutex
refreshing map[string]bool
updatesMu sync.Mutex
updateCache *coreRelease
updateTime time.Time
connectivitySlots chan struct{}
}
func randomID() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func New(options Options) (*App, error) {
store, err := OpenStore(options.DataDir)
if err != nil {
return nil, err
}
fail := func(err error) (*App, error) { store.Close(); return nil, err }
state, err := store.Load()
if err != nil {
return fail(err)
}
if options.CoreDir == "" {
options.CoreDir = filepath.Join(options.DataDir, "mihomo")
}
options.CoreDir, err = filepath.Abs(options.CoreDir)
if err != nil {
return fail(err)
}
if err = os.MkdirAll(options.CoreDir, 0750); err != nil {
return fail(err)
}
if options.CoreURL == "" {
options.CoreURL = "http://127.0.0.1:9090"
}
u, err := url.Parse(options.CoreURL)
if err != nil || u.Scheme != "http" || u.Host != "127.0.0.1:9090" || u.Path != "" || u.User != nil || u.RawQuery != "" {
return fail(errors.New("内核控制地址必须是 http://127.0.0.1:9090"))
}
secretPath := filepath.Join(options.CoreDir, "controller.secret")
secret, err := os.ReadFile(secretPath)
if os.IsNotExist(err) {
secret = []byte(randomToken())
err = atomicWrite(secretPath, secret)
}
if err != nil {
return fail(err)
}
if options.CoreBinary == "" {
options.CoreBinary, _ = exec.LookPath("mihomo")
}
core := &Core{URL: options.CoreURL, Secret: strings.TrimSpace(string(secret)), Binary: options.CoreBinary, Dir: options.CoreDir, Helper: options.Helper, HTTP: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{Proxy: nil}}}
token, err := store.Get("setup_token")
if err != nil {
return fail(err)
}
password, err := store.Get("password")
if err != nil {
return fail(err)
}
if password == "" && token == "" {
token = randomToken()
if err := store.Set("setup_token", token); err != nil {
return fail(err)
}
}
if password == "" {
log.Printf("首次设置令牌(只在受信任的管理浏览器中使用): %s", token)
}
app := &App{store: store, core: core, state: state, setupToken: token, secureCookie: options.SecureCookie, web: options.Web, download: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("重定向次数过多")
}
return validHTTPURL(req.URL.String())
}}, started: time.Now(), failures: map[string]loginLimit{}, refreshing: map[string]bool{}}
app.connectivitySlots = make(chan struct{}, 3)
return app, nil
}
func (a *App) Close() error { return a.store.Close() }
func (a *App) Initialize(ctx context.Context) error {
config, err := compileConfig(a.Snapshot(), a.core.Secret)
if err != nil {
return err
}
return a.core.Apply(ctx, config)
}
func (a *App) Snapshot() State { a.mu.RLock(); defer a.mu.RUnlock(); return cloneState(a.state) }
func (a *App) change(ctx context.Context, update func(*State) error) error {
a.mu.Lock()
defer a.mu.Unlock()
before := a.state
candidate := cloneState(before)
if err := update(&candidate); err != nil {
return err
}
if err := validateState(candidate); err != nil {
return err
}
config, err := compileConfig(candidate, a.core.Secret)
if err != nil {
return err
}
if err := a.core.Apply(ctx, config); err != nil {
a.lastError = err.Error()
return err
}
if err := a.core.Select(ctx, candidate); err != nil {
old, _ := compileConfig(before, a.core.Secret)
restoreCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if restoreErr := a.core.Apply(restoreCtx, old); restoreErr != nil {
a.lastError = "节点切换失败且恢复失败,请重启内核"
return errors.New(a.lastError)
}
_ = a.core.Select(restoreCtx, before)
return err
}
if err := a.store.Save(candidate); err != nil {
old, _ := compileConfig(before, a.core.Secret)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if restoreErr := a.core.Apply(ctx, old); restoreErr != nil {
a.lastError = "数据库写入失败,内核配置恢复也失败,请重启服务"
return errors.New(a.lastError)
}
_ = a.core.Select(ctx, before)
return errors.New("数据库保存失败,已恢复原配置")
}
a.state = candidate
a.lastError = ""
return nil
}
func (a *App) Refresh(ctx context.Context, id string) error {
a.refreshMu.Lock()
if a.refreshing[id] {
a.refreshMu.Unlock()
return errors.New("此订阅正在更新")
}
a.refreshing[id] = true
a.refreshMu.Unlock()
defer func() { a.refreshMu.Lock(); delete(a.refreshing, id); a.refreshMu.Unlock() }()
var sub Subscription
found := false
for _, s := range a.Snapshot().Subscriptions {
if s.ID == id {
sub = s
found = true
break
}
}
if !found {
return errors.New("订阅不存在")
}
nodes, usage, err := loadSubscription(ctx, sub, a.download)
if err == nil {
err = a.change(ctx, func(s *State) error {
for i := range s.Subscriptions {
if s.Subscriptions[i].ID == id {
if s.Subscriptions[i].URL != sub.URL {
return errors.New("订阅地址已改变,请重新更新")
}
s.Subscriptions[i].Nodes = nodes
s.Subscriptions[i].Usage = usage
s.Subscriptions[i].UpdatedAt = now()
s.Subscriptions[i].AttemptedAt = now()
s.Subscriptions[i].LastError = ""
return nil
}
}
return errors.New("订阅已被删除")
})
}
if err != nil {
a.mu.Lock()
candidate := cloneState(a.state)
for i := range candidate.Subscriptions {
if candidate.Subscriptions[i].ID == id && candidate.Subscriptions[i].URL == sub.URL {
candidate.Subscriptions[i].AttemptedAt = now()
candidate.Subscriptions[i].LastError = err.Error()
}
}
if saveErr := a.store.Save(candidate); saveErr == nil {
a.state = candidate
}
a.mu.Unlock()
}
return err
}
func (a *App) Run(ctx context.Context) {
// Reconcile the persisted configuration on startup, without changing Linux host networking.
if a.core.Binary != "" {
config, _ := compileConfig(a.Snapshot(), a.core.Secret)
if err := a.core.Apply(ctx, config); err != nil {
a.mu.Lock()
a.lastError = err.Error()
a.mu.Unlock()
log.Printf("启动配置未应用: %s", err)
}
_ = a.core.Select(ctx, a.Snapshot())
}
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, sub := range a.Snapshot().Subscriptions {
if !sub.Enabled {
continue
}
last, _ := time.Parse(time.RFC3339, sub.AttemptedAt)
if time.Since(last) >= time.Duration(sub.IntervalHours)*time.Hour {
if err := a.Refresh(ctx, sub.ID); err != nil {
log.Printf("订阅 %s 更新失败: %s", sub.ID, err)
}
}
}
_, _ = a.store.db.Exec("DELETE FROM sessions WHERE expires < ?", time.Now().Unix())
}
}
}
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/auth/status", a.authStatus)
mux.HandleFunc("POST /api/v1/auth/setup", a.setup)
mux.HandleFunc("POST /api/v1/auth/login", a.login)
mux.Handle("POST /api/v1/auth/logout", a.protect(http.HandlerFunc(a.logout)))
mux.Handle("PUT /api/v1/auth/password", a.protect(http.HandlerFunc(a.password)))
mux.Handle("/api/v1/", a.protect(a.api()))
if a.web != nil {
mux.Handle("/", a.web)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Cache-Control", "no-store")
}
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || u.Host != r.Host {
writeError(w, 403, "不允许跨站请求")
return
}
}
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
writeError(w, 403, "不允许跨站请求")
return
}
}
mux.ServeHTTP(w, r)
})
}
func (a *App) String() string { return fmt.Sprintf("Clash Web (Mihomo directory: %s)", a.core.Dir) }
+355
View File
@@ -0,0 +1,355 @@
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"gopkg.in/yaml.v3"
)
func newTestApp(t *testing.T) *App {
t.Helper()
a, err := New(Options{DataDir: t.TempDir(), CoreBinary: "/bin/true"})
if err != nil {
t.Fatal(err)
}
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
jsonResponse(w, 200, map[string]string{"version": "test-core"})
return
}
w.WriteHeader(204)
}))
a.core.URL = mock.URL
t.Cleanup(func() { mock.Close(); a.Close() })
return a
}
func request(t *testing.T, a *App, method, path string, body any, cookie *http.Cookie, csrf string) *httptest.ResponseRecorder {
t.Helper()
var b []byte
if body != nil {
b, _ = json.Marshal(body)
}
r := httptest.NewRequest(method, path, bytes.NewReader(b))
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
if csrf != "" {
r.Header.Set("X-CSRF-Token", csrf)
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
return w
}
func loginTest(t *testing.T, a *App) (*http.Cookie, string) {
t.Helper()
w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, "")
if w.Code != 200 {
t.Fatalf("setup %d: %s", w.Code, w.Body)
}
var response struct {
CSRF string `json:"csrf"`
}
json.Unmarshal(w.Body.Bytes(), &response)
return w.Result().Cookies()[0], response.CSRF
}
func TestAuthCSRFAndSessionRevocation(t *testing.T) {
a := newTestApp(t)
if w := request(t, a, "GET", "/api/v1/settings", nil, nil, ""); w.Code != 401 {
t.Fatal(w.Code)
}
if w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: "wrong", Password: "test-password-long"}, nil, ""); w.Code != 403 {
t.Fatal(w.Code)
}
cookie, csrf := loginTest(t, a)
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatal("session cookie flags missing")
}
if w := request(t, a, "POST", "/api/v1/subscriptions", map[string]any{}, cookie, ""); w.Code != 403 {
t.Fatal("missing CSRF allowed")
}
r := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"password":"test-password-long"}`))
r.Header.Set("Origin", "https://attacker.example")
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("cross-site login allowed")
}
w = request(t, a, "PUT", "/api/v1/auth/password", credentials{CurrentPassword: "test-password-long", Password: "new-password-long"}, cookie, csrf)
if w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
if w = request(t, a, "GET", "/api/v1/settings", nil, cookie, ""); w.Code != 401 {
t.Fatal("old session still valid")
}
if w = request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, ""); w.Code != 409 {
t.Fatal("setup can run twice")
}
}
func TestLoginRateLimit(t *testing.T) {
a := newTestApp(t)
loginTest(t, a)
for i := 0; i < 5; i++ {
if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "wrong"}, nil, ""); w.Code != 401 {
t.Fatal(w.Code)
}
}
if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "test-password-long"}, nil, ""); w.Code != 429 {
t.Fatal("not rate limited", w.Code)
}
}
func TestDecodeRejectsTrailingJSON(t *testing.T) {
for _, payload := range []string{`{} {}`, `{"unknown":1}`, `{broken`, strings.Repeat("x", 17<<20)} {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", strings.NewReader(payload))
var target credentials
if decode(w, r, &target) == nil {
t.Fatal("invalid payload accepted")
}
}
}
func TestSubscriptionImportNamespaceAndProvider(t *testing.T) {
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer provider-token" {
t.Error("missing provider header")
}
io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: 127.0.0.1\n port: 8088\n")
}))
defer provider.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: localhost\n port: 8081\nproxy-providers:\n remote:\n type: http\n url: "+provider.URL+"\n header:\n Authorization: [Bearer provider-token]\nexternal-controller: 0.0.0.0:9999\nrules: [MATCH,DIRECT]\n")
}))
defer server.Close()
sub := Subscription{ID: randomID(), URL: server.URL}
nodes, _, err := loadSubscription(context.Background(), sub, server.Client())
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 || nodes[0]["name"] == nodes[1]["name"] {
t.Fatal("nodes not namespaced", nodes)
}
for _, node := range nodes {
if !strings.HasPrefix(node["name"].(string), sub.ID+"/") {
t.Fatal("unstable namespace")
}
}
sub.ID = randomID()
other, _, err := loadSubscription(context.Background(), sub, server.Client())
if err != nil || other[0]["name"] == nodes[0]["name"] {
t.Fatal("cross-subscription conflict", err)
}
}
func TestSubscriptionRejectsInvalidSources(t *testing.T) {
for _, body := range []string{"YmFzZTY0", "proxy-providers:\n local:\n type: file\n path: /etc/shadow\n", "proxies:\n- name: a\n type: http\n dialer-proxy: unknown-group\n", "proxies:\n- name: same\n type: http\n- name: same\n type: http\n"} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) }))
_, _, err := loadSubscription(context.Background(), Subscription{ID: randomID(), URL: server.URL}, server.Client())
server.Close()
if err == nil {
t.Fatalf("accepted %s", body)
}
}
}
func TestRefreshFailureKeepsNodesAndConfig(t *testing.T) {
a := newTestApp(t)
var fail atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail.Load() {
http.Error(w, "unavailable", 503)
return
}
io.WriteString(w, "proxies:\n- name: node-A\n type: http\n server: localhost\n port: 8088\n")
}))
defer server.Close()
id := randomID()
a.state.Subscriptions = []Subscription{{ID: id, Name: "subscription", URL: server.URL, Enabled: true, IntervalHours: 6}}
if err := a.Refresh(context.Background(), id); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml"))
fail.Store(true)
if err := a.Refresh(context.Background(), id); err == nil {
t.Fatal("expected failure")
}
after, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml"))
state := a.Snapshot()
if !bytes.Equal(before, after) || len(state.Subscriptions[0].Nodes) != 1 || state.Subscriptions[0].LastError == "" {
t.Fatal("last good snapshot lost")
}
loaded, err := a.store.Load()
if err != nil || len(loaded.Subscriptions[0].Nodes) != 1 {
t.Fatal("snapshot not persisted", err)
}
}
func TestCoreReloadRollbackAndValidation(t *testing.T) {
var reloads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
jsonResponse(w, 200, map[string]string{"version": "test"})
return
}
if reloads.Add(1) == 1 {
http.Error(w, "failed", 500)
return
}
w.WriteHeader(204)
}))
defer server.Close()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
os.WriteFile(path, []byte("old"), 0600)
core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()}
if err := core.Apply(context.Background(), []byte("new")); err == nil {
t.Fatal("reload failure not surfaced")
}
actual, _ := os.ReadFile(path)
if string(actual) != "old" || reloads.Load() != 2 {
t.Fatal("rollback failed", string(actual), reloads.Load())
}
core.Binary = "/bin/false"
if err := core.Apply(context.Background(), []byte("invalid")); err == nil {
t.Fatal("validation failure not surfaced")
}
actual, _ = os.ReadFile(path)
if string(actual) != "old" {
t.Fatal("bad configuration overwrote old")
}
}
func TestStateNotCommittedWhenCoreFails(t *testing.T) {
a := newTestApp(t)
a.core.Binary = "/bin/false"
before := a.Snapshot()
err := a.change(context.Background(), func(s *State) error { s.Settings.ProxyEnabled = false; return nil })
if err == nil || a.Snapshot().Settings.ProxyEnabled != before.Settings.ProxyEnabled {
t.Fatal("failed change committed")
}
}
func TestControllerAuthFailureDoesNotStageConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
os.WriteFile(path, []byte("previous"), 0600)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", 401) }))
defer server.Close()
core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()}
if core.Apply(context.Background(), []byte("candidate")) == nil {
t.Fatal("controller auth failure was treated as stopped")
}
data, _ := os.ReadFile(path)
if string(data) != "previous" {
t.Fatal("controller auth error replaced config")
}
}
func TestCompileManualRemovalDoesNotChooseOtherNode(t *testing.T) {
s := defaultState()
s.Settings.SelectedNode = "removed"
s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{{"name": "other", "type": "http", "server": "localhost", "port": 8088}}}}
data, err := compileConfig(s, "secret")
if err != nil {
t.Fatal(err)
}
var parsed map[string]any
if err := yaml.Unmarshal(data, &parsed); err != nil {
t.Fatal(err)
}
group := parsed["proxy-groups"].([]any)[0].(map[string]any)
first := group["proxies"].([]any)[0]
if first != "REJECT" {
t.Fatal("manual node silently changed", first)
}
if parsed["external-controller"] != "127.0.0.1:9090" || parsed["profile"].(map[string]any)["store-selected"] != false {
t.Fatal("controller or selection persistence invalid")
}
s.Settings.SelectionMode = "auto"
data, _ = compileConfig(s, "secret")
yaml.Unmarshal(data, &parsed)
if parsed["proxy-groups"].([]any)[0].(map[string]any)["proxies"].([]any)[0] != AutoGroup {
t.Fatal("auto mode not selected")
}
}
func TestSettingsValidation(t *testing.T) {
valid := defaultState().Settings
if err := validateSettings(valid); err != nil {
t.Fatal(err)
}
invalid := []Settings{}
s := valid
s.Gateway = "192.168.1.1"
s.LANIP = s.Gateway
invalid = append(invalid, s)
s = valid
s.TunEnabled = true
invalid = append(invalid, s)
s = valid
s.MixedPort = 9090
invalid = append(invalid, s)
s = valid
s.Interface = "eth0; touch /tmp/x"
invalid = append(invalid, s)
s = valid
s.LANCIDR = "0.0.0.0/0"
invalid = append(invalid, s)
for _, s := range invalid {
if validateSettings(s) == nil {
t.Fatal("invalid settings accepted", s)
}
}
}
func TestStoreRestartRecovery(t *testing.T) {
dir := t.TempDir()
store, err := OpenStore(dir)
if err != nil {
t.Fatal(err)
}
s := defaultState()
s.Settings.SelectedNode = "persisted-node"
s.Subscriptions = []Subscription{{ID: randomID(), Name: "saved", URL: "https://example.com/sub", IntervalHours: 6, Enabled: true, Nodes: []map[string]any{{"name": "persisted-node", "type": "http"}}}}
if err := store.Save(s); err != nil {
t.Fatal(err)
}
store.Close()
store, err = OpenStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
loaded, err := store.Load()
if err != nil || loaded.Settings.SelectedNode != "persisted-node" || len(loaded.Subscriptions) != 1 {
t.Fatal("restart lost state", err)
}
info, _ := os.Stat(filepath.Join(dir, "clashweb.sqlite"))
if info.Mode().Perm() != 0600 {
t.Fatal("database is not private")
}
}
func TestAPINodeListDoesNotExposeCredentials(t *testing.T) {
a := newTestApp(t)
cookie, _ := loginTest(t, a)
a.state.Subscriptions = []Subscription{{ID: randomID(), Name: "private", Enabled: true, Nodes: []map[string]any{{"name": "id/inline/node", "type": "ss", "password": "do-not-expose", "server": "private.example"}}}}
w := request(t, a, "GET", "/api/v1/nodes", nil, cookie, "")
if w.Code != 200 || strings.Contains(w.Body.String(), "do-not-expose") || strings.Contains(w.Body.String(), "private.example") {
t.Fatal("credentials leaked", w.Body)
}
}
+235
View File
@@ -0,0 +1,235 @@
package app
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
type loginLimit struct {
Count int
Until time.Time
}
type credentials struct {
Password string `json:"password"`
Token string `json:"token"`
CurrentPassword string `json:"currentPassword"`
}
func decode(w http.ResponseWriter, r *http.Request, target any) error {
r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.New("请求内容无效或过大")
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("请求必须是单个 JSON 对象")
}
return nil
}
func jsonResponse(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
jsonResponse(w, status, map[string]string{"error": message})
}
func hashToken(token string) string {
b := sha256.Sum256([]byte(token))
return hex.EncodeToString(b[:])
}
func validPassword(password string) bool { return len(password) >= 12 && len(password) <= 72 }
func (a *App) session(r *http.Request) (string, string, error) {
cookie, err := r.Cookie("cw_session")
if err != nil {
return "", "", err
}
hash := hashToken(cookie.Value)
var csrf string
var expires int64
err = a.store.db.QueryRow("SELECT csrf,expires FROM sessions WHERE token_hash=?", hash).Scan(&csrf, &expires)
if err != nil || expires <= time.Now().Unix() {
return "", "", errors.New("会话已过期")
}
return hash, csrf, nil
}
func (a *App) newSession(w http.ResponseWriter) error {
token, csrf := randomToken(), randomToken()
expires := time.Now().Add(24 * time.Hour)
if _, err := a.store.db.Exec("INSERT INTO sessions(token_hash,csrf,expires) VALUES(?,?,?)", hashToken(token), csrf, expires.Unix()); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{Name: "cw_session", Value: token, Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 86400, Expires: expires})
jsonResponse(w, 200, map[string]any{"authenticated": true, "csrf": csrf})
return nil
}
func (a *App) protect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, csrf, err := a.session(r)
if err != nil {
writeError(w, 401, "请先登录")
return
}
if r.Method != "GET" && r.Method != "HEAD" {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(csrf)) != 1 {
writeError(w, 403, "会话校验失败,请重新登录")
return
}
}
next.ServeHTTP(w, r)
})
}
func (a *App) authStatus(w http.ResponseWriter, r *http.Request) {
password, err := a.store.Get("password")
if err != nil {
writeError(w, 500, "数据库读取失败")
return
}
_, csrf, err := a.session(r)
jsonResponse(w, 200, map[string]any{"initialized": password != "", "authenticated": err == nil, "csrf": csrf})
}
func (a *App) setup(w http.ResponseWriter, r *http.Request) {
a.authMu.Lock()
defer a.authMu.Unlock()
password, err := a.store.Get("password")
if err != nil {
writeError(w, 500, "数据库读取失败")
return
}
if password != "" {
writeError(w, 409, "管理员已经设置")
return
}
var input credentials
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
if subtle.ConstantTimeCompare([]byte(input.Token), []byte(a.setupToken)) != 1 {
writeError(w, 403, "首次设置令牌不正确,请查看服务启动日志")
return
}
if !validPassword(input.Password) {
writeError(w, 400, "密码需为 12–72 字节")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, 500, "密码处理失败")
return
}
if err := a.store.Set("password", string(hash)); err != nil {
writeError(w, 500, "保存失败")
return
}
_ = a.store.Set("setup_token", "")
a.setupToken = ""
if err := a.newSession(w); err != nil {
writeError(w, 500, "会话创建失败")
}
}
func (a *App) login(w http.ResponseWriter, r *http.Request) {
a.authMu.Lock()
defer a.authMu.Unlock()
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limit := a.failures[ip]
if limit.Count >= 5 && time.Now().Before(limit.Until) {
writeError(w, 429, "登录尝试过多,请 5 分钟后重试")
return
}
if time.Now().After(limit.Until) {
limit = loginLimit{}
}
var input credentials
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
password, err := a.store.Get("password")
if err != nil {
writeError(w, 500, "读取失败")
return
}
if password == "" || bcrypt.CompareHashAndPassword([]byte(password), []byte(input.Password)) != nil {
if len(a.failures) > 1024 {
a.failures = map[string]loginLimit{}
}
limit.Count++
limit.Until = time.Now().Add(5 * time.Minute)
a.failures[ip] = limit
writeError(w, 401, "密码不正确")
return
}
delete(a.failures, ip)
if err := a.newSession(w); err != nil {
writeError(w, 500, "会话创建失败")
}
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
hash, _, _ := a.session(r)
_, err := a.store.db.Exec("DELETE FROM sessions WHERE token_hash=?", hash)
if err != nil {
writeError(w, 500, "退出失败")
return
}
http.SetCookie(w, &http.Cookie{Name: "cw_session", Path: "/", HttpOnly: true, Secure: a.secureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
jsonResponse(w, 200, map[string]bool{"ok": true})
}
func (a *App) password(w http.ResponseWriter, r *http.Request) {
a.authMu.Lock()
defer a.authMu.Unlock()
var input credentials
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
old, err := a.store.Get("password")
if err != nil || bcrypt.CompareHashAndPassword([]byte(old), []byte(input.CurrentPassword)) != nil {
writeError(w, 400, "当前密码不正确")
return
}
if !validPassword(input.Password) {
writeError(w, 400, "新密码需为 12–72 字节")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, 500, "密码处理失败")
return
}
tx, err := a.store.db.Begin()
if err != nil {
writeError(w, 500, "保存失败")
return
}
defer tx.Rollback()
if _, err = tx.Exec("UPDATE kv SET value=? WHERE key='password'", string(hash)); err == nil {
_, err = tx.Exec("DELETE FROM sessions")
}
if err != nil || tx.Commit() != nil {
writeError(w, 500, "保存失败")
return
}
if err := a.newSession(w); err != nil {
writeError(w, 500, "会话创建失败")
}
}
+211
View File
@@ -0,0 +1,211 @@
package app
import (
"context"
"crypto/x509"
"errors"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"time"
)
type connectivitySite struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Category string `json:"category"`
}
var connectivityCatalog = []connectivitySite{
{"apple", "Apple", "https://www.apple.com/", "官方网站"},
{"github", "GitHub", "https://github.com/", "开发工具"},
{"google", "Google", "https://www.google.com/", "搜索"},
{"netflix", "Netflix", "https://www.netflix.com/", "流媒体"},
{"youtube", "YouTube", "https://www.youtube.com/", "流媒体"},
{"chatgpt", "ChatGPT", "https://chatgpt.com/", "AI 服务"},
{"claude", "Claude", "https://claude.ai/", "AI 服务"},
{"grok", "Grok", "https://grok.com/", "AI 服务"},
{"dockerhub", "Docker Hub", "https://hub.docker.com/", "开发工具"},
}
type connectivityResult struct {
SiteID string `json:"siteId"`
Status string `json:"status"`
Detail string `json:"detail"`
HTTPStatus int `json:"httpStatus"`
DurationMS int64 `json:"durationMS"`
TLSVerified bool `json:"tlsVerified"`
CheckedAt time.Time `json:"checkedAt"`
}
func (a *App) connectivitySites(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, 200, connectivityCatalog)
}
func (a *App) testConnectivity(w http.ResponseWriter, r *http.Request) {
var input struct {
SiteID string `json:"siteId"`
}
if err := decode(w, r, &input); err != nil {
writeError(w, 400, err.Error())
return
}
var site *connectivitySite
for i := range connectivityCatalog {
if connectivityCatalog[i].ID == input.SiteID {
site = &connectivityCatalog[i]
break
}
}
if site == nil {
writeError(w, 400, "请选择列表中的测试网站")
return
}
select {
case a.connectivitySlots <- struct{}{}:
defer func() { <-a.connectivitySlots }()
default:
writeError(w, 429, "最多同时测试 3 个网站,请稍后重试")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var version map[string]any
coreCtx, coreCancel := context.WithTimeout(ctx, 2*time.Second)
err := a.core.Request(coreCtx, "GET", "/version", nil, &version)
coreCancel()
if err != nil {
jsonResponse(w, 200, connectivityResult{SiteID: site.ID, Status: "failed", Detail: "Mihomo 内核未连接,请先启动内核", CheckedAt: time.Now().UTC()})
return
}
a.mu.RLock()
settings := a.state.Settings
a.mu.RUnlock()
client := connectivityClient(settings)
defer client.CloseIdleConnections()
jsonResponse(w, 200, probeConnectivity(ctx, client, *site))
}
func connectivityClient(settings Settings) *http.Client {
host := settings.LANIP
if host == "" {
host = "127.0.0.1"
}
proxy := &url.URL{Scheme: "http", Host: net.JoinHostPort(host, strconv.Itoa(settings.MixedPort))}
transport := http.DefaultTransport.(*http.Transport).Clone()
// Always use this VM's Mihomo, even when the service has HTTP_PROXY/NO_PROXY.
transport.Proxy = http.ProxyURL(proxy)
transport.TLSHandshakeTimeout = 8 * time.Second
return &http.Client{Transport: transport, Timeout: 12 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("网站重定向次数过多")
}
if req.URL.Scheme != "https" || req.URL.User != nil {
return errors.New("网站重定向到非 HTTPS 地址")
}
if !connectivityRedirectAllowed(req.URL.Hostname(), via[0].URL.Hostname()) {
return errors.New("网站重定向到测试范围外的域名")
}
return nil
}}
}
func connectivityRedirectAllowed(host, original string) bool {
// Only follow the selected site's domains, never a redirect into the LAN.
host, original = strings.ToLower(host), strings.ToLower(original)
for _, domain := range []string{"apple.com", "github.com", "google.com", "netflix.com", "youtube.com", "chatgpt.com", "claude.ai", "grok.com", "docker.com"} {
if original == domain || strings.HasSuffix(original, "."+domain) {
allowed := []string{domain}
if domain == "claude.ai" {
allowed = append(allowed, "claude.com")
}
if domain == "chatgpt.com" {
allowed = append(allowed, "openai.com")
}
for _, candidate := range allowed {
if host == candidate || strings.HasSuffix(host, "."+candidate) {
return true
}
}
return false
}
}
return false
}
func probeConnectivity(ctx context.Context, client *http.Client, site connectivitySite) (result connectivityResult) {
start := time.Now()
result = connectivityResult{SiteID: site.ID, Status: "failed"}
defer func() { result.DurationMS = time.Since(start).Milliseconds(); result.CheckedAt = time.Now().UTC() }()
req, err := http.NewRequestWithContext(ctx, "GET", site.URL, nil)
if err != nil {
result.Detail = "测试地址无效"
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; ClashWeb/1.0; connectivity-check)")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := client.Do(req)
if err != nil {
if resp != nil {
// net/http retains the last response when a redirect is refused.
result.HTTPStatus = resp.StatusCode
result.TLSVerified = resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0
result.Status, result.Detail = "http_error", "网站已响应,但重定向超出测试范围或次数限制"
return
}
result.Detail = connectivityError(err)
return
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
result.TLSVerified = resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
result.Detail = "网站已响应,但读取响应失败或超时"
return
}
sample := strings.ToLower(string(body))
challenge := resp.Header.Get("Cf-Mitigated") == "challenge" || strings.Contains(sample, "cf-chl-") || (strings.Contains(sample, "just a moment") && strings.Contains(sample, "cloudflare"))
switch {
case challenge:
result.Status, result.Detail = "challenge", "已连接网站,网站要求浏览器安全验证"
case resp.StatusCode >= 200 && resp.StatusCode < 300:
result.Status, result.Detail = "reachable", "HTTPS 主页响应正常"
case resp.StatusCode == 403 || resp.StatusCode == 451:
result.Status, result.Detail = "restricted", "网站拒绝访问,可能有出口地区或访问策略限制"
case resp.StatusCode == 429:
result.Status, result.Detail = "restricted", "网站请求频率受限,请稍后重试"
default:
result.Status, result.Detail = "http_error", "已连接网站,但返回异常 HTTP 状态"
}
return
}
func connectivityError(err error) string {
var authority x509.UnknownAuthorityError
var certificate x509.CertificateInvalidError
var hostname x509.HostnameError
var dns *net.DNSError
var network net.Error
switch {
case errors.Is(err, context.Canceled):
return "测试已取消"
case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout():
return "连接超时,请检查当前节点和网络"
case errors.As(err, &authority):
return "TLS 证书不受信任,请检查 VM 的 CA 证书包"
case errors.As(err, &certificate), errors.As(err, &hostname):
return "TLS 证书校验失败,请检查 VM 时间及网站证书"
case errors.As(err, &dns):
return "域名解析失败,请检查 VM 或 Mihomo 的 DNS"
case errors.Is(err, syscall.ECONNREFUSED):
return "连接被拒绝,请检查 Mihomo 代理端口与网站状态"
default:
return "连接失败,请检查当前节点、网络和网站访问策略"
}
}
+180
View File
@@ -0,0 +1,180 @@
package app
import (
"context"
"crypto/x509"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestConnectivityResponses(t *testing.T) {
for _, test := range []struct {
name string
code int
header, body, status string
}{
{"success", 200, "", "<html>Welcome</html>", "reachable"},
{"header challenge", 403, "challenge", "verify", "challenge"},
{"body challenge", 200, "", "<title>Just a moment</title> Cloudflare cf-chl-test", "challenge"},
{"denied", 403, "", "denied", "restricted"},
{"region", 451, "", "region", "restricted"},
{"limited", 429, "", "rate limited", "restricted"},
{"server failure", 503, "", "unavailable", "http_error"},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("not a GET probe")
}
w.Header().Set("Cf-Mitigated", test.header)
w.WriteHeader(test.code)
io.WriteString(w, test.body)
}))
defer server.Close()
result := probeConnectivity(context.Background(), server.Client(), connectivitySite{ID: "fixture", URL: server.URL})
if result.Status != test.status || result.HTTPStatus != test.code || !result.TLSVerified || result.CheckedAt.IsZero() {
t.Fatalf("unexpected result: %+v", result)
}
})
}
}
func TestConnectivityUsesExplicitProxyAndVerifiesTLS(t *testing.T) {
origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
defer origin.Close()
var connects atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "CONNECT" {
t.Error("expected CONNECT")
w.WriteHeader(400)
return
}
connects.Add(1)
upstream, err := net.Dial("tcp", r.Host)
if err != nil {
w.WriteHeader(502)
return
}
connection, buffered, err := w.(http.Hijacker).Hijack()
if err != nil {
upstream.Close()
return
}
defer connection.Close()
defer upstream.Close()
buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
buffered.Flush()
go io.Copy(upstream, buffered)
io.Copy(connection, upstream)
}))
defer proxy.Close()
u, _ := url.Parse(proxy.URL)
port, _ := strconv.Atoi(u.Port())
t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
t.Setenv("NO_PROXY", "*")
client := connectivityClient(Settings{MixedPort: port})
defer client.CloseIdleConnections()
transport := client.Transport.(*http.Transport)
transport.TLSClientConfig = origin.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
result := probeConnectivity(context.Background(), client, connectivitySite{ID: "fixture", URL: origin.URL})
if connects.Load() != 1 || result.Status != "reachable" || !result.TLSVerified {
t.Fatalf("proxy/TLS regression: %+v, connects=%d", result, connects.Load())
}
client.CloseIdleConnections()
transport.TLSClientConfig.RootCAs = x509.NewCertPool()
result = probeConnectivity(context.Background(), client, connectivitySite{ID: "fixture", URL: origin.URL})
if result.Status != "failed" || result.TLSVerified || !strings.Contains(result.Detail, "证书") {
t.Fatalf("untrusted TLS accepted: %+v", result)
}
}
func TestConnectivityTimeoutCancellationAndRedirects(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
defer server.Close()
client := server.Client()
client.Timeout = 30 * time.Millisecond
result := probeConnectivity(context.Background(), client, connectivitySite{URL: server.URL})
if result.Status != "failed" || !strings.Contains(result.Detail, "超时") {
t.Fatalf("timeout: %+v", result)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
result = probeConnectivity(ctx, client, connectivitySite{URL: server.URL})
if !strings.Contains(result.Detail, "取消") {
t.Fatalf("cancel: %+v", result)
}
redirectClient := connectivityClient(Settings{MixedPort: 7890})
defer redirectClient.CloseIdleConnections()
original, _ := http.NewRequest("GET", "https://chatgpt.com/", nil)
for _, target := range []string{"http://chatgpt.com/", "https://127.0.0.1/", "https://evilchatgpt.com/", "https://chatgpt.com.evil.example/", "https://user:pass@chatgpt.com/"} {
req, _ := http.NewRequest("GET", target, nil)
if redirectClient.CheckRedirect(req, []*http.Request{original}) == nil {
t.Fatalf("unsafe redirect accepted: %s", target)
}
}
req, _ := http.NewRequest("GET", "https://auth.openai.com/", nil)
if err := redirectClient.CheckRedirect(req, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if err := redirectClient.CheckRedirect(req, []*http.Request{original, original, original, original, original}); err == nil {
t.Fatal("unbounded redirects")
}
redirectServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "http://127.0.0.1/private", 302) }))
defer redirectServer.Close()
client = redirectServer.Client()
client.CheckRedirect = redirectClient.CheckRedirect
result = probeConnectivity(context.Background(), client, connectivitySite{URL: redirectServer.URL})
if result.Status != "http_error" || result.HTTPStatus != 302 || !result.TLSVerified {
t.Fatalf("redirect response discarded: %+v", result)
}
}
func TestConnectivityAPIAuthCatalogAndLimits(t *testing.T) {
a := newTestApp(t)
path := "/api/v1/system/connectivity"
if w := request(t, a, "GET", path, nil, nil, ""); w.Code != 401 {
t.Fatal("unauthenticated test access")
}
cookie, csrf := loginTest(t, a)
w := request(t, a, "GET", path, nil, cookie, "")
var sites []connectivitySite
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &sites) != nil || len(sites) != 9 {
t.Fatal("catalog missing")
}
if w := request(t, a, "POST", path+"/test", map[string]string{"siteId": "google"}, cookie, ""); w.Code != 403 {
t.Fatal("CSRF missing")
}
if w := request(t, a, "POST", path+"/test", map[string]string{"siteId": "https://127.0.0.1/"}, cookie, csrf); w.Code != 400 {
t.Fatal("arbitrary target allowed")
}
for i := 0; i < 3; i++ {
a.connectivitySlots <- struct{}{}
}
if w := request(t, a, "POST", path+"/test", map[string]string{"siteId": "google"}, cookie, csrf); w.Code != 429 {
t.Fatal("concurrency limit missing")
}
for i := 0; i < 3; i++ {
<-a.connectivitySlots
}
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) }))
defer core.Close()
a.core.URL = core.URL
w = request(t, a, "POST", path+"/test", map[string]string{"siteId": "google"}, cookie, csrf)
var result connectivityResult
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.Status != "failed" || !strings.Contains(result.Detail, "内核") {
t.Fatal("offline core not explained", w.Body)
}
if len(a.connectivitySlots) != 0 {
t.Fatal("slot leaked")
}
}
+358
View File
@@ -0,0 +1,358 @@
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
}
+183
View File
@@ -0,0 +1,183 @@
package app
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/net/proxy"
"gopkg.in/yaml.v3"
)
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
return port
}
// This test starts a real core with TUN disabled and routes test traffic through local HTTP proxy nodes.
// It never modifies the development machine's routes, forwarding, DNS, or DHCP.
func TestRealMihomoProxyAndNodeSelection(t *testing.T) {
binary := os.Getenv("CLASHWEB_TEST_MIHOMO_BIN")
rulesDir := os.Getenv("CLASHWEB_TEST_RULES_DIR")
if binary == "" || rulesDir == "" {
t.Skip("set CLASHWEB_TEST_MIHOMO_BIN and CLASHWEB_TEST_RULES_DIR to run real-core integration")
}
binary, _ = filepath.Abs(binary)
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "rules"), 0750)
for _, file := range []string{"cn-domain.mrs", "cn-ip.mrs"} {
data, err := os.ReadFile(filepath.Join(rulesDir, file))
if err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "rules", file), data, 0640)
}
makeProxy := func(label string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
fmt.Fprint(w, label)
return
}
connection, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer connection.Close()
io.WriteString(buffer, "HTTP/1.1 200 Connection Established\r\n\r\n")
buffer.Flush()
request, err := http.ReadRequest(buffer.Reader)
if err != nil {
return
}
request.Body.Close()
time.Sleep(30 * time.Millisecond)
fmt.Fprintf(buffer, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(label), label)
buffer.Flush()
}))
}
nodeA, nodeB := makeProxy("node-a"), makeProxy("node-b")
defer nodeA.Close()
defer nodeB.Close()
makeNode := func(name string, server *httptest.Server) map[string]any {
u, _ := url.Parse(server.URL)
_, p, _ := net.SplitHostPort(u.Host)
port, _ := strconv.Atoi(p)
return map[string]any{"name": name, "type": "http", "server": "127.0.0.1", "port": port}
}
s := defaultState()
s.Settings.SelectedNode = "node-A"
s.Settings.MixedPort = freePort(t)
s.Settings.DNSPort = freePort(t)
s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{makeNode("node-A", nodeA), makeNode("node-B", nodeB)}}}
data, _ := compileConfig(s, "integration-secret")
var config map[string]any
yaml.Unmarshal(data, &config)
controller := freePort(t)
config["external-controller"] = fmt.Sprintf("127.0.0.1:%d", controller)
data, _ = yaml.Marshal(config)
core := Core{Binary: binary, Dir: dir, URL: fmt.Sprintf("http://127.0.0.1:%d", controller), Secret: "integration-secret", HTTP: &http.Client{Timeout: 3 * time.Second}}
if err := core.Validate(context.Background(), data); err != nil {
t.Fatal(err)
}
if err := atomicWrite(filepath.Join(dir, "config.yaml"), data); err != nil {
t.Fatal(err)
}
command := exec.Command(binary, "-d", dir, "-f", filepath.Join(dir, "config.yaml"))
var output strings.Builder
command.Stdout = &output
command.Stderr = &output
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() {
command.Process.Signal(os.Interrupt)
command.Wait()
if t.Failed() {
t.Log(output.String())
}
}()
ready := false
for i := 0; i < 50; i++ {
var version map[string]any
if core.Request(context.Background(), "GET", "/version", nil, &version) == nil {
ready = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !ready {
t.Fatal("core did not start")
}
var latency struct {
Delay int `json:"delay"`
}
if err := core.Request(context.Background(), "GET", "/proxies/node-A/delay?url="+url.QueryEscape("http://198.51.100.42/health")+"&timeout=2000", nil, &latency); err != nil || latency.Delay <= 0 {
t.Fatalf("node latency check failed: delay=%d err=%v", latency.Delay, err)
}
u, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", s.Settings.MixedPort))
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}, Timeout: 5 * time.Second}
get := func(target, want string) {
t.Helper()
resp, err := client.Get(target)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if string(b) != want {
t.Fatalf("got %q, want %q", b, want)
}
}
get("http://198.51.100.42/test", "node-a")
if err := core.Request(context.Background(), "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": "node-B"}, nil); err != nil {
t.Fatal(err)
}
get("http://198.51.100.42/test", "node-b")
direct := makeProxy("direct-lan")
defer direct.Close()
get(direct.URL, "direct-lan")
dialer, err := proxy.SOCKS5("tcp", fmt.Sprintf("127.0.0.1:%d", s.Settings.MixedPort), nil, proxy.Direct)
if err != nil {
t.Fatal(err)
}
socksClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, address)
}}, Timeout: 5 * time.Second}
resp, err := socksClient.Get("http://198.51.100.42/socks")
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if string(b) != "node-b" {
t.Fatal("SOCKS selection not applied", string(b))
}
if err := core.Request(context.Background(), "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": "REJECT"}, nil); err != nil {
t.Fatal(err)
}
resp, err = client.Get("http://198.51.100.42/reject")
if err == nil {
resp.Body.Close()
if resp.StatusCode < 400 {
t.Fatal("REJECT fell back to a different node")
}
}
get(direct.URL, "direct-lan")
}
+74
View File
@@ -0,0 +1,74 @@
package app
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type Store struct{ db *sql.DB }
func OpenStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
p := filepath.Join(dir, "clashweb.sqlite")
f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
f.Close()
db, err := sql.Open("sqlite", p)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, csrf TEXT NOT NULL, expires INTEGER NOT NULL);
PRAGMA user_version=1;`)
if err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Get(key string) (string, error) {
var value string
err := s.db.QueryRow("SELECT value FROM kv WHERE key=?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func (s *Store) Set(key, value string) error {
_, err := s.db.Exec("INSERT INTO kv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", key, value)
return err
}
func (s *Store) Load() (State, error) {
text, err := s.Get("state")
if err != nil {
return State{}, err
}
if text == "" {
return defaultState(), nil
}
var state State
if err := json.Unmarshal([]byte(text), &state); err != nil {
return State{}, fmt.Errorf("数据库中的配置损坏: %w", err)
}
return state, validateState(state)
}
func (s *Store) Save(state State) error {
b, err := json.Marshal(state)
if err != nil {
return err
}
return s.Set("state", string(b))
}
+215
View File
@@ -0,0 +1,215 @@
package app
import (
"context"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"regexp"
"sort"
"strings"
"syscall"
"time"
"gopkg.in/yaml.v3"
)
const maxDownload = 8 << 20
type subscriptionDocument struct {
Proxies []map[string]any `yaml:"proxies"`
Providers map[string]struct {
Type string `yaml:"type"`
URL string `yaml:"url"`
Payload []map[string]any `yaml:"payload"`
Filter string `yaml:"filter"`
Exclude string `yaml:"exclude-filter"`
Override map[string]any `yaml:"override"`
Header map[string][]string `yaml:"header"`
} `yaml:"proxy-providers"`
}
func fetchSubscription(ctx context.Context, client *http.Client, raw string, headers map[string][]string) ([]byte, string, error) {
if err := validHTTPURL(raw); err != nil {
return nil, "", err
}
req, err := http.NewRequestWithContext(ctx, "GET", raw, nil)
if err != nil {
return nil, "", errors.New("无效的订阅地址")
}
req.Header.Set("User-Agent", "clash.meta/ClashWeb")
for k, values := range headers {
for _, v := range values {
req.Header.Add(k, v)
}
}
resp, err := client.Do(req)
if err != nil {
return nil, "", subscriptionDownloadError(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, "", fmt.Errorf("订阅下载失败(HTTP %d)", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))
if err != nil {
return nil, "", errors.New("订阅读取失败")
}
if len(b) > maxDownload {
return nil, "", errors.New("订阅文件超过 8 MiB 限制")
}
return b, resp.Header.Get("Subscription-Userinfo"), nil
}
func subscriptionDownloadError(err error) error {
// Do not return url.Error.String(): it includes the private subscription URL.
var authority x509.UnknownAuthorityError
var hostname x509.HostnameError
var certificate x509.CertificateInvalidError
var dns *net.DNSError
var network net.Error
switch {
case errors.As(err, &authority):
return errors.New("订阅 TLS 证书不受信任,请检查 VM 的 CA 证书包;若站点使用自签名证书,需修复其信任配置")
case errors.As(err, &hostname):
return errors.New("订阅 TLS 证书与域名不匹配,请核对订阅地址")
case errors.As(err, &certificate):
return errors.New("订阅 TLS 证书校验失败,请检查 VM 时间和站点证书有效期")
case errors.As(err, &dns):
return errors.New("订阅域名解析失败,请检查 VM 的系统 DNS 配置")
case errors.Is(err, context.Canceled):
return errors.New("订阅下载已取消")
case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout():
return errors.New("订阅下载超时,请检查 VM 到订阅服务器的网络或下载代理")
case errors.Is(err, syscall.ECONNREFUSED):
return errors.New("订阅连接被拒绝,请检查服务器或下载代理端口")
default:
return errors.New("订阅连接失败,请检查 VM 的网络、TLS 和下载代理配置")
}
}
func parseDocument(data []byte) (subscriptionDocument, error) {
var doc subscriptionDocument
if err := yaml.Unmarshal(data, &doc); err != nil {
return doc, errors.New("订阅不是有效的 Clash/Mihomo YAML;不支持 Base64 订阅")
}
if len(doc.Proxies) == 0 && len(doc.Providers) == 0 {
return doc, errors.New("订阅中未发现 proxies 或 proxy-providers")
}
return doc, nil
}
func loadSubscription(ctx context.Context, sub Subscription, client *http.Client) ([]map[string]any, string, error) {
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
b, usage, err := fetchSubscription(ctx, client, sub.URL, nil)
if err != nil {
return nil, "", err
}
doc, err := parseDocument(b)
if err != nil {
return nil, "", err
}
type batch struct {
source string
nodes []map[string]any
}
batches := []batch{{"inline", doc.Proxies}}
keys := []string{}
for key := range doc.Providers {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) > 20 {
return nil, "", errors.New("单个订阅最多允许 20 个节点提供者")
}
for _, key := range keys {
provider := doc.Providers[key]
nodes := provider.Payload
if provider.Type == "http" {
data, _, err := fetchSubscription(ctx, client, provider.URL, provider.Header)
if err != nil {
return nil, "", fmt.Errorf("节点提供者下载失败: %w", err)
}
var p subscriptionDocument
if err := yaml.Unmarshal(data, &p); err != nil {
return nil, "", errors.New("节点提供者返回了无效 YAML")
}
nodes = p.Proxies
} else if provider.Type != "inline" {
return nil, "", errors.New("仅支持 HTTP 和 inline 节点提供者,不读取订阅指定的本地文件")
}
var filter, exclude *regexp.Regexp
if provider.Filter != "" {
filter, err = regexp.Compile(provider.Filter)
if err != nil {
return nil, "", errors.New("节点提供者筛选表达式无效")
}
}
if provider.Exclude != "" {
exclude, err = regexp.Compile(provider.Exclude)
if err != nil {
return nil, "", errors.New("节点提供者排除表达式无效")
}
}
filtered := []map[string]any{}
for _, node := range nodes {
name, _ := node["name"].(string)
if filter != nil && !filter.MatchString(name) {
continue
}
if exclude != nil && exclude.MatchString(name) {
continue
}
for k, v := range provider.Override {
switch k {
case "additional-prefix", "additional-suffix":
default:
node[k] = v
}
}
prefix, _ := provider.Override["additional-prefix"].(string)
suffix, _ := provider.Override["additional-suffix"].(string)
node["name"] = prefix + name + suffix
filtered = append(filtered, node)
}
batches = append(batches, batch{key, filtered})
}
result := []map[string]any{}
for _, batch := range batches {
renames := map[string]string{}
for _, node := range batch.nodes {
name, ok := node["name"].(string)
kind, _ := node["type"].(string)
if !ok || strings.TrimSpace(name) == "" || kind == "" || len(name) > 256 {
return nil, "", errors.New("节点缺少有效名称或协议类型")
}
if _, exists := renames[name]; exists {
return nil, "", errors.New("同一个节点提供者中存在同名节点")
}
renames[name] = sub.ID + "/" + batch.source + "/" + name
}
for _, node := range batch.nodes {
if dialer, ok := node["dialer-proxy"].(string); ok && dialer != "" {
if renamed, ok := renames[dialer]; ok {
node["dialer-proxy"] = renamed
} else {
return nil, "", errors.New("节点链式代理引用了无法导入的策略组或其他节点提供者")
}
}
name := node["name"].(string)
node["name"] = renames[name]
result = append(result, node)
}
}
if len(result) == 0 {
return nil, "", errors.New("订阅中没有可用的节点")
}
if len(result) > 5000 {
return nil, "", errors.New("单个订阅最多允许 5000 个节点")
}
return result, usage, nil
}
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"context"
"crypto/x509"
"net"
"net/url"
"strings"
"syscall"
"testing"
)
func TestSubscriptionDownloadErrorsExplainCauseWithoutURL(t *testing.T) {
for _, test := range []struct {
err error
phrase string
}{
{x509.UnknownAuthorityError{}, "CA 证书包"},
{x509.HostnameError{}, "域名不匹配"},
{x509.CertificateInvalidError{}, "证书校验失败"},
{&net.DNSError{Err: "no such host", Name: "private.example"}, "域名解析失败"},
{context.DeadlineExceeded, "下载超时"},
{context.Canceled, "已取消"},
{syscall.ECONNREFUSED, "连接被拒绝"},
} {
wrapped := &url.Error{Op: "Get", URL: "https://private.example/sub?token=secret-token", Err: test.err}
message := subscriptionDownloadError(wrapped).Error()
if !strings.Contains(message, test.phrase) {
t.Fatalf("cause not explained: %s", message)
}
if strings.Contains(message, "secret-token") || strings.Contains(message, "private.example") {
t.Fatal("download error exposed subscription URL")
}
}
}
+219
View File
@@ -0,0 +1,219 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"regexp"
"strings"
"time"
)
const ProxyGroup = "CW-PROXY"
const AutoGroup = "CW-AUTO"
type Subscription struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
IntervalHours int `json:"intervalHours"`
UpdatedAt string `json:"updatedAt"`
AttemptedAt string `json:"attemptedAt"`
LastError string `json:"lastError"`
Usage string `json:"usage,omitempty"`
Nodes []map[string]any `json:"nodes"`
}
type Rule struct {
ID string `json:"id"`
Type string `json:"type"`
Value string `json:"value"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
}
type Settings struct {
ProxyEnabled bool `json:"proxyEnabled"`
TunEnabled bool `json:"tunEnabled"`
SelectionMode string `json:"selectionMode"`
SelectedNode string `json:"selectedNode"`
LANIP string `json:"lanIP"`
LANCIDR string `json:"lanCIDR"`
Gateway string `json:"gateway"`
Interface string `json:"interface"`
MixedPort int `json:"mixedPort"`
DNSPort int `json:"dnsPort"`
TestURL string `json:"testURL"`
TestTimeoutMS int `json:"testTimeoutMS"`
}
type State struct {
Subscriptions []Subscription `json:"subscriptions"`
Rules []Rule `json:"rules"`
Settings Settings `json:"settings"`
}
type Backup struct {
Version int `json:"version"`
ExportedAt string `json:"exportedAt"`
State State `json:"state"`
}
func defaultState() State {
return State{Subscriptions: []Subscription{}, Rules: []Rule{}, Settings: Settings{
ProxyEnabled: true, SelectionMode: "manual", MixedPort: 7890, DNSPort: 1053,
TestURL: "https://www.gstatic.com/generate_204", TestTimeoutMS: 5000,
}}
}
func cloneState(s State) State {
b, _ := json.Marshal(s)
var copy State
_ = json.Unmarshal(b, &copy)
return copy
}
func validHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" {
return errors.New("请输入有效的 HTTP 或 HTTPS 地址(不支持 userinfo 或 URL fragment)")
}
return nil
}
func validateSubscription(s Subscription) error {
if strings.TrimSpace(s.Name) == "" || len(s.Name) > 160 {
return errors.New("订阅名称不能为空,且不能超过 160 字节")
}
if s.IntervalHours < 1 || s.IntervalHours > 720 {
return errors.New("更新间隔应为 1–720 小时")
}
return validHTTPURL(s.URL)
}
var interfacePattern = regexp.MustCompile(`^[a-zA-Z0-9_.:-]{1,15}$`)
var domainPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$`)
func validateSettings(s Settings) error {
if s.SelectionMode != "manual" && s.SelectionMode != "auto" {
return errors.New("无效的节点选择模式")
}
if s.MixedPort < 1024 || s.MixedPort > 65535 || s.DNSPort < 1 || s.DNSPort > 65535 || s.MixedPort == s.DNSPort || s.MixedPort == 9090 || s.DNSPort == 9090 || s.MixedPort == 8080 || s.DNSPort == 8080 {
return errors.New("代理端口应为 1024–65535,DNS 端口为 1–65535,且不能与管理端口或控制端口冲突")
}
if s.TestTimeoutMS < 1000 || s.TestTimeoutMS > 15000 {
return errors.New("测速超时应为 1000–15000 毫秒")
}
if err := validHTTPURL(s.TestURL); err != nil {
return err
}
if s.Interface != "" && !interfacePattern.MatchString(s.Interface) {
return errors.New("无效的网卡名称")
}
for label, raw := range map[string]string{"VM 地址": s.LANIP, "上游网关": s.Gateway} {
if raw != "" {
ip, err := netip.ParseAddr(raw)
if err != nil || !ip.Is4() || !ip.IsPrivate() {
return fmt.Errorf("%s应为内网 IPv4 地址", label)
}
}
}
if s.LANCIDR != "" {
prefix, err := netip.ParsePrefix(s.LANCIDR)
if err != nil || !prefix.Addr().Is4() || !prefix.Addr().IsPrivate() || prefix.Bits() < 8 || prefix.Bits() > 30 {
return errors.New("请输入有效的内网 IPv4 网段,例如 192.168.1.0/24")
}
if s.LANIP != "" {
ip, _ := netip.ParseAddr(s.LANIP)
if !prefix.Contains(ip) {
return errors.New("VM 地址不在内网网段内")
}
}
if s.Gateway != "" {
ip, _ := netip.ParseAddr(s.Gateway)
if !prefix.Contains(ip) {
return errors.New("上游网关不在内网网段内")
}
}
}
if s.LANIP != "" && s.LANIP == s.Gateway {
return errors.New("VM 的上游网关必须指向原路由器,不能指向自己")
}
if s.TunEnabled && (s.LANIP == "" || s.LANCIDR == "" || s.Gateway == "" || s.Interface == "" || s.DNSPort != 53) {
return errors.New("透明代理需要填写 VM 地址、网段、上游网关、网卡,并把 DNS 端口设为 53;先完成 Linux 网络部署步骤")
}
return nil
}
func validateRule(r Rule) error {
if r.Action != "DIRECT" && r.Action != "PROXY" {
return errors.New("规则动作应为直连或代理")
}
if r.Type == "IP-CIDR" {
p, err := netip.ParsePrefix(r.Value)
if err != nil || !p.Addr().Is4() {
return errors.New("请输入有效的 IPv4 网段")
}
} else if r.Type == "DOMAIN" || r.Type == "DOMAIN-SUFFIX" {
if len(r.Value) > 253 || !domainPattern.MatchString(r.Value) || strings.Contains(r.Value, "..") {
return errors.New("请输入域名,不要包含协议、端口或路径")
}
} else {
return errors.New("不支持此规则类型")
}
return nil
}
func validateState(s State) error {
if len(s.Subscriptions) > 50 || len(s.Rules) > 1000 {
return errors.New("最多允许 50 个订阅、1000 条规则")
}
if err := validateSettings(s.Settings); err != nil {
return err
}
ids := map[string]bool{}
for _, sub := range s.Subscriptions {
if !validID(sub.ID) || ids[sub.ID] {
return errors.New("订阅 ID 无效或重复")
}
ids[sub.ID] = true
if err := validateSubscription(sub); err != nil {
return err
}
if len(sub.Nodes) > 5000 {
return errors.New("单个订阅节点过多")
}
}
ids = map[string]bool{}
for _, rule := range s.Rules {
if !validID(rule.ID) || ids[rule.ID] {
return errors.New("规则 ID 无效或重复")
}
ids[rule.ID] = true
if err := validateRule(rule); err != nil {
return err
}
}
return nil
}
func validID(s string) bool { return regexp.MustCompile(`^[a-f0-9]{24}$`).MatchString(s) }
func now() string { return time.Now().UTC().Format(time.RFC3339) }
func interfaceExists(name string, ip string) bool {
i, err := net.InterfaceByName(name)
if err != nil {
return false
}
addresses, _ := i.Addrs()
for _, a := range addresses {
if strings.Split(a.String(), "/")[0] == ip {
return true
}
}
return false
}
+125
View File
@@ -0,0 +1,125 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
const coreReleaseEndpoint = "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest"
var stableVersionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`)
type coreRelease struct {
Tag string `json:"tag_name"`
Published string `json:"published_at"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
func stableVersion(version string) ([3]int, bool) {
var parts [3]int
match := stableVersionPattern.FindStringSubmatch(strings.TrimSpace(version))
if len(match) != 4 {
return parts, false
}
for i := range parts {
n, err := strconv.Atoi(match[i+1])
if err != nil {
return parts, false
}
parts[i] = n
}
return parts, true
}
func newerStableVersion(current, latest string) *bool {
a, currentOK := stableVersion(current)
b, latestOK := stableVersion(latest)
if !currentOK || !latestOK {
return nil
}
newer := false
for i := range a {
if b[i] != a[i] {
newer = b[i] > a[i]
break
}
}
return &newer
}
func fetchCoreRelease(ctx context.Context, client *http.Client) (*coreRelease, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, coreReleaseEndpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ClashWeb-update-check")
resp, err := client.Do(req)
if err != nil {
return nil, errors.New("无法连接 GitHub,请检查网络后重试")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
return nil, errors.New("GitHub 更新检查暂时受限,请稍后重试")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("更新检查失败(HTTP %d)", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, (2<<20)+1))
if err != nil || len(data) > 2<<20 {
return nil, errors.New("版本信息读取失败")
}
var release coreRelease
if err := json.Unmarshal(data, &release); err != nil {
return nil, errors.New("版本信息格式错误")
}
if _, ok := stableVersion(release.Tag); !ok || release.Draft || release.Prerelease {
return nil, errors.New("未获取到有效的 Mihomo 稳定版本")
}
return &release, nil
}
func (a *App) latestCoreRelease(ctx context.Context) (*coreRelease, time.Time, error) {
a.updatesMu.Lock()
defer a.updatesMu.Unlock()
if a.updateCache != nil && time.Since(a.updateTime) < 10*time.Minute {
return a.updateCache, a.updateTime, nil
}
release, err := fetchCoreRelease(ctx, a.download)
if err != nil {
return nil, time.Time{}, err
}
a.updateCache, a.updateTime = release, time.Now()
return release, a.updateTime, nil
}
func (a *App) coreUpdates(w http.ResponseWriter, r *http.Request) {
release, checked, err := a.latestCoreRelease(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
var version struct {
Version string `json:"version"`
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
_ = a.core.Request(ctx, http.MethodGet, "/version", nil, &version)
jsonResponse(w, http.StatusOK, map[string]any{
"currentVersion": version.Version, "latestVersion": release.Tag,
"updateAvailable": newerStableVersion(version.Version, release.Tag),
"checkedAt": checked.Format(time.RFC3339), "publishedAt": release.Published,
"releaseURL": "https://github.com/MetaCubeX/mihomo/releases/tag/" + release.Tag,
})
}
+84
View File
@@ -0,0 +1,84 @@
package app
import (
"context"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
)
type updateTransport func(*http.Request) (*http.Response, error)
func (transport updateTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
func TestStableVersionComparison(t *testing.T) {
for _, test := range []struct {
current, latest string
known, newer bool
}{
{"v1.19.9", "v1.19.32", true, true},
{"1.19.32", "v1.19.32", true, false},
{"v2.0.0", "v1.99.99", true, false},
{"v1.19.32", "v1.20.0", true, true},
{"", "v1.19.32", false, false},
{"Meta-alpha", "v1.19.32", false, false},
{"v1.19.32", "v1.20.0-beta", false, false},
} {
result := newerStableVersion(test.current, test.latest)
if (result != nil) != test.known || result != nil && *result != test.newer {
t.Fatalf("%s -> %s: got %v", test.current, test.latest, result)
}
}
}
func TestUpdateCheckAuthCachingAndFailure(t *testing.T) {
a := newTestApp(t)
var calls atomic.Int32
a.download.Transport = updateTransport(func(r *http.Request) (*http.Response, error) {
calls.Add(1)
if r.URL.String() != coreReleaseEndpoint {
t.Fatal("unexpected update source", r.URL)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.19.33","published_at":"2026-10-03T00:00:00Z"}`)), Header: http.Header{}}, nil
})
if w := request(t, a, "GET", "/api/v1/system/updates", nil, nil, ""); w.Code != 401 || calls.Load() != 0 {
t.Fatal("unauthenticated update check must not fetch metadata")
}
cookie, _ := loginTest(t, a)
for range 2 {
w := request(t, a, "GET", "/api/v1/system/updates", nil, cookie, "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"updateAvailable":null`) || !strings.Contains(w.Body.String(), `"latestVersion":"v1.19.33"`) {
t.Fatal(w.Code, w.Body)
}
}
if calls.Load() != 1 {
t.Fatal("repeated checks must use cached release metadata")
}
a.updateCache = nil
a.download.Transport = updateTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited")), Header: http.Header{}}, nil
})
if w := request(t, a, "GET", "/api/v1/system/updates", nil, cookie, ""); w.Code != 502 {
t.Fatal(w.Code, w.Body)
}
}
func TestUpdateCheckRejectsInvalidRelease(t *testing.T) {
for _, body := range []string{
`{"tag_name":"v1.20.0-beta"}`,
`{"tag_name":"v1.20.0","draft":true}`,
`{"tag_name":"v1.20.0","prerelease":true}`,
`{broken`, strings.Repeat("x", (2<<20)+1),
} {
client := &http.Client{Transport: updateTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
})}
if _, err := fetchCoreRelease(context.Background(), client); err == nil {
t.Fatal("invalid stable release metadata accepted")
}
}
}