e4d014ba99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
4.3 KiB
Go
144 lines
4.3 KiB
Go
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
|
|
}
|