This commit is contained in:
parent
228700175c
commit
94173bf765
@ -42,7 +42,9 @@ lego 固定为项目同版本 v4.34.0,官方发布包 SHA256 为 `c681c09d3f2a
|
||||
|
||||
2026-09-17 23:30 复核发现,同一个 Nginx 已加载 `scert-apmmk.conf`,同时承载 Scert 的 8003/8004 入口。当前续期脚本执行的是整个 `nginx.service` 的停止,因此到期续期也会中断这些入口。六项模拟回归通过不能消除该跨项目影响;在明确共用服务维护窗口或完成不中断其他站点的挑战方案前,不得把自动续期列为生产验收通过。本轮没有停止或重载 Nginx,没有触发真实到期续期,也没有修改 Scert 配置。
|
||||
|
||||
续期服务预算为 MemoryHigh 96 MiB、MemoryMax 128 MiB、SwapMax 0、CPUQuota 100%、TasksMax 64。仅演练了未到期真实任务和模拟失败/超时恢复,尚未在此预算内执行真正到期续期;实际申请成功不等于到期续期故障演练完成。
|
||||
23:48 至 23:51 更新:用户随后授权测试期间暂时停止其他服务。已在 128 MiB 限额内完成真实测试环境续期,以及进程被 systemd 超时终止后的自动恢复演练;两轮分别约 9.27 秒和 20 秒后恢复 Nginx,Smail 与 Scert HTTPS 入口均已复核恢复,正式证书未更换。CA 复用已有授权,没有重新验证公网挑战;正式换证及外部告警仍未验收。此授权不自动等同于长期接受定时续期的共用维护窗口,详情见《续期维护窗口演练记录》。
|
||||
|
||||
续期服务预算为 MemoryHigh 96 MiB、MemoryMax 128 MiB、SwapMax 0、CPUQuota 100%、TasksMax 64。已演练未到期真实任务、模拟失败回归、真实测试环境续期和 systemd 超时恢复;尚未在此预算内完成全新挑战及正式到期换证完整链路,不能将已通过的分项扩大为全部续期验收完成。
|
||||
|
||||
同步脚本先检查系统信任链、五个域名、至少七天有效期与密钥匹配,再保存回退副本并更新运行文件。失败返回非零,不静默忽略。安装的 Certbot 保留,但其自动定时器已停用,避免双重续期管理。
|
||||
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
- [✓] SSH 可用;8001/8002 与 Nginx 反代、正式可信证书、七个 TLS/STARTTLS 入口复测通过。
|
||||
- [✓] 六项远程隔离续期回归通过;备份摘要、证书有效期与关键文件权限复核通过。
|
||||
- [✓] 原 128 MiB 保留预算的资源预检五分钟共 11 次采样通过,同一进程、无 OOM;不代表持续负载或生产容量验收,PROD-013 整体仍待完成。
|
||||
- [!] Nginx 现已同时承载 Scert 8003/8004;当前到期续期停止整个 Nginx,会影响其他项目,需要明确共用维护窗口或调整方案后演练。
|
||||
- [✓] 用户授权测试维护窗口后,128 MiB 限额内真实测试环境续期及 systemd 超时自动恢复通过,Smail 与 Scert 网页入口均恢复。详见《续期维护窗口演练记录》。
|
||||
- [!] Nginx 仍同时承载 Scert 8003/8004;全新挑战、正式换证、外部告警及长期共用维护窗口尚未完成验收或确认,不能把单次测试停机授权扩大为长期运维批准。
|
||||
- [!] 代理客户端 IP、公网恢复端口、远程浏览器业务闭环、管理员恢复撤销、凭据轮换、故障恢复、续期告警、容量与异机恢复仍待验收。
|
||||
|
||||
结论:维持“不满足整体生产放行”。本轮为无业务写入的部署复测,不冒充清库、最新构建或浏览器首次安装验收。
|
||||
|
||||
74
docs/续期维护窗口演练记录.md
Normal file
74
docs/续期维护窗口演练记录.md
Normal file
@ -0,0 +1,74 @@
|
||||
# 续期维护窗口演练记录
|
||||
|
||||
## 授权与范围
|
||||
|
||||
2026-09-17 用户明确允许测试期间暂停其他服务、测试后恢复。本轮仅临时暂停共享的 Nginx;Smail 邮件服务、Scert 后端、MySQL 和 PostgreSQL 没有停止。该授权适用于本次测试,不自动代表接受今后自动续期的常规中断窗口。
|
||||
|
||||
- 基线提交:`2287001`,测试前已推送。
|
||||
- 测试服务器:47.108.78.12,所有语法检查、测试脚本和应用探测均在远程执行。
|
||||
- 新增工具:`test/deployment/renewal_drill.py`。加载线上续期脚本,将测试目录和临近到期判断替换为隔离输入,真实调用 lego 与 ACME 测试环境。
|
||||
- 未修改线上续期源码、应用双程序、正式证书、Nginx 配置或业务数据库;未清库、未执行首次安装。
|
||||
- 测试环境目录从原有 staging 状态复制至独立 0700 目录,未复制正式 ACME 账号或密钥。未下载测试账号和私钥,也未删除原始数据。
|
||||
|
||||
## 恢复保护
|
||||
|
||||
执行前记录服务状态及正式文件摘要,检查 Nginx 配置和两个项目 HTTPS 入口。仅在 Nginx 已运行的情况下开始演练。
|
||||
|
||||
1. 原续期脚本在正常异常路径中执行 `finally` 恢复 Nginx。
|
||||
2. 临时 systemd 单元设置 `ExecStopPost=/usr/bin/systemctl start nginx.service`,覆盖测试进程被终止的情况。
|
||||
3. 每轮开始前创建并确认独立延时恢复定时器处于 active,作为外层兜底。确认服务恢复后停止定时器,避免稍后意外启动服务。本次未实际触发此第三层兜底。
|
||||
|
||||
两轮使用 MemoryHigh 96 MiB、MemoryMax 128 MiB、MemorySwapMax 0、CPUQuota 100%、TasksMax 64、GOMEMLIMIT 64 MiB,并保留 PrivateTmp、ProtectHome、NoNewPrivileges。正式应用、正式 ACME 目录和 Nginx 配置目录只读挂载,测试证书部署钩子被阻止,不能进入正式证书路径。
|
||||
|
||||
## 真实续期
|
||||
|
||||
证据目录:`/var/tmp/smail-renewal-drill-20260917-234758`。
|
||||
|
||||
隔离 ACME 目录:`/var/lib/smail-acme-drill-20260917-234758`,权限 0700。主机上的测试密钥和账号继续受保护,不提交仓库。
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 线上续期源码 SHA256 | `6d0abbe9466f8becd61b1cb3c750bfffb938d16c5a9bcae0b96d1451227d4216` |
|
||||
| 运行模式 | 测试环境 `renew`,模拟临近到期,使用 `--days 91 --ari-disable` 确定触发更新,保留 `--reuse-key --no-random-sleep` |
|
||||
| Nginx 停止请求 | 北京时间 23:48:46.946 |
|
||||
| Nginx 恢复完成 | 北京时间 23:48:56.215 |
|
||||
| 停止请求至恢复 | 约 9.27 秒,不代表浏览器端精确中断时长 |
|
||||
| systemd 结果 | success,退出码 0,运行 10.052 秒 |
|
||||
| 峰值内存 | systemd 输出约 72.6 MiB,无交换空间使用 |
|
||||
| 续期产物 | 证书内容已改变,原密钥复用,密钥匹配,五个 SAN 完全一致 |
|
||||
| 正式文件 | 程序、正式证书、正式私钥及现有站点配置摘要未变 |
|
||||
|
||||
CA 日志明确记录五个域名授权仍有效并跳过挑战。因此本轮证明了真实测试环境续期、资源限制和服务恢复,但不证明在相同限额下完成了全新公网 TLS-ALPN-01 挑战。未更改系统时间,也未等待证书自然临近到期。
|
||||
|
||||
测试证书没有部署到正式服务。本轮不覆盖正式 CA 到期换证、正式部署钩子的完整执行或外部失败告警。
|
||||
|
||||
## 超时自动恢复
|
||||
|
||||
证据目录:`/var/tmp/smail-renewal-drill-20260917-235019-timeout`。
|
||||
|
||||
在 Nginx 停止后模拟续期任务挂起,由测试单元的 20 秒启动超时终止进程。正式续期服务的 240 秒总超时未改动;本轮缩短的是临时测试单元期限。
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 停止请求 | 北京时间 23:50:21.740 |
|
||||
| systemd 结果 | 预期 timeout,进程由 SIGTERM 终止,ExecMainStatus 15 |
|
||||
| 单元运行时间 | 20.126 秒 |
|
||||
| 峰值内存 | 76,128,256 字节,约 72.6 MiB |
|
||||
| 自动恢复证据 | 执行任何人工兜底启动前,Nginx 已 active;启动时间为 23:50:41,PID 578671 |
|
||||
| 两个项目入口 | Smail 443 和 Scert 8004 均返回 200,系统证书校验结果均为 0 |
|
||||
|
||||
被终止的子进程没有机会写最终 JSON,`result.json` 中仍为 `running`。最终结论来自 `execution.log`、`unit-result.txt`、`automatic-recovery-state.txt` 及两个 HTTPS 恢复探测文件,不能仅凭子进程 JSON 判定仍在运行。
|
||||
|
||||
## 结束复核与任务状态
|
||||
|
||||
- [✓] 真实测试环境续期成功,正式部署文件未变。
|
||||
- [✓] 实际超时终止后,systemd 自动恢复 Nginx;两个项目 HTTPS 入口恢复。
|
||||
- [✓] Smail PID 574584 保持不变,NRestarts 0;Scert PID 576928、MySQL PID 549584、PostgreSQL PID 313101 的进程与监听仍存在。
|
||||
- [✓] 七个 TLS/STARTTLS 入口及 Host/SNI 边界复测通过,正式叶证书指纹仍为 `608fb55e3b8add3426a63f672debd86a58389a1a78d0961b9603b2e1abecae43`。
|
||||
- [✓] 原 128 MiB 保留预算的资源预检通过,无 OOM;生产批准仍为 false。
|
||||
- [✓] 两个临时恢复定时器 inactive;正式续期定时器 active,下一计划执行时间为 2026-09-18 04:21:56 CST。
|
||||
- [✓] 演练工具拒绝缺少维护授权、使用正式 ACME 目录、覆盖已有演练结果三种调用,均在停止服务之前以参数错误退出。
|
||||
- [!] 全新挑战、正式换证完整链路、外部失败与到期告警、常规共用维护窗口仍需独立验证或确认。
|
||||
- [!] 代理客户端 IP、公网恢复地址、完整浏览器流程、管理员恢复撤销、凭据轮换、容量与异机恢复等原有验收项不因本轮测试自动通过。
|
||||
|
||||
本轮解决的是授权维护窗口内的测试环境续期及任务终止恢复验证,不能签署整体生产放行。
|
||||
143
test/deployment/renewal_drill.py
Normal file
143
test/deployment/renewal_drill.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""仅在明确授权的维护窗口内执行远程测试环境续期演练。"""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SERVER = 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||
SOURCE = Path('/usr/local/libexec/smail-acme/renew_certificate.py')
|
||||
DOMAINS = {'solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn'}
|
||||
RUN = subprocess.run
|
||||
|
||||
|
||||
def run(command, **kwargs):
|
||||
kwargs.setdefault('timeout', 15)
|
||||
return RUN(command, **kwargs)
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def public_key(path, certificate=False):
|
||||
command = ['openssl', 'x509', '-in', str(path), '-pubkey', '-noout'] if certificate else [
|
||||
'openssl', 'pkey', '-in', str(path), '-pubout']
|
||||
return run(command, check=True, capture_output=True).stdout
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--staging-dir', type=Path, required=True)
|
||||
parser.add_argument('--evidence-dir', type=Path, required=True)
|
||||
parser.add_argument('--allow-nginx-maintenance', action='store_true')
|
||||
parser.add_argument('--simulate-hang', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() != 0 or not args.allow_nginx_maintenance:
|
||||
parser.error('必须以 root 明确授权 Nginx 维护窗口')
|
||||
stage = args.staging_dir.resolve(strict=True)
|
||||
evidence = args.evidence_dir.resolve(strict=True)
|
||||
if stage.parent != Path('/var/lib') or not stage.name.startswith('smail-acme-drill-'):
|
||||
parser.error('测试 ACME 目录必须是独立的演练副本')
|
||||
if evidence.parent != Path('/var/tmp') or not evidence.name.startswith('smail-renewal-drill-'):
|
||||
parser.error('证据目录不属于本轮演练')
|
||||
for directory in (stage, evidence):
|
||||
if directory.stat().st_uid != 0 or directory.stat().st_mode & 0o077:
|
||||
parser.error('演练目录必须归 root 所有且禁止其他用户访问')
|
||||
if (evidence / 'result.json').exists():
|
||||
parser.error('禁止覆盖已有演练结果,请使用新的证据目录')
|
||||
run(['nginx', '-t'], check=True)
|
||||
run(['systemctl', 'is-active', '--quiet', 'nginx'], check=True)
|
||||
|
||||
certificate = stage / 'certificates/solit.cn.crt'
|
||||
key = stage / 'certificates/solit.cn.key'
|
||||
old_certificate = digest(certificate)
|
||||
old_key = digest(key)
|
||||
protected = [Path('/opt/smail/smail'), Path('/opt/smail/smail-admin'),
|
||||
Path('/opt/smail/ssl/custom.crt'), Path('/opt/smail/ssl/custom.key'),
|
||||
Path('/var/lib/smail-acme-tls/certificates/solit.cn.crt'),
|
||||
Path('/var/lib/smail-acme-tls/certificates/solit.cn.key')]
|
||||
protected.extend(sorted(Path('/etc/nginx/conf.d').glob('*.conf')))
|
||||
before = {path: digest(path) for path in protected}
|
||||
spec = importlib.util.spec_from_file_location('production_renew', SOURCE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
report = {'startedUTC': datetime.now(timezone.utc).isoformat(),
|
||||
'sourceSHA256': digest(SOURCE), 'stagingOnly': True,
|
||||
'simulatedDue': True, 'productionDeploymentExercised': False,
|
||||
'mode': 'hang' if args.simulate_hang else 'renew', 'status': 'running'}
|
||||
started = None
|
||||
|
||||
def persist():
|
||||
(evidence / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
def execute(command, **kwargs):
|
||||
nonlocal started
|
||||
command = list(command)
|
||||
if '-checkend' in command:
|
||||
# 只模拟临近到期,不更改系统时间或正式证书。
|
||||
return subprocess.CompletedProcess(command, 1)
|
||||
if command == ['systemctl', 'stop', 'nginx']:
|
||||
started = time.monotonic()
|
||||
report['stopRequestedUTC'] = datetime.now(timezone.utc).isoformat()
|
||||
persist()
|
||||
if command[0].endswith('/lego'):
|
||||
if args.simulate_hang:
|
||||
report['hangEnteredUTC'] = datetime.now(timezone.utc).isoformat()
|
||||
persist()
|
||||
time.sleep(300)
|
||||
raise RuntimeError('挂起演练未被外部超时终止')
|
||||
command[1:1] = ['--server', SERVER]
|
||||
command[command.index('--days') + 1] = '91'
|
||||
command.append('--ari-disable')
|
||||
if command == ['/usr/local/libexec/smail-acme/deploy_certificate.py']:
|
||||
# 测试环境证书不能进入正式部署钩子,只核验续期产物。
|
||||
run(['systemctl', 'is-active', '--quiet', 'nginx'], check=True)
|
||||
if digest(certificate) == old_certificate or digest(key) != old_key:
|
||||
raise RuntimeError('证书未更新或没有复用原密钥')
|
||||
if public_key(certificate, True) != public_key(key):
|
||||
raise RuntimeError('测试证书与私钥不匹配')
|
||||
text = run(['openssl', 'x509', '-in', str(certificate), '-noout', '-ext',
|
||||
'subjectAltName'], check=True, capture_output=True, text=True).stdout
|
||||
names = {part.strip()[4:] for part in text.split('\n', 1)[1].split(',')
|
||||
if part.strip().startswith('DNS:')}
|
||||
if names != DOMAINS:
|
||||
raise RuntimeError('续期证书域名集合不符合预期')
|
||||
report['renewedCertificateSHA256'] = digest(certificate)
|
||||
report['keyReused'] = True
|
||||
report['domains'] = sorted(names)
|
||||
report['productionDeployHookBlocked'] = True
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
result = run(command, **kwargs)
|
||||
if command == ['systemctl', 'start', 'nginx']:
|
||||
report['nginxRestoredUTC'] = datetime.now(timezone.utc).isoformat()
|
||||
report['stopToStartSeconds'] = time.monotonic() - started
|
||||
persist()
|
||||
return result
|
||||
|
||||
try:
|
||||
with patch.object(module, 'ROOT', stage), patch.object(module.subprocess, 'run', execute):
|
||||
module.main()
|
||||
if any(digest(path) != value for path, value in before.items()):
|
||||
raise RuntimeError('正式文件在演练期间发生变化,需独立复核')
|
||||
report['productionFilesUnchanged'] = True
|
||||
report['status'] = 'passed'
|
||||
except BaseException as error:
|
||||
report['status'] = 'failed'
|
||||
report['errorType'] = type(error).__name__
|
||||
raise
|
||||
finally:
|
||||
report['finishedUTC'] = datetime.now(timezone.utc).isoformat()
|
||||
persist()
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user