From 43b25c8aa034b4a688b71e6895699d7cbb559dce Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 20:34:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BD=E5=86=85=E6=B5=81=E9=87=8F?= =?UTF-8?q?=E7=9B=B4=E8=BF=9E=E5=88=86=E6=B5=81(geoip-cn=20/=20geosite-cn)?= =?UTF-8?q?=E2=80=94=20#5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 国内 IP/域名直连(不走隧道)→ 省流量 + 国内访问快;非国内走代理。 - clientconfig.go: BuildClientConfig 加 ClientConfigOpts{SplitCN,RulesBaseURL}; 开启时 route 加 {rule_set:[geoip-cn,geosite-cn]→direct} + 定义 rule_set (remote .srs,download_detour:direct 直连下载) - rules.go: 控制面静态服务 /v1/rules/{name}.srs(白名单防穿越)——自托管避免 GitHub 在国内被墙的鸡生蛋;客户端反正连控制面,可达性有保证 - nodes.go ConnectNode: 读 ?split_cn → opts;NodeAPI 加 rulesBaseURL (PANGOLIN_PUBLIC_URL);main.go 挂 /v1/rules 路由 + RulesHandler - 客户端: connect_api splitCN→?split_cn=1;connection_provider 传 smartRoute 偏好 - deploy/single-node: 拉 geoip-cn/geosite-cn.srs 到 $DATA_DIR/rules + 设 PANGOLIN_PUBLIC_URL/PANGOLIN_RULES_DIR 验证:go test(splitCN 开/关渲染 + RulesHandler 白名单/404)+ flutter analyze + shellcheck;不需要节点。订阅链接暂用默认 opts、DNS 分流为后续增强。 Co-Authored-By: Claude Opus 4.8 --- client/lib/services/connect_api.dart | 7 +- client/lib/state/connection_provider.dart | 3 + deploy/single-node/deploy.sh | 15 +++ server/cmd/server/main.go | 9 +- server/internal/httpapi/clientconfig.go | 45 +++++++-- server/internal/httpapi/clientconfig_test.go | 100 +++++++++++++++++++ server/internal/httpapi/nodes.go | 12 ++- server/internal/httpapi/rules.go | 52 ++++++++++ server/internal/httpapi/subscription.go | 3 +- 9 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 server/internal/httpapi/clientconfig_test.go create mode 100644 server/internal/httpapi/rules.go diff --git a/client/lib/services/connect_api.dart b/client/lib/services/connect_api.dart index 909a8b1..730222e 100644 --- a/client/lib/services/connect_api.dart +++ b/client/lib/services/connect_api.dart @@ -47,8 +47,13 @@ class ConnectApi { Future fetchConfig({ required String nodeId, required String deviceId, + bool splitCN = false, }) async { - final uri = Uri.parse('$baseUrl/v1/nodes/$nodeId/connect'); + // splitCN=true 时带 ?split_cn=1:控制面渲染国内 IP/域名直连(不走隧道,#5)。 + var uri = Uri.parse('$baseUrl/v1/nodes/$nodeId/connect'); + if (splitCN) { + uri = uri.replace(queryParameters: {'split_cn': '1'}); + } final http.Response response; try { diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 868d5be..7e2e1a8 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -16,6 +16,7 @@ import '../services/connect_api.dart'; import 'app_providers.dart'; import 'auth_provider.dart'; import 'nodes_provider.dart'; +import 'settings_provider.dart'; // ── 设备 ID(MVP 常量;后续由 device_info_plus 取真实 ID)────────── @@ -123,6 +124,8 @@ class ConnectionController extends StateNotifier { final configJson = await _api!.fetchConfig( nodeId: node.uuid, deviceId: _kDeviceId, + // smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。 + splitCN: _ref.read(settingsProvider).smartRoute, ); // bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。 await _bridge.start(configJson); diff --git a/deploy/single-node/deploy.sh b/deploy/single-node/deploy.sh index e420689..c4efc45 100755 --- a/deploy/single-node/deploy.sh +++ b/deploy/single-node/deploy.sh @@ -175,6 +175,8 @@ CA_KEY_PATH=$ETC/ca.key CA_CERT_PATH=$ETC/ca.crt GRPC_CERT_PATH=$ETC/grpc.crt GRPC_KEY_PATH=$ETC/grpc.key +PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT +PANGOLIN_RULES_DIR=$DATA_DIR/rules EOF if [ -n "${SMTP_HOST:-}" ]; then cat >> "$ETC/server.env" </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. // @@ -19,9 +28,8 @@ import ( // - 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) -// - ttlSeconds: credential lifetime hint; not embedded in the config but can -// be used by callers to set a session timer -func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string) ([]byte, error) { +// - 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 == "" { @@ -118,19 +126,36 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string) ([]byte, e "tolerance": 50, } - // Route: LAN direct, everything else via auto. - route := map[string]any{ - "rules": []any{ - 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: LAN direct;(可选)国内 IP/域名直连;其余 via auto。 + routeRules := []any{ + 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 串格式在 diff --git a/server/internal/httpapi/clientconfig_test.go b/server/internal/httpapi/clientconfig_test.go new file mode 100644 index 0000000..6f862a4 --- /dev/null +++ b/server/internal/httpapi/clientconfig_test.go @@ -0,0 +1,100 @@ +package httpapi + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/wangjia/pangolin/server/internal/nodes" +) + +func testNode() *nodes.NodeRow { + return &nodes.NodeRow{ + UUID: "n1", Endpoint: "1.2.3.4:443", + RealityPBK: "pbk", RealityShortID: "sid", RealitySNI: "www.apple.com", + } +} + +func routeOf(t *testing.T, cfg []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(cfg, &m); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + return m["route"].(map[string]any) +} + +func TestBuildClientConfigSplitCN(t *testing.T) { + // 开启分流 + base → geoip-cn/geosite-cn rule_set + cn 直连规则;URL 自托管。 + cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", + ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://node:8080/"}) + if err != nil { + t.Fatal(err) + } + route := routeOf(t, cfg) + if rs, ok := route["rule_set"].([]any); !ok || len(rs) != 2 { + t.Fatalf("expected 2 rule_set, got %v", route["rule_set"]) + } + s := string(cfg) + for _, want := range []string{ + "geoip-cn", "geosite-cn", + "http://node:8080/v1/rules/geoip-cn.srs", + "http://node:8080/v1/rules/geosite-cn.srs", + "download_detour", + } { + if !strings.Contains(s, want) { + t.Errorf("config missing %q", want) + } + } + foundCN := false + for _, r := range route["rules"].([]any) { + rm := r.(map[string]any) + if rm["outbound"] == "direct" && rm["rule_set"] != nil { + foundCN = true + } + } + if !foundCN { + t.Error("missing cn-direct route rule (rule_set→direct)") + } + + // 关闭分流 → 无 rule_set。 + cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{}) + if _, ok := routeOf(t, cfg2)["rule_set"]; ok { + t.Error("split off should have no rule_set") + } + + // 开启但缺 base → 静默不分流(避免渲染出无效 rule_set URL)。 + cfg3, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{SplitCN: true}) + if _, ok := routeOf(t, cfg3)["rule_set"]; ok { + t.Error("split with empty base should have no rule_set") + } +} + +func TestRulesHandler(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "geoip-cn.srs"), []byte("SRS"), 0o644); err != nil { + t.Fatal(err) + } + r := chi.NewRouter() + r.Get("/v1/rules/{name}", NewRulesHandler(dir).Serve) + + get := func(path string) (int, string) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + return rec.Code, rec.Body.String() + } + + if code, body := get("/v1/rules/geoip-cn.srs"); code != 200 || body != "SRS" { + t.Fatalf("allowed file: code=%d body=%q, want 200/SRS", code, body) + } + if code, _ := get("/v1/rules/passwd"); code != 404 { + t.Errorf("disallowed name: code=%d, want 404", code) + } + if code, _ := get("/v1/rules/geosite-cn.srs"); code != 404 { + t.Errorf("allowed but missing file: code=%d, want 404", code) + } +} diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index b54f153..17a739d 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -27,11 +27,14 @@ type NodeAPI struct { store nodes.NodeStore hub *nodes.Hub deriveKey string + // rulesBaseURL 是控制面对外公网基址(PANGOLIN_PUBLIC_URL),供国内分流的 + // rule_set .srs 下载用;空则分流不生效。 + rulesBaseURL string } // NewNodeAPI creates a NodeAPI. -func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, deriveKey string) *NodeAPI { - return &NodeAPI{store: store, hub: hub, deriveKey: deriveKey} +func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, deriveKey, rulesBaseURL string) *NodeAPI { + return &NodeAPI{store: store, hub: hub, deriveKey: deriveKey, rulesBaseURL: rulesBaseURL} } // ─── GET /v1/nodes ─────────────────────────────────────────────────────────── @@ -175,7 +178,10 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { } // 7. Render and return the full sing-box CLIENT config JSON. - cfgJSON, renderErr := BuildClientConfig(node, ent.DpUUID, a.deriveKey) + // 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, ent.DpUUID, a.deriveKey, + ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL}) if renderErr != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return diff --git a/server/internal/httpapi/rules.go b/server/internal/httpapi/rules.go new file mode 100644 index 0000000..1d9d18a --- /dev/null +++ b/server/internal/httpapi/rules.go @@ -0,0 +1,52 @@ +package httpapi + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/go-chi/chi/v5" +) + +// RulesHandler 静态服务国内分流(#5)用的 sing-box rule-set(.srs)文件。 +// 客户端配置的 rule_set 指向 /v1/rules/{name},sing-box 直连下载 +// (download_detour:direct);自托管避免 GitHub 在国内被墙的鸡生蛋问题。 +// 只放行白名单文件名,防目录穿越。无需鉴权(sing-box 下载不带 token)。 +type RulesHandler struct { + dir string +} + +// NewRulesHandler 指定 .srs 所在目录(PANGOLIN_RULES_DIR,默认 /var/lib/pangolin/rules)。 +func NewRulesHandler(dir string) *RulesHandler { + if dir == "" { + dir = "/var/lib/pangolin/rules" + } + return &RulesHandler{dir: dir} +} + +var allowedRuleSets = map[string]bool{ + "geoip-cn.srs": true, + "geosite-cn.srs": true, +} + +// Serve 处理 GET /v1/rules/{name}。 +func (h *RulesHandler) Serve(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if !allowedRuleSets[name] { + http.NotFound(w, r) + return + } + f, err := os.Open(filepath.Join(h.dir, name)) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil || st.IsDir() { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeContent(w, r, name, st.ModTime(), f) +} diff --git a/server/internal/httpapi/subscription.go b/server/internal/httpapi/subscription.go index 3c9f9c4..4915f7e 100644 --- a/server/internal/httpapi/subscription.go +++ b/server/internal/httpapi/subscription.go @@ -100,7 +100,8 @@ func (s *SubscriptionAPI) ServeSub(w http.ResponseWriter, r *http.Request) { return } - cfg, err := BuildClientConfig(node, dpUUID, s.deriveKey) + // 订阅链接暂不开国内分流(默认 opts);后续可按需加 query/偏好。 + cfg, err := BuildClientConfig(node, dpUUID, s.deriveKey, ClientConfigOpts{}) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return