144 lines
6.5 KiB
Python
144 lines
6.5 KiB
Python
"""仅在明确授权的维护窗口内执行远程测试环境续期演练。"""
|
|
|
|
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()
|