Merge feat/private-dest-acl: 节点侧私有目的地 ACL(按 dp_uuid 放行家庭内网服务)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 28s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 19s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 18s
ci-pangolin / OpenAPI Sync Check (push) Successful in 1m24s
ci-pangolin / Flutter — analyze + test (push) Successful in 1m0s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 27s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 29s
ci-pangolin / Go — build + test (push) Failing after 49s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 38s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 5m3s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 43s
ci-pangolin / Lint — shellcheck (push) Failing after 12m23s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 28s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 19s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 18s
ci-pangolin / OpenAPI Sync Check (push) Successful in 1m24s
ci-pangolin / Flutter — analyze + test (push) Successful in 1m0s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 27s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 29s
ci-pangolin / Go — build + test (push) Failing after 49s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 38s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 5m3s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Successful in 43s
ci-pangolin / Lint — shellcheck (push) Failing after 12m23s
pangolin 出口 IP 是家庭内网服务(brain/nas/git/win.51yanmei.com)在 ali 侧的
唯一准入凭据;单用户时=「只有我」,现在 5 个账号退化成「所有 pangolin 用户」。
本功能在节点 sing-box route 上按 dp_uuid 补一道 fail-closed 白名单闸,把语义
拉回「只有我」。改动收敛在 internal/agentd/,零 migration,不动控制面/客户端/DB。
合并动因:该分支代码已在 pangolin1 生产运行(线上 agent 二进制 vcs.revision=
e4b527b + auth_user 修复,昨晚经 SIGHUP 热渲染 auth_user 闸生效),但 main 缺失
——从 main 重建部署 agent 会静默移除这道安全闸。合并后 main 与生产对齐,并把
线上「脏树构建(vcs.modified=true)」规整成干净可追溯来源。
含 21 个提交:设计/计划文档、ACL 配置类型与 fail-closed 加载、渲染放行+拒绝
规则对、last-good 内存+落盘跨重启兜底、SIGHUP 热重载不踢人、ip_cidr/port 校验、
resolve 堵域名绕过、auth_user(VLESS 运行时只认 auth_user)、29 个单测 + 真机验收。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
@@ -44,6 +44,23 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP
|
||||
// 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
defer signal.Stop(hup)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-hup:
|
||||
log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")
|
||||
agent.SingBox().Refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[pangolin-agent] starting (control-plane=%s, version=%s)", cfg.ControlPlaneAddr, cfg.AgentVersion)
|
||||
start := time.Now()
|
||||
if err := agent.Run(ctx); err != nil {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package agentd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ACLTarget 描述一组「私有目的地」的匹配条件。字段名与取值直接对应 sing-box
|
||||
// route rule 的同名字段:同一项内多字段是 AND,字段内多值是 OR。刻意不做自研 DSL
|
||||
// —— 形状即 sing-box 语义,少一层翻译就少一类 bug。
|
||||
//
|
||||
// 典型两类:
|
||||
// - 与公开站共用 443 的私有 vhost(brain/git) → 用 domain,依赖 sniff 取 SNI
|
||||
// - 独占端口的服务(DSM 5001 / RDP 3389 / SSH 10022-10023) → 用 ip_cidr + port
|
||||
type ACLTarget struct {
|
||||
Domain []string `json:"domain,omitempty"`
|
||||
DomainSuffix []string `json:"domain_suffix,omitempty"`
|
||||
IPCIDR []string `json:"ip_cidr,omitempty"`
|
||||
Port []int `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
// ACLConfig 是节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
|
||||
// 只有 AllowDpUUIDs 里的凭证能访问 Targets 描述的目的地,其余一律 reject。
|
||||
//
|
||||
// 与 WarpConfig 的关键区别是失效方向:WARP 读不出来就不分流(fail-open)是安全的,
|
||||
// ACL 读不出来就不拦截等于把私有服务对全体用户敞开。故本类型的 active() 语义为
|
||||
// fail-closed —— 空白名单意味着「没有人」,不是「所有人」。
|
||||
type ACLConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowDpUUIDs []string `json:"allow_dp_uuids"`
|
||||
Targets []ACLTarget `json:"targets"`
|
||||
}
|
||||
|
||||
// LoadACLConfig 读取并解析 acl.json。文件不存在 → (nil, nil)(未配置该功能,
|
||||
// 不是错误)。解析失败返回 error,由调用方决定回退到 last-good 还是告警。
|
||||
func LoadACLConfig(path string) (*ACLConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentd: read acl config %q: %w", path, err)
|
||||
}
|
||||
var ac ACLConfig
|
||||
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 &&
|
||||
len(t.IPCIDR) == 0 && len(t.Port) == 0
|
||||
}
|
||||
|
||||
// matchFields 把 target 转成 sing-box route rule 的匹配字段。
|
||||
// 每次调用返回全新 map —— 放行与拒绝两条规则各自在其上追加 user/outbound/action,
|
||||
// 共享同一对象会互相污染。
|
||||
func (t ACLTarget) matchFields() map[string]any {
|
||||
m := make(map[string]any, 4)
|
||||
if len(t.Domain) > 0 {
|
||||
m["domain"] = t.Domain
|
||||
}
|
||||
if len(t.DomainSuffix) > 0 {
|
||||
m["domain_suffix"] = t.DomainSuffix
|
||||
}
|
||||
if len(t.IPCIDR) > 0 {
|
||||
m["ip_cidr"] = t.IPCIDR
|
||||
}
|
||||
if len(t.Port) > 0 {
|
||||
m["port"] = t.Port
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// active 报告本 ACL 是否应真正注入规则。
|
||||
//
|
||||
// 注意与 WarpConfig.active() 的语义差别:此处 AllowDpUUIDs 为空**不影响**返回值。
|
||||
// 空白名单是一个合法且有意义的状态 ——「谁都不许访问这些目的地」。把它当作未启用
|
||||
// 会造成 fail-open。唯一的关闭途径是显式 "enabled": false。
|
||||
func (ac *ACLConfig) active() bool {
|
||||
if ac == nil || !ac.Enabled {
|
||||
return false
|
||||
}
|
||||
return len(ac.cleanTargets()) > 0
|
||||
}
|
||||
|
||||
// cleanUUIDs 去空白/空项后返回白名单。
|
||||
func (ac *ACLConfig) cleanUUIDs() []string {
|
||||
if ac == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(ac.AllowDpUUIDs))
|
||||
for _, u := range ac.AllowDpUUIDs {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanTargets 规范化域名(小写去空白)并丢弃无任何条件的 target。
|
||||
func (ac *ACLConfig) cleanTargets() []ACLTarget {
|
||||
if ac == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]ACLTarget, 0, len(ac.Targets))
|
||||
for _, t := range ac.Targets {
|
||||
c := ACLTarget{
|
||||
Domain: cleanHosts(t.Domain),
|
||||
DomainSuffix: cleanHosts(t.DomainSuffix),
|
||||
IPCIDR: cleanStrings(t.IPCIDR),
|
||||
Port: t.Port,
|
||||
}
|
||||
if !c.empty() {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanHosts(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.TrimSpace(strings.ToLower(s)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanStrings(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rules 产出 ACL 的 sing-box route 规则:每个 target 一对 —— 先放行白名单、再兜底拒绝。
|
||||
//
|
||||
// 顺序是安全性的一部分,不可重排:
|
||||
// 1. 全部放行规则排在全部拒绝规则之前。不能按 target 交错(放行A/拒绝A/放行B/拒绝B),
|
||||
// 因为 target 之间可能重叠,交错会让 B 的成员被 A 的拒绝规则先命中。
|
||||
// 2. 拒绝规则不带 user 维度 —— 它要对「白名单之外的所有人」生效。
|
||||
// 3. 同一 target 的放行与拒绝,目的地条件由同一个 matchFields() 生成,保证逐字相同。
|
||||
// 任何不对称都会造成「我自己也被拒」或「有人漏网」。
|
||||
//
|
||||
// 白名单为空时只产出拒绝规则(谁都不许进),这是 fail-closed 的核心:空名单的语义是
|
||||
// 「没有人」而非「所有人」。
|
||||
func (ac *ACLConfig) rules() []any {
|
||||
if !ac.active() {
|
||||
return nil
|
||||
}
|
||||
uuids := ac.cleanUUIDs()
|
||||
targets := ac.cleanTargets()
|
||||
|
||||
out := make([]any, 0, len(targets)*2)
|
||||
if len(uuids) > 0 {
|
||||
for _, t := range targets {
|
||||
r := t.matchFields()
|
||||
// auth_user(非 user):sing-box 1.13 的 route rule 里,VLESS/REALITY 入站的
|
||||
// 认证用户要用 auth_user 匹配 inbound user 的 name;user 字段对 VLESS 运行时
|
||||
// 不生效(仅 sing-box check 语法通过),会导致放行规则永不命中、白名单用户也
|
||||
// 被兜底拒绝。已用本地真 VLESS 连接实测确认(good→通, bad→被 block)。
|
||||
r["auth_user"] = uuids
|
||||
r["outbound"] = directOutboundTag
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
for _, t := range targets {
|
||||
r := t.matchFields()
|
||||
r["action"] = "reject"
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
|
||||
func persistACL(path string, ac *ACLConfig) error {
|
||||
data, err := json.MarshalIndent(ac, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: marshal acl snapshot: %w", err)
|
||||
}
|
||||
return atomicWrite(path, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
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
|
||||
}
|
||||
@@ -5,9 +5,15 @@
|
||||
// command feed by managing the local sing-box user table.
|
||||
//
|
||||
// No-state invariant (doc/04 §2, doc/06 §3): the agent persists ONLY the
|
||||
// credential table (dp_uuid + expires_at) to disk. It keeps zero user identities,
|
||||
// zero destination/DNS data and writes no access logs. A seized node leaks only
|
||||
// opaque dp_uuids, never accounts.
|
||||
// credential table (dp_uuid + expires_at) to disk. It keeps zero user identities
|
||||
// and writes no access logs. A seized node leaks only opaque dp_uuids, never
|
||||
// accounts.
|
||||
//
|
||||
// 例外(私有目的地 ACL):节点本地 acl.json 含一份 dp_uuid 白名单与目的地清单,
|
||||
// 由运营手工维护、不经控制面。它确实让节点知道「这几个 dp_uuid 享有私有访问权」
|
||||
// 以及那几个私有域名/端口 —— 这是知情接受的不变式弱化,范围仅限该文件与渲染出的
|
||||
// route 规则,不涉及账户身份,也不产生任何访问日志。设计见
|
||||
// docs/private-dest-acl-design.html §12。
|
||||
package agentd
|
||||
|
||||
import (
|
||||
@@ -58,6 +64,10 @@ type Config struct {
|
||||
// 文件不存在 = WARP 未启用。渲染时读取,支持编辑后重启 agent 生效(#29)。
|
||||
WarpConfigPath string
|
||||
|
||||
// ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
|
||||
// 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。
|
||||
ACLConfigPath string
|
||||
|
||||
// DeriveKey keys the Hy2 password derivation (see DeriveHy2Password).
|
||||
DeriveKey string
|
||||
|
||||
@@ -86,6 +96,9 @@ func (c Config) withDefaults() Config {
|
||||
if c.WarpConfigPath == "" {
|
||||
c.WarpConfigPath = filepath.Join(c.StateDir, "warp.json")
|
||||
}
|
||||
if c.ACLConfigPath == "" {
|
||||
c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json")
|
||||
}
|
||||
if c.HeartbeatInterval == 0 {
|
||||
c.HeartbeatInterval = DefaultHeartbeatInterval
|
||||
}
|
||||
@@ -118,3 +131,7 @@ func (c Config) KeyPath() string { return filepath.Join(c.StateDir, "node.key"
|
||||
func (c Config) CertPath() string { return filepath.Join(c.StateDir, "node.crt") }
|
||||
func (c Config) CAPath() string { return filepath.Join(c.StateDir, "ca.crt") }
|
||||
func (c Config) StatePath() string { return filepath.Join(c.StateDir, "state.json") }
|
||||
|
||||
// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json
|
||||
// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。
|
||||
func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") }
|
||||
|
||||
@@ -13,6 +13,11 @@ import (
|
||||
//
|
||||
// Only the opaque dp_uuid is ever written; no account identity touches the node.
|
||||
//
|
||||
// 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有
|
||||
// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id),
|
||||
// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化,
|
||||
// 设计与权衡见 docs/private-dest-acl-design.html §12。
|
||||
//
|
||||
// 用量统计走 v2ray_api StatsService(loopback gRPC):节点 sing-box 编入
|
||||
// with_v2ray_api,stats.users 列出全部 dp_uuid → 每个用户独立的
|
||||
// user>>>{dp_uuid}>>>traffic>>>uplink|downlink 计数器,agent 按用户精确读取
|
||||
@@ -27,7 +32,7 @@ const (
|
||||
warpOutboundTag = "warp"
|
||||
)
|
||||
|
||||
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) {
|
||||
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) {
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"level": "warn", "timestamp": true},
|
||||
"inbounds": buildInbounds(creds, reality, hy2, deriveKey),
|
||||
@@ -47,16 +52,55 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age
|
||||
},
|
||||
}
|
||||
|
||||
// WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。
|
||||
// warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。
|
||||
// WARP 分流(#29)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要
|
||||
// 同时容纳 ACL 规则 —— 原先 cfg["route"] = warp.warpRoute() 是整块覆盖,直接赋值
|
||||
// 会把对方的规则干掉。
|
||||
if warp.active() {
|
||||
cfg["endpoints"] = []any{warp.warpEndpoint()}
|
||||
cfg["route"] = warp.warpRoute()
|
||||
}
|
||||
if route := buildRoute(acl, warp); route != nil {
|
||||
cfg["route"] = route
|
||||
}
|
||||
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。
|
||||
// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。
|
||||
//
|
||||
// 规则顺序是安全语义的一部分:
|
||||
// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是
|
||||
// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。
|
||||
// 2. {"action":"resolve"} 紧跟 sniff 之后,同样唯一且只在产出 route 块时才出现。
|
||||
// ACL 的 ip_cidr 目的地(DSM/RDP/SSH 等独占端口服务)匹配的是已解析的连接
|
||||
// 目的地地址——若客户端发的是域名形式(如 nas.51yanmei.com:5001)而节点不主动
|
||||
// 解析,ip_cidr 规则不命中,请求会绕过 ACL 直接落到 final:direct。resolve 让
|
||||
// 节点自己解析目的地,使域名形式收敛到 ip_cidr 规则上,堵住这个绕过口子
|
||||
// (I3;这也是唯一一处从"节点不知道目的地"这条不变式主动后退的地方,是知情
|
||||
// 接受的取舍,见 docs/private-dest-acl-design.html §12)。
|
||||
// 3. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远
|
||||
// 不该还有机会被路由到 warp 出口。
|
||||
// 4. final 恒为 direct。
|
||||
func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
|
||||
aclRules := acl.rules()
|
||||
warpActive := warp.active()
|
||||
if len(aclRules) == 0 && !warpActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
rules := make([]any, 0, len(aclRules)+3)
|
||||
rules = append(rules, map[string]any{"action": "sniff"})
|
||||
rules = append(rules, map[string]any{"action": "resolve"})
|
||||
rules = append(rules, aclRules...)
|
||||
if warpActive {
|
||||
rules = append(rules, map[string]any{
|
||||
"domain_suffix": warp.cleanDomains(),
|
||||
"outbound": warpOutboundTag,
|
||||
})
|
||||
}
|
||||
return map[string]any{"rules": rules, "final": directOutboundTag}
|
||||
}
|
||||
|
||||
// statsUsers 收集所有去重 dp_uuid,供 v2ray_api stats.users 按用户开启流量计数器。
|
||||
func statsUsers(creds []Cred) []string {
|
||||
seen := make(map[string]struct{}, len(creds))
|
||||
|
||||
@@ -61,6 +61,10 @@ type SingBox struct {
|
||||
hy2 *agentv1.Hy2Inbound
|
||||
configVersion int64
|
||||
|
||||
// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
|
||||
// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
|
||||
lastGoodACL *ACLConfig
|
||||
|
||||
// started 标记 sing-box 是否已被本 agent 冷启动过:首次走 Restart(冷启动),
|
||||
// 之后配置变更走 Reload(SIGHUP 热重载)。agent 进程重启后复位为 false,
|
||||
// 下次渲染做一次冷启动以确保与渲染配置一致。
|
||||
@@ -323,7 +327,80 @@ func (s *SingBox) RenderConfig() ([]byte, error) {
|
||||
logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
|
||||
warp = nil
|
||||
}
|
||||
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp)
|
||||
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
|
||||
}
|
||||
|
||||
// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。
|
||||
//
|
||||
// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也
|
||||
// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:
|
||||
// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用
|
||||
// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失
|
||||
// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,
|
||||
// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警
|
||||
func (s *SingBox) loadACL() *ACLConfig {
|
||||
acl, err := LoadACLConfig(s.cfg.ACLConfigPath)
|
||||
switch {
|
||||
case err == nil && acl != nil:
|
||||
if acl.Enabled && len(acl.cleanTargets()) == 0 {
|
||||
// enabled 却零 target = 几乎必然是字段名手抖(如 targets 打成 targetz)。
|
||||
// encoding/json 对未知字段静默无视,这份配置"看似合法"但毫无内容——当作
|
||||
// 加载失败,走下面的 last-good 兜底,绝不静默敞开、更不能拿它覆盖兜底快照。
|
||||
logf("[acl] ERROR %s parsed but enabled=true yields zero targets — treating as broken, falling back", s.cfg.ACLConfigPath)
|
||||
break // 落到下方 last-good 兜底(Go 的 switch 内 break 只退出 switch)
|
||||
}
|
||||
if acl.active() {
|
||||
// 只有 active 的配置才配当 last-good:否则一次手抖既关闸又冲掉兜底。
|
||||
s.mu.Lock()
|
||||
s.lastGoodACL = acl
|
||||
s.mu.Unlock()
|
||||
if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
|
||||
logf("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)
|
||||
}
|
||||
} else {
|
||||
// active() 为 false 但走到了这里,只可能是显式 enabled:false —— 合法关闭。
|
||||
// 直接返回这份(空规则的)配置,不 persist,保留此前的恢复能力。
|
||||
logf("[acl] gate explicitly disabled (enabled=false)")
|
||||
}
|
||||
return acl
|
||||
case err != nil:
|
||||
logf("[acl] ERROR load %s failed: %v", s.cfg.ACLConfigPath, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
lg := s.lastGoodACL
|
||||
s.mu.Unlock()
|
||||
if lg != nil {
|
||||
logf("[acl] WARN falling back to in-memory last-good ACL")
|
||||
return lg
|
||||
}
|
||||
|
||||
disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
|
||||
if derr != nil {
|
||||
logf("[acl] ERROR reading last-good %s: %v", s.cfg.ACLLastGoodPath(), derr)
|
||||
}
|
||||
if derr == nil && disk != nil {
|
||||
logf("[acl] WARN falling back to on-disk last-good %s", s.cfg.ACLLastGoodPath())
|
||||
s.mu.Lock()
|
||||
s.lastGoodACL = disk
|
||||
s.mu.Unlock()
|
||||
return disk
|
||||
}
|
||||
|
||||
// 两级 last-good 都没有可用配置。区分两种终态:
|
||||
// - 这台节点从未配置过 ACL(acl.json 与 last-good 均从未存在过)→ 安静返回,
|
||||
// 不刷屏告警。
|
||||
// - 除此之外的任何情况(acl.json 存在但损坏/last-good 存在但损坏等)→ 私有
|
||||
// 服务的访问闸实质已消失,必须大声告警(§5 "两者都失败才不产出 ACL 规则,
|
||||
// 同时打 ERROR 并告警")。
|
||||
_, aclStatErr := os.Stat(s.cfg.ACLConfigPath)
|
||||
_, lgStatErr := os.Stat(s.cfg.ACLLastGoodPath())
|
||||
neverConfigured := os.IsNotExist(aclStatErr) && os.IsNotExist(lgStatErr)
|
||||
if !neverConfigured {
|
||||
logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+
|
||||
"private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAndRestart renders, writes the config file and restarts sing-box.
|
||||
@@ -395,6 +472,11 @@ func (s *SingBox) markDirty() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
|
||||
// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
|
||||
// 冷启动(Restart),把全部在线用户踢下线。
|
||||
func (s *SingBox) Refresh() { s.markDirty() }
|
||||
|
||||
// Run drives the debounce loop: it coalesces bursts of changes within
|
||||
// DebounceWindow into a single write+restart. It blocks until ctx is cancelled.
|
||||
func (s *SingBox) Run(ctx context.Context) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。
|
||||
@@ -66,15 +65,9 @@ func (wc *WarpConfig) mtu() int {
|
||||
}
|
||||
|
||||
// cleanDomains 去空白/空项后返回域名清单(用于 domain_suffix)。
|
||||
// 与 ACL 的目的地域名规范化共用 cleanHosts,避免两处逻辑漂移。
|
||||
func (wc *WarpConfig) cleanDomains() []string {
|
||||
out := make([]string, 0, len(wc.Domains))
|
||||
for _, d := range wc.Domains {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
if d != "" {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return cleanHosts(wc.Domains)
|
||||
}
|
||||
|
||||
// endpointHostPort 拆 Endpoint 为 host + port(active() 已校验可拆)。
|
||||
@@ -107,15 +100,3 @@ func (wc *WarpConfig) warpEndpoint() map[string]any {
|
||||
"peers": []any{peer},
|
||||
}
|
||||
}
|
||||
|
||||
// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP,
|
||||
// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。
|
||||
func (wc *WarpConfig) warpRoute() map[string]any {
|
||||
return map[string]any{
|
||||
"rules": []any{
|
||||
map[string]any{"action": "sniff"},
|
||||
map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag},
|
||||
},
|
||||
"final": directOutboundTag,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user