Files
pangolin/server/internal/agentd/warp.go
T
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

122 lines
4.2 KiB
Go

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,
}
}