验收基线:保存无停机续期候选及真实挑战受阻证据
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 02:37:49 +08:00
parent cfa41e1efe
commit a60898d682
9 changed files with 430 additions and 4 deletions

View File

@ -0,0 +1,5 @@
# 仅在五个证书域名的公网 80 验证路径全部通过真实挑战后启用。
# 正式文件为 /etc/smail/acme-renew.env,root 所有,权限 0600。
# HTTP 模式复用 Nginx 的 /var/lib/smail-acme,不监听新端口,不停止服务。
# 使用 DNS 模式时设为 dns,并在 acme-dns.env 中配置专用服务商凭据。
SMAIL_ACME_CHALLENGE=http

View File

@ -1,9 +1,10 @@
#!/usr/bin/python3
"""使用 DNS 验证续期,不停止 Nginx,候选证书通过校验后才发布。"""
'''使用显式 DNS 或 Webroot 验证续期,不停止 Nginx,校验通过后才发布。'''
import os
from pathlib import Path
import re
import stat
import subprocess
import deploy_certificate as deploy
@ -12,6 +13,7 @@ import deploy_certificate as deploy
ROOT = Path('/var/lib/smail-acme-tls')
DOMAINS = ('solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn')
RENEW_TIMEOUT = 600
WEBROOT = Path('/var/lib/smail-acme')
def dns_provider():
@ -21,6 +23,25 @@ def dns_provider():
return provider
def challenge_arguments():
method = os.environ.get('SMAIL_ACME_CHALLENGE', 'dns')
if method == 'dns':
return ['--dns', dns_provider()]
if method != 'http':
raise RuntimeError('验证方式必须为 dns 或 http,不允许自动回退到停机验证')
private = ROOT.lstat()
if (ROOT.resolve(strict=True) != ROOT or not stat.S_ISDIR(private.st_mode)
or private.st_uid != 0 or private.st_mode & 0o077):
raise RuntimeError('ACME 账号目录必须由 root 独占,禁止公开访问')
for directory in (WEBROOT, WEBROOT / '.well-known', WEBROOT / '.well-known/acme-challenge'):
info = directory.lstat()
if (directory.resolve(strict=True) != directory or not stat.S_ISDIR(info.st_mode)
or info.st_uid != 0 or info.st_mode & 0o022
or info.st_mode & 0o005 != 0o005):
raise RuntimeError('HTTP 验证目录必须由 root 管理、可读取且不能被其他用户写入')
return ['--http', '--http.webroot', str(WEBROOT)]
def main():
if os.geteuid() != 0:
raise RuntimeError('必须使用 root 执行证书续期')
@ -33,19 +54,20 @@ def main():
raise RuntimeError('无法判断线上证书有效期,拒绝续期')
due = result.returncode == 1
if due:
provider = dns_provider()
challenge = challenge_arguments()
deploy.check_nginx_paths()
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'is-active', '--quiet', 'nginx'], check=True)
command = ['/usr/local/libexec/smail-acme/lego', '--path', str(ROOT),
'--email', '', '--accept-tos', '--key-type', 'rsa2048', '--dns', provider,
'--email', '', '--accept-tos', '--key-type', 'rsa2048', *challenge,
'--server', 'https://acme-v02.api.letsencrypt.org/directory',
'--http-timeout', '20', '--cert.timeout', '30']
for domain in DOMAINS:
command.extend(['--domains', domain])
command.extend(['renew', '--days', '30', '--reuse-key', '--no-random-sleep'])
subprocess.run(command, check=True, timeout=RENEW_TIMEOUT,
env={**os.environ, 'GOMEMLIMIT': '64MiB'})
env={**os.environ, 'GOMEMLIMIT': '64MiB'},
umask=0o022 if '--http' in challenge else 0o077)
else:
print('线上证书有效期大于 30 天,本次不申请新证书')
deploy.publish()

View File

