"""仅在 47 服务器备份并升级 Smail,保留双库和失败证据。""" import argparse import hashlib import json import os from pathlib import Path import shutil import sqlite3 import subprocess import time ROOT = Path('/opt/smail') STAGING = Path('/var/tmp/smail-review-20260917') DATABASES = ('smail.db', 'smail-state.db') def run(*args): return subprocess.check_output(args, text=True).strip() def digest(path): with path.open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def snapshot(source, target): with sqlite3.connect(f'file:{source}?mode=ro', uri=True) as original: with sqlite3.connect(target) as copied: original.backup(copied) assert copied.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' target.chmod(0o600) def wait_ready(): for _ in range(60): result = subprocess.run([ 'curl', '--silent', '--fail', '--max-time', '2', '--noproxy', '*', '--cacert', str(ROOT / 'ssl/custom.crt'), '--resolve', 'mail.solit.cn:443:127.0.0.1', 'https://mail.solit.cn/api/ping', ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode == 0 and run('systemctl', 'is-active', 'smail.service') == 'active': return time.sleep(1) raise RuntimeError('升级后服务未就绪') def main(): parser = argparse.ArgumentParser() parser.add_argument('--main-sha', required=True) parser.add_argument('--admin-sha', required=True) args = parser.parse_args() os.umask(0o077) assert os.geteuid() == 0 and ROOT.resolve() == ROOT and not ROOT.is_symlink() assert run('hostname') == 'ikun2' assert '172.31.134.42/20' in run('ip', '-4', 'address', 'show') assert run('systemctl', 'show', 'smail.service', '--property=User', '--value') == 'smail' assert run('systemctl', 'is-active', 'smail.service') == 'active' expected = {'smail': args.main_sha, 'smail-admin': args.admin_sha} for name, checksum in expected.items(): path = STAGING / name with path.open('rb') as binary: header = binary.read(20) assert header[:5] == b'\x7fELF\x02' and header[18:20] == b'\x3e\x00' assert digest(path) == checksum assert not (ROOT / name).is_symlink() backup = Path('/var/backups') / time.strftime('smail-before-outbox-%Y%m%d-%H%M%S') backup.mkdir(mode=0o700) installed = False subprocess.run(['systemctl', 'stop', 'smail.service'], check=True) try: # 停服归档包含配置、密钥和日志,仅保存在远程受保护目录。 archive = backup / 'smail.tar.gz' subprocess.run(['tar', '-czf', str(archive), '-C', '/opt', 'smail'], check=True) subprocess.run(['tar', '-dzf', str(archive), '-C', '/opt'], check=True) archive.chmod(0o600) for name in DATABASES: source = ROOT / 'data' / name assert source.resolve() == source and not source.is_symlink() snapshot(source, backup / name) for name in expected: shutil.copy2(ROOT / name, backup / name) installed = True for name, checksum in expected.items(): subprocess.run(['install', '-m', '0755', '-o', 'root', '-g', 'root', str(STAGING / name), str(ROOT / name)], check=True) assert digest(ROOT / name) == checksum subprocess.run(['systemctl', 'start', 'smail.service'], check=True) wait_ready() for name in DATABASES: with sqlite3.connect(f'file:{ROOT / "data" / name}?mode=ro', uri=True) as database: assert database.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' with sqlite3.connect(f'file:{ROOT / "data/smail.db"}?mode=ro', uri=True) as database: version = database.execute('SELECT MAX(version) FROM schema_migration').fetchone()[0] assert version == 23 states = dict(database.execute('SELECT state,COUNT(*) FROM outbound_delivery GROUP BY state')) result = {'backup': str(backup), 'archive_sha256': digest(archive), 'artifacts': expected, 'migration': version, 'queue_states': states, 'service': run('systemctl', 'is-active', 'smail.service'), 'admin_version': run(str(ROOT / 'smail-admin'), 'version'), 'database_integrity': 'ok', 'database_reset': False} (backup / 'result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') print(json.dumps(result, ensure_ascii=False, indent=2)) except BaseException: if installed: subprocess.run(['systemctl', 'stop', 'smail.service'], check=True) for name in DATABASES: snapshot(ROOT / 'data' / name, backup / ('failed-' + name)) snapshot(backup / name, ROOT / 'data' / name) shutil.chown(ROOT / 'data' / name, user='smail', group='smail') for name in expected: shutil.copy2(backup / name, ROOT / name) subprocess.run(['systemctl', 'start', 'smail.service'], check=True) print('升级失败,备份及故障证据保留于:' + str(backup)) raise if __name__ == '__main__': main()