Files

359 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}