From e4d014ba992be3bee7c342e91422f893e2c25214 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 23 Jul 2026 01:07:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E7=A7=81=E6=9C=89=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E5=9C=B0=20ACL=20=E9=85=8D=E7=BD=AE=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=20fail-closed=20=E5=8A=A0=E8=BD=BD=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- server/internal/agentd/acl.go | 143 +++++++++++++++++++++++ server/internal/agentd/acl_test.go | 175 +++++++++++++++++++++++++++++ server/internal/agentd/config.go | 23 +++- 3 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 server/internal/agentd/acl.go create mode 100644 server/internal/agentd/acl_test.go diff --git a/server/internal/agentd/acl.go b/server/internal/agentd/acl.go new file mode 100644 index 0000000..ee8f336 --- /dev/null +++ b/server/internal/agentd/acl.go @@ -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 是节点本地的私有目的地访问控制表(默认 /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 +} diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go new file mode 100644 index 0000000..b0bdee2 --- /dev/null +++ b/server/internal/agentd/acl_test.go @@ -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) + } +} diff --git a/server/internal/agentd/config.go b/server/internal/agentd/config.go index 6d455f1..b75bca8 100644 --- a/server/internal/agentd/config.go +++ b/server/internal/agentd/config.go @@ -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 指向节点本地的私有目的地访问控制表(默认 /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") }