@ -7,6 +7,7 @@ Wants=network-online.target
Type=oneshot
ExecStart=/usr/local/libexec/smail-acme/renew_certificate.py
EnvironmentFile=-/etc/smail/acme-dns.env
EnvironmentFile=-/etc/smail/acme-renew.env
TimeoutStartSec=720
TimeoutStopSec=15
MemoryHigh=96M

View File

@ -0,0 +1,54 @@
# HTTP 无停机续期验收记录
## 范围与结论
时间为 2026 年 9 月 18 日 02:15 至 02:27,Asia/Shanghai。测试只在 `47.108.78.12`、主机名 `ikun2` 执行。本机仅编辑、Git、传输和静态检查。修改前基线提交及推送为 `cfa41e1`。
- [✓] 五个域名均解析到 47 服务器;现有 Nginx 模板已经为验证路径提供独立 Webroot。
- [✓] 候选增加显式 HTTP 模式,不停止 Nginx,不绑定验证端口,不从 DNS 自动降级。
- [✓] 41 项远程隔离回归通过,含原有证书校验、真实隔离 Nginx 平滑重载及失败回退,以及 HTTP 目录、掩码、安全失败行为。
- [✓] 全新测试账号的真实公网挑战失败期间,两站点 138 次 HTTPS 探测通过,正式文件与四服务进程不变,验证文件由 ACME 客户端清理,测试单元已停止。
- [!] 五个域名的真实 HTTP-01 挑战均被 CA 判为 403;HTTP 模式不能启用,未取得或部署测试证书。
- [!] 线上仍是旧续期脚本和定时任务;专用 DNS 授权、正式新证书发布、外部告警仍未验收,不满足生产放行。
## 候选实现
`deploy/nginx/renew_certificate.py` 默认仍为 DNS 模式。设置 `SMAIL_ACME_CHALLENGE=http` 时,使用 `--http --http.webroot /var/lib/smail-acme`,不启用 lego 内置 HTTP 服务器。未知或空验证方式拒绝执行;DNS 缺少服务商时不自动切换方式。
HTTP 模式要求 ACME 账号目录由 root 独占且没有符号链接;公开验证目录及两层子目录必须为 root 所有、其他用户不可写且可读取、遍历。lego 4.34.0 的 Webroot 实现以 0644 创建验证文件,服务级 `UMask=0077` 会使它变为 0600。因此仅 HTTP 子进程设置 `umask=0022`,父进程和 DNS 子进程保持 0077。账号与证书目录仍为 root 独占;公开目录不保存账号、证书或私钥。
候选 systemd 服务增加可选 `/etc/smail/acme-renew.env`。仓库 `acme-renew.env.example` 仅为示例,不代表已经配置或允许上线。实际候选未安装到 `/usr/local/libexec/smail-acme`,正式 service 和 timer 均未替换。
## 隔离回归
目录为 `/var/tmp/smail-review-http-renew-20260918`。
- `tests-022115.txt`:39 项通过,2.594 秒,峰值 23.6 MiB,交换空间为零。
- `tests-022511.txt`:增加掩码及私有账号目录回归后,41 项通过,零跳过;单元 `smail-webroot-tests-20260918-022511` 运行 2.217 秒,峰值 23.9 MiB,交换空间为零。
单元使用独立网络、私有临时目录、严格只读系统,线上应用、正式 ACME 状态、管理 socket、Nginx 配置和公开 Webroot 均不可访问。唯一可写业务目录是本轮测试目录,内存上限 128 MiB。测试以 root 执行;真实 Nginx 仅运行在隔离网络,不影响主机服务。
## 全新公网挑战
工具为 `test/deployment/webroot_renewal_drill.py`。显式参数 `--confirm-staging-challenge`、目标主机、Nginx 模板摘要、五域名解析和可用内存检查通过后,创建空的 root 独占目录,不复制任何既有 ACME 账号或授权缓存。
- 证据目录:`/var/tmp/smail-review-http-challenge-20260918-022531`。
- 独立账号目录:`/var/lib/smail-acme-http-review-20260918-022531`,权限 0700,仅保存在服务器。
- ACME 单元:`smail-http-challenge-20260918-022531`,运行 34.751 秒,峰值 10.6 MiB,交换空间为零,退出码 1。
- `acme.log` 记录新账号密钥生成、五份新授权、五次 HTTP-01 求解及 403 失败;不是缓存授权续期。
- 单元无法访问线上应用、管理 socket、Nginx 配置及正式 ACME 状态;仅独立账号目录和公开验证子目录可写。未调用正式部署钩子。
`result.json` 为 `failed`,但 `servicesUnchanged`、`protectedUnchanged`、`challengeFilesRestored`、`probesPassed`、`testUnitStopped` 均为真。两个站点累计 138 次可信 HTTPS 探测全部通过。Nginx 主进程 578671、Smail 574584、Scert 589832、MySQL 589785 及 InvocationID 均保持不变。
## 403 的证据边界
02:27 只读核查显示,服务器自身通过回环或公网 IP 访问不存在的验证文件均返回 Nginx 的 404,没有错误跳转。Nginx 访问日志在 02:25:38、43、48、54、59 记录来自 CA 验证节点的五个实际挑战请求,状态均为 200,其中两次发送 87 字节,三次发送字节数为零;错误日志没有对应文件权限错误。
与此同时,CA 报告五个域名均收到 403。这证明本站成功响应不足以代表 CA 验证成功;尚未确定是某条公网路径、其他验证节点还是中间设备造成差异。没有据此宣称云厂商或本站某条规则是已确认根因,也未通过重复申请掩盖失败。
## 后续门槛
1. 优先取得 Smail 专用最小权限 DNS 授权,在远程受保护文件配置,不借用其他项目凭据,不在聊天或仓库保存秘密。
2. 如继续 HTTP 方案,先定位公网 403 差异,再用全新测试账号确认五个域名全部通过;当前不能直接部署。
3. 验证正式签发、同密钥发布、平滑重载、七个 TLS 入口及故障回退,再同步安装续期脚本、发布脚本和 service,避免新旧锁协议混用。
4. 接入真实失败及到期告警;业务浏览器、管理员恢复、容量及灾难恢复等原发布门槛不因此缩减。

