#!/usr/bin/env python3 """Safely reuse a systemd-managed host Caddy for Clover's public entrypoint.""" import argparse import json import os from pathlib import Path import re import shutil import subprocess import sys import tempfile import time BEGIN = "# BEGIN CLOVER MANAGED IMPORT" END = "# END CLOVER MANAGED IMPORT" SERVICE = "caddy.service" SNIPPET_HEADER = "# Managed by Clover host Caddy integration\n" def command(args, timeout=30, check=True): result = subprocess.run(args, text=True, capture_output=True, timeout=timeout) if check and result.returncode: detail = (result.stderr or result.stdout or "command failed").strip() raise RuntimeError(detail[-1800:]) return result def systemd_config(): if command(["systemctl", "is-active", "--quiet", SERVICE], check=False).returncode: return None result = command(["systemctl", "show", "--property=MainPID", "--value", SERVICE], check=False) if result.returncode or not result.stdout.strip().isdigit(): return None pid = result.stdout.strip() try: argv = [item.decode() for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0") if item] except OSError: return None if not argv or "caddy" not in Path(argv[0]).name.lower() or "run" not in argv[1:3]: return None config = None adapter = None for index, value in enumerate(argv): if value == "--config" and index + 1 < len(argv): config = argv[index + 1] elif value.startswith("--config="): config = value.split("=", 1)[1] elif value == "--adapter" and index + 1 < len(argv): adapter = argv[index + 1] elif value.startswith("--adapter="): adapter = value.split("=", 1)[1] if not config or not Path(config).is_absolute() or (adapter and adapter != "caddyfile"): return None if not Path(config).is_file(): return None saw_caddy = False for port in (80, 443): listeners = command(["ss", "-H", "-ltnp", f"sport = :{port}"], check=False).stdout.splitlines() for line in listeners: fields = line.split() if 'users:(("caddy",' not in line or len(fields) < 6: return None if fields[3] not in {f"*:{port}", f"0.0.0.0:{port}"}: return None saw_caddy = True if not saw_caddy: return None binary = argv[0] if Path(argv[0]).is_file() else shutil.which("caddy") if not binary or not Path(binary).is_file(): return None return config, binary def caddy_string(value): return json.dumps(str(value), ensure_ascii=False) def replace_block(source, begin, end, block): start = source.find(begin) finish = source.find(end) if (start < 0) != (finish < 0) or (start >= 0 and finish < start): raise RuntimeError("Caddy 配置中的 Clover 标记不完整;为避免覆盖手动配置,已停止修改。") if start >= 0: finish += len(end) return source[:start] + block + source[finish:] return source.rstrip() + "\n\n" + block + "\n" def atomic_write(path, data, mode=None): path.parent.mkdir(parents=True, exist_ok=True) old_stat = path.stat() if path.exists() else None fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "wb") as output: output.write(data) output.flush() os.fsync(output.fileno()) os.chmod(temporary, mode if mode is not None else (old_stat.st_mode & 0o777 if old_stat else 0o644)) if old_stat: os.chown(temporary, old_stat.st_uid, old_stat.st_gid) os.replace(temporary, path) finally: try: os.unlink(temporary) except FileNotFoundError: pass def restore(snapshots): for path, saved in snapshots.items(): if saved is None: path.unlink(missing_ok=True) else: data, mode, uid, gid = saved atomic_write(path, data, mode) os.chown(path, uid, gid) def snapshot(path): if not path.exists(): return None info = path.stat() return path.read_bytes(), info.st_mode & 0o777, info.st_uid, info.st_gid def adapted_hosts(binary, config, host_root): args = [binary, "adapt", "--config", str(config), "--adapter", "caddyfile", "--pretty"] if host_root != Path("/"): args = ["chroot", str(host_root), *args] result = command(args) document = json.loads(result.stdout) hosts = set() def walk(value): if isinstance(value, dict): matcher = value.get("match") if isinstance(matcher, list): for item in matcher: if isinstance(item, dict) and isinstance(item.get("host"), list): hosts.update(str(host).lower() for host in item["host"]) for child in value.values(): walk(child) elif isinstance(value, list): for child in value: walk(child) walk(document) return hosts def validate_host(host): if 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(f"无效的 Caddy 域名:{host}") def configure(args): panel_host = args.panel_host.lower().rstrip(".") mail_host = args.mail_host.lower().rstrip(".") validate_host(panel_host) validate_host(mail_host) if not re.fullmatch(r"\d{1,5}", args.backend_port) or int(args.backend_port) not in range(1, 65536): raise RuntimeError("内部 Caddy 端口无效。") if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", args.acme_email): raise RuntimeError("ACME 邮箱格式无效。") if not str(Path(args.project_home)).startswith("/"): raise RuntimeError("CLOVER_HOME 必须是宿主机绝对路径。") host_root = Path(args.host_root).resolve() config = Path(args.config) if not config.is_absolute(): raise RuntimeError("Caddy 配置路径必须是绝对路径。") host_config = host_root / str(config).lstrip("/") if not host_config.is_file(): raise RuntimeError(f"找不到宿主机 Caddy 配置:{config}") binary = args.binary if not binary.startswith("/"): raise RuntimeError("Caddy 可执行文件路径必须是绝对路径。") host_binary = host_root / binary.lstrip("/") if not host_binary.is_file(): raise RuntimeError(f"找不到宿主机 Caddy 可执行文件:{binary}") snippets_dir = config.parent / "clover.d" import_glob = str(snippets_dir / "*.caddy") import_line = f"import {caddy_string(import_glob)}" source = host_config.read_text(encoding="utf-8") import_block = f"{BEGIN}\n{import_line}\n{END}" if import_glob in source and BEGIN not in source: updated_source = source else: updated_source = replace_block(source, BEGIN, END, import_block) panel_file = host_root / str((snippets_dir / "clover-panel.caddy")).lstrip("/") mail_file = host_root / str((snippets_dir / "clover-mail-acme.caddy")).lstrip("/") panel_snippet = ( f"{SNIPPET_HEADER}{panel_host} {{\n" f" tls {args.acme_email}\n" f" reverse_proxy 127.0.0.1:{args.backend_port}\n" f"}}\n" ).encode() webroot = Path(args.project_home) / "deploy" / "acme" / "webroot" mail_snippet = ( f"{SNIPPET_HEADER}http://{mail_host} {{\n" " handle /.well-known/acme-challenge/* {\n" f" root * {caddy_string(str(webroot))}\n" " file_server\n" " }\n" ' respond "Not Found" 404\n' "}\n" ).encode() for path in (panel_file, mail_file): if path.exists() and not path.read_text(encoding="utf-8", errors="replace").startswith(SNIPPET_HEADER): raise RuntimeError(f"{path} 已存在且不由 Clover 管理;为避免覆盖现有配置,已停止修改。") if (host_config.read_text(encoding="utf-8") == updated_source and panel_file.is_file() and panel_file.read_bytes() == panel_snippet and mail_file.is_file() and mail_file.read_bytes() == mail_snippet): print("宿主机 Caddy 已有 Clover 路由配置,无需重载。") return # Do not silently combine Clover routes with an unrelated site using the same host. existing_hosts = adapted_hosts(binary, config, host_root) owned_source = "\n".join( path.read_text(encoding="utf-8", errors="replace") for path in (panel_file, mail_file) if path.is_file() ) owned_hosts = set(re.findall(r"(?m)^\s*(?:http://)?([a-z0-9.-]+)\s*\{", owned_source)) for hostname in {panel_host, mail_host}: if hostname in existing_hosts and hostname not in owned_hosts: raise RuntimeError(f"宿主机 Caddy 已有站点使用 {hostname};没有覆盖它,请换面板/邮件主机名或先整理现有路由。") snapshots = {host_config: snapshot(host_config), panel_file: snapshot(panel_file), mail_file: snapshot(mail_file)} if BEGIN not in source: backup = host_config.with_name(f"{host_config.name}.clover-backup-{time.strftime('%Y%m%d%H%M%S')}-{time.time_ns()}") shutil.copy2(host_config, backup) try: atomic_write(host_config, updated_source.encode("utf-8")) atomic_write(panel_file, panel_snippet) atomic_write(mail_file, mail_snippet) validate = [binary, "validate", "--config", str(config)] reload = ["systemctl", "reload", SERVICE] if host_root != Path("/"): validate = ["chroot", str(host_root), *validate] reload = ["chroot", str(host_root), "/usr/bin/systemctl", "reload", SERVICE] command(validate, timeout=45) command(reload, timeout=45) except Exception: restore(snapshots) try: command(reload, timeout=45, check=False) except Exception: pass raise print(f"宿主机 Caddy 已重载:{panel_host} → 127.0.0.1:{args.backend_port};邮件 HTTP-01 使用 webroot。") def configure_environment(host_root, env_file): values = {} for line in Path(env_file).read_text(encoding="utf-8").splitlines(): if re.match(r"^[A-Z_][A-Z0-9_]*=", line): key, value = line.split("=", 1) values[key] = value.strip() required = { "config": "CLOVER_HOST_CADDY_CONFIG", "binary": "CLOVER_HOST_CADDY_BIN", "project_home": "CLOVER_HOME", "panel_host": "PANEL_HOSTNAME", "mail_host": "MAIL_HOSTNAME", "acme_email": "ACME_EMAIL", } missing = [key for key, name in required.items() if not values.get(name)] if missing: raise RuntimeError(".env 缺少宿主机 Caddy 配置项:" + ", ".join(required[key] for key in missing)) args = argparse.Namespace( host_root=host_root, config=values["CLOVER_HOST_CADDY_CONFIG"], binary=values["CLOVER_HOST_CADDY_BIN"], project_home=values["CLOVER_HOME"], panel_host=values["PANEL_HOSTNAME"], mail_host=values["MAIL_HOSTNAME"], backend_port=values.get("CLOVER_HTTP_PORT", "18080"), acme_email=values["ACME_EMAIL"], ) configure(args) def main(): parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="action", required=True) subparsers.add_parser("detect", help="识别占用 80/443 的 systemd Caddy") env_parser = subparsers.add_parser("configure-env", help="根据当前目录的 .env 安装或更新宿主机 Caddy 路由") env_parser.add_argument("--host-root", default="/", help="宿主机根目录;证书助手中使用 /host") env_parser.add_argument("--env-file", default=".env", help="Clover .env 文件路径") apply_parser = subparsers.add_parser("configure", help="安装或更新 Clover 的宿主机 Caddy 路由") apply_parser.add_argument("--host-root", default="/", help="宿主机根目录;证书助手中使用 /host") apply_parser.add_argument("--config", required=True, help="正在运行的 Caddyfile") apply_parser.add_argument("--binary", required=True, help="正在运行的 Caddy 可执行文件") apply_parser.add_argument("--project-home", required=True, help="Clover 宿主机安装目录") apply_parser.add_argument("--panel-host", required=True) apply_parser.add_argument("--mail-host", required=True) apply_parser.add_argument("--backend-port", required=True) apply_parser.add_argument("--acme-email", required=True) args = parser.parse_args() if args.action == "detect": result = systemd_config() if not result: return 1 print("|".join(result)) return 0 if args.action == "configure-env": configure_environment(args.host_root, args.env_file) else: configure(args) return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as error: print(f"[host-caddy] {error}", file=sys.stderr) sys.exit(1)