Files
2026-10-28 07:54:38 +00:00

211 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Systemüberwachung: erkennt, wenn der Mail-Abruf oder die Hintergrundjobs nicht mehr laufen,
* benachrichtigt Administratoren per E-Mail und liefert eine Liste von Problemen für die
* Weboberfläche und die Apps (Banner).
*/
/** Ab so vielen Fehlversuchen in Folge gilt ein Postfach als ausgefallen (bei 5-Minuten-Takt ≈ 15 Min.). */
const HEALTH_FAIL_THRESHOLD = 3;
/** Wiederholte Alarm-Mail frühestens nach so vielen Sekunden, solange der Fehler anhält. */
const HEALTH_ALERT_REPEAT = 86400;
/** E-Mail-Adressen aller aktiven Administratoren. */
function health_admin_emails(PDO $pdo): array
{
return $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
/**
* Hält das Ergebnis eines Abrufs für ein Postfach fest. $error = null bedeutet Erfolg.
* Verschickt eine Alarm-Mail an die Administratoren, wenn der Fehler anhält, und eine
* Entwarnung, wenn das Postfach wieder erreichbar ist.
*/
function health_record_mail_result(PDO $pdo, array $account, ?string $error): void
{
$now = db_now();
$label = $account['label'] ?: $account['username'];
if ($error === null) {
$wasAlerted = !empty($account['alert_sent_at']);
$pdo->prepare('UPDATE imap_accounts SET last_success_at = ?, last_error = NULL, fail_count = 0, alert_sent_at = NULL WHERE id = ?')->execute([$now, $account['id']]);
if ($wasAlerted) {
foreach (health_admin_emails($pdo) as $to) {
send_mail($to, "DeskHub: Postfach „{$label}“ ist wieder erreichbar",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> funktioniert wieder.</p>');
}
}
return;
}
$fails = (int) ($account['fail_count'] ?? 0) + 1;
$pdo->prepare('UPDATE imap_accounts SET last_error = ?, fail_count = ? WHERE id = ?')->execute([mb_substr($error, 0, 500), $fails, $account['id']]);
$lastAlert = !empty($account['alert_sent_at']) ? strtotime($account['alert_sent_at']) : 0;
if ($fails >= HEALTH_FAIL_THRESHOLD && (time() - $lastAlert) >= HEALTH_ALERT_REPEAT) {
$since = $account['last_success_at'] ? date('d.m.Y H:i', strtotime($account['last_success_at'])) : 'noch nie';
$sent = false;
foreach (health_admin_emails($pdo) as $to) {
$sent = send_mail($to, "DeskHub: Mail-Abruf für „{$label}“ fehlgeschlagen",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> (' . e($account['host']) . ') schlägt seit '
. $fails . ' Versuchen fehl. Eingehende Mails werden in dieser Zeit <strong>nicht</strong> als Tickets angelegt.</p>'
. '<p>Letzter Fehler: <code>' . e($error) . '</code><br>Letzter erfolgreicher Abruf: ' . e($since) . '</p>'
. '<p>Bitte Zugangsdaten und Server unter E-Mail-Adressen → Entferntes Postfach prüfen.</p>') || $sent;
}
// Auch ohne versendete Mail als „gemeldet“ markieren, damit nicht bei jedem Lauf neu versucht wird
$pdo->prepare('UPDATE imap_accounts SET alert_sent_at = ? WHERE id = ?')->execute([$now, $account['id']]);
}
}
/** Merkt sich den Erfolg bzw. Misserfolg des letzten Mailversands (für die Statusanzeige). */
function health_record_send_result(bool $ok, string $error = ''): void
{
try {
if ($ok) { set_setting('mail_send_last_ok', db_now()); return; }
set_setting('mail_send_last_error', db_now() . '|' . mb_substr($error, 0, 300));
} catch (Throwable $e) {
// Überwachung darf den Versand nie beeinträchtigen
}
}
/**
* Liefert die aktuellen Probleme als Liste von ['level' => 'error'|'warning', 'code' => ..., 'message' => ...].
* Leer = alles in Ordnung. Rein lesend, daher auch bei jedem Seitenaufruf aufrufbar.
*/
function system_health(PDO $pdo): array
{
$issues = [];
// Das zuletzt geprüfte Backup lässt sich nicht wiederherstellen
$bk = json_decode((string) get_setting('backup_last_check', ''), true);
if (is_array($bk) && array_key_exists('ok', $bk) && !$bk['ok']) {
$issues[] = ['level' => 'error', 'code' => 'backup_broken', 'message' => 'Das Backup ' . ($bk['file'] ?? '') . ' ist nicht wiederherstellbar: ' . implode(' ', (array) ($bk['errors'] ?? [])) . ' Bitte unter Backup ein neues erstellen.'];
}
$accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1')->fetchAll();
if ($accounts) {
if (!function_exists('imap_open')) {
$issues[] = ['level' => 'error', 'code' => 'imap_missing', 'message' => 'Die PHP-IMAP-Erweiterung fehlt – eingehende Mails können nicht abgerufen werden.'];
}
foreach ($accounts as $a) {
$label = $a['label'] ?: $a['username'];
$fails = (int) ($a['fail_count'] ?? 0);
if ($fails >= HEALTH_FAIL_THRESHOLD) {
$since = !empty($a['last_success_at']) ? ' Letzter Erfolg: ' . date('d.m.Y H:i', strtotime($a['last_success_at'])) . '.' : '';
$issues[] = ['level' => 'error', 'code' => 'mailbox_down', 'message' => "Postfach „{$label}“ ist nicht erreichbar ({$fails} Fehlversuche in Folge).{$since} " . ($a['last_error'] ?? '')];
} elseif ($fails > 0) {
$issues[] = ['level' => 'warning', 'code' => 'mailbox_flaky', 'message' => "Postfach „{$label}“: letzter Abruf fehlgeschlagen. " . ($a['last_error'] ?? '')];
}
}
// Der Abruf selbst läuft nicht mehr (Cronjob/Seitenaufrufe bleiben aus)
$last = get_setting('cron_fetch_emails_last_run', '');
if (get_setting('pseudo_cron_enabled', '1') === '1' || $last !== '') {
if ($last !== '' && (time() - strtotime($last)) > 1200) {
$issues[] = ['level' => 'warning', 'code' => 'fetch_stale', 'message' => 'Der Mail-Abruf wurde seit ' . cron_last_run_label($last) . ' nicht ausgeführt. Läuft der Cronjob (cron/fetch_emails.php)?'];
}
}
}
$lastErr = (string) get_setting('mail_send_last_error', '');
if ($lastErr !== '') {
[$at, $msg] = array_pad(explode('|', $lastErr, 2), 2, '');
$ok = (string) get_setting('mail_send_last_ok', '');
if ($at !== '' && strtotime($at) > time() - 86400 && ($ok === '' || strtotime($ok) < strtotime($at))) {
$issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg];
}
}
// Mails, die nicht rausgingen: warten in der Warteschlange oder wurden aufgegeben
if (function_exists('mail_queue_counts')) {
$q = mail_queue_counts($pdo);
if ($q['failed'] > 0) $issues[] = ['level' => 'error', 'code' => 'mail_queue_failed', 'message' => $q['failed'] . ' Mail(s) konnten auch nach mehreren Versuchen nicht gesendet werden. Unter Verwalten → Mail-Warteschlange nachsenden oder löschen.'];
elseif ($q['pending'] > 0) $issues[] = ['level' => 'warning', 'code' => 'mail_queue_pending', 'message' => $q['pending'] . ' Mail(s) warten in der Warteschlange und werden erneut versucht (Verwalten → Mail-Warteschlange).'];
}
return $issues;
}
// ── Fehler-Mail bei schweren PHP-Fehlern (wie bei WordPress) ───────────────────
/** Fehlerarten, die eine Seite abbrechen. */
const ERROR_ALERT_FATAL = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
/** Dieselbe Meldung frühestens nach so vielen Sekunden erneut mailen. */
const ERROR_ALERT_REPEAT = 3600;
/** Höchstens so viele Fehler-Mails pro Stunde insgesamt (schützt das Postfach bei einem Fehler auf jeder Seite). */
const ERROR_ALERT_MAX_PER_HOUR = 10;
/** Empfänger: eigene Adresse aus den Einstellungen, sonst Admin-Adresse, sonst alle aktiven Administratoren. */
function error_alert_recipients(PDO $pdo): array
{
$own = trim((string) get_setting('error_alert_email', ''));
if ($own !== '') return array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', $own) ?: []), fn($m) => filter_var($m, FILTER_VALIDATE_EMAIL)));
$admin = trim((string) get_setting('admin_email', '') ?: (string) (config()['site']['admin_email'] ?? ''));
if (filter_var($admin, FILTER_VALIDATE_EMAIL) && $admin !== 'support@example.com') return [$admin];
return health_admin_emails($pdo);
}
/** Betreff und Text der Mail. Nur Pfad der Adresse (keine Parameter, dort können Zugangscodes stehen), keine Formular- oder Cookie-Daten. */
function error_alert_compose(array $err, array $context = []): array
{
$root = str_replace('\\', '/', dirname(__DIR__)) . '/';
$file = str_replace($root, '', str_replace('\\', '/', (string) ($err['file'] ?? '')));
$msg = mb_substr(str_replace($root, '', str_replace('\\', '/', (string) ($err['message'] ?? ''))), 0, 1500);
$site = (string) get_setting('display_name', 'DeskHub');
$path = (string) ($context['path'] ?? (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '(Hintergrundaufruf)'));
$headline = !empty($err['headline']) ? e((string) $err['headline']) : 'Auf <strong>' . e($site) . '</strong> ist ein schwerer Fehler aufgetreten. Die aufgerufene Seite konnte nicht angezeigt werden.';
$html = '<p>' . $headline . '</p>'
. '<p><strong>Fehler:</strong><br><code>' . nl2br(e($msg)) . '</code></p>'
. '<p><strong>Datei:</strong> ' . e($file) . ((int) ($err['line'] ?? 0) > 0 ? ', Zeile ' . (int) $err['line'] : '') . '<br>'
. '<strong>Seite:</strong> ' . e($path) . '<br>'
. '<strong>Zeit:</strong> ' . e(date('d.m.Y H:i:s')) . '<br>'
. '<strong>Version:</strong> DeskHub ' . e((string) (defined('DESKHUB_VERSION') ? DESKHUB_VERSION : '?')) . ', PHP ' . e(PHP_VERSION) . '</p>'
. '<p style="color:#666;font-size:12px">Dieselbe Meldung wird höchstens einmal pro Stunde gesendet. Ausschalten oder Empfänger ändern: Einstellungen → Allgemein.</p>';
return [!empty($err['subject']) ? "{$site}: " . (string) $err['subject'] : "{$site}: Schwerer Fehler auf der Webseite", $html];
}
/**
* Meldet einen Fehler, der die Seite nicht abbricht, aber Aufmerksamkeit braucht (zum Beispiel ein Plugin, das Fehler wirft, oder ein
* fehlgeschlagener Hintergrundjob). Gleiche Ein-/Ausschalter, Empfänger und Drosselung wie bei schweren Fehlern.
*/
function error_alert_notice(string $subject, string $headline, string $where, string $message): void
{
try {
error_alert_dispatch(get_db(), ['type' => E_USER_WARNING, 'message' => $message, 'file' => $where, 'line' => 0, 'subject' => $subject, 'headline' => $headline]);
} catch (Throwable $e) {
error_log('error_alert_notice: ' . $e->getMessage());
}
}
/**
* Prüft Ein/Aus und Drosselung und verschickt die Mail. $send ist nur für Tests austauschbar (Standard: send_mail).
* Liefert die Zahl der Empfänger, an die gesendet wurde.
*/
function error_alert_dispatch(PDO $pdo, array $err, ?callable $send = null): int
{
if (get_setting('error_alert', '1') !== '1') return 0;
$fp = sha1(preg_replace('/\d+/', '#', (string) ($err['file'] ?? '') . ':' . substr((string) ($err['message'] ?? ''), 0, 200)));
$now = time();
$log = json_decode((string) get_setting('error_alert_log', '[]'), true);
$log = array_values(array_filter(is_array($log) ? $log : [], fn($r) => is_array($r) && $now - (int) ($r[0] ?? 0) < 86400));
$lastHour = array_filter($log, fn($r) => $now - (int) $r[0] < 3600);
if (count($lastHour) >= ERROR_ALERT_MAX_PER_HOUR) return 0;
foreach ($lastHour as $r) if (($r[1] ?? '') === $fp && $now - (int) $r[0] < ERROR_ALERT_REPEAT) return 0;
// erst merken, dann senden: Schlägt das Senden selbst fehl, folgt keine Endlosschleife
$log[] = [$now, $fp];
set_setting('error_alert_log', json_encode($log));
[$subject, $html] = error_alert_compose($err);
$sent = 0;
foreach (error_alert_recipients($pdo) as $to) {
if (($send ?? $GLOBALS['error_alert_send'] ?? 'send_mail')($to, $subject, $html)) $sent++;
}
return $sent;
}
/** Wird am Ende jeder Anfrage aufgerufen: war es ein schwerer Fehler, geht eine Mail an den Administrator. */
function error_alert_shutdown(): void
{
static $running = false;
$err = error_get_last();
if ($running || !$err || !in_array($err['type'], ERROR_ALERT_FATAL, true)) return;
$running = true;
try {
error_alert_dispatch(get_db(), $err);
} catch (Throwable $e) {
error_log('error_alert: ' . $e->getMessage());
}
}
register_shutdown_function('error_alert_shutdown');