324 lines
13 KiB
Python
324 lines
13 KiB
Python
#!/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)
|