#!/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)