aaa3384e3b
sing-box route 的 ip_cidr 匹配的是已解析的连接目的地地址;客户端若发的是域名
形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr + port 的拒绝规则不命中,
请求会绕过 ACL 直接落到 final:direct——DSM/RDP/SSH 因而对任何持有有效 dp_uuid
的 pangolin 用户可达,不受白名单约束。攻击者只需让客户端连接目的地时使用域名
而非 IP 即可绕过。
用户已拍板方案:在 sniff 之后紧跟加一条 {"action":"resolve"},让节点自己解析
目的地,使域名形式收敛到 ip_cidr 规则上(已用本机 sing-box 1.13.13 确认接受
[{"action":"sniff"},{"action":"resolve"},...] 这个形状,TestRenderedConfig_
PassesSingBoxCheck 也覆盖了这条产物)。route 规则顺序变为:
sniff → resolve → ACL 放行 → ACL 拒绝 → WARP。resolve 与 sniff 同条件——只在
产出 route 块时才出现,唯一且紧跟 sniff。
代价(用户已知情接受):节点从此会对连接目的地做 DNS 解析,是对"节点只见不透明
dp_uuid、不知道目的地"这条隐私姿态的一次主动后退,详见设计文档 §12。
TDD:更新 TestBuildRoute_Matrix 的四态断言(规则数 2/3/4 → 3/4/5,新增 resolve
计数/位置断言),新增 TestBuildRoute_ResolvePositionedRightAfterSniff(RED:此前
resolve 不存在)与 TestBuildRoute_NeverConfigured_NoResolve(未配置态不受影响)。
确认既有 TestRender_Warp_InjectsEndpointAndRoute(只断言首条 sniff、末条 warp,
不断言精确条数)未受影响,原样绿。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
6.7 KiB
Go
193 lines
6.7 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.
|
|
//
|
|
// 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有
|
|
// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id),
|
|
// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化,
|
|
// 设计与权衡见 docs/private-dest-acl-design.html §12。
|
|
//
|
|
// 用量统计走 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, acl *ACLConfig) ([]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)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要
|
|
// 同时容纳 ACL 规则 —— 原先 cfg["route"] = warp.warpRoute() 是整块覆盖,直接赋值
|
|
// 会把对方的规则干掉。
|
|
if warp.active() {
|
|
cfg["endpoints"] = []any{warp.warpEndpoint()}
|
|
}
|
|
if route := buildRoute(acl, warp); route != nil {
|
|
cfg["route"] = route
|
|
}
|
|
|
|
return json.MarshalIndent(cfg, "", " ")
|
|
}
|
|
|
|
// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。
|
|
// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。
|
|
//
|
|
// 规则顺序是安全语义的一部分:
|
|
// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是
|
|
// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。
|
|
// 2. {"action":"resolve"} 紧跟 sniff 之后,同样唯一且只在产出 route 块时才出现。
|
|
// ACL 的 ip_cidr 目的地(DSM/RDP/SSH 等独占端口服务)匹配的是已解析的连接
|
|
// 目的地地址——若客户端发的是域名形式(如 nas.51yanmei.com:5001)而节点不主动
|
|
// 解析,ip_cidr 规则不命中,请求会绕过 ACL 直接落到 final:direct。resolve 让
|
|
// 节点自己解析目的地,使域名形式收敛到 ip_cidr 规则上,堵住这个绕过口子
|
|
// (I3;这也是唯一一处从"节点不知道目的地"这条不变式主动后退的地方,是知情
|
|
// 接受的取舍,见 docs/private-dest-acl-design.html §12)。
|
|
// 3. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远
|
|
// 不该还有机会被路由到 warp 出口。
|
|
// 4. final 恒为 direct。
|
|
func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
|
|
aclRules := acl.rules()
|
|
warpActive := warp.active()
|
|
if len(aclRules) == 0 && !warpActive {
|
|
return nil
|
|
}
|
|
|
|
rules := make([]any, 0, len(aclRules)+3)
|
|
rules = append(rules, map[string]any{"action": "sniff"})
|
|
rules = append(rules, map[string]any{"action": "resolve"})
|
|
rules = append(rules, aclRules...)
|
|
if warpActive {
|
|
rules = append(rules, map[string]any{
|
|
"domain_suffix": warp.cleanDomains(),
|
|
"outbound": warpOutboundTag,
|
|
})
|
|
}
|
|
return map[string]any{"rules": rules, "final": directOutboundTag}
|
|
}
|
|
|
|
// 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
|
|
}
|