feat: 国内流量直连分流(geoip-cn / geosite-cn)— #5
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

国内 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 20:34:00 +08:00
parent cf2bd93c93
commit 43b25c8aa0
9 changed files with 230 additions and 16 deletions
+6 -1
View File
@@ -47,8 +47,13 @@ class ConnectApi {
Future<String> 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 {
@@ -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';
// ── 设备 IDMVP 常量;后续由 device_info_plus 取真实 ID)──────────
@@ -123,6 +124,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
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);
+15
View File
@@ -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" <<EOF
@@ -186,6 +188,19 @@ SMTP_FROM=${SMTP_FROM:-no-reply@pangolin.app}
EOF
fi
# ── 6b. 国内分流 rule-set(#5)──────────────────────────────────────────────────
# 自托管 geoip-cn / geosite-cn(控制面 /v1/rules/*.srs 静态服务,客户端 sing-box
# 直连下载)。GitHub 在国内被墙,故控制面代为下发,避免鸡生蛋。
log "拉取国内分流 rule-set(geoip-cn / geosite-cn)..."
RULES_DIR="$DATA_DIR/rules"
install -d -m 755 "$RULES_DIR"
curl -fsSL -o "$RULES_DIR/geoip-cn.srs" \
https://github.com/SagerNet/sing-geoip/raw/rule-set/geoip-cn.srs \
|| log "WARN: geoip-cn.srs 拉取失败,国内分流将不可用"
curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
https://github.com/SagerNet/sing-geosite/raw/rule-set/geosite-cn.srs \
|| log "WARN: geosite-cn.srs 拉取失败,国内分流将不可用"
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
log "执行迁移(sqlite)..."
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
+8 -1
View File
@@ -296,9 +296,13 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
var nodeStore nodes.NodeStore
if nodeSvc != nil {
nodeStore = nodeSvc.Store()
nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), os.Getenv("NODE_DERIVE_KEY"))
nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), os.Getenv("NODE_DERIVE_KEY"), os.Getenv("PANGOLIN_PUBLIC_URL"))
}
// 国内分流(#5)的 rule-set 静态服务:GET /v1/rules/{name}.srs(自托管,
// sing-box 直连下载;PANGOLIN_RULES_DIR 默认 /var/lib/pangolin/rules)。
rulesHandler := httpapi.NewRulesHandler(os.Getenv("PANGOLIN_RULES_DIR"))
// ── Subscription URL (web user-center 订阅导入) ───────────────────────────
subAPI := httpapi.NewSubscriptionAPI(sqlDB, nodeStore, os.Getenv("NODE_DERIVE_KEY"), os.Getenv("SUB_BASE"))
@@ -310,6 +314,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
// ─── Mount under /v1 ─────────────────────────────────────────────────────
r.Route("/v1", func(v1 chi.Router) {
// Public (no auth): 国内分流 rule-set 下载(sing-box 不带 token)。
v1.Get("/rules/{name}", rulesHandler.Serve)
// Public (no auth): auth endpoints.
if authHandler != nil {
v1.Post("/auth/code", authHandler.SendCode)
+35 -10
View File
@@ -8,6 +8,15 @@ import (
"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.
//
@@ -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 串格式在
@@ -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)
}
}
+9 -3
View File
@@ -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
+52
View File
@@ -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)
}
+2 -1
View File
@@ -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