View File

@ -1,5 +1,11 @@
# 无停机续期整改记录
## 2026 年 9 月 18 日 HTTP 备用方案复核
02:25 使用全新测试 CA 账号完成五域名真实 HTTP-01 挑战尝试,全部被 CA 判为 403,未取得证书。Nginx 同时段访问日志中五个对应请求均记录为 200,不能将失败直接归因于本站文件权限,也不能仅凭本站日志批准公网挑战可用。两个站点 138 次连续 HTTPS 探测全部成功,线上四个服务进程、正式证书、私钥、应用和旧续期任务均未变化。
候选新增显式 `SMAIL_ACME_CHALLENGE=http`,仅使用现有 Nginx Webroot,不监听新端口、不停止服务、不自动从 DNS 降级。41 项远程隔离回归通过,包括 HTTP 子进程掩码与 root 独占账号目录检查。HTTP 模式尚未启用,DNS 专用授权仍缺失,正式换证和外部告警仍未验收。详情见《HTTP无停机续期验收记录》;下方保留此前 DNS 整改阶段的证据。
## 范围与当前结论
远程主机时间:2026-09-18 00:00 至 00:35,时区为 Asia/Shanghai。测试仅在 `47.108.78.12` 执行,本机仅编辑和传输。本文中的日期以远程命令及证据时间为准。

View File

