Files
pangolin/server/internal/agentd/acl_test.go
T
wangjia 2abe4d0816 fix(agent): ACL 放行用 auth_user 而非 user(VLESS 运行时只认 auth_user)
生产验证发现:sing-box 1.13 的 route rule 里 user 字段对 VLESS/REALITY
入站运行时不匹配(仅 sing-box check 语法通过),导致放行规则永不命中、
白名单用户也被兜底拒绝。本地真 VLESS 连接实测确认 auth_user 生效
(good→通/bad→block),node 端 git 一进一出白名单验证 per-user 生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:36:02 +08:00

871 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// captureLog 把标准库 log 包(logf 的底层输出)重定向到一个 buffer,测试结束
// (t.Cleanup)后自动还原到原输出。agentd 包内没有自己的 logger 抽象——logf 直接
// 调 log.Printf——但标准库 log 本身就是一个可重定向的现成缝隙,不需要为了可测试性
// 另外引入 logger 接口。包内测试从不并发跑(无 t.Parallel),重定向全局 log 输出
// 是安全的。
func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
orig := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(orig) })
return &buf
}
// writeACL 把 acl.json 写到指定路径。
func writeACL(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
const validACL = `{
"enabled": true,
"allow_dp_uuids": ["uuid-me-1", "uuid-me-sub"],
"targets": [
{ "domain": ["brain.51yanmei.com", "git.51yanmei.com"] },
{ "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
]
}`
func TestLoadACLConfig(t *testing.T) {
dir := t.TempDir()
t.Run("文件不存在返回 nil,nil(未配置,不是错误)", func(t *testing.T) {
ac, err := LoadACLConfig(filepath.Join(dir, "missing.json"))
if err != nil {
t.Fatalf("want nil error, got %v", err)
}
if ac != nil {
t.Fatalf("want nil config, got %+v", ac)
}
})
t.Run("坏 JSON 返回 error(绝不静默降级)", func(t *testing.T) {
p := filepath.Join(dir, "bad.json")
writeACL(t, p, `{"enabled": true,`)
if _, err := LoadACLConfig(p); err == nil {
t.Fatal("want error for malformed JSON, got nil")
}
})
t.Run("合法配置解析出全部字段", func(t *testing.T) {
p := filepath.Join(dir, "acl.json")
writeACL(t, p, validACL)
ac, err := LoadACLConfig(p)
if err != nil {
t.Fatal(err)
}
if !ac.Enabled {
t.Error("Enabled = false, want true")
}
if len(ac.AllowDpUUIDs) != 2 {
t.Errorf("AllowDpUUIDs len = %d, want 2", len(ac.AllowDpUUIDs))
}
if len(ac.Targets) != 2 {
t.Fatalf("Targets len = %d, want 2", len(ac.Targets))
}
if len(ac.Targets[0].Domain) != 2 {
t.Errorf("Targets[0].Domain len = %d, want 2", len(ac.Targets[0].Domain))
}
if len(ac.Targets[1].Port) != 4 {
t.Errorf("Targets[1].Port len = %d, want 4", len(ac.Targets[1].Port))
}
})
}
// active() 的语义与 WARP 相反:空白名单不等于「关闭」,而等于「谁都不许进」。
func TestACLActive_FailClosed(t *testing.T) {
cases := []struct {
name string
ac *ACLConfig
want bool
}{
{"nil 配置 → 未启用", nil, false},
{"enabled=false → 未启用(唯一的合法关闭途径)", &ACLConfig{
Enabled: false,
AllowDpUUIDs: []string{"u"},
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
}, false},
{"无 target → 未启用(无从拒起)", &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"u"},
}, false},
{"target 全为空条件 → 未启用", &ACLConfig{
Enabled: true,
Targets: []ACLTarget{{}},
}, false},
{"白名单为空但有 target → 仍启用(拒绝所有人)", &ACLConfig{
Enabled: true,
AllowDpUUIDs: nil,
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
}, true},
{"完整配置 → 启用", &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"u"},
Targets: []ACLTarget{{IPCIDR: []string{"1.2.3.4/32"}, Port: []int{443}}},
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.ac.active(); got != tc.want {
t.Errorf("active() = %v, want %v", got, tc.want)
}
})
}
}
func TestACLCleanHelpers(t *testing.T) {
ac := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{" uuid-a ", "", "uuid-b"},
Targets: []ACLTarget{
{Domain: []string{" BRAIN.51yanmei.com ", ""}},
{},
{IPCIDR: []string{"1.2.3.4/32"}},
},
}
uuids := ac.cleanUUIDs()
if len(uuids) != 2 || uuids[0] != "uuid-a" || uuids[1] != "uuid-b" {
t.Errorf("cleanUUIDs() = %v, want [uuid-a uuid-b]", uuids)
}
targets := ac.cleanTargets()
if len(targets) != 2 {
t.Fatalf("cleanTargets() len = %d, want 2 (空 target 应被丢弃)", len(targets))
}
if targets[0].Domain[0] != "brain.51yanmei.com" {
t.Errorf("域名未规范化为小写去空白: %q", targets[0].Domain[0])
}
}
func TestACLTargetMatchFields(t *testing.T) {
tgt := ACLTarget{
Domain: []string{"a.com"},
IPCIDR: []string{"1.2.3.4/32"},
Port: []int{443, 5001},
}
m := tgt.matchFields()
if _, ok := m["domain"]; !ok {
t.Error("缺 domain 字段")
}
if _, ok := m["ip_cidr"]; !ok {
t.Error("缺 ip_cidr 字段")
}
if _, ok := m["port"]; !ok {
t.Error("缺 port 字段")
}
if _, ok := m["domain_suffix"]; ok {
t.Error("空的 domain_suffix 不应出现在输出里")
}
// matchFields 必须每次返回新 map,否则放行/拒绝两条规则会共享同一对象,
// 给其中一条加 "user"/"action" 会污染另一条。
m2 := tgt.matchFields()
m2["user"] = []string{"x"}
if _, ok := m["user"]; ok {
t.Error("matchFields 返回了共享 map,放行与拒绝规则会互相污染")
}
}
func TestConfigACLPaths(t *testing.T) {
c := Config{StateDir: "/etc/pangolin-agent"}.withDefaults()
if want := "/etc/pangolin-agent/acl.json"; c.ACLConfigPath != want {
t.Errorf("ACLConfigPath = %q, want %q", c.ACLConfigPath, want)
}
if want := "/etc/pangolin-agent/acl.last-good.json"; c.ACLLastGoodPath() != want {
t.Errorf("ACLLastGoodPath() = %q, want %q", c.ACLLastGoodPath(), want)
}
}
// rules() 必须产出「先全部放行、再全部拒绝」,且同一 target 两侧目的地条件逐字相同。
func TestACLRules_AllowThenDeny(t *testing.T) {
ac := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"uuid-me"},
Targets: []ACLTarget{
{Domain: []string{"brain.51yanmei.com"}},
{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}},
},
}
rules := ac.rules()
if len(rules) != 4 {
t.Fatalf("规则数 = %d, want 4 (2 target × 放行+拒绝)", len(rules))
}
// 前两条是放行:带 auth_user + outbound,不带 action
// (auth_user 而非 user:VLESS 入站运行时只认 auth_user,见 acl.go rules() 注释)
for i := 0; i < 2; i++ {
r := rules[i].(map[string]any)
if _, ok := r["auth_user"]; !ok {
t.Errorf("rules[%d] 放行规则缺 auth_user", i)
}
if _, ok := r["user"]; ok {
t.Errorf("rules[%d] 放行规则不应带 user(VLESS 不生效),应用 auth_user", i)
}
if r["outbound"] != directOutboundTag {
t.Errorf("rules[%d] outbound = %v, want %q", i, r["outbound"], directOutboundTag)
}
if _, ok := r["action"]; ok {
t.Errorf("rules[%d] 放行规则不应带 action", i)
}
}
// 后两条是拒绝:带 action=reject,不带 auth_user(对所有人生效)
for i := 2; i < 4; i++ {
r := rules[i].(map[string]any)
if r["action"] != "reject" {
t.Errorf("rules[%d] action = %v, want reject", i, r["action"])
}
if _, ok := r["auth_user"]; ok {
t.Errorf("rules[%d] 拒绝规则不应带 auth_user,否则会漏掉名单外的人", i)
}
}
// 对称性:target[0] 的放行(rules[0])与拒绝(rules[2])目的地条件必须逐字相同
allow0 := rules[0].(map[string]any)
deny0 := rules[2].(map[string]any)
if fmt.Sprint(allow0["domain"]) != fmt.Sprint(deny0["domain"]) {
t.Errorf("target0 放行/拒绝的 domain 不一致: %v vs %v", allow0["domain"], deny0["domain"])
}
allow1 := rules[1].(map[string]any)
deny1 := rules[3].(map[string]any)
if fmt.Sprint(allow1["ip_cidr"]) != fmt.Sprint(deny1["ip_cidr"]) ||
fmt.Sprint(allow1["port"]) != fmt.Sprint(deny1["port"]) {
t.Error("target1 放行/拒绝的 ip_cidr/port 不一致")
}
}
// 空白名单 → 不产出放行规则,但拒绝规则照出(fail-closed 的核心断言)。
func TestACLRules_EmptyAllowlistStillDenies(t *testing.T) {
ac := &ACLConfig{
Enabled: true,
AllowDpUUIDs: nil,
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
}
rules := ac.rules()
if len(rules) != 1 {
t.Fatalf("规则数 = %d, want 1 (仅拒绝)", len(rules))
}
r := rules[0].(map[string]any)
if r["action"] != "reject" {
t.Errorf("action = %v, want reject", r["action"])
}
}
// 未 active(含 nil / enabled=false)→ 无规则。
func TestACLRules_InactiveYieldsNil(t *testing.T) {
var nilACL *ACLConfig
if got := nilACL.rules(); got != nil {
t.Errorf("nil ACL rules() = %v, want nil", got)
}
off := &ACLConfig{Enabled: false, Targets: []ACLTarget{{Domain: []string{"a.com"}}}}
if got := off.rules(); got != nil {
t.Errorf("enabled=false rules() = %v, want nil", got)
}
}
// buildRoute 四态矩阵:ACL×WARP 开关的四种组合。
func TestBuildRoute_Matrix(t *testing.T) {
acl := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"uuid-me"},
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
}
warp := &WarpConfig{
Enabled: true, PrivateKey: "k", PeerPublicKey: "pk",
Endpoint: "162.159.192.1:2408", Address: []string{"172.16.0.2/32"},
Domains: []string{"reddit.com"},
}
t.Run("都关 → 不产出 route(向后兼容)", func(t *testing.T) {
if got := buildRoute(nil, nil); got != nil {
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
}
})
t.Run("仅 WARP → sniff + resolve + warp 规则", func(t *testing.T) {
r := buildRoute(nil, warp)
rules := r["rules"].([]any)
if len(rules) != 3 {
t.Fatalf("规则数 = %d, want 3", len(rules))
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Error("首条不是 sniff")
}
if rules[1].(map[string]any)["action"] != "resolve" {
t.Error("第二条不是 resolve(域名形式的目的地需要先解析才能命中后续 ip_cidr 规则)")
}
if rules[2].(map[string]any)["outbound"] != warpOutboundTag {
t.Error("第三条不是 warp 分流")
}
if r["final"] != directOutboundTag {
t.Errorf("final = %v, want %q", r["final"], directOutboundTag)
}
})
t.Run("仅 ACL → sniff + resolve + 放行 + 拒绝", func(t *testing.T) {
r := buildRoute(acl, nil)
rules := r["rules"].([]any)
if len(rules) != 4 {
t.Fatalf("规则数 = %d, want 4", len(rules))
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Error("首条不是 sniff")
}
if rules[1].(map[string]any)["action"] != "resolve" {
t.Error("第二条不是 resolve")
}
if _, ok := rules[2].(map[string]any)["auth_user"]; !ok {
t.Error("第三条不是放行规则")
}
if rules[3].(map[string]any)["action"] != "reject" {
t.Error("第四条不是拒绝规则")
}
})
t.Run("都开 → sniff + resolve + ACL(放行,拒绝) + warp,且 sniff/resolve 各只出现一次", func(t *testing.T) {
r := buildRoute(acl, warp)
rules := r["rules"].([]any)
if len(rules) != 5 {
t.Fatalf("规则数 = %d, want 5", len(rules))
}
sniffs, resolves := 0, 0
for _, x := range rules {
switch x.(map[string]any)["action"] {
case "sniff":
sniffs++
case "resolve":
resolves++
}
}
if sniffs != 1 {
t.Errorf("sniff 出现 %d 次, want 1", sniffs)
}
if resolves != 1 {
t.Errorf("resolve 出现 %d 次, want 1", resolves)
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Error("sniff 必须最先")
}
if rules[1].(map[string]any)["action"] != "resolve" {
t.Error("resolve 必须紧跟 sniff 之后")
}
// ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口
if rules[4].(map[string]any)["outbound"] != warpOutboundTag {
t.Error("warp 规则必须排在最后")
}
if rules[3].(map[string]any)["action"] != "reject" {
t.Error("ACL 拒绝规则必须排在 warp 之前")
}
})
}
// I3:ip_cidr 目的地能被"域名形式"的请求绕过——ip_cidr 匹配的是已解析的目的地
// 地址,客户端若发送域名形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr 规则
// 不命中,请求穿透到 final:direct。route 块加 {"action":"resolve"} 让节点自己
// 解析目的地,使域名形式收敛到 ip_cidr 规则上。resolve 必须紧跟 sniff 之后、且
// 只在产出 route 块时才出现(与 sniff 同条件)。
func TestBuildRoute_ResolvePositionedRightAfterSniff(t *testing.T) {
acl := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"uuid-me"},
Targets: []ACLTarget{{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}}},
}
r := buildRoute(acl, nil)
rules := r["rules"].([]any)
if len(rules) < 2 {
t.Fatalf("规则数 = %d, 至少要有 sniff+resolve", len(rules))
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Fatal("sniff 必须最先")
}
if rules[1].(map[string]any)["action"] != "resolve" {
t.Fatal("resolve 必须紧跟 sniff 之后")
}
}
// 未配置 ACL 也未启用 WARP → 仍不产出 route 块(resolve 不该单独出现)。
func TestBuildRoute_NeverConfigured_NoResolve(t *testing.T) {
if got := buildRoute(nil, nil); got != nil {
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
}
}
// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
func TestACL_PersistsLastGoodOnLoad(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)
}
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
t.Fatalf("last-good 未落盘: %v", err)
}
}
// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。
func TestACL_BrokenFileKeepsInMemoryLastGood(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,`) // 手抖写坏
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
}
}
// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。
func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb1 := NewSingBox(cfg, nil)
sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb1.RenderConfig(); err != nil {
t.Fatal(err)
}
writeACL(t, cfg.ACLConfigPath, `not json at all`)
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb2.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")
}
}
// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
cfg := testConfig(t)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatal(err)
}
if _, ok := m["route"]; ok {
t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")
}
}
// acl.json 被删除 → 规则不能消失(内存 last-good 兜底)。
func TestACL_DeletedFileKeepsInMemoryLastGood(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)
}
if err := os.Remove(cfg.ACLConfigPath); err != nil {
t.Fatalf("failed to remove acl.json: %v", err)
}
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("acl.json 被删除后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
}
}
// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后
// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。
func TestSingBoxRefresh_TriggersRender(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
fr := &fakeRestarter{}
sb := NewSingBox(cfg, fr)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sb.Run(ctx)
// 等首次渲染落地(ApplyConfig 已 markDirty)
eventually(t, 2*time.Second, func() bool {
_, err := os.Stat(cfg.SingboxConfigPath)
return err == nil
}, "首次渲染写出配置")
// 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染
// 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。
before := fr.reloadCount()
sb.Refresh()
eventually(t, 2*time.Second, func() bool { return fr.reloadCount() > before }, "Refresh 触发了热重载")
}
// 新 agent 实例(模拟进程重启) + 被删的 acl.json → 磁盘 last-good 兜底,规则仍在。
func TestACL_ColdStartAfterDeletedFileFallsBackToDisk(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb1 := NewSingBox(cfg, nil)
sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb1.RenderConfig(); err != nil {
t.Fatal(err)
}
if err := os.Remove(cfg.ACLConfigPath); err != nil {
t.Fatalf("failed to remove acl.json: %v", err)
}
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb2.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("冷启动(acl.json 删除)未回退到磁盘 last-good,私有服务已敞开")
}
}
// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
func TestACLExampleFileStaysValid(t *testing.T) {
path := filepath.Join("..", "..", "..", "deploy", "single-node", "acl.json.example")
ac, err := LoadACLConfig(path)
if err != nil {
t.Fatalf("样例文件解析失败 %s: %v", path, err)
}
if ac == nil {
t.Fatalf("样例文件不存在: %s", path)
}
if !ac.active() {
t.Error("样例文件应当是一份 active 的 ACL")
}
if len(ac.Targets) != 2 {
t.Errorf("样例 targets = %d, want 2", len(ac.Targets))
}
// 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
for _, u := range ac.AllowDpUUIDs {
if !strings.HasPrefix(u, "TODO-") {
t.Errorf("样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git", u)
}
}
}
// ─── C2: 手抖字段名(如 targets → targetz)不得静默关闸,也不得覆盖 last-good ──────
// 字段名手抖(targetz)→ encoding/json 忽略未知字段,Targets 变 nil,active()=false。
// 若不做特殊处理,旧代码会把这份"看似合法"的空配置当成新的 last-good 落盘,
// 把恢复用的快照也一起冲掉。正确行为:当作加载失败处理,回退 last-good,不覆盖磁盘。
func TestACL_TypoFieldWithEnabledTrue_FallsBackWithoutOverwritingLastGood(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)
}
before, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatalf("last-good 未在健康加载后落盘: %v", err)
}
// "targets" 手抖成 "targetz":JSON 仍能解析,但 Targets 字段收不到值。
writeACL(t, cfg.ACLConfigPath, `{"enabled":true,"allow_dp_uuids":["x"],"targetz":[{"domain":["a.com"]}]}`)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("字段名手抖(targetz)后拒绝规则消失了 —— 这是静默关闸,私有服务已敞开")
}
after, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("手抖配置覆盖了 acl.last-good.json —— 恢复用的快照被销毁了")
}
}
// 显式 enabled:false 是唯一合法的关闭方式,渲染结果必须真的没有 reject 规则;
// 但它不该覆盖磁盘上已有的 last-good 快照——否则日后 acl.json 意外损坏/丢失时,
// 回退到的将是这份"已关闭"的快照而不是最近一次真正 active 的配置。
func TestACL_ExplicitDisableDoesNotOverwriteLastGood(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)
}
before, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatalf("last-good 未在健康加载后落盘: %v", err)
}
writeACL(t, cfg.ACLConfigPath, `{"enabled": false, "allow_dp_uuids": ["x"], "targets": [{"domain":["a.com"]}]}`)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "reject") {
t.Fatal("enabled=false 应真正关闭 ACL,不应再产出 reject 规则")
}
after, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("显式 enabled=false 不该覆盖 last-good 快照,否则日后 acl.json 损坏就回不到真正 active 的配置了")
}
}
// 白名单为空但 target 有效 → 是合法的"拒绝所有人"配置,不是手抖,必须仍被当作
// last-good 持久化,且仍产出拒绝规则。防止 C2 修复对"零 target"的特判过度矫正到
// "零白名单"上。
func TestACL_EnabledWithEmptyAllowlistStillPersistsAndDenies(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, `{"enabled": true, "allow_dp_uuids": [], "targets": [{"domain": ["brain.51yanmei.com"]}]}`)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("空白名单+有效 target 应仍产出拒绝规则(拒所有人),不该被当成手抖")
}
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
t.Fatalf("空白名单(但有效 target)的配置应被当作合法 active 配置持久化为 last-good: %v", err)
}
}
// ─── I2: last-good 双失败必须大声告警,读盘错误不能被吞掉 ─────────────────────
// acl.json 缺失 + 磁盘 last-good 损坏(存在但解析不了,模拟卷挂载异常/写坏)→
// 两级兜底都失败,gate 实质消失,必须在 loadACL 的终点打 ALERT——这正是设计 §5
// "两者都失败才不产出 ACL 规则,同时打 ERROR 并告警" 要求的那一档。
//
// 注:没有采用"把两个文件都整个删掉"来复现,因为那种状态在文件系统层面与
// "这台节点从没配置过 ACL"完全无法区分(两次 os.Stat 都是 not-exist),任何仅凭
// 现有文件状态判断的实现都做不出区分;若真要区分,需要额外的持久化标记,超出本
// finding 的范围。"last-good 文件存在但损坏"则是一个可观察、可被 os.Stat 命中
// 的信号,且更贴近 §5 描述的真实故障("last-good 损坏"),故以此复现。
func TestACL_BothLastGoodFailuresAlert(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb1 := NewSingBox(cfg, nil)
sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb1.RenderConfig(); err != nil {
t.Fatal(err) // acl.last-good.json 现已落盘
}
if err := os.Remove(cfg.ACLConfigPath); err != nil {
t.Fatalf("failed to remove acl.json: %v", err)
}
writeACL(t, cfg.ACLLastGoodPath(), `{"enabled": true,`) // 磁盘 last-good 也损坏
buf := captureLog(t)
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空(模拟进程重启)
sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
data, err := sb2.RenderConfig()
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "reject") {
t.Fatal("两级 last-good 都失败,不应该还能产出规则(此断言只是确认前提)")
}
logged := buf.String()
if !strings.Contains(logged, "ALERT") {
t.Fatalf("两级 last-good 都失败(acl.json 缺失 + 磁盘 last-good 损坏)必须打 ALERT,实际日志:\n%s", logged)
}
if !strings.Contains(logged, "ERROR reading last-good") {
t.Fatalf("读磁盘 last-good 失败的 derr 必须落日志,实际日志:\n%s", logged)
}
}
// 从未配置过 ACL(acl.json 和 last-good 都从来没存在过)→ 终点应保持安静,不误报
// ALERT。这条守着"没用这个功能的节点不该被日志刷屏"。
func TestACL_NeverConfiguredNoAlert(t *testing.T) {
cfg := testConfig(t)
buf := captureLog(t)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb.RenderConfig(); err != nil {
t.Fatal(err)
}
if logged := buf.String(); strings.Contains(logged, "ALERT") {
t.Fatalf("从未配置过 ACL 的节点不该收到 ALERT(会造成告警刷屏),实际日志:\n%s", logged)
}
}
// 降级路径(内存/磁盘 last-good 兜底成功)的日志必须带 WARN 级别标签,便于
// journalctl | grep -E 'ERROR|WARN|ALERT' 抓到"已恢复但曾经降级"的状态。
func TestACL_FallbackLogsCarryWarnLevel(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,`) // 手抖写坏,触发内存 last-good 兜底
buf := captureLog(t)
if _, err := sb.RenderConfig(); err != nil {
t.Fatal(err)
}
if logged := buf.String(); !strings.Contains(logged, "WARN falling back to in-memory") {
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
}