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>
This commit is contained in:
wangjia
2026-07-02 13:40:00 +08:00
parent f035552ff5
commit eb7c3c1062
5 changed files with 303 additions and 3 deletions
+7
View File
@@ -54,6 +54,10 @@ type Config struct {
// SingboxConfigPath is where the rendered sing-box config is written.
SingboxConfigPath string
// WarpConfigPath 指向节点本地的 WARP 分流配置(默认 <StateDir>/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
}
+14 -2
View File
@@ -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, "", " ")
}
+9 -1
View File
@@ -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.
+121
View File
@@ -0,0 +1,121 @@
package agentd
import (
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"strings"
)
// WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。
// 由节点本地文件(默认 <StateDir>/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,
}
}
+152
View File
@@ -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")
}
}