@ -2,6 +2,14 @@
## 范围与结论
### 当前进展:无停机 HTTP 备用方案真实挑战受阻
- [✓] 候选支持显式 HTTP Webroot,不抢占端口、不停止 Nginx;41 项远程隔离回归通过,保留默认 DNS 及校验、回退机制。
- [✓] 02:25 全新测试账号挑战失败期间,两站点 138 次 HTTPS 探测通过,四个线上服务进程、正式文件和旧续期任务均未变化。
- [!] CA 对五域名 HTTP 挑战均报告 403,但对应 Nginx 日志为 200,公网差异仍待定位,不能启用 HTTP 模式。DNS 专用授权仍缺失,线上无停机自动续期尚未落地,整体生产放行仍不通过。
详见《HTTP无停机续期验收记录》。以下各阶段结论仅覆盖各自验证范围。
### 当前进展:完整候选反代隔离运行通过
- [✓] 2026 年 9 月 18 日,`review-c1dd533` 完整 Linux 候选六文件上传至 47 服务器,长度、摘要、ELF 格式核对通过;主前端和 Telegram 界面均使用新生产构建。

View File

@ -96,6 +96,7 @@ class RenewalTests(unittest.TestCase):
return subprocess.CompletedProcess(command, 1)
if command[0].endswith('/lego'):
self.assertEqual(kwargs['timeout'], 600)
self.assertEqual(kwargs['umask'], 0o077)
self.assertEqual(command[command.index('--dns') + 1], 'alidns')
self.assertNotIn('--tls', command)
self.assertIn('--server', command)

View File

