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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user