Files
pangolin/server/internal/httpapi/clientconfig.go
T
wangjia e157b16c11 fix(server): 走隧道/拒绝的域名规则也开 reverse_mapping
用户规则(可配置分流)里 action==proxy/reject 的域名规则,此前不触发
dns.reverse_mapping(只有 direct 域名规则触发)。而应用自行解析域名后按 IP
发起连接,路由层只剩 IP,无反向映射则 domain 规则永不命中——走隧道/拒绝的
域名规则会静默失效。

translateUserRules 把 hasDomainDirect 扩成 hasDomainRule:任意 action 的
域名类规则(domain/domain_suffix/domain_keyword)都置真、都开 reverse_mapping。
direct 专属的 ip_cidr→route_exclude_address(extraExclude)那条线不变。

这也是把私有服务分流(PANGOLIN_PRIVATE_SPLIT_DOMAINS)改用用户规则表达的
前置修复——否则一条"走隧道"用户规则替代 private-split 会连不上。

回归测试 TestBuildConfigProxyDomainEnablesReverseMapping:唯一一条走隧道/拒绝
域名规则(privateSplit 关、无 direct 域名规则)必须开 reverse_mapping。go test ./... 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-07-31 01:15:34 +08:00

428 lines
17 KiB
Go

package httpapi
import (
"encoding/json"
"strings"
"github.com/wangjia/pangolin/server/internal/dpcred"
"github.com/wangjia/pangolin/server/internal/nodes"
"github.com/wangjia/pangolin/server/internal/routing"
)
// ClientConfigOpts carries per-request rendering options for BuildClientConfig.
type ClientConfigOpts struct {
// SplitCN 开启国内分流:命中 geoip-cn/geosite-cn 的 IP/域名直连(不走隧道)。
SplitCN bool
// RulesBaseURL 是控制面对外公网基址(如 "http://node:8080"),rule_set 的 .srs
// 从 <base>/v1/rules/*.srs 下载。SplitCN 生效需此项非空(否则分流静默跳过)。
RulesBaseURL string
// PrivateSplitDomains 私有服务域名(家庭内网穿透,如 nas/git/win.yanmeiai.com,
// 服务端 env PANGOLIN_PRIVATE_SPLIT_DOMAINS 配置;空=行为完全不变):
// - DNS 改用系统解析器(在家吃到局域网 DNS 覆盖→私网 IP;在外解析出公网锚点)
// - 路由上私网结果命中 LAN 直连(在家零绕行),公网结果强制走隧道——该规则
// 必须排在国内分流(geoip-cn)之前:锚点(frps@ali)是国内 IP,否则 smartRoute
// 会把它分流成直连,被 frps 侧安全组限源(仅节点出口)拦截。
PrivateSplitDomains []string
// Profile 是当前用户的路由档案(可配置分流,Task 1-3)。nil = 完全回退旧行为
// (仅由 SplitCN/PrivateSplitDomains 决定),用于灰度/未迁移用户与旧调用点。
// 非 nil 时:
// - Mode=="rule":按 Profile.Rules(Enabled)插入用户规则层(系统层3之后、
// 国内分流之前);国内分流条件由 opts.SplitCN 改为 Profile.Builtin.ChinaDirect;
// route.final 由 Profile.Final 决定(direct→"direct"/否则"auto")。
// - Mode=="global"/"direct":跳过用户规则层与国内分流层,route.final 分别为
// "auto"/"direct";系统层(hijack-dns/LAN/私有域名)不受影响,恒生效。
Profile *routing.Profile
}
// translateUserRules 把 profile 的用户规则翻译成 route.rules 条目。仅
// Mode=="rule" 时产出内容;其余模式(global/direct)按约定跳过用户规则层,
// 返回全零值。nil profile 由调用方在外层门控,不会传进来。
//
// 返回值:
// - rules: 按 Profile.Rules 顺序生成的 route.rules 条目(仅 Enabled==true)。
// - extraExclude: type==ip_cidr && action==direct 的 value,供调用方并入
// TUN 入站的 route_exclude_address(auto_route 层直连才真正生效,
// 见 tunIn 构造处注释)。
// - hasDomainRule: 是否存在**任意** action 的域名类规则(domain/
// domain_suffix/domain_keyword),供调用方决定是否开 dns.reverse_mapping。
// 不限 direct:走隧道/拒绝的域名规则同样需要反向映射——应用自行解析域名后
// 按 IP 发起连接,路由层只剩 IP,没有 reverse_mapping 则 domain 规则永不命中、
// 该规则(不论直连/隧道/拒绝)静默失效。
// - geoSets: 规则引用到的 geoip-<v>/geosite-<v> rule_set tag(去重),供
// 调用方在国内分流(splitActive)之外也补上 rule_set 定义。
func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string, hasDomainRule bool, geoSets []string) {
if p == nil || p.Mode != "rule" {
return nil, nil, false, nil
}
seenGeo := map[string]bool{}
for _, r := range p.Rules {
if !r.Enabled {
continue
}
var outbound string
switch r.Action {
case "direct":
outbound = "direct"
case "proxy":
outbound = "auto"
case "reject":
outbound = "block"
default:
// 未知 action 防御性跳过(Validate 已在写入路径拦截,这里双保险)。
continue
}
switch r.Type {
case "domain", "domain_suffix", "domain_keyword":
rules = append(rules, map[string]any{r.Type: []string{r.Value}, "outbound": outbound})
// 任意 action 的域名规则都要 reverse_mapping(见返回值注释),不止 direct。
hasDomainRule = true
case "ip_cidr":
rules = append(rules, map[string]any{"ip_cidr": []string{r.Value}, "outbound": outbound})
if outbound == "direct" {
extraExclude = append(extraExclude, r.Value)
}
case "geoip", "geosite":
tag := r.Type + "-" + strings.ToLower(r.Value)
rules = append(rules, map[string]any{"rule_set": []string{tag}, "outbound": outbound})
if !seenGeo[tag] {
seenGeo[tag] = true
geoSets = append(geoSets, tag)
}
}
}
return rules, extraExclude, hasDomainRule, geoSets
}
// ruleSetDef 渲染一个自托管 remote rule_set 定义(与既有 geoip-cn/geosite-cn
// 定义块同构:base + "/v1/rules/<tag>.srs",direct 直连下载)。
func ruleSetDef(tag, base string) map[string]any {
return map[string]any{
"tag": tag, "type": "remote", "format": "binary",
"url": base + "/v1/rules/" + tag + ".srs", "download_detour": "direct",
}
}
// BuildClientConfig renders a complete sing-box CLIENT configuration JSON that
// the client app passes verbatim to the local tunnel kernel.
//
// Design rule (ARCHITECTURE.md §3.1): the Dart/Flutter client MUST NOT assemble
// or modify the config — it is rendered here, server-side, and returned raw.
//
// Parameters:
// - node: the target node row (provides endpoint, keys, ports)
// - dpUUID: the authenticated user's data-plane UUID (used as VLESS uuid)
// - deriveKey: shared HMAC key used by both server and agent to derive the
// Hysteria2 password from dp_uuid (must equal PANGOLIN_AGENT_DERIVE_KEY)
// - opts: 渲染选项(国内分流等),见 ClientConfigOpts
func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts ClientConfigOpts) ([]byte, error) {
// Parse host:port from endpoint; endpoint format is "host:port".
host, _ := splitHostPort(node.Endpoint)
if host == "" {
host = node.Endpoint
}
realityPublicKey := node.RealityPBK
realityShortID := node.RealityShortID
// Hysteria2 仅在节点确实配置了 hy2 端口时才下发。否则不出 hy2-out:
// 它会指向 REALITY 的 TCP 端口、而服务端又无 Hy2 监听,导致 urltest
// 一直探测一个死成员(connection reset by peer)。
hy2Enabled := node.Hy2Port.Valid && node.Hy2Port.Int32 > 0
hy2Password := dpcred.DeriveHy2Password(dpUUID, deriveKey)
// REALITY outbound (VLESS + REALITY TLS, TCP 443).
realityOut := map[string]any{
"type": "vless",
"tag": "reality-out",
"server": host,
"server_port": 11443, // REALITY always uses port from endpoint
"uuid": dpUUID,
"flow": dpcred.DefaultFlow,
"tls": map[string]any{
"enabled": true,
"server_name": node.RealitySNI,
"utls": map[string]any{
"enabled": true,
"fingerprint": "chrome",
},
"reality": map[string]any{
"enabled": true,
"public_key": realityPublicKey,
"short_id": realityShortID,
},
},
}
// Parse the REALITY listen port from endpoint.
if _, portStr := splitHostPort(node.Endpoint); portStr != "" {
port := 0
for _, ch := range portStr {
if ch >= '0' && ch <= '9' {
port = port*10 + int(ch-'0')
}
}
if port > 0 {
realityOut["server_port"] = port
}
}
// Hysteria2 outbound (UDP 443).
hy2Out := map[string]any{
"type": "hysteria2",
"tag": "hy2-out",
"server": host,
"server_port": node.Hy2Port.Int32,
"password": hy2Password,
"tls": map[string]any{
"enabled": true,
"alpn": []string{"h3"},
"server_name": node.RealitySNI,
// 节点 hy2 用自签证书(方案①);两端自有,跳过 CA 校验,
// 服务端鉴权靠 per-user 派生的 hy2 密码。
"insecure": true,
},
}
// TUN inbound with kill-switch (strict_route).
tunIn := map[string]any{
"type": "tun",
"tag": "tun-in",
"address": []string{"172.19.0.1/30"},
"mtu": 9000,
"auto_route": true,
"strict_route": true,
"stack": "system",
// 私有 LAN 直连:strict_route 会在 OS 层把所有流量(含 LAN)强抓进隧道,
// 光靠 route.rules 的 ip_cidr→direct 在 macOS 不生效(direct 出站的包被
// strict_route 重新捕回隧道)。route_exclude_address 在 auto_route 层就把这些
// 网段排除出隧道,LAN 走系统直连(修「隧道开着连不上局域网/NAS/家里机器」)。
// **不含 172.16.0.0/12**:隧道自身地址与 DNS(172.19.x)在此段,排除会断 DNS。
"route_exclude_address": []string{"192.168.0.0/16", "10.0.0.0/8"},
}
// 代理出站集合:REALITY 必有;Hy2 仅在启用时加入(否则不进配置/探测组)。
proxyTags := []string{"reality-out"}
proxyOutbounds := []any{realityOut}
if hy2Enabled {
proxyTags = append(proxyTags, "hy2-out")
proxyOutbounds = append(proxyOutbounds, hy2Out)
}
// urltest auto-select outbound.
autoBest := map[string]any{
"type": "urltest",
"tag": "auto",
"outbounds": proxyTags,
"url": "https://www.gstatic.com/generate_204",
"interval": "3m",
"tolerance": 50,
}
// Route: DNS 劫持 → LAN direct →(可选)私有域名走隧道 →(可选)国内直连 → 其余 via auto。
privateSplit := len(opts.PrivateSplitDomains) > 0
routeRules := []any{
// DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff):
// 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN
// 直连规则之前——否则 172.19.x 落在下面的 172.16.0.0/12 里,DNS 会被吞去直连、解析
// 失败导致打不开网站(TUN 模式 DNS 劫持是必需项,缺失则隧道连上也无法上网)。
map[string]any{"action": "hijack-dns", "port": []int{53}},
map[string]any{
"ip_cidr": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"},
"outbound": "direct",
},
}
if privateSplit {
// 私有域名强制走隧道。在家不受此规则影响:系统 DNS(局域网覆盖)解析出私网 IP,
// 上面的 LAN 直连规则先命中。域名元数据靠 dns.reverse_mapping 补回(应用自行
// 解析后按 IP 连接,无回映射则此规则永不匹配、在外会掉进国内分流被限源拦截)。
routeRules = append(routeRules, map[string]any{
"domain": opts.PrivateSplitDomains,
"outbound": "auto",
})
}
// 用户路由规则(可配置分流,Task 4):插在系统层3(私有域名)之后、
// 国内分流(splitActive,下方)之前。opts.Profile==nil 时 translateUserRules
// 直接返回全零值,以下每一步都随之短路,保证 nil-profile 渲染逐字节不变。
var userRules []any
var extraExclude []string
var hasDomainRule bool
var geoSets []string
if opts.Profile != nil {
userRules, extraExclude, hasDomainRule, geoSets = translateUserRules(opts.Profile)
if opts.RulesBaseURL == "" && len(geoSets) > 0 {
// 没有 base 就没法渲染 remote rule_set 的下载 URL,引用它的用户规则
// 会指向未定义的 tag(sing-box FATAL)。与 splitActive 缺 base 时静默
// 跳过国内分流同一语义:丢弃这些规则,不让配置渲染出无效引用。
kept := userRules[:0]
for _, ur := range userRules {
if m, ok := ur.(map[string]any); ok {
if _, hasRS := m["rule_set"]; hasRS {
continue
}
}
kept = append(kept, ur)
}
userRules = kept
geoSets = nil
}
}
if len(userRules) > 0 {
routeRules = append(routeRules, userRules...)
}
if len(extraExclude) > 0 {
// IP 直连真生效:并入 TUN 入站的 route_exclude_address(auto_route 层排除,
// 见 tunIn 构造处注释——单靠 route.rules 的 ip_cidr→direct 在 macOS
// strict_route 下不生效)。单源读 tunIn 现有种子(不重复硬编码 LAN 网段;
// 其为 192.168+10,与上面 route.rules 的 LAN 直连 4 段有意不同——不能改成
// LAN 全集,否则会漏排除/多排除),与 extraExclude 去重合并、写回。
base, _ := tunIn["route_exclude_address"].([]string)
exclude := append([]string{}, base...)
seen := make(map[string]bool, len(base))
for _, e := range base {
seen[e] = true
}
for _, e := range extraExclude {
if !seen[e] {
seen[e] = true
exclude = append(exclude, e)
}
}
tunIn["route_exclude_address"] = exclude
}
// route.final:三模式语义。nil profile 保持旧的硬编码 "auto"。
final := "auto"
if opts.Profile != nil {
switch opts.Profile.Mode {
case "direct":
final = "direct"
case "global":
final = "auto"
case "rule":
if opts.Profile.Final == "direct" {
final = "direct"
} else {
final = "auto"
}
}
}
route := map[string]any{
"final": final,
"auto_detect_interface": true,
// sing-box 1.12+ 要求显式声明出站域名用哪个 DNS 解析,缺失即 FATAL。
"default_domain_resolver": map[string]any{"server": "local"},
}
// 国内分流(#5):命中 geoip-cn / geosite-cn → 直连(不走隧道),省流量 + 国内快。
// rule_set 走控制面自托管(国内可达,客户端反正连控制面);download_detour:direct
// 让 sing-box 直连下载 .srs(不经隧道,启动期隧道还没起)。
// 有 Profile 时,是否分流改由 Profile 决定(mode==rule 且 Builtin.ChinaDirect);
// nil profile 保持旧的 opts.SplitCN 兜底,与原逻辑逐字节一致。
chinaDirectWanted := opts.SplitCN
if opts.Profile != nil {
chinaDirectWanted = opts.Profile.Mode == "rule" && opts.Profile.Builtin.ChinaDirect
}
splitActive := chinaDirectWanted && opts.RulesBaseURL != ""
base := strings.TrimRight(opts.RulesBaseURL, "/")
if splitActive {
routeRules = append(routeRules, map[string]any{
"rule_set": []string{"geoip-cn", "geosite-cn"},
"outbound": "direct",
})
route["rule_set"] = []any{
ruleSetDef("geoip-cn", base),
ruleSetDef("geosite-cn", base),
}
}
if len(geoSets) > 0 {
// 用户规则用到的 geo rule_set,凡未被上面的国内分流块定义过,在此补上
// (去重)——即便 SplitCN/Builtin.ChinaDirect 关闭,用户显式规则仍要生效。
defs, _ := route["rule_set"].([]any)
defined := map[string]bool{}
for _, d := range defs {
if dm, ok := d.(map[string]any); ok {
if tag, ok := dm["tag"].(string); ok {
defined[tag] = true
}
}
}
for _, tag := range geoSets {
if !defined[tag] {
defs = append(defs, ruleSetDef(tag, base))
defined[tag] = true
}
}
route["rule_set"] = defs
}
route["rules"] = routeRules
// DNS: remote over tunnel, local for domestic.
// sing-box 1.12+ DNS server format(type+server);旧的 address 串格式在
// 1.13 已 FATAL 拒绝(legacy DNS servers deprecated)。
dnsServers := []any{
map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"},
// local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站
// (FATAL: detour to an empty direct outbound makes no sense)。
map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"},
}
if privateSplit {
// 系统解析器(type=local,经底层物理网络):在家=路由器 DHCP 下发的局域网 DNS
// (含私有域名覆盖→私网 IP),在外=所在网络 DNS(公网记录→锚点 IP)。
// 不能用 223.5.5.5/8.8.8.8——公共 DNS 不知道家里的覆盖记录。
dnsServers = append(dnsServers, map[string]any{"tag": "dns-system", "type": "local"})
}
dns := map[string]any{
"servers": dnsServers,
"final": "remote",
"strategy": "ipv4_only",
}
dnsRules := []any{}
if privateSplit {
// 私有域名规则须排在 geosite-cn 之前(优先级最高)。
dnsRules = append(dnsRules, map[string]any{
"domain": opts.PrivateSplitDomains, "server": "dns-system",
})
}
if privateSplit || hasDomainRule {
// 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回
// 域名元数据——路由层的 domain 规则(私有域名→隧道 / 用户域名规则)靠它
// 才会命中。hasDomainRule:用户规则含**任意** action 的域名类规则(直连/
// 走隧道/拒绝)时都需要——否则该域名规则永不命中、静默失效。
dns["reverse_mapping"] = true
}
// 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的
// 国内域名用 local(223.5.5.5)直连解析,不走 remote(8.8.8.8 经隧道)。否则即便数据
// 直连,域名解析仍绕道出海(实测国内 DNS 段 200-600ms),首连凭空多一个出海 RTT。
// 复用 route.rule_set 里已定义的 geosite-cn 标签。
if splitActive {
dnsRules = append(dnsRules, map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"})
}
if len(dnsRules) > 0 {
dns["rules"] = dnsRules
}
cfg := map[string]any{
// timestamp=false:客户端日志出口(logLine)已统一加时间戳,
// 关掉 sing-box 自带时间戳避免一行打印两个时间。
"log": map[string]any{"level": "warn", "timestamp": false},
"inbounds": []any{tunIn},
"outbounds": append(proxyOutbounds,
autoBest,
map[string]any{"type": "block", "tag": "block"},
map[string]any{"type": "direct", "tag": "direct"},
),
"route": route,
"dns": dns,
}
return json.Marshal(cfg)
}
// splitHostPort splits "host:port" into (host, port). Returns ("", "") on failure.
func splitHostPort(s string) (host, port string) {
i := strings.LastIndexByte(s, ':')
if i < 0 {
return s, ""
}
return s[:i], s[i+1:]
}