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>
416 lines
17 KiB
PHP
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;
|
|
}
|
|
}
|