43b25c8aa0
国内 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>
101 lines
2.8 KiB
Go
101 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|