Smail/test/tools/runtime_review.py
smail f265cc62f5
Some checks are pending
Security Checks / security (push) Waiting to run
补充云端验收证据并加固测试凭据检查
2026-09-21 00:21:50 +08:00

240 lines
13 KiB
Python

"""在 47 服务器创建空数据副本并进行受限验收,不停止线上服务或删除文件。"""
import argparse
from contextlib import closing
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import sqlite3
import subprocess
import time
def run(*args):
return subprocess.check_output(args, text=True).strip()
def service_state():
return {name: run('systemctl', 'show', name, '--property=MainPID,NRestarts,ActiveState,InvocationID')
for name in ('smail.service', 'nginx.service', 'mysql.service', 'Scert.service')}
def database_integrity(root):
# 验收器不能创建数据库,也不能允许第二个状态库。
data = root / 'data'
assert not (data / 'smail-state.db').exists(), '单数据库实例生成了旧状态库'
assert sorted(path.name for path in data.glob('*.db')) == ['smail.db']
with closing(sqlite3.connect((data / 'smail.db').as_uri() + '?mode=ro', uri=True)) as database:
integrity = database.execute('PRAGMA integrity_check').fetchall()
assert database.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='config_records'").fetchone()[0] == 1
assert integrity == [('ok',)]
return {'smail.db': integrity}
def stop_test_units(names):
assert all(re.fullmatch(r'smail-runtime-(review|probe|nginx)-[0-9]{8}-[0-9]{6}', name)
for name in names), '只能停止本轮隔离测试单元'
errors = []
for name in names:
try:
stopped = subprocess.run(['systemctl', 'stop', name], capture_output=True,
text=True, timeout=25)
if stopped.returncode and 'not loaded' not in stopped.stderr:
raise subprocess.CalledProcessError(stopped.returncode, 'systemctl')
except (OSError, subprocess.SubprocessError) as error:
errors.append({'unit': name, 'action': 'stop', 'error': type(error).__name__})
try:
subprocess.run(['systemctl', 'kill', '--kill-whom=all', '--signal=SIGKILL', name],
check=True, capture_output=True, timeout=15)
except (OSError, subprocess.SubprocessError) as kill_error:
errors.append({'unit': name, 'action': 'kill', 'error': type(kill_error).__name__})
return errors
def collect_and_stop(result, unit, probe_unit, proxy_unit=None):
result['resource_errors'] = []
try:
for key, name in [('resources', unit)] + ([('nginx_resources', proxy_unit)] if proxy_unit else []):
try:
result[key] = subprocess.check_output([
'systemctl', 'show', name,
'--property=MemoryPeak,MemoryCurrent,MemorySwapPeak,Result,ExecMainStatus,NRestarts',
], text=True, timeout=15).strip()
except (OSError, subprocess.SubprocessError) as error:
result['resource_errors'].append({'unit': name, 'error': type(error).__name__})
finally:
names = ([proxy_unit] if proxy_unit else []) + [probe_unit, unit]
result['cleanup_errors'] = stop_test_units(names)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--main-sha', required=True)
parser.add_argument('--binary', type=Path, default=Path('/opt/smail/smail'))
parser.add_argument('--nginx-template', type=Path)
parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128)
parser.add_argument('--restore', action='store_true')
args = parser.parse_args()
assert os.geteuid() == 0 and run('hostname') == 'ikun2'
os.umask(0o027)
live = Path('/opt/smail')
binary = args.binary.resolve(strict=True)
assert binary == args.binary and binary.is_file()
if binary != live / 'smail':
assert binary.parent.parent == Path('/var/tmp') and binary.parent.name.startswith('smail-review-')
assert binary.stat().st_uid == 0 and binary.stat().st_mode & 0o022 == 0
assert binary.parent.stat().st_uid == 0 and binary.parent.stat().st_mode & 0o022 == 0
with binary.open('rb') as stream:
assert hashlib.file_digest(stream, 'sha256').hexdigest() == args.main_sha
stream.seek(0)
header = stream.read(20)
assert header[:5] == b'\x7fELF\x02' and header[18:20] == b'\x3e\x00'
with (live / 'smail').open('rb') as stream:
live_sha = hashlib.file_digest(stream, 'sha256').hexdigest()
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
required_memory = args.memory_max_mib + (192 if args.nginx_template else 144)
assert int(memory['MemAvailable'].split()[0]) >= required_memory * 1024, '可用内存不足,暂停隔离验收'
stamp = time.strftime('%Y%m%d-%H%M%S')
target = Path('/var/tmp') / ('smail-review-runtime-' + stamp)
target.mkdir(mode=0o750)
shutil.chown(target, user='smail', group='smail')
root = target / 'instance'
control = target / 'control'
for directory in (root, control, root / 'ssl'):
directory.mkdir(mode=0o750)
shutil.chown(directory, user='smail', group='smail')
shutil.copy2(binary, root / 'smail')
(root / 'smail').chmod(0o755)
shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py')
(target / 'runtime_probe.py').chmod(0o640)
shutil.chown(target / 'runtime_probe.py', user='root', group='smail')
if args.nginx_template:
shutil.copy2(Path(__file__).with_name('runtime_proxy.py'), target / 'runtime_proxy.py')
(target / 'runtime_proxy.py').chmod(0o640)
shutil.chown(target / 'runtime_proxy.py', user='root', group='smail')
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2',
'-keyout', str(root / 'ssl/runtime.key'), '-out', str(root / 'ssl/runtime.crt'),
'-subj', '/CN=mail.runtime.invalid', '-addext',
'subjectAltName=DNS:runtime.invalid,DNS:*.runtime.invalid'],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for name in ('runtime.key', 'runtime.crt'):
path = root / 'ssl' / name
path.chmod(0o600)
shutil.chown(path, user='smail', group='smail')
assert not (root / 'data').exists() and not (root / 'config.json').exists()
before = service_state()
unit = 'smail-runtime-review-' + stamp
probe_unit = 'smail-runtime-probe-' + stamp
proxy_unit = 'smail-runtime-nginx-' + stamp
result = {'target': str(target), 'unit': unit, 'main_sha256': args.main_sha,
'memory_max_mib': args.memory_max_mib,
'source': str(binary), 'live_sha256': live_sha, 'before': before,
'topology': 'nginx-8001-8002' if args.nginx_template else 'direct-80-443'}
service_env = []
probe_args = []
if args.nginx_template:
from runtime_proxy import prepare_nginx
prepare_nginx(target, root, args.nginx_template)
result['nginx_template_sha256'] = hashlib.sha256(args.nginx_template.read_bytes()).hexdigest()
service_env = ['--setenv=setup_port=8001', '--setenv=SMAIL_HTTP_PORT=8001',
'--setenv=SMAIL_HTTPS_PORT=8002']
probe_args = ['--proxy']
started = time.strftime('%Y-%m-%dT%H:%M:%S')
password = secrets.token_urlsafe(9)
try:
subprocess.run([
'systemd-run', '--unit=' + unit, '--property=Type=exec',
'--property=User=smail', '--property=Group=smail',
'--property=PrivateNetwork=yes', '--property=PrivateTmp=yes',
'--property=ProtectSystem=strict', '--property=ProtectHome=yes',
'--property=NoNewPrivileges=yes', '--property=CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
'--property=AmbientCapabilities=CAP_NET_BIND_SERVICE',
'--property=MemoryMax=' + str(args.memory_max_mib) + 'M',
'--property=MemorySwapMax=0', '--property=CPUQuota=25%',
'--property=TasksMax=128', '--property=RuntimeMaxSec=300s', '--property=OOMPolicy=stop',
'--property=KillMode=control-group', '--property=TimeoutStopSec=10s',
'--property=BindPaths=' + str(root) + ':/opt/smail ' + str(control) + ':/run/smail',
'--property=ReadWritePaths=/opt/smail /run/smail',
'--property=WorkingDirectory=/opt/smail', '--setenv=SMAIL_ROOT=/opt/smail',
'--setenv=GOMEMLIMIT=64MiB', '--setenv=GOMAXPROCS=1', *service_env, '/opt/smail/smail',
], check=True)
pid = run('systemctl', 'show', unit, '--property=MainPID', '--value')
assert pid != '0'
live_net = str(Path('/proc/self/ns/net').readlink())
assert str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net
assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino
assert (root / 'smail').stat().st_ino != (live / 'smail').stat().st_ino
if args.nginx_template:
subprocess.run([
'systemd-run', '--unit=' + proxy_unit, '--property=Type=exec',
'--property=User=smail', '--property=Group=smail',
'--property=NetworkNamespacePath=/proc/' + pid + '/ns/net',
'--property=PrivateTmp=no', '--property=ProtectSystem=strict',
'--property=ProtectHome=yes', '--property=NoNewPrivileges=yes',
'--property=InaccessiblePaths=/opt/smail /run/smail /etc/nginx',
'--property=ReadWritePaths=' + str(target / 'nginx'),
'--property=CapabilityBoundingSet=CAP_NET_BIND_SERVICE',
'--property=AmbientCapabilities=CAP_NET_BIND_SERVICE',
'--property=MemoryMax=32M', '--property=MemorySwapMax=0',
'--property=CPUQuota=25%', '--property=TasksMax=16',
'--property=RuntimeMaxSec=300s', '--property=OOMPolicy=stop',
'--property=KillMode=control-group', '--property=TimeoutStopSec=10s',
'/usr/sbin/nginx', '-p', str(target / 'nginx'),
'-c', str(target / 'nginx/nginx.conf'), '-g', 'daemon off;',
], check=True)
probe = subprocess.run([
'systemd-run', '--wait', '--pipe', '--unit=' + probe_unit,
'--property=User=smail', '--property=Group=smail',
'--property=NetworkNamespacePath=/proc/' + pid + '/ns/net',
'--property=PrivateTmp=no', '--property=ProtectSystem=strict', '--property=ProtectHome=yes',
'--property=NoNewPrivileges=yes', '--property=InaccessiblePaths=/opt/smail /run/smail',
'--property=ReadWritePaths=' + str(target), '--property=MemoryMax=48M',
'--property=MemorySwapMax=0', '--property=CPUQuota=25%', '--property=TasksMax=32',
'--property=RuntimeMaxSec=240s', '--property=OOMPolicy=stop',
'--property=KillMode=control-group', '--property=TimeoutStopSec=10s',
'python3', str(target / 'runtime_probe.py'), str(root), '--live-net', live_net,
'--password-stdin', *probe_args,
], input=password + '\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270)
(target / 'probe.log').write_text(probe.stdout, encoding='utf-8')
result['probe_code'] = probe.returncode
if (target / 'probe-result.json').exists():
result['probe'] = json.loads((target / 'probe-result.json').read_text())
assert probe.returncode == 0, '隔离验收失败,请检查保留的 probe.log'
assert result['probe']['passed']
with sqlite3.connect(f'file:{live / "data/smail.db"}?mode=ro', uri=True) as database:
message_id = result['probe']['smtp']['message_id']
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 0
result['live_message_absent'] = True
finally:
collect_and_stop(result, unit, probe_unit, proxy_unit if args.nginx_template else None)
result['after'] = service_state()
with (live / 'smail').open('rb') as stream:
result['live_unchanged'] = (result['after'] == before
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
try:
result['integrity'] = database_integrity(root)
except (AssertionError, sqlite3.Error) as error:
result['integrity'] = {'error': type(error).__name__}
(target / 'service.log').write_text(run('journalctl', '-u', unit, '--no-pager'), encoding='utf-8')
kernel = run('journalctl', '-k', '--since', started, '--no-pager')
result['oom_events'] = sum('oom-kill:constraint=' in line for line in kernel.splitlines())
(target / 'result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(result, ensure_ascii=False, indent=2))
assert result['live_unchanged'] and result['oom_events'] == 0
assert not result['cleanup_errors'], '测试单元未全部停止,需要核查保留的结果'
assert not result['resource_errors'], '资源证据不完整,需要核查保留的结果'
assert result['integrity'] == {'smail.db': [('ok',)]}
if args.restore:
from runtime_restore import verify_backup_restore
restored = verify_backup_restore(root, result, password, args.nginx_template is not None)
(target / 'restore-result.json').write_text(json.dumps(restored, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps({'restore': restored}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()