a3ad50aa75
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 27s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 20s
ci-pangolin / Flutter — analyze + test (push) Failing after 10m20s
ci-pangolin / OpenAPI Sync Check (push) Failing after 10m29s
ci-pangolin / Lint — shellcheck (push) Failing after 10m41s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14m28s
ci-pangolin / Go — build + test (push) Failing after 14m37s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Failing after 14m49s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Failing after 14m57s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 19m19s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 12m37s
隧道开着时连不上局域网/NAS/家里机器(SSH/gitea 全 reset)。根因:TUN 入站 auto_route+strict_route 在 OS 层把所有流量(含 LAN)强抓进隧道,而 route.rules 里的 ip_cidr(192.168/16…)→direct 在 macOS 被 strict_route 抵消(direct 出站的 包又被捕回隧道)。 修法:TUN 入站加 route_exclude_address=[192.168.0.0/16, 10.0.0.0/8],在 auto_route 层就把这些网段排除出隧道,LAN 走系统直连。**刻意不含 172.16.0.0/12**——隧道自身 地址与 DNS(172.19.x)在此段,排除会断 DNS。route_exclude_address 为 sing-box v1.13 合法 TUN 字段(option/tun.go)。 影响:VPN 不再黑洞局域网——直连 NAS/家里机器/内网 gitea 恢复,CI runner 也不再 需要经 ali 的中继隧道(relay)。客户端需完整重连拉新配置生效。 测试:TestBuildClientConfigLANExclude 断言 tun 含 192.168/16+10/8、不含 172.16/12; go test ./internal/httpapi 全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
255 lines
10 KiB
Go
255 lines
10 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/dpcred"
|
|
"github.com/wangjia/pangolin/server/internal/nodes"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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",
|
|
})
|
|
}
|
|
route := map[string]any{
|
|
"final": "auto",
|
|
"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(不经隧道,启动期隧道还没起)。
|
|
splitActive := opts.SplitCN && opts.RulesBaseURL != ""
|
|
if splitActive {
|
|
base := strings.TrimRight(opts.RulesBaseURL, "/")
|
|
routeRules = append(routeRules, map[string]any{
|
|
"rule_set": []string{"geoip-cn", "geosite-cn"},
|
|
"outbound": "direct",
|
|
})
|
|
route["rule_set"] = []any{
|
|
map[string]any{"tag": "geoip-cn", "type": "remote", "format": "binary",
|
|
"url": base + "/v1/rules/geoip-cn.srs", "download_detour": "direct"},
|
|
map[string]any{"tag": "geosite-cn", "type": "remote", "format": "binary",
|
|
"url": base + "/v1/rules/geosite-cn.srs", "download_detour": "direct"},
|
|
}
|
|
}
|
|
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",
|
|
})
|
|
// 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回
|
|
// 域名元数据——路由层的 domain 规则(私有域名→隧道)靠它才会命中。
|
|
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:]
|
|
}
|