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 // 从 /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", } // 代理出站集合: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:] }