diff --git a/server/internal/agentd/acl.go b/server/internal/agentd/acl.go index d9af0d7..537cbad 100644 --- a/server/internal/agentd/acl.go +++ b/server/internal/agentd/acl.go @@ -3,6 +3,7 @@ package agentd import ( "encoding/json" "fmt" + "net/netip" "os" "strings" ) @@ -47,9 +48,39 @@ func LoadACLConfig(path string) (*ACLConfig, error) { if err := json.Unmarshal(data, &ac); err != nil { return nil, fmt.Errorf("agentd: parse acl config %q: %w", path, err) } + if err := ac.validate(); err != nil { + return nil, fmt.Errorf("agentd: acl config %q: %w", path, err) + } return &ac, nil } +// validate 校验每个 target 的 ip_cidr/port 取值本身是否合法(与 cleanTargets 只做 +// trim/lower、完全不校验取值的定位不同)。一个语法正确但语义非法的值(如 +// port:70000、ip_cidr:"not-a-cidr")会被 encoding/json 顺利接受,一路渲染进 +// sing-box 配置——sing-box 自己在 reload/restart 时会拒绝它并保留旧实例,但那时 +// agent 早已把这份坏配置当"渲染成功"写盘,运维靠 journalctl/看 config.json 内容 +// 完全看不出线上 gate 其实没生效。故必须在加载阶段就把这类值当加载失败处理, +// 使其走 fail-closed 的 last-good 回退路径,而不是让它成为渲染产物。 +func (ac *ACLConfig) validate() error { + for i, t := range ac.Targets { + for _, c := range t.IPCIDR { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, err := netip.ParsePrefix(c); err != nil { + return fmt.Errorf("target[%d] invalid ip_cidr %q: %w", i, c, err) + } + } + for _, p := range t.Port { + if p < 1 || p > 65535 { + return fmt.Errorf("target[%d] invalid port %d (must be 1-65535)", i, p) + } + } + } + return nil +} + // empty 报告该 target 是否没有任何匹配条件(没有条件的规则会匹配一切,危险)。 func (t ACLTarget) empty() bool { return len(t.Domain) == 0 && len(t.DomainSuffix) == 0 && diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go index 3753ed1..b4adeb9 100644 --- a/server/internal/agentd/acl_test.go +++ b/server/internal/agentd/acl_test.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -699,3 +700,121 @@ func TestACL_FallbackLogsCarryWarnLevel(t *testing.T) { t.Fatalf("内存 last-good 兜底日志缺 WARN 级别标签,实际日志:\n%s", logged) } } + +// ─── I1: 非法 ip_cidr/port 必须在加载阶段被拒绝,走 fail-closed last-good ────── + +// port 超出 1-65535、ip_cidr 不是合法 CIDR,cleanTargets 此前只做 trim/lower, +// 完全不校验取值——这类配置会被当作"合法"渲染进 sing-box 配置,而 sing-box 自己 +// 会在运行时(reload/restart)拒绝它、保留旧实例,导致"渲染产物落盘看起来正常, +// 但线上 gate 其实没生效"这种难排查的静默失败。加载阶段直接拒绝,让它走 +// last-good 兜底(与解析失败同一条路径),而不是把坏值一路渲染出去。 +func TestLoadACLConfig_RejectsInvalidPort(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "acl.json") + writeACL(t, p, `{ + "enabled": true, + "allow_dp_uuids": ["u"], + "targets": [{ "ip_cidr": ["182.92.213.171/32"], "port": [70000] }] + }`) + if _, err := LoadACLConfig(p); err == nil { + t.Fatal("port 70000 超出合法范围(1-65535),LoadACLConfig 应返回 error") + } +} + +func TestLoadACLConfig_RejectsInvalidCIDR(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "acl.json") + writeACL(t, p, `{ + "enabled": true, + "allow_dp_uuids": ["u"], + "targets": [{ "ip_cidr": ["not-a-cidr"], "port": [443] }] + }`) + if _, err := LoadACLConfig(p); err == nil { + t.Fatal("ip_cidr \"not-a-cidr\" 不是合法 CIDR,LoadACLConfig 应返回 error") + } +} + +// 非法配置在 loadACL 层面必须走 last-good 兜底(与解析失败同一条路径),而不是 +// 被渲染进 sing-box 配置。 +func TestACL_InvalidPortFallsBackToLastGood(t *testing.T) { + cfg := testConfig(t) + writeACL(t, cfg.ACLConfigPath, validACL) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + if _, err := sb.RenderConfig(); err != nil { + t.Fatal(err) + } + + writeACL(t, cfg.ACLConfigPath, `{ + "enabled": true, + "allow_dp_uuids": ["u"], + "targets": [{ "ip_cidr": ["182.92.213.171/32"], "port": [70000] }] + }`) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "70000") { + t.Fatal("非法 port 70000 不应该被渲染进 sing-box 配置,应走 last-good 兜底") + } + if !strings.Contains(string(data), "reject") { + t.Fatal("非法配置应走 last-good 兜底,拒绝规则不应消失") + } +} + +// 产物合法性:渲染结果喂给真实 sing-box 二进制的 `check` 子命令,必须 exit 0。 +// 这把设计 §9 "产物合法性" 验收项变成一条常驻测试,而不是只在上线前手工跑一次。 +// +// 刻意不经 SingBox/ApplyConfig 走完整 reality/hy2 inbound(sampleSnapshot 里的 +// PrivateKey/CertPath 都是占位假值,sing-box 会因证书/密钥不合法而拒绝——那是 +// 另一类问题,不是本测试要盯的 ACL route 合法性)。直接调用 renderSingboxConfig, +// reality/hy2 传 nil,只产出 outbounds + ACL route,聚焦 §9 要验的东西。 +func TestRenderedConfig_PassesSingBoxCheck(t *testing.T) { + singboxBin, err := exec.LookPath("sing-box") + if err != nil { + t.Skip("sing-box not installed, skipping resident config-validity check") + } + + ac, err := LoadACLConfig(writeTempACL(t, validACL)) + if err != nil { + t.Fatal(err) + } + data, err := renderSingboxConfig(nil, nil, nil, "test-derive-key", nil, ac) + if err != nil { + t.Fatal(err) + } + + // homebrew 装的本机 sing-box 二进制没有编译 with_v2ray_api tag(生产节点上的 + // 那份是),`v2ray_api` 这段实验性配置在本机会导致 check 因为"功能未编译进来" + // 而 FATAL——这与本测试要验的 ACL/route 语法合法性无关,故只为本次校验剥离它。 + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if exp, ok := m["experimental"].(map[string]any); ok { + delete(exp, "v2ray_api") + } + data, err = json.Marshal(m) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(cfgPath, data, 0o600); err != nil { + t.Fatal(err) + } + + out, err := exec.Command(singboxBin, "check", "-c", cfgPath).CombinedOutput() + if err != nil { + t.Fatalf("sing-box check 未通过(exit != 0): %v\n%s", err, out) + } +} + +// writeTempACL 把 body 写到临时目录下的 acl.json 并返回路径。 +func writeTempACL(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "acl.json") + writeACL(t, p, body) + return p +}