diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index d91f3cf..ea3f9d3 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -376,7 +376,15 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv slog.Warn("PANGOLIN_PUBLIC_URL 未设置:国内分流(split_cn)将被静默跳过,客户端全量走隧道。" + "如需国内直连,设为控制面对外公网基址(如 http://<公网IP>:8080)") } - nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), nodeSvc.Load(), os.Getenv("NODE_DERIVE_KEY"), publicURL) + // 私有服务域名分流(家庭内网穿透,如 nas/git/win.yanmeiai.com): + // 逗号分隔;这些域名用系统 DNS 解析、在外强制走隧道(排在国内分流前)。 + var privateSplitDomains []string + for _, d := range strings.Split(os.Getenv("PANGOLIN_PRIVATE_SPLIT_DOMAINS"), ",") { + if d = strings.TrimSpace(d); d != "" { + privateSplitDomains = append(privateSplitDomains, d) + } + } + nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), nodeSvc.Load(), os.Getenv("NODE_DERIVE_KEY"), publicURL, privateSplitDomains) } // 国内分流(#5)的 rule-set 静态服务:GET /v1/rules/{name}.srs(自托管, diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index a282aa2..426f1e4 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -15,6 +15,13 @@ type ClientConfigOpts struct { // 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 @@ -126,7 +133,8 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "tolerance": 50, } - // Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。 + // 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 @@ -138,6 +146,15 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "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, @@ -166,24 +183,42 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien // 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": []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"}, - }, + "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 { - dns["rules"] = []any{ - map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"}, - } + dnsRules = append(dnsRules, map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"}) + } + if len(dnsRules) > 0 { + dns["rules"] = dnsRules } cfg := map[string]any{ diff --git a/server/internal/httpapi/clientconfig_test.go b/server/internal/httpapi/clientconfig_test.go index df0985c..a144d1f 100644 --- a/server/internal/httpapi/clientconfig_test.go +++ b/server/internal/httpapi/clientconfig_test.go @@ -91,6 +91,84 @@ func TestBuildClientConfigSplitCN(t *testing.T) { } } +func TestBuildClientConfigPrivateSplit(t *testing.T) { + domains := []string{"nas.yanmeiai.com", "git.yanmeiai.com", "win.yanmeiai.com"} + // 私有分流 + 国内分流同时开:验证规则齐全且顺序正确 + // (LAN 直连 → 私有域名强制走隧道 → 国内直连;私有规则必须在国内直连之前, + // 否则锚点是国内 IP 会被分流成直连、被 frps 侧安全组限源拦截)。 + cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", + ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://node:8080", + PrivateSplitDomains: domains}) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(cfg, &m); err != nil { + t.Fatal(err) + } + + // ① dns.servers 含系统解析器(type=local):在家吃到局域网 DNS 覆盖(私网IP), + // 在外用所在网络 DNS 解析出公网锚点。 + dnsm := m["dns"].(map[string]any) + foundSystem := false + for _, s := range dnsm["servers"].([]any) { + sm := s.(map[string]any) + if sm["tag"] == "dns-system" && sm["type"] == "local" { + foundSystem = true + } + } + if !foundSystem { + t.Error("missing dns-system (type=local) dns server") + } + + // ② dns.rules 首条 = 私有域名→dns-system(须排在 geosite-cn→local 之前)。 + dnsRules := dnsm["rules"].([]any) + dr := dnsRules[0].(map[string]any) + if dr["server"] != "dns-system" || dr["domain"] == nil { + t.Errorf("dns.rules[0] should be private domains → dns-system, got %v", dr) + } + + // ③ reverse_mapping 开启:应用自行解析后按 IP 连接,回映射补回域名元数据, + // 路由的 domain 规则才有效。 + if dnsm["reverse_mapping"] != true { + t.Error("reverse_mapping should be true when private split is on") + } + + // ④ 路由顺序:LAN 直连 < 私有域名→auto < 国内 rule_set→direct。 + rules := m["route"].(map[string]any)["rules"].([]any) + lanIdx, privIdx, cnIdx := -1, -1, -1 + for i, r := range rules { + rm := r.(map[string]any) + if rm["ip_cidr"] != nil && rm["outbound"] == "direct" { + lanIdx = i + } + if rm["domain"] != nil && rm["outbound"] == "auto" { + privIdx = i + } + if rm["rule_set"] != nil && rm["outbound"] == "direct" { + cnIdx = i + } + } + if lanIdx < 0 || privIdx < 0 || cnIdx < 0 { + t.Fatalf("missing rules: lan=%d priv=%d cn=%d", lanIdx, privIdx, cnIdx) + } + if !(lanIdx < privIdx && privIdx < cnIdx) { + t.Errorf("rule order wrong: lan=%d < priv=%d < cn=%d expected", lanIdx, privIdx, cnIdx) + } + + // ⑤ 不配置 → 全部不出现(行为与旧版完全一致)。 + cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{}) + var m2 map[string]any + _ = json.Unmarshal(cfg2, &m2) + dnsm2 := m2["dns"].(map[string]any) + if _, ok := dnsm2["reverse_mapping"]; ok { + t.Error("private split off: reverse_mapping should be absent") + } + if strings.Contains(string(cfg2), "dns-system") { + t.Error("private split off: dns-system should be absent") + } +} + func TestRulesHandler(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "geoip-cn.srs"), []byte("SRS"), 0o644); err != nil { diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index 76e3aa6..8f0e232 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -42,12 +42,16 @@ type NodeAPI struct { // rulesBaseURL 是控制面对外公网基址(PANGOLIN_PUBLIC_URL),供国内分流的 // rule_set .srs 下载用;空则分流不生效。 rulesBaseURL string + // privateSplitDomains 私有服务域名(PANGOLIN_PRIVATE_SPLIT_DOMAINS,逗号分隔), + // 见 ClientConfigOpts.PrivateSplitDomains;空则不渲染相关规则。 + privateSplitDomains []string } // NewNodeAPI creates a NodeAPI. load may be nil (then all nodes are treated as // data-plane healthy — agent gRPC liveness still gates status). -func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string) *NodeAPI { - return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey, rulesBaseURL: rulesBaseURL} +func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string, privateSplitDomains []string) *NodeAPI { + return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey, + rulesBaseURL: rulesBaseURL, privateSplitDomains: privateSplitDomains} } // dataPlaneHealthy reports the node's last sing-box health (default true when @@ -315,7 +319,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // split_cn=1/true → 国内 IP/域名直连(#5);客户端按 smartRoute 偏好传。 splitCN := r.URL.Query().Get("split_cn") == "1" || r.URL.Query().Get("split_cn") == "true" cfgJSON, renderErr := BuildClientConfig(node, dpUUID, a.deriveKey, - ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL}) + ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL, + PrivateSplitDomains: a.privateSplitDomains}) if renderErr != nil { slog.Error("connect: build client config failed", "node", nodeUUID, "err", renderErr) apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) @@ -324,7 +329,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // 可观测:国内分流是否真正生效(split_active=两个条件都满足才渲染 rule_set)。 slog.Info("client config rendered", "node", nodeUUID, "split_cn", splitCN, "rules_base_set", a.rulesBaseURL != "", - "split_active", splitCN && a.rulesBaseURL != "", "bytes", len(cfgJSON)) + "split_active", splitCN && a.rulesBaseURL != "", + "private_split", len(a.privateSplitDomains), "bytes", len(cfgJSON)) w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(cfgJSON) diff --git a/server/internal/httpapi/nodes_disconnect_test.go b/server/internal/httpapi/nodes_disconnect_test.go index d8aab8a..d28c7cb 100644 --- a/server/internal/httpapi/nodes_disconnect_test.go +++ b/server/internal/httpapi/nodes_disconnect_test.go @@ -51,7 +51,7 @@ func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1 func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int { t.Helper() - api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除 + api := NewNodeAPI(store, nil, nil, "", "", nil) // nil hub:跳过 Push,只验证凭证删除 req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body)) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "node-1")