Files
wangjia eb7c3c1062 feat(agent): WARP 域名分流 —— 命中域名走 Cloudflare 干净出口(#29)
节点 sing-box 渲染新增可选 WARP 分流:节点本地 warp.json(默认
<StateDir>/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 <noreply@anthropic.com>
2026-07-02 13:40:00 +08:00

153 lines
4.7 KiB
Go

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")
}
}