Files
patrickandClaude Sonnet 5 f3ed234e56 feat(admin): SUPER_ADMIN TLS-Zertifikat-Status + Renewal-Trigger
Neuer Router /admin/tls (SUPER_ADMIN only, AuditLog, Rate-Limit 5/hour):
- GET  /admin/tls/status         – erkennt proxy/certbot/internal-Modus,
  liest Ablaufdatum via openssl x509 -enddate
- POST /admin/tls/renew/certbot  – ruft setup-tls.sh <domain> auf
- POST /admin/tls/renew/internal – ruft setup-tls-internal.sh <hostname> [ip]
  auf, reloaded nginx danach

Läuft mit den Root-Rechten des bestehenden timemaster.service (User=root,
unverändert) - Angriffsfläche dadurch begrenzt auf SUPER_ADMIN-Auth +
Domain/Hostname-Validierung (Regex, kein Shell-Interpolieren, subprocess
mit Argument-Liste statt shell=True).

Frontend: neuer Tab "Server / TLS" in TenantsPage – Status-Anzeige +
zwei Formulare (öffentlich/intern).

3 neue Tests in test_tls_admin.py (Rollen-Gate, Status im Testcontext,
Input-Validierung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTxkZEUdfgMxZvHPiZJ8bV
2026-08-05 20:29:47 +02:00

97 lines
3.5 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.
"""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:]