Initialize Clash Web source and deployment documentation
This commit is contained in:
commit
8e0fa00a73
66 files changed
+8532
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "会话创建失败")
|
||||
}
|
||||
}
|
||||
@@ -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 "连接失败,请检查当前节点、网络和网站访问策略"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ©)
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user