Files
pangolin/server/internal/agentd/render.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

149 lines
4.4 KiB
Go

package agentd
import (
"encoding/json"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// renderSingboxConfig produces a complete sing-box SERVER config JSON for the node:
// a VLESS+REALITY inbound and a Hysteria2 inbound, each carrying one user per
// provisioned credential. REALITY users key on the dp_uuid; Hy2 users key on the
// derived password (DeriveHy2Password) — both from the same dp_uuid source.
//
// Only the opaque dp_uuid is ever written; no account identity touches the node.
//
// 用量统计走 v2ray_api StatsService(loopback gRPC):节点 sing-box 编入
// with_v2ray_api,stats.users 列出全部 dp_uuid → 每个用户独立的
// user>>>{dp_uuid}>>>traffic>>>uplink|downlink 计数器,agent 按用户精确读取
// (替代旧的 clash 节点总量分摊)。clash_api 仍保留作本地调试。
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, 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": directOutboundTag}},
"experimental": map[string]any{
"clash_api": map[string]any{
"external_controller": clashAPIAddr,
"secret": clashAPISecret,
},
"v2ray_api": map[string]any{
"listen": v2rayAPIAddr,
"stats": map[string]any{
"enabled": true,
"users": statsUsers(creds),
},
},
},
}
// 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, "", " ")
}
// statsUsers 收集所有去重 dp_uuid,供 v2ray_api stats.users 按用户开启流量计数器。
func statsUsers(creds []Cred) []string {
seen := make(map[string]struct{}, len(creds))
users := make([]string, 0, len(creds))
for _, c := range creds {
if c.DpUUID == "" {
continue
}
if _, ok := seen[c.DpUUID]; ok {
continue
}
seen[c.DpUUID] = struct{}{}
users = append(users, c.DpUUID)
}
return users
}
func buildInbounds(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string) []any {
inbounds := make([]any, 0, 2)
if reality != nil {
users := make([]any, 0, len(creds))
for _, c := range creds {
if c.Protocol == agentv1.ProtocolReality || c.Protocol == agentv1.ProtocolBoth {
flow := c.Flow
if flow == "" {
flow = DefaultFlow
}
users = append(users, map[string]any{
"name": c.DpUUID,
"uuid": c.DpUUID,
"flow": flow,
})
}
}
realityTLS := map[string]any{
"enabled": true,
"server_name": reality.ServerName,
"reality": map[string]any{
"enabled": true,
"private_key": reality.PrivateKey,
"short_id": []string{reality.ShortID},
"handshake": map[string]any{
"server": reality.HandshakeServer,
"server_port": reality.HandshakePort,
},
},
}
inbounds = append(inbounds, map[string]any{
"type": "vless",
"tag": "reality-in",
"listen": "::",
"listen_port": reality.ListenPort,
"users": users,
"tls": realityTLS,
})
}
if hy2 != nil {
users := make([]any, 0, len(creds))
for _, c := range creds {
if c.Protocol == agentv1.ProtocolHy2 || c.Protocol == agentv1.ProtocolBoth {
users = append(users, map[string]any{
"name": c.DpUUID,
"password": DeriveHy2Password(c.DpUUID, deriveKey),
})
}
}
hy2In := map[string]any{
"type": "hysteria2",
"tag": "hy2-in",
"listen": "::",
"listen_port": hy2.ListenPort,
"users": users,
"tls": map[string]any{
"enabled": true,
"alpn": []string{"h3"},
"certificate_path": hy2.CertPath,
"key_path": hy2.KeyPath,
},
}
if hy2.Masquerade != "" {
hy2In["masquerade"] = hy2.Masquerade
}
inbounds = append(inbounds, hy2In)
}
return inbounds
}