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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "", " ")
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user