2abe4d0816
生产验证发现: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>
871 lines
31 KiB
Go
871 lines
31 KiB
Go
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
|
||
}
|