f147ffbe9e
cleanTargets 只做 trim/lower,从不校验取值——port:[70000]、ip_cidr:"not-a-cidr" 这类语法正确但语义非法的值会被顺利渲染进 sing-box 配置。SIGHUP 热重载路径下 sing-box 会自己校验并拒绝这份新配置、保留旧实例继续跑,但 agent 早已把它当 "渲染成功"写盘并记正常日志——于是 /etc/sing-box/config.json 内容看起来正常 (能被 json.load 解析、rules 都在),但线上的 gate 其实完全没生效,而这在 运维层面几乎不可见。 修复: - LoadACLConfig 解析成功后跑一遍 validate():每个 target 的 ip_cidr 必须能被 netip.ParsePrefix 解析,每个 port 必须落在 1-65535,否则返回 error——与解析 失败走同一条路径(fail-closed last-good 兜底),而不是把坏值一路渲染出去。 - 新增 TestRenderedConfig_PassesSingBoxCheck:把渲染结果喂给真实 sing-box 1.13.13 二进制的 `check` 子命令断言 exit 0,把设计 §9 "产物合法性" 验收项落成常驻测试 (exec.LookPath 找不到二进制则 t.Skip,不 fail)。本机 sing-box(homebrew 装, 无 with_v2ray_api tag)会因实验性 v2ray_api 段落报编译期不支持的 FATAL——与本 测试要验的 ACL/route 语法合法性无关,故只在这条测试内剥离该段落后再校验。 TDD:acl_test.go 新增 TestLoadACLConfig_RejectsInvalidPort / _RejectsInvalidCIDR (RED:此前返回 nil error)、TestACL_InvalidPortFallsBackToLastGood(RED:非法端口 被渲染进配置)。三条现全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
7.1 KiB
Go
219 lines
7.1 KiB
Go
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()
|
|
r["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)
|
|
}
|