Merge branch 'main' into feat/configurable-proxy
# Conflicts: # .gitignore # client/lib/l10n/strings_es.dart # client/lib/l10n/strings_ja.dart # client/lib/l10n/strings_ko.dart # client/lib/l10n/strings_ru.dart # docs/index.html # scripts/local_test.sh
This commit is contained in:
@@ -15,6 +15,13 @@ type ClientConfigOpts struct {
|
||||
// RulesBaseURL 是控制面对外公网基址(如 "http://node:8080"),rule_set 的 .srs
|
||||
// 从 <base>/v1/rules/*.srs 下载。SplitCN 生效需此项非空(否则分流静默跳过)。
|
||||
RulesBaseURL string
|
||||
// PrivateSplitDomains 私有服务域名(家庭内网穿透,如 nas/git/win.yanmeiai.com,
|
||||
// 服务端 env PANGOLIN_PRIVATE_SPLIT_DOMAINS 配置;空=行为完全不变):
|
||||
// - DNS 改用系统解析器(在家吃到局域网 DNS 覆盖→私网 IP;在外解析出公网锚点)
|
||||
// - 路由上私网结果命中 LAN 直连(在家零绕行),公网结果强制走隧道——该规则
|
||||
// 必须排在国内分流(geoip-cn)之前:锚点(frps@ali)是国内 IP,否则 smartRoute
|
||||
// 会把它分流成直连,被 frps 侧安全组限源(仅节点出口)拦截。
|
||||
PrivateSplitDomains []string
|
||||
}
|
||||
|
||||
// BuildClientConfig renders a complete sing-box CLIENT configuration JSON that
|
||||
@@ -106,6 +113,12 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
||||
"auto_route": true,
|
||||
"strict_route": true,
|
||||
"stack": "system",
|
||||
// 私有 LAN 直连:strict_route 会在 OS 层把所有流量(含 LAN)强抓进隧道,
|
||||
// 光靠 route.rules 的 ip_cidr→direct 在 macOS 不生效(direct 出站的包被
|
||||
// strict_route 重新捕回隧道)。route_exclude_address 在 auto_route 层就把这些
|
||||
// 网段排除出隧道,LAN 走系统直连(修「隧道开着连不上局域网/NAS/家里机器」)。
|
||||
// **不含 172.16.0.0/12**:隧道自身地址与 DNS(172.19.x)在此段,排除会断 DNS。
|
||||
"route_exclude_address": []string{"192.168.0.0/16", "10.0.0.0/8"},
|
||||
}
|
||||
|
||||
// 代理出站集合:REALITY 必有;Hy2 仅在启用时加入(否则不进配置/探测组)。
|
||||
@@ -126,7 +139,8 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
||||
"tolerance": 50,
|
||||
}
|
||||
|
||||
// Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。
|
||||
// Route: DNS 劫持 → LAN direct →(可选)私有域名走隧道 →(可选)国内直连 → 其余 via auto。
|
||||
privateSplit := len(opts.PrivateSplitDomains) > 0
|
||||
routeRules := []any{
|
||||
// DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff):
|
||||
// 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN
|
||||
@@ -138,6 +152,15 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
||||
"outbound": "direct",
|
||||
},
|
||||
}
|
||||
if privateSplit {
|
||||
// 私有域名强制走隧道。在家不受此规则影响:系统 DNS(局域网覆盖)解析出私网 IP,
|
||||
// 上面的 LAN 直连规则先命中。域名元数据靠 dns.reverse_mapping 补回(应用自行
|
||||
// 解析后按 IP 连接,无回映射则此规则永不匹配、在外会掉进国内分流被限源拦截)。
|
||||
routeRules = append(routeRules, map[string]any{
|
||||
"domain": opts.PrivateSplitDomains,
|
||||
"outbound": "auto",
|
||||
})
|
||||
}
|
||||
route := map[string]any{
|
||||
"final": "auto",
|
||||
"auto_detect_interface": true,
|
||||
@@ -166,24 +189,42 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
||||
// DNS: remote over tunnel, local for domestic.
|
||||
// sing-box 1.12+ DNS server format(type+server);旧的 address 串格式在
|
||||
// 1.13 已 FATAL 拒绝(legacy DNS servers deprecated)。
|
||||
dnsServers := []any{
|
||||
map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"},
|
||||
// local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站
|
||||
// (FATAL: detour to an empty direct outbound makes no sense)。
|
||||
map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"},
|
||||
}
|
||||
if privateSplit {
|
||||
// 系统解析器(type=local,经底层物理网络):在家=路由器 DHCP 下发的局域网 DNS
|
||||
// (含私有域名覆盖→私网 IP),在外=所在网络 DNS(公网记录→锚点 IP)。
|
||||
// 不能用 223.5.5.5/8.8.8.8——公共 DNS 不知道家里的覆盖记录。
|
||||
dnsServers = append(dnsServers, map[string]any{"tag": "dns-system", "type": "local"})
|
||||
}
|
||||
dns := map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"},
|
||||
// local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站
|
||||
// (FATAL: detour to an empty direct outbound makes no sense)。
|
||||
map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"},
|
||||
},
|
||||
"servers": dnsServers,
|
||||
"final": "remote",
|
||||
"strategy": "ipv4_only",
|
||||
}
|
||||
dnsRules := []any{}
|
||||
if privateSplit {
|
||||
// 私有域名规则须排在 geosite-cn 之前(优先级最高)。
|
||||
dnsRules = append(dnsRules, map[string]any{
|
||||
"domain": opts.PrivateSplitDomains, "server": "dns-system",
|
||||
})
|
||||
// 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回
|
||||
// 域名元数据——路由层的 domain 规则(私有域名→隧道)靠它才会命中。
|
||||
dns["reverse_mapping"] = true
|
||||
}
|
||||
// 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的
|
||||
// 国内域名用 local(223.5.5.5)直连解析,不走 remote(8.8.8.8 经隧道)。否则即便数据
|
||||
// 直连,域名解析仍绕道出海(实测国内 DNS 段 200-600ms),首连凭空多一个出海 RTT。
|
||||
// 复用 route.rule_set 里已定义的 geosite-cn 标签。
|
||||
if splitActive {
|
||||
dns["rules"] = []any{
|
||||
map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"},
|
||||
}
|
||||
dnsRules = append(dnsRules, map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"})
|
||||
}
|
||||
if len(dnsRules) > 0 {
|
||||
dns["rules"] = dnsRules
|
||||
}
|
||||
|
||||
cfg := map[string]any{
|
||||
|
||||
@@ -91,6 +91,84 @@ func TestBuildClientConfigSplitCN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -115,3 +193,45 @@ func TestRulesHandler(t *testing.T) {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,16 @@ type NodeAPI struct {
|
||||
// rulesBaseURL 是控制面对外公网基址(PANGOLIN_PUBLIC_URL),供国内分流的
|
||||
// rule_set .srs 下载用;空则分流不生效。
|
||||
rulesBaseURL string
|
||||
// privateSplitDomains 私有服务域名(PANGOLIN_PRIVATE_SPLIT_DOMAINS,逗号分隔),
|
||||
// 见 ClientConfigOpts.PrivateSplitDomains;空则不渲染相关规则。
|
||||
privateSplitDomains []string
|
||||
}
|
||||
|
||||
// NewNodeAPI creates a NodeAPI. load may be nil (then all nodes are treated as
|
||||
// data-plane healthy — agent gRPC liveness still gates status).
|
||||
func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string) *NodeAPI {
|
||||
return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey, rulesBaseURL: rulesBaseURL}
|
||||
func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string, privateSplitDomains []string) *NodeAPI {
|
||||
return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey,
|
||||
rulesBaseURL: rulesBaseURL, privateSplitDomains: privateSplitDomains}
|
||||
}
|
||||
|
||||
// dataPlaneHealthy reports the node's last sing-box health (default true when
|
||||
@@ -315,7 +319,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
// 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, dpUUID, a.deriveKey,
|
||||
ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL})
|
||||
ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL,
|
||||
PrivateSplitDomains: a.privateSplitDomains})
|
||||
if renderErr != nil {
|
||||
slog.Error("connect: build client config failed", "node", nodeUUID, "err", renderErr)
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
@@ -324,7 +329,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
// 可观测:国内分流是否真正生效(split_active=两个条件都满足才渲染 rule_set)。
|
||||
slog.Info("client config rendered", "node", nodeUUID, "split_cn", splitCN,
|
||||
"rules_base_set", a.rulesBaseURL != "",
|
||||
"split_active", splitCN && a.rulesBaseURL != "", "bytes", len(cfgJSON))
|
||||
"split_active", splitCN && a.rulesBaseURL != "",
|
||||
"private_split", len(a.privateSplitDomains), "bytes", len(cfgJSON))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(cfgJSON)
|
||||
|
||||
@@ -51,7 +51,7 @@ func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1
|
||||
|
||||
func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int {
|
||||
t.Helper()
|
||||
api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除
|
||||
api := NewNodeAPI(store, nil, nil, "", "", nil) // nil hub:跳过 Push,只验证凭证删除
|
||||
req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "node-1")
|
||||
|
||||
Reference in New Issue
Block a user