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