194 lines
8.0 KiB
Python
194 lines
8.0 KiB
Python
#!/usr/bin/python3
|
||
"""校验证书并同步至 Smail,私钥与回退副本仅在服务器保存。"""
|
||
|
||
from datetime import datetime, timezone
|
||
from contextlib import contextmanager
|
||
import fcntl
|
||
import hashlib
|
||
import os
|
||
from pathlib import Path
|
||
import pwd
|
||
import shlex
|
||
import socket
|
||
import ssl
|
||
import stat
|
||
import subprocess
|
||
import time
|
||
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')
|
||
BACKUPS = Path('/var/backups')
|
||
LOCK = Path('/run/smail-certificate-renew.lock')
|
||
CA_FILE = '/etc/ssl/certs/ca-certificates.crt'
|
||
LIVE_PORTS = (443, 8002)
|
||
|
||
|
||
def run(*args, **kwargs):
|
||
kwargs.setdefault('timeout', 15)
|
||
return subprocess.run(args, check=True, capture_output=True, **kwargs).stdout
|
||
|
||
|
||
@contextmanager
|
||
def certificate_lock():
|
||
fd = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
|
||
with os.fdopen(fd, 'a') as stream:
|
||
info = os.fstat(stream.fileno())
|
||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1
|
||
or info.st_mode & 0o077):
|
||
raise RuntimeError('证书锁文件不安全')
|
||
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
yield
|
||
|
||
|
||
def read_file(path, uid):
|
||
if path.parent.resolve(strict=True) != path.parent:
|
||
raise RuntimeError('证书目录不能包含符号链接')
|
||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
|
||
with os.fdopen(fd, 'rb') as stream:
|
||
info = os.fstat(stream.fileno())
|
||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != uid
|
||
or info.st_mode & 0o077 or info.st_size > 1024 * 1024):
|
||
raise RuntimeError('证书文件类型、属主、权限或大小不符合要求')
|
||
data = stream.read(1024 * 1024 + 1)
|
||
if len(data) != info.st_size:
|
||
raise RuntimeError('读取期间证书发生变化')
|
||
return data
|
||
|
||
|
||
def check_nginx_paths():
|
||
configuration = run('nginx', '-T').decode('utf-8')
|
||
found = set()
|
||
expected = {'ssl_certificate': TARGET / 'custom.crt',
|
||
'ssl_certificate_key': TARGET / 'custom.key'}
|
||
for line in configuration.splitlines():
|
||
if not line.lstrip().startswith(('ssl_certificate ', 'ssl_certificate\t',
|
||
'ssl_certificate_key ', 'ssl_certificate_key\t')):
|
||
continue
|
||
tokens = shlex.split(line, comments=True)
|
||
if len(tokens) < 2 or tokens[0] not in expected:
|
||
continue
|
||
value = tokens[1].rstrip(';')
|
||
if value.startswith('/var/lib/smail-acme') or value.startswith(str(LINEAGE) + '/'):
|
||
raise RuntimeError('Nginx 仍引用签发目录,必须先迁移到已校验的线上证书路径')
|
||
if value == str(expected[tokens[0]]):
|
||
found.add(tokens[0])
|
||
if found != set(expected):
|
||
raise RuntimeError('未找到 Nginx 的 Smail 线上证书路径,拒绝发布')
|
||
|
||
|
||
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 validate(directory):
|
||
for domain in DOMAINS:
|
||
run('openssl', 'verify', '-CAfile', CA_FILE, '-purpose', 'sslserver',
|
||
'-untrusted', str(directory / 'candidate.issuer.crt'), '-verify_hostname', domain,
|
||
str(directory / 'candidate.crt'))
|
||
run('openssl', 'x509', '-in', str(directory / 'candidate.crt'), '-checkend', '604800', '-noout')
|
||
public_cert = run('openssl', 'x509', '-in', str(directory / 'candidate.crt'), '-pubkey', '-noout')
|
||
public_key = run('openssl', 'pkey', '-in', str(directory / 'candidate.key'), '-pubout')
|
||
if public_cert != public_key:
|
||
raise RuntimeError('证书与私钥不匹配')
|
||
# 普通续期只允许复用密钥,避免两个文件替换之间出现不匹配;密钥轮换另行维护。
|
||
previous_key = run('openssl', 'pkey', '-in', str(directory / 'previous.key'), '-pubout')
|
||
previous_cert = run('openssl', 'x509', '-in', str(directory / 'previous.crt'), '-pubkey', '-noout')
|
||
if public_key != previous_key or previous_key != previous_cert:
|
||
raise RuntimeError('无停机续期必须复用现有密钥,密钥轮换需要单独流程')
|
||
|
||
|
||
def verify_live_certificate(fingerprint):
|
||
context = ssl.create_default_context(cafile=CA_FILE)
|
||
for port in LIVE_PORTS:
|
||
deadline = time.monotonic() + 12
|
||
while True:
|
||
try:
|
||
with socket.create_connection(('127.0.0.1', port), timeout=2) as connection:
|
||
with context.wrap_socket(connection, server_hostname='mail.solit.cn') as secure:
|
||
actual = hashlib.sha256(secure.getpeercert(binary_form=True)).hexdigest()
|
||
if actual == fingerprint:
|
||
break
|
||
raise RuntimeError('线上入口未返回预期证书')
|
||
except (OSError, RuntimeError):
|
||
if time.monotonic() >= deadline:
|
||
raise RuntimeError('线上证书验证失败,端口=' + str(port)) from None
|
||
time.sleep(0.25)
|
||
|
||
|
||
def fingerprint(path):
|
||
return hashlib.sha256(run('openssl', 'x509', '-in', str(path), '-outform', 'DER')).hexdigest()
|
||
|
||
|
||
def publish():
|
||
check_nginx_paths()
|
||
run('systemctl', 'is-active', '--quiet', 'nginx')
|
||
identity = pwd.getpwnam('smail')
|
||
sources = {'candidate.crt': read_file(LINEAGE / 'solit.cn.crt', 0),
|
||
'candidate.key': read_file(LINEAGE / 'solit.cn.key', 0),
|
||
'candidate.issuer.crt': read_file(LINEAGE / 'solit.cn.issuer.crt', 0),
|
||
'previous.crt': read_file(TARGET / 'custom.crt', identity.pw_uid),
|
||
'previous.key': read_file(TARGET / 'custom.key', identity.pw_uid)}
|
||
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
|
||
backup = BACKUPS / ('smail-certificate-' + stamp)
|
||
backup.mkdir(mode=0o700)
|
||
print('证书校验及回退快照=' + str(backup), flush=True)
|
||
for name, data in sources.items():
|
||
replace_file(backup / name, data, 0, 0)
|
||
# 校验与发布使用同一份受保护快照,不重新读取可能变化的签发目录。
|
||
validate(backup)
|
||
expected = fingerprint(backup / 'candidate.crt')
|
||
changed = [suffix for suffix in ('key', 'crt')
|
||
if sources['candidate.' + suffix] != sources['previous.' + suffix]]
|
||
if not changed:
|
||
try:
|
||
verify_live_certificate(expected)
|
||
except RuntimeError:
|
||
# 恢复上次在文件发布后、重载前中断的任务;不能仅因文件相同就宣告成功。
|
||
run('nginx', '-t')
|
||
run('systemctl', 'reload', 'nginx')
|
||
verify_live_certificate(expected)
|
||
print('证书校验通过,线上入口与运行文件已同步')
|
||
return
|
||
try:
|
||
for suffix in changed:
|
||
replace_file(TARGET / ('custom.' + suffix), sources['candidate.' + suffix],
|
||
identity.pw_uid, identity.pw_gid)
|
||
run('nginx', '-t')
|
||
run('systemctl', 'reload', 'nginx')
|
||
verify_live_certificate(expected)
|
||
except Exception:
|
||
for suffix in changed:
|
||
replace_file(TARGET / ('custom.' + suffix), sources['previous.' + suffix],
|
||
identity.pw_uid, identity.pw_gid)
|
||
run('nginx', '-t')
|
||
run('systemctl', 'reload', 'nginx')
|
||
verify_live_certificate(fingerprint(backup / 'previous.crt'))
|
||
raise
|
||
print('证书已同步,SHA256=' + expected)
|
||
|
||
|
||
def main():
|
||
if os.geteuid() != 0:
|
||
raise RuntimeError('必须使用 root 执行证书同步')
|
||
with certificate_lock():
|
||
publish()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|