Add append-only batch node onboarding and recovery checks
Some checks are pending
Docker Build and Publish / build (push) Waiting to run

This commit is contained in:
hellcat 2026-10-17 08:29:05 +08:00
parent 522666c339
commit 6af676421b
17 changed files with 1941 additions and 253 deletions

View File

@ -1,6 +1,6 @@
# dpl 项目维护
开始维护前阅读 [维护入口](docs/maintenance/README.md),再按任务范围阅读架构、业务约束、发布恢复与验收状态。用户最新明确要求优先;文档中的历史状态必须按当前代码和运行环境复核。
先读 [CoDocs 项目总览](/Users/hellcat/Documents/CoDocs/项目总览.md) 与首次会话的 [持续维护协议](/Users/hellcat/Documents/CoDocs/00-协作规范/维护协议.md),再阅读 [维护入口](docs/maintenance/README.md),再按任务范围阅读架构、业务约束、发布恢复与验收状态。用户最新明确要求优先;文档中的历史状态必须按当前代码和运行环境复核。
- 保留用户及其他任务既有修改,提交只包含本次范围;不要重置或清理无关文件。
- 默认 Git 发布:提交、推送,再让服务器更新到明确版本并验证;未经明确要求不得打包覆盖。
@ -10,4 +10,10 @@
- 本地开发优先 dev/热更新;构建用于发布或构建验证。相关检查与命令见 docs/maintenance/ARCHITECTURE.md。
- 后台变更实际验证显示、切换、保存与权限;HTTP 200 不代表界面可用。测试、提交、发布和真实验收分别报告,未执行的步骤不能写成完成。
## 批量节点接入:默认启动一个子助手
用户要求执行批量节点接入(明确说批量,或同批接入两个及以上节点)时,主协调者必须默认启动一个子助手,无需再次询问是否使用助手:核查验收助手负责候选服务器、端口、中转与已有资源冲突核查,并独立审查脚本、测试并发/查重/恢复及执行接入验收。主协调者负责统一参数、展示编号、端口、唯一账本及生产写入。完整分工、执行关卡与消耗控制见 [批量接入协作流程](docs/maintenance/NODE_BATCH.md#多智能体协作流程)。
这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。
本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。

View File

@ -2,11 +2,11 @@
## 目标与连接
本项目生产站点为 https://dp-link.net,后台 /dove;后端目录 /www/wwwroot/dp-link.net,服务器逻辑名 site-jp02。通过既定白色跳板连接;具体连接资料参见本机 /Users/hellcat/Documents/CoDocs/项目关系.md 与 vps连接资料.md,不把它们的内容提交到公开仓库。
本项目生产站点为 https://dp-link.net,后台 /dove;后端目录 /www/wwwroot/dp-link.net,服务器逻辑名 site-jp02。通过既定白色跳板连接;项目与连接入口参见 [dpl 档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md) 和 [私有资源索引](/Users/hellcat/Documents/CoDocs/30-私有资源/README.md),不把它们的内容提交到公开仓库。
本地 http://dpl.loc/dove 已配置到当前 Laravel public/。本机 Nginx 配置 /usr/local/etc/nginx/servers/dpl.loc.conf,PHP-FPM 使用现有 9000 端口。系统级服务重载可能需要 Mac 管理员授权;不持久保存系统密码。
本地开发与联调访问规则(用户于 2026-10-11 指定):本地不访问 tp-link.net,需要访问远端站点时使用 https://dpl.tomshell.com.de 代替;本地站点仍使用 http://dpl.loc。该访问约定不自动修改生产支付回调域名或生产配置。
本地开发与联调访问规则:本地站点仍使用 `http://dpl.loc`;本地访问线上项目域名必须查询 [CoDocs 反代表](/Users/hellcat/Documents/CoDocs/20-基础设施/访问与反代.md) 并走明确登记的白色机反代,dpl 主站入口为 `https://dpl.tomshell.com.de`。缺映射/用途不符时询问用户,不直接或变相访问源站。该访问约定不自动修改生产支付回调域名或生产配置。
## Git 发布顺序

View File

@ -0,0 +1,149 @@
# 节点批量接入
本专题说明批量调度;单节点操作、append 的保护范围与验收仍以 [执行手册](NODE_RUNBOOK.md) 为准,展示编号以 [命名规则](NODE_ONBOARDING.md#节点命名与编号) 为准。
2026-10-16:新增标准库 Python 调度器、命令适配器和离线进程测试。没有部署、连接服务器或增加生产节点。调度器可以运行经过审阅的任务适配器;**没有内置 Cloudflare/中转后台 API,也没有替使用者实现各站点的 SSH 登录与现场检查。**生产任务必须先按下述协议配置命令,不能将离线测试通过视为生产全链路可用。
## 文件与运行方式
| 文件 | 用途 |
| --- | --- |
| `scripts/node-onboarding/batch.py` | 参数校验、资源预留、并发、进程间锁、逐阶段账本与恢复 |
| `scripts/node-onboarding/command-adapter.py` | 调用经过摘要固定的任务命令;不经过 shell,缺命令返回等待 |
| `tests/node-onboarding/test_batch.py` | 临时目录模拟远端,验证并发、重复运行和回包丢失;不加载 Laravel/.env |
在仓库根目录运行,计划文件与状态目录用绝对路径,放在受保护任务目录。计划只含非秘密参数和引用;不要加入密码、令牌、私钥内容、含订阅令牌的完整 URL 或命令行凭据。
```sh
python3 scripts/node-onboarding/batch.py plan --plan /private/task/batch.json --state-root /private/task/ledger
python3 scripts/node-onboarding/batch.py run --plan /private/task/batch.json --state-root /private/task/ledger --jobs 4 --timeout 900 --execute
python3 scripts/node-onboarding/batch.py status --plan /private/task/batch.json --state-root /private/task/ledger
```
`plan` 仅做本地参数校验,不预留资源、不访问网络、不生成状态目录。`status` 只读账本,不触发命令或创建账本。`run --execute` 才可能执行写入。退出码 0 表示本次要求完成;2 表示校验失败、等待、冲突、未知或未完成,不应自动当成临时网络错误无限重试。默认并发 4,上限 16;timeout 是每次适配器调用的超时秒数,不是整个批次的时限。
## 参数格式
以下使用保留示例地址,不能直接运行生产任务。`provision_commit` 填已审阅、已推送的完整 Git 提交,bundle 与客户端二进制路径必须由前检核验。VPS ID 与实际地址从 CoDocs 对应资源读取,不能从展示编号推断。所有节点放在同一个 `nodes` 数组,SOCKS 端口各不相同。
```json
{
"version": 1,
"mode": "append",
"batch_id": "jp-batch-20261016",
"context": {
"panel_vps": "VPS-001",
"gateway_vps": "VPS-002",
"jump_vps": "VPS-003",
"app_root": "/absolute/panel/app",
"panel_url": "https://panel.example.invalid",
"subscription_url": "https://subscribe.example.invalid",
"provision_commit": "0000000000000000000000000000000000000000",
"bundle": "/private/task/reviewed.bundle",
"client_binary": "/absolute/gateway/sing-box"
},
"adapter": ["/absolute/python3", "/absolute/repo/scripts/node-onboarding/command-adapter.py"],
"command_profile": {
"files": {"/private/task/handler.py": "填写该文件的SHA256"},
"phases": {
"preflight": {
"probe": ["/absolute/python3", "/private/task/handler.py", "--plan", "{plan}", "--node", "{node}", "--phase", "{phase}", "--action", "{action}", "--state-dir", "{state_dir}"]
}
}
},
"nodes": [{
"key": "jp-new-a",
"country": "JP", "sequence": 2, "flag": "🇯🇵", "country_name": "日本",
"name": "🇯🇵 日本 JP - 02",
"vps_id": "VPS-011", "node_address": "192.0.2.11", "listen_port": 24443,
"relay_host": "relay.example.invalid", "relay_port": 47011,
"tls_domain": "jp-new-a.example.invalid", "formal_group_id": 1,
"expected_exit": "192.0.2.11", "socks_port": 19011
}]
}
```
示例只声明 preflight 命令,其他阶段必然等待。实际任务可为八个阶段逐一登记 `probe`,并在 stage/dns/provision/relay/publish 登记有权限执行的 `apply`。缺少 API 的阶段可以只提供读取实际状态的 probe,由操作者完成外部操作后续跑。不要使用“手工勾选成功”或静态回执替代现场 probe。
`files` 必须覆盖本地适配器、handler 及其加载的操作配置/脚本;不要只固定 Python 解释器。任一摘要变化停止,不接受在原批次中改命令后直接续跑。远端代码、安装包、二进制也由前检按明确 Git 提交/摘要核验。通用调度器不会自动检查命令是否遵守这些语义,也不会替命令构造 SSH 配置。`adapter` 亦可指向符合协议的专用程序,不必使用命令适配器。
## 调度与操作协议
所有候选节点先通过 preflight,才能开始任何 stage。每个节点依次执行以下流程;独立节点同时推进,共享写入由锁串行执行。
| 阶段 | 并发边界 | 任务命令必须核验的事实 |
| --- | --- | --- |
| preflight | 按 VPS 并发,全批屏障 | 当前代码/工具/系统依赖;VPS 与地址;空闲端口;现有机器/节点/组/名称/域名/中转冲突;目标正式组;保留资源基线 |
| stage | 面板串行 | 参数与 manifest 完整匹配;隔离 QA 和新节点确属本任务;创建后回读对象及私有传输文件 |
| dns | 全局串行 | 本次域名实际 DNS 指向;有权限的 API 才能 apply;无 API 则等待 |
| provision | 按 VPS 并发 | 已推送提交、安装包摘要、独立服务/配置归属、证书/SNI、旧服务保持;不能仅按文件存在判定完成 |
| relay | 全局串行 | 独立规则的入口和目标完全匹配;网关侧严格 TLS/SNI 连通;不得占用旧规则 |
| verify | 按 VPS 并发 | 从隔离网关获取真实完整 QA 订阅,用 `verify-node.py --run` 验证预期出口/国家并核对实际流量上报;报告绑定本请求 |
| publish | 面板串行 | `promote.php --mode=append` 先预览后应用;只开放本次新节点到指定已有组并禁用 QA;不能重写套餐、用户组或订阅地址 |
| audit | 按 VPS 并发 | 正式多节点订阅完整、旧节点/网关/套餐/用户关联/订阅地址符合基线、QA 禁用且订阅拒绝/不下发;新节点仍可用 |
preflight/verify/audit 仅调用 `probe`,没有 apply。verify 的 probe 会产生隔离测试流量,可以启动/停止本次临时客户端;不能修改正式配置。已经完成 audit 的节点,重跑只做 preflight 与完整 audit,避免重新激活 QA。尚未完成的节点会从第一阶段重新逐项 probe,确认后才越过。
适配器接受 `--plan FILE --node FILE --phase PHASE --action probe|apply --state-dir DIR`。node 文件包含 `request_sha256`,它绑定 context 与完整节点参数。标准输出必须只含一条 JSON,字段如下:
```json
{"key":"jp-new-a","phase":"stage","request_sha256":"从node文件原样读取","status":"absent","retry_safe":true}
```
| status | 含义与调度行为 |
| --- | --- |
| ready | 当前现场满足本阶段所有要求,可以继续;不能只依据上次成功日志 |
| absent | 已权威确认本阶段尚未执行;只有同时明确 `retry_safe: true` 才能调用 apply |
| waiting | 外部依赖未就绪或需人工处理,本节点停下;不占工作线程反复轮询 |
| unknown | 现场无法确认,不写入,不自动重试 apply |
| conflict | 资源占用、参数漂移或归属冲突;阻止该节点,通知其余任务停止继续写入 |
apply 返回成功后仍必须 probe 确认。退出码非零、输出损坏、身份不符、超时或 Ctrl-C 后,远端结果一律按未知处理;重跑仍从现场读取。apply 命令必须支持安全处理部分完成的现场;不能把“有进程/有目录但未完成”随意返回 absent。stage 的现有对象没有匹配 manifest、或事务结果与 manifest 不一致时应 conflict/unknown,人工核实,不能自动创建第二套对象。
命令适配器的占位符仅支持 plan/node/state_dir/key/phase/action;每个元素作为独立 argv 传递,不解释 shell。操作脚本需要参数时自行读取受保护的 JSON。禁止在 argv/标准输出加入凭据;密码与令牌由 handler 按 CoDocs 私有引用按需读取,SSH 保持经白色机跳转;本地 HTTP 继续使用明确登记的反代。不要为了批量化改变既有访问规则。
## 查重、锁与断点恢复
计划内拒绝重复的部署 key、展示名、国家编号、VPS ID、实际落地 IP、TLS 域名、中转入口域名+端口与网关 SOCKS 端口。展示名格式与旗帜/国家/两位编号必须匹配。**这些是本地候选查重,已在线资源的查重必须由 preflight/stage 实际查询。**同一物理地址使用不同 VPS 别名也会被拦下。
状态目录是唯一协调入口:SQLite 事务原子预留身份,同目录 fcntl 锁协调进程。VPS/IP/TLS 与中转入口预留跨面板;名称/编号/key 按面板应用划分;SOCKS 按网关划分。相同节点参数可重跑;不同参数不能借用同一批次或占用已有预留。预留不自动释放,不提供强制接管命令。
所有参与聊天/进程必须使用**同一台协调机、同一个状态目录**。另建账本、复制状态目录、在另一台主机启动独立协调器都不能获得跨机锁保证。对共享面板的非批量手工操作也必须协调暂停,不能指望本地锁约束后台人工修改。每次写入前的远端归属/参数检查仍必要。
中断后保留原计划、账本、远端 manifest 和操作日志。先用 status 查看,再以同一计划、同一状态目录运行相同 run 命令;不要删除账本、改 batch_id 或清除 manifest 来“解除报错”。冲突需先核对原始请求与现场,不能通过修改参数静默接管。未知结果宁可停住;恢复不保证所有断点都能无人干预推进。
SIGINT/SIGTERM 会停止派发后续写入,终止当前本地适配器进程组;**这不保证已发出的远端命令停止,也不自动回滚**。其他已开始的节点可能已完成步骤。已完成节点保留成果;未完成节点留在其最后安全阶段。需要回退时按执行手册逐字段预览与回退,不做全批删除。
## 多智能体协作流程
用户于 2026-10-16 明确授权:以后执行批量节点接入,默认启动一个子助手,按本节分工执行,不再重复询问是否需要助手。触发入口见项目根 [AGENTS.md](../../AGENTS.md)。这里的“启动”由主协调者使用子助手工具完成;`batch.py` 本身不调用模型或创建助手。明确要求批量,或同批接入两个及以上节点时触发;仅讨论、修改脚本/文档、单节点接入不自动触发。用户当次明确调整角色或数量时,以最新要求为准。
| 角色 | 固定职责 | 写入与协作边界 |
| --- | --- | --- |
| 主协调者 | 明确本批范围,统一参数、展示编号与端口,固定计划,持有唯一账本,安排生产写入并汇总结果 | 只有主协调者启动本批正式调度和共享资源写入;不以助手已派发代替工作完成 |
| 核查验收助手 | 接入前核查候选服务器身份、既有节点/机器、端口、中转、DNS 和归属冲突;独立审查脚本与参数、测试并发/查重/恢复;开放前后核对 QA、正式订阅和旧资源基线 | 分阶段完成两类职责,返回每节点结论、证据及缺口;不自行分配最终编号、创建节点、改后台或另起调度。真实联网验收的对象、端口和时机由主协调者统一安排 |
执行顺序:
1. 主协调者在生产写入前启动/复用一个核查验收助手,给出当前任务范围、必要资料入口、受保护资源、分阶段输出和完成标准。当前批次已存在对应子助手时继续使用,不在每个节点或每次续跑时重新创建。
2. 助手分阶段核查资源与审查方案;主协调者并行准备参数与调度。助手完成冲突核查和相关隔离测试/审查后,主协调者解决影响写入的缺口,固定同一参数单,再运行全批 preflight。检查发现变更时重新核对受影响项,不把旧回执沿用到不同参数。
3. 独立节点由脚本按 `--jobs` 并发,面板/DNS/中转按共享锁执行。正式开放前,核查验收助手核对本节点隔离 QA、严格 TLS、实际出口与上报证据;主协调者确认后执行 publish。助手不得另起同批调度、重复 stage、共用浏览器会话切换账户或绕开账本。
4. 正式开放后,核查验收助手检查正式订阅、QA 禁用/拒绝、旧资源基线并报告通过或具体缺口。主协调者核实后逐节点报告完成、等待或需用户操作;批次全部完成必须有对应实际验收,不能以子助手结束作为完成证明。
5. 子助手不可用、并发名额不足或失败时,主协调者明确报告,复用/恢复角色或等待名额;不自动追加更多助手、跳过必需角色或把未知写成通过。暂停和停止指令必须传达给子助手,并停止派发后续写入;已发出的远端操作仍按断点恢复规则回读。
主协调者与一个子助手分别消耗处理其输入和输出所需的 token,总量不是固定倍数。主协调者默认仅传必要背景、资料路径、节点参数与任务边界,避免复制整段历史;每项检查明确归属,汇报用结论、证据位置与缺口,不反复粘贴完整日志。独立验收保留必要的交叉核对,不为节省 token 省略验收。没有固定 token 配额或价格承诺。
该常驻授权仅允许启动当前任务的子助手;不自动新建/分叉侧边栏普通聊天,向已有聊天派消息仍需用户明确授权。节点并发数由脚本控制,助手数保持一个;不会为十个节点启动十个助手。项目规则不扩大实际生产变更、凭据、访问路线或后台自动化授权。
## 本地验证
本地测试:
```sh
python3 -m unittest discover -s tests/node-onboarding -p 'test_batch.py' -v
```
本次 11 项测试通过,覆盖:plan/status 无副作用,计划内重复与命名约束,全批前检屏障,独立节点重叠执行/面板串行,完整重跑不重复写,模拟写成后回包丢失,SIGTERM 中断后续跑,跨批预留/参数漂移,两进程竞争、错误身份回执、命令摘要变更及配置完整的适配器流程。测试没有验证生产命令的正确性、DNS/中转 API、真实 SSH/网络、现场负载或十节点耗时。生产批次的候选参数、命令 profile 与现场验收须在具体获授权的接入任务中另行核实。
2026-10-17追加面板隔离测试:`php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php`,9 tests / 65 assertions通过。真实Eloquent与SQLite内存库,无.env/Redis/队列/网络;覆盖append保护范围、QA禁用、幂等、事务失败/通知失败恢复和冲突拒绝。NodeSync为测试替身,生产通知与用户下发仍需现场验收。

View File

@ -1,5 +1,7 @@
# 节点与订阅网关接入
后续接入的参数与命令唯一入口:[节点接入执行手册](NODE_RUNBOOK.md)。首次初始化与追加节点分别执行,不自动改订阅地址、套餐或用户关联。
首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。
## 2026-10-16 日本节点接入
@ -23,11 +25,19 @@
订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。
流量:客户端 → haiwi.lshan.eu.org:47101 → node-us01 64.44.115.18:24443 → 目标网络。AnyTLS 使用 us01.deeplink-subscribe.com 的有效证书,订阅要求严格校验。普通 Cloudflare 橙云只用于订阅 HTTP,不用于原生 AnyTLS 流量。
流量链路按项目现有接入记录分列(2026-10-11/16历史现场结果,本次未联网复验):
| 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 |
| --- | --- | --- | --- | --- | --- |
| us01 | node-us01 | haiwi.lshan.eu.org:47101 | 64.44.115.18:24443 | us01.deeplink-subscribe.com | 2026-10-11 首节点验收 |
| jp02 | node-jp02 | haiwi.lshan.eu.org:47202 | 193.111.31.52:24443 | jp02.deeplink-subscribe.com | 本文2026-10-16日本节点接入 |
客户端 → 对应中转入口 → 对应落地 → 目标网络。两条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。
| 资源 | 本次新增 | 必须保留的原有资源 |
| --- | --- | --- |
| node-us01 | dpl-node,TCP 24443,机器/节点 ID 1 | XrayR,TCP 443 |
| node-us01 | dpl-node,TCP 24443,机器/节点 ID 1(2026-10-11) | XrayR,TCP 443 |
| node-jp02 | dpl-node,TCP 24443,机器/节点 ID 2(2026-10-16) | XrayR,TCP 443;原中转 46202 → 443 |
| 中转 | dpl-us01-anytls,47101 → 24443 | us01,46101 → 443;其他规则 |
| gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin |
| 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 |
@ -90,70 +100,20 @@ VPS 代号、数据库节点/机器 ID、部署 key 与展示编号各自独立
## 新节点参数单
| 参数 | 首次实例 | 后续填写规则 |
| --- | --- | --- |
| key | us01 | 唯一、稳定的部署标识;不用于推导展示序号 |
| name | 🇺🇸 美国 US - 01 | 按[节点命名与编号](#节点命名与编号)从生产清单分配,按国家从01递增,不沿用VPS序号 |
| server | node-us01 | 从 CoDocs 项目关系及连接资料确认 |
| listen-port | 24443 | 当场核对 TCP/UDP 和防火墙,不复用过期判断 |
| relay-host / relay-port | haiwi.lshan.eu.org / 47101 | 新增规则,不改原规则 |
| tls-domain | us01.deeplink-subscribe.com | DNS only,有效证书 |
| group / plans | 首次隔离组;套餐 1、6、7、8 | 当前真实 ID 和授权集合 |
| backup | /root/codex-backups/dpl/node-onboarding-20261011-us01 | 每节点独立时间/key 目录 |
参数、冲突检查与模式选择统一见[执行手册](NODE_RUNBOOK.md#统一参数单与冲突检查),不沿用首节点示例作为后续默认值。命名仍以本文前一节为准。
## 重复执行步骤
1. 读取根 AGENTS、维护入口和本文。经 CoDocs 白色机中转访问服务器;本地网页使用指定访问域名。记录原服务 PID/启动时间/端口、中转规则、Docker ID/启动时间、原订阅结果;审计资源、防火墙、所有证书钩子。受保护备份本次将修改的配置和 Git 差异,不输出私密内容。
2. 非敏感脚本和模板先 Git 提交/推送,再更新服务器到明确提交,保留生产原有差异。无法直接拉取时可传输已推送提交的 Git bundle 后 fetch/fast-forward,不能用压缩包覆盖。scripts/node-onboarding 可 subtree split 为独立运维仓库;面板 .env 不送到节点。
3. 在生产应用根目录按参数单运行 stage.php,创建机器、AnyTLS 节点、隔离组和 100MB/一天的临时用户。节点虽启用显示,正式用户没有隔离组权限。相同 manifest 重跑只报告已创建;同名资源存在却缺少 manifest 时停止,不自动接管。
按[统一接入顺序](NODE_RUNBOOK.md#统一接入顺序)执行;先区分首次 initialize 与已有组 append。新本地promote.php必须显式mode;append只修改新节点及本次QA,默认预览、apply才写入。该版本尚未测试或发布,线上旧脚本不能直接按新语义调用。
```sh
php scripts/node-onboarding/stage.php \
--key=us01 --name='🇺🇸 美国 US - 01' \
--relay-host=haiwi.lshan.eu.org --relay-port=47101 \
--listen-port=24443 --tls-domain=us01.deeplink-subscribe.com \
--backup=/root/codex-backups/dpl/node-onboarding-20261011-us01
```
4. 将受保护备份的 node-config.yml、node-secret.env 经白机安装到节点 /etc/dpl-node/config.yml、secret.env,均 600;运维仓库 checkout 明确 Git 版本到 /opt/dpl-node/provision。首次安装使用下面命令。脚本检查参数、配置、端口、已有后端及 XrayR 状态,校验官方二进制并安装独立 unit,不自动启动。已有 dpl-node 时不能重跑覆盖;升级须单独复核。
```sh
bash /opt/dpl-node/provision/install-node.sh \
--listen-port=24443 --tls-domain=us01.deeplink-subscribe.com
```
5. 配置 DNS only、有效证书并审计/安装仅匹配新域名的续期钩子,再启用 dpl-node。在中转后台只新增 dpl-<key>-anytls,核对保存后的入口/目标和诊断结果。本次未启用收发 Proxy Protocol,以后启用前须同时确认入口和后端支持。
6. 后续 dpl 节点复用 c2 及当前网关,不再次更换 NS。另一个机场需要不同上游时才新增独立实例;不得改已有上游、运行原项目 setup/update/down 或复用旧卷。
7. 在网关创建本次验收目录(700),以 600 保存 stage.php 生成的 QA 私密文件和实际订阅;准备官方客户端并校验官方发布摘要。首轮已校验的 Linux 1.14.3 客户端保留于 /opt/dpl-gateway/acceptance/,下述命令可复用它。拉取临时用户完整订阅到私密文件;sing-box 使用正常版本 UA(如 sing-box/1.14.3),不要使用覆盖版本识别的裸 flag=sing-box。核验名称、入口、SNI、严格 TLS、缓存头和真实 IP;先官方客户端 check,再完整配置实际联网,核对出口、DNS、上下行计费与上报。
```sh
python3 /opt/dpl-gateway/provision/verify-node.py \
--qa /var/lib/dpl-sgw/qa/qa.json \
--binary /opt/dpl-gateway/acceptance/sing-box-1.14.3-linux-amd64/sing-box \
--subscription /var/lib/dpl-sgw/qa/subscription.json \
--expected-exit 64.44.115.18
```
helper 仅替换 TUN 等入站为临时本机 SOCKS,保留订阅 DNS、分流及出站,退出时停止客户端;失败日志留在同一私密目录。这不等于手机 GUI、系统 TUN、多地区、超额或多设备并发验收。某环境到入口不通时分段诊断并换环境核验,HTTP 200 不能替代联网。
8. 上述验收通过后,在生产根目录运行 readiness.php 和 promote.php,显式指定当前套餐集合。已有正式组时额外传 --group=<ID>。只关联所选尚无组的套餐及这些套餐现有无组用户,不改价格、周期、流量或设备限制。
```sh
php scripts/node-onboarding/readiness.php \
--backup=/root/codex-backups/dpl/node-onboarding-20261011-us01
php scripts/node-onboarding/promote.php \
--backup=/root/codex-backups/dpl/node-onboarding-20261011-us01 \
--plans=1,6,7,8
```
首次隔离组改为基础节点、订阅域名设置为 c2,以后复用正式组。脚本要求节点在线且临时用户已有流量,保存 before-promotion.json,最后禁用临时用户并写 promoted.json。清理本次客户端和私密传输副本,保留服务器备份、禁用用户与审计记录。
9. 回归原机场服务/规则/订阅,实际核对正式组权限、面板节点及订阅域名保存,确认测试用户禁用且不再下发。更新本文及 STATUS,分别记录提交、发布、真实验收及未完成项。
依赖检查在创建面板资源前完成;证书演练、完整订阅验收、精确回退与现有脚本限制均在执行手册维护,不在多处复制命令。本地新增面板正式开放/精确回退与多节点验证入口,均尚未测试;细节与奶油接手测试清单见执行手册。远程服务/DNS/中转仍按专项恢复。
数据库默认 sing-box 模板已迁移新 DNS/路由格式,保留原分流和选择逻辑;现代本地 DoH 使用默认 dialer,不绕到空 direct 出站。仓库原默认资源和其他机场模板不改。apply-template.php 仅接受原默认模板,或与显式 expected-current-sha256 完全匹配的旧版本,逐次备份后只更新该模板/缓存。用户定制或未知模板须先审查,不能强行覆盖。
## 回退与排障
节点变更先按[精确回退入口与证据契约](NODE_RUNBOOK.md#精确回退入口与证据契约)确定本次对象及 before/after。下列专项恢复说明继续适用;历史 before-promotion 不保证包含所有 after 值,不能盲目恢复。
- 单节点:停用本次节点/新增中转,停止新 dpl-node;保留 XrayR、原规则和审计数据。
- 正式关联:依据 before-promotion.json 精确恢复本次套餐/用户组及订阅域名,先确认当前值仍是本次写入值,避免覆盖后续业务。不恢复整库。
- 模板:从对应私密模板备份恢复该条内容并清理模板缓存,不替换整个设置表。

View File

@ -0,0 +1,254 @@
# DPL 节点接入执行手册
入口:[节点关系与历史验收](NODE_ONBOARDING.md)。本手册定义后续授权任务的步骤。2026-10-16 已补齐本地脚本草稿;用户要求测试交给奶油,本轮未运行脚本、语法检查或功能测试,未触及线上。**新脚本必须先在隔离环境测试,再按Git流程审阅发布;本地存在不代表线上已更新。**已有节点展示命名按原入口的[节点命名与编号](NODE_ONBOARDING.md#节点命名与编号),此处不另建规则。
## 先选择变更模式
| 模式 | 适用情形 | 允许修改的范围 |
| --- | --- | --- |
| initialize | 首次建立 DPL 网关、基础节点组及明确授权的套餐可见范围 | 本次参数单与授权清单逐项列明;订阅地址、套餐/用户关联必须分别确认 |
| append | 已有网关/正式节点组上增加节点 | 新机器、新节点、新独立中转、节点 DNS/证书和 QA 隔离资源;验收后只把新节点加入指定已有组并禁用 QA |
默认判断依据是现有资源与目标,不把“新节点”当作首次初始化。append 保持现有订阅地址、网关、套餐全部字段、正式用户分组及旧节点不变。扩展套餐覆盖另列变更任务。未知已有组/节点状态时先解决该依赖,不能通过重跑初始化猜测。
## 统一参数单与冲突检查
在本次服务器受保护任务目录保存参数,目录 700、文件 600;非秘密参数也不进公开日志。以下是待填写模板,没有可直接用于新节点的默认值。文件只使用赋值,不含执行命令;人工审阅后才加载。
```sh
MODE='' # initialize 或 append
KEY='' # 稳定部署 key
NODE_NAME='' # 按既有命名规则确定;与 VPS 代号独立
VPS_ID='' # CoDocs 稳定 ID
NODE_ADDRESS='' # 实际落地地址,按私有资源读取
LISTEN_PORT=''
RELAY_HOST=''
RELAY_PORT=''
TLS_DOMAIN=''
FORMAL_GROUP_ID='' # append 必须填已有正式组
PLAN_IDS='' # 仅 initialize 或另有明确套餐变更授权时填
BACKUP='' # 绝对路径,每次任务独立,不复用其他节点目录
QA_DIR='' # 网关本次独立验收目录
CLIENT_BINARY='' # 已校验摘要的官方客户端绝对路径
EXPECTED_EXIT=''
EXPECTED_COUNTRY=''
LOCAL_SOCKS_PORT='' # 网关上本次隔离客户端空闲端口
APP_ROOT='' # 面板应用路径,从项目档案核实
PROVISION_ROOT='' # 面板上本次已确认脚本路径
NODE_PROVISION='' # 节点上已确认的运维 Git 路径
GIT_COMMIT='' # 已推送且经过审阅的明确提交
PANEL_URL='' # 节点回连面板的明确HTTPS地址
SUBSCRIBE_URL='' # 仅initialize明确修改的订阅地址
QA_REPORT='' # 本次隔离QA的私有实时验收报告
FORMAL_EXPECTATIONS='' # 正式多节点预期的私有JSON文件
FORMAL_REPORT='' # 正式多节点验证报告
```
填写后逐项核对:key/展示名、机器名、面板 ID、VPS、落地监听端口、中转入口的域名+端口、源站目标、SNI、正式组、DNS 现值、备份路径和 QA 端口。相同 key 有 manifest 时,只回读已有对象并确认参数完全一致;新 stage.php 重跑要求 manifest 记录的完整参数一致,并核对节点、机器和中转端点;旧 manifest 没有 parameters 时停止,不能自动采纳。相同资源无 manifest、冲突端口或未授权的覆盖均停止,不自动接管。
机密字段单独沿用 stage.php 生成的私密文件;不把机器令牌、QA UUID、订阅令牌写进上述参数单、文档或命令行。参数按角色分发,不把面板 .env 复制到节点。
可在审阅参数文件后运行以下本地字段检查;它不验证线上冲突或资源归属:
```sh
set -eu
: "${MODE:?}" "${KEY:?}" "${NODE_NAME:?}" "${VPS_ID:?}" "${NODE_ADDRESS:?}"
: "${LISTEN_PORT:?}" "${RELAY_HOST:?}" "${RELAY_PORT:?}" "${TLS_DOMAIN:?}"
: "${BACKUP:?}" "${QA_DIR:?}" "${CLIENT_BINARY:?}" "${EXPECTED_EXIT:?}" "${EXPECTED_COUNTRY:?}"
: "${LOCAL_SOCKS_PORT:?}" "${APP_ROOT:?}" "${PROVISION_ROOT:?}" "${NODE_PROVISION:?}" "${GIT_COMMIT:?}"
: "${PANEL_URL:?}" "${QA_REPORT:?}"
case "$MODE" in
append) : "${FORMAL_GROUP_ID:?}"; test -z "$PLAN_IDS" ;;
initialize) : "${PLAN_IDS:?}" "${SUBSCRIBE_URL:?}" ;;
*) exit 1 ;;
esac
```
## 创建面板记录之前完成前置检查
只在具体接入任务授权后,通过 CoDocs 白色机跳板及登记的后台反代执行以下检查。本次文档维护没有执行这些动作。
| 位置 | 必须确认 | 未通过时 |
| --- | --- | --- |
| 面板 | Git 提交、当前组和节点清单、现有订阅设置、套餐/用户分组快照、应用运行环境、脚本实际版本 | 先补证据与备份,不创建机器/节点/QA 记录 |
| 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 |
| 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 |
| 证书 | 已有 renewal 配置、pre/post/deploy hooks、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 |
| 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 |
| 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 |
| 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 |
历史 jp02 接入曾因 Debian11 源包 404 临时使用 Debian 官方 HTTPS 归档安装 Git;这是该次处理记录,不是以后默认改源或安装命令。先核对目标当下发行版与来源,不在本手册固化过期包 URL。
前置完成后,保存本次允许改变的对象、每个对象的 before 值、预期 after 值和精确回退依据。不要打印整库快照或秘密。
## 本地脚本能力与边界(尚未测试/发布)
| 工具 | 新行为 | 边界 |
| --- | --- | --- |
| stage.php | 明确panel-url、完整参数/对象重跑核对、端点冲突检查;受保护manifest可恢复传输文件 | 旧manifest不自动采纳;依赖与实际资源前检仍由接入任务完成 |
| promote.php | 强制mode;默认只预览;append只写新节点group_ids及QA banned;initialize显式plans/subscribe-url/可选group-name | 真正写入需apply及15分钟内匹配该QA、端点与SNI的实时验证报告;不根据节点名推导身份 |
| readiness.php | 只统计显式plans,缺省不统计套餐;可检查给定group的冲突套餐 | 只读快照,不代替真实订阅/计费/界面验收 |
| verify-node.py | 默认离线结构核对;显式run才启动客户端/联网;可按预期逐一验证多节点、SNI、密码、出口IP及国家 | 正式多节点需要完整预期清单;只证明实际执行的探针,不代替业务/手机/TUN/全路由验收 |
| rollback.php | 默认预览;基于新promotion-journal逐字段比较after再恢复before;保持QA禁用,隔离本次节点 | 冲突整次停止,不覆盖后续变化;只覆盖面板变更,不停止远程服务或修改DNS/中转;旧before-promotion需专项人工核对 |
| operations.php | 私有文件锁、原子写日志、事务内记录写前意图、提交后完成标记;中断后按before/after识别已应用或冲突 | 文件与数据库不是同一个事务;状态未知需依据日志恢复,不盲目重新stage |
| install-node.sh / renew-node.sh | 沿用已登记的独立安装与专用续期hook | 本次未更改,不代表前置工具/安装源/实际timer已经验证 |
新脚本不修改节点展示名、不分配编号。操作日志不包含密码/UUID/机器令牌;脚本不在错误输出中打印数据库异常全文或客户端配置。首次运行前必须由奶油进行隔离测试,特别核对事务、缓存/节点通知、重复执行和崩溃恢复。
## 统一接入顺序
1. 完成参数单、模式确认、前检和恢复点;非秘密运维代码先提交/推送,再更新目标至明确 Git 提交。已有线上差异保留。拉取受限时只能传输已推送提交的 Git bundle 后 fetch/fast-forward,不用归档覆盖。
2. 在确认的面板应用根目录,用下列统一参数创建隔离资源;创建后把实际 node_id、machine_id、group_id、test_user_id 与 key/VPS 对应写回本次任务 manifest,不能从服务器名推导面板 ID。
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/stage.php" \
--key="$KEY" --name="$NODE_NAME" \
--relay-host="$RELAY_HOST" --relay-port="$RELAY_PORT" \
--listen-port="$LISTEN_PORT" --tls-domain="$TLS_DOMAIN" \
--panel-url="$PANEL_URL" --backup="$BACKUP"
```
3. 按既有受保护传输方式安装 node-config.yml 和 node-secret.env 至节点的独立目录,文件 600、父目录 700;实际运维 Git 路径使用 NODE_PROVISION,保留二进制固定版本/摘要核验。节点未安装本服务时才运行:
```sh
bash "$NODE_PROVISION/install-node.sh" \
--listen-port="$LISTEN_PORT" --tls-domain="$TLS_DOMAIN"
```
4. 配置本次 DNS only、证书和隔离续期策略,再启动新服务;中转只新增本次规则,保存后回读入口、目标及诊断结果。Proxy Protocol 默认保持本次参数单记录,不能单端打开。已有网关按 append 复用,不重建网关、NS、旧卷或订阅配置。
5. 按下节执行隔离验收;不因 service active、内核磁盘无 JSON 或 HTTP 200 单独判断成功/故障。Xboard-Node 可使用内存配置,须按实际版本、启动日志、证书和请求链路判断。
6. 验收通过后按下方“面板变更命令”预览,再对同一组参数明确apply。append只改新节点与QA;initialize才允许修改明确列出的套餐/用户分组及订阅地址。保存前后日志并回读。未通过隔离测试的新版本不能直接带到生产执行。
7. 回读正式订阅与原资源,检查 QA 禁用、订阅拒绝、下发集合无 QA,确认旧节点、旧中转、套餐/用户和订阅设置保持预期。同步 CoDocs 对象关系、节点登记及 STATUS;提交/推送/部署/现场验收分别报告。
## 证书钩子与演练
新证书只挂本项目专用 hook。历史已验证的调用方式为 `/bin/sh /opt/dpl-node/provision/renew-node.sh`;不能假定脚本有执行权限后直接执行。路径实际不同时按当前运维检出路径填写。renew-node.sh 依据 `/etc/dpl-node/certificate-domain` 和 RENEWED_LINEAGE 精确匹配,只操作 dpl-node。
目标实际 Certbot 命令路径记为 CERTBOT_BIN;先审计目录钩子、renewal 配置及 Snap/apt timer 的调用方式。经确认的演练入口:
```sh
: "${CERTBOT_BIN:?}"
"$CERTBOT_BIN" renew --cert-name "$TLS_DOMAIN" --dry-run \
--no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks
```
`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。
续期完成条件包括:证书配置正确、调度 enabled/下一次触发可见、调度参数确实覆盖新证书及专用 hook、dry-run 退出状态和旧服务保护证据齐全。证书到期时间引用证书/域名对应记录,不把一次演练日期当作长期验收。
## 统一验收入口
每次在 QA_DIR 保存 700/600 的 QA 私密文件与完整实际订阅;文件名统一为 qa.json、subscription.json、验收结果和受保护日志,参数与 stage manifest 对照,不使用首节点默认路径。本地浏览器、服务器请求均按各自授权路线执行。
在已授权的网关环境,先用本次参数里的官方客户端检查完整配置,再运行完整隔离订阅:
```sh
"$CLIENT_BINARY" check -c "$QA_DIR/subscription.json"
python3 /opt/dpl-gateway/provision/verify-node.py \
--qa "$QA_DIR/qa.json" --subscription "$QA_DIR/subscription.json" \
--binary "$CLIENT_BINARY" --port "$LOCAL_SOCKS_PORT" \
--expected-exit "$EXPECTED_EXIT" --expected-country "$EXPECTED_COUNTRY" \
--report "$QA_REPORT" --run
```
上面脚本路径是既有网关检出约定,执行前核对实际 Git 版本与路径,若不同则使用本次确认路径,不复制临时代码替代。QA可按host/port唯一匹配本次节点,冲突时显式target-tag;正式多节点使用下方expectations。验证保留DNS/分流及全部出站,替换临时SOCKS入站,并仅为api.ipify.org与www.cloudflare.com两个诊断目的地添加指定出站规则,从而逐节点验证;不是原订阅所有路由均已验收。
| 验收层 | 必须保留的结果 |
| --- | --- |
| 参数一致 | 订阅包含本次入口/端口/SNI、严格 TLS 开启;机器/节点与 VPS 对应正确 |
| 协议/联网 | 官方客户端配置检查与完整启动结果、严格 TLS、目标 HTTPS、出口 IP 与 EXPECTED_EXIT 一致;脚本强制比对 EXPECTED_COUNTRY;仅结构核对不产生live_verified报告 |
| 订阅 | 有效用户标准/短路径按本次范围检查,JSON/gzip、private/no-store、无 HIT/Age/挑战;不要在日志保存令牌 |
| 上报 | readiness.php回读节点在线、last_check/last_push及QA上下行;按显式plans/group统计,缺省不选任何套餐 |
| 正式开放 | 正式有效用户订阅与完整expectations逐一对应,并逐节点验证;不把单节点QA结果扩大成全部可用 |
| QA 清理 | QA banned、其订阅拒绝、实际下发用户集合无 QA;客户端退出、临时本地秘密副本清理,服务器审计文件保留 |
| 回归 | 原节点/中转、套餐字段/用户组、订阅 URL、网关容器 ID/启动时间/重启计数与 before 快照对照 |
| 浏览器 | 正确反代入口,管理列表与保存结果回读;只查看后取消不算提交验收 |
面板回读命令入口如下;它只覆盖该脚本实际返回的节点/QA 字段,其他行须另留证据:
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/readiness.php" --backup="$BACKUP" --group="$FORMAL_GROUP_ID"
```
GUI/手机、TUN、多地区、并发及用户明确跳过的测试分别标为未执行或跳过。未确认结果不补成通过;统一表格不替代真实执行。
### 正式多节点验证
在私有文件 FORMAL_EXPECTATIONS 保存每个AnyTLS出站的明确预期,完整覆盖订阅中的全部AnyTLS标签。每条结构:`tag`、`host`、整数`port`、`sni`、`uuid`、`expected_exit`、`expected_country`;uuid是该正式用户的实际密码,不能复制进普通参数单或文档。标签必须与订阅精确一致;不重命名节点。
```sh
python3 /opt/dpl-gateway/provision/verify-node.py \
--subscription "$QA_DIR/formal-subscription.json" \
--expectations "$FORMAL_EXPECTATIONS" --binary "$CLIENT_BINARY" \
--port "$LOCAL_SOCKS_PORT" --report "$FORMAL_REPORT" --run
```
不加run时只读取私有JSON并核对结构,不启动客户端、不发网络请求;run每次会先将旧报告失效,避免失败后误用旧成功结果。实时报告记录具体结果与时间,面板正式开放仅接受匹配manifest QA及端点、15分钟内的实时QA报告,正式多节点报告不能代替QA报告。
### 面板变更命令
以下命令默认只预览数据库字段变化;确认输出范围后,对同一命令追加 `--verification="$QA_REPORT" --apply` 才写入。append禁止传plans、subscribe-url和group-name;它不会修改现有套餐/正式用户/订阅地址。initialize对应已有套餐中的无组用户关联也属于实际写入范围,必须先看预览。
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/promote.php" --backup="$BACKUP" --mode=append \
--group="$FORMAL_GROUP_ID"
```
首次初始化另用以下命令,按任务需要显式传group/group-name;没有这些参数时不自动改组名:
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/promote.php" --backup="$BACKUP" --mode=initialize \
--plans="$PLAN_IDS" --subscribe-url="$SUBSCRIBE_URL"
```
promotion-journal.json记录允许写入字段、before/after与manifest。相同日志/参数重跑会比较实际字段,已应用则只确认;参数变化、已启动回退、旧promoted.json无新日志或对象冲突时停止。prepared可能意味着数据库未提交、已提交但后续通知/缓存/落盘失败;只有完整before或完整after匹配才恢复,中间/未知状态先人工处理。不要删除日志重新执行。
## 精确回退入口与证据契约
触发失败先停止正式开放,隔离新节点。回退按本次 manifest 指定对象执行;不恢复整库、不删除原卷、不停止旧节点。执行前取回当前值,只有当前值仍等于本次 after 才恢复 before;存在后续变化时停止本次自动回退,不写任何字段;专项人工恢复另行核对。
| 对象 | 必需恢复证据 | 动作及回读 |
| --- | --- | --- |
| append 新节点正式组 | 该 node_id 的 before/after group_ids、enabled/show 及实际变动字段 | 新rollback.php按日志恢复本次字段并禁用/隐藏节点;不动已有套餐/用户/订阅地址 |
| initialize 正式关联 | 新promotion-journal.json、manifest.json;旧before-promotion/promoted仅用于历史专项回退 | 按 ID 逐条恢复本次变动的套餐/用户组、节点组、组名、订阅 URL;缺 after 不盲目回填,以实际变更证据核对 |
| QA | test_user_id 与实际禁用状态 | 始终保持本次测试账号禁用、订阅拒绝、不下发;不因回退恢复 QA 活跃 |
| 中转 | 新增规则的真实 ID、入口和目标 before/after | 只停用本次新增规则,再回读;原规则保持 |
| 节点服务 | 新服务/unit/配置及安装前状态、原服务 PID/启动时间 | 在已授权节点上执行下方停止入口,只停止新 dpl-node,回查旧服务;不删证书/审计目录 |
| DNS/证书续期 | 本次记录 ID/旧值/新值、renewal/hook/timer 原文件及权限 | 仅撤回本次增改;已共用证书/调度不能直接删除,恢复后核对下一次调度及原服务 |
| 网关/模板/CF | 对应专项恢复点 | append 不应修改;initialize 或专项变更按原[恢复专题](NODE_ONBOARDING.md#回退与排障)逐项执行 |
节点上的停止入口(确认本服务确为本次新增后):
```sh
systemctl stop dpl-node
systemctl is-active dpl-node || test "$?" -eq 3
```
新日志的面板回退统一入口(默认预览,加apply才执行):
```sh
cd "$APP_ROOT"
php "$PROVISION_ROOT/rollback.php" --backup="$BACKUP"
```
回退逐字段核对当前值仍等于promotion记录的after,任何冲突整体停止;写回本次before、保持QA禁用,并禁用/隐藏本次节点。rollback-journal.json保存回退本身的意图与完成状态,重复执行也需匹配完整结果。旧日志不能自动补造after或升级成新日志。DNS、中转、远程服务、证书、网关/CF属于其他对象,仍按对应专项步骤和恢复证据处理;该面板脚本不包含跨服务器/服务商变更。
## 本次交付与交接测试范围
2026-10-16,本地已编写mode隔离的正式开放、参数化回读、多节点验证及面板字段回退,并补stage参数与重跑保护。**按用户要求尚未测试,包括未运行php -l、Python结构/联网验证或任何应用命令;未提交/推送/部署。** 原有运行结果仍引用接入历史,不替代新代码验收。
奶油应在全新SQLite内存库/隔离应用容器中测试,禁止加载项目本地真实.env、默认数据库/Redis/队列或使用正式凭据:
1. append仅改变本次node.group_ids、QA.banned,现有订阅设置/全部套餐/正式用户/旧节点逐字段不变;拒绝append夹带plans/url/group-name。
2. initialize只修改明确套餐及其无组用户、指定订阅地址/可选组名;拒绝不存在ID、其他组冲突,验证原null订阅设置的创建及回退。
3. 默认预览无数据库写入;apply要求近期真实QA报告且QA身份、端点/SNI正确;结构报告、过期/错误QA报告不能通过。
4. 回读仅使用本次显式plans;无plans时不查询/统计隐含套餐集合。
5. 应用/回退幂等;数据库提交前失败、提交后通知/缓存/日志失败、prepared+完整before/after与混合冲突,不能重复创建或覆盖后续变化;QA始终不复活。
6. 多节点JSON用虚构身份;缺失/重复标签、端点/SNI/密码/严格TLS/国家/IP错误均失败。模拟子进程和传输,不发公网请求;默认无run绝不启动进程/开socket,失败后不能留下旧成功报告。
7. stage相同完整参数恢复缺失传输文件;参数变化、旧manifest、现有同名/同中转端点资源或已promote时拒绝接管;检查所有秘密文件权限。
8. 通过隔离测试之后才另行申请或接受具体线上发布/验收任务,本次授权不包含这些动作。节点命名仍由原维护任务处理,不因测试改名或分配编号。

View File

@ -4,7 +4,7 @@
## 接手顺序
1. 阅读全局及项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。
1. 按全局规则先读 CoDocs 项目总览、首次会话的持续维护协议与 dpl 档案,再读项目根目录 AGENTS.md、当前用户要求和本目录说明。用户最新明确边界优先,不扩大本次授权。
2. 确认所在仓库、分支、工作区差异与远端;分别识别用户既有修改、本次任务与其他聊天修改。不要覆盖、清理或混入其他任务。
3. 按 [架构与配置](ARCHITECTURE.md) 沿实际调用链检查问题;按 [业务约束](BUSINESS_RULES.md) 判断影响。
4. 修改后运行与影响范围相关的检查;后台界面必须浏览器验收,包括页面显示、保存、切换、权限。HTTP 200 不代表前端可用。
@ -21,9 +21,13 @@
| TELEGRAM_TICKETS.md | Telegram 工单接入、绑定、权限及故障处理 |
| STATUS.md | 最近验证基线、已知限制与待验收项目 |
| CLOUD_FLARE_AUDIT.md | 两机场 Cloudflare 审查、已部署调优、域名轮换与回退及可选未实施项 |
| NODE_ONBOARDING.md | 共享资源隔离、订阅网关与新增节点流程及待验收状态 |
| NODE_ONBOARDING.md | 两节点链路、命名规则、共享资源与历史验收 |
| NODE_RUNBOOK.md | initialize/append参数、脚本入口、前检/证书/验收与面板回退;新本地代码未测试,含奶油测试清单 |
| NODE_BATCH.md | 批量接入默认一个子助手的分工与关卡;append并发锁/查重/断点恢复、命令协议与测试边界 |
| GATEWAY_ADMIN_PROXY.md | 两网关管理域名、白色机新旧区域反代、独立证书续期、恢复和验收边界 |
这些说明用于辅助 AI 接手,不代表 AI 会在聊天结束后自动持续运行。应用定时任务在服务器执行,AI 维护任务需要会话或明确配置的自动化来启动。
正式维护文档放在 docs/maintenance/,纳入 Git 并随相关代码一起更新。项目根目录 AGENTS.md 是 AI 自动接手入口;运行日志、截图、临时脚本与任务产物放在 codex/output/,继续默认忽略。受保护备份和私密配置按运行环境保存,不提交 Git。
本机共享 wwwroot 权限已于 2026-10-13 收紧;策略、服务身份、限制和恢复入口见 [CoDocs 本地目录权限](/Users/hellcat/Documents/CoDocs/20-基础设施/本地目录权限.md)。仅本机适用,未变更本项目业务或生产权限。

View File

@ -2,6 +2,28 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
## 2026-10-17 三节点批次前检(执行中)
用户授权追加3节点,暂定node-jp03/04/05,展示编号JP - 02/03/04;经用户批准的精确SSH指纹及白色机跳转登录,现场确认root、Debian11、x86_64、JP出口、旧XrayR active、无dpl-node、24443空闲。面板现场只有US - 01与JP - 01,未创建新节点。新增PanelChangeIsolatedTest.php:9 tests / 65 assertions通过;批量进程11 tests重跑通过。面板测试使用真实Eloquent/SQLite内存库与通知替身,不覆盖真实Redis/observer/联网;生产追加尚未完成。
## 2026-10-16 批量接入默认单子助手规则
按用户明确要求,在项目 AGENTS.md 登记批量接入触发规则,在 [批量专题](NODE_BATCH.md#多智能体协作流程) 维护唯一完整流程:主协调者统一参数/编号/端口/账本与写入;按用户为节省 token 的最新要求,只启动一个核查验收助手,分阶段核查候选及冲突、审查脚本、测试恢复并独立验收。规定复用、失败/暂停处理、写入及正式开放关卡、最小上下文与避免重复检查。此次仅更新文档,没有启动新接入任务或工作助手,没有生产变更;先前单个显示测试助手不算本批分工验收。规则本地落盘不代表自动执行或生产验证;尚未提交/推送。
## 2026-10-16 批量调度本地验证
新增 `batch.py`、`command-adapter.py` 与[批量专题](NODE_BATCH.md):append 模式,全批前检屏障,独立 VPS 并发,共享面板/DNS/中转锁,跨批资源预留,固定计划与逐阶段现场回读恢复。11 项独立进程离线测试通过,覆盖并发/串行边界、双进程去重、回包丢失与 SIGTERM 中断恢复、参数冲突、错误回执和命令摘要变更;没有加载 Laravel/.env 或访问网络。CoDocs 离线结构检查通过。该测试结果只覆盖新调度器与命令适配器,不覆盖下节其他聊天新写的 PHP 面板脚本及真实订阅验证器。
当前未提交/推送/部署,未新增线上节点。生产操作通过经过审阅的任务命令 profile 接入;没有内置 DNS/中转后台自动写入或 SSH 登录工具,缺少 handler 的阶段返回等待。真实生产命令、十节点耗时、远端现场及业务验收尚未验证,不能宣称已经支持现网一键接入。
## 2026-10-16 节点脚本本地草稿(未测试/未发布)
已在本地补promote.php的append/initialize模式隔离和默认预览,新增operations.php字段日志及rollback.php面板精确回退;readiness.php按显式套餐回读,verify-node.py支持完整多节点预期及IP/国家断言、默认不联网,stage.php移除固定面板地址并核对完整重跑参数。使用与限制见[NODE_RUNBOOK](NODE_RUNBOOK.md)。用户要求测试交给奶油,本次未运行测试/语法检查或应用命令,未改线上,未提交/推送/部署,不能视为已验证可用版本。节点命名未改;本次代码不更改展示名或分配编号。
## 2026-10-16 节点文档核对(仅离线)
核对两条既有节点链路并补 CoDocs 美国节点关联;新[执行手册](NODE_RUNBOOK.md)统一参数/前检/证书钩子与演练/验收及精确回退,首次初始化与追加模式分开。现有 promote.php 会写固定订阅地址并关联无组套餐/用户,不适合作为追加默认入口;readiness.php 套餐统计硬编码,verify-node.py 只接受单个 AnyTLS 出站,自动追加与统一回退工具仍缺失。文档已明确限制与现有管理界面操作入口,本次未改脚本或线上。以下运行结果均引用原任务证据,不是本轮复验;节点命名继续由原维护任务处理。
## 2026-10-16 日本节点
`node-jp02` 已按既有 Git 运维版本独立部署并加入基础节点组 1,节点/机器 ID 2、AnyTLS、名称 `🇯🇵 日本 JP - 01`。完整隔离订阅经中转实际联网、严格 TLS、日本出口、DNS/路由、上下行上报均通过;证书续期演练及专用 deploy hook 通过。正式有效订阅同时包含美国、日本节点,测试账号禁用且订阅 403。未改价格/套餐限制;原 XrayR 与原中转保留。管理面板实际登录、节点/服务器列表切换、在线状态及已保存参数回读通过;未额外提交编辑表单默认值。参数、Git 版本、备份和边界见 [节点接入记录](NODE_ONBOARDING.md#2026-10-16-日本节点接入)。

View File

@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""DPL append-only batch coordinator. Adapters must probe before and after writes."""
import argparse
import concurrent.futures
import contextlib
import fcntl
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import signal
import sqlite3
import subprocess
import sys
import threading
import time
PHASES = ('stage', 'dns', 'provision', 'relay', 'verify', 'publish', 'audit')
SHARED = {'stage': 'panel', 'dns': 'dns', 'relay': 'relay', 'publish': 'panel'}
STOP = threading.Event()
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(',', ':')).encode()).hexdigest()
def read_json(path):
return json.loads(Path(path).read_text())
def atomic_json(path, value):
path = Path(path)
tmp = path.with_name(path.name + '.tmp-' + str(os.getpid()) + '-' + str(threading.get_ident()))
with tmp.open('x') as f:
os.chmod(tmp, 0o600)
json.dump(value, f, ensure_ascii=False, sort_keys=True)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def validate(plan):
if plan.get('version') != 1 or plan.get('mode') != 'append':
raise ValueError('Only version=1, mode=append is supported')
if not re.fullmatch(r'[a-z0-9-]{1,64}', plan.get('batch_id', '')):
raise ValueError('Invalid batch_id')
context = plan.get('context', {})
required = ('panel_vps', 'gateway_vps', 'jump_vps', 'app_root', 'panel_url',
'subscription_url', 'provision_commit', 'bundle', 'client_binary')
if any(not context.get(k) for k in required):
raise ValueError('Missing explicit context fields')
if not re.fullmatch(r'[0-9a-f]{40}', context['provision_commit']):
raise ValueError('A full reviewed, pushed Git commit is required')
for k in ('panel_url', 'subscription_url'):
if not re.fullmatch(r'https://[a-z0-9.-]+', context[k]):
raise ValueError('HTTPS origin without token/path required: ' + k)
adapter = plan.get('adapter')
if not isinstance(adapter, list) or not adapter or not all(isinstance(x, str) for x in adapter):
raise ValueError('adapter must be an argv array, never a shell string')
if not plan.get('nodes') or not isinstance(plan['nodes'], list):
raise ValueError('A nonempty node list is required')
seen = {k: set() for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain', 'relay', 'socks_port', 'number')}
for n in plan['nodes']:
if any(k not in n for k in ('key', 'country', 'sequence', 'flag', 'country_name', 'name',
'vps_id', 'node_address', 'listen_port', 'relay_host',
'relay_port', 'tls_domain', 'formal_group_id',
'expected_exit', 'socks_port')):
raise ValueError('Incomplete node specification')
if not re.fullmatch(r'[a-z0-9-]{1,48}', n['key']):
raise ValueError('Invalid deployment key')
if not re.fullmatch(r'[A-Z]{2}', n['country']):
raise ValueError('Country must be a two-letter code')
if type(n['sequence']) is not int or not 1 <= n['sequence'] <= 99:
raise ValueError('Display sequence must be 01..99')
expected = f"{n['flag']} {n['country_name']} {n['country']} - {n['sequence']:02d}"
flag = ''.join(chr(0x1F1E6 + ord(x) - ord('A')) for x in n['country'])
if n['name'] != expected or n['flag'] != flag:
raise ValueError('Display name/flag does not match country and sequence')
for k in ('listen_port', 'relay_port', 'socks_port'):
if type(n[k]) is not int or not 1024 <= n[k] <= 65535:
raise ValueError('Invalid port: ' + k)
if type(n['formal_group_id']) is not int or n['formal_group_id'] < 1:
raise ValueError('An existing formal group is required')
ipaddress.ip_address(n['node_address'])
ipaddress.ip_address(n['expected_exit'])
for k in ('relay_host', 'tls_domain'):
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}', n[k]):
raise ValueError('Invalid DNS name: ' + k)
for k in ('vps_id',):
if not re.fullmatch(r'VPS-[0-9]{3}', n[k]):
raise ValueError('Use the exact CoDocs VPS ID')
identities = {k: n[k] for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain', 'socks_port')}
identities.update(relay=(n['relay_host'], n['relay_port']), number=(n['country'], n['sequence']))
for k, value in identities.items():
if value in seen[k]:
raise ValueError('Duplicate batch identity: ' + k)
seen[k].add(value)
# The plan carries references, never secret values. Adapters load credentials themselves.
def check(value):
if isinstance(value, dict):
for k, v in value.items():
if re.search(r'password|secret|token|private_key', k, re.I):
raise ValueError('Secret fields are not allowed in a batch plan')
check(v)
elif isinstance(value, list):
for v in value:
check(v)
check(plan)
return plan
@contextlib.contextmanager
def lock(root, name):
path = root / ('lock-' + hashlib.sha256(name.encode()).hexdigest())
with path.open('a+') as f:
os.chmod(path, 0o600)
while True:
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if STOP.wait(.1):
raise RuntimeError('Stopped while waiting for resource lock')
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
class Ledger:
def __init__(self, root):
self.root = Path(root).resolve()
if self.root == Path('/'):
raise ValueError('Invalid state root')
self.root.mkdir(parents=True, mode=0o700, exist_ok=True)
os.chmod(self.root, 0o700)
self.path = self.root / 'registry.sqlite3'
with self.db() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS batches(id TEXT PRIMARY KEY, digest TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS claims(namespace TEXT, kind TEXT, value TEXT,
owner TEXT NOT NULL, PRIMARY KEY(namespace,kind,value));
CREATE TABLE IF NOT EXISTS phases(batch TEXT, node TEXT, phase TEXT,
status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
updated REAL NOT NULL, PRIMARY KEY(batch,node,phase));
''')
os.chmod(self.path, 0o600)
def db(self):
c = sqlite3.connect(self.path, timeout=30)
c.execute('PRAGMA synchronous=FULL')
return c
def reserve(self, plan):
namespace = plan['context']['panel_vps'] + ':' + plan['context']['app_root']
with self.db() as c:
c.execute('BEGIN IMMEDIATE')
prior = c.execute('SELECT digest FROM batches WHERE id=?', (plan['batch_id'],)).fetchone()
if prior and prior[0] != digest(plan):
raise ValueError('Batch plan changed; resume requires the original exact plan')
for n in plan['nodes']:
owner = digest({'context': plan['context'], 'node': n})
claims = {k: n[k] for k in ('key', 'name', 'vps_id', 'node_address', 'tls_domain')}
claims['relay'] = [n['relay_host'], n['relay_port']]
claims['number'] = [n['country'], n['sequence']]
for kind, value in claims.items():
scope = namespace
if kind == 'relay':
scope = 'relay:' + n['relay_host']
elif kind in ('vps_id', 'node_address', 'tls_domain'):
scope = 'infrastructure'
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True)
existing = c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?',
(scope, kind, encoded)).fetchone()
if existing and existing[0] != owner:
raise ValueError('Another batch reserved identity: ' + kind)
c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, kind, encoded, owner))
# SOCKS listeners are shared by all batches using this gateway.
scope, value = 'gateway:' + plan['context']['gateway_vps'], str(n['socks_port'])
old = c.execute('SELECT owner FROM claims WHERE namespace=? AND kind=? AND value=?',
(scope, 'socks_port', value)).fetchone()
if old and old[0] != owner:
raise ValueError('Another batch reserved the gateway SOCKS port')
c.execute('INSERT OR IGNORE INTO claims VALUES(?,?,?,?)', (scope, 'socks_port', value, owner))
c.execute('INSERT OR IGNORE INTO batches VALUES(?,?)', (plan['batch_id'], digest(plan)))
def state(self, batch, key, phase):
with self.db() as c:
row = c.execute('SELECT status FROM phases WHERE batch=? AND node=? AND phase=?',
(batch, key, phase)).fetchone()
return row[0] if row else 'pending'
def mark(self, batch, key, phase, status, attempt=False):
with self.db() as c:
c.execute('''INSERT INTO phases VALUES(?,?,?,?,?,?) ON CONFLICT(batch,node,phase)
DO UPDATE SET status=excluded.status,attempts=phases.attempts+?,updated=excluded.updated''',
(batch, key, phase, status, int(attempt), time.time(), int(attempt)))
class Coordinator:
def __init__(self, plan, root, jobs=4, timeout=900):
self.plan = validate(plan)
self.ledger = Ledger(root)
self.jobs = jobs
self.timeout = timeout
self.ledger.reserve(plan)
self.dir = self.ledger.root / plan['batch_id']
self.dir.mkdir(mode=0o700, exist_ok=True)
self.plan_file = self.dir / 'plan.json'
atomic_json(self.plan_file, plan)
def call(self, n, phase, action):
fingerprint = digest({'context': self.plan['context'], 'node': n})
path = self.dir / (n['key'] + '.json')
atomic_json(path, dict(n, request_sha256=fingerprint))
argv = self.plan['adapter'] + ['--plan', str(self.plan_file), '--node', str(path),
'--phase', phase, '--action', action, '--state-dir', str(self.dir)]
with subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
start_new_session=True) as p:
deadline = time.monotonic() + self.timeout
while True:
try:
output, _ = p.communicate(timeout=.2)
break
except subprocess.TimeoutExpired:
if STOP.is_set() or time.monotonic() >= deadline:
os.killpg(p.pid, signal.SIGTERM)
try:
p.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL)
p.communicate()
raise RuntimeError('Adapter interrupted; remote outcome must be probed')
if p.returncode or len(output) > 65536:
raise RuntimeError('Adapter failed; no stdout/stderr persisted')
r = json.loads(output)
if (r.get('key'), r.get('phase'), r.get('request_sha256')) != (n['key'], phase, fingerprint):
raise RuntimeError('Adapter receipt identity mismatch')
if r.get('status') not in ('ready', 'absent', 'waiting', 'unknown', 'conflict'):
raise RuntimeError('Invalid adapter status')
return r
def phase(self, n, phase):
try:
return self._phase(n, phase)
except Exception:
self.ledger.mark(self.plan['batch_id'], n['key'], phase, 'unknown')
return False
def _phase(self, n, phase):
batch, key = self.plan['batch_id'], n['key']
resource = SHARED.get(phase, n['vps_id'])
# Common panel/dns/relay lock names across batches and processes.
namespace = self.plan['context']['panel_vps']
if resource in ('dns', 'relay'):
namespace = 'dpl-external'
with lock(self.ledger.root, namespace + ':' + resource):
r = self.call(n, phase, 'probe')
if r['status'] == 'ready':
self.ledger.mark(batch, key, phase, 'done')
return True
if r['status'] == 'conflict':
STOP.set()
if phase in ('preflight', 'verify', 'audit') or r['status'] != 'absent' or r.get('retry_safe') is not True:
self.ledger.mark(batch, key, phase, r['status'])
return False
if STOP.is_set():
return False
# Persist intent before a write; never infer success from the command exit code.
self.ledger.mark(batch, key, phase, 'running', attempt=True)
applied = self.call(n, phase, 'apply')
if applied['status'] == 'conflict':
self.ledger.mark(batch, key, phase, 'conflict')
STOP.set()
return False
r = self.call(n, phase, 'probe')
self.ledger.mark(batch, key, phase, 'done' if r['status'] == 'ready' else r['status'])
if r['status'] == 'conflict':
STOP.set()
return r['status'] == 'ready'
def worker(self, n):
try:
with lock(self.ledger.root, 'node:' + self.plan['context']['panel_vps'] + ':' + n['key']):
self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'running')
# Already finished batches are re-audited; disabled QA need not be re-enabled.
if self.ledger.state(self.plan['batch_id'], n['key'], 'audit') == 'done':
success = self.phase(n, 'audit')
else:
success = True
for phase in PHASES:
if STOP.is_set() or not self.phase(n, phase):
success = False
break
self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'done' if success else 'waiting')
return success
except Exception:
# Never print adapter payloads, commands, credentials or exception contents.
self.ledger.mark(self.plan['batch_id'], n['key'], 'coordinator', 'unknown')
return False
def run(self):
# Whole-batch barrier: no staging until every candidate passed current preflight.
with concurrent.futures.ThreadPoolExecutor(max_workers=self.jobs) as pool:
def check(n):
try:
return self.phase(n, 'preflight')
except Exception:
self.ledger.mark(self.plan['batch_id'], n['key'], 'preflight', 'unknown')
return False
checks = list(pool.map(check, self.plan['nodes']))
if not all(checks):
return False
return all(list(pool.map(self.worker, self.plan['nodes'])))
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('command', choices=('plan', 'run', 'status'))
p.add_argument('--plan', required=True)
p.add_argument('--state-root', required=True)
p.add_argument('--jobs', type=int, default=4)
p.add_argument('--timeout', type=int, default=900)
p.add_argument('--execute', action='store_true', help='Required for a run that may write remotely')
a = p.parse_args()
os.umask(0o077)
if not 1 <= a.jobs <= 16 or not 1 <= a.timeout <= 3600:
p.error('jobs must be 1..16; timeout 1..3600')
try:
plan = validate(read_json(a.plan))
if a.command == 'plan':
print(json.dumps({'batch_id': plan['batch_id'], 'nodes': len(plan['nodes']),
'plan_sha256': digest(plan), 'writes': False}))
return 0
if a.command == 'run' and not a.execute:
p.error('run requires --execute; plan does not perform remote requests')
if a.command == 'status':
path = Path(a.state_root).resolve() / 'registry.sqlite3'
rows = {}
if path.exists():
with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as c:
prior = c.execute('SELECT digest FROM batches WHERE id=?', (plan['batch_id'],)).fetchone()
if prior and prior[0] != digest(plan):
raise ValueError('Batch plan differs from its reserved plan')
rows = {(key, phase): status for key, phase, status in c.execute(
'SELECT node,phase,status FROM phases WHERE batch=?', (plan['batch_id'],))}
print(json.dumps({n['key']: {phase: rows.get((n['key'], phase), 'pending')
for phase in ('preflight',) + PHASES + ('coordinator',)}
for n in plan['nodes']}))
return 0
runner = Coordinator(plan, a.state_root, a.jobs, a.timeout)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda *_: STOP.set())
success = runner.run()
print(json.dumps({'batch_id': plan['batch_id'], 'complete': success,
'next': None if success else 'Inspect status, resolve waiting/conflict, rerun the exact plan'}))
return 0 if success else 2
except Exception as exc:
# Validation errors have no adapter/credential material.
print(json.dumps({'error': str(exc) if isinstance(exc, ValueError) else 'Coordinator failed; inspect protected state'}))
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Run reviewed, pinned site-specific commands for batch.py without a shell.
Missing integration is a waiting gate, never an assumed successful operation.
Handlers receive paths to the frozen request, not passwords on the command line.
"""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import sys
def main():
p = argparse.ArgumentParser(description=__doc__)
for field in ('plan', 'node', 'phase', 'action', 'state-dir'):
p.add_argument('--' + field, required=True)
a = p.parse_args()
node = json.loads(Path(a.node).read_text())
plan = json.loads(Path(a.plan).read_text())
identity = {'key': node['key'], 'phase': a.phase, 'request_sha256': node['request_sha256']}
try:
profile = plan['command_profile']
# Every runnable local script/config must be pinned; pinning the interpreter
# alone does not pin scripts, remote code or the commands' dependencies.
files = profile['files']
if not files:
raise ValueError('No pinned handler files')
for filename, expected in files.items():
if not Path(filename).is_absolute():
raise ValueError('Absolute handler paths required')
if hashlib.sha256(Path(filename).read_bytes()).hexdigest() != expected:
raise ValueError('Handler changed')
command = profile['phases'].get(a.phase, {}).get(a.action)
if command is None:
result = dict(identity, status='waiting', reason='handler_not_configured')
else:
if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
raise ValueError('Command must be argv')
values = dict(plan=a.plan, node=a.node, state_dir=a.state_dir,
key=node['key'], phase=a.phase, action=a.action)
argv = [x.format_map(values) for x in command]
if not Path(argv[0]).is_absolute():
raise ValueError('Absolute executable required')
completed = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
if completed.returncode or len(completed.stdout) > 65536:
raise ValueError('Handler failed')
result = json.loads(completed.stdout)
if any(result.get(k) != v for k, v in identity.items()):
raise ValueError('Wrong handler receipt')
if result.get('status') not in ('ready', 'absent', 'waiting', 'unknown', 'conflict'):
raise ValueError('Wrong handler status')
# Only structured nonsecret receipt fields are allowed through to the runner.
safe = {**identity, 'status': result['status']}
if result.get('retry_safe') is True:
safe['retry_safe'] = True
print(json.dumps(safe))
return 0
except Exception:
print(json.dumps(dict(identity, status='unknown')))
return 2
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,332 @@
<?php
/** Shared CLI helpers; loading this file does not bootstrap Laravel or open a connection. */
namespace DplOnboarding;
use App\Models\{Server, ServerGroup, User, Plan, Setting};
use Illuminate\Support\Facades\{DB, Cache};
use RuntimeException;
function ids(string $value): array
{
if (!preg_match('/^[1-9][0-9]*(,[1-9][0-9]*)*$/D', $value)) {
throw new RuntimeException('Expected explicit comma-separated positive IDs');
}
$ids = array_map('intval', explode(',', $value));
if (count($ids) !== count(array_unique($ids))) throw new RuntimeException('Duplicate IDs');
return $ids;
}
function readJson(string $path): array
{
if (!is_file($path) || is_link($path)) throw new RuntimeException('Required regular journal/manifest missing');
$data = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) throw new RuntimeException('Invalid JSON document');
return $data;
}
function writeJson(string $path, array $data): void
{
if (is_link($path)) throw new RuntimeException('Refusing journal symlink');
$tmp = tempnam(dirname($path), '.onboarding-');
if ($tmp === false) throw new RuntimeException('Cannot create journal');
try {
chmod($tmp, 0600);
$body = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
if (file_put_contents($tmp, $body) !== strlen($body) || !rename($tmp, $path)) {
throw new RuntimeException('Cannot persist journal');
}
} finally {
if (is_file($tmp)) unlink($tmp);
}
}
function manifest(string $dir): array
{
$m = readJson($dir . '/manifest.json');
foreach (['node_id','machine_id','group_id','test_user_id'] as $field) {
if (!isset($m[$field]) || !preg_match('/^[1-9][0-9]*$/D', (string)$m[$field])) {
throw new RuntimeException('Manifest missing positive object ID');
}
$m[$field] = (int)$m[$field];
}
if (!isset($m['key']) || !preg_match('/^[a-z0-9-]+$/D', $m['key'])) throw new RuntimeException('Invalid manifest key');
return $m;
}
function locked(string $dir, callable $fn): mixed
{
if (!is_dir($dir) || is_link($dir) || (fileperms($dir)&0077)!==0) throw new RuntimeException('Existing private backup directory required');
$path = $dir . '/operation.lock';
if (is_link($path)) throw new RuntimeException('Refusing lock symlink');
$h = fopen($path, 'c');
if ($h === false) throw new RuntimeException('Cannot open operation lock');
chmod($path, 0600);
try {
if (!flock($h, LOCK_EX | LOCK_NB)) throw new RuntimeException('Another operation is active');
return $fn();
} finally { flock($h, LOCK_UN); fclose($h); }
}
function boot(): void
{
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
}
final class PanelChange
{
private const MODELS = ['node'=>Server::class,'qa'=>User::class,'user'=>User::class,
'plan'=>Plan::class,'group'=>ServerGroup::class,'setting'=>Setting::class];
private const FIELDS = ['node'=>['group_ids'],'qa'=>['banned'],'user'=>['group_id'],
'plan'=>['group_id'],'group'=>['name'],'setting'=>['value']];
private static function normalize(string $field, mixed $value): mixed
{
return match ($field) {
'group_ids' => array_map('strval', $value ?? []),
'group_id' => $value === null ? null : (int)$value,
'banned','enabled','show' => (bool)$value,
default => $value,
};
}
private static function object(string $kind, int|string $id): mixed
{
$class = self::MODELS[$kind] ?? throw new RuntimeException('Unsupported journal object');
return $class::where($kind === 'setting' ? 'name' : 'id', $id)->lockForUpdate()->first();
}
private static function value(mixed $model, string $field): mixed
{
return $model === null ? null : self::normalize($field, $model->getAttribute($field));
}
private static function operation(string $kind, mixed $model, string $field, mixed $after, int|string|null $id = null): ?array
{
if (!in_array($field, self::FIELDS[$kind] ?? [], true)) throw new RuntimeException('Unsupported journal field');
$before = self::value($model, $field);
$after = self::normalize($field, $after);
if ($model !== null && $before === $after) return null;
return ['kind'=>$kind,'id'=>$id ?? $model->getKey(),'field'=>$field,
'before_exists'=>$model !== null,'before'=>$before,'after'=>$after];
}
private static function context(array $m): array
{
$node = self::object('node', $m['node_id']) ?? throw new RuntimeException('Manifest node missing');
$qa = self::object('qa', $m['test_user_id']) ?? throw new RuntimeException('Manifest QA missing');
if ((int)$node->machine_id !== $m['machine_id'] || $node->type !== 'anytls') {
throw new RuntimeException('Manifest node/machine mismatch');
}
if ($qa->is_admin || (int)$qa->group_id !== $m['group_id'] ||
!str_starts_with($qa->email, 'node-qa-' . $m['key'] . '-') ||
!str_ends_with($qa->email, '@example.invalid')) {
throw new RuntimeException('Manifest does not identify an isolated QA user');
}
return [$node, $qa];
}
private static function checkJournal(array $j, array $m): void
{
if (($j['version'] ?? null) !== 1 || ($j['manifest'] ?? null) !== $m || !is_array($j['operations'] ?? null)) {
throw new RuntimeException('Journal/manifest mismatch or unsupported version');
}
foreach ($j['operations'] as $op) {
if (!in_array($op['field'], self::FIELDS[$op['kind']] ?? [], true)) throw new RuntimeException('Invalid journal operation');
if ($op['kind']==='node' && $op['id']!==$m['node_id']) throw new RuntimeException('Wrong node in journal');
if ($op['kind']==='qa' && $op['id']!==$m['test_user_id']) throw new RuntimeException('Wrong QA in journal');
if ($op['kind']==='setting' && $op['id']!=='subscribe_url') throw new RuntimeException('Wrong setting in journal');
if ($j['request']['mode']==='append' && !in_array($op['kind'], ['node','qa'], true)) {
throw new RuntimeException('Append journal contains shared-resource writes');
}
}
}
private static function current(array $op): array
{
$model = self::object($op['kind'], $op['id']);
return [$model, $model !== null, self::value($model, $op['field'])];
}
private static function applyOperation(array $op): void
{
[$model, $exists, $value] = self::current($op);
if ($exists !== $op['before_exists'] || $value !== $op['before']) throw new RuntimeException('Object changed before application');
if (!$exists) {
if ($op['kind'] !== 'setting') throw new RuntimeException('Missing target');
$model = new Setting(['name'=>$op['id']]);
}
$model->setAttribute($op['field'], $op['after']);
// Keep model observers for node full-sync notifications; QA does not send notifications.
$op['kind'] === 'qa' ? $model->saveQuietly() : $model->save();
}
private static function flushSettings(array $ops): void
{
if (array_filter($ops, fn($op) => $op['kind']==='setting')) {
// Called after commit; clearing request-local state also makes retry recovery safe.
Cache::store('redis')->forget(\App\Support\Setting::CACHE_KEY);
app()->forgetInstance(\App\Support\Setting::class);
}
}
private static function notifyCommitted(array $m): void
{
// Explicitly repeat idempotent invalidations after commit. If an observer's
// after-commit callback failed, prepared+after recovery must still notify.
\App\Services\NodeSyncService::notifyFullSync($m['node_id']);
\App\Services\NodeSyncService::notifyMachineNodesChanged($m['machine_id']);
}
private static function verifyEvidence(array $request, array $m, mixed $node): void
{
$report = readJson($request['verification'] ?? throw new RuntimeException('Missing --verification live QA report'));
$at = $report['verified_at'] ?? 0;
if (($report['state'] ?? '') !== 'live_verified' || ($report['qa_user_id'] ?? null) !== $m['test_user_id'] ||
!is_int($at) || $at > time() || time()-$at > 900) {
throw new RuntimeException('Recent live verification for this manifest QA required');
}
$sni = $node->protocol_settings['tls']['server_name'] ?? null;
$targets = array_filter($report['results'] ?? [], fn($row) =>
($row['live'] ?? false) === true && ($row['strict_tls'] ?? false) === true &&
($row['relay'] ?? null) === $node->host && (int)($row['relay_port'] ?? 0) === (int)$node->port &&
($row['sni'] ?? null) === $sni);
if (count($targets)!==1) throw new RuntimeException('Live evidence endpoint/SNI mismatch');
}
public static function promote(string $dir, array $request, bool $apply): array
{
$mode = $request['mode'] ?? '';
if (!in_array($mode, ['append','initialize'], true)) throw new RuntimeException('Explicit append/initialize mode required');
$request['group'] = isset($request['group']) ? (int)$request['group'] : null;
if ($mode==='append' && (!$request['group'] || isset($request['plans']) || isset($request['subscribe-url']) || isset($request['group-name']))) {
throw new RuntimeException('Append requires group and forbids plans, subscribe-url and group-name');
}
if ($mode==='initialize') {
$request['plan_ids'] = ids($request['plans'] ?? '');
$url = $request['subscribe-url'] ?? '';
$parts = parse_url($url);
if (!is_array($parts) || ($parts['scheme'] ?? '')!=='https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
throw new RuntimeException('Initialize requires an explicit HTTPS subscribe-url without credentials/query');
}
}
ksort($request);
$m = manifest($dir); $path = $dir . '/promotion-journal.json';
$result = DB::transaction(function () use ($dir,$request,$mode,$m,$path,$apply) {
[$node,$qa] = self::context($m);
if (is_file($path)) {
$j = readJson($path); self::checkJournal($j,$m);
if ($j['request'] !== $request) throw new RuntimeException('Existing operation has different parameters');
if (is_file($dir.'/rollback-journal.json')) throw new RuntimeException('Rollback started; use a new reviewed task');
$before = $after = true;
foreach ($j['operations'] as $op) {
[, $exists,$value] = self::current($op);
$before = $before && $exists===$op['before_exists'] && $value===$op['before'];
$after = $after && $exists && $value===$op['after'];
}
if (!$after && (!$before || $j['state']!=='prepared')) throw new RuntimeException('Journal conflict; inspect before retrying');
if (!$apply) return ['state'=>$after?'already_applied':'preview','journal'=>$j];
if (!$after) {
self::verifyEvidence($request,$m,$node);
if (!$node->is_online || (int)$qa->u+(int)$qa->d<=0 || $qa->banned) throw new RuntimeException('QA evidence no longer ready');
foreach ($j['operations'] as $op) self::applyOperation($op);
}
return ['state'=>'applied','journal'=>$j];
}
if (is_file($dir.'/promoted.json')) throw new RuntimeException('Legacy promotion exists without new journal; do not adopt automatically');
if (!$node->enabled || !$node->show || !$node->is_online || (int)$qa->u + (int)$qa->d <= 0 || $qa->banned) throw new RuntimeException('Fresh online/active QA traffic evidence missing');
if (self::value($node,'group_ids') !== [(string)$m['group_id']]) throw new RuntimeException('Node no longer in its isolated stage group');
$groupId = $request['group'] ?? $m['group_id'];
$group = self::object('group',$groupId) ?? throw new RuntimeException('Target group missing');
if ($mode==='append' && $groupId===$m['group_id']) throw new RuntimeException('Append requires an existing formal group');
$ops = [self::operation('node',$node,'group_ids',[(string)$groupId]), self::operation('qa',$qa,'banned',true)];
if ($mode==='initialize') {
$plans = Plan::whereIn('id',$request['plan_ids'])->orderBy('id')->lockForUpdate()->get();
if ($plans->count()!==count($request['plan_ids'])) throw new RuntimeException('Plan IDs do not all exist');
foreach ($plans as $plan) {
if ($plan->group_id !== null && (int)$plan->group_id!==$groupId) throw new RuntimeException('Plan belongs to another group');
$ops[] = self::operation('plan',$plan,'group_id',$groupId);
}
// Only the explicitly selected plans' unassigned users can change in initialize mode.
foreach (User::whereIn('plan_id',$request['plan_ids'])->whereNull('group_id')->orderBy('id')->lockForUpdate()->get() as $user) {
$ops[] = self::operation('user',$user,'group_id',$groupId);
}
if (isset($request['group-name'])) $ops[] = self::operation('group',$group,'name',$request['group-name']);
$setting = self::object('setting','subscribe_url');
$ops[] = self::operation('setting',$setting,'value',$request['subscribe-url'],'subscribe_url');
}
if ($apply) self::verifyEvidence($request,$m,$node);
$j = ['version'=>1,'state'=>'prepared','manifest'=>$m,'request'=>$request,
'operations'=>array_values(array_filter($ops)),
'node_guard'=>['enabled'=>(bool)$node->enabled,'show'=>(bool)$node->show]];
if (!$apply) return ['state'=>'preview','journal'=>$j];
writeJson($path,$j); // Durable intent before any DB write; recover prepared+after on retry.
foreach ($j['operations'] as $op) self::applyOperation($op);
return ['state'=>'applied','journal'=>$j];
},1);
if ($apply) {
self::flushSettings($result['journal']['operations']);
self::notifyCommitted($m);
$result['journal']['state']='applied'; writeJson($path,$result['journal']);
}
return $result;
}
public static function rollback(string $dir, bool $apply): array
{
$m = manifest($dir); $j = readJson($dir.'/promotion-journal.json'); self::checkJournal($j,$m);
$rollbackPath=$dir.'/rollback-journal.json';
$result=DB::transaction(function () use ($m,$j,$apply,$rollbackPath) {
[$node,$qa]=self::context($m);
if (!$qa->banned) throw new RuntimeException('QA must remain disabled during rollback');
if (is_file($rollbackPath)) {
$r=readJson($rollbackPath);
if (($r['promotion']??null)!==$j) throw new RuntimeException('Rollback journal mismatch');
} else {
// Rollback itself has a durable before/after journal, including node containment.
$undo=[];
foreach (array_reverse($j['operations']) as $op) {
if ($op['kind']==='qa') continue; // Never reactivate test users.
[$model,$exists,$value]=self::current($op);
if (!$exists || $value!==$op['after']) throw new RuntimeException('Rollback conflict: current value differs from recorded after');
$undo[]=['kind'=>$op['kind'],'id'=>$op['id'],'field'=>$op['field'],
'before_exists'=>true,'before'=>$op['after'],'after'=>$op['before'],
'delete'=>!$op['before_exists']];
}
foreach (['enabled','show'] as $field) {
if ((bool)$node->$field!==$j['node_guard'][$field]) throw new RuntimeException('Node containment field changed after promotion');
if ($node->$field) $undo[]=['kind'=>'node','id'=>$m['node_id'],'field'=>$field,
'before_exists'=>true,'before'=>true,'after'=>false,'delete'=>false];
}
if (!$qa->banned) throw new RuntimeException('QA reactivated after promotion; resolve conflict before rollback');
$r=['version'=>1,'state'=>'prepared','promotion'=>$j,'operations'=>$undo];
}
$before=$after=true;
foreach ($r['operations'] as $op) {
// Only rollback containment adds these two node fields.
if (!in_array($op['field'],self::FIELDS[$op['kind']]??[],true) && !($op['kind']==='node' && $op['id']===$m['node_id'] && in_array($op['field'],['enabled','show'],true))) {
throw new RuntimeException('Invalid rollback field');
}
[, $exists,$value]=self::current($op);
$before=$before && $exists && $value===$op['before'];
$after=$after && ($op['delete'] ? !$exists : ($exists && $value===$op['after']));
}
if (!$after && (!$before || $r['state']!=='prepared')) throw new RuntimeException('Rollback conflicts; no objects restored');
if (!$apply) return ['state'=>$after?'already_rolled_back':'preview','journal'=>$r];
if (!is_file($rollbackPath)) writeJson($rollbackPath,$r);
if (!$after) foreach ($r['operations'] as $op) {
[$model]=self::current($op);
if ($op['delete']) $model->delete();
else { $model->setAttribute($op['field'],$op['after']); $model->save(); }
}
return ['state'=>'rolled_back','journal'=>$r];
},1);
if ($apply) {
self::flushSettings($j['operations']);
self::notifyCommitted($m);
$result['journal']['state']='rolled_back';writeJson($rollbackPath,$result['journal']);
}
return $result;
}
}

View File

@ -1,43 +1,25 @@
<?php
// Run only after real relay, subscription, certificate and traffic checks pass.
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\Plan;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\User;
use Illuminate\Support\Facades\DB;
$opts = getopt('', ['backup:', 'plans:', 'group:']);
$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup');
$ids = array_map('intval', explode(',', $opts['plans'] ?? ''));
if (!$ids || in_array(0, $ids, true)) throw new RuntimeException('Explicit plan IDs required');
$m = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
$groupId = (int)($opts['group'] ?? $m['group_id']);
$node = Server::findOrFail($m['node_id']);
$qa = User::findOrFail($m['test_user_id']);
if (!$node->is_online || $qa->u + $qa->d <= 0) throw new RuntimeException('Online and traffic evidence missing');
if (is_file("$dir/promoted.json")) { echo "Already promoted; verify current state separately\n"; exit; }
$plans = Plan::whereIn('id', $ids)->get();
if ($plans->count() !== count($ids)) throw new RuntimeException('Plan list mismatch');
foreach ($plans as $p) if ($p->group_id !== null && $p->group_id !== $groupId) throw new RuntimeException('Plan already assigned elsewhere');
$users = User::whereIn('plan_id', $ids)->whereNull('group_id')->get(['id','group_id','updated_at']);
umask(0077);
file_put_contents("$dir/before-promotion.json", json_encode([
'plans' => $plans->map->getAttributes(), 'user_groups' => $users,
'node_groups' => $node->group_ids, 'group' => ServerGroup::findOrFail($groupId)->getAttributes(),
'subscribe_url' => admin_setting('subscribe_url'), 'qa_banned' => $qa->banned,
], JSON_THROW_ON_ERROR));
DB::transaction(function () use ($ids, $users, $node, $qa, $groupId) {
$group = ServerGroup::findOrFail($groupId);
if (str_starts_with($group->name, 'dpl 接入验收 ')) { $group->name = '基础节点'; $group->save(); }
Plan::whereIn('id', $ids)->whereNull('group_id')->update(['group_id' => $groupId]);
User::whereIn('id', $users->pluck('id'))->whereNull('group_id')->update(['group_id' => $groupId]);
$node->group_ids = [(string)$groupId]; $node->save();
$qa->banned = 1; $qa->saveQuietly();
admin_setting(['subscribe_url' => 'https://c2.deeplink-subscribe.com']);
});
$result = ['node_id' => $node->id, 'group_id' => $groupId, 'plan_ids' => $ids,
'existing_users_assigned' => $users->count(), 'qa_disabled' => true];
file_put_contents("$dir/promoted.json", json_encode($result, JSON_THROW_ON_ERROR));
echo json_encode($result) . PHP_EOL;
// Default is preview. Apply only in a separately authorized target environment.
require __DIR__ . '/operations.php';
use DplOnboarding\PanelChange;
$opts = getopt('', ['backup:', 'mode:', 'plans:', 'group:', 'subscribe-url:', 'group-name:', 'verification:', 'apply', 'help']);
if (isset($opts['help'])) {
echo "--backup=DIR --mode=append --group=ID [--verification=QA_REPORT --apply]\n";
echo "--backup=DIR --mode=initialize --plans=IDS --subscribe-url=HTTPS_URL [--group=ID] [--group-name=NAME] [--verification=QA_REPORT --apply]\n";
exit;
}
try {
$dir = $opts['backup'] ?? throw new RuntimeException('Missing --backup');
$apply = array_key_exists('apply', $opts);
unset($opts['backup'], $opts['apply']);
if (!isset($opts['mode']) || !in_array($opts['mode'], ['append','initialize'], true)) throw new RuntimeException('Explicit --mode required');
if (isset($opts['group'])) { DplOnboarding\ids($opts['group']); if (str_contains($opts['group'],',')) throw new RuntimeException('One group required'); }
DplOnboarding\boot();
$result = DplOnboarding\locked($dir, fn() => PanelChange::promote($dir, $opts, $apply));
// Journal is field-scoped and contains no passwords/UUIDs/tokens.
echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
$reason=get_class($e)===RuntimeException::class ? $e->getMessage() : "Inspect protected journal/current state";
fwrite(STDERR, "Promotion stopped: " . $reason . "\n");
exit(1);
}

View File

@ -1,16 +1,32 @@
<?php
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$opts = getopt('', ['backup:']);
$dir = $opts['backup'] ?? throw new RuntimeException('Missing backup');
$m = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
$node = App\Models\Server::findOrFail($m['node_id']);
$qa = App\Models\User::findOrFail($m['test_user_id']);
echo json_encode([
'node_id' => $node->id, 'name' => $node->name, 'is_online' => $node->is_online,
'last_check_at' => $node->last_check_at, 'last_push_at' => $node->last_push_at,
'qa_upload' => $qa->u, 'qa_download' => $qa->d, 'qa_banned' => $qa->banned,
'plans' => App\Models\Plan::orderBy('id')->get(['id', 'group_id']),
'existing_users_with_selected_plan_and_null_group' => App\Models\User::whereIn('plan_id', [1,6,7,8])->whereNull('group_id')->count(),
], JSON_UNESCAPED_UNICODE) . PHP_EOL;
require __DIR__ . '/operations.php';
$opts=getopt('', ['backup:', 'plans:', 'group:', 'help']);
if (isset($opts['help'])) { echo "--backup=DIR [--plans=ID,ID] [--group=ID]; no implicit plan IDs\n"; exit; }
try {
$dir=$opts['backup'] ?? throw new RuntimeException('Missing --backup');
$ids=isset($opts['plans']) ? DplOnboarding\ids($opts['plans']) : [];
$group=null;
if (isset($opts['group'])) {
$groups=DplOnboarding\ids($opts['group']);
if (count($groups)!==1) throw new RuntimeException('One group required');
$group=$groups[0];
}
$m=DplOnboarding\manifest($dir);
DplOnboarding\boot();
$node=App\Models\Server::findOrFail($m['node_id']);
$qa=App\Models\User::findOrFail($m['test_user_id']);
if ((int)$node->machine_id!==$m['machine_id']) throw new RuntimeException('Manifest machine mismatch');
$plans=App\Models\Plan::whereIn('id',$ids)->orderBy('id')->get(['id','group_id']);
if ($plans->count()!==count($ids)) throw new RuntimeException('Some selected plans missing');
if ($group!==null) App\Models\ServerGroup::findOrFail($group);
$result=[
'node_id'=>$node->id, 'name'=>$node->name, 'is_online'=>(bool)$node->is_online,
'last_check_at'=>$node->last_check_at, 'last_push_at'=>$node->last_push_at,
'group_ids'=>$node->group_ids, 'qa_upload'=>(int)$qa->u, 'qa_download'=>(int)$qa->d,
'qa_banned'=>(bool)$qa->banned, 'selected_plan_ids'=>$ids, 'plans'=>$plans->toArray(),
'existing_users_with_selected_plan_and_null_group'=>$ids ? App\Models\User::whereIn('plan_id',$ids)->whereNull('group_id')->count() : null,
'selected_plans_in_other_group'=>$group===null ? null : $plans->filter(fn($p) => $p->group_id!==null && (int)$p->group_id!==$group)->pluck('id')->all(),
'scope'=>'Read-only snapshot; not subscription/traffic/GUI acceptance',
];
echo json_encode($result,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE).PHP_EOL;
} catch (Throwable $e) { fwrite(STDERR,"Readiness failed; verify parameters and manifest.\n"); exit(1); }

View File

@ -0,0 +1,16 @@
<?php
// Field-scoped compare-before-restore. Preview unless explicitly --apply.
require __DIR__ . '/operations.php';
use DplOnboarding\PanelChange;
$opts=getopt('', ['backup:', 'apply', 'help']);
if (isset($opts['help'])) { echo "--backup=DIR [--apply]; restores new-format promotion journal only\n"; exit; }
try {
$dir=$opts['backup'] ?? throw new RuntimeException('Missing --backup');
DplOnboarding\boot();
$result=DplOnboarding\locked($dir, fn() => PanelChange::rollback($dir, array_key_exists('apply',$opts)));
echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
$reason=get_class($e)===RuntimeException::class ? $e->getMessage() : "Inspect journal/conflicts";
fwrite(STDERR, "Rollback stopped: " . $reason . "\n");
exit(1);
}

View File

@ -1,82 +1,109 @@
<?php
// Run from the production application root. All credential output is private.
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\ServerMachine;
use App\Models\User;
// Run only in an authorized target environment. --help never boots the app.
require __DIR__ . '/operations.php';
use App\Models\{Server,ServerGroup,ServerMachine,User};
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
$opts = getopt('', ['key:', 'name:', 'relay-host:', 'relay-port:', 'listen-port:', 'tls-domain:', 'backup:']);
foreach (['key', 'name', 'relay-host', 'relay-port', 'listen-port', 'tls-domain', 'backup'] as $key) {
if (empty($opts[$key])) throw new RuntimeException("Missing --$key");
}
if (!preg_match('/^[a-z0-9-]+$/', $opts['key'])) throw new RuntimeException('Invalid key');
foreach (['relay-port', 'listen-port'] as $key) {
if (!ctype_digit((string)$opts[$key]) || $opts[$key] < 1024 || $opts[$key] > 65535) throw new RuntimeException('Invalid port');
}
$dir = rtrim($opts['backup'], '/');
umask(0077);
if (!is_dir($dir) && !mkdir($dir, 0700, true)) throw new RuntimeException('Backup creation failed');
if (is_file("$dir/manifest.json")) {
$manifest = json_decode(file_get_contents("$dir/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
if ($manifest['key'] !== $opts['key']) throw new RuntimeException('Backup belongs to another node');
echo json_encode(['already_staged' => true, 'node_id' => $manifest['node_id']]) . PHP_EOL;
$opts=getopt('', ['key:', 'name:', 'relay-host:', 'relay-port:', 'listen-port:', 'tls-domain:', 'panel-url:', 'backup:', 'help']);
if (isset($opts['help'])) {
echo "--key=KEY --name=NAME --relay-host=HOST --relay-port=PORT --listen-port=PORT --tls-domain=DOMAIN --panel-url=HTTPS_URL --backup=DIR\n";
exit;
}
if (ServerMachine::where('name', 'dpl-' . $opts['key'])->exists() || Server::where('name', $opts['name'])->exists()) {
throw new RuntimeException('Existing resources found without this backup manifest');
try {
foreach (['key','name','relay-host','relay-port','listen-port','tls-domain','panel-url','backup'] as $key) {
if (!isset($opts[$key]) || !is_string($opts[$key]) || $opts[$key]==='') throw new RuntimeException('Missing required stage option');
}
if (!preg_match('/^[a-z0-9-]+$/D',$opts['key'])) throw new RuntimeException('Invalid deployment key');
foreach (['relay-port','listen-port'] as $key) {
if (!ctype_digit($opts[$key]) || (int)$opts[$key]<1024 || (int)$opts[$key]>65535) throw new RuntimeException('Invalid port');
$opts[$key]=(int)$opts[$key];
}
foreach (['relay-host','tls-domain'] as $key) {
if (!preg_match('/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/Di',$opts[$key])) throw new RuntimeException('Explicit hostname required');
}
$url=parse_url($opts['panel-url']);
if (!is_array($url) || ($url['scheme']??'')!=='https' || empty($url['host']) || isset($url['user']) || isset($url['pass']) || isset($url['query']) || isset($url['fragment'])) {
throw new RuntimeException('Explicit HTTPS panel URL without credentials/query required');
}
$dir=rtrim($opts['backup'],'/');
if (!str_starts_with($dir,'/') || is_link($dir)) throw new RuntimeException('Absolute private backup directory required');
umask(0077);
if (!is_dir($dir) && !mkdir($dir,0700,true)) throw new RuntimeException('Cannot create backup directory');
if ((fileperms($dir)&0077)!==0) throw new RuntimeException('Backup directory must be private');
$parameters=$opts;unset($parameters['backup']);ksort($parameters);
DplOnboarding\boot();
$m=DplOnboarding\locked($dir,function () use ($dir,$parameters) {
$path=$dir.'/manifest.json';
if (is_file($path)) {
$m=DplOnboarding\manifest($dir);
if (($m['parameters']??null)!==$parameters) throw new RuntimeException('Existing manifest has different/legacy parameters; no automatic adoption');
$node=Server::findOrFail($m['node_id']);$qa=User::findOrFail($m['test_user_id']);
$machine=ServerMachine::findOrFail($m['machine_id']);
if ((int)$node->machine_id!==$machine->id || $machine->name!=='dpl-'.$parameters['key'] ||
$node->host!==$parameters['relay-host'] || (int)$node->port!==$parameters['relay-port'] ||
(int)$node->server_port!==$parameters['listen-port'] || $node->type!=='anytls' ||
($node->protocol_settings['tls']['server_name']??null)!==$parameters['tls-domain'] ||
(int)$qa->group_id!==$m['group_id']) throw new RuntimeException('Staged resources drifted');
if (is_file($dir.'/promotion-journal.json') || is_file($dir.'/promoted.json')) throw new RuntimeException('Node already promoted; staging cannot regenerate credentials');
return $m;
}
return DB::transaction(function () use ($parameters,$path,$dir) {
if (ServerMachine::where('name','dpl-'.$parameters['key'])->exists() || Server::where('name',$parameters['name'])->exists() ||
Server::where('host',$parameters['relay-host'])->where('port',(string)$parameters['relay-port'])->exists() ||
ServerGroup::where('name','dpl 接入验收 '.$parameters['key'])->exists()) {
throw new RuntimeException('Existing resource/relay tuple without matching manifest; refusing takeover');
}
DplOnboarding\writeJson($dir.'/before.json',[
'server_ids'=>Server::orderBy('id')->pluck('id')->all(),
'machine_ids'=>ServerMachine::orderBy('id')->pluck('id')->all(),
'group_ids'=>ServerGroup::orderBy('id')->pluck('id')->all(),
]);
$group=new ServerGroup();$group->name='dpl 接入验收 '.$parameters['key'];$group->save();
$machine=ServerMachine::create(['name'=>'dpl-'.$parameters['key'],
'notes'=>'Independent AnyTLS service; existing XrayR preserved','is_active'=>true,'token'=>ServerMachine::generateToken()]);
$node=Server::create([
'name'=>$parameters['name'],'type'=>'anytls','host'=>$parameters['relay-host'],
'port'=>(string)$parameters['relay-port'],'server_port'=>(string)$parameters['listen-port'],
'group_ids'=>[(string)$group->id],'route_ids'=>[],'tags'=>[],
'rate'=>1,'show'=>true,'enabled'=>true,'machine_id'=>$machine->id,'sort'=>1,
'protocol_settings'=>['tls'=>['server_name'=>$parameters['tls-domain'],'allow_insecure'=>false]],
'cert_config'=>['cert_mode'=>'file','domain'=>$parameters['tls-domain'],
'cert_file'=>'/etc/letsencrypt/live/'.$parameters['tls-domain'].'/fullchain.pem',
'key_file'=>'/etc/letsencrypt/live/'.$parameters['tls-domain'].'/privkey.pem'],
]);
$qa=new User([
'email'=>'node-qa-'.$parameters['key'].'-'.Str::lower(Str::random(8)).'@example.invalid',
'password'=>password_hash(Str::random(40),PASSWORD_BCRYPT),'uuid'=>(string)Str::uuid(),'token'=>Str::random(32),
'group_id'=>$group->id,'transfer_enable'=>104857600,'expired_at'=>time()+86400,'banned'=>0,'is_admin'=>0,
'remind_expire'=>0,'remind_traffic'=>0,'device_limit'=>2,
]);$qa->saveQuietly();
$m=['key'=>$parameters['key'],'node_id'=>$node->id,'machine_id'=>$machine->id,'group_id'=>$group->id,
'test_user_id'=>$qa->id,'parameters'=>$parameters];
// Save identities before commit. A failed/unknown commit must be inspected,
// never repeated into new resources merely because a response was lost.
DplOnboarding\writeJson($path,$m);
return $m;
},1);
});
// Recovery can regenerate missing transport files from the same staged objects.
$machine=ServerMachine::findOrFail($m['machine_id']);$qa=User::findOrFail($m['test_user_id']);
$panel=json_encode($parameters['panel-url'],JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES);
$config="panel:\n url: $panel\nmachine:\n machine_id: {$machine->id}\n token_env: DPL_MACHINE_TOKEN\nkernel:\n type: singbox\n config_dir: /var/lib/dpl-node/kernel\nlog:\n level: info\nruntime:\n gomemlimit: 384MiB\n gogc: 75\nnode:\n push_interval: 30\n pull_interval: 30\n";
foreach (['node-config.yml'=>$config,'node-secret.env'=>'DPL_MACHINE_TOKEN='.$machine->token."\n"] as $file=>$body) {
$path=$dir.'/'.$file;
if (is_link($path)) throw new RuntimeException('Refusing private transport symlink');
$tmp=tempnam($dir,'.private-');if ($tmp===false) throw new RuntimeException('Private file failed');
chmod($tmp,0600);
if (file_put_contents($tmp,$body)!==strlen($body) || !rename($tmp,$path)) {
if (is_file($tmp)) unlink($tmp);
throw new RuntimeException('Private file failed');
}
}
DplOnboarding\writeJson($dir.'/qa-private.json',['user_id'=>$qa->id,'uuid'=>$qa->uuid,'subscribe_token'=>$qa->token,
'host'=>$parameters['relay-host'],'port'=>$parameters['relay-port'],'sni'=>$parameters['tls-domain']]);
echo json_encode(['key'=>$m['key'],'node_id'=>$m['node_id'],'machine_id'=>$m['machine_id'],
'group_id'=>$m['group_id'],'test_user_id'=>$m['test_user_id']],JSON_THROW_ON_ERROR).PHP_EOL;
} catch (Throwable $e) {
$reason=get_class($e)===RuntimeException::class ? $e->getMessage() : 'Inspect manifest/DB state before retrying';
fwrite(STDERR,'Staging stopped: '.$reason.PHP_EOL);exit(1);
}
file_put_contents("$dir/before.json", json_encode([
'servers' => DB::table('v2_server')->get(),
'groups' => DB::table('v2_server_group')->get(),
'machines' => DB::table('v2_server_machine')->get(),
], JSON_THROW_ON_ERROR));
$manifest = DB::transaction(function () use ($opts) {
$group = new ServerGroup();
$group->name = 'dpl 接入验收 ' . $opts['key'];
$group->save();
$machine = ServerMachine::create([
'name' => 'dpl-' . $opts['key'], 'notes' => 'Independent AnyTLS service; existing XrayR preserved',
'is_active' => true, 'token' => ServerMachine::generateToken(),
]);
$node = Server::create([
'name' => $opts['name'], 'type' => 'anytls', 'host' => $opts['relay-host'],
'port' => (string)$opts['relay-port'], 'server_port' => (string)$opts['listen-port'],
'group_ids' => [(string)$group->id], 'route_ids' => [], 'tags' => [],
'rate' => 1, 'show' => true, 'enabled' => true, 'machine_id' => $machine->id, 'sort' => 1,
'protocol_settings' => ['tls' => ['server_name' => $opts['tls-domain'], 'allow_insecure' => false]],
'cert_config' => [
'cert_mode' => 'file', 'domain' => $opts['tls-domain'],
'cert_file' => '/etc/letsencrypt/live/' . $opts['tls-domain'] . '/fullchain.pem',
'key_file' => '/etc/letsencrypt/live/' . $opts['tls-domain'] . '/privkey.pem',
],
]);
$user = new User([
'email' => 'node-qa-' . $opts['key'] . '-' . Str::lower(Str::random(8)) . '@example.invalid',
'password' => password_hash(Str::random(40), PASSWORD_BCRYPT),
'uuid' => (string)Str::uuid(), 'token' => Str::random(32),
'group_id' => $group->id, 'transfer_enable' => 104857600,
'expired_at' => time() + 86400, 'banned' => 0, 'is_admin' => 0,
'remind_expire' => 0, 'remind_traffic' => 0, 'device_limit' => 2,
]);
$user->saveQuietly();
return ['key' => $opts['key'], 'node_id' => $node->id, 'machine_id' => $machine->id,
'group_id' => $group->id, 'test_user_id' => $user->id];
});
file_put_contents("$dir/manifest.json", json_encode($manifest, JSON_THROW_ON_ERROR));
$machine = ServerMachine::findOrFail($manifest['machine_id']);
$user = User::findOrFail($manifest['test_user_id']);
$config = "panel:\n url: \"https://dp-link.net\"\nmachine:\n machine_id: {$machine->id}\n token_env: DPL_MACHINE_TOKEN\nkernel:\n type: singbox\n config_dir: /var/lib/dpl-node/kernel\nlog:\n level: info\nruntime:\n gomemlimit: 384MiB\n gogc: 75\nnode:\n push_interval: 30\n pull_interval: 30\n";
file_put_contents("$dir/node-config.yml", $config);
file_put_contents("$dir/node-secret.env", 'DPL_MACHINE_TOKEN=' . $machine->token . "\n");
file_put_contents("$dir/qa-private.json", json_encode([
'user_id' => $user->id, 'uuid' => $user->uuid, 'subscribe_token' => $user->token,
'host' => $opts['relay-host'], 'port' => (int)$opts['relay-port'], 'sni' => $opts['tls-domain'],
], JSON_THROW_ON_ERROR));
echo json_encode($manifest, JSON_UNESCAPED_UNICODE) . PHP_EOL;

View File

@ -1,72 +1,240 @@
#!/usr/bin/env python3
"""Run an isolated SOCKS client with strict TLS; never print QA credentials."""
"""Validate private subscriptions; network execution requires explicit --run.
For a formal multi-node subscription use a private --expectations JSON array:
[{"tag": "exact outbound tag", "host": "relay.example", "port": 12345,
"sni": "node.example", "uuid": "private password", "expected_exit": "192.0.2.1",
"expected_country": "JP"}]. Never print credentials or the subscription.
"""
import argparse
import copy
import ipaddress
import json
import os
import pathlib
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import time
p = argparse.ArgumentParser()
p.add_argument('--qa', required=True)
p.add_argument('--binary', required=True)
p.add_argument('--expected-exit', required=True)
p.add_argument('--port', type=int, default=19080)
p.add_argument('--subscription')
a = p.parse_args()
q = json.loads(pathlib.Path(a.qa).read_text())
c = {'log': {'level': 'error'}, 'dns': {'servers': [{'type': 'local', 'tag': 'local'}]},
'inbounds': [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': a.port}],
'outbounds': [{'type': 'anytls', 'tag': 'qa', 'server': q['host'], 'server_port': q['port'],
'password': q['uuid'], 'tls': {'enabled': True, 'server_name': q['sni'], 'insecure': False}}],
'route': {'default_domain_resolver': 'local', 'final': 'qa'}}
if a.subscription:
c = json.loads(pathlib.Path(a.subscription).read_text())
nodes = [x for x in c['outbounds'] if x.get('type') == 'anytls']
assert len(nodes) == 1 and nodes[0]['tls'].get('insecure') is False
assert nodes[0]['server'] == q['host'] and nodes[0]['server_port'] == q['port']
assert nodes[0]['password'] == q['uuid']
c['inbounds'] = [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': a.port}]
c['log'] = {'level': 'error'}
os.umask(0o077)
with tempfile.TemporaryDirectory(prefix='dpl-client-') as d:
config = pathlib.Path(d) / 'client.json'
config.write_text(json.dumps(c))
env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75')
with (pathlib.Path(d) / 'client.log').open('wb') as log:
proc = subprocess.Popen([a.binary, 'run', '-c', str(config)], cwd=d, env=env, stdout=log, stderr=log)
class VerificationError(Exception):
pass
def require(condition, message):
if not condition:
raise VerificationError(message)
def read_private(path):
p = Path(path)
require(p.is_file() and not p.is_symlink(), 'Private input must be a regular file')
require(p.stat().st_mode & 0o077 == 0, 'Private input must have no group/other permissions')
return json.loads(p.read_text())
def validate(config, expectations, exact=True):
require(isinstance(config, dict) and isinstance(config.get('outbounds'), list), 'Missing outbounds')
all_tags = [o.get('tag') for o in config['outbounds'] if isinstance(o, dict)]
require(len(all_tags) == len(config['outbounds']) and all(isinstance(t, str) and t for t in all_tags), 'Each outbound needs a tag')
require(len(set(all_tags)) == len(all_tags), 'Duplicate outbound tags')
nodes = {o['tag']: o for o in config['outbounds'] if o.get('type') == 'anytls'}
require(isinstance(expectations, list) and expectations, 'Nonempty expectations required')
selected = []
for spec in expectations:
require(isinstance(spec, dict), 'Invalid expectation')
for key in ['tag', 'host', 'sni', 'uuid', 'expected_exit', 'expected_country']:
require(isinstance(spec.get(key), str) and bool(spec[key]), 'Expectation missing required field')
require(isinstance(spec.get('port'), int) and not isinstance(spec['port'], bool) and 1 <= spec['port'] <= 65535, 'Invalid relay port')
try:
for _ in range(50):
assert proc.poll() is None, 'Client exited during startup'
try:
with socket.create_connection(('127.0.0.1', a.port), 0.1):
break
except OSError:
time.sleep(0.1)
command = ['curl', '-q', '--fail', '--silent', '--show-error', '--max-time', '30',
'--socks5-hostname', f'127.0.0.1:{a.port}']
result = subprocess.run(command + ['https://api.ipify.org'], capture_output=True, text=True, check=True)
ip = result.stdout.strip()
assert ip == a.expected_exit, 'Unexpected exit IP'
result = subprocess.run(command + ['https://www.cloudflare.com/cdn-cgi/trace'], capture_output=True, text=True, check=True)
trace = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
assert trace.get('ip') == a.expected_exit, 'Second exit check mismatch'
print(json.dumps({'anytls': True, 'relay': q['host'], 'port': q['port'], 'strict_tls': True,
'exit_ip': ip, 'country': trace.get('loc'), 'https_tls': trace.get('tls')}, ensure_ascii=False))
ipaddress.ip_address(spec['expected_exit'])
except ValueError as exc:
raise VerificationError('Invalid expected exit IP') from exc
require(len(spec['expected_country']) == 2 and spec['expected_country'].isalpha() and spec['expected_country'].isupper(), 'Expected country must be two uppercase letters')
tag = spec['tag']
require(tag in nodes and tag not in selected, 'Missing/duplicate selected node')
node = nodes[tag]
tls = node.get('tls') or {}
require(tls.get('enabled') is True and tls.get('insecure') is False, 'Strict TLS must be explicit')
require(node.get('server') == spec['host'] and node.get('server_port') == spec['port'], 'Relay endpoint mismatch')
require(tls.get('server_name') == spec['sni'], 'SNI mismatch')
require(node.get('password') == spec['uuid'], 'Subscription password mismatch')
selected.append(tag)
if exact:
require(set(selected) == set(nodes), 'Expectations must cover every AnyTLS node exactly')
return selected
def probe_config(config, spec, port):
result = copy.deepcopy(config)
result['inbounds'] = [{'type': 'mixed', 'listen': '127.0.0.1', 'listen_port': port}]
result['log'] = {'level': 'error'}
# Preserve DNS, remote rulesets and all original outbounds. Only the two
# diagnostic destinations are pinned; ordinary subscription routing remains.
route = result.setdefault('route', {})
rules = route.setdefault('rules', [])
require(isinstance(rules, list), 'Invalid route rules')
rules.insert(0, {'domain': ['api.ipify.org', 'www.cloudflare.com'],
'action': 'route', 'outbound': spec['tag']})
return result
def stop(proc):
if proc is None or proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
def run_node(config, spec, binary, port, diagnostic_dir):
require(1024 <= port <= 65535, 'Invalid local SOCKS port')
with socket.socket() as free:
try:
free.bind(('127.0.0.1', port))
except OSError as exc:
raise VerificationError('Local SOCKS port is occupied') from exc
with tempfile.TemporaryDirectory(prefix='dpl-client-', dir=diagnostic_dir) as tmp:
temporary = Path(tmp)
path = temporary / 'client.json'
path.write_text(json.dumps(probe_config(config, spec, port)))
path.chmod(0o600)
env = dict(os.environ, GOMEMLIMIT='64MiB', GOGC='75')
proc = None
log_path = temporary / 'client.log'
try:
with log_path.open('wb') as log:
subprocess.run([binary, 'check', '-c', str(path)], env=env,
cwd=tmp, stdout=log, stderr=log, check=True, timeout=30)
proc = subprocess.Popen([binary, 'run', '-c', str(path)], env=env,
cwd=tmp, stdout=log, stderr=log)
ready = False
for _ in range(100):
require(proc.poll() is None, 'Client exited during startup')
try:
with socket.create_connection(('127.0.0.1', port), 0.1):
ready = True
break
except OSError:
time.sleep(0.1)
require(ready, 'Client startup timed out')
command = ['curl', '-q', '--fail', '--silent', '--show-error', '--max-time', '30',
'--socks5-hostname', f'127.0.0.1:{port}', '--noproxy', '']
ip = subprocess.run(command + ['https://api.ipify.org'], capture_output=True,
text=True, check=True, timeout=35).stdout.strip()
require(ipaddress.ip_address(ip) == ipaddress.ip_address(spec['expected_exit']), 'Unexpected exit IP')
response = subprocess.run(command + ['https://www.cloudflare.com/cdn-cgi/trace'],
capture_output=True, text=True, check=True, timeout=35)
trace = dict(line.split('=', 1) for line in response.stdout.splitlines() if '=' in line)
require(trace.get('ip') == ip, 'Second exit check mismatch')
require(trace.get('loc') == spec['expected_country'], 'Exit country mismatch')
require(trace.get('tls', '').startswith('TLSv'), 'Target HTTPS TLS not observed')
return {'tag': spec['tag'], 'strict_tls': True, 'exit_ip': ip,
'country': trace['loc'], 'https_tls': trace['tls'], 'live': True,
'relay': spec['host'], 'relay_port': spec['port'], 'sni': spec['sni']}
except Exception:
log.flush()
failure = pathlib.Path(a.qa).parent / 'client-failure.log'
shutil.copyfile(pathlib.Path(d) / 'client.log', failure)
failure.chmod(0o600)
print(f'Client failed; protected diagnostic log: {failure}')
# Logs may contain private config context; retain only in private task directory.
stop(proc)
if log_path.exists():
fd, name = tempfile.mkstemp(prefix='client-failure-', suffix='.log', dir=diagnostic_dir)
os.close(fd)
failure = Path(name)
shutil.copyfile(log_path, failure)
failure.chmod(0o600)
raise
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stop(proc)
def write_report(path, report):
destination = Path(path)
require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0,
'Report directory must be private')
require(not destination.is_symlink(), 'Refusing report symlink')
with tempfile.NamedTemporaryFile(mode='w', dir=destination.parent, delete=False) as stream:
temporary = Path(stream.name)
try:
os.chmod(temporary, 0o600)
json.dump(report, stream, ensure_ascii=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, destination)
finally:
if temporary.exists():
temporary.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__)
sources = parser.add_mutually_exclusive_group(required=True)
sources.add_argument('--qa')
sources.add_argument('--expectations')
parser.add_argument('--subscription', required=True)
parser.add_argument('--binary')
parser.add_argument('--expected-exit')
parser.add_argument('--expected-country')
parser.add_argument('--target-tag')
parser.add_argument('--report', help='Private JSON result file; required for --run')
parser.add_argument('--port', type=int, default=19080)
execution = parser.add_mutually_exclusive_group()
execution.add_argument('--run', action='store_true')
execution.add_argument('--validate-only', action='store_true')
args = parser.parse_args()
os.umask(0o077)
try:
require(not args.run or args.report, '--run requires a private --report destination')
if args.report:
destination = Path(args.report)
inputs = [args.subscription, args.qa, args.expectations]
require(all(destination.resolve() != Path(item).resolve() for item in inputs if item), 'Report must not overwrite a private input')
require(destination.parent.is_dir() and destination.parent.stat().st_mode & 0o077 == 0 and not destination.is_symlink(), 'Private regular report destination required')
if args.report:
write_report(args.report, {'state': 'verification_pending', 'verified_at': None})
config = read_private(args.subscription)
qa_user_id = None
source = args.expectations or args.qa
if args.expectations:
require(not any([args.expected_exit, args.expected_country, args.target_tag]), 'Put each node expectation in the private expectations file')
specs = read_private(args.expectations)
else:
qa = read_private(args.qa)
qa_user_id = qa.get('user_id')
require(args.expected_exit and args.expected_country, 'QA requires explicit expected-exit and expected-country')
candidates = [n for n in config.get('outbounds', []) if n.get('type') == 'anytls'
and n.get('server') == qa.get('host') and n.get('server_port') == qa.get('port')
and (not args.target_tag or n.get('tag') == args.target_tag)]
require(len(candidates) == 1, 'QA target ambiguous/missing; supply exact target-tag')
specs = [{'tag': candidates[0]['tag'], 'host': qa.get('host'), 'port': qa.get('port'),
'sni': qa.get('sni'), 'uuid': qa.get('uuid'), 'expected_exit': args.expected_exit,
'expected_country': args.expected_country}]
tags = validate(config, specs, exact=bool(args.expectations))
results = []
if args.run:
require(args.binary and Path(args.binary).is_file(), 'Explicit existing client binary required')
binary = str(Path(args.binary).resolve())
folder = Path(source).resolve().parent
require(folder.stat().st_mode & 0o077 == 0, 'Live diagnostics directory must be private')
for spec in specs:
results.append(run_node(config, spec, binary, args.port, str(folder)))
report = {'state': 'live_verified' if args.run else 'structure_validated',
'verified_at': int(time.time()) if args.run else None,
'qa_user_id': qa_user_id, 'selected_nodes': tags, 'results': results,
'scope': 'Full subscription retained; only diagnostic destination routes pinned. No traffic/account/GUI acceptance implied.'}
if args.report:
write_report(args.report, report)
print(json.dumps(report, ensure_ascii=False))
return 0
except (Exception, KeyboardInterrupt) as exc:
# No traceback or subprocess stderr: either can contain credentials.
message = str(exc) if isinstance(exc, VerificationError) else 'Verification failed; inspect protected diagnostics'
print(json.dumps({'state': 'failed', 'reason': message}), file=__import__('sys').stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())

View File

@ -0,0 +1,116 @@
<?php
/** Standalone: php vendor/bin/phpunit --no-configuration tests/node-onboarding/PanelChangeIsolatedTest.php
* Real Eloquent + SQLite :memory:, no application bootstrap or dotenv.
* NodeSync is a test double; no Redis, queue, HTTP or production connections.
*/
namespace App\Services {
final class NodeSyncService {
public static array $calls=[];
public static bool $fail=false;
public static function notifyFullSync(int $id): void {
self::$calls[]=['node',$id];
if (self::$fail) throw new \RuntimeException('Injected post-commit notification failure');
}
public static function notifyMachineNodesChanged(int $id): void { self::$calls[]=['machine',$id]; }
}
}
namespace {
require_once dirname(__DIR__,2).'/vendor/autoload.php';
require_once dirname(__DIR__,2).'/scripts/node-onboarding/operations.php';
use App\Models\{Server,User};
use App\Services\NodeSyncService;
use DplOnboarding\PanelChange;
use Illuminate\Container\Container;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Facade;
use PHPUnit\Framework\TestCase;
final class PanelChangeIsolatedTest extends TestCase {
private Capsule $db;
private string $dir;
private array $request;
protected function setUp(): void {
$app=new Container(); Container::setInstance($app); Facade::clearResolvedInstances(); Facade::setFacadeApplication($app);
$app->instance('config',new \Illuminate\Config\Repository(['cache'=>['default'=>'array','stores'=>['array'=>['driver'=>'array']]],'app'=>['key'=>'test']]));
$events=new Dispatcher($app); $app->instance('events',$events);
$this->db=new Capsule($app); $this->db->addConnection(['driver'=>'sqlite','database'=>':memory:','foreign_key_constraints'=>true]);
$this->db->setEventDispatcher($events); $this->db->setAsGlobal(); $this->db->bootEloquent();
$app->instance('db',$this->db->getDatabaseManager());
$app->singleton('cache',fn($app)=>new \Illuminate\Cache\CacheManager($app));
$schema=$this->db->schema();
$schema->create('v2_server',function($t){$t->increments('id');$t->string('name');$t->string('type');$t->integer('machine_id');$t->text('group_ids');$t->text('protocol_settings');$t->string('host');$t->string('port');$t->boolean('enabled');$t->boolean('show');$t->timestamps();});
$schema->create('v2_user',function($t){$t->increments('id');$t->string('email');$t->boolean('is_admin')->default(false);$t->integer('group_id')->nullable();$t->integer('plan_id')->nullable();$t->integer('u')->default(0);$t->integer('d')->default(0);$t->boolean('banned')->default(false);$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
$schema->create('v2_server_group',function($t){$t->increments('id');$t->string('name');$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
$schema->create('v2_plan',function($t){$t->increments('id');$t->integer('group_id')->nullable();$t->string('name');$t->integer('price');$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
$schema->create('v2_settings',function($t){$t->increments('id');$t->string('name')->unique();$t->text('value')->nullable();$t->timestamps();});
Capsule::table('v2_server_group')->insert([['id'=>1,'name'=>'formal'],['id'=>2,'name'=>'qa']]);
Capsule::table('v2_plan')->insert(['id'=>1,'name'=>'protected-plan','price'=>1234,'group_id'=>1]);
Capsule::table('v2_user')->insert([['id'=>1,'email'=>'node-qa-test-key-a@example.invalid','group_id'=>2,'u'=>10,'d'=>20],['id'=>2,'email'=>'formal@example.invalid','group_id'=>1,'u'=>200,'d'=>300]]);
Capsule::table('v2_settings')->insert(['name'=>'subscribe_url','value'=>'https://original.example.invalid']);
foreach ([1,2] as $id) Server::create(['id'=>$id,'name'=>'node-'.$id,'type'=>'anytls','machine_id'=>$id,'group_ids'=>[(string)$id], 'host'=>'relay.example.invalid','port'=>(string)(47000+$id),'enabled'=>true,'show'=>true,'protocol_settings'=>['tls'=>['server_name'=>'node.example.invalid','allow_insecure'=>false]]]);
// Guarded IDs auto-increment to 1/2, matching the manifest.
\Illuminate\Support\Facades\Cache::put(\App\Utils\CacheKey::get('SERVER_ANYTLS_LAST_CHECK_AT',2),time());
$this->dir=sys_get_temp_dir().'/dpl-isolated-'.bin2hex(random_bytes(8)); mkdir($this->dir,0700);
\DplOnboarding\writeJson($this->dir.'/manifest.json',['key'=>'test-key','node_id'=>2,'machine_id'=>2,'group_id'=>2,'test_user_id'=>1]);
$this->request=['mode'=>'append','group'=>'1','verification'=>$this->dir.'/report.json'];
$this->evidence(); NodeSyncService::$calls=[]; NodeSyncService::$fail=false;
}
private function evidence(array $overrides=[]): void {
\DplOnboarding\writeJson($this->dir.'/report.json',array_replace(['state'=>'live_verified','qa_user_id'=>1,'verified_at'=>time(),'results'=>[['live'=>true,'strict_tls'=>true,'relay'=>'relay.example.invalid','relay_port'=>47002,'sni'=>'node.example.invalid']]],$overrides));
}
protected function tearDown(): void { foreach(glob($this->dir.'/*') as $p) unlink($p);rmdir($this->dir);$this->db->getDatabaseManager()->disconnect();NodeSyncService::$fail=false; }
private function snapshot(): array { $all=[];foreach(['v2_server','v2_user','v2_plan','v2_settings','v2_server_group'] as $t)$all[$t]=Capsule::table($t)->orderBy('id')->get()->toJson();return $all; }
private function protectedSnapshot(): array { return [Capsule::table('v2_server')->where('id',1)->first(),Capsule::table('v2_user')->where('id',2)->first(),Capsule::table('v2_plan')->get()->toJson(),Capsule::table('v2_settings')->get()->toJson(),Capsule::table('v2_server_group')->get()->toJson()]; }
private function rejects(callable $f): void { $rejected=false;try{$f();}catch(\RuntimeException $e){$rejected=true;}self::assertTrue($rejected,'Expected safe refusal'); }
public function testPreviewAppendIdempotenceAndRollback(): void {
$before=$this->snapshot();$protected=$this->protectedSnapshot();
self::assertSame('preview',PanelChange::promote($this->dir,$this->request,false)['state']);self::assertSame($before,$this->snapshot());self::assertFileDoesNotExist($this->dir.'/promotion-journal.json');
self::assertSame('applied',PanelChange::promote($this->dir,$this->request,true)['state']);self::assertSame(['1'],Server::find(2)->group_ids);self::assertTrue((bool)User::find(1)->banned);self::assertEquals($protected,$this->protectedSnapshot());
$after=$this->snapshot();PanelChange::promote($this->dir,$this->request,true);self::assertSame($after,$this->snapshot());
self::assertSame('preview',PanelChange::rollback($this->dir,false)['state']);self::assertSame($after,$this->snapshot());
PanelChange::rollback($this->dir,true);self::assertSame(['2'],Server::find(2)->group_ids);self::assertFalse(Server::find(2)->enabled);self::assertFalse(Server::find(2)->show);self::assertTrue((bool)User::find(1)->banned);self::assertEquals($protected,$this->protectedSnapshot());
$rolled=$this->snapshot();PanelChange::rollback($this->dir,true);self::assertSame($rolled,$this->snapshot());
}
public function testAppendForbidsSharedWritesAndBadEvidence(): void {
$before=$this->snapshot();foreach(['plans'=>'1','subscribe-url'=>'https://bad.example.invalid','group-name'=>'bad'] as $key=>$value){$r=$this->request;$r[$key]=$value;$this->rejects(fn()=>PanelChange::promote($this->dir,$r,true));}
foreach([['state'=>'structure_validated'],['qa_user_id'=>2],['verified_at'=>time()-901],['verified_at'=>time()+60],['results'=>[]],['results'=>[['live'=>true,'strict_tls'=>true,'relay'=>'wrong.example.invalid','relay_port'=>47002,'sni'=>'node.example.invalid']]],['results'=>[['live'=>true,'strict_tls'=>false,'relay'=>'relay.example.invalid','relay_port'=>47002,'sni'=>'node.example.invalid']]]] as $bad){$this->evidence($bad);$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($before,$this->snapshot());}
}
public function testTransactionFailurePreparedBeforeRecovers(): void {
$before=$this->snapshot();$once=true;
Server::updating(function()use(&$once){if($once){$once=false;throw new \RuntimeException('Injected transaction failure');}});
$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($before,$this->snapshot());self::assertSame('prepared',\DplOnboarding\readJson($this->dir.'/promotion-journal.json')['state']);
PanelChange::promote($this->dir,$this->request,true);self::assertTrue((bool)User::find(1)->banned);
}
public function testCommittedNotificationFailureRecoversWithoutReactivation(): void {
NodeSyncService::$fail=true;$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertTrue((bool)User::find(1)->banned);self::assertSame(['1'],Server::find(2)->group_ids);
self::assertSame('prepared',\DplOnboarding\readJson($this->dir.'/promotion-journal.json')['state']);NodeSyncService::$fail=false;
$after=$this->snapshot();PanelChange::promote($this->dir,$this->request,true);self::assertSame($after,$this->snapshot());self::assertSame('applied',\DplOnboarding\readJson($this->dir.'/promotion-journal.json')['state']);
}
public function testRollbackConflictDoesNotOverwriteLaterChange(): void {
PanelChange::promote($this->dir,$this->request,true);Capsule::table('v2_server')->where('id',2)->update(['group_ids'=>'["3"]']);$before=$this->snapshot();
$this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame($before,$this->snapshot());self::assertTrue((bool)User::find(1)->banned);
}
public function testPreparedMixedStateCannotResume(): void {
NodeSyncService::$fail=true;$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));NodeSyncService::$fail=false;
Capsule::table('v2_user')->where('id',1)->update(['banned'=>0]);$before=$this->snapshot();$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($before,$this->snapshot());
}
public function testDifferentRequestAndRollbackPreventRepromotion(): void {
PanelChange::promote($this->dir,$this->request,true);$before=$this->snapshot();$r=$this->request;$r['group']='3';
$this->rejects(fn()=>PanelChange::promote($this->dir,$r,true));self::assertSame($before,$this->snapshot());
PanelChange::rollback($this->dir,true);$rolled=$this->snapshot();$this->rejects(fn()=>PanelChange::promote($this->dir,$this->request,true));self::assertSame($rolled,$this->snapshot());self::assertTrue((bool)User::find(1)->banned);
}
public function testRollbackNotificationFailureRecoversAfterState(): void {
PanelChange::promote($this->dir,$this->request,true);NodeSyncService::$fail=true;
$this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame('prepared',\DplOnboarding\readJson($this->dir.'/rollback-journal.json')['state']);self::assertFalse(Server::find(2)->enabled);self::assertTrue((bool)User::find(1)->banned);
$rolled=$this->snapshot();NodeSyncService::$fail=false;PanelChange::rollback($this->dir,true);self::assertSame($rolled,$this->snapshot());self::assertSame('rolled_back',\DplOnboarding\readJson($this->dir.'/rollback-journal.json')['state']);
}
public function testQaReactivationAndContainmentChangeBlockRollback(): void {
PanelChange::promote($this->dir,$this->request,true);Capsule::table('v2_user')->where('id',1)->update(['banned'=>0]);$before=$this->snapshot();
$this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame($before,$this->snapshot());
Capsule::table('v2_user')->where('id',1)->update(['banned'=>1]);Capsule::table('v2_server')->where('id',2)->update(['enabled'=>false]);$before=$this->snapshot();
$this->rejects(fn()=>PanelChange::rollback($this->dir,true));self::assertSame($before,$this->snapshot());self::assertTrue((bool)User::find(1)->banned);
}
}
}

View File

@ -0,0 +1,202 @@
"""Offline process-level tests. No Laravel bootstrap, .env, SSH or network."""
import copy
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[2]
RUNNER = ROOT / 'scripts/node-onboarding/batch.py'
ADAPTER = ROOT / 'scripts/node-onboarding/command-adapter.py'
spec = importlib.util.spec_from_file_location('dpl_batch', RUNNER)
batch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(batch)
FAKE = r'''
import argparse, json, os, sys, time
from pathlib import Path
p=argparse.ArgumentParser()
for x in ('plan','node','phase','action','state-dir'): p.add_argument('--'+x,required=True)
a=p.parse_args(); n=json.loads(Path(a.node).read_text()); plan=json.loads(Path(a.plan).read_text())
root=Path(plan['fixture_root']); root.mkdir(exist_ok=True)
identity=dict(key=n['key'], phase=a.phase, request_sha256=n['request_sha256'])
def emit(status, **kw): print(json.dumps(dict(identity,status=status,**kw)))
if plan.get('bad_receipt'): identity['request_sha256']='wrong'
if a.phase=='preflight':
emit('waiting' if plan.get('block_preflight') and n['key']=='jp2' else 'ready'); sys.exit()
if a.phase in ('verify','audit'): emit('ready'); sys.exit()
marker=root/(n['key']+'-'+a.phase)
if a.action=='probe':
emit('ready' if marker.exists() else 'absent',retry_safe=True); sys.exit()
def log(event):
fd=os.open(root/'events',os.O_CREAT|os.O_WRONLY|os.O_APPEND,0o600)
os.write(fd,(json.dumps(dict(key=n['key'],phase=a.phase,event=event,t=time.monotonic()))+'\n').encode());os.close(fd)
log('start'); time.sleep(1.2 if a.phase=='provision' else .12); marker.write_text('owned'); log('end')
if plan.get('crash') and a.phase=='provision' and not (root/'crashed').exists():
(root/'crashed').write_text('yes'); sys.exit(8)
emit('ready')
'''
class BatchTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.fake = self.root / 'fake.py'
self.fake.write_text(FAKE)
self.state = self.root / 'state'
self.plan = {
'version': 1, 'mode': 'append', 'batch_id': 'offline-test',
'context': {'panel_vps': 'VPS-001', 'gateway_vps': 'VPS-002',
'jump_vps': 'VPS-003', 'app_root': '/app',
'panel_url': 'https://panel.example.invalid',
'subscription_url': 'https://sub.example.invalid',
'provision_commit': 'a' * 40, 'bundle': '/private/bundle',
'client_binary': '/private/sing-box'},
'adapter': [sys.executable, str(self.fake)],
'fixture_root': str(self.root / 'remote'), 'nodes': [self.node(1)],
}
self.path = self.root / 'plan.json'
def tearDown(self):
self.tmp.cleanup()
def node(self, i):
return dict(key=f'jp{i}', country='JP', sequence=i, flag='🇯🇵', country_name='日本',
name=f'🇯🇵 日本 JP - {i:02d}', vps_id=f'VPS-{10+i:03d}',
node_address=f'192.0.2.{i}', listen_port=24443, relay_host='relay.example.invalid',
relay_port=47000+i, tls_domain=f'jp{i}.example.invalid', formal_group_id=1,
expected_exit=f'192.0.2.{i}', socks_port=19000+i)
def argv(self, command='run'):
self.path.write_text(json.dumps(self.plan, ensure_ascii=False))
return [sys.executable, str(RUNNER), command, '--plan', str(self.path),
'--state-root', str(self.state), '--jobs', '3'] + (['--execute'] if command == 'run' else [])
def run_cli(self, command='run', expected=0):
p = subprocess.run(self.argv(command), capture_output=True, text=True, timeout=30)
self.assertEqual(p.returncode, expected, p.stdout + p.stderr)
return json.loads(p.stdout)
def events(self):
path = self.root / 'remote/events'
return [json.loads(x) for x in path.read_text().splitlines()] if path.exists() else []
def test_plan_and_status_are_read_only(self):
self.run_cli('plan'); self.run_cli('status')
self.assertFalse(self.state.exists())
self.assertFalse((self.root/'remote').exists())
def test_duplicate_parameters_and_naming_rejected(self):
for field in ('key','name','vps_id','node_address','tls_domain','relay_port','socks_port','sequence'):
with self.subTest(field=field):
other = self.node(2); other[field] = self.plan['nodes'][0][field]
p = copy.deepcopy(self.plan); p['nodes'].append(other)
with self.assertRaises(ValueError): batch.validate(p)
p = copy.deepcopy(self.plan); p['nodes'][0]['name'] = '🇯🇵 日本 JP - 11'
with self.assertRaises(ValueError): batch.validate(p)
def test_preflight_barrier_blocks_every_write(self):
self.plan['nodes'].append(self.node(2)); self.plan['block_preflight'] = True
self.run_cli(expected=2)
self.assertEqual(self.events(), [])
def test_parallel_nodes_serial_panel_and_resume(self):
self.plan['nodes'] = [self.node(i) for i in (1,2,3)]
self.run_cli()
events = self.events()
for phase, expected_peak in [('stage',1), ('publish',1), ('provision',None)]:
active = peak = 0
for e in sorted((e for e in events if e['phase']==phase), key=lambda e:e['t']):
active += 1 if e['event']=='start' else -1; peak=max(peak,active)
if expected_peak is None:
self.assertGreater(peak,1,'Independent nodes should overlap')
self.assertLessEqual(peak,3)
else:
self.assertEqual(peak, expected_peak, phase)
self.run_cli()
self.assertEqual(self.events(), events, 'A completed retry must not write again')
status = self.run_cli('status')
self.assertTrue(all(n['audit']=='done' for n in status.values()))
self.assertEqual(self.state.stat().st_mode & 0o777, 0o700)
self.assertEqual((self.state/'registry.sqlite3').stat().st_mode & 0o777, 0o600)
def test_lost_response_recovers_by_probe_without_duplicate(self):
self.plan['crash'] = True
self.run_cli(expected=2)
self.run_cli()
writes = [e for e in self.events() if e['phase']=='provision' and e['event']=='start']
self.assertEqual(len(writes),1)
self.assertEqual(self.run_cli('status')['jp1']['audit'],'done')
def test_cross_batch_and_plan_drift_rejected_before_commands(self):
self.run_cli(); events = self.events()
self.plan['nodes'][0]['expected_exit'] = '192.0.2.99'
self.run_cli(expected=2)
self.plan['batch_id'] = 'other-batch'
self.run_cli(expected=2)
self.assertEqual(self.events(), events)
def test_two_processes_share_locks_and_do_not_duplicate(self):
argv = self.argv()
a = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
b = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
for process in (a,b):
out, err = process.communicate(timeout=30)
self.assertEqual(process.returncode,0,out+err)
stages = [e for e in self.events() if e['phase']=='stage' and e['event']=='start']
self.assertEqual(len(stages),1)
def test_invalid_receipt_cannot_authorize_write(self):
self.plan['bad_receipt'] = True
self.run_cli(expected=2)
self.assertEqual(self.events(), [])
def test_interrupted_apply_records_unknown_and_can_resume(self):
p = subprocess.Popen(self.argv(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
deadline = time.monotonic() + 10
while not any(e['phase']=='provision' and e['event']=='start' for e in self.events()):
self.assertIsNone(p.poll())
self.assertLess(time.monotonic(), deadline)
time.sleep(.02)
p.send_signal(signal.SIGTERM)
out, err = p.communicate(timeout=10)
self.assertEqual(p.returncode,2,out+err)
self.assertEqual(self.run_cli('status')['jp1']['provision'],'unknown')
self.run_cli()
self.assertEqual(self.run_cli('status')['jp1']['audit'],'done')
finally:
if p.poll() is None:
p.kill(); p.communicate()
def test_configured_command_adapter_runs_full_protocol(self):
self.plan['adapter'] = [sys.executable,str(ADAPTER)]
command = [sys.executable,str(self.fake),'--plan','{plan}','--node','{node}',
'--phase','{phase}','--action','{action}','--state-dir','{state_dir}']
self.plan['command_profile'] = {
'files': {str(self.fake): hashlib.sha256(self.fake.read_bytes()).hexdigest()},
'phases': {phase: {'probe': command, 'apply': command}
for phase in ('preflight',) + batch.PHASES}}
self.run_cli()
self.assertEqual(self.run_cli('status')['jp1']['audit'],'done')
def test_command_adapter_pins_handlers_and_missing_steps_wait(self):
self.plan['adapter'] = [sys.executable,str(ADAPTER)]
self.plan['command_profile'] = {'files': {str(self.fake): hashlib.sha256(self.fake.read_bytes()).hexdigest()},
'phases': {}}
self.run_cli(expected=2)
self.assertEqual(self.run_cli('status')['jp1']['preflight'],'waiting')
self.fake.write_text('# changed')
self.run_cli(expected=2)
self.assertEqual(self.run_cli('status')['jp1']['preflight'],'unknown')
if __name__ == '__main__': unittest.main()