Initialize Clash Web source and deployment documentation

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

No files matched your search

+358
View File
@@ -0,0 +1,358 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
"gopkg.in/yaml.v3"
)
type Core struct {
URL string
Secret string
Binary string
Dir string
Helper string
HTTP *http.Client
}
var errCoreUnavailable = errors.New("Mihomo 内核未连接,请检查服务状态和控制端口")
func (c *Core) Request(ctx context.Context, method, path string, body any, target any) error {
var payload io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
payload = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.URL+path, payload)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Secret)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, syscall.ECONNREFUSED) {
return errCoreUnavailable
}
return errors.New("Mihomo 请求超时或通信失败,请检查内核状态")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("内核请求失败(HTTP %d)", resp.StatusCode)
}
if target != nil {
return json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(target)
}
return nil
}
func (c *Core) Validate(ctx context.Context, config []byte) error {
if c.Binary == "" {
return errors.New("未找到 Mihomo,设置 CLASHWEB_MIHOMO_BIN 后才能应用配置")
}
file, err := os.CreateTemp(c.Dir, "validate-*.yaml")
if err != nil {
return err
}
defer os.Remove(file.Name())
if err := file.Chmod(0640); err != nil {
file.Close()
return err
}
if _, err = file.Write(config); err != nil {
file.Close()
return err
}
file.Close()
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, c.Binary, "-t", "-d", c.Dir, "-f", file.Name())
if _, err := cmd.CombinedOutput(); err != nil {
return errors.New("Mihomo 配置校验失败,请检查节点格式及规则资源是否已安装;原配置已保留")
}
return nil
}
func atomicWrite(path string, b []byte) error {
f, err := os.CreateTemp(filepath.Dir(path), ".config-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if err := f.Chmod(0640); err != nil {
f.Close()
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), path)
}
func (c *Core) Apply(ctx context.Context, config []byte) error {
if err := c.Validate(ctx, config); err != nil {
return err
}
var version map[string]any
probeErr := c.Request(ctx, "GET", "/version", nil, &version)
if probeErr != nil && !errors.Is(probeErr, errCoreUnavailable) {
return probeErr
}
path := filepath.Join(c.Dir, "config.yaml")
old, readErr := os.ReadFile(path)
if err := atomicWrite(path, config); err != nil {
return err
}
if probeErr != nil {
// A validated configuration can be staged while the service is stopped.
return nil
}
if err := c.Request(ctx, "PUT", "/configs?force=true", map[string]any{"path": path}, nil); err != nil {
if readErr == nil {
if writeErr := atomicWrite(path, old); writeErr != nil {
return fmt.Errorf("加载失败且恢复配置文件失败: %w", writeErr)
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if rollbackErr := c.Request(rollbackCtx, "PUT", "/configs?force=true", map[string]any{"path": path}, nil); rollbackErr != nil {
return errors.New("配置加载失败,旧文件已恢复,但内核恢复失败;请重启 Mihomo 服务")
}
} else {
_ = os.Remove(path)
}
return errors.New("配置加载失败,已恢复上一个配置")
}
return nil
}
func (c *Core) Service(ctx context.Context, action string) error {
if action != "start" && action != "stop" && action != "restart" {
return errors.New("不支持的服务操作")
}
if c.Helper == "" {
return errors.New("尚未安装服务控制辅助程序,请使用 Linux 安装脚本")
}
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/sudo", "-n", c.Helper, action)
if _, err := cmd.CombinedOutput(); err != nil {
return errors.New("服务操作失败,请检查 systemd 与辅助程序权限")
}
return nil
}
func (c *Core) Logs(ctx context.Context, send func(any) error) error {
u, err := url.Parse(c.URL)
if err != nil {
return err
}
u.Scheme = "ws"
u.Path = "/logs"
u.RawQuery = "level=info"
headers := http.Header{}
headers.Set("Authorization", "Bearer "+c.Secret)
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second, Proxy: nil}
conn, resp, err := dialer.DialContext(ctx, u.String(), headers)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
return errors.New("日志流未连接")
}
defer conn.Close()
conn.SetReadLimit(1 << 20)
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
conn.Close()
case <-done:
}
}()
for {
var item map[string]any
if err := conn.ReadJSON(&item); err != nil {
return err
}
if text, ok := item["payload"].(string); ok {
item["payload"] = redactLog(text)
}
if err := send(item); err != nil {
return err
}
}
}
func compileConfig(state State, secret string) ([]byte, error) {
settings := state.Settings
proxies := []map[string]any{}
names := []string{}
for _, sub := range state.Subscriptions {
if !sub.Enabled {
continue
}
for _, node := range sub.Nodes {
proxies = append(proxies, node)
name, _ := node["name"].(string)
names = append(names, name)
}
}
autoNames := names
if len(autoNames) == 0 {
autoNames = []string{"REJECT"}
}
manualNames := append([]string{}, names...)
manualNames = append(manualNames, AutoGroup, "REJECT")
first := "REJECT"
if settings.SelectionMode == "auto" {
first = AutoGroup
} else {
for _, n := range names {
if n == settings.SelectedNode {
first = n
break
}
}
}
ordered := []string{first}
for _, n := range manualNames {
if n != first {
ordered = append(ordered, n)
}
}
manualNames = ordered
rules := []string{"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", "IP-CIDR,172.16.0.0/12,DIRECT,no-resolve", "IP-CIDR,192.168.0.0/16,DIRECT,no-resolve", "IP-CIDR,169.254.0.0/16,DIRECT,no-resolve", "IP-CIDR,224.0.0.0/4,DIRECT,no-resolve"}
for _, rule := range state.Rules {
if !rule.Enabled {
continue
}
action := rule.Action
if action == "PROXY" {
action = ProxyGroup
}
s := rule.Type + "," + rule.Value + "," + action
if rule.Type == "IP-CIDR" {
s += ",no-resolve"
}
rules = append(rules, s)
}
// Rule sets are installed and cached locally; the core does not download them at boot.
providers := map[string]any{}
for _, r := range []struct{ Name, Path, Behavior string }{{"cn-domain", "rules/cn-domain.mrs", "domain"}, {"cn-ip", "rules/cn-ip.mrs", "ipcidr"}} {
providers[r.Name] = map[string]any{"type": "file", "path": r.Path, "behavior": r.Behavior, "format": "mrs"}
}
rules = append(rules, "RULE-SET,cn-domain,DIRECT", "RULE-SET,cn-ip,DIRECT", "MATCH,"+ProxyGroup)
mode := "rule"
if !settings.ProxyEnabled {
mode = "direct"
}
listen := "127.0.0.1"
if settings.LANIP != "" {
listen = settings.LANIP
}
config := map[string]any{
"mixed-port": settings.MixedPort, "allow-lan": settings.LANIP != "", "bind-address": listen,
"lan-allowed-ips": []string{settings.LANCIDR}, "mode": mode, "log-level": "info", "ipv6": false,
"external-controller": "127.0.0.1:9090", "secret": secret, "external-controller-cors": map[string]any{"allow-origins": []string{}, "allow-private-network": false},
"profile": map[string]any{"store-selected": false, "store-fake-ip": true}, "unified-delay": true,
"proxies": proxies, "proxy-groups": []any{
map[string]any{"name": ProxyGroup, "type": "select", "proxies": manualNames},
map[string]any{"name": AutoGroup, "type": "url-test", "proxies": autoNames, "url": settings.TestURL, "interval": 300, "tolerance": 50, "lazy": true},
}, "rule-providers": providers, "rules": rules,
"dns": map[string]any{
"enable": true, "listen": fmt.Sprintf("%s:%d", listen, settings.DNSPort), "ipv6": false, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16",
"fake-ip-filter": []string{"*.lan", "*.local", "localhost", "+.home.arpa"},
"default-nameserver": []string{"223.5.5.5", "119.29.29.29"},
"proxy-server-nameserver": []string{"https://dns.alidns.com/dns-query"},
"direct-nameserver": []string{"https://dns.alidns.com/dns-query"},
"nameserver-policy": map[string]any{"rule-set:cn-domain": []string{"https://dns.alidns.com/dns-query"}},
"nameserver": []string{"https://cloudflare-dns.com/dns-query#" + ProxyGroup},
},
"tun": map[string]any{"enable": settings.TunEnabled, "device": "cw-tun", "stack": "mixed", "auto-route": true, "auto-redirect": true, "auto-detect-interface": settings.Interface == "", "dns-hijack": []string{"any:53", "tcp://any:53"}, "mtu": 1500, "route-exclude-address": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}},
}
if settings.LANCIDR == "" {
delete(config, "lan-allowed-ips")
}
if settings.Interface != "" {
config["interface-name"] = settings.Interface
}
if !settings.ProxyEnabled {
config["dns"].(map[string]any)["nameserver"] = []string{"https://dns.alidns.com/dns-query"}
}
return yaml.Marshal(config)
}
func (c *Core) Select(ctx context.Context, state State) error {
var version map[string]any
if err := c.Request(ctx, "GET", "/version", nil, &version); err != nil {
if errors.Is(err, errCoreUnavailable) {
return nil
}
return err
}
selected := "REJECT"
if state.Settings.SelectionMode == "auto" {
selected = AutoGroup
} else {
for _, sub := range state.Subscriptions {
if !sub.Enabled {
continue
}
for _, node := range sub.Nodes {
if node["name"] == state.Settings.SelectedNode {
selected = state.Settings.SelectedNode
}
}
}
}
return c.Request(ctx, "PUT", "/proxies/"+ProxyGroup, map[string]string{"name": selected}, nil)
}
func redactLog(text string) string {
words := strings.Fields(text)
for _, word := range words {
start := strings.Index(word, "http")
if start < 0 {
continue
}
raw := strings.TrimRight(word[start:], "\"'(),;")
u, err := url.Parse(raw)
if err == nil && u.Host != "" {
u.User = nil
if u.RawQuery != "" {
u.RawQuery = "REDACTED"
}
text = strings.ReplaceAll(text, raw, u.String())
}
}
return text
}