Files
pangolin/server/internal/httpapi/clientconfig.go
T
wangjia cebc9a1c4f fix(client/macos+server): 隧道运行时打通——DNS 劫持 + 死锁/空指针修复
接续 447f3f4(系统扩展可加载),修复"扩展能起但连上无法上网"的运行时问题,
现已在 macOS 15(cara)端到端连通:出口=节点 IP、国外站可达、DNS 经隧道解析。

服务端(clientconfig.go):
- route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 直连规则前)。
  否则发往隧道 DNS(172.19.0.2:53)的查询被 172.16.0.0/12 吞去直连,域名解析失败。
  sing-box 1.13 按端口劫持(protocol:dns 需先 sniff,不稳)。

客户端——保留三个真 bug 修复:
- startTunnel 的 libbox 启动移到后台队列:避免在 provider 队列同步阻塞,与
  openTun→setTunnelNetworkSettings 回调三方死锁(隧道永远卡 connecting)。
- startOrReloadService(options:) 传非空 LibboxOverrideOptions():传 nil 致空指针 SIGSEGV。
- startDefaultInterfaceMonitor 阻塞到首个 path 更新再返回:修 "no available network interface"。
- 清除排障期临时诊断代码;CFBundleVersion 递增(sysextd 按版本去重)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
2026-06-22 08:57:55 +08:00

204 lines
7.2 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
}
// 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",
}
// 代理出站集合: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。
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",
},
}
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(不经隧道,启动期隧道还没起)。
if opts.SplitCN && opts.RulesBaseURL != "" {
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)。
dns := map[string]any{
"servers": []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"},
},
"final": "remote",
"strategy": "ipv4_only",
}
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:]
}