@ -0,0 +1,152 @@
'''无停机 HTTP 验证选项回归,仅使用隔离目录和模拟续期命令。'''
from contextlib import nullcontext
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
BASE = Path(__file__).resolve().parent
SCRIPTS = BASE if (BASE / 'renew_certificate.py').exists() else BASE.parents[1] / 'deploy/nginx'
sys.path.insert(0, str(SCRIPTS))
import renew_certificate as renew
class WebrootTests(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp(prefix='webroot-tests-', dir=Path.cwd()))
self.webroot = self.directory / 'public'
self.challenge = self.webroot / '.well-known/acme-challenge'
self.challenge.mkdir(parents=True)
for path in (self.webroot, self.challenge.parent, self.challenge):
path.chmod(0o755)
for context in (patch.object(renew, 'WEBROOT', self.webroot),
patch.object(renew, 'ROOT', self.directory),
patch.dict(os.environ, {'SMAIL_ACME_CHALLENGE': 'http'}, clear=True)):
context.start()
self.addCleanup(context.stop)
def execute(self, failure=None, due=True):
calls = []
def run(command, **kwargs):
calls.append(command)
if '-checkend' in command:
return subprocess.CompletedProcess(command, 1 if due else 0)
if command[0].endswith('/lego'):
self.assertEqual(kwargs['timeout'], 600)
self.assertEqual(kwargs['umask'], 0o022)
self.assertEqual(command[command.index('--http.webroot') + 1], str(self.webroot))
for required in ('--http', '--reuse-key', '--no-random-sleep'):
self.assertIn(required, command)
for forbidden in ('--dns', '--tls', '--http.port'):
self.assertNotIn(forbidden, command)
if failure:
raise failure
return subprocess.CompletedProcess(command, 0)
with patch.object(renew.os, 'geteuid', return_value=0), \
patch.object(renew.deploy, 'certificate_lock', return_value=nullcontext()), \
patch.object(renew.deploy, 'check_nginx_paths'), \
patch.object(renew.deploy, 'publish') as publish, \
patch.object(renew.subprocess, 'run', side_effect=run):
if failure:
with self.assertRaises(type(failure)):
renew.main()
publish.assert_not_called()
else:
renew.main()
publish.assert_called_once()
self.assertFalse(any(command[:2] in (['systemctl', 'stop'], ['systemctl', 'start'])
for command in calls))
return calls
def test_http_explicitly_uses_existing_webroot(self):
self.assertEqual(renew.challenge_arguments(), ['--http', '--http.webroot', str(self.webroot)])
def test_due_http_success_reuses_key_without_port_binding(self):
self.assertTrue(any(command[0].endswith('/lego') for command in self.execute()))
def test_due_http_failure_does_not_publish_or_stop(self):
self.execute(subprocess.CalledProcessError(1, 'lego'))
def test_due_http_timeout_does_not_publish_or_stop(self):
self.execute(subprocess.TimeoutExpired('lego', 600))
def test_not_due_does_not_request_new_certificate(self):
self.assertFalse(any(command[0].endswith('/lego') for command in self.execute(due=False)))
def test_invalid_method_does_not_fall_back(self):
for method in ('', 'tls', 'HTTP', 'http dns'):
with self.subTest(method=method), patch.dict(os.environ, {'SMAIL_ACME_CHALLENGE': method}):
with self.assertRaises(RuntimeError):
renew.challenge_arguments()
def test_dns_remains_explicit_and_requires_provider(self):
with patch.dict(os.environ, {'SMAIL_ACME_CHALLENGE': 'dns'}):
with self.assertRaises(RuntimeError):
renew.challenge_arguments()
with patch.dict(os.environ, {'SMAIL_ACME_DNS_PROVIDER': 'alidns'}):
self.assertEqual(renew.challenge_arguments(), ['--dns', 'alidns'])
def test_missing_directory_is_rejected(self):
with patch.object(renew, 'WEBROOT', self.directory / 'missing'):
with self.assertRaises(FileNotFoundError):
renew.challenge_arguments()
def test_public_account_directory_is_rejected(self):
self.directory.chmod(0o755)
try:
with self.assertRaises(RuntimeError):
renew.challenge_arguments()
finally:
self.directory.chmod(0o700)
def test_child_umask_preserves_private_parent_and_readable_challenge(self):
token = self.challenge / 'token'
key = self.directory / 'key'
previous = os.umask(0o077)
try:
subprocess.run([
sys.executable, '-c',
'import os,sys; fd=os.open(sys.argv[1],os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o644); os.close(fd)',
str(token),
], check=True, umask=0o022, timeout=10)
key.write_bytes(b'private-test-data')
self.assertEqual(token.stat().st_mode & 0o777, 0o644)
self.assertEqual(key.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.directory.stat().st_mode & 0o777, 0o700)
finally:
os.umask(previous)
def test_unsafe_permissions_on_each_directory_are_rejected(self):
for directory in (self.webroot, self.challenge.parent, self.challenge):
for mode in (0o775, 0o777, 0o750, 0o700):
with self.subTest(directory=directory.name, mode=oct(mode)):
directory.chmod(mode)
try:
with self.assertRaises(RuntimeError):
renew.challenge_arguments()
finally:
directory.chmod(0o755)
def test_symlink_directory_is_rejected(self):
link = self.directory / 'linked'
link.symlink_to(self.webroot, target_is_directory=True)
with patch.object(renew, 'WEBROOT', link), self.assertRaises(RuntimeError):
renew.challenge_arguments()
def test_regular_file_is_not_webroot(self):
path = self.directory / 'file'
path.write_bytes(b'not-a-directory')
path.chmod(0o755)
with patch.object(renew, 'WEBROOT', path), self.assertRaises(RuntimeError):
renew.challenge_arguments()
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@ -0,0 +1,177 @@
'''使用全新 ACME 测试账号验证公网 HTTP 挑战,不停服,不发布测试证书。'''
import argparse
from datetime import datetime, timezone
import hashlib
import http.client
import json
import os
from pathlib import Path
import socket
import subprocess
import threading
import time
import nginx_certificate_cutover as monitor
import renew_certificate as renew
SERVER = 'https://acme-staging-v02.api.letsencrypt.org/directory'
CONFIG = Path('/etc/nginx/conf.d/smail.conf')
CONFIG_SHA = 'bf6816c38f9c639ab17fd7344fff4b06afc5a2388f74318e18c60b8b3311ebd8'
def run(*command):
return subprocess.check_output(command, text=True, timeout=15).strip()
def file_state(path):
info = path.stat()
return {'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'uid': info.st_uid, 'gid': info.st_gid, 'mode': oct(info.st_mode & 0o777)}
def service_state():
return {name: run('systemctl', 'show', name, '--property=ActiveState,MainPID,InvocationID,NRestarts')
for name in ('smail.service', 'nginx.service', 'Scert.service', 'mysql.service')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--confirm-staging-challenge', action='store_true')
args = parser.parse_args()
if not args.confirm_staging_challenge or os.geteuid() != 0 or run('hostname') != 'ikun2':
parser.error('只允许在 47 服务器明确执行测试 CA 的全新 HTTP 挑战')
if file_state(CONFIG)['sha256'] != CONFIG_SHA:
parser.error('Nginx 配置与已审查模板不符,停止演练')
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
if int(memory['MemAvailable'].split()[0]) < 256 * 1024:
parser.error('可用内存不足,停止演练')
run('nginx', '-t')
run('systemctl', 'is-active', 'nginx', 'smail', 'Scert', 'mysql')
for domain in renew.DOMAINS:
addresses = {entry[4][0] for entry in socket.getaddrinfo(domain, 80, type=socket.SOCK_STREAM)}
if addresses != {'47.108.78.12'}:
parser.error('域名解析与本次授权目标不符:' + domain)
connection = http.client.HTTPConnection('127.0.0.1', 80, timeout=5)
try:
connection.request('GET', '/.well-known/acme-challenge/preflight-absent', headers={'Host': domain})
response = connection.getresponse()
response.read()
if response.status != 404:
parser.error('验证路径未返回预期状态:' + domain)
finally:
connection.close()
os.umask(0o077)
stamp = time.strftime('%Y%m%d-%H%M%S')
evidence = Path('/var/tmp') / ('smail-review-http-challenge-' + stamp)
state = Path('/var/lib') / ('smail-acme-http-review-' + stamp)
evidence.mkdir(mode=0o700)
state.mkdir(mode=0o700)
assert not list(state.iterdir()), '必须使用全新账号目录,不能复用授权缓存'
renew.ROOT = state
os.environ['SMAIL_ACME_CHALLENGE'] = 'http'
challenge = renew.challenge_arguments()
protected = [Path('/opt/smail/smail'), Path('/opt/smail/smail-admin'),
Path('/opt/smail/ssl/custom.crt'), Path('/opt/smail/ssl/custom.key'),
Path('/usr/local/libexec/smail-acme/renew_certificate.py'),
Path('/usr/local/libexec/smail-acme/deploy_certificate.py'),
Path('/etc/systemd/system/smail-certificate-renew.service')]
protected.extend(sorted(Path('/etc/nginx/conf.d').glob('*.conf')))
protected.extend(sorted(Path('/var/lib/smail-acme-tls/certificates').glob('solit.cn.*')))
before = {str(path): file_state(path) for path in protected}
before_services = service_state()
challenge_dir = renew.WEBROOT / '.well-known/acme-challenge'
before_tokens = {path.name: file_state(path) for path in challenge_dir.iterdir()}
report = {'status': 'running', 'startedUTC': datetime.now(timezone.utc).isoformat(),
'stagingOnly': True, 'freshAccount': True, 'productionDeploymentExercised': False,
'evidence': str(evidence), 'state': str(state), 'beforeServices': before_services,
'protectedBefore': before, 'samples': []}
stop = threading.Event()
def sample():
for host in ('mail.solit.cn', 'web.apmmk.cn'):
value = {'host': host, 'timeUTC': datetime.now(timezone.utc).isoformat()}
try:
value['bytes'] = len(monitor.request(host, 443))
value['ok'] = True
except Exception as error:
value.update(ok=False, error=type(error).__name__)
report['samples'].append(value)
def watch():
while not stop.wait(0.5):
sample()
unit = 'smail-http-challenge-' + stamp
command = [
'systemd-run', '--wait', '--pipe', '--unit=' + unit,
'--property=Type=exec', '--property=UMask=0022',
'--property=ProtectSystem=strict', '--property=ProtectHome=yes',
'--property=NoNewPrivileges=yes', '--property=PrivateTmp=yes',
'--property=InaccessiblePaths=/opt/smail /run/smail /etc/nginx /var/lib/smail-acme-tls',
'--property=ReadWritePaths=' + str(state) + ' ' + str(challenge_dir),
'--property=CapabilityBoundingSet=', '--property=MemoryHigh=96M',
'--property=MemoryMax=128M', '--property=MemorySwapMax=0', '--property=CPUQuota=100%',
'--property=TasksMax=64', '--property=RuntimeMaxSec=600s',
'--property=TimeoutStopSec=15s', '--property=KillMode=control-group',
'--property=OOMPolicy=stop', '--setenv=GOMEMLIMIT=64MiB',
'/usr/local/libexec/smail-acme/lego', '--path', str(state), '--server', SERVER,
'--email', '', '--accept-tos', '--key-type', 'rsa2048',
'--http-timeout', '20', '--cert.timeout', '30', *challenge,
]
for domain in renew.DOMAINS:
command.extend(['--domains', domain])
command.append('run')
thread = threading.Thread(target=watch, daemon=True)
with renew.deploy.certificate_lock():
try:
sample()
assert all(item['ok'] for item in report['samples']), '演练前业务探测失败'
thread.start()
with (evidence / 'acme.log').open('xb') as log:
result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, timeout=630)
report['exitCode'] = result.returncode
assert result.returncode == 0, '全新 HTTP 挑战失败,日志已保留'
certificate = state / 'certificates/solit.cn.crt'
key = state / 'certificates/solit.cn.key'
public_cert = run('openssl', 'x509', '-in', str(certificate), '-pubkey', '-noout')
public_key = run('openssl', 'pkey', '-in', str(key), '-pubout')
assert public_cert == public_key
for domain in renew.DOMAINS:
run('openssl', 'x509', '-in', str(certificate), '-checkhost', domain, '-noout')
report['certificate'] = file_state(certificate)
report['domains'] = list(renew.DOMAINS)
report['certificateMetadata'] = run('openssl', 'x509', '-in', str(certificate),
'-noout', '-issuer', '-dates', '-ext', 'subjectAltName')
report['status'] = 'passed'
except BaseException as error:
report.update(status='failed', error=type(error).__name__)
raise
finally:
try:
stopped = subprocess.run(['systemctl', 'stop', unit], capture_output=True, text=True, timeout=25)
report['testUnitStopped'] = stopped.returncode == 0 or 'not loaded' in stopped.stderr
sample()
finally:
stop.set()
if thread.ident:
thread.join(timeout=10)
report['afterServices'] = service_state()
report['servicesUnchanged'] = report['afterServices'] == before_services
report['protectedUnchanged'] = all(file_state(path) == before[str(path)] for path in protected)
report['challengeFilesRestored'] = before_tokens == {
path.name: file_state(path) for path in challenge_dir.iterdir()}
report['probesPassed'] = bool(report['samples']) and all(item['ok'] for item in report['samples'])
if not all(report.get(key) for key in ('testUnitStopped', 'servicesUnchanged',
'protectedUnchanged', 'challengeFilesRestored', 'probesPassed')):
report['status'] = 'failed'
report['finishedUTC'] = datetime.now(timezone.utc).isoformat()
(evidence / 'result.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps({key: value for key, value in report.items()
if key not in ('samples', 'protectedBefore')}, ensure_ascii=False, indent=2))
assert report['status'] == 'passed'
if __name__ == '__main__':
main()