diff --git a/DEVLOG.md b/DEVLOG.md index fda8be2..f3756ea 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2722,3 +2722,85 @@ Keine Commits in dieser Session. - backend/app/routers/hours_payouts.py | 22 ++++++++++++---------- --- +## 2026-08-05 19:01 – 19:17 (16m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- edb727e fix(security): python-jose CVE-2024-33663 durch pyjwt ersetzt, Deps gepinnt + +### Geänderte Dateien +- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++ +- backend/app/core/dependencies.py | 2 +- +- backend/app/core/security.py | 3 ++- +- backend/app/routers/auth.py | 2 +- +- backend/requirements.txt | 58 +++++++++++++++++++++++++++++----------------------------- +- nginx.conf | 11 ++++++++++- + +--- +## 2026-08-05 19:18 – 19:20 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++ +- backend/app/core/dependencies.py | 2 +- +- backend/app/core/security.py | 3 ++- +- backend/app/routers/auth.py | 2 +- +- backend/requirements.txt | 58 +++++++++++++++++++++++++++++----------------------------- +- nginx.conf | 11 ++++++++++- + +--- +## 2026-08-05 19:23 – 19:23 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++ +- backend/app/core/dependencies.py | 2 +- +- backend/app/core/security.py | 3 ++- +- backend/app/routers/auth.py | 2 +- +- backend/requirements.txt | 58 +++++++++++++++++++++++++++++----------------------------- +- nginx.conf | 11 ++++++++++- + +--- +## 2026-08-05 19:33 – 19:35 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 319e070 feat(tls): optionales certbot-Setup vorbereitet (Option A) + +### Geänderte Dateien +- nginx.conf | 13 +++++++------ +- setup-tls.sh | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-08-05 19:35 – 19:36 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 9c86cbc feat(tls): optionales Setup für interne CA vorbereitet + +### Geänderte Dateien +- setup-tls-internal.sh | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-08-05 19:37 – 19:37 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- setup-tls-internal.sh | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/main.py b/backend/app/main.py index 490b2d5..0298d7a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.routers import hours_payouts from app.routers import public_stamp from app.routers import ical from app.routers import reseller, tenants +from app.routers import tls_admin @asynccontextmanager @@ -112,6 +113,7 @@ app.include_router(special_assignments.router, prefix=API_PREFIX) app.include_router(hours_payouts.router, prefix=API_PREFIX) app.include_router(reseller.router, prefix=API_PREFIX) app.include_router(tenants.router, prefix=API_PREFIX) +app.include_router(tls_admin.router, prefix=API_PREFIX) # ── Health ──────────────────────────────────────────────────────────────────── diff --git a/backend/app/routers/tls_admin.py b/backend/app/routers/tls_admin.py new file mode 100644 index 0000000..3b2c6a0 --- /dev/null +++ b/backend/app/routers/tls_admin.py @@ -0,0 +1,92 @@ +"""SUPER_ADMIN: TLS-Zertifikat-Status + manuelles Renewal (server-lokal). + +Läuft mit Root-Rechten (timemaster.service User=root), deshalb strikt auf +SUPER_ADMIN begrenzt + AuditLog + Rate-Limit. Kein Cross-Server-Wissen – +zeigt nur das Zertifikat DIESES Servers. +""" +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_client_ip, require_role +from app.core.limiter import limiter +from app.models.audit_log import AuditLog +from app.models.user import User, UserRole +from app.services import tls_service + +router = APIRouter(prefix="/admin/tls", tags=["Admin · TLS"]) + +_sa = (UserRole.SUPER_ADMIN,) + + +class TlsStatusOut(BaseModel): + mode: str + domain: str | None + valid_until: str | None + renewable: bool + ca_cert_path: str | None = None + + +class TlsRenewCertbot(BaseModel): + domain: str + + +class TlsRenewInternal(BaseModel): + hostname: str + ip: str | None = None + + +@router.get("/status", response_model=TlsStatusOut) +async def tls_status(current_user: User = require_role(*_sa)): + return tls_service.get_tls_status() + + +@router.post("/renew/certbot", response_model=TlsStatusOut) +@limiter.limit("5/hour") +async def tls_renew_certbot( + request: Request, + data: TlsRenewCertbot, + current_user: User = require_role(*_sa), + db: AsyncSession = Depends(get_db), +): + try: + tls_service.renew_certbot(data.domain) + except ValueError as e: + raise HTTPException(422, str(e)) + except RuntimeError as e: + raise HTTPException(500, f"Zertifikat-Renewal fehlgeschlagen: {e}") + + db.add(AuditLog( + company_id=None, user_id=current_user.id, + action="tls_renewed_certbot", entity_type="server", entity_id=None, + new_value={"domain": data.domain}, + ip=get_client_ip(request), + )) + await db.commit() + return tls_service.get_tls_status() + + +@router.post("/renew/internal", response_model=TlsStatusOut) +@limiter.limit("5/hour") +async def tls_renew_internal( + request: Request, + data: TlsRenewInternal, + current_user: User = require_role(*_sa), + db: AsyncSession = Depends(get_db), +): + try: + tls_service.renew_internal(data.hostname, data.ip) + except ValueError as e: + raise HTTPException(422, str(e)) + except RuntimeError as e: + raise HTTPException(500, f"Zertifikat-Renewal fehlgeschlagen: {e}") + + db.add(AuditLog( + company_id=None, user_id=current_user.id, + action="tls_renewed_internal", entity_type="server", entity_id=None, + new_value={"hostname": data.hostname, "ip": data.ip}, + ip=get_client_ip(request), + )) + await db.commit() + return tls_service.get_tls_status() diff --git a/backend/app/services/tls_service.py b/backend/app/services/tls_service.py new file mode 100644 index 0000000..2113cd4 --- /dev/null +++ b/backend/app/services/tls_service.py @@ -0,0 +1,96 @@ +"""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:] diff --git a/backend/tests/test_tls_admin.py b/backend/tests/test_tls_admin.py new file mode 100644 index 0000000..1448dd3 --- /dev/null +++ b/backend/tests/test_tls_admin.py @@ -0,0 +1,65 @@ +"""Tests für SUPER_ADMIN TLS-Status/Renewal – Test-Server hat kein echtes +Zertifikat, daher fällt get_tls_status() auf mode="proxy" zurück. Renewal- +Endpunkte werden nur auf Rollen-Gate + Input-Validierung geprüft (echtes +Skript-Ausführen ist Server-Ops, kein Unit-Test-Ziel).""" +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy import text + +from app.core.security import hash_password +from app.models.user import User, UserRole + + +async def _make_user(db, *, email, role, company_id=None, password="Secret123"): + await db.execute(text("SET LOCAL app.bypass_rls = 'on'")) + user = User( + id=uuid.uuid4(), company_id=company_id, email=email, + password_hash=hash_password(password), + first_name=role.title(), last_name="User", + role=UserRole(role), is_active=True, + ) + db.add(user) + await db.commit() + return user.id + + +async def _login(client, email, password="Secret123"): + r = await client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200, r.text + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_tls_status_superadmin_only(client: AsyncClient, db_session): + await _make_user(db_session, email="sa-tls@platform.de", role="SUPER_ADMIN") + h = await _login(client, "sa-tls@platform.de") + + r = await client.get("/api/v1/admin/tls/status", headers=h) + assert r.status_code == 200, r.text + assert r.json()["mode"] == "proxy" + assert r.json()["renewable"] is False + + +@pytest.mark.asyncio(loop_scope="session") +async def test_tls_status_forbidden_for_non_superadmin(client: AsyncClient, db_session): + company_id = uuid.uuid4() + from app.models.company import Company + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + db_session.add(Company(id=company_id, name="Acme", slug=f"acme-{company_id.hex[:8]}")) + await db_session.commit() + await _make_user(db_session, email="admin-tls@acme.de", role="COMPANY_ADMIN", company_id=company_id) + h = await _login(client, "admin-tls@acme.de") + + r = await client.get("/api/v1/admin/tls/status", headers=h) + assert r.status_code == 403 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_tls_renew_certbot_rejects_invalid_domain(client: AsyncClient, db_session): + await _make_user(db_session, email="sa-tls2@platform.de", role="SUPER_ADMIN") + h = await _login(client, "sa-tls2@platform.de") + + r = await client.post("/api/v1/admin/tls/renew/certbot", json={"domain": "; rm -rf /"}, headers=h) + assert r.status_code == 422 diff --git a/frontend/src/pages/TenantsPage.tsx b/frontend/src/pages/TenantsPage.tsx index 004592e..a8b1e89 100644 --- a/frontend/src/pages/TenantsPage.tsx +++ b/frontend/src/pages/TenantsPage.tsx @@ -13,15 +13,25 @@ interface Reseller { is_active: boolean; company_count: number } interface Me { first_name: string; last_name: string; role: string } +interface TlsStatus { + mode: 'proxy' | 'certbot' | 'internal' + domain: string | null + valid_until: string | null + renewable: boolean + ca_cert_path?: string | null +} const empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' } const emptyReseller = { email: '', first_name: '', last_name: '' } export function TenantsPage() { const [me, setMe] = useState(null) - const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants') + const [tab, setTab] = useState<'tenants' | 'resellers' | 'tls'>('tenants') const [tenants, setTenants] = useState([]) const [resellers, setResellers] = useState([]) + const [tls, setTls] = useState(null) + const [tlsBusy, setTlsBusy] = useState(false) + const [tlsForm, setTlsForm] = useState({ domain: '', hostname: '', ip: '' }) const [error, setError] = useState(null) const [showNew, setShowNew] = useState(false) const [form, setForm] = useState({ ...empty, reseller_id: '' }) @@ -42,11 +52,37 @@ export function TenantsPage() { } } + async function loadTls() { + try { setTls(await api.get('/admin/tls/status')) } + catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') } + } + useEffect(() => { api.get('/auth/me').then(setMe).catch(() => {}) load() + loadTls() }, []) + async function renewTlsCertbot() { + if (!tlsForm.domain.trim()) return + setTlsBusy(true); setError(null) + try { + setTls(await api.post('/admin/tls/renew/certbot', { domain: tlsForm.domain.trim() })) + } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Renewal fehlgeschlagen') } + finally { setTlsBusy(false) } + } + + async function renewTlsInternal() { + if (!tlsForm.hostname.trim()) return + setTlsBusy(true); setError(null) + try { + setTls(await api.post('/admin/tls/renew/internal', { + hostname: tlsForm.hostname.trim(), ip: tlsForm.ip.trim() || null, + })) + } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Renewal fehlgeschlagen') } + finally { setTlsBusy(false) } + } + async function createTenant() { setBusy(true); setError(null) try { @@ -112,12 +148,12 @@ export function TenantsPage() {
- {(['tenants', 'resellers'] as const).map(t => ( + {(['tenants', 'resellers', 'tls'] as const).map(t => ( ))}
@@ -200,6 +236,66 @@ export function TenantsPage() { )} + {tab === 'tls' && ( +
+
+

Zertifikat-Status (dieser Server)

+ {!tls ? ( +

Lädt…

+ ) : ( +
+
Modus
+
+ {tls.mode === 'proxy' ? 'Vorgeschalteter Proxy (kein lokales Zertifikat)' + : tls.mode === 'certbot' ? "Let's Encrypt (certbot)" : 'Interne CA'} +
+ {tls.domain && (<>
Domain
{tls.domain}
)} +
Gültig bis
+
{tls.valid_until ?? '—'}
+ {tls.ca_cert_path && (<>
Root-CA-Pfad
{tls.ca_cert_path}
)} +
+ )} + {tls?.mode === 'proxy' && ( +

+ TLS wird aktuell vom vorgeschalteten Proxy terminiert. Erst Renewal-Aktion unten nutzen, + wenn dieser Server direkt (ohne Proxy) mit eigenem Zertifikat laufen soll. +

+ )} +
+ +
+

Öffentliches Zertifikat holen/erneuern (Let's Encrypt)

+
+ setTlsForm({ ...tlsForm, domain: e.target.value })} /> + +
+

Domain muss per DNS bereits auf diesen Server zeigen.

+
+ +
+

Internes Zertifikat (eigene CA, kein öffentliches DNS nötig)

+
+ setTlsForm({ ...tlsForm, hostname: e.target.value })} /> + setTlsForm({ ...tlsForm, ip: e.target.value })} /> +
+ +

+ Beim ersten Aufruf wird eine interne Root-CA erzeugt. Die muss danach auf allen Clients + (Browser/Kiosk-Geräten) importiert werden, sonst Zertifikatswarnung. +

+
+
+ )} + {showNew && ( setShowNew(false)} onSubmit={createTenant} busy={busy}> setForm({ ...form, name: e.target.value })} />