a3ad50aa75
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 27s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 20s
ci-pangolin / Flutter — analyze + test (push) Failing after 10m20s
ci-pangolin / OpenAPI Sync Check (push) Failing after 10m29s
ci-pangolin / Lint — shellcheck (push) Failing after 10m41s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14m28s
ci-pangolin / Go — build + test (push) Failing after 14m37s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Failing after 14m49s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Failing after 14m57s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 19m19s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 12m37s
隧道开着时连不上局域网/NAS/家里机器(SSH/gitea 全 reset)。根因:TUN 入站 auto_route+strict_route 在 OS 层把所有流量(含 LAN)强抓进隧道,而 route.rules 里的 ip_cidr(192.168/16…)→direct 在 macOS 被 strict_route 抵消(direct 出站的 包又被捕回隧道)。 修法:TUN 入站加 route_exclude_address=[192.168.0.0/16, 10.0.0.0/8],在 auto_route 层就把这些网段排除出隧道,LAN 走系统直连。**刻意不含 172.16.0.0/12**——隧道自身 地址与 DNS(172.19.x)在此段,排除会断 DNS。route_exclude_address 为 sing-box v1.13 合法 TUN 字段(option/tun.go)。 影响:VPN 不再黑洞局域网——直连 NAS/家里机器/内网 gitea 恢复,CI runner 也不再 需要经 ali 的中继隧道(relay)。客户端需完整重连拉新配置生效。 测试:TestBuildClientConfigLANExclude 断言 tun 含 192.168/16+10/8、不含 172.16/12; go test ./internal/httpapi 全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
7.6 KiB
Go
238 lines
7.6 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)")
|
|
}
|
|
|
|
// DNS 面分流:开分流时国内域名(geosite-cn)用 local 解析,不走 remote(隧道)。
|
|
var m map[string]any
|
|
_ = json.Unmarshal(cfg, &m)
|
|
dnsRules, ok := m["dns"].(map[string]any)["rules"].([]any)
|
|
if !ok || len(dnsRules) == 0 {
|
|
t.Fatalf("split on should have dns.rules (geosite-cn→local), got %v", m["dns"])
|
|
}
|
|
dr := dnsRules[0].(map[string]any)
|
|
if dr["server"] != "local" || dr["rule_set"] == nil {
|
|
t.Errorf("dns rule should route geosite-cn → local, got %v", dr)
|
|
}
|
|
|
|
// 关闭分流 → 无 rule_set,且 DNS 无分流规则(全量 remote)。
|
|
cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{})
|
|
if _, ok := routeOf(t, cfg2)["rule_set"]; ok {
|
|
t.Error("split off should have no rule_set")
|
|
}
|
|
var m2 map[string]any
|
|
_ = json.Unmarshal(cfg2, &m2)
|
|
if _, ok := m2["dns"].(map[string]any)["rules"]; ok {
|
|
t.Error("split off should have no dns.rules")
|
|
}
|
|
|
|
// 开启但缺 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 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 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TUN 入站必须把私有 LAN 网段从隧道排除(route_exclude_address),否则 strict_route
|
|
// 会在 macOS 把 LAN 强抓进隧道 → 隧道开着连不上局域网/NAS。不得含 172.16/12
|
|
// (隧道自身 172.19.x 在此段,排除会断 DNS)。
|
|
func TestBuildClientConfigLANExclude(t *testing.T) {
|
|
cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{})
|
|
if err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(cfg, &m); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
var tun map[string]any
|
|
for _, in := range m["inbounds"].([]any) {
|
|
im := in.(map[string]any)
|
|
if im["type"] == "tun" {
|
|
tun = im
|
|
break
|
|
}
|
|
}
|
|
if tun == nil {
|
|
t.Fatal("no tun inbound")
|
|
}
|
|
exRaw, ok := tun["route_exclude_address"]
|
|
if !ok {
|
|
t.Fatal("tun inbound missing route_exclude_address (LAN would be captured by strict_route)")
|
|
}
|
|
got := map[string]bool{}
|
|
for _, v := range exRaw.([]any) {
|
|
got[v.(string)] = true
|
|
}
|
|
if !got["192.168.0.0/16"] {
|
|
t.Error("route_exclude_address must contain 192.168.0.0/16")
|
|
}
|
|
if !got["10.0.0.0/8"] {
|
|
t.Error("route_exclude_address must contain 10.0.0.0/8")
|
|
}
|
|
if got["172.16.0.0/12"] {
|
|
t.Error("route_exclude_address must NOT contain 172.16.0.0/12 (tunnel DNS 172.19.x lives there)")
|
|
}
|
|
}
|