presseportale/app/Services/Admin/AdminSystemHealth.php
Kevin Adametz 05535aac37 auth: fehlgeschlagene Anmeldungen mit Einstieg (livewire/fortify/two-factor); unbekannte Adressen als Tageszähler statt Verlaufseintrag, Kachel im Systemstatus, Löschfrist 90 Tage
Auf Live kamen seit dem 05.10. rund 250 Versuche am Tag aus über 50 Quellen,
alle mit unbekannter Adresse — verteiltes Durchprobieren fremder
Zugangsdaten, das im Verlauf die relevanten Einträge verdeckte. Neue Tabelle
failed_login_tallies (Tag × Einstieg × IP-Hash), model:prune täglich 04:05.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:30:29 +00:00

416 lines
17 KiB
PHP

<?php
namespace App\Services\Admin;
use App\Authorization\Permission as PermissionCatalog;
use App\Models\ArchiveEntry;
use App\Models\FailedLoginTally;
use App\Models\PressReleaseAttachment;
use App\Models\PressReleaseImage;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Monitoring\HealthProbe;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
use Throwable;
/**
* Betriebsüberwachung fürs Admin-Backend: Zustand der Queue (kritisch, weil
* Magic-Link-Login und fristrelevante Bestätigungs-Mails darüber laufen) sowie
* Deployment-/Konfigurations-Checks (neue Permissions ausgerollt, Debug/Env,
* ausstehende Medien-Privatisierung).
*/
class AdminSystemHealth
{
private const StatusOk = 'ok';
private const StatusWarn = 'warn';
private const StatusCritical = 'critical';
/**
* Sekundenschwellen für den ältesten wartenden Job: darüber gilt der Worker
* als verzögert (warn) bzw. mutmaßlich gestoppt (critical).
*/
private const PendingWarnSeconds = 60;
private const PendingCriticalSeconds = 300;
/**
* @return array{
* generated_at: Carbon,
* overall: string,
* queue: array<string, mixed>,
* checks: list<array{key: string, label: string, status: string, detail: string}>
* }
*/
public function snapshot(): array
{
$queue = $this->queueHealth();
$checks = $this->deploymentChecks($queue);
$statuses = array_merge(
[$queue['status']],
array_map(static fn (array $check): string => $check['status'], $checks),
);
return [
'generated_at' => now(),
'overall' => $this->worstStatus($statuses),
'queue' => $queue,
'checks' => $checks,
];
}
/**
* @return array<string, mixed>
*/
private function queueHealth(): array
{
$connection = (string) config('queue.default');
$usesDatabase = $connection === 'database';
if (! $usesDatabase || ! Schema::hasTable('jobs')) {
return [
'status' => self::StatusWarn,
'driver' => $connection,
'supported' => false,
'pending' => 0,
'delayed' => 0,
'reserved' => 0,
'failed_total' => Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0,
'failed_recent' => 0,
'oldest_pending_seconds' => null,
'worker_running' => null,
'recent_failures' => [],
];
}
$now = now()->getTimestamp();
$pending = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', $now)->count();
$delayed = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', $now)->count();
$reserved = DB::table('jobs')->whereNotNull('reserved_at')->count();
$oldestAvailable = DB::table('jobs')
->whereNull('reserved_at')
->where('available_at', '<=', $now)
->min('available_at');
$oldestPendingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : null;
$failedTotal = DB::table('failed_jobs')->count();
$failedRecent = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subDay())
->count();
$recentFailures = DB::table('failed_jobs')
->orderByDesc('failed_at')
->limit(10)
->get(['uuid', 'queue', 'payload', 'exception', 'failed_at'])
->map(fn ($row): array => [
'uuid' => $row->uuid,
'queue' => $row->queue,
'job' => $this->jobDisplayName($row->payload),
'error' => $this->firstExceptionLine($row->exception),
'failed_at' => Carbon::parse($row->failed_at),
])
->all();
// Worker gilt als aktiv, wenn kein wartender Job zu lange unbearbeitet
// liegt. Ein reservierter Job zeigt aktive Verarbeitung an.
$workerRunning = $oldestPendingSeconds === null || $oldestPendingSeconds < self::PendingCriticalSeconds;
$status = self::StatusOk;
if ($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingCriticalSeconds) {
$status = self::StatusCritical;
} elseif (($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingWarnSeconds) || $failedRecent > 0) {
$status = self::StatusWarn;
}
return [
'status' => $status,
'driver' => $connection,
'supported' => true,
'pending' => $pending,
'delayed' => $delayed,
'reserved' => $reserved,
'failed_total' => $failedTotal,
'failed_recent' => $failedRecent,
'oldest_pending_seconds' => $oldestPendingSeconds,
'worker_running' => $workerRunning,
'recent_failures' => $recentFailures,
];
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function failedLoginCheck(): array
{
$summary = fn (string $day): object => FailedLoginTally::query()
->whereDate('day', $day)
->selectRaw('coalesce(sum(attempts), 0) as attempts, count(distinct ip_hash) as sources')
->first() ?? (object) ['attempts' => 0, 'sources' => 0];
$today = $summary(now()->toDateString());
$yesterday = $summary(now()->subDay()->toDateString());
$warnPerDay = (int) config('monitoring.failed_logins.warn_per_day', 1000);
return [
'key' => 'failed_logins',
'label' => 'Anmeldeversuche mit unbekannter Adresse',
'status' => (int) $today->attempts >= $warnPerDay ? self::StatusWarn : self::StatusOk,
'detail' => sprintf(
'Heute %d Versuche aus %d Quellen, gestern %d aus %d.%s',
(int) $today->attempts,
(int) $today->sources,
(int) $yesterday->attempts,
(int) $yesterday->sources,
(int) $today->attempts >= $warnPerDay ? ' Ungewöhnlich viele — Einstieg in failed_login_tallies prüfen.' : '',
),
];
}
/**
* @param array<string, mixed> $queue
* @return list<array{key: string, label: string, status: string, detail: string}>
*/
private function deploymentChecks(array $queue): array
{
$checks = [];
// Queue-Worker als expliziter Check (spiegelt den Queue-Block).
$checks[] = [
'key' => 'queue_worker',
'label' => 'Queue-Worker',
'status' => $queue['status'],
'detail' => $this->queueWorkerDetail($queue),
];
// Scheduler-Herzschlag und Plattenplatz — dieselben Prüfungen, die
// der externe Monitor über /health abfragt (Monitoring-Plan 1.4/1.5).
$probe = app(HealthProbe::class);
$checks[] = $probe->scheduler();
$checks[] = $probe->disk();
// Anmeldeversuche mit unbekannter Adresse (Nachtrag 1.7): Tageszähler
// statt Verlaufseinträgen. Viel Grundrauschen ist normal; warnen erst
// ab der konfigurierten Tagesmenge.
$checks[] = $this->failedLoginCheck();
// Neue Permissions ausgerollt?
$expected = PermissionCatalog::adminOnly();
$existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all();
$missing = array_values(array_diff($expected, $existing));
$checks[] = [
'key' => 'permissions',
'label' => 'Berechtigungen ausgerollt',
'status' => $missing === [] ? self::StatusOk : self::StatusCritical,
'detail' => $missing === []
? 'Alle erwarteten Rollen-Permissions sind vorhanden.'
: 'Fehlende Permissions (Seeder ausführen): '.implode(', ', $missing),
];
// APP_DEBUG in nicht-lokalen Umgebungen.
$isLocal = app()->environment('local');
$debug = (bool) config('app.debug');
$checks[] = [
'key' => 'app_debug',
'label' => 'Debug-Modus',
'status' => ($debug && ! $isLocal) ? self::StatusCritical : self::StatusOk,
'detail' => $debug
? ('APP_DEBUG ist aktiv'.($isLocal ? ' (lokal ok).' : ' — im Produktivbetrieb deaktivieren!'))
: 'APP_DEBUG ist deaktiviert.',
];
// Secure Session Cookie außerhalb lokaler Umgebung.
$secureCookie = (bool) config('session.secure');
$checks[] = [
'key' => 'session_secure_cookie',
'label' => 'Session Secure-Cookie',
'status' => (! $secureCookie && ! $isLocal) ? self::StatusWarn : self::StatusOk,
'detail' => $secureCookie
? 'Session-Cookie wird nur über HTTPS gesetzt.'
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
];
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben.
$provenanceFailures = PressReleaseImage::query()
->withTrashed()
->where('provenance_status', 'error')
->where('provenance_checked_at', '>=', now()->subDays(7))
->count();
$checks[] = [
'key' => 'image_provenance',
'label' => 'Herkunftsprüfung Bilder',
'status' => $provenanceFailures === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $provenanceFailures === 0
? 'Keine fehlgeschlagene Herkunftsprüfung in den letzten 7 Tagen.'
: $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).',
];
// Beweisarchiv: Schlüssel (Umsetzungsaudit 29.09.2026, A-01/A-15) —
// ohne ARCHIVE_KEY wird nichts archiviert und der Nachholer bricht
// ab; Einträge mit einem nicht mehr konfigurierten Schlüssel sind
// unlesbar.
$archive = app(EvidenceArchive::class);
$unknownKeyEntries = $archive->isConfigured()
? ArchiveEntry::query()->whereNull('deleted_at')->whereNotNull('key_id')->whereNotIn('key_id', $archive->knownKeyIds())->count()
: 0;
$checks[] = [
'key' => 'archive_key',
'label' => 'Beweisarchiv-Schlüssel',
'status' => ! $archive->isConfigured() || $unknownKeyEntries > 0 ? self::StatusCritical : self::StatusOk,
'detail' => match (true) {
! $archive->isConfigured() => 'ARCHIVE_KEY fehlt oder ist ungültig — es wird nichts archiviert, `archive:retry-pending` bricht ab. Schlüssel aus der getrennten Sicherung setzen, `config:cache`.',
$unknownKeyEntries > 0 => $unknownKeyEntries.' Archiveintrag/-einträge mit unbekanntem Schlüssel — früheren Schlüssel in ARCHIVE_PREVIOUS_KEYS ergänzen.',
default => 'ARCHIVE_KEY gesetzt (Schlüssel-ID '.$archive->keyId().', '.(count($archive->knownKeyIds()) - 1).' frühere).',
},
];
// Offene Archivaufträge (N11, A-02) und Altmarker ohne Auftrag;
// dazu weich gelöschte v2-Bilder, deren lokale Beweisdateien noch
// nicht bereinigt sind (5.1 e).
$pending = app(ArchivePendingQueue::class)->status();
$pendingTotal = $pending['jobs'] + $pending['legacy_markers'];
$checks[] = [
'key' => 'archive_pending',
'label' => 'Beweisarchiv-Nachholer',
'status' => $pendingTotal === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $pendingTotal === 0
? 'Alle Archiveinträge wurden beim Vorgang geschrieben.'
: sprintf(
'%d offene(r) Archivauftrag/-aufträge%s%s — `archive:retry-pending` läuft täglich; Log auf ARCHIVE_KEY/Archiv-Disk prüfen.',
$pending['jobs'],
$pending['oldest'] !== null ? ' (ältester seit '.$pending['oldest']->format('d.m.Y H:i').')' : '',
$pending['legacy_markers'] > 0 ? ', '.$pending['legacy_markers'].' Altmarker ohne Auftrag' : '',
),
];
// Orphan-Sweep ausgesetzt (Restore-Abgleich, A-03).
$sweepPaused = $archive->isSweepPaused();
$checks[] = [
'key' => 'archive_sweep',
'label' => 'Beweisarchiv-Orphan-Sweep',
'status' => $sweepPaused ? self::StatusWarn : self::StatusOk,
'detail' => $sweepPaused
? 'Ausgesetzt (Restore-Abgleich oder ARCHIVE_SWEEP_PAUSED) — nach `archive:reregister` mit `archive:sweep-orphans --resume` fortsetzen.'
: 'Aktiv; verwaiste Payloads wandern in die Quarantäne ('.(int) config('archive.quarantine_days', 30).' Tage).',
];
$evidenceLeftovers = PressReleaseImageRemover::countLocalEvidenceLeftovers();
$checks[] = [
'key' => 'image_evidence_leftovers',
'label' => 'Bildnachweise lokal',
'status' => $evidenceLeftovers === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $evidenceLeftovers === 0
? 'Keine entfernten Bilder mit lokalen Beweisdateien.'
: $evidenceLeftovers.' entfernte(s) Bild(er) mit noch vorhandenen lokalen Beweisdateien (Original/Snapshot) — Überführung ausstehend oder Löschung fehlgeschlagen, siehe Log.',
];
// Offene Bild-Sichtungen aus dem KI-Vorab-Check (Block K).
$pendingPrechecks = PressReleaseImage::query()->awaitingPrecheckReview()->count();
$checks[] = [
'key' => 'image_precheck_queue',
'label' => 'Bild-Sichtung',
'status' => $pendingPrechecks === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $pendingPrechecks === 0
? 'Keine Titelbilder warten auf eine Sichtung.'
: $pendingPrechecks.' Titelbild(er) warten auf die Sichtung durch einen Admin (Pressemitteilungen ▸ Bild-Sichtung).',
];
// Ausstehende Medien-Privatisierung.
$publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count();
$publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count();
$publicMedia = $publicImages + $publicAttachments;
$checks[] = [
'key' => 'media_privatization',
'label' => 'Medien-Privatisierung',
'status' => $publicMedia > 0 ? self::StatusWarn : self::StatusOk,
'detail' => $publicMedia > 0
? "Noch {$publicMedia} PM-Medien auf der public-Disk — `press-releases:privatize-media` ausführen."
: 'Alle PM-Medien liegen auf der privaten Disk.',
];
return $checks;
}
/**
* @param array<string, mixed> $queue
*/
private function queueWorkerDetail(array $queue): string
{
if (! $queue['supported']) {
return "Queue-Treiber „{$queue['driver']}\" — Job-Metriken hier nicht verfügbar.";
}
if ($queue['oldest_pending_seconds'] === null) {
return 'Keine wartenden Jobs. Worker im Leerlauf oder aktiv.';
}
$age = $this->humanizeSeconds((int) $queue['oldest_pending_seconds']);
if ($queue['status'] === self::StatusCritical) {
return "Ältester wartender Job seit {$age} unbearbeitet — Worker vermutlich gestoppt.";
}
return "Ältester wartender Job seit {$age} in der Warteschlange.";
}
private function jobDisplayName(string $payload): string
{
try {
$data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
} catch (Throwable) {
return 'Unbekannter Job';
}
if (is_array($data) && is_string($data['displayName'] ?? null)) {
return $data['displayName'];
}
return 'Unbekannter Job';
}
private function firstExceptionLine(string $exception): string
{
$line = trim(strtok($exception, "\n") ?: '');
return $line === '' ? 'Unbekannter Fehler' : mb_strimwidth($line, 0, 240, '…');
}
private function humanizeSeconds(int $seconds): string
{
if ($seconds < 60) {
return "{$seconds}s";
}
if ($seconds < 3600) {
return floor($seconds / 60).'min';
}
return floor($seconds / 3600).'h';
}
/**
* @param list<string> $statuses
*/
private function worstStatus(array $statuses): string
{
if (in_array(self::StatusCritical, $statuses, true)) {
return self::StatusCritical;
}
if (in_array(self::StatusWarn, $statuses, true)) {
return self::StatusWarn;
}
return self::StatusOk;
}
}