feat(agent): 私有目的地 ACL 配置类型与 fail-closed 加载语义
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
|
||||
Reference in New Issue
Block a user