diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index db6c1bd..b4c682c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,7 +20,7 @@ on: # · runs-on: ubuntu-latest —— 在 catthehacker 容器里跑,仅用于纯 bash 扫描 # (redline/cleartext/portable-sql);容器内**不能**嵌套 `docker run`(DinD 挂载 # 失败,$PWD 在宿主不存在),故套 docker 的 job 不能用它。 -# · runs-on: nas —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主 +# · runs-on: mac —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主 # 真 docker(非嵌套),可正常拉/跑 node/golang/flutter/python/shellcheck 镜像。 # golden 保留 ghcr.io/cirruslabs/flutter Linux 容器 → 与入库基线渲染一致。 jobs: @@ -28,7 +28,7 @@ jobs: # ── Job 1: Lint (shellcheck) ───────────────────────────────────────────── lint: name: Lint — shellcheck - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -81,7 +81,7 @@ jobs: # ── Job 2: OpenAPI Sync Check ──────────────────────────────────────────── openapi-check: name: OpenAPI Sync Check - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -120,7 +120,7 @@ jobs: # ── Job 4: Flutter 客户端(分析 + 单测/组件测试)──────────────────────── flutter-client: name: Flutter — analyze + test - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -156,7 +156,7 @@ jobs: # (改了 design/colors_and_type.css 没重生成,或手改了生成物)。 codegen-drift: name: Codegen Drift — token 生成物未漂移 - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -170,7 +170,7 @@ jobs: ds-flow: name: DS-flow — 原型/跨端同源/代码色单源闸 - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -189,7 +189,7 @@ jobs: # -tags integration(需 docker 起 mysql/redis),见 go-integration job。 go-server: name: Go — build + test - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -215,7 +215,7 @@ jobs: # 详见 scripts/e2e-smoke.sh + server/test/e2e/。 e2e-smoke: name: E2E Smoke — L4 进程级端到端 - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -241,7 +241,7 @@ jobs: # · -p 1 串行:一次只起一个 mysql 容器,避免并发把 Docker Desktop 压垮/端口资源争用。 go-integration: name: Go — integration (mysql/redis testcontainers) - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 @@ -256,7 +256,7 @@ jobs: # tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。 golden: name: Golden — 视觉回归 (全量:components/auth/desktop/tablet) - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitea/workflows/deploy-client.yml b/.gitea/workflows/deploy-client.yml index 39b7688..fc76eca 100644 --- a/.gitea/workflows/deploy-client.yml +++ b/.gitea/workflows/deploy-client.yml @@ -34,11 +34,15 @@ concurrency: jobs: build-android: - runs-on: nas + runs-on: mac env: GOPROXY: https://goproxy.cn,direct PUB_HOSTED_URL: https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + # sing-box 源码经 NAS gitea 公网镜像取(mac runner 连不上 github)。 + # 用稳定公网域名 git.51yanmei.com(ali frps ← 家里 NAS frpc,nginx 443 反代 + # gitea)—— 外网/内网都通,不依赖本地 relay/tailscale。见 scripts/build-libbox.sh。 + SINGBOX_GIT: https://git.51yanmei.com/wangjia/sing-box.git steps: - name: Checkout uses: actions/checkout@v4 @@ -106,11 +110,14 @@ jobs: # simply picks up whatever artifacts DO exist — an absent "macos" # artifact is not an error there. build-macos: - runs-on: nas + runs-on: mac continue-on-error: true env: + GOPROXY: https://goproxy.cn,direct PUB_HOSTED_URL: https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + # macOS 同样内嵌 libbox → 同走 NAS gitea 公网镜像取 sing-box 源码(见 build-android)。 + SINGBOX_GIT: https://git.51yanmei.com/wangjia/sing-box.git steps: - name: Checkout uses: actions/checkout@v4 @@ -137,11 +144,14 @@ jobs: path: dist/ build-ios: - runs-on: nas + runs-on: mac continue-on-error: true env: + GOPROXY: https://goproxy.cn,direct PUB_HOSTED_URL: https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + # iOS 同样内嵌 libbox → 同走 NAS gitea 公网镜像取 sing-box 源码(见 build-android)。 + SINGBOX_GIT: https://git.51yanmei.com/wangjia/sing-box.git steps: - name: Checkout uses: actions/checkout@v4 @@ -168,7 +178,7 @@ jobs: # (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。 release-deploy: needs: [build-android] - runs-on: nas + runs-on: mac steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 1c2928a..5ec8afd 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,14 @@ client/ios/Flutter/ephemeral/ client/pubspec.lock # Android release 签名材料(本地或 CI 落盘,绝不入库) -client/android/key.properties -client/android/*.jks -client/android/*.keystore +# 用全局通配:签名私钥曾以 pangolin-release.jks 出现在**仓库根**,而旧规则只盖 +# client/android/ 一层,根目录那份不被忽略 → 一次 `git add -A` 就会把私钥提交进库。 +key.properties +*.jks +*.keystore +*.p12 +*.mobileprovision +*.provisionprofile # CI 客户端产物暂存目录(scripts/ci/compile-{android,windows}.sh 输出,构建期生成) /dist/ @@ -91,3 +96,6 @@ client/windows/installer/Output/ /server/coverage.txt /client/coverage/ local-l13-start.sh + +# superpowers 执行进度暂存(本地,不入库) +.superpowers/ diff --git a/client/android/build.gradle b/client/android/build.gradle index d2ffbff..8f2e274 100644 --- a/client/android/build.gradle +++ b/client/android/build.gradle @@ -1,5 +1,8 @@ allprojects { repositories { + // 国内镜像优先(见 settings.gradle 同注);原仓库保留作 fallback。 + maven { url "https://maven.aliyun.com/repository/google" } + maven { url "https://maven.aliyun.com/repository/public" } google() mavenCentral() } diff --git a/client/android/gradle/wrapper/gradle-wrapper.properties b/client/android/gradle/wrapper/gradle-wrapper.properties index 4518132..72153b1 100644 --- a/client/android/gradle/wrapper/gradle-wrapper.properties +++ b/client/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +# 国内镜像:services.gradle.org 被 GFW 墙(SSL 重置),CI runner 在墙内下载发行版会失败。 +# 腾讯云 gradle 镜像提供同一份 zip。见 client/android/{settings,build}.gradle 的 maven 镜像同理。 +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip diff --git a/client/android/settings.gradle b/client/android/settings.gradle index 60dcd4a..e5263dc 100644 --- a/client/android/settings.gradle +++ b/client/android/settings.gradle @@ -10,6 +10,11 @@ pluginManagement { includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { + // 国内镜像优先(CI runner 在墙内,直连 google/central/gradlePluginPortal 会被 GFW 重置); + // 原仓库保留作 fallback。与 deploy-client.yml 的 GOPROXY/PUB 镜像同理。 + maven { url "https://maven.aliyun.com/repository/google" } + maven { url "https://maven.aliyun.com/repository/public" } + maven { url "https://maven.aliyun.com/repository/gradle-plugin" } google() mavenCentral() gradlePluginPortal() diff --git a/client/ios/PacketTunnel/PacketTunnelProvider.swift b/client/ios/PacketTunnel/PacketTunnelProvider.swift index 3de86f3..6f2c35f 100644 --- a/client/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/client/ios/PacketTunnel/PacketTunnelProvider.swift @@ -206,6 +206,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol let ipv4 = NEIPv4Settings(addresses: v4addrs.map(\.address), subnetMasks: v4addrs.map(\.mask)) ipv4.includedRoutes = includedRoutes4(options) + ipv4.excludedRoutes = excludedRoutes4(options) settings.ipv4Settings = ipv4 } let v6addrs = routePrefixes(options.getInet6Address()) @@ -213,6 +214,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol let ipv6 = NEIPv6Settings(addresses: v6addrs.map(\.address), networkPrefixLengths: v6addrs.map { NSNumber(value: $0.prefix) }) ipv6.includedRoutes = includedRoutes6(options) + ipv6.excludedRoutes = excludedRoutes6(options) settings.ipv6Settings = ipv6 } if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty { @@ -311,6 +313,19 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol networkPrefixLength: NSNumber(value: $0.prefix)) } } + // 排除路由:sing-box config 的 route_exclude_address(私有 LAN 网段)经 libbox + // 暴露在此。之前只设 includedRoutes、丢了 excludedRoutes → strict_route 把 LAN 也 + // 抓进隧道,VPN 开着连不上局域网/NAS/家里机器。设上 excludedRoutes 后 LAN 直连。 + private func excludedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] { + routePrefixes(o.getInet4RouteExcludeAddress()) + .map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) } + } + private func excludedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] { + routePrefixes(o.getInet6RouteExcludeAddress()) + .map { NEIPv6Route(destinationAddress: $0.address, + networkPrefixLength: NSNumber(value: $0.prefix)) } + } + // utun fd:getpeername 得 sockaddr_ctl,匹配 utun 控制 id(WireGuard 技法) private static func tunFd() -> Int32? { var ctlInfo = ctl_info() diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 038c295..5f0b342 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -343,7 +343,7 @@ class StringsEs extends AppText { @override String get payMethodCrypto => 'USDT (TRC20)'; @override - String get choosePayMethod => 'Elige el método de pago'; + String get choosePayMethod => 'Elige un método de pago'; @override String get proMonthly => 'Pro · Mensual'; @override diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 8d4eab2..7a3cdac 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -345,7 +345,7 @@ class StringsJa extends AppText { @override String get choosePayMethod => 'お支払い方法を選択'; @override - String get proMonthly => 'Pro · 月額'; + String get proMonthly => 'Pro・月額'; @override String get proQuarterly => 'Pro · 3か月'; @override diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index e72bb9f..a064b62 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -333,7 +333,7 @@ class StringsRu extends AppText { @override String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.'; @override - String get purchaseTitle => 'Покупка тарифа'; + String get purchaseTitle => 'Купить тариф'; @override String get paymentTitle => 'Оплата'; @override @@ -345,7 +345,7 @@ class StringsRu extends AppText { @override String get choosePayMethod => 'Выберите способ оплаты'; @override - String get proMonthly => 'Pro · Месяц'; + String get proMonthly => 'Pro · Помесячно'; @override String get proQuarterly => 'Pro · Квартал'; @override diff --git a/client/macos/PacketTunnel/PacketTunnelProvider.swift b/client/macos/PacketTunnel/PacketTunnelProvider.swift index 4ffb4c4..69d923a 100644 --- a/client/macos/PacketTunnel/PacketTunnelProvider.swift +++ b/client/macos/PacketTunnel/PacketTunnelProvider.swift @@ -201,6 +201,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol let ipv4 = NEIPv4Settings(addresses: v4addrs.map(\.address), subnetMasks: v4addrs.map(\.mask)) ipv4.includedRoutes = includedRoutes4(options) + ipv4.excludedRoutes = excludedRoutes4(options) settings.ipv4Settings = ipv4 } let v6addrs = routePrefixes(options.getInet6Address()) @@ -208,6 +209,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol let ipv6 = NEIPv6Settings(addresses: v6addrs.map(\.address), networkPrefixLengths: v6addrs.map { NSNumber(value: $0.prefix) }) ipv6.includedRoutes = includedRoutes6(options) + ipv6.excludedRoutes = excludedRoutes6(options) settings.ipv6Settings = ipv6 } if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty { @@ -301,6 +303,19 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol networkPrefixLength: NSNumber(value: $0.prefix)) } } + // 排除路由:sing-box config 的 route_exclude_address(私有 LAN 网段)经 libbox + // 暴露在此。之前只设 includedRoutes、丢了 excludedRoutes → strict_route 把 LAN 也 + // 抓进隧道,VPN 开着连不上局域网/NAS/家里机器。设上 excludedRoutes 后 LAN 直连。 + private func excludedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] { + routePrefixes(o.getInet4RouteExcludeAddress()) + .map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) } + } + private func excludedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] { + routePrefixes(o.getInet6RouteExcludeAddress()) + .map { NEIPv6Route(destinationAddress: $0.address, + networkPrefixLength: NSNumber(value: $0.prefix)) } + } + // utun fd:getpeername 得 sockaddr_ctl,匹配 utun 控制 id(WireGuard 技法) private static func tunFd() -> Int32? { var ctlInfo = ctl_info() diff --git a/deploy/single-node/acl.json.example b/deploy/single-node/acl.json.example new file mode 100644 index 0000000..8229bae --- /dev/null +++ b/deploy/single-node/acl.json.example @@ -0,0 +1,19 @@ +{ + "_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] + } + ] +} diff --git a/deploy/single-node/systemd/pangolin-agent.service b/deploy/single-node/systemd/pangolin-agent.service index 5ce9add..f24fe38 100644 --- a/deploy/single-node/systemd/pangolin-agent.service +++ b/deploy/single-node/systemd/pangolin-agent.service @@ -12,6 +12,10 @@ StartLimitIntervalSec=0 Type=simple EnvironmentFile=/etc/pangolin-agent/agent.env ExecStart=/usr/local/bin/pangolin-agent +# SIGHUP → agent 重读节点本地配置(acl.json/warp.json)并热重渲染,不冷启动 +# sing-box、不踢在线用户。没有这行,`systemctl reload` 会报「Job type reload is +# not applicable」,运维只能退回 restart(整进程重启,sing-box 冷启动瞬断全员)。 +ExecReload=/bin/kill -HUP $MAINPID # 始终自愈(崩溃/被依赖停后都拉起);RestartSec 做退避。 Restart=always RestartSec=5 diff --git a/design/i18n/strings.json b/design/i18n/strings.json index c6f7bda..506fe19 100644 --- a/design/i18n/strings.json +++ b/design/i18n/strings.json @@ -1294,7 +1294,7 @@ "en": "Purchase", "ja": "プランを購入", "ko": "요금제 구매", - "ru": "Покупка тарифа", + "ru": "Купить тариф", "es": "Comprar plan" }, "paymentTitle": { @@ -1335,14 +1335,14 @@ "ja": "お支払い方法を選択", "ko": "결제 수단 선택", "ru": "Выберите способ оплаты", - "es": "Elige el método de pago" + "es": "Elige un método de pago" }, "proMonthly": { "zh": "专业版·月付", "en": "Pro · Monthly", - "ja": "Pro · 月額", + "ja": "Pro・月額", "ko": "Pro · 월간", - "ru": "Pro · Месяц", + "ru": "Pro · Помесячно", "es": "Pro · Mensual" }, "proQuarterly": { @@ -2423,4 +2423,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/ci-runner.md b/docs/ci-runner.md index 7c9f7dc..5941e46 100644 --- a/docs/ci-runner.md +++ b/docs/ci-runner.md @@ -10,13 +10,13 @@ | 级别 | **项目级**(绑 `wangjia/pangolin`,只服务本仓库) | | 机器 | mac 本机(`/Users/wangjia/bin/act_runner` v0.6.1) | | 执行模式 | **host**(非容器) | -| label | `nas`(即 `runs-on: nas`) | +| label | `mac`(即 `runs-on: mac`) | | 连接 | 经 jiu 的 TCP relay `127.0.0.1:13000` → NAS gitea `192.168.3.200:3000` | ### 为什么是 host 模式 + mac - ci.yml 的 9 个 job **全是 docker-in-docker**(每个 job 内部 `docker run …` 拉 golang/flutter/redis 等镜像)。container 模式(像 jiu 的 `ubuntu-latest:docker://catthehacker`)在非特权容器里跑不了 DinD,所以必须 **host 模式**。 - mac 的 Docker Desktop 能跑 Linux 容器,host 模式下 `docker run ` 可用。 -- 理想归宿其实是 **NAS(Linux)host runner**(给 NAS 现有 runner 加 `nas:host` label),mac 只是当前落点。 +- 理想归宿其实是 **NAS(Linux)host runner**(给 NAS 现有 runner 加 `mac:host` label),mac 只是当前落点。 ### 为什么要 relay(13000) mac 上的 Shadowrocket 网络扩展会拦截 act_runner 这个 Go 二进制的流量,直连 NAS gitea 失败。jiu 已有一个常驻 python TCP relay(`~/bin/tcp_relay.py`,`127.0.0.1:13000 → 192.168.3.200:3000`),act_runner 连本地 relay 绕过。pangolin runner **复用**这个 relay。 @@ -30,7 +30,7 @@ runner: capacity: 1 timeout: 3h labels: - - "nas:host" + - "mac:host" cache: enabled: true host: diff --git a/docs/index.html b/docs/index.html index 372cedf..29ed885 100644 --- a/docs/index.html +++ b/docs/index.html @@ -59,6 +59,11 @@
去重 + 归位:App 自足、Web 退居次要;每功能一个家。桌面提 Account 为一级侧栏项,移动 Account tab 含设置/联系下钻。收敛联系渠道(×3→1)/语言主题(×2→1)/购买入口(散3→Account)。为「🔔 通知」(顶栏铃铛)+「邀请好友」(账户入口)预留落点。决策:奖励双方都得、兑换码保留、Web 链接保留。后续 ②推广激励 ③系统通知 另立 spec。
docs/account-ia-reorg-design.html
+ +
私有目的地访问控制(节点侧 ACL)HTML
+
pangolin 出口 IP 曾等价于「只有我」,多用户后退化成「所有 pangolin 用户」——家庭内网服务(brain/nas/git/win.51yanmei.com)因此对全体用户敞开。在节点 sing-box 加一道按 dp_uuid 的闸:节点本地 acl.json(照 warp.json 骨架)→ 每个目的地渲染「放行白名单 + 兜底拒绝」一对规则,与 WARP 规则合并而非覆写。控制面/DB/proto/客户端/管理后台一律不动,改动收敛在 internal/agentd/。关键点:失效方向必须 fail-closed(与 WARP 的 fail-open 先例相反)、白名单须同时含设备级与账户级 dp_uuid(否则 /sub 订阅链接把自己锁在外面)、生效走 agent SIGHUP 以免重启踢掉全部在线用户。含四态渲染矩阵、规则顺序三条硬约束、名单维护 runbook、验收清单。
+
docs/private-dest-acl-design.html
+
CI/CD 全流程(tag 触发编译/发版/部署)HTML
#30。参考 jiu 的 scripts/ci + .gitea/workflows:tag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。
@@ -116,6 +121,11 @@
阅读版;执行真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md(14 任务 TDD,含 checkbox)。后端(1-10):迁移 000024(referrals/reward_claims + source 扩容) → codes.GrantRewardTx 发奖原语 → reward.Store/Service(邀请码+防刷) → 接注册 ReferralHook → 接首充 pay webhook Rewarder → GET /v1/invite → TG 绑定 token → TG webhook getChatMember 真校验 → main.go 装配。客户端(11-13):invite api/provider → invite_page 真实化+六语 l10n → 注册页邀请码输入(deep-link 预填单列可选后置)。设计见 invite-task-rewards-design.html。
docs/invite-task-rewards-plan.html · 真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md
+ +
私有目的地 ACL(节点侧)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-23-private-dest-acl.md(含 checkbox)。设计见 docs/private-dest-acl-design.html。6 个 TDD 任务:①ACL 配置类型 + fail-closed 的 active()(空白名单=拒绝所有人,非「未启用」)→ ②rules() 产出「全部放行→全部拒绝」规则对,两侧目的地条件逐字相同 → ③buildRoute 合并 ACL 与 WARP(四态矩阵;现状 cfg["route"] 是整块覆盖,直接赋值会干掉 WARP)→ ④渲染时读 ACL + 内存/磁盘双层 last-good 兜底(fail-closed 须跨重启成立)→ ⑤SIGHUP 触发热重渲染(重启 agent 会冷启 sing-box 踢掉全部在线用户)→ ⑥配置样例与 runbook。改动收敛在 internal/agentd/ + cmd/agent,零 migration/零 proto/零客户端改动。
+
docs/private-dest-acl-plan.html · 真相源 docs/superpowers/plans/2026-07-23-private-dest-acl.md
+
前端设计系统治理重构(ds-flow 全端)HTML
阅读版;执行真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。非从零 bootstrap(已约 65% 达标):补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。
diff --git a/docs/private-dest-acl-design.html b/docs/private-dest-acl-design.html new file mode 100644 index 0000000..49069c0 --- /dev/null +++ b/docs/private-dest-acl-design.html @@ -0,0 +1,251 @@ + + + + + +私有目的地访问控制(设计 · 节点侧 ACL) + + + +
+ ← 文档索引 +

