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
This commit is contained in:
2026-08-05 20:29:47 +02:00
co-authored by Claude Sonnet 5
parent f354bddd1e
commit f3ed234e56
6 changed files with 436 additions and 3 deletions
+82
View File
@@ -2722,3 +2722,85 @@ Keine Commits in dieser Session.
- backend/app/routers/hours_payouts.py | 22 ++++++++++++---------- - 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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+2
View File
@@ -20,6 +20,7 @@ from app.routers import hours_payouts
from app.routers import public_stamp from app.routers import public_stamp
from app.routers import ical from app.routers import ical
from app.routers import reseller, tenants from app.routers import reseller, tenants
from app.routers import tls_admin
@asynccontextmanager @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(hours_payouts.router, prefix=API_PREFIX)
app.include_router(reseller.router, prefix=API_PREFIX) app.include_router(reseller.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX) app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(tls_admin.router, prefix=API_PREFIX)
# ── Health ──────────────────────────────────────────────────────────────────── # ── Health ────────────────────────────────────────────────────────────────────
+92
View File
@@ -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()
+96
View File
@@ -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:]
+65
View File
@@ -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
+99 -3
View File
@@ -13,15 +13,25 @@ interface Reseller {
is_active: boolean; company_count: number is_active: boolean; company_count: number
} }
interface Me { first_name: string; last_name: string; role: string } 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 empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' }
const emptyReseller = { email: '', first_name: '', last_name: '' } const emptyReseller = { email: '', first_name: '', last_name: '' }
export function TenantsPage() { export function TenantsPage() {
const [me, setMe] = useState<Me | null>(null) const [me, setMe] = useState<Me | null>(null)
const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants') const [tab, setTab] = useState<'tenants' | 'resellers' | 'tls'>('tenants')
const [tenants, setTenants] = useState<Tenant[]>([]) const [tenants, setTenants] = useState<Tenant[]>([])
const [resellers, setResellers] = useState<Reseller[]>([]) const [resellers, setResellers] = useState<Reseller[]>([])
const [tls, setTls] = useState<TlsStatus | null>(null)
const [tlsBusy, setTlsBusy] = useState(false)
const [tlsForm, setTlsForm] = useState({ domain: '', hostname: '', ip: '' })
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [form, setForm] = useState({ ...empty, reseller_id: '' }) const [form, setForm] = useState({ ...empty, reseller_id: '' })
@@ -42,11 +52,37 @@ export function TenantsPage() {
} }
} }
async function loadTls() {
try { setTls(await api.get<TlsStatus>('/admin/tls/status')) }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
}
useEffect(() => { useEffect(() => {
api.get<Me>('/auth/me').then(setMe).catch(() => {}) api.get<Me>('/auth/me').then(setMe).catch(() => {})
load() load()
loadTls()
}, []) }, [])
async function renewTlsCertbot() {
if (!tlsForm.domain.trim()) return
setTlsBusy(true); setError(null)
try {
setTls(await api.post<TlsStatus>('/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<TlsStatus>('/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() { async function createTenant() {
setBusy(true); setError(null) setBusy(true); setError(null)
try { try {
@@ -112,12 +148,12 @@ export function TenantsPage() {
</div> </div>
<div className="flex gap-1 border-b border-gray-200"> <div className="flex gap-1 border-b border-gray-200">
{(['tenants', 'resellers'] as const).map(t => ( {(['tenants', 'resellers', 'tls'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} <button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${ className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
tab === t ? 'border-blue-600 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700' tab === t ? 'border-blue-600 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}> }`}>
{t === 'tenants' ? `Firmen (${tenants.length})` : `Reseller (${resellers.length})`} {t === 'tenants' ? `Firmen (${tenants.length})` : t === 'resellers' ? `Reseller (${resellers.length})` : 'Server / TLS'}
</button> </button>
))} ))}
</div> </div>
@@ -200,6 +236,66 @@ export function TenantsPage() {
</div> </div>
)} )}
{tab === 'tls' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6 max-w-2xl">
<div>
<h2 className="text-sm font-semibold text-gray-700 mb-2">Zertifikat-Status (dieser Server)</h2>
{!tls ? (
<p className="text-sm text-gray-400">Lädt</p>
) : (
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<dt className="text-gray-500">Modus</dt>
<dd className="font-medium text-gray-800">
{tls.mode === 'proxy' ? 'Vorgeschalteter Proxy (kein lokales Zertifikat)'
: tls.mode === 'certbot' ? "Let's Encrypt (certbot)" : 'Interne CA'}
</dd>
{tls.domain && (<><dt className="text-gray-500">Domain</dt><dd>{tls.domain}</dd></>)}
<dt className="text-gray-500">Gültig bis</dt>
<dd>{tls.valid_until ?? '—'}</dd>
{tls.ca_cert_path && (<><dt className="text-gray-500">Root-CA-Pfad</dt><dd className="text-xs">{tls.ca_cert_path}</dd></>)}
</dl>
)}
{tls?.mode === 'proxy' && (
<p className="text-xs text-gray-400 mt-2">
TLS wird aktuell vom vorgeschalteten Proxy terminiert. Erst Renewal-Aktion unten nutzen,
wenn dieser Server direkt (ohne Proxy) mit eigenem Zertifikat laufen soll.
</p>
)}
</div>
<div className="border-t border-gray-100 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-700">Öffentliches Zertifikat holen/erneuern (Let's Encrypt)</h3>
<div className="flex gap-2">
<input className={inp} placeholder="timemaster.example.com" value={tlsForm.domain}
onChange={e => setTlsForm({ ...tlsForm, domain: e.target.value })} />
<button onClick={renewTlsCertbot} disabled={tlsBusy || !tlsForm.domain.trim()}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap">
{tlsBusy ? 'Läuft…' : 'Zertifikat holen'}
</button>
</div>
<p className="text-xs text-gray-400">Domain muss per DNS bereits auf diesen Server zeigen.</p>
</div>
<div className="border-t border-gray-100 pt-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-700">Internes Zertifikat (eigene CA, kein öffentliches DNS nötig)</h3>
<div className="grid grid-cols-2 gap-2">
<input className={inp} placeholder="timemaster.local" value={tlsForm.hostname}
onChange={e => setTlsForm({ ...tlsForm, hostname: e.target.value })} />
<input className={inp} placeholder="IP (optional)" value={tlsForm.ip}
onChange={e => setTlsForm({ ...tlsForm, ip: e.target.value })} />
</div>
<button onClick={renewTlsInternal} disabled={tlsBusy || !tlsForm.hostname.trim()}
className="px-4 py-2 bg-gray-700 text-white text-sm font-medium rounded-lg hover:bg-gray-800 disabled:opacity-50">
{tlsBusy ? 'Läuft…' : 'Internes Zertifikat erzeugen'}
</button>
<p className="text-xs text-gray-400">
Beim ersten Aufruf wird eine interne Root-CA erzeugt. Die muss danach auf allen Clients
(Browser/Kiosk-Geräten) importiert werden, sonst Zertifikatswarnung.
</p>
</div>
</div>
)}
{showNew && ( {showNew && (
<Modal title="Neue Firma anlegen" onClose={() => setShowNew(false)} onSubmit={createTenant} busy={busy}> <Modal title="Neue Firma anlegen" onClose={() => setShowNew(false)} onSubmit={createTenant} busy={busy}>
<Field label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></Field> <Field label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></Field>