feat: 国内流量直连分流(geoip-cn / geosite-cn)— #5
国内 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:
@@ -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';
|
||||
|
||||
// ── 设备 ID(MVP 常量;后续由 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user