私有目的地访问控制(设计 · 节点侧 ACL)

+

只有指定 dp_uuid 才能经 pangolin 出口访问家庭内网服务 · 2026-07-23

+ +
+

pangolin 出口 IP(103.119.13.48)是家庭内网服务在 ali 侧的唯一准入凭据——brain/nas/git/win.51yanmei.com 全靠「来自这个 IP」放行。单用户时它等价于「只有我」;现在 pangolin 有 5 个账号,它退化成了「所有 pangolin 用户」。本设计在节点 sing-box 上补一道按 dp_uuid 的闸,把语义拉回「只有我」。

+
+ +

1. 范围

+

只做「私有服务白名单」一件事。不做通用租户黑白名单、不做上网管控、不做管理后台、不要求实时生效。这些在评审中被显式砍掉——它们的复杂度大半来自「多策略 × 多组 × 实时下发」,而本需求三个都不需要。

+ +

问题的真实边界

+

经 pangolin 出口能摸到的家里服务,各自本来就有鉴权,唯一裸奔的是 brain

+ + + + + + + +
服务入口自带鉴权
git.51yanmei.comali 443 → GiteaGitea 登录
nas.51yanmei.com:5001frp → DSM账号 + 2FA + 自动封锁
nas/win SSH (10022/10023)frp密钥登录
win.51yanmei.com:3389frp → RDPNLA + 密码
brain.51yanmei.comali 443 → 静态站
+

所以本设计提供的是网络层前置闸(别人连登录页都摸不到),而不是唯一防线。

+ +

2. 关键决策

+ + + + + + + + + +
维度选择理由
执行点节点 sing-box route.rules控制面不在数据路径上;sing-box 无外部授权钩子(实测 external_controller/auth_request/external_acl/authenticator/webhook/http_provider/external_rule_provider 七个字段名全部 unknown field)。客户端执行会被用户自行修改,且 /sub/{token} 路径天然绕过。
名单来源节点本地 acl.jsonwarp.json 现成骨架。控制面/DB/proto/客户端/管理后台一律不动,改动收敛在 internal/agentd/ 一个包。
主体粒度dp_uuid 列表dp_uuid 每设备一个、懒生成、从不轮换RotateCredential 协议在但控制面无调用方),写死后基本一劳永逸。当前我的账号名下仅 1 台设备有 dp_uuid。
目的地表达域名 + IP:端口 两类brain/git 与公开的 jiu/travel/sudoku 共用 ali 的 443,只能靠 SNI 区分;DSM/RDP/SSH 用独立端口,直接 IP+端口匹配。
sniff复用现有WARP 已在节点启用,线上 route 块本来就是 [{"action":"sniff"},{...warp}]——域名匹配零额外成本。
失效方向fail-closed与 WARP 相反。见 §5,这是本设计唯一容易写错的地方。
生效方式agent SIGHUP 触发重渲染不要求延时,但重启 agent 会让 sing-box 走冷启动、踢掉全部在线用户(见 §6),故加一个轻量信号处理。
+ +

3. 现状事实(设计所依赖的)

+ + +

4. 设计

+ +

4.1 配置文件 /etc/pangolin-agent/acl.json

+
{
+  "enabled": true,
+  "allow_dp_uuids": [
+    "<我的设备级 dp_uuid>",
+    "<我的账户级 dp_uuid(/sub 用)>"
+  ],
+  "targets": [
+    { "domain":  ["brain.51yanmei.com", "git.51yanmei.com"] },
+    { "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
+  ]
+}
+

targets 每项是一个「目的地形状」,字段直接对应 sing-box route rule 的匹配字段(同一项内多字段是 AND,字段内多值是 OR)。刻意不做自研 DSL——形状即 sing-box 语义,减少一层翻译和一类 bug。

+ +

4.2 渲染出的 route 块

+

每个 target 产出一对规则:先放行白名单、再兜底拒绝。顺序即优先级,首条命中即返回。

+
"route": {
+  "rules": [
+    {"action": "sniff"},
+    {"action": "resolve"},
+
+    {"auth_user": ["<我的uuid…>"], "domain": ["brain.51yanmei.com","git.51yanmei.com"],
+     "outbound": "direct"},
+    {"auth_user": ["<我的uuid…>"], "ip_cidr": ["182.92.213.171/32"],
+     "port": [5001,3389,10022,10023], "outbound": "direct"},
+
+    {"domain": ["brain.51yanmei.com","git.51yanmei.com"], "action": "reject"},
+    {"ip_cidr": ["182.92.213.171/32"], "port": [5001,3389,10022,10023],
+     "action": "reject"},
+
+    {"domain_suffix": ["reddit.com","redd.it",…], "outbound": "warp"}
+  ],
+  "final": "direct"
+}
+
+

血泪教训:VLESS 只认 auth_user,不认 user(2026-07-23 生产验证)

+

放行规则匹配凭证必须用 auth_user 而非 usersing-box check 对两者都语法通过,但 user 字段对 VLESS/REALITY 入站运行时根本不匹配——放行规则永不命中,结果连白名单用户也被兜底拒绝(全员进不去)。上线时正是踩了这个:节点端 git 一直 000,把本机 uuid 换着法加进白名单都没用。本地起一对真 VLESS 实例实测才定位:auth_user:["good"] 生效(good 通/bad 被 block),user 不生效。只跑 sing-box check 不足以验收访问控制,必须真连接跑一次。

+
+

已用本机 sing-box 1.13.13 真 VLESS 连接验证 auth_user 匹配 + resolve 后 ip_cidr/domain 仍匹配;节点端以 git 做「白名单一进一出」验证 per-user 放行/拒绝生效。

+ +
+

规则顺序的三条硬约束

+
    +
  1. {"action":"sniff"} 必须最先且只出现一次——域名匹配依赖它取 TLS SNI。合并时若 WARP 已产出 sniff,不得重复追加。
  2. +
  3. 全部 ACL 规则(放行 + 拒绝)排在 WARP 规则之前。当前两者目的地不重叠(reddit vs 我的域名),但被拒绝的目的地永远不该有机会被路由到 warp 出口。
  4. +
  5. 放行必须排在拒绝之前,且两者目的地条件逐字相同。任何不对称都会造成「我自己也被拒」或「有人漏网」。
  6. +
+
+ +

4.3 与 WARP 的合并

+

render.gocfg["route"] = warp.warpRoute() 的直接赋值,改为由一个 buildRoute(acl, warp) 统一产出:

+ + + + + + +
ACLWARProute 块
不产出(与现有配置逐字节一致,保持向后兼容)
现状不变:sniff + warp 规则
sniff + ACL 规则final:"direct"
sniff + ACL 规则 + warp 规则final:"direct"endpoints 照旧由 WARP 注入
+ +

5. 失效语义 最易写错

+
+

WARP 的先例是 fail-open,绝不能照抄。singbox.go:321-324 加载失败即 warp = nil 放弃分流;warp.go:47-58active() 任一字段缺失即返回 false,注释写着「宁可不分流(全直连)」。对分流这是对的,对访问控制方向正好反了——同样的写法会让一次手抖的编辑把私有服务对全体 pangolin 用户敞开,而且是静默的。

+

本设计的规则:

+
    +
  • 解析失败 / 文件读不出保留上一次成功加载的 ACL(agent 内存中持有 last-good),不清空规则,打 ERROR 日志。
  • +
  • last-good 必须落盘:每次成功加载后把规范化结果写入 <StateDir>/acl.last-good.json(照 state.json 的持久化套路)。否则 agent 一重启,内存里的 last-good 就没了,fail-closed 只在进程生命周期内成立——而进程重启恰恰是最可能撞上坏配置的时刻。
  • +
  • agent 冷启动:先读 acl.json;失败则回退 acl.last-good.json两者都失败才不产出 ACL 规则,同时打 ERROR 并告警。注意 这最后一档的实际后果是 fail-open(私有服务对全体 pangolin 用户敞开)——之所以只能如此,是因为白名单与目的地清单同在一个文件,文件全丢时连「该拒绝哪些目的地」都无从得知,无法凭空拒起。这也是为什么 last-good 落盘是必需项而非优化项。
  • +
  • allow_dp_uuids 为空数组仍产出拒绝规则,即「谁都不许进」。空名单的语义是「没有人」,不是「所有人」。
  • +
  • 关闭 ACL 只有一条合法途径:显式写 "enabled": false。删文件不算(走 last-good 分支)。
  • +
  • agent 长期离线 → 节点保留旧配置,被移除的 uuid 在 agent 恢复前仍可进入。这是可接受的(不要求延时),但要在 runbook 里写明:撤销某人的访问后,须确认 agent 已重新渲染
  • +
+
+ +

6. 生效方式

+

编辑 acl.json 本身不会触发渲染——markDirty() 只在凭证变更时被调用。可选做法与代价:

+ + + + + +
做法代价
systemctl restart pangolin-agent会踢人 新进程内 s.started=false,首次渲染走 Restart()systemctl restart sing-box全部在线用户瞬断
等下一次凭证变更顺带生效时机不可控,可能几小时不生效
给 agent 加 SIGHUP 处理 → markDirty() 采用约 10 行;走 sing-box SIGHUP 热重载,不断线systemctl reload pangolin-agent 即可
+ +

7. 代码改动清单

+ + + + + + + + +
文件改动
internal/agentd/acl.go 新增ACLConfig 结构、LoadACLConfigactive()(fail-closed 语义)、aclRules() 产出规则对。镜像 warp.go 骨架。
internal/agentd/render.gorenderSingboxConfigacl 入参;抽出 buildRoute(acl, warp) 取代 cfg["route"] = warp.warpRoute()(§4.3 四态表)
internal/agentd/singbox.goRenderConfig 每次重读 acl.json(紧邻现有 warp 重读);持有 last-good ACL 字段,成功加载后落盘 acl.last-good.json,冷启动按 acl.json → last-good → 无 顺序回退
internal/agentd/config.go新增 ACLConfigPath,默认 <StateDir>/acl.json
internal/agentd/agent.gocmd/agentSIGHUP → markDirty()
internal/agentd/acl_test.go 新增见 §9
+

不动:控制面、数据库(零 migration)、proto、客户端、管理后台、CI。改动全部收敛在 internal/agentd/

+ +

8. 名单维护 Runbook

+

新增/更换设备后,重新生成白名单(UNION 那一半是账户级 dp_uuid,供 /sub 订阅链接用,别漏)。样例文件见 deploy/single-node/acl.json.example,复制到 /etc/pangolin-agent/acl.json 后按下方 SQL 填入 uuid。

+
sqlite3 /var/lib/pangolin/pangolin.db \
+  "SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id
+    WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL
+   UNION
+   SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"
+
+# 填进 acl.json 的 allow_dp_uuids 后
+systemctl reload pangolin-agent          # 不断线
+journalctl -u pangolin-agent -n 20       # 确认已重渲染、无 ACL ERROR
+

设备的 dp_uuid 是首次 connect 时懒生成的:新设备装好后要先连一次,才查得到 dp_uuid。

+ +

9. 测试与验收

+

单元测试(acl_test.go,表驱动,镜像 warp_test.go

+ +

上线验收

+
    +
  1. 我的设备:brain 首页 200、DSM 5001 可登录、ssh nas-r
  2. +
  3. 另一个账号的设备:以上全部被拒(连接被 reject,非超时)
  4. +
  5. 公开站不受影响:jiu / travel / sudoku / pay 在两个账号下均正常
  6. +
  7. WARP 未被破坏:reddit 仍走 warp 出口
  8. +
  9. acl.json 改坏 → 规则仍在、日志有 ERROR(fail-closed 实证)
  10. +
  11. systemctl reload pangolin-agent 期间在线用户不掉线
  12. +
+ +

10. 刻意不做(YAGNI)

+ + +

11. 演进路径

+

若将来真要做多租户策略,渲染层可原样保留,只把名单来源换掉:给 Credentialinternal/pb/agentv1/types.go + agentd/singbox.goCred)加一个 PrivateAccess bool。该字段同时走增量推送与 Register 全量快照两条通道,天然免疫「重连被 ApplyConfig(snap,true) 整表替换」的问题。届时 acl.json 退化为只保留 targetsallow_dp_uuids 由控制面下发。

+ +

12. 已知残留

+ + +

相关:~/code/brain/docs/remote-access-brain-domain.html(brain 外网入口与出口 IP 白名单)· baize 台账 ~/code/baize/data/{hosts,domains}.yaml

+
+ + diff --git a/docs/private-dest-acl-plan.html b/docs/private-dest-acl-plan.html new file mode 100644 index 0000000..d17414e --- /dev/null +++ b/docs/private-dest-acl-plan.html @@ -0,0 +1,1093 @@ + + + + + +私有目的地 ACL · 实现计划(阅读版) + + + +
+← 文档索引 + +

私有目的地访问控制(节点侧 ACL)Implementation Plan

+

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

+

Goal: 在节点 sing-box 上加一道按 dp_uuid 的闸,使家庭内网服务(brain/nas/git/win.51yanmei.com)只对白名单凭证开放,而不是对所有 pangolin 用户开放。

+

Architecture: 节点本地 acl.json(照 warp.json 骨架)声明「白名单 dp_uuid + 私有目的地清单」;agent 每次渲染 sing-box 配置时读取,为每个目的地产出一对 route 规则——先放行白名单、再兜底拒绝。ACL 规则与 WARP 规则由新的 buildRoute() 合并进同一个 route 块(现状是 WARP 整块覆盖 cfg["route"],直接赋值会把对方干掉)。控制面、数据库、proto、客户端、管理后台一律不动。

+

Tech Stack: Go 1.2x,标准库;sing-box 1.13.13 配置语义;测试为同包 *_test.go + t.TempDir()go test ./internal/agentd/...

+

Global Constraints

+ +

执行后修订(2026-07-23,提交 2abe4d0)· 血泪教训:放行规则匹配凭证必须用 auth_user 而非本计划各处写的 usersing-box check 对两者都语法通过,但 user 字段对 VLESS/REALITY 入站运行时不匹配,导致放行规则永不命中、连白名单用户也被兜底拒绝(全员进不去)。生产上线时踩中,本地起真 VLESS 连接实测才定位。凡本计划(Task 2 rules()、Task 3 测试)出现 r["user"] = uuids 或断言 r["user"] 之处,一律应为 auth_user 且验收不能只跑 sing-box check——必须真连接跑一次(节点端「白名单 uuid 一进一出」即可)。

+
+

Task 1: ACL 配置类型、加载与 fail-closed 的 active()

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

创建 server/internal/agentd/acl_test.go

+
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)
+	}
+}
+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'ACL' -v
+

Expected: 编译失败,undefined: LoadACLConfig / undefined: ACLConfig / c.ACLConfigPath undefined

+
[ ]Step 3: 写实现
+

创建 server/internal/agentd/acl.go

+
package agentd
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+)
+
+// ACLTarget 描述一组「私有目的地」的匹配条件。字段名与取值直接对应 sing-box
+// route rule 的同名字段:同一项内多字段是 AND,字段内多值是 OR。刻意不做自研 DSL
+// —— 形状即 sing-box 语义,少一层翻译就少一类 bug。
+//
+// 典型两类:
+//   - 与公开站共用 443 的私有 vhost(brain/git) → 用 domain,依赖 sniff 取 SNI
+//   - 独占端口的服务(DSM 5001 / RDP 3389 / SSH 10022-10023) → 用 ip_cidr + port
+type ACLTarget struct {
+	Domain       []string `json:"domain,omitempty"`
+	DomainSuffix []string `json:"domain_suffix,omitempty"`
+	IPCIDR       []string `json:"ip_cidr,omitempty"`
+	Port         []int    `json:"port,omitempty"`
+}
+
+// ACLConfig 是节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
+// 只有 AllowDpUUIDs 里的凭证能访问 Targets 描述的目的地,其余一律 reject。
+//
+// 与 WarpConfig 的关键区别是失效方向:WARP 读不出来就不分流(fail-open)是安全的,
+// ACL 读不出来就不拦截等于把私有服务对全体用户敞开。故本类型的 active() 语义为
+// fail-closed —— 空白名单意味着「没有人」,不是「所有人」。
+type ACLConfig struct {
+	Enabled      bool        `json:"enabled"`
+	AllowDpUUIDs []string    `json:"allow_dp_uuids"`
+	Targets      []ACLTarget `json:"targets"`
+}
+
+// LoadACLConfig 读取并解析 acl.json。文件不存在 → (nil, nil)(未配置该功能,
+// 不是错误)。解析失败返回 error,由调用方决定回退到 last-good 还是告警。
+func LoadACLConfig(path string) (*ACLConfig, error) {
+	data, err := os.ReadFile(path)
+	if os.IsNotExist(err) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf("agentd: read acl config %q: %w", path, err)
+	}
+	var ac ACLConfig
+	if err := json.Unmarshal(data, &ac); err != nil {
+		return nil, fmt.Errorf("agentd: parse acl config %q: %w", path, err)
+	}
+	return &ac, nil
+}
+
+// empty 报告该 target 是否没有任何匹配条件(没有条件的规则会匹配一切,危险)。
+func (t ACLTarget) empty() bool {
+	return len(t.Domain) == 0 && len(t.DomainSuffix) == 0 &&
+		len(t.IPCIDR) == 0 && len(t.Port) == 0
+}
+
+// matchFields 把 target 转成 sing-box route rule 的匹配字段。
+// 每次调用返回全新 map —— 放行与拒绝两条规则各自在其上追加 user/outbound/action,
+// 共享同一对象会互相污染。
+func (t ACLTarget) matchFields() map[string]any {
+	m := make(map[string]any, 4)
+	if len(t.Domain) > 0 {
+		m["domain"] = t.Domain
+	}
+	if len(t.DomainSuffix) > 0 {
+		m["domain_suffix"] = t.DomainSuffix
+	}
+	if len(t.IPCIDR) > 0 {
+		m["ip_cidr"] = t.IPCIDR
+	}
+	if len(t.Port) > 0 {
+		m["port"] = t.Port
+	}
+	return m
+}
+
+// active 报告本 ACL 是否应真正注入规则。
+//
+// 注意与 WarpConfig.active() 的语义差别:此处 AllowDpUUIDs 为空**不影响**返回值。
+// 空白名单是一个合法且有意义的状态 ——「谁都不许访问这些目的地」。把它当作未启用
+// 会造成 fail-open。唯一的关闭途径是显式 "enabled": false。
+func (ac *ACLConfig) active() bool {
+	if ac == nil || !ac.Enabled {
+		return false
+	}
+	return len(ac.cleanTargets()) > 0
+}
+
+// cleanUUIDs 去空白/空项后返回白名单。
+func (ac *ACLConfig) cleanUUIDs() []string {
+	if ac == nil {
+		return nil
+	}
+	out := make([]string, 0, len(ac.AllowDpUUIDs))
+	for _, u := range ac.AllowDpUUIDs {
+		if u = strings.TrimSpace(u); u != "" {
+			out = append(out, u)
+		}
+	}
+	return out
+}
+
+// cleanTargets 规范化域名(小写去空白)并丢弃无任何条件的 target。
+func (ac *ACLConfig) cleanTargets() []ACLTarget {
+	if ac == nil {
+		return nil
+	}
+	out := make([]ACLTarget, 0, len(ac.Targets))
+	for _, t := range ac.Targets {
+		c := ACLTarget{
+			Domain:       cleanHosts(t.Domain),
+			DomainSuffix: cleanHosts(t.DomainSuffix),
+			IPCIDR:       cleanStrings(t.IPCIDR),
+			Port:         t.Port,
+		}
+		if !c.empty() {
+			out = append(out, c)
+		}
+	}
+	return out
+}
+
+func cleanHosts(in []string) []string {
+	out := make([]string, 0, len(in))
+	for _, s := range in {
+		if s = strings.TrimSpace(strings.ToLower(s)); s != "" {
+			out = append(out, s)
+		}
+	}
+	return out
+}
+
+func cleanStrings(in []string) []string {
+	out := make([]string, 0, len(in))
+	for _, s := range in {
+		if s = strings.TrimSpace(s); s != "" {
+			out = append(out, s)
+		}
+	}
+	return out
+}
+

修改 server/internal/agentd/config.go

+

