"""TLS-Status + Renewal für SUPER_ADMIN. Server-lokal – jede Installation (137/164) kennt nur ihr eigenes Zertifikat, kein Cross-Server-Wissen (siehe CLAUDE.md: Server sind entkoppelt). Erkennt drei Modi: - "proxy" – kein lokal verwaltetes Zertifikat, vorgeschalteter Proxy terminiert TLS (aktueller Standardfall) - "certbot" – öffentliches Let's-Encrypt-Zertifikat (setup-tls.sh) - "internal" – eigene interne CA (setup-tls-internal.sh) """ import re import subprocess from pathlib import Path from typing import Any LETSENCRYPT_LIVE = Path("/etc/letsencrypt/live") INTERNAL_CA_DIR = Path("/etc/nginx/internal-ca") SETUP_TLS_SCRIPT = Path("/opt/timemaster/setup-tls.sh") SETUP_TLS_INTERNAL_SCRIPT = Path("/opt/timemaster/setup-tls-internal.sh") _DOMAIN_RE = re.compile(r"^[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)+$") _IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") def _cert_enddate(cert_path: Path) -> str | None: try: out = subprocess.run( ["openssl", "x509", "-enddate", "-noout", "-in", str(cert_path)], capture_output=True, text=True, timeout=5, check=True, ) except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): return None # Format: "notAfter=Aug 5 12:00:00 2027 GMT" return out.stdout.strip().removeprefix("notAfter=") or None def get_tls_status() -> dict[str, Any]: if LETSENCRYPT_LIVE.is_dir(): for entry in LETSENCRYPT_LIVE.iterdir(): cert = entry / "cert.pem" if cert.exists(): return { "mode": "certbot", "domain": entry.name, "valid_until": _cert_enddate(cert), "renewable": True, } internal_cert = INTERNAL_CA_DIR / "server.crt" if internal_cert.exists(): return { "mode": "internal", "domain": None, "valid_until": _cert_enddate(internal_cert), "renewable": True, "ca_cert_path": str(INTERNAL_CA_DIR / "ca.crt"), } return { "mode": "proxy", "domain": None, "valid_until": None, "renewable": False, } def renew_certbot(domain: str) -> str: if not _DOMAIN_RE.match(domain): raise ValueError("Ungültiger Domain-Name") if not SETUP_TLS_SCRIPT.exists(): raise RuntimeError("setup-tls.sh nicht gefunden") result = subprocess.run( [str(SETUP_TLS_SCRIPT), domain], capture_output=True, text=True, timeout=120, ) if result.returncode != 0: raise RuntimeError(result.stderr[-2000:] or result.stdout[-2000:]) return result.stdout[-4000:] def renew_internal(hostname: str, ip: str | None) -> str: if not _DOMAIN_RE.match(hostname) and not _IP_RE.match(hostname): raise ValueError("Ungültiger Hostname") if ip and not _IP_RE.match(ip): raise ValueError("Ungültige IP") if not SETUP_TLS_INTERNAL_SCRIPT.exists(): raise RuntimeError("setup-tls-internal.sh nicht gefunden") args = [str(SETUP_TLS_INTERNAL_SCRIPT), hostname] if ip: args.append(ip) result = subprocess.run(args, capture_output=True, text=True, timeout=60) if result.returncode != 0: raise RuntimeError(result.stderr[-2000:] or result.stdout[-2000:]) subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10) return result.stdout[-4000:]