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 是节点本地的私有目的地访问控制表(默认 /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) }