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,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
|
||||
}
|
||||
Reference in new issue
Block a user