80 lines
3.5 KiB
Python
80 lines
3.5 KiB
Python
#!/usr/bin/python3
|
||
'''使用显式 DNS 或 Webroot 验证续期,不停止 Nginx,校验通过后才发布。'''
|
||
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import stat
|
||
import subprocess
|
||
|
||
import deploy_certificate as deploy
|
||
|
||
|
||
ROOT = Path('/var/lib/smail-acme-tls')
|
||
DOMAINS = deploy.DOMAINS
|
||
RENEW_TIMEOUT = 600
|
||
WEBROOT = Path('/var/lib/smail-acme')
|
||
|
||
|
||
def dns_provider():
|
||
provider = os.environ.get('SMAIL_ACME_DNS_PROVIDER', '')
|
||
if not re.fullmatch(r'[a-z][a-z0-9_]{0,63}', provider) or provider == 'exec':
|
||
raise RuntimeError('未配置有效的 DNS 服务商,拒绝续期;不会回退到停机验证')
|
||
return provider
|
||
|
||
|
||
def challenge_arguments():
|
||
method = os.environ.get('SMAIL_ACME_CHALLENGE', 'dns')
|
||
if method == 'dns':
|
||
return ['--dns', dns_provider()]
|
||
if method != 'http':
|
||
raise RuntimeError('验证方式必须为 dns 或 http,不允许自动回退到停机验证')
|
||
if '*.solit.cn' in DOMAINS:
|
||
raise RuntimeError('泛域名证书必须使用 DNS 验证,不能使用 HTTP 验证')
|
||
private = ROOT.lstat()
|
||
if (ROOT.resolve(strict=True) != ROOT or not stat.S_ISDIR(private.st_mode)
|
||
or private.st_uid != 0 or private.st_mode & 0o077):
|
||
raise RuntimeError('ACME 账号目录必须由 root 独占,禁止公开访问')
|
||
for directory in (WEBROOT, WEBROOT / '.well-known', WEBROOT / '.well-known/acme-challenge'):
|
||
info = directory.lstat()
|
||
if (directory.resolve(strict=True) != directory or not stat.S_ISDIR(info.st_mode)
|
||
or info.st_uid != 0 or info.st_mode & 0o022
|
||
or info.st_mode & 0o005 != 0o005):
|
||
raise RuntimeError('HTTP 验证目录必须由 root 管理、可读取且不能被其他用户写入')
|
||
return ['--http', '--http.webroot', str(WEBROOT)]
|
||
|
||
|
||
def main():
|
||
if os.geteuid() != 0:
|
||
raise RuntimeError('必须使用 root 执行证书续期')
|
||
with deploy.certificate_lock():
|
||
certificate = str(deploy.TARGET / 'custom.crt')
|
||
subprocess.run(['openssl', 'x509', '-in', certificate, '-noout'], check=True)
|
||
result = subprocess.run(['openssl', 'x509', '-in', certificate, '-noout', '-checkend', '2592000'],
|
||
capture_output=True)
|
||
if result.returncode not in (0, 1):
|
||
raise RuntimeError('无法判断线上证书有效期,拒绝续期')
|
||
due = result.returncode == 1
|
||
if due:
|
||
challenge = challenge_arguments()
|
||
deploy.check_nginx_paths()
|
||
subprocess.run(['nginx', '-t'], check=True)
|
||
subprocess.run(['systemctl', 'is-active', '--quiet', 'nginx'], check=True)
|
||
command = ['/usr/local/libexec/smail-acme/lego', '--path', str(ROOT),
|
||
'--email', '', '--accept-tos', '--key-type', 'rsa2048', *challenge,
|
||
'--server', 'https://acme-v02.api.letsencrypt.org/directory',
|
||
'--http-timeout', '20', '--cert.timeout', '30']
|
||
for domain in DOMAINS:
|
||
command.extend(['--domains', domain])
|
||
command.extend(['renew', '--days', '30', '--reuse-key', '--no-random-sleep'])
|
||
subprocess.run(command, check=True, timeout=RENEW_TIMEOUT,
|
||
env={**os.environ, 'GOMEMLIMIT': '64MiB'},
|
||
umask=0o022 if '--http' in challenge else 0o077)
|
||
else:
|
||
print('线上证书有效期大于 30 天,本次不申请新证书')
|
||
deploy.publish()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|