fix(nodes): validate renewal hooks and record five-node acceptance

This commit is contained in:
hellcat 2026-10-18 20:13:33 +08:00
parent 6b0ac99d19
commit d085e211b3
6 changed files with 119 additions and 17 deletions

View File

@ -1,6 +1,6 @@
# 批量接入正式适配器
本专题是后续批量接入的默认技术入口。适配器实现、隔离测试及文档于2026-10-17完成;**本轮没有新增线上节点、部署新运维版本或运行真实SSH/客户端验收**。上一轮三节点实际验收使用的是已冻结旧任务适配器,不能套用为新实现的生产验收。
本专题是后续批量接入的默认技术入口。适配器实现与隔离测试于2026-10-17完成,2026-10-18首次正式批次线上验收完成;部署、动态全量验收和例外范围见[本次节点记录](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。此前三节点批次使用冻结旧任务适配器,与本实现的生产证据分开。
`batch.py`保留原命令适配器兼容,新计划默认用`configure-site.py`生成`runtime.kind=ssh-site-v1`。旧计划、账本、任务handler一律保留,不修改源码摘要后直接续跑旧任务。生产操作仍须具体接入授权、独立助手审核、Git运维部署及现场验收。
@ -26,7 +26,7 @@ python3 scripts/node-onboarding/bundle-ops.py --source FULL_SOURCE_COMMIT --base
同一运维分支的后续修订只有显式`--advance`才允许,且`--base`必须精确等于当前分支头;新提交以该base为父并通过旧值比较更新,仅快进,不强推或覆盖并行修改。bundle仍要求新路径。随后按已授权任务推送返回的运维分支、审核commit/bundle摘要,再经白色机部署到明确版本。当前源码/运维快照的提交推送与远端部署分别报告;本轮不自动切换任何生产应用或旧任务的运维目录。
适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局证书钩子或目标证书的额外pre/post钩子均停止。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。
适配器在stage前检查本地依赖、远端Git版本及工具、正式组和账号、节点既有配置、独立端口、原服务、证书归属与钩子。既有证书无本任务owner、全局执行钩子、普通/Snap目录钩子或目标证书额外执行钩子均停止。目标证书只允许单个精确专用`renew_hook`或`deploy_hook`(兼容连字符别名);重复及未知执行钩子停止。2026-10-18补齐字段兼容检查,仅用于后续新计划;已完成批次的实际钩子另行只读审查,冻结源码/计划/pin不重写。缺依赖须在准备阶段解决,不先创建一批面板对象后再临时安装工具。
Certbot准备不能只检查`command -v`:还须实际运行`certbot --version`。2026-10-17后续批次两台Debian10虽有Snap Certbot、renew timer和生成的profiles,但既有AppArmor加载服务缺失,证书命令报`missing profile`。项目提供`load-certbot-apparmor.sh`、`dpl-certbot-apparmor.service`及renew drop-in作为该情形的限定修复:只校验并加载已安装Certbot/core生成的profiles,保留其原有模式;不启用全局AppArmor服务、不关闭安全校验、不重启XrayR。仅在现场确认符合该缺口的主机部署到独立Git目录`/opt/dpl-certbot-security/provision`,先`--check`、再加载并验证工具;原续期命令前重检profiles,启动时也加载。安装前保存原单位/drop-in存在性及已加载profile清单;失败不签发,回退先确认无证书进程,再撤销本任务unit/drop-in并按原清单处理本次新加载项,不卸载其他任务profile。本段修复的实际部署及验收状态见[STATUS](STATUS.md)。
@ -108,6 +108,8 @@ preflight/publish/audit始终现场执行。只有先前状态为done、收到
原服务保存最初unit/程序/配置SHA256、监听、active及PID/启动时间/重启数。配置/程序/监听改变或不可用停止;仅进程变化分类为“需调查”,不宣称是DPL导致,也不自动刷新基线。记录观察后由操作者及助手核查生命周期、OOM、操作时间线和稳定采样;认可时在本节点私有备份登记`preserved-service-review.json`,绑定最初/本次观察摘要、原因及证据SHA256。原基线保持;再次重启不匹配旧审核,须重新调查。
隔离stage QA与正式验收租约分开:stage创建的QA为24小时,长时间中断后必须先核对实际有效期,不能用未禁用状态代替有效账号判断。本次曾在独立审查后对仍归本任务、未banned、未推广、原身份/隔离组/额度/设备/无订单全匹配的过期QA作单次1小时续期;仅改expired_at,写前私有journal+事务行锁,重新联网测试并重审,不复用旧报告。已有续期journal再次到期或任一归属变化则停止,不自动顺延或启用关闭账号;正式推广后仍须验证banned/403/实际下发排除。此为受限恢复步骤,不授权后台长期续期。
## 验证范围
新增Python测试覆盖动态1/2/7/23节点、集合变化/漏旧节点、出口/TLS/QA身份/摘要/时效、白色机跳转及严格密钥策略、线程会话复用/断线、依赖续跑及未知/过期状态、原服务精确调查回执、远端脚本与PHP构造。PHP隔离测试使用真实Eloquent/SQLite和ServerService,覆盖自动受限用户、既有管理员复用、真实下发、无效账号、journal恢复、关闭后新租约、归属漂移、插件排除及失败收尾;不加载.env/应用provider/Redis/HTTP。生产停机、失败回退、远端真实会话复用性能及十节点耗时尚未演练,不承诺固定提速倍数。

View File

@ -4,6 +4,24 @@
首节点历史核对日期:2026-10-11。本文是后续新增 dpl 节点的执行入口。首节点部署、服务器侧完整订阅联网与正式套餐开放已完成;用户明确要求跳过本地连通测试。Cloudflare 本聊天已正常访问,DNS/严格 TLS、有效订阅响应、HTTP/2 与 gzip 解码已复核;两个专用订阅区域的全域缓存绕过、两机场订阅路径 BIC 例外及原机场严格回源已部署并复验。已实施范围与可选未实施调优见下文。凭据、机器令牌、用户 UUID 与订阅令牌只存受保护文件,不写入本文或 Git。
## 2026-10-18 后续五节点批量接入
新增日本 JP - 05/06/07(node-jp06/07/08)和美国 US - 02/03(node-us03/04),展示编号独立于VPS、部署key和数据库ID。均以独立AnyTLS 24443、DNS only节点证书名和专用中转规则接入;只把新节点append到基础节点组1,未改旧443中转。node-us02因既有SSH信任密钥不符停止且未发送目标凭据,按用户批准备用node-us04精确指纹接入。
五台均先在各自隔离组完成完整订阅、严格TLS/SNI、预期IPv4/国家、在线及上下行上报测试;一个助手独立复核面板与网关原始报告字节摘要及精确QA身份后,主协调者按append预览/应用开放。本批QA21/23/22/24/25分别属于jp06/jp07/jp08/us03/us04;节点/机器ID为6/8/7/9/10。等待超过24小时使QA到期后,事务内核对归属、未禁用、隔离、无订单及100MiB额度,仅续原账号1小时;没有重置身份或复活已禁用用户,旧报告全部重测并重新审核。
正式验收动态读取有效既有管理员实际授权集合,本次为美国01至03、日本01至07共10节点,逐条完整订阅联网、严格TLS及独立预期出口/国家一致。五个QA均已禁用,订阅403且不在节点实际用户下发集合;未为正式验收新增用户或更改管理员权限。全部旧节点关键配置、套餐、原用户组/套餐关联和订阅地址均符合原基线;四个网关容器ID、启动时间、重启数保持原值。管理后台节点/服务器列表切换、名称、端口、正式权限组及在线状态已回读;未额外保存编辑表单默认值。未进行本机/手机客户端GUI、TUN、多地区、多设备并发或生产回退演练。
5台节点的证书签发和专用续期dry-run均通过。两台美国节点既有Snap Certbot AppArmor加载缺口已用独立运维提交ebe12d3修复,目录/loader/启动及续期unit均保持生成profiles原模式;包含当前core Snap helper,不启用全局AppArmor服务、不关闭校验。原XrayR程序、unit、配置及443监听保持;日本3台在等待/验收前检期间发生既有xray-core v1.8.20 QUIC sniffer越界panic并自动恢复,逐台日志、无OOM及稳定采样独立审查后登记精确原/现摘要回执,原基线保留。此次没有修复或升级旧XrayR。
本地源码6b0ac99及独立运维2547ea25、Certbot安全修复ebe12d3均已提交推送;面板/网关和5台节点仅部署运维目录,DPL生产应用仍为7e8fbf6,原工作区摘要未变。Python34项回归及既有PHP隔离19项103断言已通过(具体执行日期以原测试记录为准);本轮恢复另对受限修复、取证与文档脚本做语法及独立审查,真实业务结果以本节现场验收为准。
本批曾因发布probe状态未知停止,经独立现场回读确认3台已推广、2台仍隔离,未盲重放;后两台重新测试并审核。收尾时另发生新SSH握手失败,白色机聚合日志无足够证据确定原因。最终使用独立审核的任务完成控制器(SHA256见任务finish-controller-reviewed.sha256),单工作线程、已认证连接保活及工作线程内清理,仍调用冻结Coordinator.phase;全批publish屏障后统一动态audit。未修改冻结适配器源码或跳过现场关卡,不把此任务控制器写成通用调度器已自动采用的策略。
收尾发现Certbot 5.8把专用命令持久化为`renew_hook`。本批已另行只读核对五台实际配置:均为单个精确专用命令,两个全局配置无执行钩子,普通/Snap目录钩子为空。随后本地适配器补齐`renew_hook`/`deploy_hook`及连字符别名、重复/未知钩子与目录钩子检查,回归测试覆盖实际生成的远端检查程序,收尾完整Python 39项测试通过。该修复用于后续新计划;本批冻结运行源码已按摘要归档于`frozen-runtime/`,原计划、账本及pin未重写,也未用新代码重放已完成批次。
证据入口:忽略目录codex/output/node-batch-five-20261017/。原frozen.json及旧账本摘要不改;GemDocs迁移仅更换SSH凭据引用根后,新resume.json保持context/nodes/owner不变并固定新runtime pin,恢复账本为ledger/jp06-jp08-us03-us04-20261018-resume。最终formal-group-1.json、final-proof原始报告/QA拒绝/集合/绑定、final-metadata.json及后台截图共同保存验收。面板和节点备份仍为/root/codex-backups/dpl/node-batch-five-20261017,网关验收为/var/lib/dpl-sgw/node-batch-five-20261017;回退按各节点promotion-journal逐字段预览,不恢复整库或覆盖共享资源。GemDocs AI版新增5实例/5域名及相关服务器、项目引用已同步;人类版未自动写入。
## 2026-10-17 三个日本节点批量接入
按用户授权新增日本 JP - 02/03/04,分别部署于 node-jp03/04/05;展示编号、VPS、部署key和数据库ID独立。三台均为本次登录核实的 Debian11/x86_64、JP IPv4出口;新建独立 AnyTLS 24443、DNS only A记录及中转规则 dpl-jp03/04/05-anytls,入口47203/47204/47205,总规则25→28。收发Proxy Protocol均关闭,未改原443规则。
@ -41,7 +59,7 @@
订阅:客户端 → c2.deeplink-subscribe.com(Cloudflare 橙云 HTTPS)→ gateway-01 宿主 Nginx → dpl 独立 SubSieve → dpl 面板。
流量链路按部署key分列;2026-10-17正式五节点订阅逐条联网复验通过:
流量链路按部署key分列;2026-10-18正式十节点动态完整订阅逐条联网复验通过:
| 部署 key | VPS(不代表展示编号) | 中转入口 | 落地 | TLS 名称 | 证据 |
| --- | --- | --- | --- | --- | --- |
@ -50,8 +68,13 @@
| jp03 | node-jp03 | haiwi.lshan.eu.org:47203 | 185.200.65.70:24443 | jp03.deeplink-subscribe.com | 本文2026-10-17批量接入 |
| jp04 | node-jp04 | haiwi.lshan.eu.org:47204 | 85.113.70.174:24443 | jp04.deeplink-subscribe.com | 本文2026-10-17批量接入 |
| jp05 | node-jp05 | haiwi.lshan.eu.org:47205 | 45.153.246.171:24443 | jp05.deeplink-subscribe.com | 本文2026-10-17批量接入 |
| jp06 | node-jp06 | haiwi.lshan.eu.org:47206 | 45.153.246.172:24443 | jp06.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| jp07 | node-jp07 | haiwi.lshan.eu.org:47207 | 45.153.246.173:24443 | jp07.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| jp08 | node-jp08 | haiwi.lshan.eu.org:47208 | 172.93.221.4:24443 | jp08.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| us03 | node-us03 | haiwi.lshan.eu.org:47103 | 107.175.24.202:24443 | us03.deeplink-subscribe.com | 本文2026-10-18批量接入 |
| us04 | node-us04 | haiwi.lshan.eu.org:47104 | 23.94.87.178:24443 | us04.deeplink-subscribe.com | 本文2026-10-18批量接入 |
客户端 → 对应中转入口 → 对应落地 → 目标网络。五条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。
客户端 → 对应中转入口 → 对应落地 → 目标网络。十条链路均使用严格 AnyTLS TLS;普通 Cloudflare 橙云用于订阅 HTTP,不用于原生 AnyTLS 流量。完整对象关联见[CoDocs DPL档案](/Users/hellcat/Documents/CoDocs/10-项目/dpl.md)。
| 资源 | DPL独立资源(各次接入) | 必须保留的原有资源 |
| --- | --- | --- |
@ -60,7 +83,12 @@
| node-jp03 | dpl-node,TCP 24443,机器/节点 ID 4(2026-10-17) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-jp04 | dpl-node,TCP 24443,机器/节点 ID 5(2026-10-17) | XrayR,TCP 443 |
| node-jp05 | dpl-node,TCP 24443,机器/节点 ID 3(2026-10-17) | XrayR,TCP 443 |
| 中转 | 五条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 |
| node-jp06 | dpl-node,TCP 24443,机器/节点 ID 6(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-jp07 | dpl-node,TCP 24443,机器/节点 ID 8(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-jp08 | dpl-node,TCP 24443,机器/节点 ID 7(2026-10-18) | XrayR,TCP 443;自身panic恢复例外见本次记录 |
| node-us03 | dpl-node,TCP 24443,机器/节点 ID 9(2026-10-18) | XrayR,TCP 443 |
| node-us04 | dpl-node,TCP 24443,机器/节点 ID 10(2026-10-18) | XrayR,TCP 443 |
| 中转 | 十条独立AnyTLS规则,链路见上表 | 原443链路及其他规则 |
| gateway-01 | dpl-subscribe-gateway、dpl-subscribe-admin | subscribe-gateway、subscribe-admin |
| 网关回环端口 | 18080 / 18443,管理 16444 | 8080 / 8443,管理 64444 |
| Docker 存储 | dpl_sgw_config、dpl_sgw_logs | sgw_gw_config、sgw_gw_logs |

View File

@ -73,7 +73,7 @@ esac
| 面板 | Git 提交、当前组和节点清单、现有订阅设置、套餐/用户分组快照、应用运行环境、脚本实际版本 | 先补证据与备份,不创建机器/节点/QA 记录 |
| 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 |
| 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 |
| 证书 | 已有 renewal 配置、pre/post/deploy hooks、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 |
| 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 |
| 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 |
| 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 |
| 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 |
@ -138,7 +138,7 @@ bash "$NODE_PROVISION/install-node.sh" \
--no-random-sleep-on-renew --run-deploy-hooks --no-directory-hooks
```
`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。
`--no-directory-hooks` 不替代审计 renewal 配置内的显式钩子。Certbot 5.8.0实测把`--deploy-hook`持久化为`renew_hook`;与`deploy_hook`按同一种部署钩子检查,只接受单个精确专用命令,不能把未识别字段当作无hook。全局cli.ini也须覆盖renew别名及其他可执行hook;目标出现额外pre/post或未知hook时停止。必须确认该证书配置仍明确调用专用 `/bin/sh …/renew-node.sh`,并检查演练后的服务状态及旧服务 PID/启动时间。关闭随机等待只用于本次演练可控等待,不等于修改常规定时调度。BrokenPipe/超时只记未确认,回查进程和最终结果后再决定是否重试,不重复发起签发。
续期完成条件包括:证书配置正确、调度 enabled/下一次触发可见、调度参数确实覆盖新证书及专用 hook、dry-run 退出状态和旧服务保护证据齐全。证书到期时间引用证书/域名对应记录,不把一次演练日期当作长期验收。

View File

@ -2,13 +2,13 @@
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
## 2026-10-17 后续五节点接入(进行中)
## 2026-10-18 后续五节点接入完成
本批授权新增5个节点,复用一个核查验收助手。准备阶段独立审查发现并修复正式适配器的两处调用链阻断:QA订阅远端Python脚本缩进错误、标准化快照未保留协议类型导致最终audit无法重复校验。Python全套34项通过,新增覆盖实际发送的QA脚本编译,以及正式source→snapshot→audit→缓存audit链路;这些是离线回归,不表示本批线上接入或验收完成。
日本JP - 05/06/07(node-jp06/07/08)及美国US - 02/03(node-us03/04)已append基础节点组1;五份新QA经独立原始字节审查后开放并禁用QA21–25。动态正式授权集合本次10节点全部完整订阅、严格TLS和预期出口/国家联网通过,QA403/实际下发排除、旧节点/套餐/用户关联/订阅地址及四网关容器基线审计通过;后台节点和服务器列表显示/切换/保存值回读通过。GemDocs AI版已登记5实例/5域名及项目和服务器关联。
候选jp06/07/08、us03现场只读核查通过;us02因SSH主机密钥与既有信任不符停止,用户批准备用us04精确首次指纹后核查通过。5台原XrayR均在运行,新接入端口24443空闲;Debian11/10的Git依赖按各自发行版准备,不沿用硬编码bullseye源。本批账本与现场证据在忽略目录`codex/output/node-batch-five-20261017/`。正式开放、动态全量订阅、QA清理、旧资源回归及资源文档同步尚待完成。
面板、网关、5节点运维2547ea25,US两台Certbot安全修复ebe12d3已Git部署;源码6b0ac99已推送,生产应用仍7e8fbf6及原工作区摘要。所有新证书签发/专用续期dry-run通过。日本三台原XrayR既有QUIC sniffer panic自动恢复已独立调查并绑定精确回执,原程序/配置/unit/443保持;原基线不覆盖,未修复旧XrayR。GemDocs迁移后原冻结计划/账本不改,新resume计划保持context/nodes/owner并固定新SSH引用摘要。完整证据、边界、恢复及未测项目见[NODE_ONBOARDING](NODE_ONBOARDING.md#2026-10-18-后续五节点批量接入)。
现场进展:面板、网关和5台候选的运维目录已Git部署至2547ea25;修复后的本地适配器源码ac9f249已推送。日本3台QA及独立审查通过,尚未开放;美国两台节点程序已安装,但既有Snap Certbot因AppArmor profile未加载而不能签证书。已准备限定Certbot/core profiles的安全加载脚本与启动/续期unit,尚待提交推送、独立运维部署及实际验证;不将准备状态写成修复完成。
收尾本地适配器补齐Certbot `renew_hook`及别名、重复/未知/目录执行钩子检查;Python完整39项回归通过。本批实际钩子已另行只读核对,冻结源码归档、原计划/pin不改;新修复仅用于后续新计划,无需重部署生产应用。
## 2026-10-17 批量接入流程优化(本地验证)

View File

@ -73,19 +73,31 @@ class SiteAdapter:
owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256'])
script=r"""from pathlib import Path
import re
def hook_options(path):
for line in path.read_text().splitlines():
match=re.match(r'^\s*([A-Za-z0-9_-]+)\s*=(.*)$',line)
if match:
key=match.group(1).lower().replace('-','_')
if key.endswith('_hook'):yield key,match.group(2).strip()
for name in ['/etc/letsencrypt/cli.ini','/root/.config/letsencrypt/cli.ini']:
p=Path(name)
if p.exists():
for line in p.read_text().splitlines():
if re.match(r'^\s*(pre[-_]hook|post[-_]hook|deploy[-_]hook)\s*=\s*\S',line):raise SystemExit(2)
if p.exists() and any(value for key,value in hook_options(p)):raise SystemExit(2)
# Both normal and Snap directory hooks need an explicit independent review.
for name in ['/etc/letsencrypt/renewal-hooks','/var/snap/certbot/common/letsencrypt/renewal-hooks']:
p=Path(name)
if p.is_symlink() or (p.exists() and any(x.is_file() or x.is_symlink() for x in p.rglob('*'))):raise SystemExit(6)
domain=DOMAIN
paths=[Path('/etc/letsencrypt')/kind/domain for kind in ['live','archive']]+[Path('/etc/letsencrypt/renewal')/(domain+'.conf')]
if not OWNED and any(p.exists() or p.is_symlink() for p in paths):raise SystemExit(3)
p=paths[-1]
if p.exists():
for line in p.read_text().splitlines():
if re.match(r'^\s*(pre_hook|post_hook)\s*=\s*\S',line):raise SystemExit(4)
if re.match(r'^\s*deploy_hook\s*=',line) and line.split('=',1)[1].strip()!=HOOK:raise SystemExit(5)
seen_hook=False
for key,value in hook_options(p):
if key in ('deploy_hook','renew_hook'):
if seen_hook:raise SystemExit(5)
seen_hook=True
if value!=HOOK:raise SystemExit(5)
elif value:raise SystemExit(4)
"""
prefix='DOMAIN='+repr(n['tls_domain'])+';OWNED='+repr(owned)+';HOOK='+repr('/bin/sh '+self.ctx['node_root']+'/renew-node.sh')+'\n'
execute(node,"python3 - <<'DPLCERTGATE'\n"+prefix+script+"\nDPLCERTGATE")

View File

@ -434,4 +434,64 @@ class PackagingTests(unittest.TestCase):
for x in lines[:-1]:self.assertEqual(set(x),{'batch','node','phase','status','time'})
finally:test.tearDown()
class CertificateHookGateTests(unittest.TestCase):
"""Execute certificate_gate's actual remote Python against an isolated filesystem."""
def check_gate(self, target='', global_hook='', owned=True, directory_hook=None, conflict=None, global_file='/etc/letsencrypt/cli.ini'):
import types
from site_adapter import SiteAdapter
adapter=object.__new__(SiteAdapter);adapter.ctx={'node_root':'/opt/dpl-node/provision'}
node={'tls_domain':'qa.example.invalid','request_sha256':'owner'}
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp)
def mapped(value):return root/str(value).lstrip('/')
if target:
path=mapped('/etc/letsencrypt/renewal/qa.example.invalid.conf');path.parent.mkdir(parents=True);path.write_text(target)
if global_hook:
path=mapped(global_file);path.parent.mkdir(parents=True,exist_ok=True);path.write_text(global_hook)
if directory_hook:
path=mapped(directory_hook);path.parent.mkdir(parents=True,exist_ok=True);path.write_text('# hook')
if conflict:
path=mapped('/etc/letsencrypt/'+conflict+'/qa.example.invalid');path.parent.mkdir(parents=True,exist_ok=True);path.mkdir()
calls=[]
def execute(_,command):
calls.append(command)
if command.startswith("python3 - <<'DPLCERTGATE'\n"):
program=command.split('\n',1)[1].rsplit('\nDPLCERTGATE',1)[0]
compile(program,'actual-generated-certificate-gate','exec')
with patch.dict(sys.modules,{'pathlib':types.SimpleNamespace(Path=mapped)}):
exec(program,{'__builtins__':__builtins__})
owner=json.dumps({'request_sha256':'owner'}).encode() if owned else None
adapter.certificate_gate(node,None,lambda *args:owner,execute)
self.assertEqual(len(calls),2) # Tool readiness is reached only after gate success.
def test_renew_and_deploy_hook_aliases_accept_only_exact_command(self):
command='/bin/sh /opt/dpl-node/provision/renew-node.sh'
for name in ['renew_hook','renew-hook','deploy_hook','deploy-hook']:
with self.subTest(name=name):self.check_gate(target='[renewalparams]\n'+name+' = '+command+'\n')
for second in ['renew_hook','deploy_hook']:
with self.subTest(duplicate=second),self.assertRaises(SystemExit):
self.check_gate(target='renew_hook = '+command+'\n'+second+' = '+command+'\n')
def test_malicious_unknown_and_additional_hooks_block_before_tools(self):
command='/bin/sh /opt/dpl-node/provision/renew-node.sh'
for hook in ['renew_hook = /bin/false','renew-hook = '+command+'; touch /tmp/side-effect','deploy_hook = '+command+' extra','renew_hook = ','pre_hook = /bin/true','post-hook = /bin/true','manual_auth_hook = /bin/true','custom_hook = /bin/true','renew_hook = '+command+'\nrenew_hook = /bin/false','renew_hook = '+command+'\npost_hook = /bin/true']:
with self.subTest(hook=hook),self.assertRaises(SystemExit):self.check_gate(target=hook)
def test_global_renew_alias_and_other_executable_hooks_block(self):
for file in ['/etc/letsencrypt/cli.ini','/root/.config/letsencrypt/cli.ini']:
for name in ['renew-hook','renew_hook','deploy_hook','pre_hook','post_hook','manual_cleanup_hook','unknown_hook']:
with self.subTest(file=file,name=name),self.assertRaises(SystemExit):
self.check_gate(global_hook=name+' = /bin/true',global_file=file)
def test_directory_hooks_and_unowned_certificate_material_still_block(self):
for hook in ['/etc/letsencrypt/renewal-hooks/deploy/unrelated','/var/snap/certbot/common/letsencrypt/renewal-hooks/pre/unrelated']:
with self.subTest(hook=hook),self.assertRaises(SystemExit):self.check_gate(directory_hook=hook)
for kind in ['live','archive']:
with self.subTest(kind=kind),self.assertRaises(SystemExit):self.check_gate(owned=False,conflict=kind)
with self.assertRaises(SystemExit):self.check_gate(owned=False,target='authenticator = standalone')
def test_comments_and_non_hook_options_do_not_create_false_conflicts(self):
self.check_gate(target='# renew_hook = /bin/false\n[renewalparams]\nauthenticator = standalone\nrun_deploy_hooks = True\n',global_hook='email = qa@example.invalid\n# renew-hook = /bin/false\n')
if __name__=='__main__':unittest.main()