From eb7c3c1062bbbd9c4826d9a41a1b63a40886bb5c Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 2 Jul 2026 13:40:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20WARP=20=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E5=88=86=E6=B5=81=20=E2=80=94=E2=80=94=20=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E8=B5=B0=20Cloudflare=20=E5=B9=B2=E5=87=80?= =?UTF-8?q?=E5=87=BA=E5=8F=A3(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 节点 sing-box 渲染新增可选 WARP 分流:节点本地 warp.json(默认 /warp.json)配 WARP 凭证 + 域名清单 → 渲染时注入一个 userspace WireGuard(WARP)endpoint + route(sniff 取 SNI/Host → domain_suffix 命中走 warp,其余 final=direct)。sing-box 1.11+ endpoints 语法,system=false 用户态 不依赖内核 wg 模块。 - 运营改域名只需编辑 warp.json + 重启 agent(sing-box 无热重载),即「配置的方式」。 - warp.json 不存在/enabled=false/域名空/凭证缺/坏 JSON → 一律按未启用,配置与旧 节点逐字节一致,坏配置绝不产出无法启动的 sing-box config(渲染读失败仅记日志)。 - WARP 凭证节点私有(wgcf 注册免费匿名账号),不入 git、不经控制面。 - 测试:注入 endpoint+route/无配置无 route/禁用或残缺不注入/坏 JSON 优雅退化。 Co-Authored-By: Claude Opus 4.8 --- server/internal/agentd/config.go | 7 ++ server/internal/agentd/render.go | 16 ++- server/internal/agentd/singbox.go | 10 +- server/internal/agentd/warp.go | 121 ++++++++++++++++++++++ server/internal/agentd/warp_test.go | 152 ++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 server/internal/agentd/warp.go create mode 100644 server/internal/agentd/warp_test.go diff --git a/server/internal/agentd/config.go b/server/internal/agentd/config.go index c503d21..6d455f1 100644 --- a/server/internal/agentd/config.go +++ b/server/internal/agentd/config.go @@ -54,6 +54,10 @@ type Config struct { // SingboxConfigPath is where the rendered sing-box config is written. SingboxConfigPath string + // WarpConfigPath 指向节点本地的 WARP 分流配置(默认 /warp.json)。 + // 文件不存在 = WARP 未启用。渲染时读取,支持编辑后重启 agent 生效(#29)。 + WarpConfigPath string + // DeriveKey keys the Hy2 password derivation (see DeriveHy2Password). DeriveKey string @@ -79,6 +83,9 @@ func (c Config) withDefaults() Config { if c.SingboxConfigPath == "" { c.SingboxConfigPath = DefaultSingboxCfg } + if c.WarpConfigPath == "" { + c.WarpConfigPath = filepath.Join(c.StateDir, "warp.json") + } if c.HeartbeatInterval == 0 { c.HeartbeatInterval = DefaultHeartbeatInterval } diff --git a/server/internal/agentd/render.go b/server/internal/agentd/render.go index 239e08b..ef2966d 100644 --- a/server/internal/agentd/render.go +++ b/server/internal/agentd/render.go @@ -21,13 +21,17 @@ const ( clashAPIAddr = "127.0.0.1:19090" clashAPISecret = "pangolin-local-stats" v2rayAPIAddr = "127.0.0.1:19091" + + // sing-box outbound/endpoint tags used in route rules. + directOutboundTag = "direct" + warpOutboundTag = "warp" ) -func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string) ([]byte, error) { +func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) { cfg := map[string]any{ "log": map[string]any{"level": "warn", "timestamp": true}, "inbounds": buildInbounds(creds, reality, hy2, deriveKey), - "outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}}, + "outbounds": []any{map[string]any{"type": "direct", "tag": directOutboundTag}}, "experimental": map[string]any{ "clash_api": map[string]any{ "external_controller": clashAPIAddr, @@ -42,6 +46,14 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age }, }, } + + // WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。 + // warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。 + if warp.active() { + cfg["endpoints"] = []any{warp.warpEndpoint()} + cfg["route"] = warp.warpRoute() + } + return json.MarshalIndent(cfg, "", " ") } diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index 70f5969..9cbf554 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -315,7 +315,15 @@ func (s *SingBox) RenderConfig() ([]byte, error) { hy2 := s.hy2 s.mu.Unlock() sort.Slice(creds, func(i, j int) bool { return creds[i].DpUUID < creds[j].DpUUID }) - return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey) + + // WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。 + // 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。 + warp, err := LoadWarpConfig(s.cfg.WarpConfigPath) + if err != nil { + logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err) + warp = nil + } + return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp) } // writeAndRestart renders, writes the config file and restarts sing-box. diff --git a/server/internal/agentd/warp.go b/server/internal/agentd/warp.go new file mode 100644 index 0000000..acb40ee --- /dev/null +++ b/server/internal/agentd/warp.go @@ -0,0 +1,121 @@ +package agentd + +import ( + "encoding/json" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。 +// 由节点本地文件(默认 /warp.json)提供,agent 渲染 sing-box 配置时读取: +// 存在且 enabled 且有域名 → 注入一个 WireGuard(WARP) endpoint + 域名分流 route 规则, +// 命中域名走 WARP、其余直连。运营改域名清单只需编辑该文件并重启 agent(sing-box 无热重载)。 +// +// WARP 凭证(private_key / peer_public_key / endpoint / address / reserved)由 wgcf +// 注册免费匿名 WARP 账号得到,是节点私有的,不入 git、不经控制面。 +type WarpConfig struct { + Enabled bool `json:"enabled"` + PrivateKey string `json:"private_key"` + PeerPublicKey string `json:"peer_public_key"` + Endpoint string `json:"endpoint"` // host:port,如 162.159.192.1:2408 + Address []string `json:"address"` // 本端 WARP 分配地址,如 ["172.16.0.2/32","2606:4700:110:...::/128"] + Reserved []int `json:"reserved"` // WARP client reserved 三字节(可空) + MTU int `json:"mtu"` // 缺省 1280 + Domains []string `json:"domains"` // 走 WARP 的域名后缀,如 ["reddit.com","redd.it"] +} + +// LoadWarpConfig 读取并解析 warp.json。文件不存在 → 返回 (nil, nil)(WARP 未启用, +// 不是错误)。解析失败或字段缺失才返回 error,避免坏配置静默退化。 +func LoadWarpConfig(path string) (*WarpConfig, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("agentd: read warp config %q: %w", path, err) + } + var wc WarpConfig + if err := json.Unmarshal(data, &wc); err != nil { + return nil, fmt.Errorf("agentd: parse warp config %q: %w", path, err) + } + return &wc, nil +} + +// active 报告本配置是否应真正注入分流(启用、凭证齐全、至少一个域名)。 +// 任一必需字段缺失都返回 false —— 宁可不分流(全直连)也不产出坏 sing-box 配置。 +func (wc *WarpConfig) active() bool { + if wc == nil || !wc.Enabled || len(wc.Domains) == 0 { + return false + } + if wc.PrivateKey == "" || wc.PeerPublicKey == "" || wc.Endpoint == "" || len(wc.Address) == 0 { + return false + } + host, _, err := net.SplitHostPort(wc.Endpoint) + return err == nil && host != "" +} + +// mtu 返回配置的 MTU 或缺省 1280(WARP 常用值)。 +func (wc *WarpConfig) mtu() int { + if wc.MTU > 0 { + return wc.MTU + } + return 1280 +} + +// cleanDomains 去空白/空项后返回域名清单(用于 domain_suffix)。 +func (wc *WarpConfig) cleanDomains() []string { + out := make([]string, 0, len(wc.Domains)) + for _, d := range wc.Domains { + d = strings.TrimSpace(strings.ToLower(d)) + if d != "" { + out = append(out, d) + } + } + return out +} + +// endpointHostPort 拆 Endpoint 为 host + port(active() 已校验可拆)。 +func (wc *WarpConfig) endpointHostPort() (string, int) { + host, portStr, _ := net.SplitHostPort(wc.Endpoint) + port, _ := strconv.Atoi(portStr) + return host, port +} + +// warpEndpoint 构造 sing-box 1.11+ 的 WireGuard endpoint(userspace,无需内核 wg 模块)。 +// tag = "warp",route 规则以此 tag 作 outbound。 +func (wc *WarpConfig) warpEndpoint() map[string]any { + host, port := wc.endpointHostPort() + peer := map[string]any{ + "address": host, + "port": port, + "public_key": wc.PeerPublicKey, + "allowed_ips": []string{"0.0.0.0/0", "::/0"}, + } + if len(wc.Reserved) == 3 { + peer["reserved"] = wc.Reserved + } + return map[string]any{ + "type": "wireguard", + "tag": warpOutboundTag, + "system": false, // gVisor 用户态,不依赖内核 wireguard + "mtu": wc.mtu(), + "address": wc.Address, + "private_key": wc.PrivateKey, + "peers": []any{peer}, + } +} + +// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP, +// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。 +func (wc *WarpConfig) warpRoute() map[string]any { + return map[string]any{ + "rules": []any{ + map[string]any{"action": "sniff"}, + map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag}, + }, + "final": directOutboundTag, + } +} diff --git a/server/internal/agentd/warp_test.go b/server/internal/agentd/warp_test.go new file mode 100644 index 0000000..f4520f7 --- /dev/null +++ b/server/internal/agentd/warp_test.go @@ -0,0 +1,152 @@ +package agentd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" +) + +// writeWarp 把 warp.json 写到 cfg 的 WarpConfigPath。 +func writeWarp(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +const validWarp = `{ + "enabled": true, + "private_key": "aW52YWxpZC1rZXk=", + "peer_public_key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=", + "endpoint": "162.159.192.1:2408", + "address": ["172.16.0.2/32", "2606:4700:110:8abc::/128"], + "reserved": [1, 2, 3], + "mtu": 1280, + "domains": ["reddit.com", "redd.it"] +}` + +// 无 warp.json → 配置里既无 endpoints 也无 route(向后兼容,与旧节点逐字节一致)。 +func TestRender_NoWarp_NoRouteSection(t *testing.T) { + sb := NewSingBox(testConfig(t), nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if _, ok := cfg["endpoints"]; ok { + t.Error("no warp.json but endpoints present") + } + if _, ok := cfg["route"]; ok { + t.Error("no warp.json but route present") + } +} + +// 有效 warp.json → 注入 WireGuard endpoint(tag=warp,userspace)+ 域名分流 route。 +func TestRender_Warp_InjectsEndpointAndRoute(t *testing.T) { + cfg := testConfig(t) + writeWarp(t, cfg.WarpConfigPath, validWarp) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("rendered config invalid JSON: %v", err) + } + + eps, ok := m["endpoints"].([]any) + if !ok || len(eps) != 1 { + t.Fatalf("want 1 endpoint, got %v", m["endpoints"]) + } + ep := eps[0].(map[string]any) + if ep["type"] != "wireguard" || ep["tag"] != "warp" { + t.Errorf("endpoint type/tag wrong: %v", ep) + } + if ep["system"] != false { + t.Errorf("WARP endpoint must be userspace (system=false), got %v", ep["system"]) + } + peers := ep["peers"].([]any) + peer := peers[0].(map[string]any) + if peer["public_key"] != "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=" { + t.Errorf("peer public_key wrong: %v", peer["public_key"]) + } + if peer["address"] != "162.159.192.1" { + t.Errorf("peer address wrong: %v", peer["address"]) + } + + route := m["route"].(map[string]any) + if route["final"] != "direct" { + t.Errorf("route.final = %v, want direct", route["final"]) + } + rules := route["rules"].([]any) + // 首条必须是 sniff(否则客户端发来的已解析 IP 无域名可匹配)。 + if rules[0].(map[string]any)["action"] != "sniff" { + t.Errorf("first route rule must be sniff, got %v", rules[0]) + } + last := rules[len(rules)-1].(map[string]any) + if last["outbound"] != "warp" { + t.Errorf("domain rule must route to warp, got %v", last) + } + if !strings.Contains(string(data), "reddit.com") { + t.Error("configured domain reddit.com not in route") + } +} + +// enabled=false 或域名为空 → 视为未启用,不注入(坏配置宁可全直连)。 +func TestRender_Warp_DisabledOrIncomplete(t *testing.T) { + cases := map[string]string{ + "disabled": strings.Replace(validWarp, `"enabled": true`, `"enabled": false`, 1), + "no-domains": strings.Replace(validWarp, `["reddit.com", "redd.it"]`, `[]`, 1), + "no-key": strings.Replace(validWarp, `"private_key": "aW52YWxpZC1rZXk=",`, `"private_key": "",`, 1), + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + cfg := testConfig(t) + writeWarp(t, cfg.WarpConfigPath, body) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + var m map[string]any + _ = json.Unmarshal(data, &m) + if _, ok := m["route"]; ok { + t.Errorf("%s: route must be absent", name) + } + }) + } +} + +// 坏 JSON → 渲染不报错、按未启用处理(不产出无法启动的配置)。 +func TestRender_Warp_BadJSONDegradesGracefully(t *testing.T) { + cfg := testConfig(t) + writeWarp(t, cfg.WarpConfigPath, `{ this is not json `) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + data, err := sb.RenderConfig() + if err != nil { + t.Fatalf("bad warp.json must not fail render: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["route"]; ok { + t.Error("bad warp.json must degrade to no route") + } +}