Files
pangolin/server/internal/agentd/acl.go
T
wangjia 2abe4d0816 fix(agent): ACL 放行用 auth_user 而非 user(VLESS 运行时只认 auth_user)
生产验证发现:sing-box 1.13 的 route rule 里 user 字段对 VLESS/REALITY
入站运行时不匹配(仅 sing-box check 语法通过),导致放行规则永不命中、
白名单用户也被兜底拒绝。本地真 VLESS 连接实测确认 auth_user 生效
(good→通/bad→block),node 端 git 一进一出白名单验证 per-user 生效。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:36:02 +08:00

223 lines
7.5 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()
// 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)
}