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
+99 -3
View File
@@ -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<Me | null>(null)
const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants')
const [tab, setTab] = useState<'tenants' | 'resellers' | 'tls'>('tenants')
const [tenants, setTenants] = useState<Tenant[]>([])
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 [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<TlsStatus>('/admin/tls/status')) }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
}
useEffect(() => {
api.get<Me>('/auth/me').then(setMe).catch(() => {})
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() {
setBusy(true); setError(null)
try {
@@ -112,12 +148,12 @@ export function TenantsPage() {
</div>
<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)}
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'
}`}>
{t === 'tenants' ? `Firmen (${tenants.length})` : `Reseller (${resellers.length})`}
{t === 'tenants' ? `Firmen (${tenants.length})` : t === 'resellers' ? `Reseller (${resellers.length})` : 'Server / TLS'}
</button>
))}
</div>
@@ -200,6 +236,66 @@ export function TenantsPage() {
</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 && (
<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>