feat(agent): 私有目的地 ACL 配置类型与 fail-closed 加载语义

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-23 01:07:15 +08:00
parent b71f6038ae
commit e4d014ba99
3 changed files with 338 additions and 3 deletions
+143
View File
@@ -0,0 +1,143 @@
package agentd
import (
"encoding/json"
"fmt"
"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)
}
return &ac, 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
}
+175
View File
@@ -0,0 +1,175 @@
package agentd
import (
"os"
"path/filepath"
"testing"
)
// 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)
}
}
+20 -3
View File
@@ -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") }