① 包注释订正(原文声称 agent 保有零目的地数据,ACL 打破了这条,必须如实记录)。把第 7–10 行的 no-state invariant 段落改为:

+
// 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。
+

② 在 WarpConfigPath 字段之后新增:

+
	// ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
+	// 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。
+	ACLConfigPath string
+

③ 在 withDefaults()WarpConfigPath 分支之后新增:

+
	if c.ACLConfigPath == "" {
+		c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json")
+	}
+

④ 在文件末尾路径方法区新增:

+
// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json
+// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。
+func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") }
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'ACL|ConfigACLPaths' -v
+

Expected: PASS,全部子测试通过。

+
[ ]Step 5: 全包回归 + 提交
+
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 加载语义"
+

执行后修订(2026-07-23,提交 d4156b8:审查指出上面的 cleanHostswarp.go 既有的 cleanDomains 是同包内逐字重复的逻辑。已按用户裁决改为 cleanDomains 委托 cleanHostsfunc (wc *WarpConfig) cleanDomains() []string { return cleanHosts(wc.Domains) },并移除 warp.go 中变为未使用的 strings import)。若日后照本计划重新实现,请直接采用委托版本,不要写回两份重复实现。

+
+

Task 2: 由 ACL 产出 route 规则对(放行 + 兜底拒绝)

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.go

+
// 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)
+	}
+}
+

acl_test.go 的 import 块加入 "fmt"

+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'ACLRules' -v
+

Expected: 编译失败,ac.rules undefined (type *ACLConfig has no field or method rules)

+
[ ]Step 3: 写实现
+

server/internal/agentd/acl.go 末尾追加:

+
// 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
+}
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'ACL' -v
+

Expected: PASS。

+
[ ]Step 5: 提交
+
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 渲染放行/拒绝规则对,空白名单仍拒绝"
+
+

Task 3: buildRoute 合并 ACL 与 WARP,接入渲染

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.go

+
// 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 之前")
+		}
+	})
+}
+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'BuildRoute' -v
+

Expected: 编译失败,undefined: buildRoute

+
[ ]Step 3: 写实现
+

① 在 server/internal/agentd/render.go 中,把第 30 行签名与第 50–55 行的 route 注入替换为:

+
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) {
+
	// 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
+	}
+

② 在 render.gostatsUsers 之前插入:

+
// 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}
+}
+

③ 删除 server/internal/agentd/warp.go 第 111–121 行的 warpRoute() 方法及其上方注释块(唯一调用方已被 buildRoute 取代;grep -rn "warpRoute()" internal/agentd/ 应无剩余命中)。

+

④ 订正 render.go 第 14 行的隐私不变式注释。原文:

+
// Only the opaque dp_uuid is ever written; no account identity touches the node.
+

改为:

+
// 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。
+

⑤ 修改 server/internal/agentd/singbox.go 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):

+
	return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v
+

Expected: PASS。特别确认既有的 TestRender_NoWarp_NoRouteSectionTestRender_Warp_InjectsEndpointAndRoute 仍绿——这两条守着向后兼容。

+
[ ]Step 5: 提交
+
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"
+
+

Task 4: 渲染时读取 ACL,last-good 内存 + 落盘兜底

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.go

+
// 成功加载后必须把快照落盘,否则 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 块")
+	}
+}
+

acl_test.go 的 import 块补齐 "encoding/json""strings",以及 agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"

+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+

Expected: TestACL_PersistsLastGoodOnLoad 失败(last-good 未落盘),TestACL_BrokenFile... / TestACL_ColdStart... 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。

+
[ ]Step 3: 写实现
+

① 在 server/internal/agentd/acl.go 末尾追加:

+
// 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)
+}
+

② 在 server/internal/agentd/singbox.goSingBox 结构体中,configVersion int64 之后新增字段:

+
	// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
+	// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
+	lastGoodACL *ACLConfig
+

③ 在 server/internal/agentd/singbox.goRenderConfig 中,把 WARP 加载之后的部分替换为:

+
	// 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
+}
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+

Expected: PASS,四条全绿。

+
[ ]Step 5: 提交
+
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 跨重启"
+
+

Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户)

+

Files:

+ +

Interfaces:

+ +

为什么不用 systemctl restart pangolin-agent 新进程内 s.started == falsesingbox.go:64-67),首次渲染会走 restarter.Restart()systemctl restart sing-box全部在线用户瞬断。SIGHUP 走的是既有的 debounce → writeAndRestartReload()(SIGHUP 热重载)路径,不断线。

+
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.go

+
// 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 触发了热重载")
+}
+

acl_test.go 的 import 块补齐 "context""time"

+

复用 singbox_test.go:17-47 既有的 fakeRestartercount() 返回 Restart 次数、reloadCount() 返回 Reload 次数)与 integration_test.go:248eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)

+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'Refresh' -v
+

Expected: 编译失败,sb.Refresh undefined

+
[ ]Step 3: 写实现
+

① 在 server/internal/agentd/singbox.gomarkDirty 之后追加:

+
// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
+// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
+// 冷启动(Restart),把全部在线用户踢下线。
+func (s *SingBox) Refresh() { s.markDirty() }
+

② 修改 server/cmd/agent/main.go,把第 44–45 行替换为:

+
	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()
+			}
+		}
+	}()
+

oslog 已在该文件 import 中(osenv() 用到的 os.LookupEnv);若 go build 报缺失,按提示补齐。

+
[ ]Step 4: 跑测试确认通过
+
cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v
+

Expected: PASS。

+
[ ]Step 5: 提交
+
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 重读节点本地配置并热重渲染,不踢在线用户"
+
+

Task 6: 节点配置样例与运维文档

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写样例文件
+

创建 deploy/single-node/acl.json.example

+
{
+  "_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]
+    }
+  ]
+}
+

_comment 字段会被 encoding/json 忽略(ACLConfig 无对应字段且未启用 DisallowUnknownFields),可安全保留作自文档。样例里的 TODO- 值是样例文件的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。

+
[ ]Step 2: 加一条测试,锁死「样例文件永远可解析」
+

样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 go run 检查一次没有约束力。改为一条常驻测试。追加到 server/internal/agentd/acl_test.go

+
// 样例文件必须始终可被解析且产出预期规则,防止改了 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)
+		}
+	}
+}
+

跑:

+
cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -v
+

Expected: PASS。

+

注:_comment 字段会被 encoding/json 忽略(ACLConfig 无对应字段且未启用 DisallowUnknownFields),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。

+
[ ]Step 3: 更新设计文档的 runbook 指向
+

docs/private-dest-acl-design.html 的 §8 首段(<h2>8. 名单维护 Runbook</h2> 之后的第一个 <p>)末尾追加一句:

+
样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。
+
[ ]Step 4: 提交
+
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 配置样例与常驻校验测试"
+

git add 只列具体路径,切勿 git add -A:仓库里有未跟踪的 pangolin-release.jks(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。

+
+

上线验收(人工,非代码任务)

+

代码合并后在 pangolin1 上执行。先备份现网 sing-box 配置sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)(此处 $(...) 在服务器交互 shell 中手动执行,不经 agent)。

+
    +
  1. 取白名单 uuid(UNION 那一半是账户级 dp_uuid,供 /sub 订阅链接用,漏了会把自己锁在外面):
  2. +
+

```sql

+

sqlite3 /var/lib/pangolin/pangolin.db \

+

"SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id

+

WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL

+

UNION

+

SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"

+

```

+
    +
  1. /etc/pangolin-agent/acl.json(权限 0600,属主 pangolin),systemctl reload pangolin-agent
  2. +
  3. journalctl -u pangolin-agent -n 30 确认已重渲染、无 [acl] ERROR / [acl] ALERT
  4. +
  5. sudo python3 -c "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])" 确认规则顺序为 sniff → resolve → 放行 → 拒绝 → warp(resolve 见 I3:堵住 ip_cidr 目的地的域名形式绕过)。
  6. +
+

config.json 内容正常不等于线上生效——SIGHUP 走的是 sing-box 自身校验,若新配置被 sing-box 拒绝,它会保留旧实例继续跑,agent 侧仍记"渲染成功"。reload 后必须额外确认:

+ +

不在本计划范围内

+ +
+ + diff --git a/docs/superpowers/plans/2026-07-23-private-dest-acl.md b/docs/superpowers/plans/2026-07-23-private-dest-acl.md new file mode 100644 index 0000000..1ad617f --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-private-dest-acl.md @@ -0,0 +1,1251 @@ +# 私有目的地访问控制(节点侧 ACL)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在节点 sing-box 上加一道按 dp_uuid 的闸,使家庭内网服务(brain/nas/git/win.51yanmei.com)只对白名单凭证开放,而不是对所有 pangolin 用户开放。 + +**Architecture:** 节点本地 `acl.json`(照 `warp.json` 骨架)声明「白名单 dp_uuid + 私有目的地清单」;agent 每次渲染 sing-box 配置时读取,为每个目的地产出一对 route 规则——先放行白名单、再兜底拒绝。ACL 规则与 WARP 规则由新的 `buildRoute()` 合并进同一个 route 块(现状是 WARP 整块覆盖 `cfg["route"]`,直接赋值会把对方干掉)。控制面、数据库、proto、客户端、管理后台一律不动。 + +**Tech Stack:** Go 1.2x,标准库;sing-box 1.13.13 配置语义;测试为同包 `*_test.go` + `t.TempDir()`,`go test ./internal/agentd/...`。 + +## Global Constraints + +- 设计真相源:`docs/private-dest-acl-design.html`。本计划的任何取舍以该文档为准。 +- **改动收敛在 `server/internal/agentd/` + `server/cmd/agent/main.go`**。不得新增 migration、不得改 proto、不得改控制面(`internal/httpapi`、`internal/nodes`)、不得改客户端。 +- **失效方向必须 fail-closed**,与 `warp.go` 的 fail-open 先例相反。具体:读失败保留 last-good;`allow_dp_uuids: []` 仍产出拒绝规则;仅 `"enabled": false` 能关闭。 +- **route 规则顺序硬约束**:`{"action":"sniff"}` 唯一且最先 → ACL 放行 → ACL 拒绝 → WARP 分流。 +- **放行与拒绝的目的地条件必须逐字相同**,且全部放行规则排在全部拒绝规则之前(目的地可能重叠,交错会漏)。 +- 向后兼容:ACL 与 WARP 均未启用时,渲染结果必须与改动前**逐字节一致**(现有测试 `TestRender_NoWarp_NoRouteSection` 守这条)。 +- 每个 Task 结束必须 `cd server && go build ./... && go test ./internal/agentd/...` 全绿再提交。 +- Commit message 前缀用 `feat(agent):` / `test(agent):` / `refactor(agent):`。 + +> **执行后修订(2026-07-23,提交 `2abe4d0`)· 血泪教训**:放行规则匹配凭证必须用 **`auth_user`** 而非本计划各处写的 `user`。`sing-box check` 对两者都语法通过,但 `user` 字段对 VLESS/REALITY 入站**运行时不匹配**,导致放行规则永不命中、**连白名单用户也被兜底拒绝**(全员进不去)。生产上线时踩中,本地起真 VLESS 连接实测才定位。**凡本计划(Task 2 `rules()`、Task 3 测试)出现 `r["user"] = uuids` 或断言 `r["user"]` 之处,一律应为 `auth_user`。** 且验收不能只跑 `sing-box check`——必须真连接跑一次(节点端「白名单 uuid 一进一出」即可)。 + +--- + +### Task 1: ACL 配置类型、加载与 fail-closed 的 active() + +**Files:** +- Create: `server/internal/agentd/acl.go` +- Create: `server/internal/agentd/acl_test.go` +- Modify: `server/internal/agentd/config.go`(新增 `ACLConfigPath` 字段、`withDefaults` 分支、`ACLLastGoodPath()`,并订正包注释) + +**Interfaces:** +- Produces: `type ACLTarget struct{ Domain, DomainSuffix, IPCIDR []string; Port []int }`;`type ACLConfig struct{ Enabled bool; AllowDpUUIDs []string; Targets []ACLTarget }`;`func LoadACLConfig(path string) (*ACLConfig, error)`;`func (ac *ACLConfig) active() bool`;`func (ac *ACLConfig) cleanUUIDs() []string`;`func (ac *ACLConfig) cleanTargets() []ACLTarget`;`func (t ACLTarget) empty() bool`;`func (t ACLTarget) matchFields() map[string]any`;`func (c Config) ACLLastGoodPath() string` +- Consumes: 无(本任务是叶子) + +- [ ] **Step 1: 写失败测试** + +创建 `server/internal/agentd/acl_test.go`: + +```go +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) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd server && go test ./internal/agentd/ -run 'ACL' -v +``` + +Expected: 编译失败,`undefined: LoadACLConfig` / `undefined: ACLConfig` / `c.ACLConfigPath undefined`。 + +- [ ] **Step 3: 写实现** + +创建 `server/internal/agentd/acl.go`: + +```go +package agentd + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// ACLTarget 描述一组「私有目的地」的匹配条件。字段名与取值直接对应 sing-box +// route rule 的同名字段:同一项内多字段是 AND,字段内多值是 OR。刻意不做自研 DSL +// —— 形状即 sing-box 语义,少一层翻译就少一类 bug。 +// +// 典型两类: +// - 与公开站共用 443 的私有 vhost(brain/git) → 用 domain,依赖 sniff 取 SNI +// - 独占端口的服务(DSM 5001 / RDP 3389 / SSH 10022-10023) → 用 ip_cidr + port +type ACLTarget struct { + Domain []string `json:"domain,omitempty"` + DomainSuffix []string `json:"domain_suffix,omitempty"` + IPCIDR []string `json:"ip_cidr,omitempty"` + Port []int `json:"port,omitempty"` +} + +// ACLConfig 是节点本地的私有目的地访问控制表(默认 /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 +} +``` + +修改 `server/internal/agentd/config.go`: + +① 包注释订正(原文声称 agent 保有零目的地数据,ACL 打破了这条,必须如实记录)。把第 7–10 行的 no-state invariant 段落改为: + +```go +// 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。 +``` + +② 在 `WarpConfigPath` 字段之后新增: + +```go + // ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 /acl.json)。 + // 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。 + ACLConfigPath string +``` + +③ 在 `withDefaults()` 里 `WarpConfigPath` 分支之后新增: + +```go + if c.ACLConfigPath == "" { + c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json") + } +``` + +④ 在文件末尾路径方法区新增: + +```go +// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json +// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。 +func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") } +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd server && go test ./internal/agentd/ -run 'ACL|ConfigACLPaths' -v +``` + +Expected: PASS,全部子测试通过。 + +- [ ] **Step 5: 全包回归 + 提交** + +```bash +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 加载语义" +``` + +> **执行后修订(2026-07-23,提交 `d4156b8`)**:审查指出上面的 `cleanHosts` 与 `warp.go` 既有的 `cleanDomains` 是同包内逐字重复的逻辑。已按用户裁决改为 **`cleanDomains` 委托 `cleanHosts`**(`func (wc *WarpConfig) cleanDomains() []string { return cleanHosts(wc.Domains) }`,并移除 `warp.go` 中变为未使用的 `strings` import)。**若日后照本计划重新实现,请直接采用委托版本,不要写回两份重复实现。** + +--- + +### Task 2: 由 ACL 产出 route 规则对(放行 + 兜底拒绝) + +**Files:** +- Modify: `server/internal/agentd/acl.go`(新增 `rules()`) +- Modify: `server/internal/agentd/acl_test.go`(追加测试) + +**Interfaces:** +- Consumes: Task 1 的 `ACLConfig.active()` / `cleanUUIDs()` / `cleanTargets()` / `ACLTarget.matchFields()` +- Produces: `func (ac *ACLConfig) rules() []any` —— 返回按「全部放行规则 → 全部拒绝规则」排序的 sing-box route 规则切片;未 active 时返回 nil + +- [ ] **Step 1: 写失败测试** + +追加到 `server/internal/agentd/acl_test.go`: + +```go +// 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) + } +} +``` + +在 `acl_test.go` 的 import 块加入 `"fmt"`。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd server && go test ./internal/agentd/ -run 'ACLRules' -v +``` + +Expected: 编译失败,`ac.rules undefined (type *ACLConfig has no field or method rules)`。 + +- [ ] **Step 3: 写实现** + +在 `server/internal/agentd/acl.go` 末尾追加: + +```go +// 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 +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd server && go test ./internal/agentd/ -run 'ACL' -v +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +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 渲染放行/拒绝规则对,空白名单仍拒绝" +``` + +--- + +### Task 3: buildRoute 合并 ACL 与 WARP,接入渲染 + +**Files:** +- Modify: `server/internal/agentd/render.go`(`:30-58` `renderSingboxConfig` 增参、替换 `cfg["route"]` 赋值、新增 `buildRoute`;`:14` 隐私不变式注释订正) +- Modify: `server/internal/agentd/warp.go:113-121`(删除 `warpRoute()`,其唯一调用方被 `buildRoute` 取代) +- Modify: `server/internal/agentd/singbox.go:326`(`renderSingboxConfig` 调用处补 `nil` 实参,本任务先传 nil,Task 4 换成真实 ACL) +- Modify: `server/internal/agentd/acl_test.go`(追加四态矩阵测试) + +**Interfaces:** +- Consumes: Task 2 的 `(*ACLConfig).rules()`;既有 `(*WarpConfig).active()` / `cleanDomains()` / `warpEndpoint()` +- Produces: `func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any`(两者皆未激活时返回 nil);`renderSingboxConfig` 新签名 `func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error)` + +- [ ] **Step 1: 写失败测试** + +追加到 `server/internal/agentd/acl_test.go`: + +```go +// 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 之前") + } + }) +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd server && go test ./internal/agentd/ -run 'BuildRoute' -v +``` + +Expected: 编译失败,`undefined: buildRoute`。 + +- [ ] **Step 3: 写实现** + +① 在 `server/internal/agentd/render.go` 中,把第 30 行签名与第 50–55 行的 route 注入替换为: + +```go +func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) { +``` + +```go + // 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 + } +``` + +② 在 `render.go` 的 `statsUsers` 之前插入: + +```go +// 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} +} +``` + +③ 删除 `server/internal/agentd/warp.go` 第 111–121 行的 `warpRoute()` 方法及其上方注释块(唯一调用方已被 `buildRoute` 取代;`grep -rn "warpRoute()" internal/agentd/` 应无剩余命中)。 + +④ 订正 `render.go` 第 14 行的隐私不变式注释。原文: + +```go +// Only the opaque dp_uuid is ever written; no account identity touches the node. +``` + +改为: + +```go +// 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。 +``` + +⑤ 修改 `server/internal/agentd/singbox.go` 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL): + +```go + return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil) +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v +``` + +Expected: PASS。特别确认既有的 `TestRender_NoWarp_NoRouteSection` 与 `TestRender_Warp_InjectsEndpointAndRoute` 仍绿——这两条守着向后兼容。 + +- [ ] **Step 5: 提交** + +```bash +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" +``` + +--- + +### Task 4: 渲染时读取 ACL,last-good 内存 + 落盘兜底 + +**Files:** +- Modify: `server/internal/agentd/singbox.go:49-74`(`SingBox` 增 `lastGoodACL` 字段)、`:308-327`(`RenderConfig` 读 ACL) +- Modify: `server/internal/agentd/acl.go`(新增 `persistACL`) +- Modify: `server/internal/agentd/acl_test.go`(追加 last-good 测试) + +**Interfaces:** +- Consumes: Task 1 的 `LoadACLConfig` / `Config.ACLConfigPath` / `Config.ACLLastGoodPath()`;Task 3 的 `renderSingboxConfig` 六参签名;既有包内函数 `atomicWrite(path string, data []byte, perm os.FileMode) error` +- Produces: `func (s *SingBox) loadACL() *ACLConfig`;`func persistACL(path string, ac *ACLConfig) error` + +- [ ] **Step 1: 写失败测试** + +追加到 `server/internal/agentd/acl_test.go`: + +```go +// 成功加载后必须把快照落盘,否则 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 块") + } +} +``` + +在 `acl_test.go` 的 import 块补齐 `"encoding/json"`、`"strings"`,以及 `agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"`。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd server && go test ./internal/agentd/ -run 'TestACL_' -v +``` + +Expected: `TestACL_PersistsLastGoodOnLoad` 失败(last-good 未落盘),`TestACL_BrokenFile...` / `TestACL_ColdStart...` 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。 + +- [ ] **Step 3: 写实现** + +① 在 `server/internal/agentd/acl.go` 末尾追加: + +```go +// 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) +} +``` + +② 在 `server/internal/agentd/singbox.go` 的 `SingBox` 结构体中,`configVersion int64` 之后新增字段: + +```go + // lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它, + // 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。 + lastGoodACL *ACLConfig +``` + +③ 在 `server/internal/agentd/singbox.go` 的 `RenderConfig` 中,把 WARP 加载之后的部分替换为: + +```go + // 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 +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd server && go test ./internal/agentd/ -run 'TestACL_' -v +``` + +Expected: PASS,四条全绿。 + +- [ ] **Step 5: 提交** + +```bash +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 跨重启" +``` + +--- + +### Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户) + +**Files:** +- Modify: `server/internal/agentd/singbox.go`(新增导出的 `Refresh()`) +- Modify: `server/cmd/agent/main.go:44-45`(注册 SIGHUP 处理) +- Modify: `server/internal/agentd/acl_test.go`(追加 `Refresh` 测试) + +**Interfaces:** +- Consumes: 既有 `(*SingBox).markDirty()`(未导出)、`(*Agent).SingBox() *SingBox`(`agent.go:83`,已导出) +- Produces: `func (s *SingBox) Refresh()` —— 外部触发一次重渲染 + +**为什么不用 `systemctl restart pangolin-agent`:** 新进程内 `s.started == false`(`singbox.go:64-67`),首次渲染会走 `restarter.Restart()` 即 `systemctl restart sing-box`,**全部在线用户瞬断**。SIGHUP 走的是既有的 debounce → `writeAndRestart` → `Reload()`(SIGHUP 热重载)路径,不断线。 + +- [ ] **Step 1: 写失败测试** + +追加到 `server/internal/agentd/acl_test.go`: + +```go +// 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 触发了热重载") +} +``` + +在 `acl_test.go` 的 import 块补齐 `"context"` 与 `"time"`。 + +> 复用 `singbox_test.go:17-47` 既有的 `fakeRestarter`(`count()` 返回 Restart 次数、`reloadCount()` 返回 Reload 次数)与 `integration_test.go:248` 的 `eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)`。 + +- [ ] **Step 2: 跑测试确认失败** + +```bash +cd server && go test ./internal/agentd/ -run 'Refresh' -v +``` + +Expected: 编译失败,`sb.Refresh undefined`。 + +- [ ] **Step 3: 写实现** + +① 在 `server/internal/agentd/singbox.go` 的 `markDirty` 之后追加: + +```go +// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件 +// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走 +// 冷启动(Restart),把全部在线用户踢下线。 +func (s *SingBox) Refresh() { s.markDirty() } +``` + +② 修改 `server/cmd/agent/main.go`,把第 44–45 行替换为: + +```go + 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() + } + } + }() +``` + +`os` 与 `log` 已在该文件 import 中(`os` 见 `env()` 用到的 `os.LookupEnv`);若 `go build` 报缺失,按提示补齐。 + +- [ ] **Step 4: 跑测试确认通过** + +```bash +cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +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 重读节点本地配置并热重渲染,不踢在线用户" +``` + +--- + +### Task 6: 节点配置样例与运维文档 + +**Files:** +- Create: `deploy/single-node/acl.json.example` +- Modify: `server/internal/agentd/acl_test.go`(追加样例文件常驻校验测试) +- Modify: `docs/private-dest-acl-design.html`(把 §8 runbook 里的占位符换成指向样例文件) + +**Interfaces:** +- Consumes: Task 1 定义的 `ACLConfig` JSON 形状 +- Produces: 可直接复制到 `/etc/pangolin-agent/acl.json` 的样例 + +- [ ] **Step 1: 写样例文件** + +创建 `deploy/single-node/acl.json.example`: + +```json +{ + "_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] + } + ] +} +``` + +> `_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),可安全保留作自文档。样例里的 `TODO-` 值是**样例文件**的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。 + +- [ ] **Step 2: 加一条测试,锁死「样例文件永远可解析」** + +样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 `go run` 检查一次没有约束力。改为一条常驻测试。追加到 `server/internal/agentd/acl_test.go`: + +```go +// 样例文件必须始终可被解析且产出预期规则,防止改了 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) + } + } +} +``` + +跑: + +```bash +cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -v +``` + +Expected: PASS。 + +> 注:`_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。 + +- [ ] **Step 3: 更新设计文档的 runbook 指向** + +在 `docs/private-dest-acl-design.html` 的 §8 首段(`

