Files
pangolin/docs/private-dest-acl-plan.html
T

1094 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>私有目的地 ACL · 实现计划(阅读版)</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:17px;margin:32px 0 10px;color:var(--accent);scroll-margin-top:16px}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.6;color:#cdd3df}
pre code{background:none;padding:0;color:inherit;font-size:1em}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;display:block;overflow-x:auto}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px;white-space:nowrap}
ul,ol{padding-left:22px;margin:10px 0}
li{margin:5px 0}
blockquote{border-left:3px solid var(--accent);background:var(--panel);margin:16px 0;padding:12px 18px;border-radius:0 10px 10px 0;color:var(--fg2)}
blockquote p{margin:6px 0}
hr{border:0;border-top:1px solid var(--border);margin:38px 0}
a{color:var(--accent2)}
b,strong{color:#fff}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
.step{background:var(--panel);border:1px solid var(--border);border-left:3px solid var(--accent2);
border-radius:0 10px 10px 0;padding:10px 16px;margin:14px 0 8px;font-weight:600}
.step .box{color:var(--accent2);font-family:var(--mono);margin-right:8px}
.banner{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);
border-radius:12px;padding:16px 20px;margin:0 0 24px;color:var(--fg2);font-size:13.5px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">&larr; 文档索引</a>
<div class="banner">阅读版 · 执行真相源是 <code>docs/superpowers/plans/2026-07-23-private-dest-acl.md</code>(含 <code>- [ ]</code> 进度勾选)。本页仅供阅读,勿在此打勾。</div>
<h1>私有目的地访问控制(节点侧 ACLImplementation Plan</h1>
<blockquote><p><b>For agentic workers:</b> REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (<code>- [ ]</code>) syntax for tracking.</p></blockquote>
<p><b>Goal:</b> 在节点 sing-box 上加一道按 dp_uuid 的闸,使家庭内网服务(brain/nas/git/win.51yanmei.com)只对白名单凭证开放,而不是对所有 pangolin 用户开放。</p>
<p><b>Architecture:</b> 节点本地 <code>acl.json</code>(照 <code>warp.json</code> 骨架)声明「白名单 dp_uuid + 私有目的地清单」;agent 每次渲染 sing-box 配置时读取,为每个目的地产出一对 route 规则——先放行白名单、再兜底拒绝。ACL 规则与 WARP 规则由新的 <code>buildRoute()</code> 合并进同一个 route 块(现状是 WARP 整块覆盖 <code>cfg[&quot;route&quot;]</code>,直接赋值会把对方干掉)。控制面、数据库、proto、客户端、管理后台一律不动。</p>
<p><b>Tech Stack:</b> Go 1.2x,标准库;sing-box 1.13.13 配置语义;测试为同包 <code>*_test.go</code> + <code>t.TempDir()</code><code>go test ./internal/agentd/...</code></p>
<h2>Global Constraints</h2>
<ul>
<li>设计真相源:<code>docs/private-dest-acl-design.html</code>。本计划的任何取舍以该文档为准。</li>
<li><b>改动收敛在 <code>server/internal/agentd/</code> + <code>server/cmd/agent/main.go</code></b>。不得新增 migration、不得改 proto、不得改控制面(<code>internal/httpapi</code><code>internal/nodes</code>)、不得改客户端。</li>
<li><b>失效方向必须 fail-closed</b>,与 <code>warp.go</code> 的 fail-open 先例相反。具体:读失败保留 last-good;<code>allow_dp_uuids: []</code> 仍产出拒绝规则;仅 <code>&quot;enabled&quot;: false</code> 能关闭。</li>
<li><b>route 规则顺序硬约束</b><code>{&quot;action&quot;:&quot;sniff&quot;}</code> 唯一且最先 → ACL 放行 → ACL 拒绝 → WARP 分流。</li>
<li><b>放行与拒绝的目的地条件必须逐字相同</b>,且全部放行规则排在全部拒绝规则之前(目的地可能重叠,交错会漏)。</li>
<li>向后兼容:ACL 与 WARP 均未启用时,渲染结果必须与改动前<b>逐字节一致</b>(现有测试 <code>TestRender_NoWarp_NoRouteSection</code> 守这条)。</li>
<li>每个 Task 结束必须 <code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...</code> 全绿再提交。</li>
<li>Commit message 前缀用 <code>feat(agent):</code> / <code>test(agent):</code> / <code>refactor(agent):</code></li>
</ul>
<blockquote><p><b>执行后修订(2026-07-23,提交 <code>2abe4d0</code>)· 血泪教训</b>:放行规则匹配凭证必须用 <b><code>auth_user</code></b> 而非本计划各处写的 <code>user</code><code>sing-box check</code> 对两者都语法通过,但 <code>user</code> 字段对 VLESS/REALITY 入站<b>运行时不匹配</b>,导致放行规则永不命中、<b>连白名单用户也被兜底拒绝</b>(全员进不去)。生产上线时踩中,本地起真 VLESS 连接实测才定位。<b>凡本计划(Task 2 <code>rules()</code>、Task 3 测试)出现 <code>r[&quot;user&quot;] = uuids</code> 或断言 <code>r[&quot;user&quot;]</code> 之处,一律应为 <code>auth_user</code></b> 且验收不能只跑 <code>sing-box check</code>——必须真连接跑一次(节点端「白名单 uuid 一进一出」即可)。</p></blockquote>
<hr>
<h3>Task 1: ACL 配置类型、加载与 fail-closed 的 active()</h3>
<p><b>Files:</b></p>
<ul>
<li>Create: <code>server/internal/agentd/acl.go</code></li>
<li>Create: <code>server/internal/agentd/acl_test.go</code></li>
<li>Modify: <code>server/internal/agentd/config.go</code>(新增 <code>ACLConfigPath</code> 字段、<code>withDefaults</code> 分支、<code>ACLLastGoodPath()</code>,并订正包注释)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Produces: <code>type ACLTarget struct{ Domain, DomainSuffix, IPCIDR []string; Port []int }</code><code>type ACLConfig struct{ Enabled bool; AllowDpUUIDs []string; Targets []ACLTarget }</code><code>func LoadACLConfig(path string) (*ACLConfig, error)</code><code>func (ac *ACLConfig) active() bool</code><code>func (ac *ACLConfig) cleanUUIDs() []string</code><code>func (ac *ACLConfig) cleanTargets() []ACLTarget</code><code>func (t ACLTarget) empty() bool</code><code>func (t ACLTarget) matchFields() map[string]any</code><code>func (c Config) ACLLastGoodPath() string</code></li>
<li>Consumes: 无(本任务是叶子)</li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>创建 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>package agentd
import (
&quot;os&quot;
&quot;path/filepath&quot;
&quot;testing&quot;
)
// 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 = `{
&quot;enabled&quot;: true,
&quot;allow_dp_uuids&quot;: [&quot;uuid-me-1&quot;, &quot;uuid-me-sub&quot;],
&quot;targets&quot;: [
{ &quot;domain&quot;: [&quot;brain.51yanmei.com&quot;, &quot;git.51yanmei.com&quot;] },
{ &quot;ip_cidr&quot;: [&quot;182.92.213.171/32&quot;], &quot;port&quot;: [5001, 3389, 10022, 10023] }
]
}`
func TestLoadACLConfig(t *testing.T) {
dir := t.TempDir()
t.Run(&quot;文件不存在返回 nil,nil(未配置,不是错误)&quot;, func(t *testing.T) {
ac, err := LoadACLConfig(filepath.Join(dir, &quot;missing.json&quot;))
if err != nil {
t.Fatalf(&quot;want nil error, got %v&quot;, err)
}
if ac != nil {
t.Fatalf(&quot;want nil config, got %+v&quot;, ac)
}
})
t.Run(&quot;坏 JSON 返回 error(绝不静默降级)&quot;, func(t *testing.T) {
p := filepath.Join(dir, &quot;bad.json&quot;)
writeACL(t, p, `{&quot;enabled&quot;: true,`)
if _, err := LoadACLConfig(p); err == nil {
t.Fatal(&quot;want error for malformed JSON, got nil&quot;)
}
})
t.Run(&quot;合法配置解析出全部字段&quot;, func(t *testing.T) {
p := filepath.Join(dir, &quot;acl.json&quot;)
writeACL(t, p, validACL)
ac, err := LoadACLConfig(p)
if err != nil {
t.Fatal(err)
}
if !ac.Enabled {
t.Error(&quot;Enabled = false, want true&quot;)
}
if len(ac.AllowDpUUIDs) != 2 {
t.Errorf(&quot;AllowDpUUIDs len = %d, want 2&quot;, len(ac.AllowDpUUIDs))
}
if len(ac.Targets) != 2 {
t.Fatalf(&quot;Targets len = %d, want 2&quot;, len(ac.Targets))
}
if len(ac.Targets[0].Domain) != 2 {
t.Errorf(&quot;Targets[0].Domain len = %d, want 2&quot;, len(ac.Targets[0].Domain))
}
if len(ac.Targets[1].Port) != 4 {
t.Errorf(&quot;Targets[1].Port len = %d, want 4&quot;, len(ac.Targets[1].Port))
}
})
}
// active() 的语义与 WARP 相反:空白名单不等于「关闭」,而等于「谁都不许进」。
func TestACLActive_FailClosed(t *testing.T) {
cases := []struct {
name string
ac *ACLConfig
want bool
}{
{&quot;nil 配置 → 未启用&quot;, nil, false},
{&quot;enabled=false → 未启用(唯一的合法关闭途径)&quot;, &amp;ACLConfig{
Enabled: false,
AllowDpUUIDs: []string{&quot;u&quot;},
Targets: []ACLTarget{{Domain: []string{&quot;a.com&quot;}}},
}, false},
{&quot;无 target → 未启用(无从拒起)&quot;, &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{&quot;u&quot;},
}, false},
{&quot;target 全为空条件 → 未启用&quot;, &amp;ACLConfig{
Enabled: true,
Targets: []ACLTarget{{}},
}, false},
{&quot;白名单为空但有 target → 仍启用(拒绝所有人)&quot;, &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: nil,
Targets: []ACLTarget{{Domain: []string{&quot;a.com&quot;}}},
}, true},
{&quot;完整配置 → 启用&quot;, &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{&quot;u&quot;},
Targets: []ACLTarget{{IPCIDR: []string{&quot;1.2.3.4/32&quot;}, 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(&quot;active() = %v, want %v&quot;, got, tc.want)
}
})
}
}
func TestACLCleanHelpers(t *testing.T) {
ac := &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{&quot; uuid-a &quot;, &quot;&quot;, &quot;uuid-b&quot;},
Targets: []ACLTarget{
{Domain: []string{&quot; BRAIN.51yanmei.com &quot;, &quot;&quot;}},
{},
{IPCIDR: []string{&quot;1.2.3.4/32&quot;}},
},
}
uuids := ac.cleanUUIDs()
if len(uuids) != 2 || uuids[0] != &quot;uuid-a&quot; || uuids[1] != &quot;uuid-b&quot; {
t.Errorf(&quot;cleanUUIDs() = %v, want [uuid-a uuid-b]&quot;, uuids)
}
targets := ac.cleanTargets()
if len(targets) != 2 {
t.Fatalf(&quot;cleanTargets() len = %d, want 2 (空 target 应被丢弃)&quot;, len(targets))
}
if targets[0].Domain[0] != &quot;brain.51yanmei.com&quot; {
t.Errorf(&quot;域名未规范化为小写去空白: %q&quot;, targets[0].Domain[0])
}
}
func TestACLTargetMatchFields(t *testing.T) {
tgt := ACLTarget{
Domain: []string{&quot;a.com&quot;},
IPCIDR: []string{&quot;1.2.3.4/32&quot;},
Port: []int{443, 5001},
}
m := tgt.matchFields()
if _, ok := m[&quot;domain&quot;]; !ok {
t.Error(&quot;缺 domain 字段&quot;)
}
if _, ok := m[&quot;ip_cidr&quot;]; !ok {
t.Error(&quot;缺 ip_cidr 字段&quot;)
}
if _, ok := m[&quot;port&quot;]; !ok {
t.Error(&quot;缺 port 字段&quot;)
}
if _, ok := m[&quot;domain_suffix&quot;]; ok {
t.Error(&quot;空的 domain_suffix 不应出现在输出里&quot;)
}
// matchFields 必须每次返回新 map,否则放行/拒绝两条规则会共享同一对象,
// 给其中一条加 &quot;user&quot;/&quot;action&quot; 会污染另一条。
m2 := tgt.matchFields()
m2[&quot;user&quot;] = []string{&quot;x&quot;}
if _, ok := m[&quot;user&quot;]; ok {
t.Error(&quot;matchFields 返回了共享 map,放行与拒绝规则会互相污染&quot;)
}
}
func TestConfigACLPaths(t *testing.T) {
c := Config{StateDir: &quot;/etc/pangolin-agent&quot;}.withDefaults()
if want := &quot;/etc/pangolin-agent/acl.json&quot;; c.ACLConfigPath != want {
t.Errorf(&quot;ACLConfigPath = %q, want %q&quot;, c.ACLConfigPath, want)
}
if want := &quot;/etc/pangolin-agent/acl.last-good.json&quot;; c.ACLLastGoodPath() != want {
t.Errorf(&quot;ACLLastGoodPath() = %q, want %q&quot;, c.ACLLastGoodPath(), want)
}
}</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;ACL&#x27; -v</code></pre>
<p>Expected: 编译失败,<code>undefined: LoadACLConfig</code> / <code>undefined: ACLConfig</code> / <code>c.ACLConfigPath undefined</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p>创建 <code>server/internal/agentd/acl.go</code></p>
<pre><code>package agentd
import (
&quot;encoding/json&quot;
&quot;fmt&quot;
&quot;os&quot;
&quot;strings&quot;
)
// 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:&quot;domain,omitempty&quot;`
DomainSuffix []string `json:&quot;domain_suffix,omitempty&quot;`
IPCIDR []string `json:&quot;ip_cidr,omitempty&quot;`
Port []int `json:&quot;port,omitempty&quot;`
}
// ACLConfig 是节点本地的私有目的地访问控制表(默认 &lt;StateDir&gt;/acl.json)。
// 只有 AllowDpUUIDs 里的凭证能访问 Targets 描述的目的地,其余一律 reject。
//
// 与 WarpConfig 的关键区别是失效方向:WARP 读不出来就不分流(fail-open)是安全的,
// ACL 读不出来就不拦截等于把私有服务对全体用户敞开。故本类型的 active() 语义为
// fail-closed —— 空白名单意味着「没有人」,不是「所有人」。
type ACLConfig struct {
Enabled bool `json:&quot;enabled&quot;`
AllowDpUUIDs []string `json:&quot;allow_dp_uuids&quot;`
Targets []ACLTarget `json:&quot;targets&quot;`
}
// 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(&quot;agentd: read acl config %q: %w&quot;, path, err)
}
var ac ACLConfig
if err := json.Unmarshal(data, &amp;ac); err != nil {
return nil, fmt.Errorf(&quot;agentd: parse acl config %q: %w&quot;, path, err)
}
return &amp;ac, nil
}
// empty 报告该 target 是否没有任何匹配条件(没有条件的规则会匹配一切,危险)。
func (t ACLTarget) empty() bool {
return len(t.Domain) == 0 &amp;&amp; len(t.DomainSuffix) == 0 &amp;&amp;
len(t.IPCIDR) == 0 &amp;&amp; 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) &gt; 0 {
m[&quot;domain&quot;] = t.Domain
}
if len(t.DomainSuffix) &gt; 0 {
m[&quot;domain_suffix&quot;] = t.DomainSuffix
}
if len(t.IPCIDR) &gt; 0 {
m[&quot;ip_cidr&quot;] = t.IPCIDR
}
if len(t.Port) &gt; 0 {
m[&quot;port&quot;] = t.Port
}
return m
}
// active 报告本 ACL 是否应真正注入规则。
//
// 注意与 WarpConfig.active() 的语义差别:此处 AllowDpUUIDs 为空**不影响**返回值。
// 空白名单是一个合法且有意义的状态 ——「谁都不许访问这些目的地」。把它当作未启用
// 会造成 fail-open。唯一的关闭途径是显式 &quot;enabled&quot;: false。
func (ac *ACLConfig) active() bool {
if ac == nil || !ac.Enabled {
return false
}
return len(ac.cleanTargets()) &gt; 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 != &quot;&quot; {
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 != &quot;&quot; {
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 != &quot;&quot; {
out = append(out, s)
}
}
return out
}</code></pre>
<p>修改 <code>server/internal/agentd/config.go</code></p>
<p>① 包注释订正(原文声称 agent 保有零目的地数据,ACL 打破了这条,必须如实记录)。把第 7–10 行的 no-state invariant 段落改为:</p>
<pre><code>// 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
// 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。</code></pre>
<p>② 在 <code>WarpConfigPath</code> 字段之后新增:</p>
<pre><code> // ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 &lt;StateDir&gt;/acl.json)。
// 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。
ACLConfigPath string</code></pre>
<p>③ 在 <code>withDefaults()</code><code>WarpConfigPath</code> 分支之后新增:</p>
<pre><code> if c.ACLConfigPath == &quot;&quot; {
c.ACLConfigPath = filepath.Join(c.StateDir, &quot;acl.json&quot;)
}</code></pre>
<p>④ 在文件末尾路径方法区新增:</p>
<pre><code>// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json
// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。
func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, &quot;acl.last-good.json&quot;) }</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;ACL|ConfigACLPaths&#x27; -v</code></pre>
<p>Expected: PASS,全部子测试通过。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 全包回归 + 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/acl.go server/internal/agentd/acl_test.go server/internal/agentd/config.go
git commit -m &quot;feat(agent): 私有目的地 ACL 配置类型与 fail-closed 加载语义&quot;</code></pre>
<blockquote><p><b>执行后修订(2026-07-23,提交 <code>d4156b8</code></b>:审查指出上面的 <code>cleanHosts</code><code>warp.go</code> 既有的 <code>cleanDomains</code> 是同包内逐字重复的逻辑。已按用户裁决改为 <b><code>cleanDomains</code> 委托 <code>cleanHosts</code></b><code>func (wc *WarpConfig) cleanDomains() []string { return cleanHosts(wc.Domains) }</code>,并移除 <code>warp.go</code> 中变为未使用的 <code>strings</code> import)。<b>若日后照本计划重新实现,请直接采用委托版本,不要写回两份重复实现。</b></p></blockquote>
<hr>
<h3>Task 2: 由 ACL 产出 route 规则对(放行 + 兜底拒绝)</h3>
<p><b>Files:</b></p>
<ul>
<li>Modify: <code>server/internal/agentd/acl.go</code>(新增 <code>rules()</code></li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加测试)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: Task 1 的 <code>ACLConfig.active()</code> / <code>cleanUUIDs()</code> / <code>cleanTargets()</code> / <code>ACLTarget.matchFields()</code></li>
<li>Produces: <code>func (ac *ACLConfig) rules() []any</code> —— 返回按「全部放行规则 → 全部拒绝规则」排序的 sing-box route 规则切片;未 active 时返回 nil</li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// rules() 必须产出「先全部放行、再全部拒绝」,且同一 target 两侧目的地条件逐字相同。
func TestACLRules_AllowThenDeny(t *testing.T) {
ac := &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{&quot;uuid-me&quot;},
Targets: []ACLTarget{
{Domain: []string{&quot;brain.51yanmei.com&quot;}},
{IPCIDR: []string{&quot;182.92.213.171/32&quot;}, Port: []int{5001}},
},
}
rules := ac.rules()
if len(rules) != 4 {
t.Fatalf(&quot;规则数 = %d, want 4 (2 target × 放行+拒绝)&quot;, len(rules))
}
// 前两条是放行:带 user + outbound,不带 action
for i := 0; i &lt; 2; i++ {
r := rules[i].(map[string]any)
if _, ok := r[&quot;user&quot;]; !ok {
t.Errorf(&quot;rules[%d] 放行规则缺 user&quot;, i)
}
if r[&quot;outbound&quot;] != directOutboundTag {
t.Errorf(&quot;rules[%d] outbound = %v, want %q&quot;, i, r[&quot;outbound&quot;], directOutboundTag)
}
if _, ok := r[&quot;action&quot;]; ok {
t.Errorf(&quot;rules[%d] 放行规则不应带 action&quot;, i)
}
}
// 后两条是拒绝:带 action=reject,不带 user(对所有人生效)
for i := 2; i &lt; 4; i++ {
r := rules[i].(map[string]any)
if r[&quot;action&quot;] != &quot;reject&quot; {
t.Errorf(&quot;rules[%d] action = %v, want reject&quot;, i, r[&quot;action&quot;])
}
if _, ok := r[&quot;user&quot;]; ok {
t.Errorf(&quot;rules[%d] 拒绝规则不应带 user,否则会漏掉名单外的人&quot;, i)
}
}
// 对称性:target[0] 的放行(rules[0])与拒绝(rules[2])目的地条件必须逐字相同
allow0 := rules[0].(map[string]any)
deny0 := rules[2].(map[string]any)
if fmt.Sprint(allow0[&quot;domain&quot;]) != fmt.Sprint(deny0[&quot;domain&quot;]) {
t.Errorf(&quot;target0 放行/拒绝的 domain 不一致: %v vs %v&quot;, allow0[&quot;domain&quot;], deny0[&quot;domain&quot;])
}
allow1 := rules[1].(map[string]any)
deny1 := rules[3].(map[string]any)
if fmt.Sprint(allow1[&quot;ip_cidr&quot;]) != fmt.Sprint(deny1[&quot;ip_cidr&quot;]) ||
fmt.Sprint(allow1[&quot;port&quot;]) != fmt.Sprint(deny1[&quot;port&quot;]) {
t.Error(&quot;target1 放行/拒绝的 ip_cidr/port 不一致&quot;)
}
}
// 空白名单 → 不产出放行规则,但拒绝规则照出(fail-closed 的核心断言)。
func TestACLRules_EmptyAllowlistStillDenies(t *testing.T) {
ac := &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: nil,
Targets: []ACLTarget{{Domain: []string{&quot;brain.51yanmei.com&quot;}}},
}
rules := ac.rules()
if len(rules) != 1 {
t.Fatalf(&quot;规则数 = %d, want 1 (仅拒绝)&quot;, len(rules))
}
r := rules[0].(map[string]any)
if r[&quot;action&quot;] != &quot;reject&quot; {
t.Errorf(&quot;action = %v, want reject&quot;, r[&quot;action&quot;])
}
}
// 未 active(含 nil / enabled=false)→ 无规则。
func TestACLRules_InactiveYieldsNil(t *testing.T) {
var nilACL *ACLConfig
if got := nilACL.rules(); got != nil {
t.Errorf(&quot;nil ACL rules() = %v, want nil&quot;, got)
}
off := &amp;ACLConfig{Enabled: false, Targets: []ACLTarget{{Domain: []string{&quot;a.com&quot;}}}}
if got := off.rules(); got != nil {
t.Errorf(&quot;enabled=false rules() = %v, want nil&quot;, got)
}
}</code></pre>
<p><code>acl_test.go</code> 的 import 块加入 <code>&quot;fmt&quot;</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;ACLRules&#x27; -v</code></pre>
<p>Expected: 编译失败,<code>ac.rules undefined (type *ACLConfig has no field or method rules)</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p><code>server/internal/agentd/acl.go</code> 末尾追加:</p>
<pre><code>// 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) &gt; 0 {
for _, t := range targets {
r := t.matchFields()
r[&quot;user&quot;] = uuids
r[&quot;outbound&quot;] = directOutboundTag
out = append(out, r)
}
}
for _, t := range targets {
r := t.matchFields()
r[&quot;action&quot;] = &quot;reject&quot;
out = append(out, r)
}
return out
}</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;ACL&#x27; -v</code></pre>
<p>Expected: PASS。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/acl.go server/internal/agentd/acl_test.go
git commit -m &quot;feat(agent): ACL 渲染放行/拒绝规则对,空白名单仍拒绝&quot;</code></pre>
<hr>
<h3>Task 3: buildRoute 合并 ACL 与 WARP,接入渲染</h3>
<p><b>Files:</b></p>
<ul>
<li>Modify: <code>server/internal/agentd/render.go</code><code>:30-58</code> <code>renderSingboxConfig</code> 增参、替换 <code>cfg[&quot;route&quot;]</code> 赋值、新增 <code>buildRoute</code><code>:14</code> 隐私不变式注释订正)</li>
<li>Modify: <code>server/internal/agentd/warp.go:113-121</code>(删除 <code>warpRoute()</code>,其唯一调用方被 <code>buildRoute</code> 取代)</li>
<li>Modify: <code>server/internal/agentd/singbox.go:326</code><code>renderSingboxConfig</code> 调用处补 <code>nil</code> 实参,本任务先传 nil,Task 4 换成真实 ACL</li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加四态矩阵测试)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: Task 2 的 <code>(*ACLConfig).rules()</code>;既有 <code>(*WarpConfig).active()</code> / <code>cleanDomains()</code> / <code>warpEndpoint()</code></li>
<li>Produces: <code>func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any</code>(两者皆未激活时返回 nil);<code>renderSingboxConfig</code> 新签名 <code>func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error)</code></li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// buildRoute 四态矩阵:ACL×WARP 开关的四种组合。
func TestBuildRoute_Matrix(t *testing.T) {
acl := &amp;ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{&quot;uuid-me&quot;},
Targets: []ACLTarget{{Domain: []string{&quot;brain.51yanmei.com&quot;}}},
}
warp := &amp;WarpConfig{
Enabled: true, PrivateKey: &quot;k&quot;, PeerPublicKey: &quot;pk&quot;,
Endpoint: &quot;162.159.192.1:2408&quot;, Address: []string{&quot;172.16.0.2/32&quot;},
Domains: []string{&quot;reddit.com&quot;},
}
t.Run(&quot;都关 → 不产出 route(向后兼容)&quot;, func(t *testing.T) {
if got := buildRoute(nil, nil); got != nil {
t.Errorf(&quot;buildRoute(nil,nil) = %v, want nil&quot;, got)
}
})
t.Run(&quot;仅 WARP → sniff + warp 规则(与改动前逐字节一致)&quot;, func(t *testing.T) {
r := buildRoute(nil, warp)
rules := r[&quot;rules&quot;].([]any)
if len(rules) != 2 {
t.Fatalf(&quot;规则数 = %d, want 2&quot;, len(rules))
}
if rules[0].(map[string]any)[&quot;action&quot;] != &quot;sniff&quot; {
t.Error(&quot;首条不是 sniff&quot;)
}
if rules[1].(map[string]any)[&quot;outbound&quot;] != warpOutboundTag {
t.Error(&quot;次条不是 warp 分流&quot;)
}
if r[&quot;final&quot;] != directOutboundTag {
t.Errorf(&quot;final = %v, want %q&quot;, r[&quot;final&quot;], directOutboundTag)
}
})
t.Run(&quot;仅 ACL → sniff + 放行 + 拒绝&quot;, func(t *testing.T) {
r := buildRoute(acl, nil)
rules := r[&quot;rules&quot;].([]any)
if len(rules) != 3 {
t.Fatalf(&quot;规则数 = %d, want 3&quot;, len(rules))
}
if rules[0].(map[string]any)[&quot;action&quot;] != &quot;sniff&quot; {
t.Error(&quot;首条不是 sniff&quot;)
}
if _, ok := rules[1].(map[string]any)[&quot;user&quot;]; !ok {
t.Error(&quot;第二条不是放行规则&quot;)
}
if rules[2].(map[string]any)[&quot;action&quot;] != &quot;reject&quot; {
t.Error(&quot;第三条不是拒绝规则&quot;)
}
})
t.Run(&quot;都开 → sniff + ACL(放行,拒绝) + warp,且 sniff 只出现一次&quot;, func(t *testing.T) {
r := buildRoute(acl, warp)
rules := r[&quot;rules&quot;].([]any)
if len(rules) != 4 {
t.Fatalf(&quot;规则数 = %d, want 4&quot;, len(rules))
}
sniffs := 0
for _, x := range rules {
if x.(map[string]any)[&quot;action&quot;] == &quot;sniff&quot; {
sniffs++
}
}
if sniffs != 1 {
t.Errorf(&quot;sniff 出现 %d 次, want 1&quot;, sniffs)
}
if rules[0].(map[string]any)[&quot;action&quot;] != &quot;sniff&quot; {
t.Error(&quot;sniff 必须最先&quot;)
}
// ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口
if rules[3].(map[string]any)[&quot;outbound&quot;] != warpOutboundTag {
t.Error(&quot;warp 规则必须排在最后&quot;)
}
if rules[2].(map[string]any)[&quot;action&quot;] != &quot;reject&quot; {
t.Error(&quot;ACL 拒绝规则必须排在 warp 之前&quot;)
}
})
}</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;BuildRoute&#x27; -v</code></pre>
<p>Expected: 编译失败,<code>undefined: buildRoute</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p>① 在 <code>server/internal/agentd/render.go</code> 中,把第 30 行签名与第 50–55 行的 route 注入替换为:</p>
<pre><code>func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) {</code></pre>
<pre><code> // WARP 分流(#29)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要
// 同时容纳 ACL 规则 —— 原先 cfg[&quot;route&quot;] = warp.warpRoute() 是整块覆盖,直接赋值
// 会把对方的规则干掉。
if warp.active() {
cfg[&quot;endpoints&quot;] = []any{warp.warpEndpoint()}
}
if route := buildRoute(acl, warp); route != nil {
cfg[&quot;route&quot;] = route
}</code></pre>
<p>② 在 <code>render.go</code><code>statsUsers</code> 之前插入:</p>
<pre><code>// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。
// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。
//
// 规则顺序是安全语义的一部分:
// 1. {&quot;action&quot;:&quot;sniff&quot;} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是
// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。
// 2. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远
// 不该还有机会被路由到 warp 出口。
// 3. final 恒为 direct。
func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
aclRules := acl.rules()
warpActive := warp.active()
if len(aclRules) == 0 &amp;&amp; !warpActive {
return nil
}
rules := make([]any, 0, len(aclRules)+2)
rules = append(rules, map[string]any{&quot;action&quot;: &quot;sniff&quot;})
rules = append(rules, aclRules...)
if warpActive {
rules = append(rules, map[string]any{
&quot;domain_suffix&quot;: warp.cleanDomains(),
&quot;outbound&quot;: warpOutboundTag,
})
}
return map[string]any{&quot;rules&quot;: rules, &quot;final&quot;: directOutboundTag}
}</code></pre>
<p>③ 删除 <code>server/internal/agentd/warp.go</code> 第 111121 行的 <code>warpRoute()</code> 方法及其上方注释块(唯一调用方已被 <code>buildRoute</code> 取代;<code>grep -rn &quot;warpRoute()&quot; internal/agentd/</code> 应无剩余命中)。</p>
<p>④ 订正 <code>render.go</code> 第 14 行的隐私不变式注释。原文:</p>
<pre><code>// Only the opaque dp_uuid is ever written; no account identity touches the node.</code></pre>
<p>改为:</p>
<pre><code>// Only the opaque dp_uuid is ever written; no account identity touches the node.
//
// 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有
// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id),
// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化,
// 设计与权衡见 docs/private-dest-acl-design.html §12。</code></pre>
<p>⑤ 修改 <code>server/internal/agentd/singbox.go</code> 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):</p>
<pre><code> return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;BuildRoute|Warp|Render&#x27; -v</code></pre>
<p>Expected: PASS。特别确认既有的 <code>TestRender_NoWarp_NoRouteSection</code><code>TestRender_Warp_InjectsEndpointAndRoute</code> 仍绿——这两条守着向后兼容。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/render.go server/internal/agentd/warp.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
git commit -m &quot;refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP&quot;</code></pre>
<hr>
<h3>Task 4: 渲染时读取 ACLlast-good 内存 + 落盘兜底</h3>
<p><b>Files:</b></p>
<ul>
<li>Modify: <code>server/internal/agentd/singbox.go:49-74</code><code>SingBox</code><code>lastGoodACL</code> 字段)、<code>:308-327</code><code>RenderConfig</code> 读 ACL</li>
<li>Modify: <code>server/internal/agentd/acl.go</code>(新增 <code>persistACL</code></li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加 last-good 测试)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: Task 1 的 <code>LoadACLConfig</code> / <code>Config.ACLConfigPath</code> / <code>Config.ACLLastGoodPath()</code>Task 3 的 <code>renderSingboxConfig</code> 六参签名;既有包内函数 <code>atomicWrite(path string, data []byte, perm os.FileMode) error</code></li>
<li>Produces: <code>func (s *SingBox) loadACL() *ACLConfig</code><code>func persistACL(path string, ac *ACLConfig) error</code></li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb.RenderConfig(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
t.Fatalf(&quot;last-good 未落盘: %v&quot;, err)
}
}
// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。
func TestACL_BrokenFileKeepsInMemoryLastGood(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb.RenderConfig(); err != nil {
t.Fatal(err)
}
writeACL(t, cfg.ACLConfigPath, `{&quot;enabled&quot;: true,`) // 手抖写坏
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), &quot;reject&quot;) {
t.Fatal(&quot;acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开&quot;)
}
}
// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。
func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb1 := NewSingBox(cfg, nil)
sb1.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb1.RenderConfig(); err != nil {
t.Fatal(err)
}
writeACL(t, cfg.ACLConfigPath, `not json at all`)
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
sb2.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
data, err := sb2.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), &quot;reject&quot;) {
t.Fatal(&quot;冷启动未回退到磁盘 last-good,私有服务已敞开&quot;)
}
}
// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
cfg := testConfig(t)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &amp;m); err != nil {
t.Fatal(err)
}
if _, ok := m[&quot;route&quot;]; ok {
t.Error(&quot;未配置 ACL 也未启用 WARP,不应产出 route 块&quot;)
}
}</code></pre>
<p><code>acl_test.go</code> 的 import 块补齐 <code>&quot;encoding/json&quot;</code><code>&quot;strings&quot;</code>,以及 <code>agentv1 &quot;github.com/wangjia/pangolin/server/internal/pb/agentv1&quot;</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;TestACL_&#x27; -v</code></pre>
<p>Expected: <code>TestACL_PersistsLastGoodOnLoad</code> 失败(last-good 未落盘),<code>TestACL_BrokenFile...</code> / <code>TestACL_ColdStart...</code> 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。</p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p>① 在 <code>server/internal/agentd/acl.go</code> 末尾追加:</p>
<pre><code>// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
func persistACL(path string, ac *ACLConfig) error {
data, err := json.MarshalIndent(ac, &quot;&quot;, &quot; &quot;)
if err != nil {
return fmt.Errorf(&quot;agentd: marshal acl snapshot: %w&quot;, err)
}
return atomicWrite(path, data, 0o600)
}</code></pre>
<p>② 在 <code>server/internal/agentd/singbox.go</code><code>SingBox</code> 结构体中,<code>configVersion int64</code> 之后新增字段:</p>
<pre><code> // lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
lastGoodACL *ACLConfig</code></pre>
<p>③ 在 <code>server/internal/agentd/singbox.go</code><code>RenderConfig</code> 中,把 WARP 加载之后的部分替换为:</p>
<pre><code> // WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。
// 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。
warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)
if err != nil {
logf(&quot;[warp] load %s failed, WARP routing disabled: %v&quot;, s.cfg.WarpConfigPath, err)
warp = nil
}
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
}
// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。
//
// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也
// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:
// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用
// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失
// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,
// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警
func (s *SingBox) loadACL() *ACLConfig {
acl, err := LoadACLConfig(s.cfg.ACLConfigPath)
switch {
case err == nil &amp;&amp; acl != nil:
s.mu.Lock()
s.lastGoodACL = acl
s.mu.Unlock()
if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
logf(&quot;[acl] persist last-good to %s failed: %v&quot;, s.cfg.ACLLastGoodPath(), perr)
}
return acl
case err != nil:
logf(&quot;[acl] ERROR load %s failed: %v&quot;, s.cfg.ACLConfigPath, err)
}
s.mu.Lock()
lg := s.lastGoodACL
s.mu.Unlock()
if lg != nil {
logf(&quot;[acl] falling back to in-memory last-good ACL&quot;)
return lg
}
disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
if derr == nil &amp;&amp; disk != nil {
logf(&quot;[acl] falling back to on-disk last-good %s&quot;, s.cfg.ACLLastGoodPath())
s.mu.Lock()
s.lastGoodACL = disk
s.mu.Unlock()
return disk
}
if err != nil {
logf(&quot;[acl] ALERT acl.json is broken and no last-good snapshot exists — &quot;+
&quot;private destinations are UNPROTECTED (path=%s)&quot;, s.cfg.ACLConfigPath)
}
return nil
}</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;TestACL_&#x27; -v</code></pre>
<p>Expected: PASS,四条全绿。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/acl.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
git commit -m &quot;feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启&quot;</code></pre>
<hr>
<h3>Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户)</h3>
<p><b>Files:</b></p>
<ul>
<li>Modify: <code>server/internal/agentd/singbox.go</code>(新增导出的 <code>Refresh()</code></li>
<li>Modify: <code>server/cmd/agent/main.go:44-45</code>(注册 SIGHUP 处理)</li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加 <code>Refresh</code> 测试)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: 既有 <code>(*SingBox).markDirty()</code>(未导出)、<code>(*Agent).SingBox() *SingBox</code><code>agent.go:83</code>,已导出)</li>
<li>Produces: <code>func (s *SingBox) Refresh()</code> —— 外部触发一次重渲染</li>
</ul>
<p><b>为什么不用 <code>systemctl restart pangolin-agent</code></b> 新进程内 <code>s.started == false</code><code>singbox.go:64-67</code>),首次渲染会走 <code>restarter.Restart()</code><code>systemctl restart sing-box</code><b>全部在线用户瞬断</b>。SIGHUP 走的是既有的 debounce → <code>writeAndRestart</code><code>Reload()</code>SIGHUP 热重载)路径,不断线。</p>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后
// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。
func TestSingBoxRefresh_TriggersRender(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
fr := &amp;fakeRestarter{}
sb := NewSingBox(cfg, fr)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sb.Run(ctx)
// 等首次渲染落地(ApplyConfig 已 markDirty)
eventually(t, 2*time.Second, func() bool {
_, err := os.Stat(cfg.SingboxConfigPath)
return err == nil
}, &quot;首次渲染写出配置&quot;)
// 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染
// 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。
before := fr.reloadCount()
sb.Refresh()
eventually(t, 2*time.Second, func() bool { return fr.reloadCount() &gt; before }, &quot;Refresh 触发了热重载&quot;)
}</code></pre>
<p><code>acl_test.go</code> 的 import 块补齐 <code>&quot;context&quot;</code><code>&quot;time&quot;</code></p>
<blockquote><p>复用 <code>singbox_test.go:17-47</code> 既有的 <code>fakeRestarter</code><code>count()</code> 返回 Restart 次数、<code>reloadCount()</code> 返回 Reload 次数)与 <code>integration_test.go:248</code><code>eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)</code></p></blockquote>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;Refresh&#x27; -v</code></pre>
<p>Expected: 编译失败,<code>sb.Refresh undefined</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p>① 在 <code>server/internal/agentd/singbox.go</code><code>markDirty</code> 之后追加:</p>
<pre><code>// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
// 冷启动(Restart),把全部在线用户踢下线。
func (s *SingBox) Refresh() { s.markDirty() }</code></pre>
<p>② 修改 <code>server/cmd/agent/main.go</code>,把第 4445 行替换为:</p>
<pre><code> ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP
// 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for {
select {
case &lt;-ctx.Done():
return
case &lt;-hup:
log.Printf(&quot;[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)&quot;)
agent.SingBox().Refresh()
}
}
}()</code></pre>
<p><code>os</code><code>log</code> 已在该文件 import 中(<code>os</code><code>env()</code> 用到的 <code>os.LookupEnv</code>);若 <code>go build</code> 报缺失,按提示补齐。</p>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/ -run &#x27;Refresh&#x27; -v</code></pre>
<p>Expected: PASS。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/singbox.go server/internal/agentd/acl_test.go server/cmd/agent/main.go
git commit -m &quot;feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户&quot;</code></pre>
<hr>
<h3>Task 6: 节点配置样例与运维文档</h3>
<p><b>Files:</b></p>
<ul>
<li>Create: <code>deploy/single-node/acl.json.example</code></li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加样例文件常驻校验测试)</li>
<li>Modify: <code>docs/private-dest-acl-design.html</code>(把 §8 runbook 里的占位符换成指向样例文件)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: Task 1 定义的 <code>ACLConfig</code> JSON 形状</li>
<li>Produces: 可直接复制到 <code>/etc/pangolin-agent/acl.json</code> 的样例</li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写样例文件</b></div>
<p>创建 <code>deploy/single-node/acl.json.example</code></p>
<pre><code>{
&quot;_comment&quot;: &quot;私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restartrestart 会冷启 sing-box 踢掉全部在线用户)。&quot;,
&quot;enabled&quot;: true,
&quot;allow_dp_uuids&quot;: [
&quot;TODO-设备级-dp-uuid&quot;,
&quot;TODO-账户级-dp-uuid-供-sub-订阅链接用&quot;
],
&quot;targets&quot;: [
{
&quot;_comment&quot;: &quot;与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分&quot;,
&quot;domain&quot;: [&quot;brain.51yanmei.com&quot;, &quot;git.51yanmei.com&quot;]
},
{
&quot;_comment&quot;: &quot;独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023&quot;,
&quot;ip_cidr&quot;: [&quot;182.92.213.171/32&quot;],
&quot;port&quot;: [5001, 3389, 10022, 10023]
}
]
}</code></pre>
<blockquote><p><code>_comment</code> 字段会被 <code>encoding/json</code> 忽略(<code>ACLConfig</code> 无对应字段且未启用 <code>DisallowUnknownFields</code>),可安全保留作自文档。样例里的 <code>TODO-</code> 值是<b>样例文件</b>的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。</p></blockquote>
<div class="step"><span class="box">[ ]</span><b>Step 2: 加一条测试,锁死「样例文件永远可解析」</b></div>
<p>样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 <code>go run</code> 检查一次没有约束力。改为一条常驻测试。追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
func TestACLExampleFileStaysValid(t *testing.T) {
path := filepath.Join(&quot;..&quot;, &quot;..&quot;, &quot;..&quot;, &quot;deploy&quot;, &quot;single-node&quot;, &quot;acl.json.example&quot;)
ac, err := LoadACLConfig(path)
if err != nil {
t.Fatalf(&quot;样例文件解析失败 %s: %v&quot;, path, err)
}
if ac == nil {
t.Fatalf(&quot;样例文件不存在: %s&quot;, path)
}
if !ac.active() {
t.Error(&quot;样例文件应当是一份 active 的 ACL&quot;)
}
if len(ac.Targets) != 2 {
t.Errorf(&quot;样例 targets = %d, want 2&quot;, len(ac.Targets))
}
// 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
for _, u := range ac.AllowDpUUIDs {
if !strings.HasPrefix(u, &quot;TODO-&quot;) {
t.Errorf(&quot;样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git&quot;, u)
}
}
}</code></pre>
<p>跑:</p>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;ExampleFileStaysValid&#x27; -v</code></pre>
<p>Expected: PASS。</p>
<blockquote><p>注:<code>_comment</code> 字段会被 <code>encoding/json</code> 忽略(<code>ACLConfig</code> 无对应字段且未启用 <code>DisallowUnknownFields</code>),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。</p></blockquote>
<div class="step"><span class="box">[ ]</span><b>Step 3: 更新设计文档的 runbook 指向</b></div>
<p><code>docs/private-dest-acl-design.html</code> 的 §8 首段(<code>&lt;h2&gt;8. 名单维护 Runbook&lt;/h2&gt;</code> 之后的第一个 <code>&lt;p&gt;</code>)末尾追加一句:</p>
<pre><code>样例文件见 &lt;code&gt;deploy/single-node/acl.json.example&lt;/code&gt;,复制到 &lt;code&gt;/etc/pangolin-agent/acl.json&lt;/code&gt; 后按下方 SQL 填入 uuid。</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add deploy/single-node/acl.json.example server/internal/agentd/acl_test.go docs/private-dest-acl-design.html
git commit -m &quot;docs(agent): 私有目的地 ACL 配置样例与常驻校验测试&quot;</code></pre>
<blockquote><p><code>git add</code> 只列具体路径,<b>切勿 <code>git add -A</code></b>:仓库里有未跟踪的 <code>pangolin-release.jks</code>(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。</p></blockquote>
<hr>
<h2>上线验收(人工,非代码任务)</h2>
<p>代码合并后在 pangolin1 上执行。<b>先备份现网 sing-box 配置</b><code>sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)</code>(此处 <code>$(...)</code> 在服务器交互 shell 中手动执行,不经 agent)。</p>
<ol>
<li>取白名单 uuid<code>UNION</code> 那一半是账户级 dp_uuid,供 <code>/sub</code> 订阅链接用,<b>漏了会把自己锁在外面</b>):</li>
</ol>
<p> ```sql</p>
<p> sqlite3 /var/lib/pangolin/pangolin.db \</p>
<p> &quot;SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id</p>
<p> WHERE u.email = &#x27;&lt;我的邮箱&gt;&#x27; AND d.dp_uuid IS NOT NULL</p>
<p> UNION</p>
<p> SELECT dp_uuid FROM users WHERE email = &#x27;&lt;我的邮箱&gt;&#x27;;&quot;</p>
<p> ```</p>
<ol>
<li><code>/etc/pangolin-agent/acl.json</code>(权限 <code>0600</code>,属主 <code>pangolin</code>),<code>systemctl reload pangolin-agent</code></li>
<li><code>journalctl -u pangolin-agent -n 30</code> 确认已重渲染、无 <code>[acl] ERROR</code> / <code>[acl] ALERT</code></li>
<li><code>sudo python3 -c &quot;import json;print(json.load(open(&#x27;/etc/sing-box/config.json&#x27;))[&#x27;route&#x27;])&quot;</code> 确认规则顺序为 sniff → resolve → 放行 → 拒绝 → warpresolve 见 I3:堵住 ip_cidr 目的地的域名形式绕过)。</li>
</ol>
<p> <b><code>config.json</code> 内容正常不等于线上生效</b>——SIGHUP 走的是 sing-box 自身校验,若新配置被 sing-box 拒绝,它会保留旧实例继续跑,agent 侧仍记&quot;渲染成功&quot;。reload 后必须额外确认:</p>
<ul>
<li><code>systemctl is-active sing-box</code><code>active</code></li>
<li><code>journalctl -u sing-box -n5 | grep -v FATAL</code>(有 FATAL 说明 sing-box 拒绝了新配置,旧实例还在跑,config.json 上的内容其实没生效)。</li>
<li><b>我的设备</b>brain 首页 200、DSM 5001 可登录、<code>ssh nas-r</code> 通。</li>
<li><b>另一账号的设备</b>:以上全部被拒(连接被 reject,不是超时)。<b>分别用域名和裸 IP 两种形式各测一遍</b>(如 <code>curl https://brain.51yanmei.com</code><code>curl --resolve brain.51yanmei.com:443:103.119.13.48 https://brain.51yanmei.com</code>DSM 同理分别用 <code>nas.51yanmei.com:5001</code><code>182.92.213.171:5001</code>)——I3 表明目的地表达形式(域名 vs IP)会走到不同的 sing-box 匹配路径,只测一种形式验证不到位。</li>
<li><b>公开站不受影响</b>jiu / travel / sudoku / pay 在两个账号下均正常。</li>
<li><b>WARP 未被破坏</b>reddit 仍走 warp 出口。</li>
<li><b>fail-closed 实证</b>:把 <code>acl.json</code> 改坏 → <code>systemctl reload pangolin-agent</code> → 规则仍在、日志有 ERROR;恢复文件。</li>
<li><b>不断线实证</b>:reload 期间另一台设备保持连接不掉。</li>
</ul>
<h2>不在本计划范围内</h2>
<ul>
<li>给 brain 加 mTLS / basic auth 作纵深防御(与本计划正交,约一小时,另开)</li>
<li><code>assertNoIdentityFields</code> 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复)</li>
<li>控制面下发 ACL(演进路径见设计文档 §11,届时渲染层可原样保留)</li>
</ul>
</div>
</body>
</html>