Files

755 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Clover host certificate worker. Runs in the isolated helper container."""
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import time
PROJECT = Path('/clover')
HOST = Path('/host')
ACME_IMAGE = 'certbot/certbot:v5.1.0'
DOCKER = shutil.which('docker') or '/usr/bin/docker'
NGINX_SITE = HOST / 'etc/nginx/conf.d/clover-panel.conf'
RECOVERY = PROJECT / 'deploy/acme/recovery.json'
STATUS = PROJECT / 'deploy/acme/status.json'
def run(args, timeout=40, check=True, input_text=None):
if args and args[0] == 'docker':
args = [DOCKER, *args[1:]]
result = subprocess.run(args, text=True, capture_output=True, timeout=timeout, input=input_text)
if check and result.returncode:
raise RuntimeError((result.stderr or result.stdout or '命令执行失败')[-2500:])
return result
def atomic(path, text, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(f'{path.name}.{os.getpid()}.{time.time_ns()}.tmp')
temp.write_text(text, encoding='utf-8')
temp.chmod(mode)
os.replace(temp, path)
def host_run(*args, timeout=40, check=True):
# The helper already shares the host PID/network namespaces. nsenter --root
# can be blocked by /proc hidepid/ptrace restrictions, so use the mounted host
# filesystem to invoke systemd/nginx from their real root instead.
return run(['chroot', str(HOST), *args], timeout=timeout, check=check)
class Manager:
def __init__(self):
self.env = {}
for line in (PROJECT / '.env').read_text(encoding='utf-8').splitlines():
if re.match(r'^[A-Z_][A-Z0-9_]*=', line):
key, value = line.split('=', 1)
self.env[key] = value.strip()
self.data = PROJECT / 'deploy/acme'
self.state = self.load(STATUS, {'mode': 'unconfigured', 'certificates': {}, 'autoRenew': False})
@staticmethod
def load(path, fallback):
try:
return json.loads(path.read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError):
return fallback
def save(self):
atomic(STATUS, json.dumps(self.state, ensure_ascii=False, indent=2))
def log(self, message):
self.state['message'] = message
self.state['updatedAt'] = dt.datetime.now(dt.timezone.utc).isoformat()
self.save()
print(message, flush=True)
def host_name(self):
host = self.env.get('PANEL_HOSTNAME', '').lower()
if len(host) > 253 or not re.fullmatch(r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}', host):
raise RuntimeError('请先在安装配置中填写有效的面板域名')
return host
def mail_host_name(self):
host = self.env.get('MAIL_HOSTNAME', '').lower()
if len(host) > 253 or not re.fullmatch(r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}', host):
raise RuntimeError('请先在面板设置中填写有效的邮件主机名')
return host
def shared_nginx_mode(self):
return self.env.get('CLOVER_SHARED_NGINX') == '1' or (
self.env.get('CLOVER_CADDY_SCHEME') == 'http' and not self.shared_caddy_mode())
def shared_caddy_mode(self):
return self.env.get('CLOVER_SHARED_CADDY') == '1'
def configure_host_caddy(self):
if not self.shared_caddy_mode():
return
result = run([
sys.executable, '/app/host-caddy.py', 'configure-env', '--host-root', '/host',
'--env-file', str(PROJECT / '.env'),
], timeout=90, check=False)
if result.returncode:
raise RuntimeError((result.stderr or result.stdout or '宿主机 Caddy 配置失败').strip()[-1800:])
if result.stdout.strip():
self.log(result.stdout.strip())
def cert_dir(self, host):
return self.data / 'live' / host
def cert_valid_days(self, host):
cert = self.cert_dir(host) / 'fullchain.pem'
if not cert.exists():
return None
result = run(['openssl', 'x509', '-in', str(cert), '-noout', '-enddate'], check=False)
if result.returncode:
return None
match = re.search(r'notAfter=(.+)', result.stdout)
if not match:
return None
expiry = dt.datetime.strptime(match.group(1), '%b %d %H:%M:%S %Y %Z').replace(tzinfo=dt.timezone.utc)
return max(0, int((expiry - dt.datetime.now(dt.timezone.utc)).total_seconds() // 86400))
def certificate_info(self, host, installed=False):
cert = self.cert_dir(host) / 'fullchain.pem'
if not cert.is_file():
return {'host': host, 'installed': False, 'daysRemaining': None}
result = run(['openssl', 'x509', '-in', str(cert), '-noout', '-startdate', '-enddate',
'-issuer', '-subject', '-fingerprint', '-sha256'], check=False)
if result.returncode:
return {'host': host, 'installed': False, 'daysRemaining': None}
fields = {}
for line in result.stdout.splitlines():
if '=' in line:
key, value = line.split('=', 1)
fields[key.strip()] = value.strip()
def as_iso(value):
try:
return dt.datetime.strptime(value, '%b %d %H:%M:%S %Y %Z').replace(
tzinfo=dt.timezone.utc).isoformat()
except ValueError:
return None
fingerprint = hashlib.sha256(cert.read_bytes()).hexdigest()
valid_from = as_iso(fields.get('notBefore', ''))
valid_to = as_iso(fields.get('notAfter', ''))
expiry = dt.datetime.fromisoformat(valid_to) if valid_to else None
days_remaining = int((expiry - dt.datetime.now(dt.timezone.utc)).total_seconds() // 86400) if expiry else None
return {
'host': host,
'installed': installed and self.state.get('mailFingerprint') == fingerprint,
'daysRemaining': days_remaining,
'validFrom': valid_from,
'validTo': valid_to,
'issuer': fields.get('issuer', '').removeprefix('issuer='),
'subject': fields.get('subject', '').removeprefix('subject='),
'fingerprint': fields.get('sha256 Fingerprint', ''),
}
def served_cert_days(self, host):
port = '443' if self.shared_caddy_mode() else self.env.get('CLOVER_HTTPS_PORT', '443')
result = run(['openssl', 's_client', '-connect', f'127.0.0.1:{port}', '-servername', host, '-showcerts'], timeout=12, check=False)
match = re.search(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', result.stdout + result.stderr, re.S)
if not match:
return None
cert = run(['openssl', 'x509', '-noout', '-enddate', '-checkhost', host],
input_text=match.group(0), check=False)
if cert.returncode:
return None
end = re.search(r'notAfter=(.+)', cert.stdout)
if not end:
return None
expiry = dt.datetime.strptime(end.group(1), '%b %d %H:%M:%S %Y %Z').replace(tzinfo=dt.timezone.utc)
return max(0, int((expiry - dt.datetime.now(dt.timezone.utc)).total_seconds() // 86400))
def dns_check(self, host):
try:
records = {item[4][0] for item in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)}
except OSError as error:
raise RuntimeError(f'{host} DNS 查询失败:{error}') from error
public_ip = self.env.get('CLOVER_VPS_IP', '').strip()
if public_ip and public_ip not in records:
raise RuntimeError(f'{host} 当前解析到 {", ".join(sorted(records))},应解析到本机 {public_ip},并关闭 Cloudflare 代理后再签发。')
def listener(self, port):
hex_port = f'{port:04X}'
return run(['sh', '-c', f"grep -iE ':{hex_port} ' /proc/net/tcp /proc/net/tcp6"], check=False).stdout
def wait_listener_clear(self, port, timeout=30):
deadline = time.monotonic() + timeout
while self.listener(port):
if time.monotonic() >= deadline:
return False
time.sleep(1)
return True
def is_nginx_active(self):
if host_run('/usr/bin/systemctl', 'is-active', '--quiet', 'nginx.service', check=False).returncode == 0:
return True
# aaPanel installs nginx under /www/server and registers a SysV unit.
# systemd can mark that unit failed while its actual master still serves
# traffic (for example, when an earlier graceful stop was checked too soon).
config = self.host_nginx_config()
if not config:
return False
try:
pid = config['pidfile'].read_text(encoding='ascii').strip()
if not re.fullmatch(r'\d+', pid):
return False
except OSError:
return False
if not self.listener(80) or not self.listener(443):
return False
# A newly written aaPanel vhost can reference a Clover certificate
# before ACME has issued it. `nginx -T` then fails against disk even
# though the already-running master still owns both public listeners.
try:
command = (Path('/proc') / pid / 'cmdline').read_bytes().replace(b'\0', b' ').decode('utf-8', 'replace')
except OSError:
return False
return str(config['nginx_bin']) in command and str(config['configfile']) in command
def nginx_service_action(self, action):
if self.host_nginx_config():
unit = f'clover-nginx-{action}-{time.time_ns()}'
return host_run('/usr/bin/systemd-run', f'--unit={unit}', '--wait', '--collect',
'/etc/init.d/nginx', action, timeout=45, check=False)
return host_run('/usr/bin/systemctl', action, 'nginx.service', timeout=45, check=False)
@staticmethod
def host_nginx_config():
try:
script = (HOST / 'etc/init.d/nginx').read_text(encoding='utf-8', errors='replace')
except OSError:
return None
values = {}
for key in ('NAME', 'NGINX_BIN', 'CONFIGFILE', 'PIDFILE'):
match = re.search(rf'^\s*{key}\s*=\s*["\']?([^\s"\']+)', script, re.M)
if not match:
return None
values[key] = match.group(1)
name = values['NAME']
for key in ('NGINX_BIN', 'CONFIGFILE', 'PIDFILE'):
values[key] = values[key].replace('$NAME', name).replace('${NAME}', name)
paths = {
'nginx_bin': Path('/' + values['NGINX_BIN'].lstrip('/')),
'configfile': Path('/' + values['CONFIGFILE'].lstrip('/')),
'pidfile': HOST / values['PIDFILE'].lstrip('/'),
}
binary_exists = (HOST / str(paths['nginx_bin']).lstrip('/')).is_file()
config_exists = (HOST / str(paths['configfile']).lstrip('/')).is_file()
return paths if binary_exists and config_exists else None
def paused_services(self):
return self.load(RECOVERY, {'services': []}).get('services', [])
@staticmethod
def aapanel_paths():
data = HOST / 'www/server/panel/data'
return data / 'daemon_service.pl', data / 'daemon_service_lock.pl', data / 'mod_push_data/manual_flag.pl'
def pause_aapanel_nginx_watchdog(self, recovery):
daemon_file, lock_file, manual_file = self.aapanel_paths()
try:
services = json.loads(daemon_file.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return False
if 'nginx' not in services:
return False
lock_file.parent.mkdir(parents=True, exist_ok=True)
lock_file.touch(exist_ok=True)
with lock_file.open('r+') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
existed = manual_file.exists()
try:
flags = json.loads(manual_file.read_text(encoding='utf-8')) if existed else {}
except json.JSONDecodeError as error:
raise RuntimeError('aaPanel 的 Nginx 守护标记不是有效 JSON;为避免覆盖其设置,已取消自动暂停。') from error
if not isinstance(flags, dict):
raise RuntimeError('aaPanel 的 Nginx 守护标记格式异常;为避免覆盖其设置,已取消自动暂停。')
recovery['aapanelNginxFlag'] = {
'path': str(manual_file.relative_to(HOST)),
'existed': existed,
'value': flags.get('nginx'),
}
atomic(RECOVERY, json.dumps(recovery))
flags['nginx'] = 1
manual_file.parent.mkdir(parents=True, exist_ok=True)
manual_file.write_text(json.dumps(flags), encoding='utf-8')
self.nginx_service_action('stop')
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
return True
def restore_aapanel_nginx_watchdog(self, recovery):
saved = recovery.get('aapanelNginxFlag')
if not saved:
return
daemon_file, lock_file, default_manual_file = self.aapanel_paths()
manual_file = HOST / saved.get('path', str(default_manual_file.relative_to(HOST)))
lock_file.touch(exist_ok=True)
with lock_file.open('r+') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
try:
flags = json.loads(manual_file.read_text(encoding='utf-8')) if manual_file.exists() else {}
except json.JSONDecodeError as error:
raise RuntimeError('aaPanel 的 Nginx 守护标记损坏,无法安全恢复。') from error
if saved.get('existed'):
if saved.get('value') is None:
flags.pop('nginx', None)
else:
flags['nginx'] = saved['value']
manual_file.write_text(json.dumps(flags), encoding='utf-8')
else:
flags.pop('nginx', None)
if flags:
manual_file.write_text(json.dumps(flags), encoding='utf-8')
else:
manual_file.unlink(missing_ok=True)
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
def recover(self):
recovery = self.load(RECOVERY, {'services': []})
services = recovery.get('services', [])
errors = []
for service in reversed(services):
if service == 'nginx':
try:
self.restore_aapanel_nginx_watchdog(recovery)
except Exception as error:
errors.append(str(error))
deadline = time.monotonic() + 30
restored = self.is_nginx_active() and self.listener(80) and self.listener(443)
while time.monotonic() < deadline:
if restored:
break
host_run('/usr/bin/systemctl', 'reset-failed', 'nginx.service', check=False)
self.nginx_service_action('start')
if self.is_nginx_active() and self.listener(80) and self.listener(443):
restored = True
break
time.sleep(2)
if not restored:
errors.append('nginx 未能恢复;请执行 systemctl start nginx')
else:
self.log('nginx 已恢复')
elif service == 'caddy':
result = run(['docker', 'start', 'clover-caddy'], check=False)
if result.returncode:
errors.append('Caddy 未能恢复;请执行 docker start clover-caddy')
else:
self.log('Caddy 已恢复')
atomic(RECOVERY, json.dumps({'services': [] if not errors else services,
'timerUnit': recovery.get('timerUnit'),
'timerServiceUnit': recovery.get('timerServiceUnit')}))
if errors:
raise RuntimeError(';'.join(errors))
timer_unit = recovery.get('timerUnit') or 'clover-nginx-recovery.timer'
timer_service_unit = recovery.get('timerServiceUnit') or 'clover-nginx-recovery.service'
host_run('/usr/bin/systemctl', 'stop', timer_unit, check=False)
host_run('/usr/bin/systemctl', 'stop', timer_service_unit, check=False)
host_run('/usr/bin/systemctl', 'reset-failed', timer_unit, timer_service_unit, check=False)
def pause_nginx(self):
# The helper can see host sockets, but its /proc view may not expose host
# process names to `ss -p`. Rely on systemd's nginx state when owner data is
# unavailable; after stopping nginx, verify that port 80 is actually free.
owners = run(['ss', '-H', '-ltnp', 'sport = :80'], check=False).stdout
nginx_active = self.is_nginx_active()
has_owner_info = 'users:(' in owners
if owners and has_owner_info and 'nginx' not in owners:
raise RuntimeError('公网 80 被其他程序占用,不会自动停止它。请释放 80 端口后重试。')
if owners and not nginx_active:
raise RuntimeError('公网 80 被其他程序占用,不会自动停止它。请释放 80 端口后重试。')
# When process details are hidden inside the helper, the active systemd unit
# or a validated host nginx init script identifies the managed listener.
# Stop it, then verify port 80 became free; if another listener remains,
# the caller's finally block restores nginx.
# A transient timer also restores nginx if this worker exits during ACME.
if owners and nginx_active:
unit = f'clover-nginx-recovery-{time.time_ns()}'
timer_unit = unit + '.timer'
timer_service_unit = unit + '.service'
atomic(RECOVERY, json.dumps({'services': ['nginx'], 'timerUnit': timer_unit,
'timerServiceUnit': timer_service_unit}))
host_run('/usr/bin/systemctl', 'stop', 'clover-nginx-recovery.timer', check=False)
host_run('/usr/bin/systemctl', 'stop', 'clover-nginx-recovery.service', check=False)
host_run('/usr/bin/systemctl', 'reset-failed', 'clover-nginx-recovery.timer',
'clover-nginx-recovery.service', check=False)
host_run('/usr/bin/systemd-run', f'--unit={unit}', '--on-active=6m',
'--timer-property=AccuracySec=1s', '/usr/bin/docker', 'exec',
'clover-certificate-helper', 'python3', '/app/certificates.py', 'recover')
self.log('签发期间临时暂停 nginx;证书流程结束后会自动恢复(现有网站会短暂中断)')
recovery = self.load(RECOVERY, {'services': ['nginx']})
if not self.pause_aapanel_nginx_watchdog(recovery):
self.nginx_service_action('stop')
if not self.wait_listener_clear(80):
host_run('/usr/bin/systemctl', 'kill', '--signal=QUIT', 'nginx.service', check=False)
if not self.wait_listener_clear(80):
host_run('/usr/bin/systemctl', 'kill', '--signal=TERM', 'nginx.service', check=False)
if not self.wait_listener_clear(80, timeout=15):
raise RuntimeError('nginx 停止后 45 秒内 80 端口仍被占用;证书申请已取消。请检查其他监听程序。')
if self.listener(80):
raise RuntimeError('nginx 已暂停,但 80 端口仍被占用;未识别或停止其他程序。请先释放公网 80 端口。')
def pause_caddy(self):
status = run(['docker', 'inspect', '--format', '{{.State.Status}}', 'clover-caddy'], check=False).stdout.strip()
if status != 'running':
if self.listener(80):
raise RuntimeError('公网 80 被其他程序占用。请释放端口后重试签发邮件证书。')
return
unit = f'clover-caddy-recovery-{time.time_ns()}'
timer_unit = unit + '.timer'
timer_service_unit = unit + '.service'
atomic(RECOVERY, json.dumps({'services': ['caddy'], 'timerUnit': timer_unit,
'timerServiceUnit': timer_service_unit}))
host_run('/usr/bin/systemctl', 'stop', 'clover-nginx-recovery.timer', check=False)
host_run('/usr/bin/systemctl', 'stop', 'clover-nginx-recovery.service', check=False)
host_run('/usr/bin/systemctl', 'reset-failed', 'clover-nginx-recovery.timer',
'clover-nginx-recovery.service', check=False)
host_run('/usr/bin/systemd-run', f'--unit={unit}', '--on-active=6m',
'--timer-property=AccuracySec=1s', '/usr/bin/docker', 'exec',
'clover-certificate-helper', 'python3', '/app/certificates.py', 'recover')
self.log('签发邮件证书期间临时暂停 Caddy;完成后会自动恢复(面板会短暂中断)')
run(['docker', 'stop', '--time', '20', 'clover-caddy'], timeout=30)
if not self.wait_listener_clear(80):
raise RuntimeError('Caddy 已暂停,但公网 80 仍被占用;请检查其他监听程序。')
def pause_for_acme(self):
if self.shared_nginx_mode():
self.pause_nginx()
else:
self.pause_caddy()
def nginx_config(self, host):
root_cert = f'{self.env["CLOVER_HOME"]}/deploy/acme/live/{host}/fullchain.pem'
root_key = f'{self.env["CLOVER_HOME"]}/deploy/acme/live/{host}/privkey.pem'
port = self.env.get('CLOVER_HTTP_PORT', '18080')
nginx = self.host_nginx_config()
if nginx:
site = self.host_nginx_site_path(nginx)
nginx_bin = nginx['nginx_bin']
nginx_config = nginx['configfile']
else:
site = NGINX_SITE
nginx_bin = Path('/usr/sbin/nginx')
nginx_config = Path('/etc/nginx/nginx.conf')
previous = site.read_text(encoding='utf-8') if site.exists() else None
if previous and not previous.startswith('# Managed by Clover'):
raise RuntimeError(f'{site.relative_to(HOST)} 已存在且不由 Clover 管理,拒绝覆盖。')
sites = host_run(str(nginx_bin), '-T', '-c', str(nginx_config), check=False)
for line in (sites.stdout + sites.stderr).splitlines():
if re.search(r'\bserver_name\s', line) and re.search(rf'(^|\s){re.escape(host)}(\s|;|$)', line) and (previous is None or line.strip() not in previous):
raise RuntimeError(f'nginx 已有站点配置使用 {host},请先移除重复 server_name 后再配置。')
config = f'''# Managed by Clover certificate manager
server {{
listen 80;
server_name {host};
return 301 https://$host$request_uri;
}}
server {{
listen 443 ssl;
server_name {host};
ssl_certificate {root_cert};
ssl_certificate_key {root_key};
location / {{
proxy_pass http://127.0.0.1:{port};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
}}
}}
'''
atomic(site, config, 0o644)
check = host_run(str(nginx_bin), '-t', '-c', str(nginx_config), check=False)
if check.returncode:
if previous is None:
site.unlink(missing_ok=True)
else:
atomic(site, previous, 0o644)
raise RuntimeError('nginx 配置校验失败,已回滚 Clover 配置:' + (check.stderr or check.stdout)[-1200:])
loaded = host_run(str(nginx_bin), '-T', '-c', str(nginx_config), check=False)
if loaded.returncode or '# Managed by Clover certificate manager' not in loaded.stdout + loaded.stderr:
if previous is None:
site.unlink(missing_ok=True)
else:
atomic(site, previous, 0o644)
raise RuntimeError(f'nginx 没有加载 {site.relative_to(HOST)};已回滚配置,请确认主配置 include 了该目录。')
self.nginx_service_action('reload' if self.is_nginx_active() else 'start')
@staticmethod
def host_nginx_site_path(config):
host_config = HOST / str(config['configfile']).lstrip('/')
try:
source = host_config.read_text(encoding='utf-8', errors='replace')
except OSError as error:
raise RuntimeError(f'无法读取 Nginx 主配置:{config["configfile"]}') from error
uncommented = re.sub(r'(?m)#.*$', '', source)
block = re.search(r'(?m)^\s*http\s*\{', uncommented)
if not block:
raise RuntimeError('Nginx 主配置中没有 http 配置段,无法安全加入面板反向代理。')
opening = uncommented.find('{', block.start())
depth = 1
closing = opening + 1
while closing < len(uncommented) and depth:
if uncommented[closing] == '{':
depth += 1
elif uncommented[closing] == '}':
depth -= 1
closing += 1
if depth:
raise RuntimeError('Nginx http 配置段不完整,无法安全加入面板反向代理。')
includes = re.findall(r'^\s*include\s+([^;]+);', uncommented[opening + 1:closing - 1], re.M)
candidates = []
for include in includes:
include = include.strip().strip('"\'')
candidate = Path(include)
if not candidate.is_absolute():
candidate = host_config.parent / candidate
if '*' not in candidate.name or candidate.suffix != '.conf':
continue
candidates.append(candidate)
if candidates:
candidates.sort(key=lambda path: (
0 if path.parent.name in {'conf.d', 'vhost', 'sites-enabled'} else 1,
len(path.parts),
))
return HOST / str(candidates[0].parent / 'clover-panel.conf').lstrip('/')
raise RuntimeError('Nginx 主配置中没有可用的 *.conf 站点目录,无法安全加入面板反向代理。')
def issue(self, force=False):
host = self.host_name()
self.dns_check(host)
if not os.path.isfile(DOCKER) or not os.access(DOCKER, os.X_OK):
raise RuntimeError(f'证书助手内缺少 Docker CLI(检查路径:{DOCKER})。请重建证书助手镜像:docker compose build --no-cache certificate-helper && docker compose up -d certificate-helper')
if self.shared_caddy_mode():
self.configure_host_caddy()
self.log(f'宿主机 Caddy 正在为 {host} 申请/检查 HTTPS 证书…')
deadline = time.monotonic() + 120
days = self.served_cert_days(host)
while days is None and time.monotonic() < deadline:
time.sleep(2)
days = self.served_cert_days(host)
if days is None:
raise RuntimeError(f'宿主机 Caddy 尚未返回 {host} 的 TLS 证书。请确认 DNS 指向本机,公网 TCP 80/443 可达且 Cloudflare 代理已关闭。')
self.state['mode'] = 'host-caddy'
self.state['provider'] = '宿主机 Caddy'
self.state.setdefault('certificates', {})
self.state['certificates']['panel'] = {'host': host, 'daysRemaining': days}
self.state['autoRenew'] = True
self.state['error'] = None
self.log(f'{host} 面板证书由宿主机 Caddy 自动管理,剩余 {days} 天')
return
days = self.cert_valid_days(host)
if days is not None and days > 30 and not force:
self.log(f'证书有效期还有 {days} 天,无需续签')
self.configure_panel_tls(host)
return
self.log(f'正在为 {host} 申请/续签证书;需要公网 TCP 80 可达,Cloudflare 代理需暂时关闭')
if run(['docker', 'image', 'inspect', ACME_IMAGE], check=False).returncode:
run(['docker', 'pull', ACME_IMAGE], timeout=300)
try:
self.pause_for_acme()
self.run_certbot(host)
finally:
self.recover()
self.configure_panel_tls(host)
def run_certbot(self, host, webroot=False):
home = self.env['CLOVER_HOME']
args = ['docker', 'run', '--rm', '--name', 'clover-acme-job', '--network', 'host',
'-v', f'{home}/deploy/acme:/etc/letsencrypt']
if webroot:
(PROJECT / 'deploy/acme/webroot').mkdir(parents=True, exist_ok=True)
args.extend(['-v', f'{home}/deploy/acme/webroot:/var/www/certbot'])
args.extend([ACME_IMAGE, 'certonly'])
if webroot:
args.extend(['--webroot', '--webroot-path', '/var/www/certbot'])
else:
args.extend(['--standalone', '--preferred-challenges', 'http'])
args.extend(['--non-interactive', '--agree-tos', '--email', self.env['ACME_EMAIL'],
'--cert-name', host, '-d', host, '--keep-until-expiring', '--no-random-sleep-on-renew'])
run(args, timeout=360)
def install_mail_certificate(self, host):
cert = self.cert_dir(host) / 'fullchain.pem'
key = self.cert_dir(host) / 'privkey.pem'
if not cert.is_file() or not key.is_file():
raise RuntimeError(f'没有找到 {host} 的证书文件,无法安装到邮件服务。')
fingerprint = hashlib.sha256(cert.read_bytes()).hexdigest()
if self.state.get('mailFingerprint') == fingerprint:
return False
self.log('正在将邮件证书安装到 docker-mailserver 并重载邮件服务…')
container_env = run(['docker', 'inspect', '--format', '{{range .Config.Env}}{{println .}}{{end}}', 'docker-mailserver']).stdout
settings = dict(line.split('=', 1) for line in container_env.splitlines() if '=' in line)
if settings.get('SSL_TYPE') != 'manual':
raise RuntimeError('docker-mailserver 未配置 SSL_TYPE=manual,无法安装邮件证书。请先升级并重启邮件服务后重试。')
cert_path = settings.get('SSL_CERT_PATH', '/tmp/docker-mailserver/ssl/mail.crt')
key_path = settings.get('SSL_KEY_PATH', '/tmp/docker-mailserver/ssl/mail.key')
if not cert_path.startswith('/tmp/docker-mailserver/') or not key_path.startswith('/tmp/docker-mailserver/'):
raise RuntimeError('邮件证书路径不在 DMS 配置卷中;为避免重启后丢失证书,已取消安装。')
for path in (cert_path, key_path):
run(['docker', 'exec', 'docker-mailserver', 'mkdir', '-p', str(Path(path).parent)])
# Certbot's live/ entries are relative symlinks into archive/. Copying
# the link itself leaves a dangling /tmp/archive/... path inside DMS.
# Resolve both source links, and remove any stale destination symlink
# before docker cp (otherwise Docker follows it into the missing archive).
for path in (cert_path, key_path):
run(['docker', 'exec', 'docker-mailserver', 'rm', '-f', path])
run(['docker', 'cp', str(cert.resolve(strict=True)), f'docker-mailserver:{cert_path}'])
run(['docker', 'cp', str(key.resolve(strict=True)), f'docker-mailserver:{key_path}'])
run(['docker', 'exec', 'docker-mailserver', 'chmod', '600', key_path])
run(['docker', 'restart', '--time', '60', 'docker-mailserver'], timeout=90)
self.state['mailFingerprint'] = fingerprint
self.state.setdefault('certificates', {})['mail'] = {'host': host, 'daysRemaining': self.cert_valid_days(host)}
self.save()
return True
def issue_mail(self, force=False):
host = self.mail_host_name()
self.dns_check(host)
if not os.path.isfile(DOCKER) or not os.access(DOCKER, os.X_OK):
raise RuntimeError(f'证书助手内缺少 Docker CLI(检查路径:{DOCKER})。请重建证书助手镜像。')
if self.shared_caddy_mode():
self.configure_host_caddy()
days = self.cert_valid_days(host)
if days is not None and days > 30 and not force:
self.log(f'{host} 证书有效期还有 {days} 天,无需续签')
self.install_mail_certificate(host)
return
self.log(f'正在为邮件主机 {host} 申请/续签证书;需要公网 TCP 80 可达,Cloudflare 代理需暂时关闭')
if run(['docker', 'image', 'inspect', ACME_IMAGE], check=False).returncode:
run(['docker', 'pull', ACME_IMAGE], timeout=300)
try:
if self.shared_caddy_mode():
self.run_certbot(host, webroot=True)
else:
self.pause_for_acme()
self.run_certbot(host)
finally:
self.recover()
self.install_mail_certificate(host)
self.log(f'邮件证书已安装到 docker-mailserver:{host}')
def configure_panel_tls(self, host):
self.nginx_config(host)
days = self.cert_valid_days(host)
self.state['mode'] = 'nginx'
self.state['certificates']['panel'] = {'host': host, 'daysRemaining': days}
self.state['autoRenew'] = True
self.state['error'] = None
self.log(f'{host} 证书已安装;自动续期已启用')
def status(self):
mail_host = self.env.get('MAIL_HOSTNAME', '')
mail_info = self.certificate_info(mail_host, installed=True) if mail_host else None
if self.shared_caddy_mode():
host = self.env.get('PANEL_HOSTNAME', '')
days = self.served_cert_days(host) if host else None
certificates = {'panel': {'host': host, 'daysRemaining': days}}
if mail_info:
certificates['mail'] = mail_info
self.state.update({'mode': 'host-caddy', 'provider': '宿主机 Caddy', 'autoRenew': True,
'certificates': certificates})
if not self.state.get('running') and not self.state.get('message'):
self.state['message'] = '面板证书由宿主机 Caddy 自动申请和续期。'
print(json.dumps(self.state, ensure_ascii=False))
return
if not self.shared_nginx_mode():
host = self.env.get('PANEL_HOSTNAME', '')
days = self.served_cert_days(host) if host else None
certificates = {'panel': {'host': host, 'daysRemaining': days}}
if mail_info:
certificates['mail'] = mail_info
self.state.update({'mode': 'caddy', 'provider': 'Caddy', 'autoRenew': True,
'certificates': certificates})
if not self.state.get('running') and not self.state.get('message'):
self.state['message'] = 'Caddy 自动申请和续期面板证书。'
print(json.dumps(self.state, ensure_ascii=False))
return
host = self.host_name()
days = self.cert_valid_days(host)
certificates = {'panel': {'host': host, 'daysRemaining': days}}
if mail_info:
certificates['mail'] = mail_info
self.state['certificates'] = certificates
self.state['autoRenew'] = self.state.get('mode') == 'nginx'
print(json.dumps(self.state, ensure_ascii=False))
def install_timer(self):
executable = '/usr/bin/docker exec clover-certificate-helper python3 /app/certificates.py renew'
service = '[Unit]\nDescription=Clover panel certificate renewal\nAfter=docker.service network-online.target\n\n[Service]\nType=oneshot\nExecStart=' + executable + '\nTimeoutStartSec=8min\n'
timer = '[Unit]\nDescription=Check Clover panel certificate twice daily\n\n[Timer]\nOnCalendar=*-*-* 03,15:00:00\nRandomizedDelaySec=1800\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n'
atomic(HOST / 'etc/systemd/system/clover-certificates.service', service, 0o644)
atomic(HOST / 'etc/systemd/system/clover-certificates.timer', timer, 0o644)
host_run('/usr/bin/systemctl', 'daemon-reload')
host_run('/usr/bin/systemctl', 'enable', '--now', 'clover-certificates.timer')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['issue', 'issue-mail', 'renew', 'status', 'recover'])
parser.add_argument('--force', action='store_true')
args = parser.parse_args()
manager = Manager()
manager.data.mkdir(parents=True, exist_ok=True)
if args.action == 'recover':
manager.recover()
return
if args.action == 'status':
manager.status()
return
with (manager.data / 'job.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise RuntimeError('已有证书任务正在执行,请稍后再试') from error
manager.state['running'] = True
manager.state['error'] = None
manager.save()
manager.log('证书任务开始…')
try:
manager.recover()
if args.action == 'issue':
manager.issue(force=args.force)
elif args.action == 'issue-mail':
manager.issue_mail(force=args.force)
elif args.action == 'renew':
if manager.shared_nginx_mode():
manager.issue()
elif manager.shared_caddy_mode():
manager.configure_host_caddy()
if manager.env.get('MAIL_HOSTNAME'):
manager.issue_mail()
manager.install_timer()
except Exception as error:
manager.state['error'] = str(error)
manager.log('证书任务失败:' + str(error))
raise
finally:
manager.state['running'] = False
manager.save()
if __name__ == '__main__':
try:
main()
except Exception as error:
print('[证书] ' + str(error), file=sys.stderr, flush=True)
sys.exit(1)