8. 名单维护 Runbook

` 之后的第一个 `

`)末尾追加一句: + +```html +样例文件见 deploy/single-node/acl.json.example,复制到 /etc/pangolin-agent/acl.json 后按下方 SQL 填入 uuid。 +``` + +- [ ] **Step 4: 提交** + +```bash +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 配置样例与常驻校验测试" +``` + +> `git add` 只列具体路径,**切勿 `git add -A`**:仓库里有未跟踪的 `pangolin-release.jks`(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。 + +--- + +## 上线验收(人工,非代码任务) + +代码合并后在 pangolin1 上执行。**先备份现网 sing-box 配置**:`sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)`(此处 `$(...)` 在服务器交互 shell 中手动执行,不经 agent)。 + +1. 取白名单 uuid(`UNION` 那一半是账户级 dp_uuid,供 `/sub` 订阅链接用,**漏了会把自己锁在外面**): + ```sql + sqlite3 /var/lib/pangolin/pangolin.db \ + "SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id + WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL + UNION + SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';" + ``` +2. 写 `/etc/pangolin-agent/acl.json`(权限 `0600`,属主 `pangolin`),`systemctl reload pangolin-agent`。 +3. `journalctl -u pangolin-agent -n 30` 确认已重渲染、无 `[acl] ERROR` / `[acl] ALERT`。 +4. `sudo python3 -c "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])"` 确认规则顺序为 sniff → resolve → 放行 → 拒绝 → warp(resolve 见 I3:堵住 ip_cidr 目的地的域名形式绕过)。 + **`config.json` 内容正常不等于线上生效**——SIGHUP 走的是 sing-box 自身校验,若新配置被 sing-box 拒绝,它会保留旧实例继续跑,agent 侧仍记"渲染成功"。reload 后必须额外确认: + - `systemctl is-active sing-box` → `active`; + - `journalctl -u sing-box -n5 | grep -v FATAL`(有 FATAL 说明 sing-box 拒绝了新配置,旧实例还在跑,config.json 上的内容其实没生效)。 +5. **我的设备**:brain 首页 200、DSM 5001 可登录、`ssh nas-r` 通。 +6. **另一账号的设备**:以上全部被拒(连接被 reject,不是超时)。**分别用域名和裸 IP 两种形式各测一遍**(如 `curl https://brain.51yanmei.com` 与 `curl --resolve brain.51yanmei.com:443:103.119.13.48 https://brain.51yanmei.com`;DSM 同理分别用 `nas.51yanmei.com:5001` 与 `182.92.213.171:5001`)——I3 表明目的地表达形式(域名 vs IP)会走到不同的 sing-box 匹配路径,只测一种形式验证不到位。 +7. **公开站不受影响**:jiu / travel / sudoku / pay 在两个账号下均正常。 +8. **WARP 未被破坏**:reddit 仍走 warp 出口。 +9. **fail-closed 实证**:把 `acl.json` 改坏 → `systemctl reload pangolin-agent` → 规则仍在、日志有 ERROR;恢复文件。 +10. **不断线实证**:reload 期间另一台设备保持连接不掉。 + +## 不在本计划范围内 + +- 给 brain 加 mTLS / basic auth 作纵深防御(与本计划正交,约一小时,另开) +- 补 `assertNoIdentityFields` 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复) +- 控制面下发 ACL(演进路径见设计文档 §11,届时渲染层可原样保留) diff --git a/scripts/build-libbox.sh b/scripts/build-libbox.sh index b421a21..1803071 100644 --- a/scripts/build-libbox.sh +++ b/scripts/build-libbox.sh @@ -54,7 +54,10 @@ gomobile init echo "==> [2/3] 拉 sing-box $SINGBOX_VERSION 源码" mkdir -p "$WORK" rm -rf "$WORK/sing-box" -git clone --depth 1 --branch "$SINGBOX_VERSION" https://github.com/SagerNet/sing-box.git "$WORK/sing-box" +# sing-box 源码:默认 github,CI 里国内 runner 连不上 github → 经 SINGBOX_GIT 指向 +# NAS gitea 镜像(wangjia/sing-box,gitea migrate 自 github)。见 deploy-client.yml。 +SINGBOX_GIT="${SINGBOX_GIT:-https://github.com/SagerNet/sing-box.git}" +git clone --depth 1 --branch "$SINGBOX_VERSION" "$SINGBOX_GIT" "$WORK/sing-box" cd "$WORK/sing-box" echo "==> [3/3] 用官方 build_libbox 编核心库" diff --git a/scripts/local_test.sh b/scripts/local_test.sh index 7015770..3f3040a 100755 --- a/scripts/local_test.sh +++ b/scripts/local_test.sh @@ -12,11 +12,20 @@ # run 直跑 /Applications 版本(绕 Gatekeeper,实时输出日志,并打印测试账号) # all 依次执行 build → sign → copy → run # -# 用法: scripts/local_test.sh all | scripts/local_test.sh build ... +# ── iOS / iPad 真机(USB 连接;与上面 macOS 流程完全独立)── +# ios-devices 列出已连接的 iOS 物理设备(拿 device id) +# ipad [id|名字] iOS/iPad 真机装机:ad-hoc 导出(**公司**分发证书 Apple +# Distribution: Yanmei…)→ 核验签名主体 → devicectl 装。 +# 仅一台 iOS 设备时可省参数;iPhone/iPad 同连时须指定 id 或名字子串。 +# 注:不用 `flutter run --release` —— 那走开发签名,是**个人**证书。 +# +# 用法: scripts/local_test.sh all | scripts/local_test.sh ipad ... set -euo pipefail # ─────────── 配置(按需改)─────────── -API_URL="${API_URL:-http://103.119.13.48:8080}" # 联调控制面;发版走 env: API_URL=https://api.yanmeiai.com +# 联调控制面。默认指向联调节点;要装生产地址的包用 env 覆盖: +# API_URL=https://api.yanmeiai.com scripts/local_test.sh ipad +API_URL="${API_URL:-http://103.119.13.48:8080}" SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名 SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名 @@ -29,6 +38,12 @@ IP_SVC="https://api.ipify.org" # 返回纯文本公网 IP # 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体); # app/sysext entitlements 与之对齐(见 write_entitlements)。 +# ── iOS/iPad 真机分发(ad-hoc,**公司**证书)──────────────────────────────── +TEAM_ID="BYL4KQHMTN" # Yanmei (beijing) Technology Co., Ltd +IOS_BUNDLE_ID="com.pangolin.pangolinVpn" +# 期望的签名主体。装机前逐字核对,不符即中止 —— 防止悄悄退回个人开发证书。 +IOS_EXPECT_SIGNER="Apple Distribution: Yanmei (beijing) Technology Co., Ltd (${TEAM_ID})" + # ─────────── 路径推导 ─────────── SRC="${BASH_SOURCE[0]}" DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="." @@ -235,6 +250,134 @@ cmd_ks_status(){ 2>/dev/null | tail -20 || echo " (无,确认已连接过一次)" } +# ─────────── iOS / iPad 真机安装 ─────────── +# 与 macOS 流程独立:iOS 用 Network Extension(非 System Extension),不需要 Developer ID +# 重签 / 公证;flutter build ios --release 由 Xcode 工程配置签名(Team=BYL4KQHMTN,自动 +# 签名会把已连接设备注册进描述文件),再 flutter install 走 USB 直接装。 + +# 列出已连接的物理 iOS 设备(输出 "idname" 每行一台;模拟器/无线设备排除)。 +list_ios_devices(){ + flutter devices --machine 2>/dev/null | python3 -c ' +import sys, json +try: + devs = json.load(sys.stdin) +except Exception: + devs = [] +for d in devs: + if str(d.get("targetPlatform", "")).startswith("ios") and not d.get("emulator", False): + print((d.get("id", "") or "") + "\t" + (d.get("name", "") or "")) +' +} + +cmd_ios_devices(){ + log "已连接的 iOS 物理设备" + local list; list="$(list_ios_devices)" + [ -n "$list" ] || die "没检测到 iOS 设备。用数据线连上 iPad、解锁并在弹窗点「信任此电脑」后重试。" + printf '%s\n' "$list" | while IFS=$'\t' read -r id name; do printf ' %-42s %s\n' "$id" "$name"; done +} + +# 解析目标设备到全局 DEVICE_ID:优先用传入的 id/名字子串;否则仅一台 iOS 设备时自动选它, +# 多台(如 iPhone+iPad 同连)则列出并要求显式指定,绝不瞎猜。 +resolve_ios_device(){ + local want="${1:-}" list + list="$(list_ios_devices)" + [ -n "$list" ] || die "没检测到 iOS 设备。用数据线连上 iPad、解锁并在弹窗点「信任此电脑」后重试。" + if [ -n "$want" ]; then + DEVICE_ID="$(printf '%s\n' "$list" | grep -iF "$want" | head -1 | cut -f1)" + [ -n "$DEVICE_ID" ] || die "找不到匹配「$want」的 iOS 设备。先跑 ios-devices 看可用列表。" + return 0 + fi + local n; n="$(printf '%s\n' "$list" | grep -c . || true)" + if [ "$n" = 1 ]; then + DEVICE_ID="$(printf '%s\n' "$list" | head -1 | cut -f1)" + else + printf '%s\n' "$list" | while IFS=$'\t' read -r id name; do printf ' %-42s %s\n' "$id" "$name" >&2; done + die "检测到多台 iOS 设备(如上)。请指定:scripts/local_test.sh ipad <设备id或名字子串>" + fi +} + +# ad-hoc 导出选项。**不要**改回 development/automatic: +# Apple 只把 Development 证书签发给团队里的**个人成员**(没有组织版),走开发签名 +# 装机后 iPad「设置→通用→VPN 与设备管理」里开发者会显示成个人姓名。要显示公司名 +# 必须用分发证书,而能装到自有真机的分发方式就是 ad-hoc(Xcode 15+ 叫 release-testing)。 +write_ios_export_options(){ + cat > "$WORK/ExportOptions-adhoc.plist" < + + + + methodrelease-testing + teamID${TEAM_ID} + signingStyleautomatic + stripSwiftSymbols + uploadSymbols + compileBitcode + + +PLIST +} + +cmd_ipad(){ + command -v flutter >/dev/null || die "找不到 flutter。" + local DEVICE_ID="" + resolve_ios_device "${1:-}" + write_ios_export_options + cd "$CLIENT" + echo "" + echo " 目标设备 : $DEVICE_ID" + echo " 签名主体 : $IOS_EXPECT_SIGNER" + echo " 联调 API : $API_URL" + echo " 测试账号 : $TEST_EMAIL / $TEST_PASSWORD" + echo " ad-hoc 分发签名无需在「VPN 与设备管理」信任;首次启动要联网让 iOS 校验证书。" + echo " VPN 配置在系统弹窗点「允许」。" + echo "" + + log "flutter build ipa --release(ad-hoc / 公司分发证书)" + # 新设备第一次装会因描述文件不含其 UDID 失败(0xe8008012)。自动签名要真正去 + # **注册设备**,光给 -allowProvisioningUpdates 不够,必须同时给 + # -allowProvisioningDeviceRegistration —— 由 Xcode 在 archive 阶段完成。 + flutter build ipa --release \ + --export-options-plist="$WORK/ExportOptions-adhoc.plist" \ + --dart-define="PANGOLIN_API_URL=$API_URL" \ + || die "IPA 构建失败。若报设备未注册,跑一次: + cd $CLIENT/ios && xcodebuild -workspace Runner.xcworkspace -scheme Runner \\ + -configuration Release -destination \"id=$DEVICE_ID\" \\ + -allowProvisioningUpdates -allowProvisioningDeviceRegistration build" + + # 用 glob 取,不要 `ls | head` —— set -o pipefail 下 head 先退出会让 ls 吃 SIGPIPE, + # 管道返回 141,再被 set -e 当失败直接终止脚本(实测踩过)。 + local IPA IPAS=() + IPAS=("$CLIENT"/build/ios/ipa/*.ipa) + IPA="${IPAS[0]}" + [ -f "$IPA" ] || die "没找到 IPA(build/ios/ipa/*.ipa)。" + + # ── 装机前核验签名主体,防止无声退回个人开发证书 ── + local VDIR="$WORK/ipa_verify"; rm -rf "$VDIR"; mkdir -p "$VDIR" + ( cd "$VDIR" && unzip -q "$IPA" ) || die "IPA 解包失败。" + # 同理避开提前退出的管道消费者(grep -m1 / head):先整段捕获,再在内存里取第一条 + # Authority(叶子证书)。awk 读完全部输入,不会给上游制造 SIGPIPE。 + local AUTH CS_OUT + CS_OUT="$(codesign -dv --verbose=4 "$VDIR/Payload/Runner.app" 2>&1 || true)" + AUTH="$(printf '%s\n' "$CS_OUT" | awk -F'Authority=' '/^Authority=/ && !seen {print $2; seen=1}')" + [ "$AUTH" = "$IOS_EXPECT_SIGNER" ] \ + || die "签名主体不符,拒绝安装。 + 期望: $IOS_EXPECT_SIGNER + 实际: ${AUTH:-<空>}" + log "签名核验通过:$AUTH" + + log "装到设备 $DEVICE_ID" + # 同 bundle id 的旧包若是**别的签名主体**(如早先的 Apple Development 个人证书), + # iOS 拒绝覆盖安装。仅在这种情况下才卸载重装 —— 卸载会清空 app 数据(需重新登录), + # 所以不无条件先卸。 + if ! xcrun devicectl device install app --device "$DEVICE_ID" "$IPA"; then + log "覆盖安装失败(多半是旧包签名主体不同),卸载旧包后重装 —— app 数据会清空,需重新登录" + xcrun devicectl device uninstall app --device "$DEVICE_ID" "$IOS_BUNDLE_ID" || true + xcrun devicectl device install app --device "$DEVICE_ID" "$IPA" \ + || die "安装失败。" + fi + log "已安装 $IOS_BUNDLE_ID → $DEVICE_ID;在设备上点开「穿山甲」即可(首次需联网校验证书)" +} + case "${1:-}" in build) cmd_build ;; sign) cmd_sign ;; # 旧:手动 Developer ID 重签(现已由 Xcode 工程配置直接签,通常不需要) @@ -246,5 +389,7 @@ case "${1:-}" in ks-baseline) cmd_ks_baseline ;; # ① VPN 断开下记录真实公网 IP ks-test) cmd_ks_test ;; # ② VPN 连接(killswitch 开)下杀扩展做漏测 ks-status) cmd_ks_status ;; # 看 VPN 状态 + killswitch 配置打点日志 - *) echo "用法: $0 {all|build|sign|notarize|copy|run|ks-baseline|ks-test|ks-status}"; exit 2 ;; + ios-devices) cmd_ios_devices ;; # 列出已连接的 iOS 物理设备(拿 device id) + ipad|ios) cmd_ipad "${2:-}" ;; # 构建 iOS release 并装到 iPad(可传设备 id/名字子串) + *) echo "用法: $0 {all|build|sign|notarize|copy|run|ks-baseline|ks-test|ks-status|ios-devices|ipad [id]}"; exit 2 ;; esac diff --git a/server/cmd/agent/main.go b/server/cmd/agent/main.go index 8b24693..db358d7 100644 --- a/server/cmd/agent/main.go +++ b/server/cmd/agent/main.go @@ -44,6 +44,23 @@ func main() { 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() + } + } + }() + log.Printf("[pangolin-agent] starting (control-plane=%s, version=%s)", cfg.ControlPlaneAddr, cfg.AgentVersion) start := time.Now() if err := agent.Run(ctx); err != nil { diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index b7dccde..1415b63 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -419,7 +419,15 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv slog.Warn("PANGOLIN_PUBLIC_URL 未设置:国内分流(split_cn)将被静默跳过,客户端全量走隧道。" + "如需国内直连,设为控制面对外公网基址(如 http://<公网IP>:8080)") } - nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), nodeSvc.Load(), os.Getenv("NODE_DERIVE_KEY"), publicURL) + // 私有服务域名分流(家庭内网穿透,如 nas/git/win.yanmeiai.com): + // 逗号分隔;这些域名用系统 DNS 解析、在外强制走隧道(排在国内分流前)。 + var privateSplitDomains []string + for _, d := range strings.Split(os.Getenv("PANGOLIN_PRIVATE_SPLIT_DOMAINS"), ",") { + if d = strings.TrimSpace(d); d != "" { + privateSplitDomains = append(privateSplitDomains, d) + } + } + nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), nodeSvc.Load(), os.Getenv("NODE_DERIVE_KEY"), publicURL, privateSplitDomains) } // 国内分流(#5)的 rule-set 静态服务:GET /v1/rules/{name}.srs(自托管, diff --git a/server/internal/admin/auth.go b/server/internal/admin/auth.go index b120342..6175813 100644 --- a/server/internal/admin/auth.go +++ b/server/internal/admin/auth.go @@ -31,11 +31,14 @@ type Authenticator struct { failMax int lockDur time.Duration sec *SecurityLog + trusted *TrustedStore + trustTTL time.Duration // now is overridable in tests. now func() time.Time } -// NewAuthenticator wires an Authenticator. +// NewAuthenticator wires an Authenticator. Device-trust ("记住此设备") is +// enabled when cfg.TrustedDeviceTTL > 0. func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cfg *Config, sec *SecurityLog) *Authenticator { return &Authenticator{ store: store, @@ -45,58 +48,105 @@ func NewAuthenticator(store Store, sessions *SessionStore, rdb *redis.Client, cf failMax: cfg.LoginFailMax, lockDur: cfg.LoginLockDuration, sec: sec, + trusted: NewTrustedStore(rdb, cfg.TrustedDeviceTTL), + trustTTL: cfg.TrustedDeviceTTL, now: func() time.Time { return time.Now().UTC() }, } } +// LoginResult is the outcome of a device-aware login. +type LoginResult struct { + SID string + Session *Session + // NewTrustToken is non-empty when the caller ticked "记住此设备" and a fresh + // device-trust token was issued — the handler sets it as the trust cookie. + NewTrustToken string + // Persistent is true when this login should use a long-lived (trust-TTL) + // session + cookie instead of the short idle default. + Persistent bool +} + // Login validates username + password + TOTP and, on success, creates a // session and returns its id. Every failure is rate-limited and recorded as a // security event in the audit log (red line: admin records only security // events, never routine access). func (a *Authenticator) Login(ctx context.Context, username, password, code, remoteIP string) (sid string, sess *Session, err error) { + res, lerr := a.LoginDevice(ctx, username, password, code, remoteIP, "", false) + return res.SID, res.Session, lerr +} + +// LoginDevice is the device-aware login: username + password are ALWAYS +// required; the TOTP second factor is skipped only when trustToken is a live +// trust token bound to this admin ("记住此设备"). When remember is set, a fresh +// trust token is issued and the session is made persistent (trust-TTL long). +// +// Security invariant: a trust token never substitutes for the password — a +// wrong password fails regardless of trust. +func (a *Authenticator) LoginDevice(ctx context.Context, username, password, code, remoteIP, trustToken string, remember bool) (LoginResult, error) { locked, lerr := a.isLocked(ctx, username) if lerr != nil { - return "", nil, fmt.Errorf("admin.Login lock check: %w", lerr) + return LoginResult{}, fmt.Errorf("admin.Login lock check: %w", lerr) } if locked { a.sec.LoginLocked(ctx, username, remoteIP) - return "", nil, ErrLockedOut + return LoginResult{}, ErrLockedOut } admin, gerr := a.store.GetAdminByUsername(ctx, username) if gerr != nil && !errors.Is(gerr, ErrAdminNotFound) { - return "", nil, fmt.Errorf("admin.Login lookup: %w", gerr) + return LoginResult{}, fmt.Errorf("admin.Login lookup: %w", gerr) } if admin == nil || admin.Status != "active" || !VerifyPassword(admin.PwHash, password) { a.recordFail(ctx, username) a.sec.LoginFail(ctx, username, remoteIP, "bad_password") - return "", nil, ErrInvalidCredentials + return LoginResult{}, ErrInvalidCredentials } - secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc) - if derr != nil { - a.recordFail(ctx, username) - a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt") - return "", nil, ErrInvalidCredentials - } - if !totp.Validate(secret, code, a.now(), 1) { - a.recordFail(ctx, username) - a.sec.LoginFail(ctx, username, remoteIP, "bad_totp") - return "", nil, ErrInvalidCredentials + // Second factor: skip only for a device already trusted by THIS admin. + if !a.trusted.Check(ctx, trustToken, admin.ID) { + secret, derr := DecryptSecret(a.secret, admin.TOTPSecretEnc) + if derr != nil { + a.recordFail(ctx, username) + a.sec.LoginFail(ctx, username, remoteIP, "totp_decrypt") + return LoginResult{}, ErrInvalidCredentials + } + if !totp.Validate(secret, code, a.now(), 1) { + a.recordFail(ctx, username) + a.sec.LoginFail(ctx, username, remoteIP, "bad_totp") + return LoginResult{}, ErrInvalidCredentials + } } // Success: clear counter, stamp login, create session. a.clearFail(ctx, username) if uerr := a.store.UpdateLastLogin(ctx, admin.ID, a.now()); uerr != nil { - return "", nil, fmt.Errorf("admin.Login update: %w", uerr) + return LoginResult{}, fmt.Errorf("admin.Login update: %w", uerr) + } + + persistent := remember && a.trusted.Enabled() + var ( + sid string + sess *Session + serr error + ) + if persistent { + sid, sess, serr = a.sessions.CreateWithTTL(ctx, admin.ID, admin.Username, a.trustTTL) + } else { + sid, sess, serr = a.sessions.Create(ctx, admin.ID, admin.Username) } - sid, sess, serr := a.sessions.Create(ctx, admin.ID, admin.Username) if serr != nil { - return "", nil, serr + return LoginResult{}, serr + } + + res := LoginResult{SID: sid, Session: sess, Persistent: persistent} + if remember { + if tok, terr := a.trusted.Issue(ctx, admin.ID); terr == nil { + res.NewTrustToken = tok + } } a.sec.LoginOK(ctx, username, remoteIP) - return sid, sess, nil + return res, nil } func (a *Authenticator) isLocked(ctx context.Context, username string) (bool, error) { diff --git a/server/internal/admin/config.go b/server/internal/admin/config.go index 1c77bea..6c365b6 100644 --- a/server/internal/admin/config.go +++ b/server/internal/admin/config.go @@ -44,6 +44,12 @@ type Config struct { // CookieSecure controls the Secure attribute on the session cookie. // Defaults to true; only disabled explicitly for local/dev over plain HTTP. CookieSecure bool + + // TrustedDeviceTTL is how long a "记住此设备" trust lasts. Within this window + // the device (a) skips the TOTP second factor on re-login and (b) keeps a + // persistent session of the same length. Password is ALWAYS still required. + // Zero disables the feature (checkbox has no effect). + TrustedDeviceTTL time.Duration } // defaultInternalCIDRs are the loopback and private/ULA ranges allowed by @@ -66,6 +72,7 @@ var defaultInternalCIDRs = []string{ // ADMIN_LOGIN_FAIL_MAX int (default 5) // ADMIN_LOGIN_LOCK Go duration (default 15m) // ADMIN_COOKIE_INSECURE "1" disables Secure flag (dev only) +// ADMIN_TRUSTED_DEVICE_TTL Go duration (default 720h = 30d; 0 disables) func FromEnv() (*Config, error) { c := &Config{ Listen: getEnvDefault("ADMIN_LISTEN", "127.0.0.1:9443"), @@ -73,6 +80,14 @@ func FromEnv() (*Config, error) { LoginFailMax: 5, LoginLockDuration: 15 * time.Minute, CookieSecure: os.Getenv("ADMIN_COOKIE_INSECURE") != "1", + TrustedDeviceTTL: 30 * 24 * time.Hour, + } + if v := os.Getenv("ADMIN_TRUSTED_DEVICE_TTL"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + return nil, fmt.Errorf("config: ADMIN_TRUSTED_DEVICE_TTL %q invalid: %w", v, err) + } + c.TrustedDeviceTTL = d } if err := validateListen(c.Listen); err != nil { diff --git a/server/internal/admin/handlers.go b/server/internal/admin/handlers.go index 278c783..f19722a 100644 --- a/server/internal/admin/handlers.go +++ b/server/internal/admin/handlers.go @@ -61,9 +61,15 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PostFormValue("username")) password := r.PostFormValue("password") code := strings.TrimSpace(r.PostFormValue("totp")) - ip := hostOnly(r.RemoteAddr) + remember := r.PostFormValue("remember") != "" + ip := realIP(r) // 经本机 caddy 反代时取 XFF 末跳,否则 TCP 对端(见 mw_ipallow.go) - sid, _, err := h.auth.Login(r.Context(), username, password, code, ip) + var trustToken string + if c, cerr := r.Cookie(TrustedDeviceCookieName); cerr == nil { + trustToken = c.Value + } + + res, err := h.auth.LoginDevice(r.Context(), username, password, code, ip, trustToken, remember) if err != nil { flash := "用户名、密码或动态验证码有误" if err == ErrLockedOut { @@ -73,7 +79,14 @@ func (h *Handlers) LoginSubmit(w http.ResponseWriter, r *http.Request) { h.render.render(w, "login", pageData{Flash: flash}) return } - h.setSessionCookie(w, sid) + if res.Persistent { + h.setSessionCookieTTL(w, res.SID, h.cfg.TrustedDeviceTTL) + } else { + h.setSessionCookie(w, res.SID) + } + if res.NewTrustToken != "" { + h.setTrustedCookie(w, res.NewTrustToken) + } http.Redirect(w, r, "/", http.StatusFound) } @@ -394,6 +407,14 @@ func (h *Handlers) writeAudit(ctx context.Context, actor, action, target, metaJS } func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) { + h.setSessionCookieTTL(w, sid, h.cfg.SessionTTL) +} + +// setSessionCookieTTL sets the session cookie with an explicit Max-Age. For a +// persistent ("记住此设备") session ttl is the long trust-TTL; otherwise the +// short idle default. Max-Age <= 0 would make it a session cookie, so ttl must +// be positive here. +func (h *Handlers) setSessionCookieTTL(w http.ResponseWriter, sid string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: sid, @@ -401,7 +422,21 @@ func (h *Handlers) setSessionCookie(w http.ResponseWriter, sid string) { HttpOnly: true, Secure: h.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, - MaxAge: int(h.cfg.SessionTTL.Seconds()), + MaxAge: int(ttl.Seconds()), + }) +} + +// setTrustedCookie stores the device-trust token (HttpOnly, Secure, Strict) so +// this device can skip TOTP on future logins for the trust TTL. +func (h *Handlers) setTrustedCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: TrustedDeviceCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: h.cfg.CookieSecure, + SameSite: http.SameSiteStrictMode, + MaxAge: int(h.cfg.TrustedDeviceTTL.Seconds()), }) } diff --git a/server/internal/admin/login_device_handler_test.go b/server/internal/admin/login_device_handler_test.go new file mode 100644 index 0000000..4671426 --- /dev/null +++ b/server/internal/admin/login_device_handler_test.go @@ -0,0 +1,114 @@ +package admin + +import ( + "crypto/rand" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/wangjia/pangolin/server/internal/totp" +) + +// newTrustEnv wires a full admin router with device-trust ENABLED and seeds +// one admin, returning the router and that admin's TOTP secret. +func newTrustEnv(t *testing.T) (http.Handler, string) { + t.Helper() + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { rdb.Close() }) + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + allow, _ := ParseCIDRs([]string{"127.0.0.0/8"}) + cfg := &Config{ + Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key, + SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute, + CookieSecure: false, TrustedDeviceTTL: 30 * 24 * time.Hour, + } + store := newFakeStore() + secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") + + sessions := NewSessionStore(rdb, cfg.SessionTTL) + sec := NewSecurityLog(store, nil) + auth := NewAuthenticator(store, sessions, rdb, cfg, sec) + svc := Services{Codes: &fakeCodes{}, Lifecycle: &recordingLifecycle{ready: true}, Provision: &recordingProvision{ready: true}} + h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil) + if err != nil { + t.Fatal(err) + } + return NewRouter(h, sessions, cfg, sec), secret +} + +func cookieByName(resp *http.Response, name string) *http.Cookie { + for _, c := range resp.Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +func postLogin(t *testing.T, router http.Handler, form url.Values, cookies ...*http.Cookie) *http.Response { + t.Helper() + req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "127.0.0.1:5000" + for _, c := range cookies { + req.AddCookie(c) + } + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr.Result() +} + +// 端到端:勾选记住 → 登录成功 → 同时下发会话 cookie 与信任 cookie; +// 随后仅凭信任 cookie + 空 TOTP 再次登录成功(免二次验证)。 +func TestLoginHandler_RememberThenSkipTOTP(t *testing.T) { + router, secret := newTrustEnv(t) + code, _ := totp.Code(secret, time.Now().UTC()) + + resp := postLogin(t, router, url.Values{ + "username": {"alice"}, "password": {"s3cret-pass"}, + "totp": {code}, "remember": {"1"}, + }) + if resp.StatusCode != http.StatusFound { + t.Fatalf("remember login: status = %d, want 302", resp.StatusCode) + } + trust := cookieByName(resp, TrustedDeviceCookieName) + if trust == nil || trust.Value == "" { + t.Fatal("remember login should set a non-empty trusted cookie") + } + if trust.MaxAge <= 0 { + t.Errorf("trusted cookie MaxAge = %d, want > 0 (persistent)", trust.MaxAge) + } + sessCookie := cookieByName(resp, SessionCookieName) + if sessCookie == nil || sessCookie.MaxAge <= int((30 * time.Minute).Seconds()) { + t.Error("remember login should set a long-lived (persistent) session cookie") + } + + // 第二次:只带信任 cookie,TOTP 留空 → 成功 + resp2 := postLogin(t, router, url.Values{ + "username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""}, + }, &http.Cookie{Name: TrustedDeviceCookieName, Value: trust.Value}) + if resp2.StatusCode != http.StatusFound { + t.Fatalf("trusted re-login: status = %d, want 302 (TOTP skipped)", resp2.StatusCode) + } +} + +// 无信任 cookie + 空 TOTP → 401(二次验证仍强制)。 +func TestLoginHandler_NoTrustEmptyTOTPRejected(t *testing.T) { + router, _ := newTrustEnv(t) + resp := postLogin(t, router, url.Values{ + "username": {"alice"}, "password": {"s3cret-pass"}, "totp": {""}, + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("empty TOTP without trust: status = %d, want 401", resp.StatusCode) + } +} diff --git a/server/internal/admin/login_device_test.go b/server/internal/admin/login_device_test.go new file mode 100644 index 0000000..242eca1 --- /dev/null +++ b/server/internal/admin/login_device_test.go @@ -0,0 +1,114 @@ +package admin + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/totp" +) + +// newTrustAuth builds an Authenticator with device-trust ENABLED (30d). +func newTrustAuth(t *testing.T) (*Authenticator, *fakeStore, []byte) { + t.Helper() + rdb, _ := newTestRedis(t) + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + store := newFakeStore() + cfg := &Config{ + SecretKey: key, LoginFailMax: 3, LoginLockDuration: time.Minute, + SessionTTL: 30 * time.Minute, TrustedDeviceTTL: 30 * 24 * time.Hour, + } + sessions := NewSessionStore(rdb, cfg.SessionTTL) + sec := NewSecurityLog(store, nil) + return NewAuthenticator(store, sessions, rdb, cfg, sec), store, key +} + +// 勾选「记住此设备」成功登录 → 返回可用于下次跳过 TOTP 的信任令牌。 +func TestLoginDevice_RememberIssuesTrust(t *testing.T) { + auth, store, key := newTrustAuth(t) + secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") + code, _ := totp.Code(secret, time.Now().UTC()) + ctx := context.Background() + + res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true) + if err != nil { + t.Fatalf("login: %v", err) + } + if res.NewTrustToken == "" { + t.Fatal("remember=true should issue a trust token") + } + if !res.Persistent { + t.Error("remember=true should mark session persistent") + } + // 下次:带该令牌 + 空 TOTP 也能登录(跳过二次验证) + res2, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", res.NewTrustToken, false) + if err != nil { + t.Fatalf("trusted re-login should skip TOTP: %v", err) + } + if res2.SID == "" { + t.Fatal("no session on trusted re-login") + } +} + +// 不勾选:不签发令牌,且 TOTP 仍必填。 +func TestLoginDevice_NoRememberRequiresTOTP(t *testing.T) { + auth, store, key := newTrustAuth(t) + secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") + code, _ := totp.Code(secret, time.Now().UTC()) + ctx := context.Background() + + res, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", false) + if err != nil { + t.Fatalf("login: %v", err) + } + if res.NewTrustToken != "" { + t.Error("remember=false must not issue a trust token") + } + // 无令牌 + 空 TOTP → 拒 + if _, err := auth.LoginDevice(ctx, "alice", "s3cret-pass", "", "127.0.0.1", "", false); err != ErrInvalidCredentials { + t.Errorf("untrusted device with empty TOTP should fail, got %v", err) + } +} + +// 铁律:即便持有效信任令牌,密码错误一律拒(只跳过 TOTP,不跳过密码)。 +func TestLoginDevice_TrustNeverSkipsPassword(t *testing.T) { + auth, store, key := newTrustAuth(t) + secret := newTestAdmin(t, store, key, "alice", "s3cret-pass") + code, _ := totp.Code(secret, time.Now().UTC()) + ctx := context.Background() + + res, _ := auth.LoginDevice(ctx, "alice", "s3cret-pass", code, "127.0.0.1", "", true) + if res.NewTrustToken == "" { + t.Fatal("precondition: expected trust token") + } + if _, err := auth.LoginDevice(ctx, "alice", "WRONG", "", "127.0.0.1", res.NewTrustToken, false); err != ErrInvalidCredentials { + t.Errorf("trusted device must still require correct password, got %v", err) + } +} + +// 信任令牌绑定 admin:换个用户名不认(令牌属 alice,拿去登 bob 无效)。 +func TestLoginDevice_TrustBoundToAdmin(t *testing.T) { + auth, store, key := newTrustAuth(t) + sa := newTestAdmin(t, store, key, "alice", "alice-pass") + _ = sa + // bob:另一个 admin,不同 ID + hash, _ := HashPassword("bob-pass") + bsecret, _ := totp.GenerateSecret() + benc, _ := EncryptSecret(key, bsecret) + store.admins["bob"] = &Admin{ID: 2, Username: "bob", PwHash: hash, TOTPSecretEnc: benc, Status: "active"} + ctx := context.Background() + + acode, _ := totp.Code(sa, time.Now().UTC()) + ares, _ := auth.LoginDevice(ctx, "alice", "alice-pass", acode, "127.0.0.1", "", true) + if ares.NewTrustToken == "" { + t.Fatal("precondition: alice trust token") + } + // 用 alice 的令牌 + 空 TOTP 登 bob → 应要求 TOTP(令牌对 bob 无效) + if _, err := auth.LoginDevice(ctx, "bob", "bob-pass", "", "127.0.0.1", ares.NewTrustToken, false); err != ErrInvalidCredentials { + t.Errorf("alice's trust token must not skip TOTP for bob, got %v", err) + } +} diff --git a/server/internal/admin/mw_ipallow.go b/server/internal/admin/mw_ipallow.go index be974d6..8a0c920 100644 --- a/server/internal/admin/mw_ipallow.go +++ b/server/internal/admin/mw_ipallow.go @@ -3,6 +3,7 @@ package admin import ( "net" "net/http" + "strings" ) // IPAllow is middleware that rejects any request whose source IP is not within @@ -58,3 +59,28 @@ func hostOnly(remoteAddr string) string { } return host } + +// realIP returns the client IP for **audit logging** (admin_login_ok 等)。 +// +// 与上面 IPAllow 的取址原则刻意不同:白名单闸继续只看 TCP 对端(不可伪造); +// 而审计日志要的是"人从哪来"——经本机反代(caddy mTLS 网关在同机回环上反代 +// 127.0.0.1:9444)时 RemoteAddr 恒为 loopback,没有溯源价值。仅当 TCP 对端是 +// loopback(即请求确实来自本机可信反代)才信 X-Forwarded-For,且取**最后一跳** +// (Caddy 把真实 TCP 对端追加在末位;更早的段可被客户端伪造预置,不可信)。 +func realIP(r *http.Request) string { + peer := hostOnly(r.RemoteAddr) + ip := net.ParseIP(peer) + if ip == nil || !ip.IsLoopback() { + return peer + } + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return peer + } + parts := strings.Split(xff, ",") + last := strings.TrimSpace(parts[len(parts)-1]) + if net.ParseIP(last) == nil { + return peer + } + return last +} diff --git a/server/internal/admin/realip_test.go b/server/internal/admin/realip_test.go new file mode 100644 index 0000000..dc6a51e --- /dev/null +++ b/server/internal/admin/realip_test.go @@ -0,0 +1,37 @@ +package admin + +import ( + "net/http/httptest" + "testing" +) + +// realIP:仅当 TCP 对端为 loopback(本机可信反代,如 caddy mTLS 网关)时才信 +// X-Forwarded-For 的最后一跳;其余情况一律用 TCP 对端,防止伪造头污染审计。 +func TestRealIP(t *testing.T) { + cases := []struct { + name string + remoteAddr string + xff string + want string + }{ + {"直连无代理", "203.0.113.7:52011", "", "203.0.113.7"}, + {"直连时伪造 XFF 不可信", "203.0.113.7:52011", "1.2.3.4", "203.0.113.7"}, + {"经本机反代取 XFF 最后一跳", "127.0.0.1:38200", "198.51.100.23", "198.51.100.23"}, + {"多跳取最后一跳(前段可伪造)", "127.0.0.1:38200", "6.6.6.6, 198.51.100.23", "198.51.100.23"}, + {"本机反代但无 XFF 回退 loopback", "127.0.0.1:38200", "", "127.0.0.1"}, + {"XFF 非法值回退", "127.0.0.1:38200", "not-an-ip", "127.0.0.1"}, + {"IPv6 loopback 反代", "[::1]:38200", "198.51.100.23", "198.51.100.23"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/login", nil) + r.RemoteAddr = c.remoteAddr + if c.xff != "" { + r.Header.Set("X-Forwarded-For", c.xff) + } + if got := realIP(r); got != c.want { + t.Errorf("realIP(remote=%s, xff=%q) = %q, want %q", c.remoteAddr, c.xff, got, c.want) + } + }) + } +} diff --git a/server/internal/admin/session.go b/server/internal/admin/session.go index f75e502..13899dc 100644 --- a/server/internal/admin/session.go +++ b/server/internal/admin/session.go @@ -25,6 +25,9 @@ type Session struct { Username string `json:"username"` CSRFToken string `json:"csrf"` CreatedAt time.Time `json:"created_at"` + // TTLSeconds, when > 0, overrides the store's default sliding idle TTL for + // this session (used by "记住此设备" persistent sessions). 0 = use default. + TTLSeconds int64 `json:"ttl_seconds,omitempty"` } // SessionStore persists admin sessions in Redis with a sliding idle TTL. @@ -41,9 +44,15 @@ func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore { return &SessionStore{rdb: rdb, ttl: ttl} } -// Create starts a new session for the given admin and returns the opaque -// session id (to be set as the cookie value). +// Create starts a new session with the store's default sliding idle TTL. func (s *SessionStore) Create(ctx context.Context, adminID int64, username string) (string, *Session, error) { + return s.CreateWithTTL(ctx, adminID, username, 0) +} + +// CreateWithTTL starts a new session. When ttl > 0 the session uses that TTL +// (both the initial expiry and the per-request slide) instead of the store +// default — this is how "记住此设备" keeps a device logged in for e.g. 30 days. +func (s *SessionStore) CreateWithTTL(ctx context.Context, adminID int64, username string, ttl time.Duration) (string, *Session, error) { sid, err := randToken(32) if err != nil { return "", nil, err @@ -58,12 +67,24 @@ func (s *SessionStore) Create(ctx context.Context, adminID int64, username strin CSRFToken: csrf, CreatedAt: time.Now().UTC(), } + if ttl > 0 { + sess.TTLSeconds = int64(ttl / time.Second) + } if err := s.save(ctx, sid, sess); err != nil { return "", nil, err } return sid, sess, nil } +// slideTTL is the TTL to (re)apply to a session: its own override if set, +// otherwise the store default. +func (s *SessionStore) slideTTL(sess *Session) time.Duration { + if sess != nil && sess.TTLSeconds > 0 { + return time.Duration(sess.TTLSeconds) * time.Second + } + return s.ttl +} + // Get loads a session and slides its TTL forward. Returns (nil, nil) when the // session is absent or expired. func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) { @@ -81,8 +102,8 @@ func (s *SessionStore) Get(ctx context.Context, sid string) (*Session, error) { if err := json.Unmarshal(val, &sess); err != nil { return nil, fmt.Errorf("admin.SessionStore.Get unmarshal: %w", err) } - // Slide the idle timeout. - if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.ttl).Err(); err != nil { + // Slide the idle timeout (persistent sessions slide by their own TTL). + if err := s.rdb.Expire(ctx, sessionKeyPrefix+sid, s.slideTTL(&sess)).Err(); err != nil { return nil, fmt.Errorf("admin.SessionStore.Get expire: %w", err) } return &sess, nil @@ -101,7 +122,7 @@ func (s *SessionStore) save(ctx context.Context, sid string, sess *Session) erro if err != nil { return fmt.Errorf("admin.SessionStore.save: %w", err) } - if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.ttl).Err(); err != nil { + if err := s.rdb.Set(ctx, sessionKeyPrefix+sid, b, s.slideTTL(sess)).Err(); err != nil { return fmt.Errorf("admin.SessionStore.save set: %w", err) } return nil diff --git a/server/internal/admin/templates/login.html b/server/internal/admin/templates/login.html index e167976..fb4a72d 100644 --- a/server/internal/admin/templates/login.html +++ b/server/internal/admin/templates/login.html @@ -12,9 +12,10 @@ {{if .Flash}}

