88 lines
3.5 KiB
Python
88 lines
3.5 KiB
Python
#!/usr/bin/python3
|
||
"""校验证书并同步至 Smail,私钥与回退副本仅在服务器保存。"""
|
||
|
||
from datetime import datetime, timezone
|
||
import hashlib
|
||
import os
|
||
from pathlib import Path
|
||
import pwd
|
||
import shutil
|
||
import subprocess
|
||
import uuid
|
||
|
||
|
||
DOMAINS = ('solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn')
|
||
LINEAGE = Path('/var/lib/smail-acme-tls/certificates')
|
||
TARGET = Path('/opt/smail/ssl')
|
||
|
||
|
||
def run(*args, **kwargs):
|
||
return subprocess.run(args, check=True, capture_output=True, **kwargs).stdout
|
||
|
||
|
||
def replace_file(path, data, uid, gid):
|
||
staged = path.with_name(path.name + '.new-' + uuid.uuid4().hex)
|
||
fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
with os.fdopen(fd, 'wb') as stream:
|
||
os.fchown(stream.fileno(), uid, gid)
|
||
stream.write(data)
|
||
stream.flush()
|
||
os.fsync(stream.fileno())
|
||
os.replace(staged, path)
|
||
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||
try:
|
||
os.fsync(fd)
|
||
finally:
|
||
os.close(fd)
|
||
|
||
|
||
def main():
|
||
if os.geteuid() != 0:
|
||
raise RuntimeError('必须使用 root 执行证书同步')
|
||
# 先验证信任链、服务域名、有效期和密钥匹配,再修改运行文件。
|
||
for domain in DOMAINS:
|
||
run('openssl', 'verify', '-CAfile', '/etc/ssl/certs/ca-certificates.crt',
|
||
'-untrusted', str(LINEAGE / 'solit.cn.issuer.crt'), '-verify_hostname', domain,
|
||
str(LINEAGE / 'solit.cn.crt'))
|
||
run('openssl', 'x509', '-in', str(LINEAGE / 'solit.cn.crt'), '-checkend', '604800', '-noout')
|
||
public_cert = run('openssl', 'x509', '-in', str(LINEAGE / 'solit.cn.crt'), '-pubkey', '-noout')
|
||
public_key = run('openssl', 'pkey', '-in', str(LINEAGE / 'solit.cn.key'), '-pubout')
|
||
if public_cert != public_key:
|
||
raise RuntimeError('证书与私钥不匹配')
|
||
if TARGET.is_symlink() or TARGET.resolve() != TARGET:
|
||
raise RuntimeError('证书目标目录不能包含符号链接')
|
||
pairs = [('solit.cn.key', 'custom.key'), ('solit.cn.crt', 'custom.crt')]
|
||
for _, name in pairs:
|
||
path = TARGET / name
|
||
if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1:
|
||
raise RuntimeError('现有证书文件类型不符合要求')
|
||
if all((LINEAGE / source).read_bytes() == (TARGET / name).read_bytes() for source, name in pairs):
|
||
print('证书校验通过,运行文件已同步')
|
||
return
|
||
identity = pwd.getpwnam('smail')
|
||
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
|
||
backup = Path('/var/backups') / ('smail-certificate-' + stamp)
|
||
backup.mkdir(mode=0o700)
|
||
for _, name in pairs:
|
||
path = TARGET / name
|
||
if path.is_symlink() or not path.is_file():
|
||
raise RuntimeError('现有证书文件类型不符合要求')
|
||
shutil.copy2(path, backup / name)
|
||
os.chmod(backup / name, 0o600)
|
||
try:
|
||
for source, name in pairs:
|
||
replace_file(TARGET / name, (LINEAGE / source).read_bytes(), identity.pw_uid, identity.pw_gid)
|
||
run('nginx', '-t')
|
||
run('systemctl', 'reload', 'nginx')
|
||
except Exception:
|
||
for _, name in pairs:
|
||
replace_file(TARGET / name, (backup / name).read_bytes(), identity.pw_uid, identity.pw_gid)
|
||
raise
|
||
fingerprint = hashlib.sha256(run('openssl', 'x509', '-in', str(TARGET / 'custom.crt'), '-outform', 'DER')).hexdigest()
|
||
print('证书已同步,SHA256=' + fingerprint)
|
||
print('回退副本=' + str(backup))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|