Files
timemaster/backend/app/services/tls_service.py
T
patrickandClaude Sonnet 5 90f45a4f85
Security Audit / Node.js Dependency Audit (push) Has been cancelled
Security Audit / Python Dependency Audit (push) Has been cancelled
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 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:]