{{.Flash}}
{{end}} - + + -

仅限内网 / SSH 隧道访问。

+

仅限白名单设备(客户端证书)访问。已记住的设备可留空动态码。

{{end}} diff --git a/server/internal/admin/trusted.go b/server/internal/admin/trusted.go new file mode 100644 index 0000000..be09e9b --- /dev/null +++ b/server/internal/admin/trusted.go @@ -0,0 +1,74 @@ +package admin + +import ( + "context" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +// TrustedDeviceCookieName is the cookie holding a device-trust token. +const TrustedDeviceCookieName = "admin_trusted" + +// trustedKeyPrefix namespaces device-trust tokens in Redis. +const trustedKeyPrefix = "admin:trusted:" + +// TrustedStore records "记住此设备" device-trust tokens in Redis. +// +// A trusted token lets a device SKIP THE TOTP SECOND FACTOR on re-login +// (password is still always required). Each token is opaque (32 bytes of +// entropy), bound to one admin id, and expires after ttl — so a flushed +// Redis, an expired token, or a mismatched admin all fail closed to +// "TOTP required". +type TrustedStore struct { + rdb *redis.Client + ttl time.Duration +} + +// NewTrustedStore wires a TrustedStore. A ttl <= 0 disables the feature: +// Issue returns an empty token and Check always returns false. +func NewTrustedStore(rdb *redis.Client, ttl time.Duration) *TrustedStore { + return &TrustedStore{rdb: rdb, ttl: ttl} +} + +// Enabled reports whether device-trust is active. +func (s *TrustedStore) Enabled() bool { return s != nil && s.rdb != nil && s.ttl > 0 } + +// Issue mints a new trust token bound to adminID and stores it with the TTL. +// Returns "" (no error) when the feature is disabled. +func (s *TrustedStore) Issue(ctx context.Context, adminID int64) (string, error) { + if !s.Enabled() { + return "", nil + } + tok, err := randToken(32) + if err != nil { + return "", err + } + if err := s.rdb.Set(ctx, trustedKeyPrefix+tok, strconv.FormatInt(adminID, 10), s.ttl).Err(); err != nil { + return "", err + } + return tok, nil +} + +// Check reports whether tok is a live trust token bound to adminID. +// Any miss (disabled, empty, unknown, expired, wrong admin) returns false. +func (s *TrustedStore) Check(ctx context.Context, tok string, adminID int64) bool { + if !s.Enabled() || tok == "" { + return false + } + v, err := s.rdb.Get(ctx, trustedKeyPrefix+tok).Result() + if err != nil { + return false + } + return v == strconv.FormatInt(adminID, 10) +} + +// Revoke deletes a trust token (e.g. on explicit logout-all). A no-op when +// disabled or empty. +func (s *TrustedStore) Revoke(ctx context.Context, tok string) error { + if !s.Enabled() || tok == "" { + return nil + } + return s.rdb.Del(ctx, trustedKeyPrefix+tok).Err() +} diff --git a/server/internal/admin/trusted_test.go b/server/internal/admin/trusted_test.go new file mode 100644 index 0000000..19ec974 --- /dev/null +++ b/server/internal/admin/trusted_test.go @@ -0,0 +1,108 @@ +package admin + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func newTestTrustedStore(t *testing.T) (*TrustedStore, *miniredis.Miniredis) { + t.Helper() + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + t.Cleanup(mr.Close) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + return NewTrustedStore(rdb, 30*24*time.Hour), mr +} + +func TestTrustedStore_IssueThenCheck(t *testing.T) { + ts, _ := newTestTrustedStore(t) + ctx := context.Background() + + tok, err := ts.Issue(ctx, 7) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if tok == "" { + t.Fatal("Issue returned empty token") + } + // 正确 admin 命中 + if !ts.Check(ctx, tok, 7) { + t.Error("Check should accept the token for the issuing admin") + } + // 令牌绑定 admin:换个 admin id 不认 + if ts.Check(ctx, tok, 8) { + t.Error("Check must reject a token bound to a different admin") + } + // 未知令牌不认 + if ts.Check(ctx, "bogus-token", 7) { + t.Error("Check must reject an unknown token") + } + // 空令牌不认 + if ts.Check(ctx, "", 7) { + t.Error("Check must reject an empty token") + } +} + +func TestTrustedStore_Revoke(t *testing.T) { + ts, _ := newTestTrustedStore(t) + ctx := context.Background() + tok, _ := ts.Issue(ctx, 7) + if !ts.Check(ctx, tok, 7) { + t.Fatal("precondition: token should be valid") + } + if err := ts.Revoke(ctx, tok); err != nil { + t.Fatalf("Revoke: %v", err) + } + if ts.Check(ctx, tok, 7) { + t.Error("Check must reject a revoked token") + } +} + +func TestTrustedStore_Expires(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + ts := NewTrustedStore(rdb, time.Hour) + ctx := context.Background() + + tok, _ := ts.Issue(ctx, 7) + if !ts.Check(ctx, tok, 7) { + t.Fatal("precondition: token valid before expiry") + } + mr.FastForward(2 * time.Hour) // 超过 TTL + if ts.Check(ctx, tok, 7) { + t.Error("Check must reject an expired token (fail-closed)") + } +} + +// TTL<=0 视为功能关闭:Issue 返回空、Check 恒 false。 +func TestTrustedStore_Disabled(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis: %v", err) + } + defer mr.Close() + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + ts := NewTrustedStore(rdb, 0) + ctx := context.Background() + + tok, err := ts.Issue(ctx, 7) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if tok != "" { + t.Error("disabled store should issue empty token") + } + if ts.Check(ctx, "anything", 7) { + t.Error("disabled store should never trust") + } +} diff --git a/server/internal/agentd/acl.go b/server/internal/agentd/acl.go new file mode 100644 index 0000000..76a174d --- /dev/null +++ b/server/internal/agentd/acl.go @@ -0,0 +1,222 @@ +package agentd + +import ( + "encoding/json" + "fmt" + "net/netip" + "os" + "strings" +) + +// ACLTarget 描述一组「私有目的地」的匹配条件。字段名与取值直接对应 sing-box +// route rule 的同名字段:同一项内多字段是 AND,字段内多值是 OR。刻意不做自研 DSL +// —— 形状即 sing-box 语义,少一层翻译就少一类 bug。 +// +// 典型两类: +// - 与公开站共用 443 的私有 vhost(brain/git) → 用 domain,依赖 sniff 取 SNI +// - 独占端口的服务(DSM 5001 / RDP 3389 / SSH 10022-10023) → 用 ip_cidr + port +type ACLTarget struct { + Domain []string `json:"domain,omitempty"` + DomainSuffix []string `json:"domain_suffix,omitempty"` + IPCIDR []string `json:"ip_cidr,omitempty"` + Port []int `json:"port,omitempty"` +} + +// ACLConfig 是节点本地的私有目的地访问控制表(默认 /acl.json)。 +// 只有 AllowDpUUIDs 里的凭证能访问 Targets 描述的目的地,其余一律 reject。 +// +// 与 WarpConfig 的关键区别是失效方向:WARP 读不出来就不分流(fail-open)是安全的, +// ACL 读不出来就不拦截等于把私有服务对全体用户敞开。故本类型的 active() 语义为 +// fail-closed —— 空白名单意味着「没有人」,不是「所有人」。 +type ACLConfig struct { + Enabled bool `json:"enabled"` + AllowDpUUIDs []string `json:"allow_dp_uuids"` + Targets []ACLTarget `json:"targets"` +} + +// LoadACLConfig 读取并解析 acl.json。文件不存在 → (nil, nil)(未配置该功能, +// 不是错误)。解析失败返回 error,由调用方决定回退到 last-good 还是告警。 +func LoadACLConfig(path string) (*ACLConfig, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("agentd: read acl config %q: %w", path, err) + } + var ac ACLConfig + if err := json.Unmarshal(data, &ac); err != nil { + return nil, fmt.Errorf("agentd: parse acl config %q: %w", path, err) + } + if err := ac.validate(); err != nil { + return nil, fmt.Errorf("agentd: acl config %q: %w", path, err) + } + return &ac, nil +} + +// validate 校验每个 target 的 ip_cidr/port 取值本身是否合法(与 cleanTargets 只做 +// trim/lower、完全不校验取值的定位不同)。一个语法正确但语义非法的值(如 +// port:70000、ip_cidr:"not-a-cidr")会被 encoding/json 顺利接受,一路渲染进 +// sing-box 配置——sing-box 自己在 reload/restart 时会拒绝它并保留旧实例,但那时 +// agent 早已把这份坏配置当"渲染成功"写盘,运维靠 journalctl/看 config.json 内容 +// 完全看不出线上 gate 其实没生效。故必须在加载阶段就把这类值当加载失败处理, +// 使其走 fail-closed 的 last-good 回退路径,而不是让它成为渲染产物。 +func (ac *ACLConfig) validate() error { + for i, t := range ac.Targets { + for _, c := range t.IPCIDR { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, err := netip.ParsePrefix(c); err != nil { + return fmt.Errorf("target[%d] invalid ip_cidr %q: %w", i, c, err) + } + } + for _, p := range t.Port { + if p < 1 || p > 65535 { + return fmt.Errorf("target[%d] invalid port %d (must be 1-65535)", i, p) + } + } + } + return nil +} + +// empty 报告该 target 是否没有任何匹配条件(没有条件的规则会匹配一切,危险)。 +func (t ACLTarget) empty() bool { + return len(t.Domain) == 0 && len(t.DomainSuffix) == 0 && + len(t.IPCIDR) == 0 && len(t.Port) == 0 +} + +// matchFields 把 target 转成 sing-box route rule 的匹配字段。 +// 每次调用返回全新 map —— 放行与拒绝两条规则各自在其上追加 user/outbound/action, +// 共享同一对象会互相污染。 +func (t ACLTarget) matchFields() map[string]any { + m := make(map[string]any, 4) + if len(t.Domain) > 0 { + m["domain"] = t.Domain + } + if len(t.DomainSuffix) > 0 { + m["domain_suffix"] = t.DomainSuffix + } + if len(t.IPCIDR) > 0 { + m["ip_cidr"] = t.IPCIDR + } + if len(t.Port) > 0 { + m["port"] = t.Port + } + return m +} + +// active 报告本 ACL 是否应真正注入规则。 +// +// 注意与 WarpConfig.active() 的语义差别:此处 AllowDpUUIDs 为空**不影响**返回值。 +// 空白名单是一个合法且有意义的状态 ——「谁都不许访问这些目的地」。把它当作未启用 +// 会造成 fail-open。唯一的关闭途径是显式 "enabled": false。 +func (ac *ACLConfig) active() bool { + if ac == nil || !ac.Enabled { + return false + } + return len(ac.cleanTargets()) > 0 +} + +// cleanUUIDs 去空白/空项后返回白名单。 +func (ac *ACLConfig) cleanUUIDs() []string { + if ac == nil { + return nil + } + out := make([]string, 0, len(ac.AllowDpUUIDs)) + for _, u := range ac.AllowDpUUIDs { + if u = strings.TrimSpace(u); u != "" { + out = append(out, u) + } + } + return out +} + +// cleanTargets 规范化域名(小写去空白)并丢弃无任何条件的 target。 +func (ac *ACLConfig) cleanTargets() []ACLTarget { + if ac == nil { + return nil + } + out := make([]ACLTarget, 0, len(ac.Targets)) + for _, t := range ac.Targets { + c := ACLTarget{ + Domain: cleanHosts(t.Domain), + DomainSuffix: cleanHosts(t.DomainSuffix), + IPCIDR: cleanStrings(t.IPCIDR), + Port: t.Port, + } + if !c.empty() { + out = append(out, c) + } + } + return out +} + +func cleanHosts(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if s = strings.TrimSpace(strings.ToLower(s)); s != "" { + out = append(out, s) + } + } + return out +} + +func cleanStrings(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out +} + +// rules 产出 ACL 的 sing-box route 规则:每个 target 一对 —— 先放行白名单、再兜底拒绝。 +// +// 顺序是安全性的一部分,不可重排: +// 1. 全部放行规则排在全部拒绝规则之前。不能按 target 交错(放行A/拒绝A/放行B/拒绝B), +// 因为 target 之间可能重叠,交错会让 B 的成员被 A 的拒绝规则先命中。 +// 2. 拒绝规则不带 user 维度 —— 它要对「白名单之外的所有人」生效。 +// 3. 同一 target 的放行与拒绝,目的地条件由同一个 matchFields() 生成,保证逐字相同。 +// 任何不对称都会造成「我自己也被拒」或「有人漏网」。 +// +// 白名单为空时只产出拒绝规则(谁都不许进),这是 fail-closed 的核心:空名单的语义是 +// 「没有人」而非「所有人」。 +func (ac *ACLConfig) rules() []any { + if !ac.active() { + return nil + } + uuids := ac.cleanUUIDs() + targets := ac.cleanTargets() + + out := make([]any, 0, len(targets)*2) + if len(uuids) > 0 { + for _, t := range targets { + r := t.matchFields() + // auth_user(非 user):sing-box 1.13 的 route rule 里,VLESS/REALITY 入站的 + // 认证用户要用 auth_user 匹配 inbound user 的 name;user 字段对 VLESS 运行时 + // 不生效(仅 sing-box check 语法通过),会导致放行规则永不命中、白名单用户也 + // 被兜底拒绝。已用本地真 VLESS 连接实测确认(good→通, bad→被 block)。 + r["auth_user"] = uuids + r["outbound"] = directOutboundTag + out = append(out, r) + } + } + for _, t := range targets { + r := t.matchFields() + r["action"] = "reject" + out = append(out, r) + } + return out +} + +// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。 +func persistACL(path string, ac *ACLConfig) error { + data, err := json.MarshalIndent(ac, "", " ") + if err != nil { + return fmt.Errorf("agentd: marshal acl snapshot: %w", err) + } + return atomicWrite(path, data, 0o600) +} diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go new file mode 100644 index 0000000..3f91270 --- /dev/null +++ b/server/internal/agentd/acl_test.go @@ -0,0 +1,870 @@ +package agentd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" +) + +// captureLog 把标准库 log 包(logf 的底层输出)重定向到一个 buffer,测试结束 +// (t.Cleanup)后自动还原到原输出。agentd 包内没有自己的 logger 抽象——logf 直接 +// 调 log.Printf——但标准库 log 本身就是一个可重定向的现成缝隙,不需要为了可测试性 +// 另外引入 logger 接口。包内测试从不并发跑(无 t.Parallel),重定向全局 log 输出 +// 是安全的。 +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + orig := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(orig) }) + return &buf +} + +// 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) + } +} + +// 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)) + } + + // 前两条是放行:带 auth_user + outbound,不带 action + // (auth_user 而非 user:VLESS 入站运行时只认 auth_user,见 acl.go rules() 注释) + for i := 0; i < 2; i++ { + r := rules[i].(map[string]any) + if _, ok := r["auth_user"]; !ok { + t.Errorf("rules[%d] 放行规则缺 auth_user", i) + } + if _, ok := r["user"]; ok { + t.Errorf("rules[%d] 放行规则不应带 user(VLESS 不生效),应用 auth_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,不带 auth_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["auth_user"]; ok { + t.Errorf("rules[%d] 拒绝规则不应带 auth_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) + } +} + +// 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 + resolve + warp 规则", func(t *testing.T) { + r := buildRoute(nil, warp) + 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 rules[1].(map[string]any)["action"] != "resolve" { + t.Error("第二条不是 resolve(域名形式的目的地需要先解析才能命中后续 ip_cidr 规则)") + } + if rules[2].(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 + resolve + 放行 + 拒绝", func(t *testing.T) { + r := buildRoute(acl, nil) + rules := r["rules"].([]any) + if len(rules) != 4 { + t.Fatalf("规则数 = %d, want 4", len(rules)) + } + if rules[0].(map[string]any)["action"] != "sniff" { + t.Error("首条不是 sniff") + } + if rules[1].(map[string]any)["action"] != "resolve" { + t.Error("第二条不是 resolve") + } + if _, ok := rules[2].(map[string]any)["auth_user"]; !ok { + t.Error("第三条不是放行规则") + } + if rules[3].(map[string]any)["action"] != "reject" { + t.Error("第四条不是拒绝规则") + } + }) + + t.Run("都开 → sniff + resolve + ACL(放行,拒绝) + warp,且 sniff/resolve 各只出现一次", func(t *testing.T) { + r := buildRoute(acl, warp) + rules := r["rules"].([]any) + if len(rules) != 5 { + t.Fatalf("规则数 = %d, want 5", len(rules)) + } + sniffs, resolves := 0, 0 + for _, x := range rules { + switch x.(map[string]any)["action"] { + case "sniff": + sniffs++ + case "resolve": + resolves++ + } + } + if sniffs != 1 { + t.Errorf("sniff 出现 %d 次, want 1", sniffs) + } + if resolves != 1 { + t.Errorf("resolve 出现 %d 次, want 1", resolves) + } + if rules[0].(map[string]any)["action"] != "sniff" { + t.Error("sniff 必须最先") + } + if rules[1].(map[string]any)["action"] != "resolve" { + t.Error("resolve 必须紧跟 sniff 之后") + } + // ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口 + if rules[4].(map[string]any)["outbound"] != warpOutboundTag { + t.Error("warp 规则必须排在最后") + } + if rules[3].(map[string]any)["action"] != "reject" { + t.Error("ACL 拒绝规则必须排在 warp 之前") + } + }) +} + +// I3:ip_cidr 目的地能被"域名形式"的请求绕过——ip_cidr 匹配的是已解析的目的地 +// 地址,客户端若发送域名形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr 规则 +// 不命中,请求穿透到 final:direct。route 块加 {"action":"resolve"} 让节点自己 +// 解析目的地,使域名形式收敛到 ip_cidr 规则上。resolve 必须紧跟 sniff 之后、且 +// 只在产出 route 块时才出现(与 sniff 同条件)。 +func TestBuildRoute_ResolvePositionedRightAfterSniff(t *testing.T) { + acl := &ACLConfig{ + Enabled: true, + AllowDpUUIDs: []string{"uuid-me"}, + Targets: []ACLTarget{{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}}}, + } + r := buildRoute(acl, nil) + rules := r["rules"].([]any) + if len(rules) < 2 { + t.Fatalf("规则数 = %d, 至少要有 sniff+resolve", len(rules)) + } + if rules[0].(map[string]any)["action"] != "sniff" { + t.Fatal("sniff 必须最先") + } + if rules[1].(map[string]any)["action"] != "resolve" { + t.Fatal("resolve 必须紧跟 sniff 之后") + } +} + +// 未配置 ACL 也未启用 WARP → 仍不产出 route 块(resolve 不该单独出现)。 +func TestBuildRoute_NeverConfigured_NoResolve(t *testing.T) { + if got := buildRoute(nil, nil); got != nil { + t.Errorf("buildRoute(nil,nil) = %v, want nil", got) + } +} + +// 成功加载后必须把快照落盘,否则 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 块") + } +} + +// acl.json 被删除 → 规则不能消失(内存 last-good 兜底)。 +func TestACL_DeletedFileKeepsInMemoryLastGood(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.Remove(cfg.ACLConfigPath); err != nil { + t.Fatalf("failed to remove acl.json: %v", err) + } + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "reject") { + t.Fatal("acl.json 被删除后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开") + } +} + +// 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 触发了热重载") +} + +// 新 agent 实例(模拟进程重启) + 被删的 acl.json → 磁盘 last-good 兜底,规则仍在。 +func TestACL_ColdStartAfterDeletedFileFallsBackToDisk(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) + } + + if err := os.Remove(cfg.ACLConfigPath); err != nil { + t.Fatalf("failed to remove acl.json: %v", err) + } + 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("冷启动(acl.json 删除)未回退到磁盘 last-good,私有服务已敞开") + } +} + +// 样例文件必须始终可被解析且产出预期规则,防止改了 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) + } + } +} + +// ─── C2: 手抖字段名(如 targets → targetz)不得静默关闸,也不得覆盖 last-good ────── + +// 字段名手抖(targetz)→ encoding/json 忽略未知字段,Targets 变 nil,active()=false。 +// 若不做特殊处理,旧代码会把这份"看似合法"的空配置当成新的 last-good 落盘, +// 把恢复用的快照也一起冲掉。正确行为:当作加载失败处理,回退 last-good,不覆盖磁盘。 +func TestACL_TypoFieldWithEnabledTrue_FallsBackWithoutOverwritingLastGood(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) + } + before, err := os.ReadFile(cfg.ACLLastGoodPath()) + if err != nil { + t.Fatalf("last-good 未在健康加载后落盘: %v", err) + } + + // "targets" 手抖成 "targetz":JSON 仍能解析,但 Targets 字段收不到值。 + writeACL(t, cfg.ACLConfigPath, `{"enabled":true,"allow_dp_uuids":["x"],"targetz":[{"domain":["a.com"]}]}`) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "reject") { + t.Fatal("字段名手抖(targetz)后拒绝规则消失了 —— 这是静默关闸,私有服务已敞开") + } + after, err := os.ReadFile(cfg.ACLLastGoodPath()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("手抖配置覆盖了 acl.last-good.json —— 恢复用的快照被销毁了") + } +} + +// 显式 enabled:false 是唯一合法的关闭方式,渲染结果必须真的没有 reject 规则; +// 但它不该覆盖磁盘上已有的 last-good 快照——否则日后 acl.json 意外损坏/丢失时, +// 回退到的将是这份"已关闭"的快照而不是最近一次真正 active 的配置。 +func TestACL_ExplicitDisableDoesNotOverwriteLastGood(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) + } + before, err := os.ReadFile(cfg.ACLLastGoodPath()) + if err != nil { + t.Fatalf("last-good 未在健康加载后落盘: %v", err) + } + + writeACL(t, cfg.ACLConfigPath, `{"enabled": false, "allow_dp_uuids": ["x"], "targets": [{"domain":["a.com"]}]}`) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "reject") { + t.Fatal("enabled=false 应真正关闭 ACL,不应再产出 reject 规则") + } + after, err := os.ReadFile(cfg.ACLLastGoodPath()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("显式 enabled=false 不该覆盖 last-good 快照,否则日后 acl.json 损坏就回不到真正 active 的配置了") + } +} + +// 白名单为空但 target 有效 → 是合法的"拒绝所有人"配置,不是手抖,必须仍被当作 +// last-good 持久化,且仍产出拒绝规则。防止 C2 修复对"零 target"的特判过度矫正到 +// "零白名单"上。 +func TestACL_EnabledWithEmptyAllowlistStillPersistsAndDenies(t *testing.T) { + cfg := testConfig(t) + writeACL(t, cfg.ACLConfigPath, `{"enabled": true, "allow_dp_uuids": [], "targets": [{"domain": ["brain.51yanmei.com"]}]}`) + 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) + } + if !strings.Contains(string(data), "reject") { + t.Fatal("空白名单+有效 target 应仍产出拒绝规则(拒所有人),不该被当成手抖") + } + if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil { + t.Fatalf("空白名单(但有效 target)的配置应被当作合法 active 配置持久化为 last-good: %v", err) + } +} + +// ─── I2: last-good 双失败必须大声告警,读盘错误不能被吞掉 ───────────────────── + +// acl.json 缺失 + 磁盘 last-good 损坏(存在但解析不了,模拟卷挂载异常/写坏)→ +// 两级兜底都失败,gate 实质消失,必须在 loadACL 的终点打 ALERT——这正是设计 §5 +// "两者都失败才不产出 ACL 规则,同时打 ERROR 并告警" 要求的那一档。 +// +// 注:没有采用"把两个文件都整个删掉"来复现,因为那种状态在文件系统层面与 +// "这台节点从没配置过 ACL"完全无法区分(两次 os.Stat 都是 not-exist),任何仅凭 +// 现有文件状态判断的实现都做不出区分;若真要区分,需要额外的持久化标记,超出本 +// finding 的范围。"last-good 文件存在但损坏"则是一个可观察、可被 os.Stat 命中 +// 的信号,且更贴近 §5 描述的真实故障("last-good 损坏"),故以此复现。 +func TestACL_BothLastGoodFailuresAlert(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) // acl.last-good.json 现已落盘 + } + + if err := os.Remove(cfg.ACLConfigPath); err != nil { + t.Fatalf("failed to remove acl.json: %v", err) + } + writeACL(t, cfg.ACLLastGoodPath(), `{"enabled": true,`) // 磁盘 last-good 也损坏 + + buf := captureLog(t) + 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 都失败,不应该还能产出规则(此断言只是确认前提)") + } + logged := buf.String() + if !strings.Contains(logged, "ALERT") { + t.Fatalf("两级 last-good 都失败(acl.json 缺失 + 磁盘 last-good 损坏)必须打 ALERT,实际日志:\n%s", logged) + } + if !strings.Contains(logged, "ERROR reading last-good") { + t.Fatalf("读磁盘 last-good 失败的 derr 必须落日志,实际日志:\n%s", logged) + } +} + +// 从未配置过 ACL(acl.json 和 last-good 都从来没存在过)→ 终点应保持安静,不误报 +// ALERT。这条守着"没用这个功能的节点不该被日志刷屏"。 +func TestACL_NeverConfiguredNoAlert(t *testing.T) { + cfg := testConfig(t) + buf := captureLog(t) + 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 logged := buf.String(); strings.Contains(logged, "ALERT") { + t.Fatalf("从未配置过 ACL 的节点不该收到 ALERT(会造成告警刷屏),实际日志:\n%s", logged) + } +} + +// 降级路径(内存/磁盘 last-good 兜底成功)的日志必须带 WARN 级别标签,便于 +// journalctl | grep -E 'ERROR|WARN|ALERT' 抓到"已恢复但曾经降级"的状态。 +func TestACL_FallbackLogsCarryWarnLevel(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,`) // 手抖写坏,触发内存 last-good 兜底 + buf := captureLog(t) + if _, err := sb.RenderConfig(); err != nil { + t.Fatal(err) + } + if logged := buf.String(); !strings.Contains(logged, "WARN falling back to in-memory") { + t.Fatalf("内存 last-good 兜底日志缺 WARN 级别标签,实际日志:\n%s", logged) + } +} + +// ─── I1: 非法 ip_cidr/port 必须在加载阶段被拒绝,走 fail-closed last-good ────── + +// port 超出 1-65535、ip_cidr 不是合法 CIDR,cleanTargets 此前只做 trim/lower, +// 完全不校验取值——这类配置会被当作"合法"渲染进 sing-box 配置,而 sing-box 自己 +// 会在运行时(reload/restart)拒绝它、保留旧实例,导致"渲染产物落盘看起来正常, +// 但线上 gate 其实没生效"这种难排查的静默失败。加载阶段直接拒绝,让它走 +// last-good 兜底(与解析失败同一条路径),而不是把坏值一路渲染出去。 +func TestLoadACLConfig_RejectsInvalidPort(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "acl.json") + writeACL(t, p, `{ + "enabled": true, + "allow_dp_uuids": ["u"], + "targets": [{ "ip_cidr": ["182.92.213.171/32"], "port": [70000] }] + }`) + if _, err := LoadACLConfig(p); err == nil { + t.Fatal("port 70000 超出合法范围(1-65535),LoadACLConfig 应返回 error") + } +} + +func TestLoadACLConfig_RejectsInvalidCIDR(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "acl.json") + writeACL(t, p, `{ + "enabled": true, + "allow_dp_uuids": ["u"], + "targets": [{ "ip_cidr": ["not-a-cidr"], "port": [443] }] + }`) + if _, err := LoadACLConfig(p); err == nil { + t.Fatal("ip_cidr \"not-a-cidr\" 不是合法 CIDR,LoadACLConfig 应返回 error") + } +} + +// 非法配置在 loadACL 层面必须走 last-good 兜底(与解析失败同一条路径),而不是 +// 被渲染进 sing-box 配置。 +func TestACL_InvalidPortFallsBackToLastGood(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, + "allow_dp_uuids": ["u"], + "targets": [{ "ip_cidr": ["182.92.213.171/32"], "port": [70000] }] + }`) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "70000") { + t.Fatal("非法 port 70000 不应该被渲染进 sing-box 配置,应走 last-good 兜底") + } + if !strings.Contains(string(data), "reject") { + t.Fatal("非法配置应走 last-good 兜底,拒绝规则不应消失") + } +} + +// 产物合法性:渲染结果喂给真实 sing-box 二进制的 `check` 子命令,必须 exit 0。 +// 这把设计 §9 "产物合法性" 验收项变成一条常驻测试,而不是只在上线前手工跑一次。 +// +// 刻意不经 SingBox/ApplyConfig 走完整 reality/hy2 inbound(sampleSnapshot 里的 +// PrivateKey/CertPath 都是占位假值,sing-box 会因证书/密钥不合法而拒绝——那是 +// 另一类问题,不是本测试要盯的 ACL route 合法性)。直接调用 renderSingboxConfig, +// reality/hy2 传 nil,只产出 outbounds + ACL route,聚焦 §9 要验的东西。 +func TestRenderedConfig_PassesSingBoxCheck(t *testing.T) { + singboxBin, err := exec.LookPath("sing-box") + if err != nil { + t.Skip("sing-box not installed, skipping resident config-validity check") + } + + ac, err := LoadACLConfig(writeTempACL(t, validACL)) + if err != nil { + t.Fatal(err) + } + data, err := renderSingboxConfig(nil, nil, nil, "test-derive-key", nil, ac) + if err != nil { + t.Fatal(err) + } + + // homebrew 装的本机 sing-box 二进制没有编译 with_v2ray_api tag(生产节点上的 + // 那份是),`v2ray_api` 这段实验性配置在本机会导致 check 因为"功能未编译进来" + // 而 FATAL——这与本测试要验的 ACL/route 语法合法性无关,故只为本次校验剥离它。 + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if exp, ok := m["experimental"].(map[string]any); ok { + delete(exp, "v2ray_api") + } + data, err = json.Marshal(m) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(cfgPath, data, 0o600); err != nil { + t.Fatal(err) + } + + out, err := exec.Command(singboxBin, "check", "-c", cfgPath).CombinedOutput() + if err != nil { + t.Fatalf("sing-box check 未通过(exit != 0): %v\n%s", err, out) + } +} + +// writeTempACL 把 body 写到临时目录下的 acl.json 并返回路径。 +func writeTempACL(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "acl.json") + writeACL(t, p, body) + return p +} diff --git a/server/internal/agentd/config.go b/server/internal/agentd/config.go index 6d455f1..b75bca8 100644 --- a/server/internal/agentd/config.go +++ b/server/internal/agentd/config.go @@ -5,9 +5,15 @@ // command feed by managing the local sing-box user table. // // 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, -// zero destination/DNS data and writes no access logs. A seized node leaks only -// opaque dp_uuids, never accounts. +// 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。 package agentd import ( @@ -58,6 +64,10 @@ type Config struct { // 文件不存在 = WARP 未启用。渲染时读取,支持编辑后重启 agent 生效(#29)。 WarpConfigPath string + // ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 /acl.json)。 + // 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。 + ACLConfigPath string + // DeriveKey keys the Hy2 password derivation (see DeriveHy2Password). DeriveKey string @@ -86,6 +96,9 @@ func (c Config) withDefaults() Config { if c.WarpConfigPath == "" { c.WarpConfigPath = filepath.Join(c.StateDir, "warp.json") } + if c.ACLConfigPath == "" { + c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json") + } if c.HeartbeatInterval == 0 { c.HeartbeatInterval = DefaultHeartbeatInterval } @@ -118,3 +131,7 @@ func (c Config) KeyPath() string { return filepath.Join(c.StateDir, "node.key" func (c Config) CertPath() string { return filepath.Join(c.StateDir, "node.crt") } func (c Config) CAPath() string { return filepath.Join(c.StateDir, "ca.crt") } func (c Config) StatePath() string { return filepath.Join(c.StateDir, "state.json") } + +// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json +// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。 +func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") } diff --git a/server/internal/agentd/render.go b/server/internal/agentd/render.go index ef2966d..452df8a 100644 --- a/server/internal/agentd/render.go +++ b/server/internal/agentd/render.go @@ -13,6 +13,11 @@ import ( // // 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。 +// // 用量统计走 v2ray_api StatsService(loopback gRPC):节点 sing-box 编入 // with_v2ray_api,stats.users 列出全部 dp_uuid → 每个用户独立的 // user>>>{dp_uuid}>>>traffic>>>uplink|downlink 计数器,agent 按用户精确读取 @@ -27,7 +32,7 @@ const ( warpOutboundTag = "warp" ) -func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) { +func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) { cfg := map[string]any{ "log": map[string]any{"level": "warn", "timestamp": true}, "inbounds": buildInbounds(creds, reality, hy2, deriveKey), @@ -47,16 +52,55 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age }, } - // WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。 - // warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。 + // WARP 分流(#29)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要 + // 同时容纳 ACL 规则 —— 原先 cfg["route"] = warp.warpRoute() 是整块覆盖,直接赋值 + // 会把对方的规则干掉。 if warp.active() { cfg["endpoints"] = []any{warp.warpEndpoint()} - cfg["route"] = warp.warpRoute() + } + if route := buildRoute(acl, warp); route != nil { + cfg["route"] = route } return json.MarshalIndent(cfg, "", " ") } +// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。 +// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。 +// +// 规则顺序是安全语义的一部分: +// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是 +// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。 +// 2. {"action":"resolve"} 紧跟 sniff 之后,同样唯一且只在产出 route 块时才出现。 +// ACL 的 ip_cidr 目的地(DSM/RDP/SSH 等独占端口服务)匹配的是已解析的连接 +// 目的地地址——若客户端发的是域名形式(如 nas.51yanmei.com:5001)而节点不主动 +// 解析,ip_cidr 规则不命中,请求会绕过 ACL 直接落到 final:direct。resolve 让 +// 节点自己解析目的地,使域名形式收敛到 ip_cidr 规则上,堵住这个绕过口子 +// (I3;这也是唯一一处从"节点不知道目的地"这条不变式主动后退的地方,是知情 +// 接受的取舍,见 docs/private-dest-acl-design.html §12)。 +// 3. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远 +// 不该还有机会被路由到 warp 出口。 +// 4. 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)+3) + rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, map[string]any{"action": "resolve"}) + 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} +} + // statsUsers 收集所有去重 dp_uuid,供 v2ray_api stats.users 按用户开启流量计数器。 func statsUsers(creds []Cred) []string { seen := make(map[string]struct{}, len(creds)) diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index 9cbf554..73930b7 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -61,6 +61,10 @@ type SingBox struct { hy2 *agentv1.Hy2Inbound configVersion int64 + // lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它, + // 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。 + lastGoodACL *ACLConfig + // started 标记 sing-box 是否已被本 agent 冷启动过:首次走 Restart(冷启动), // 之后配置变更走 Reload(SIGHUP 热重载)。agent 进程重启后复位为 false, // 下次渲染做一次冷启动以确保与渲染配置一致。 @@ -323,7 +327,80 @@ func (s *SingBox) RenderConfig() ([]byte, error) { logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err) warp = nil } - return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp) + 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: + if acl.Enabled && len(acl.cleanTargets()) == 0 { + // enabled 却零 target = 几乎必然是字段名手抖(如 targets 打成 targetz)。 + // encoding/json 对未知字段静默无视,这份配置"看似合法"但毫无内容——当作 + // 加载失败,走下面的 last-good 兜底,绝不静默敞开、更不能拿它覆盖兜底快照。 + logf("[acl] ERROR %s parsed but enabled=true yields zero targets — treating as broken, falling back", s.cfg.ACLConfigPath) + break // 落到下方 last-good 兜底(Go 的 switch 内 break 只退出 switch) + } + if acl.active() { + // 只有 active 的配置才配当 last-good:否则一次手抖既关闸又冲掉兜底。 + 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) + } + } else { + // active() 为 false 但走到了这里,只可能是显式 enabled:false —— 合法关闭。 + // 直接返回这份(空规则的)配置,不 persist,保留此前的恢复能力。 + logf("[acl] gate explicitly disabled (enabled=false)") + } + 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] WARN falling back to in-memory last-good ACL") + return lg + } + + disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath()) + if derr != nil { + logf("[acl] ERROR reading last-good %s: %v", s.cfg.ACLLastGoodPath(), derr) + } + if derr == nil && disk != nil { + logf("[acl] WARN falling back to on-disk last-good %s", s.cfg.ACLLastGoodPath()) + s.mu.Lock() + s.lastGoodACL = disk + s.mu.Unlock() + return disk + } + + // 两级 last-good 都没有可用配置。区分两种终态: + // - 这台节点从未配置过 ACL(acl.json 与 last-good 均从未存在过)→ 安静返回, + // 不刷屏告警。 + // - 除此之外的任何情况(acl.json 存在但损坏/last-good 存在但损坏等)→ 私有 + // 服务的访问闸实质已消失,必须大声告警(§5 "两者都失败才不产出 ACL 规则, + // 同时打 ERROR 并告警")。 + _, aclStatErr := os.Stat(s.cfg.ACLConfigPath) + _, lgStatErr := os.Stat(s.cfg.ACLLastGoodPath()) + neverConfigured := os.IsNotExist(aclStatErr) && os.IsNotExist(lgStatErr) + if !neverConfigured { + logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+ + "private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath) + } + return nil } // writeAndRestart renders, writes the config file and restarts sing-box. @@ -395,6 +472,11 @@ func (s *SingBox) markDirty() { } } +// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件 +// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走 +// 冷启动(Restart),把全部在线用户踢下线。 +func (s *SingBox) Refresh() { s.markDirty() } + // Run drives the debounce loop: it coalesces bursts of changes within // DebounceWindow into a single write+restart. It blocks until ctx is cancelled. func (s *SingBox) Run(ctx context.Context) { diff --git a/server/internal/agentd/warp.go b/server/internal/agentd/warp.go index acb40ee..d86e97f 100644 --- a/server/internal/agentd/warp.go +++ b/server/internal/agentd/warp.go @@ -6,7 +6,6 @@ import ( "net" "os" "strconv" - "strings" ) // WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。 @@ -66,15 +65,9 @@ func (wc *WarpConfig) mtu() int { } // cleanDomains 去空白/空项后返回域名清单(用于 domain_suffix)。 +// 与 ACL 的目的地域名规范化共用 cleanHosts,避免两处逻辑漂移。 func (wc *WarpConfig) cleanDomains() []string { - out := make([]string, 0, len(wc.Domains)) - for _, d := range wc.Domains { - d = strings.TrimSpace(strings.ToLower(d)) - if d != "" { - out = append(out, d) - } - } - return out + return cleanHosts(wc.Domains) } // endpointHostPort 拆 Endpoint 为 host + port(active() 已校验可拆)。 @@ -107,15 +100,3 @@ func (wc *WarpConfig) warpEndpoint() map[string]any { "peers": []any{peer}, } } - -// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP, -// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。 -func (wc *WarpConfig) warpRoute() map[string]any { - return map[string]any{ - "rules": []any{ - map[string]any{"action": "sniff"}, - map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag}, - }, - "final": directOutboundTag, - } -} diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index a282aa2..af0541d 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -15,6 +15,13 @@ type ClientConfigOpts struct { // RulesBaseURL 是控制面对外公网基址(如 "http://node:8080"),rule_set 的 .srs // 从 /v1/rules/*.srs 下载。SplitCN 生效需此项非空(否则分流静默跳过)。 RulesBaseURL string + // PrivateSplitDomains 私有服务域名(家庭内网穿透,如 nas/git/win.yanmeiai.com, + // 服务端 env PANGOLIN_PRIVATE_SPLIT_DOMAINS 配置;空=行为完全不变): + // - DNS 改用系统解析器(在家吃到局域网 DNS 覆盖→私网 IP;在外解析出公网锚点) + // - 路由上私网结果命中 LAN 直连(在家零绕行),公网结果强制走隧道——该规则 + // 必须排在国内分流(geoip-cn)之前:锚点(frps@ali)是国内 IP,否则 smartRoute + // 会把它分流成直连,被 frps 侧安全组限源(仅节点出口)拦截。 + PrivateSplitDomains []string } // BuildClientConfig renders a complete sing-box CLIENT configuration JSON that @@ -106,6 +113,12 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "auto_route": true, "strict_route": true, "stack": "system", + // 私有 LAN 直连:strict_route 会在 OS 层把所有流量(含 LAN)强抓进隧道, + // 光靠 route.rules 的 ip_cidr→direct 在 macOS 不生效(direct 出站的包被 + // strict_route 重新捕回隧道)。route_exclude_address 在 auto_route 层就把这些 + // 网段排除出隧道,LAN 走系统直连(修「隧道开着连不上局域网/NAS/家里机器」)。 + // **不含 172.16.0.0/12**:隧道自身地址与 DNS(172.19.x)在此段,排除会断 DNS。 + "route_exclude_address": []string{"192.168.0.0/16", "10.0.0.0/8"}, } // 代理出站集合:REALITY 必有;Hy2 仅在启用时加入(否则不进配置/探测组)。 @@ -126,7 +139,8 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "tolerance": 50, } - // Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。 + // Route: DNS 劫持 → LAN direct →(可选)私有域名走隧道 →(可选)国内直连 → 其余 via auto。 + privateSplit := len(opts.PrivateSplitDomains) > 0 routeRules := []any{ // DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff): // 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN @@ -138,6 +152,15 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "outbound": "direct", }, } + if privateSplit { + // 私有域名强制走隧道。在家不受此规则影响:系统 DNS(局域网覆盖)解析出私网 IP, + // 上面的 LAN 直连规则先命中。域名元数据靠 dns.reverse_mapping 补回(应用自行 + // 解析后按 IP 连接,无回映射则此规则永不匹配、在外会掉进国内分流被限源拦截)。 + routeRules = append(routeRules, map[string]any{ + "domain": opts.PrivateSplitDomains, + "outbound": "auto", + }) + } route := map[string]any{ "final": "auto", "auto_detect_interface": true, @@ -166,24 +189,42 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien // DNS: remote over tunnel, local for domestic. // sing-box 1.12+ DNS server format(type+server);旧的 address 串格式在 // 1.13 已 FATAL 拒绝(legacy DNS servers deprecated)。 + dnsServers := []any{ + map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"}, + // local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站 + // (FATAL: detour to an empty direct outbound makes no sense)。 + map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"}, + } + if privateSplit { + // 系统解析器(type=local,经底层物理网络):在家=路由器 DHCP 下发的局域网 DNS + // (含私有域名覆盖→私网 IP),在外=所在网络 DNS(公网记录→锚点 IP)。 + // 不能用 223.5.5.5/8.8.8.8——公共 DNS 不知道家里的覆盖记录。 + dnsServers = append(dnsServers, map[string]any{"tag": "dns-system", "type": "local"}) + } dns := map[string]any{ - "servers": []any{ - map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"}, - // local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站 - // (FATAL: detour to an empty direct outbound makes no sense)。 - map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"}, - }, + "servers": dnsServers, "final": "remote", "strategy": "ipv4_only", } + dnsRules := []any{} + if privateSplit { + // 私有域名规则须排在 geosite-cn 之前(优先级最高)。 + dnsRules = append(dnsRules, map[string]any{ + "domain": opts.PrivateSplitDomains, "server": "dns-system", + }) + // 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回 + // 域名元数据——路由层的 domain 规则(私有域名→隧道)靠它才会命中。 + dns["reverse_mapping"] = true + } // 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的 // 国内域名用 local(223.5.5.5)直连解析,不走 remote(8.8.8.8 经隧道)。否则即便数据 // 直连,域名解析仍绕道出海(实测国内 DNS 段 200-600ms),首连凭空多一个出海 RTT。 // 复用 route.rule_set 里已定义的 geosite-cn 标签。 if splitActive { - dns["rules"] = []any{ - map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"}, - } + dnsRules = append(dnsRules, map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"}) + } + if len(dnsRules) > 0 { + dns["rules"] = dnsRules } cfg := map[string]any{ diff --git a/server/internal/httpapi/clientconfig_test.go b/server/internal/httpapi/clientconfig_test.go index df0985c..5d54d81 100644 --- a/server/internal/httpapi/clientconfig_test.go +++ b/server/internal/httpapi/clientconfig_test.go @@ -91,6 +91,84 @@ func TestBuildClientConfigSplitCN(t *testing.T) { } } +func TestBuildClientConfigPrivateSplit(t *testing.T) { + domains := []string{"nas.yanmeiai.com", "git.yanmeiai.com", "win.yanmeiai.com"} + // 私有分流 + 国内分流同时开:验证规则齐全且顺序正确 + // (LAN 直连 → 私有域名强制走隧道 → 国内直连;私有规则必须在国内直连之前, + // 否则锚点是国内 IP 会被分流成直连、被 frps 侧安全组限源拦截)。 + cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", + ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://node:8080", + PrivateSplitDomains: domains}) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(cfg, &m); err != nil { + t.Fatal(err) + } + + // ① dns.servers 含系统解析器(type=local):在家吃到局域网 DNS 覆盖(私网IP), + // 在外用所在网络 DNS 解析出公网锚点。 + dnsm := m["dns"].(map[string]any) + foundSystem := false + for _, s := range dnsm["servers"].([]any) { + sm := s.(map[string]any) + if sm["tag"] == "dns-system" && sm["type"] == "local" { + foundSystem = true + } + } + if !foundSystem { + t.Error("missing dns-system (type=local) dns server") + } + + // ② dns.rules 首条 = 私有域名→dns-system(须排在 geosite-cn→local 之前)。 + dnsRules := dnsm["rules"].([]any) + dr := dnsRules[0].(map[string]any) + if dr["server"] != "dns-system" || dr["domain"] == nil { + t.Errorf("dns.rules[0] should be private domains → dns-system, got %v", dr) + } + + // ③ reverse_mapping 开启:应用自行解析后按 IP 连接,回映射补回域名元数据, + // 路由的 domain 规则才有效。 + if dnsm["reverse_mapping"] != true { + t.Error("reverse_mapping should be true when private split is on") + } + + // ④ 路由顺序:LAN 直连 < 私有域名→auto < 国内 rule_set→direct。 + rules := m["route"].(map[string]any)["rules"].([]any) + lanIdx, privIdx, cnIdx := -1, -1, -1 + for i, r := range rules { + rm := r.(map[string]any) + if rm["ip_cidr"] != nil && rm["outbound"] == "direct" { + lanIdx = i + } + if rm["domain"] != nil && rm["outbound"] == "auto" { + privIdx = i + } + if rm["rule_set"] != nil && rm["outbound"] == "direct" { + cnIdx = i + } + } + if lanIdx < 0 || privIdx < 0 || cnIdx < 0 { + t.Fatalf("missing rules: lan=%d priv=%d cn=%d", lanIdx, privIdx, cnIdx) + } + if !(lanIdx < privIdx && privIdx < cnIdx) { + t.Errorf("rule order wrong: lan=%d < priv=%d < cn=%d expected", lanIdx, privIdx, cnIdx) + } + + // ⑤ 不配置 → 全部不出现(行为与旧版完全一致)。 + cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{}) + var m2 map[string]any + _ = json.Unmarshal(cfg2, &m2) + dnsm2 := m2["dns"].(map[string]any) + if _, ok := dnsm2["reverse_mapping"]; ok { + t.Error("private split off: reverse_mapping should be absent") + } + if strings.Contains(string(cfg2), "dns-system") { + t.Error("private split off: dns-system should be absent") + } +} + func TestRulesHandler(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "geoip-cn.srs"), []byte("SRS"), 0o644); err != nil { @@ -115,3 +193,45 @@ func TestRulesHandler(t *testing.T) { t.Errorf("allowed but missing file: code=%d, want 404", code) } } + +// TUN 入站必须把私有 LAN 网段从隧道排除(route_exclude_address),否则 strict_route +// 会在 macOS 把 LAN 强抓进隧道 → 隧道开着连不上局域网/NAS。不得含 172.16/12 +// (隧道自身 172.19.x 在此段,排除会断 DNS)。 +func TestBuildClientConfigLANExclude(t *testing.T) { + cfg, err := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{}) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + if err := json.Unmarshal(cfg, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var tun map[string]any + for _, in := range m["inbounds"].([]any) { + im := in.(map[string]any) + if im["type"] == "tun" { + tun = im + break + } + } + if tun == nil { + t.Fatal("no tun inbound") + } + exRaw, ok := tun["route_exclude_address"] + if !ok { + t.Fatal("tun inbound missing route_exclude_address (LAN would be captured by strict_route)") + } + got := map[string]bool{} + for _, v := range exRaw.([]any) { + got[v.(string)] = true + } + if !got["192.168.0.0/16"] { + t.Error("route_exclude_address must contain 192.168.0.0/16") + } + if !got["10.0.0.0/8"] { + t.Error("route_exclude_address must contain 10.0.0.0/8") + } + if got["172.16.0.0/12"] { + t.Error("route_exclude_address must NOT contain 172.16.0.0/12 (tunnel DNS 172.19.x lives there)") + } +} diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index 76e3aa6..8f0e232 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -42,12 +42,16 @@ type NodeAPI struct { // rulesBaseURL 是控制面对外公网基址(PANGOLIN_PUBLIC_URL),供国内分流的 // rule_set .srs 下载用;空则分流不生效。 rulesBaseURL string + // privateSplitDomains 私有服务域名(PANGOLIN_PRIVATE_SPLIT_DOMAINS,逗号分隔), + // 见 ClientConfigOpts.PrivateSplitDomains;空则不渲染相关规则。 + privateSplitDomains []string } // NewNodeAPI creates a NodeAPI. load may be nil (then all nodes are treated as // data-plane healthy — agent gRPC liveness still gates status). -func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string) *NodeAPI { - return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey, rulesBaseURL: rulesBaseURL} +func NewNodeAPI(store nodes.NodeStore, hub *nodes.Hub, load nodeLoadReader, deriveKey, rulesBaseURL string, privateSplitDomains []string) *NodeAPI { + return &NodeAPI{store: store, hub: hub, load: load, deriveKey: deriveKey, + rulesBaseURL: rulesBaseURL, privateSplitDomains: privateSplitDomains} } // dataPlaneHealthy reports the node's last sing-box health (default true when @@ -315,7 +319,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // split_cn=1/true → 国内 IP/域名直连(#5);客户端按 smartRoute 偏好传。 splitCN := r.URL.Query().Get("split_cn") == "1" || r.URL.Query().Get("split_cn") == "true" cfgJSON, renderErr := BuildClientConfig(node, dpUUID, a.deriveKey, - ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL}) + ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL, + PrivateSplitDomains: a.privateSplitDomains}) if renderErr != nil { slog.Error("connect: build client config failed", "node", nodeUUID, "err", renderErr) apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) @@ -324,7 +329,8 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // 可观测:国内分流是否真正生效(split_active=两个条件都满足才渲染 rule_set)。 slog.Info("client config rendered", "node", nodeUUID, "split_cn", splitCN, "rules_base_set", a.rulesBaseURL != "", - "split_active", splitCN && a.rulesBaseURL != "", "bytes", len(cfgJSON)) + "split_active", splitCN && a.rulesBaseURL != "", + "private_split", len(a.privateSplitDomains), "bytes", len(cfgJSON)) w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(cfgJSON) diff --git a/server/internal/httpapi/nodes_disconnect_test.go b/server/internal/httpapi/nodes_disconnect_test.go index d8aab8a..d28c7cb 100644 --- a/server/internal/httpapi/nodes_disconnect_test.go +++ b/server/internal/httpapi/nodes_disconnect_test.go @@ -51,7 +51,7 @@ func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1 func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int { t.Helper() - api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除 + api := NewNodeAPI(store, nil, nil, "", "", nil) // nil hub:跳过 Push,只验证凭证删除 req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body)) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "node-1") diff --git a/todo/todo.html b/todo/todo.html index 9b710a2..cc8ebdd 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -327,12 +327,12 @@ header .header-meta{color:var(--fg-soft)}

feature+windows — 项目 TODO

-
生成于 2026-07-08 · 真相源 todo/todo.json
+
生成于 2026-07-23 · 真相源 todo/todo.json
-
18全部
-
8待开始
-
0开发中
-
2待验收
+
22全部
+
9待开始
+
1开发中
+
4待验收
8已验收
@@ -376,7 +376,7 @@ header .header-meta{color:var(--fg-soft)}
- 📋 待开始 8 + 📋 待开始 9 ▴ 收起
@@ -599,6 +599,37 @@ header .header-meta{color:var(--fg-soft)}
+
  • +
    + #21 + 邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活 +
    + 待开始 + 重要 + + + +
    +
    + + + + + +
  • +
  • - 🔨 开发中 0 + 🔨 开发中 1 ▴ 收起
      -

      暂无条目

      + +
    • +
      + #23 + 私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closed,SIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md +
      + 开发中 + 重要 + + + +
      +
      + + + + + +
    - 🔍 待验收 2 + 🔍 待验收 4 ▴ 收起
    @@ -714,6 +775,68 @@ header .header-meta{color:var(--fg-soft)} 🤖 agent 🕐 2026-07-07 +
    +
    +
  • + +
  • +
    + #20 + Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常) +
    + 待验收 + 重要 + + + +
    +
    + + + + + +
  • + +
  • +
    + #22 + 客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开 +
    + 待验收 + 重要 + + + +
    +
    + + + + +
  • diff --git a/todo/todo.json b/todo/todo.json index a7cd675..fad570d 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,9 +1,9 @@ { "meta": { "title": "feature+windows — 项目 TODO", - "updated_at": "2026-07-07T17:45:18.036Z" + "updated_at": "2026-07-22T16:59:57.735Z" }, - "seq": 19, + "seq": 23, "items": [ { "id": 1, @@ -317,6 +317,62 @@ "proposed_at": "2026-07-07T15:41:36.536Z", "approved_at": "2026-07-07T15:54:54.386Z" } + }, + { + "id": 20, + "title": "Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "done", + "created_at": "2026-07-12T10:48:49.013Z", + "done": false, + "completed_at": "2026-07-13T02:20:58.685Z", + "version": "site-v0.0.13" + }, + { + "id": 21, + "title": "邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "open", + "created_at": "2026-07-13T04:29:47.253Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 22, + "title": "客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "done", + "created_at": "2026-07-15T21:40:35.427Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 23, + "title": "私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closed,SIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "doing", + "created_at": "2026-07-22T16:59:41.945Z", + "done": false, + "completed_at": null, + "version": null } ] }