344dfe9a82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1094 lines
58 KiB
HTML
1094 lines
58 KiB
HTML
<!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">← 文档索引</a>
|
||
<div class="banner">阅读版 · 执行真相源是 <code>docs/superpowers/plans/2026-07-23-private-dest-acl.md</code>(含 <code>- [ ]</code> 进度勾选)。本页仅供阅读,勿在此打勾。</div>
|
||
<h1>私有目的地访问控制(节点侧 ACL)Implementation 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["route"]</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>"enabled": false</code> 能关闭。</li>
|
||
<li><b>route 规则顺序硬约束</b>:<code>{"action":"sniff"}</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 && go build ./... && 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["user"] = uuids</code> 或断言 <code>r["user"]</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 (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 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 = `{
|
||
"enabled": true,
|
||
"allow_dp_uuids": ["uuid-me-1", "uuid-me-sub"],
|
||
"targets": [
|
||
{ "domain": ["brain.51yanmei.com", "git.51yanmei.com"] },
|
||
{ "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
|
||
]
|
||
}`
|
||
|
||
func TestLoadACLConfig(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
t.Run("文件不存在返回 nil,nil(未配置,不是错误)", func(t *testing.T) {
|
||
ac, err := LoadACLConfig(filepath.Join(dir, "missing.json"))
|
||
if err != nil {
|
||
t.Fatalf("want nil error, got %v", err)
|
||
}
|
||
if ac != nil {
|
||
t.Fatalf("want nil config, got %+v", ac)
|
||
}
|
||
})
|
||
|
||
t.Run("坏 JSON 返回 error(绝不静默降级)", func(t *testing.T) {
|
||
p := filepath.Join(dir, "bad.json")
|
||
writeACL(t, p, `{"enabled": true,`)
|
||
if _, err := LoadACLConfig(p); err == nil {
|
||
t.Fatal("want error for malformed JSON, got nil")
|
||
}
|
||
})
|
||
|
||
t.Run("合法配置解析出全部字段", func(t *testing.T) {
|
||
p := filepath.Join(dir, "acl.json")
|
||
writeACL(t, p, validACL)
|
||
ac, err := LoadACLConfig(p)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !ac.Enabled {
|
||
t.Error("Enabled = false, want true")
|
||
}
|
||
if len(ac.AllowDpUUIDs) != 2 {
|
||
t.Errorf("AllowDpUUIDs len = %d, want 2", len(ac.AllowDpUUIDs))
|
||
}
|
||
if len(ac.Targets) != 2 {
|
||
t.Fatalf("Targets len = %d, want 2", len(ac.Targets))
|
||
}
|
||
if len(ac.Targets[0].Domain) != 2 {
|
||
t.Errorf("Targets[0].Domain len = %d, want 2", len(ac.Targets[0].Domain))
|
||
}
|
||
if len(ac.Targets[1].Port) != 4 {
|
||
t.Errorf("Targets[1].Port len = %d, want 4", len(ac.Targets[1].Port))
|
||
}
|
||
})
|
||
}
|
||
|
||
// active() 的语义与 WARP 相反:空白名单不等于「关闭」,而等于「谁都不许进」。
|
||
func TestACLActive_FailClosed(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
ac *ACLConfig
|
||
want bool
|
||
}{
|
||
{"nil 配置 → 未启用", nil, false},
|
||
{"enabled=false → 未启用(唯一的合法关闭途径)", &ACLConfig{
|
||
Enabled: false,
|
||
AllowDpUUIDs: []string{"u"},
|
||
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
|
||
}, false},
|
||
{"无 target → 未启用(无从拒起)", &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: []string{"u"},
|
||
}, false},
|
||
{"target 全为空条件 → 未启用", &ACLConfig{
|
||
Enabled: true,
|
||
Targets: []ACLTarget{{}},
|
||
}, false},
|
||
{"白名单为空但有 target → 仍启用(拒绝所有人)", &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: nil,
|
||
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
|
||
}, true},
|
||
{"完整配置 → 启用", &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: []string{"u"},
|
||
Targets: []ACLTarget{{IPCIDR: []string{"1.2.3.4/32"}, 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("active() = %v, want %v", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestACLCleanHelpers(t *testing.T) {
|
||
ac := &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: []string{" uuid-a ", "", "uuid-b"},
|
||
Targets: []ACLTarget{
|
||
{Domain: []string{" BRAIN.51yanmei.com ", ""}},
|
||
{},
|
||
{IPCIDR: []string{"1.2.3.4/32"}},
|
||
},
|
||
}
|
||
uuids := ac.cleanUUIDs()
|
||
if len(uuids) != 2 || uuids[0] != "uuid-a" || uuids[1] != "uuid-b" {
|
||
t.Errorf("cleanUUIDs() = %v, want [uuid-a uuid-b]", uuids)
|
||
}
|
||
targets := ac.cleanTargets()
|
||
if len(targets) != 2 {
|
||
t.Fatalf("cleanTargets() len = %d, want 2 (空 target 应被丢弃)", len(targets))
|
||
}
|
||
if targets[0].Domain[0] != "brain.51yanmei.com" {
|
||
t.Errorf("域名未规范化为小写去空白: %q", targets[0].Domain[0])
|
||
}
|
||
}
|
||
|
||
func TestACLTargetMatchFields(t *testing.T) {
|
||
tgt := ACLTarget{
|
||
Domain: []string{"a.com"},
|
||
IPCIDR: []string{"1.2.3.4/32"},
|
||
Port: []int{443, 5001},
|
||
}
|
||
m := tgt.matchFields()
|
||
if _, ok := m["domain"]; !ok {
|
||
t.Error("缺 domain 字段")
|
||
}
|
||
if _, ok := m["ip_cidr"]; !ok {
|
||
t.Error("缺 ip_cidr 字段")
|
||
}
|
||
if _, ok := m["port"]; !ok {
|
||
t.Error("缺 port 字段")
|
||
}
|
||
if _, ok := m["domain_suffix"]; ok {
|
||
t.Error("空的 domain_suffix 不应出现在输出里")
|
||
}
|
||
// matchFields 必须每次返回新 map,否则放行/拒绝两条规则会共享同一对象,
|
||
// 给其中一条加 "user"/"action" 会污染另一条。
|
||
m2 := tgt.matchFields()
|
||
m2["user"] = []string{"x"}
|
||
if _, ok := m["user"]; ok {
|
||
t.Error("matchFields 返回了共享 map,放行与拒绝规则会互相污染")
|
||
}
|
||
}
|
||
|
||
func TestConfigACLPaths(t *testing.T) {
|
||
c := Config{StateDir: "/etc/pangolin-agent"}.withDefaults()
|
||
if want := "/etc/pangolin-agent/acl.json"; c.ACLConfigPath != want {
|
||
t.Errorf("ACLConfigPath = %q, want %q", c.ACLConfigPath, want)
|
||
}
|
||
if want := "/etc/pangolin-agent/acl.last-good.json"; c.ACLLastGoodPath() != want {
|
||
t.Errorf("ACLLastGoodPath() = %q, want %q", c.ACLLastGoodPath(), want)
|
||
}
|
||
}</code></pre>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'ACL' -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 (
|
||
"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
|
||
}</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 指向节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
|
||
// 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。
|
||
ACLConfigPath string</code></pre>
|
||
<p>③ 在 <code>withDefaults()</code> 里 <code>WarpConfigPath</code> 分支之后新增:</p>
|
||
<pre><code> if c.ACLConfigPath == "" {
|
||
c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json")
|
||
}</code></pre>
|
||
<p>④ 在文件末尾路径方法区新增:</p>
|
||
<pre><code>// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json
|
||
// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。
|
||
func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") }</code></pre>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'ACL|ConfigACLPaths' -v</code></pre>
|
||
<p>Expected: PASS,全部子测试通过。</p>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 5: 全包回归 + 提交</b></div>
|
||
<pre><code>cd server && go build ./... && 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 "feat(agent): 私有目的地 ACL 配置类型与 fail-closed 加载语义"</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 := &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: []string{"uuid-me"},
|
||
Targets: []ACLTarget{
|
||
{Domain: []string{"brain.51yanmei.com"}},
|
||
{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}},
|
||
},
|
||
}
|
||
rules := ac.rules()
|
||
if len(rules) != 4 {
|
||
t.Fatalf("规则数 = %d, want 4 (2 target × 放行+拒绝)", len(rules))
|
||
}
|
||
|
||
// 前两条是放行:带 user + outbound,不带 action
|
||
for i := 0; i < 2; i++ {
|
||
r := rules[i].(map[string]any)
|
||
if _, ok := r["user"]; !ok {
|
||
t.Errorf("rules[%d] 放行规则缺 user", i)
|
||
}
|
||
if r["outbound"] != directOutboundTag {
|
||
t.Errorf("rules[%d] outbound = %v, want %q", i, r["outbound"], directOutboundTag)
|
||
}
|
||
if _, ok := r["action"]; ok {
|
||
t.Errorf("rules[%d] 放行规则不应带 action", i)
|
||
}
|
||
}
|
||
// 后两条是拒绝:带 action=reject,不带 user(对所有人生效)
|
||
for i := 2; i < 4; i++ {
|
||
r := rules[i].(map[string]any)
|
||
if r["action"] != "reject" {
|
||
t.Errorf("rules[%d] action = %v, want reject", i, r["action"])
|
||
}
|
||
if _, ok := r["user"]; ok {
|
||
t.Errorf("rules[%d] 拒绝规则不应带 user,否则会漏掉名单外的人", i)
|
||
}
|
||
}
|
||
|
||
// 对称性:target[0] 的放行(rules[0])与拒绝(rules[2])目的地条件必须逐字相同
|
||
allow0 := rules[0].(map[string]any)
|
||
deny0 := rules[2].(map[string]any)
|
||
if fmt.Sprint(allow0["domain"]) != fmt.Sprint(deny0["domain"]) {
|
||
t.Errorf("target0 放行/拒绝的 domain 不一致: %v vs %v", allow0["domain"], deny0["domain"])
|
||
}
|
||
allow1 := rules[1].(map[string]any)
|
||
deny1 := rules[3].(map[string]any)
|
||
if fmt.Sprint(allow1["ip_cidr"]) != fmt.Sprint(deny1["ip_cidr"]) ||
|
||
fmt.Sprint(allow1["port"]) != fmt.Sprint(deny1["port"]) {
|
||
t.Error("target1 放行/拒绝的 ip_cidr/port 不一致")
|
||
}
|
||
}
|
||
|
||
// 空白名单 → 不产出放行规则,但拒绝规则照出(fail-closed 的核心断言)。
|
||
func TestACLRules_EmptyAllowlistStillDenies(t *testing.T) {
|
||
ac := &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: nil,
|
||
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
|
||
}
|
||
rules := ac.rules()
|
||
if len(rules) != 1 {
|
||
t.Fatalf("规则数 = %d, want 1 (仅拒绝)", len(rules))
|
||
}
|
||
r := rules[0].(map[string]any)
|
||
if r["action"] != "reject" {
|
||
t.Errorf("action = %v, want reject", r["action"])
|
||
}
|
||
}
|
||
|
||
// 未 active(含 nil / enabled=false)→ 无规则。
|
||
func TestACLRules_InactiveYieldsNil(t *testing.T) {
|
||
var nilACL *ACLConfig
|
||
if got := nilACL.rules(); got != nil {
|
||
t.Errorf("nil ACL rules() = %v, want nil", got)
|
||
}
|
||
off := &ACLConfig{Enabled: false, Targets: []ACLTarget{{Domain: []string{"a.com"}}}}
|
||
if got := off.rules(); got != nil {
|
||
t.Errorf("enabled=false rules() = %v, want nil", got)
|
||
}
|
||
}</code></pre>
|
||
<p>在 <code>acl_test.go</code> 的 import 块加入 <code>"fmt"</code>。</p>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'ACLRules' -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) > 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
|
||
}</code></pre>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'ACL' -v</code></pre>
|
||
<p>Expected: PASS。</p>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
|
||
<pre><code>cd server && go build ./... && go test ./internal/agentd/...
|
||
git add server/internal/agentd/acl.go server/internal/agentd/acl_test.go
|
||
git commit -m "feat(agent): ACL 渲染放行/拒绝规则对,空白名单仍拒绝"</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["route"]</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 := &ACLConfig{
|
||
Enabled: true,
|
||
AllowDpUUIDs: []string{"uuid-me"},
|
||
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
|
||
}
|
||
warp := &WarpConfig{
|
||
Enabled: true, PrivateKey: "k", PeerPublicKey: "pk",
|
||
Endpoint: "162.159.192.1:2408", Address: []string{"172.16.0.2/32"},
|
||
Domains: []string{"reddit.com"},
|
||
}
|
||
|
||
t.Run("都关 → 不产出 route(向后兼容)", func(t *testing.T) {
|
||
if got := buildRoute(nil, nil); got != nil {
|
||
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
|
||
}
|
||
})
|
||
|
||
t.Run("仅 WARP → sniff + warp 规则(与改动前逐字节一致)", func(t *testing.T) {
|
||
r := buildRoute(nil, warp)
|
||
rules := r["rules"].([]any)
|
||
if len(rules) != 2 {
|
||
t.Fatalf("规则数 = %d, want 2", len(rules))
|
||
}
|
||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||
t.Error("首条不是 sniff")
|
||
}
|
||
if rules[1].(map[string]any)["outbound"] != warpOutboundTag {
|
||
t.Error("次条不是 warp 分流")
|
||
}
|
||
if r["final"] != directOutboundTag {
|
||
t.Errorf("final = %v, want %q", r["final"], directOutboundTag)
|
||
}
|
||
})
|
||
|
||
t.Run("仅 ACL → sniff + 放行 + 拒绝", func(t *testing.T) {
|
||
r := buildRoute(acl, nil)
|
||
rules := r["rules"].([]any)
|
||
if len(rules) != 3 {
|
||
t.Fatalf("规则数 = %d, want 3", len(rules))
|
||
}
|
||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||
t.Error("首条不是 sniff")
|
||
}
|
||
if _, ok := rules[1].(map[string]any)["user"]; !ok {
|
||
t.Error("第二条不是放行规则")
|
||
}
|
||
if rules[2].(map[string]any)["action"] != "reject" {
|
||
t.Error("第三条不是拒绝规则")
|
||
}
|
||
})
|
||
|
||
t.Run("都开 → sniff + ACL(放行,拒绝) + warp,且 sniff 只出现一次", func(t *testing.T) {
|
||
r := buildRoute(acl, warp)
|
||
rules := r["rules"].([]any)
|
||
if len(rules) != 4 {
|
||
t.Fatalf("规则数 = %d, want 4", len(rules))
|
||
}
|
||
sniffs := 0
|
||
for _, x := range rules {
|
||
if x.(map[string]any)["action"] == "sniff" {
|
||
sniffs++
|
||
}
|
||
}
|
||
if sniffs != 1 {
|
||
t.Errorf("sniff 出现 %d 次, want 1", sniffs)
|
||
}
|
||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||
t.Error("sniff 必须最先")
|
||
}
|
||
// ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口
|
||
if rules[3].(map[string]any)["outbound"] != warpOutboundTag {
|
||
t.Error("warp 规则必须排在最后")
|
||
}
|
||
if rules[2].(map[string]any)["action"] != "reject" {
|
||
t.Error("ACL 拒绝规则必须排在 warp 之前")
|
||
}
|
||
})
|
||
}</code></pre>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'BuildRoute' -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["route"] = warp.warpRoute() 是整块覆盖,直接赋值
|
||
// 会把对方的规则干掉。
|
||
if warp.active() {
|
||
cfg["endpoints"] = []any{warp.warpEndpoint()}
|
||
}
|
||
if route := buildRoute(acl, warp); route != nil {
|
||
cfg["route"] = route
|
||
}</code></pre>
|
||
<p>② 在 <code>render.go</code> 的 <code>statsUsers</code> 之前插入:</p>
|
||
<pre><code>// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。
|
||
// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。
|
||
//
|
||
// 规则顺序是安全语义的一部分:
|
||
// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 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 && !warpActive {
|
||
return nil
|
||
}
|
||
|
||
rules := make([]any, 0, len(aclRules)+2)
|
||
rules = append(rules, map[string]any{"action": "sniff"})
|
||
rules = append(rules, aclRules...)
|
||
if warpActive {
|
||
rules = append(rules, map[string]any{
|
||
"domain_suffix": warp.cleanDomains(),
|
||
"outbound": warpOutboundTag,
|
||
})
|
||
}
|
||
return map[string]any{"rules": rules, "final": directOutboundTag}
|
||
}</code></pre>
|
||
<p>③ 删除 <code>server/internal/agentd/warp.go</code> 第 111–121 行的 <code>warpRoute()</code> 方法及其上方注释块(唯一调用方已被 <code>buildRoute</code> 取代;<code>grep -rn "warpRoute()" 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 && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -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 && go build ./... && 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 "refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP"</code></pre>
|
||
<hr>
|
||
<h3>Task 4: 渲染时读取 ACL,last-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(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||
|
||
if _, err := sb.RenderConfig(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
|
||
t.Fatalf("last-good 未落盘: %v", 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(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||
if _, err := sb.RenderConfig(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
writeACL(t, cfg.ACLConfigPath, `{"enabled": true,`) // 手抖写坏
|
||
data, err := sb.RenderConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(string(data), "reject") {
|
||
t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
|
||
}
|
||
}
|
||
|
||
// 新 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(&agentv1.Credential{DpUUID: "aaaa", 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(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||
data, err := sb2.RenderConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(string(data), "reject") {
|
||
t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")
|
||
}
|
||
}
|
||
|
||
// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
|
||
func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
|
||
cfg := testConfig(t)
|
||
sb := NewSingBox(cfg, nil)
|
||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||
data, err := sb.RenderConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var m map[string]any
|
||
if err := json.Unmarshal(data, &m); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, ok := m["route"]; ok {
|
||
t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")
|
||
}
|
||
}</code></pre>
|
||
<p>在 <code>acl_test.go</code> 的 import 块补齐 <code>"encoding/json"</code>、<code>"strings"</code>,以及 <code>agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"</code>。</p>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'TestACL_' -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, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("agentd: marshal acl snapshot: %w", 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("[warp] load %s failed, WARP routing disabled: %v", 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 && acl != nil:
|
||
s.mu.Lock()
|
||
s.lastGoodACL = acl
|
||
s.mu.Unlock()
|
||
if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
|
||
logf("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)
|
||
}
|
||
return acl
|
||
case err != nil:
|
||
logf("[acl] ERROR load %s failed: %v", s.cfg.ACLConfigPath, err)
|
||
}
|
||
|
||
s.mu.Lock()
|
||
lg := s.lastGoodACL
|
||
s.mu.Unlock()
|
||
if lg != nil {
|
||
logf("[acl] falling back to in-memory last-good ACL")
|
||
return lg
|
||
}
|
||
|
||
disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
|
||
if derr == nil && disk != nil {
|
||
logf("[acl] falling back to on-disk last-good %s", s.cfg.ACLLastGoodPath())
|
||
s.mu.Lock()
|
||
s.lastGoodACL = disk
|
||
s.mu.Unlock()
|
||
return disk
|
||
}
|
||
|
||
if err != nil {
|
||
logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+
|
||
"private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)
|
||
}
|
||
return nil
|
||
}</code></pre>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'TestACL_' -v</code></pre>
|
||
<p>Expected: PASS,四条全绿。</p>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
|
||
<pre><code>cd server && go build ./... && 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 "feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启"</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 := &fakeRestarter{}
|
||
sb := NewSingBox(cfg, fr)
|
||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", 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
|
||
}, "首次渲染写出配置")
|
||
|
||
// 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染
|
||
// 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。
|
||
before := fr.reloadCount()
|
||
sb.Refresh()
|
||
eventually(t, 2*time.Second, func() bool { return fr.reloadCount() > before }, "Refresh 触发了热重载")
|
||
}</code></pre>
|
||
<p>在 <code>acl_test.go</code> 的 import 块补齐 <code>"context"</code> 与 <code>"time"</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 && go test ./internal/agentd/ -run 'Refresh' -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>,把第 44–45 行替换为:</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 <-ctx.Done():
|
||
return
|
||
case <-hup:
|
||
log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")
|
||
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 && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v</code></pre>
|
||
<p>Expected: PASS。</p>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
|
||
<pre><code>cd server && go build ./... && 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 "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户"</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>{
|
||
"_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",
|
||
"enabled": true,
|
||
"allow_dp_uuids": [
|
||
"TODO-设备级-dp-uuid",
|
||
"TODO-账户级-dp-uuid-供-sub-订阅链接用"
|
||
],
|
||
"targets": [
|
||
{
|
||
"_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",
|
||
"domain": ["brain.51yanmei.com", "git.51yanmei.com"]
|
||
},
|
||
{
|
||
"_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",
|
||
"ip_cidr": ["182.92.213.171/32"],
|
||
"port": [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("..", "..", "..", "deploy", "single-node", "acl.json.example")
|
||
ac, err := LoadACLConfig(path)
|
||
if err != nil {
|
||
t.Fatalf("样例文件解析失败 %s: %v", path, err)
|
||
}
|
||
if ac == nil {
|
||
t.Fatalf("样例文件不存在: %s", path)
|
||
}
|
||
if !ac.active() {
|
||
t.Error("样例文件应当是一份 active 的 ACL")
|
||
}
|
||
if len(ac.Targets) != 2 {
|
||
t.Errorf("样例 targets = %d, want 2", len(ac.Targets))
|
||
}
|
||
// 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
|
||
for _, u := range ac.AllowDpUUIDs {
|
||
if !strings.HasPrefix(u, "TODO-") {
|
||
t.Errorf("样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git", u)
|
||
}
|
||
}
|
||
}</code></pre>
|
||
<p>跑:</p>
|
||
<pre><code>cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -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><h2>8. 名单维护 Runbook</h2></code> 之后的第一个 <code><p></code>)末尾追加一句:</p>
|
||
<pre><code>样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。</code></pre>
|
||
<div class="step"><span class="box">[ ]</span><b>Step 4: 提交</b></div>
|
||
<pre><code>cd server && go build ./... && 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 "docs(agent): 私有目的地 ACL 配置样例与常驻校验测试"</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> "SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id</p>
|
||
<p> WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL</p>
|
||
<p> UNION</p>
|
||
<p> SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"</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 "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])"</code> 确认规则顺序为 sniff → resolve → 放行 → 拒绝 → warp(resolve 见 I3:堵住 ip_cidr 目的地的域名形式绕过)。</li>
|
||
</ol>
|
||
<p> <b><code>config.json</code> 内容正常不等于线上生效</b>——SIGHUP 走的是 sing-box 自身校验,若新配置被 sing-box 拒绝,它会保留旧实例继续跑,agent 侧仍记"渲染成功"。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>
|