Files
deskhub/Plugins/plugins/ueberwachung/lib.php
T
2026-10-24 20:15:16 +00:00

433 lines
28 KiB
PHP
Raw 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
// Website- und Server-Überwachung. Alle Funktionen beginnen mit ueberwachung_.
const UEBERWACHUNG = 'ueberwachung';
require_once __DIR__ . '/meldung.php';
/**
* Selbstheilung: Fehlt nach einem Update eine Datenbank-Änderung des Plugins (ältere DeskHub-Dateien spielen sie nicht von selbst ein),
* wird sie hier nachgeholt, statt dass das Plugin mit Datenbankfehlern abgeschaltet wird.
*/
function ueberwachung_ensure_schema(bool $force = false): void
{
static $done = false;
if ($done && !$force) return;
$done = true;
try {
get_db()->query('SELECT first_fail_at, verify_tls, last_status, last_title FROM plugin_ueberwachung_monitors LIMIT 1');
} catch (Throwable $e) {
try {
plugin_run_migrations(get_db(), UEBERWACHUNG);
} catch (Throwable $e2) {
error_log('ueberwachung schema: ' . $e2->getMessage());
}
}
}
function ueberwachung_config(): array
{
$mail = trim((string) plugin_setting(UEBERWACHUNG, 'alert_email', ''));
return [
'threshold' => max(1, min(10, (int) plugin_setting(UEBERWACHUNG, 'threshold', '2'))),
'department_id' => (int) plugin_setting(UEBERWACHUNG, 'department_id', '0'),
'priority_id' => (int) plugin_setting(UEBERWACHUNG, 'priority_id', '0'),
'create_tickets' => plugin_setting(UEBERWACHUNG, 'create_tickets', '1') === '1',
'auto_resolve' => plugin_setting(UEBERWACHUNG, 'auto_resolve', '1') === '1',
'mail' => plugin_setting(UEBERWACHUNG, 'mail', '1') === '1',
'alert_email' => $mail !== '' ? $mail : (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')),
];
}
function ueberwachung_types(): array
{
return ['http' => 'Webseite (HTTP/HTTPS)', 'port' => 'Port (TCP)', 'tls' => 'Zertifikat eines Dienstes (TLS-Port)'];
}
/** Prüft die Eingaben eines Überwachungsziels. @return array{ok:bool,error?:string,data?:array} */
function ueberwachung_validate(array $in): array
{
$name = trim((string) ($in['name'] ?? ''));
$type = (string) ($in['type'] ?? 'http');
$target = trim((string) ($in['target'] ?? ''));
if ($name === '' || mb_strlen($name) > 120) return ['ok' => false, 'error' => 'Bitte einen Namen angeben (höchstens 120 Zeichen).'];
if (!isset(ueberwachung_types()[$type])) return ['ok' => false, 'error' => 'Unbekannte Art der Prüfung.'];
if ($type === 'http') {
$p = parse_url($target);
if (!$p || !in_array($p['scheme'] ?? '', ['http', 'https'], true) || empty($p['host']) || preg_match('/[\s\x00-\x1F]/', $target)) return ['ok' => false, 'error' => 'Bitte eine Adresse mit http:// oder https:// angeben.'];
} else {
if (!preg_match('/^([A-Za-z0-9.-]{1,253}|\[[0-9a-fA-F:]+\]):(\d{1,5})$/', $target, $m) || (int) $m[2] < 1 || (int) $m[2] > 65535) return ['ok' => false, 'error' => 'Bitte Host und Port im Format host:port angeben (zum Beispiel mail.example.de:993).'];
}
return ['ok' => true, 'data' => [
'name' => $name, 'type' => $type, 'target' => mb_substr($target, 0, 500),
'expect_text' => $type === 'http' ? mb_substr(trim((string) ($in['expect_text'] ?? '')), 0, 200) : '',
'interval_min' => max(1, min(1440, (int) ($in['interval_min'] ?? 5))),
'timeout_sec' => max(2, min(30, (int) ($in['timeout_sec'] ?? 8))),
'cert_warn_days' => max(1, min(90, (int) ($in['cert_warn_days'] ?? 14))),
'verify_tls' => array_key_exists('verify_tls', $in) ? (!empty($in['verify_tls']) ? 1 : 0) : 1,
'is_active' => array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : 1,
]];
}
function ueberwachung_save(PDO $pdo, array $in, int $id = 0): array
{
$v = ueberwachung_validate($in);
if (!$v['ok']) return $v;
$d = $v['data'];
if ($id === 0) {
$pdo->prepare('INSERT INTO plugin_ueberwachung_monitors (name, type, target, expect_text, interval_min, timeout_sec, cert_warn_days, verify_tls, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
->execute([$d['name'], $d['type'], $d['target'], $d['expect_text'], $d['interval_min'], $d['timeout_sec'], $d['cert_warn_days'], $d['verify_tls'], $d['is_active'], db_now()]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET name=?, type=?, target=?, expect_text=?, interval_min=?, timeout_sec=?, cert_warn_days=?, verify_tls=?, is_active=? WHERE id=?')
->execute([$d['name'], $d['type'], $d['target'], $d['expect_text'], $d['interval_min'], $d['timeout_sec'], $d['cert_warn_days'], $d['verify_tls'], $d['is_active'], $id]);
return ['ok' => true, 'id' => $id];
}
function ueberwachung_delete(PDO $pdo, int $id): void
{
$pdo->prepare('DELETE FROM plugin_ueberwachung_events WHERE monitor_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM plugin_ueberwachung_monitors WHERE id = ?')->execute([$id]);
}
// ── Prüfung ────────────────────────────────────────────────────────────────
/** Eine Prüfung ausführen. @return array{ok:bool,error:?string,ms:int,cert_expires:?int} (für Tests über $GLOBALS['ueberwachung_probe'] austauschbar) */
function ueberwachung_probe(array $m): array
{
if (isset($GLOBALS['ueberwachung_probe']) && is_callable($GLOBALS['ueberwachung_probe'])) return ($GLOBALS['ueberwachung_probe'])($m);
$timeout = max(2, (int) $m['timeout_sec']);
$start = microtime(true);
$ms = fn() => (int) round((microtime(true) - $start) * 1000);
$verifyTls = !isset($m['verify_tls']) || (int) $m['verify_tls'] === 1;
$ssl = ['capture_peer_cert' => true, 'verify_peer' => $verifyTls, 'verify_peer_name' => $verifyTls];
$certInfo = [];
$certTs = function ($ctx) use (&$certInfo): ?int {
$params = stream_context_get_params($ctx);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if (!$cert) return null;
$info = @openssl_x509_parse($cert);
if (!$info) return null;
$certInfo = ['issuer' => (string) ($info['issuer']['O'] ?? $info['issuer']['CN'] ?? ''), 'cert_subject' => (string) ($info['subject']['CN'] ?? '')];
return (int) $info['validTo_time_t'];
};
if ($m['type'] === 'http' && function_exists('curl_init') && empty($GLOBALS['ueberwachung_no_curl'])) return ueberwachung_probe_curl($m, $timeout);
if ($m['type'] === 'http') {
$ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 5, 'header' => "User-Agent: DeskHub-Ueberwachung\r\nAccept: */*\r\n"], 'ssl' => $ssl]);
$old = ini_set('default_socket_timeout', (string) $timeout);
$warnings = [];
set_error_handler(function (int $no, string $str) use (&$warnings): bool { $warnings[] = $str; return true; });
try {
$fh = fopen((string) $m['target'], 'r', false, $ctx);
} finally {
restore_error_handler();
}
if ($old !== false) ini_set('default_socket_timeout', (string) $old);
if (!$fh) {
return ['ok' => false, 'error' => ueberwachung_describe_error(implode(' | ', $warnings)), 'ms' => $ms(), 'cert_expires' => null];
}
stream_set_timeout($fh, $timeout);
$body = (string) stream_get_contents($fh, 65536);
$meta = stream_get_meta_data($fh);
fclose($fh);
$status = 0;
foreach ((array) ($meta['wrapper_data'] ?? []) as $h) if (is_string($h) && preg_match('#^HTTP/\S+\s+(\d+)#', $h, $mm)) $status = (int) $mm[1];
$cert = $certTs($ctx);
$extra = $certInfo + ['status' => $status, 'title' => ueberwachung_page_title($body)];
if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP-Status ' . ($status ?: 'unbekannt'), 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
if ($m['expect_text'] !== '' && !str_contains($body, (string) $m['expect_text'])) return ['ok' => false, 'error' => 'Der erwartete Text fehlt auf der Seite.', 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
if ($m['expect_text'] === '' && ($why = ueberwachung_default_page($extra['title'], $body))) return ['ok' => false, 'error' => $why, 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
return ['ok' => true, 'error' => null, 'ms' => $ms(), 'cert_expires' => $cert] + $extra;
}
[$host, $port] = [preg_replace('/:\d+$/', '', (string) $m['target']), (int) substr((string) strrchr((string) $m['target'], ':'), 1)];
$isTls = $m['type'] === 'tls';
$ctx = stream_context_create(['ssl' => $ssl + ['peer_name' => trim($host, '[]')]]);
$fh = @stream_socket_client(($isTls ? 'ssl://' : 'tcp://') . $host . ':' . $port, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
if (!$fh) return ['ok' => false, 'error' => ueberwachung_describe_error((string) $errstr ?: 'connection refused'), 'ms' => $ms(), 'cert_expires' => null];
$cert = $isTls ? $certTs($ctx) : null;
fclose($fh);
return ['ok' => true, 'error' => null, 'ms' => $ms(), 'cert_expires' => $cert] + $certInfo;
}
/** Kennt dieses PHP keine vertrauenswürdigen Zertifizierungsstellen? Dann scheitert jede HTTPS-Prüfung mit „Zertifikat ungültig“, auch bei gesunden Seiten. */
function ueberwachung_ca_bundle_missing(): bool
{
// Mit open_basedir (bei Plesk üblich) lassen sich die Dateien nicht prüfen: dann nichts behaupten
if ((string) ini_get('open_basedir') !== '') return false;
foreach ([(string) ini_get('curl.cainfo'), (string) ini_get('openssl.cafile'), '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'] as $file) {
if ($file !== '' && is_file($file)) return false;
}
$loc = function_exists('openssl_get_cert_locations') ? openssl_get_cert_locations() : [];
if (!empty($loc['default_cert_file']) && is_file($loc['default_cert_file'])) return false;
$dir = (string) ($loc['default_cert_dir'] ?? '');
return !($dir !== '' && is_dir($dir) && (glob($dir . '/*') ?: []));
}
/** Prüfung einer Webseite mit cURL: genaue Fehlertexte, HTTP-Status und Zertifikatsdaten. */
function ueberwachung_probe_curl(array $m, int $timeout): array
{
$verify = !isset($m['verify_tls']) || (int) $m['verify_tls'] === 1;
$body = '';
$ch = curl_init((string) $m['target']);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => min($timeout, 10),
CURLOPT_USERAGENT => 'DeskHub-Ueberwachung', CURLOPT_HTTPHEADER => ['Accept: */*'],
CURLOPT_SSL_VERIFYPEER => $verify, CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, CURLOPT_CERTINFO => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
// nur die ersten 64 KB lesen (reicht für den erwarteten Text)
CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
$body .= $data;
return strlen($body) > 65536 ? 0 : strlen($data);
},
]);
$start = microtime(true);
$ok = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$ms = (int) round((microtime(true) - $start) * 1000);
$cert = null;
$extra = [];
$info = curl_getinfo($ch, CURLINFO_CERTINFO);
if (is_array($info) && !empty($info[0]['Expire date'])) {
$ts = strtotime((string) $info[0]['Expire date']);
if ($ts) $cert = $ts;
$issuer = (string) ($info[0]['Issuer'] ?? '');
$subject = (string) ($info[0]['Subject'] ?? '');
$extra = ['issuer' => preg_match('/O\s*=\s*([^,\/]+)/', $issuer, $x) ? trim($x[1]) : (preg_match('/CN\s*=\s*([^,\/]+)/', $issuer, $x) ? trim($x[1]) : ''), 'cert_subject' => preg_match('/CN\s*=\s*([^,\/]+)/', $subject, $x) ? trim($x[1]) : ''];
}
curl_close($ch);
$extra += ['status' => $status];
// Zertifikatsfehler: ohne Prüfung nachsehen, ob die Seite antwortet und was für ein Zertifikat sie hat
if ($ok === false && $verify && preg_match('/certificate|ssl|tls/i', $error)) {
$r2 = ueberwachung_probe_curl(array_merge($m, ['verify_tls' => 0]), $timeout);
$msg = ueberwachung_describe_error($error);
if (!empty($r2['cert_expires']) && $r2['cert_expires'] < time()) $msg = 'Zertifikat/TLS-Fehler (Zertifikat abgelaufen): Die gesicherte Verbindung kam nicht zustande';
if (ueberwachung_ca_bundle_missing()) $msg .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle). Das Ziel kann in Ordnung sein: Zertifikatsprüfung für dieses Ziel abschalten oder curl.cainfo/openssl.cafile in der PHP-Konfiguration setzen';
elseif (!empty($r2['status'])) $msg .= ' (die Seite antwortet ohne Prüfung mit HTTP ' . $r2['status'] . ')';
return ['ok' => false, 'error' => $msg, 'ms' => $ms, 'cert_expires' => $r2['cert_expires'] ?? null] + array_intersect_key($r2, ['issuer' => 1, 'cert_subject' => 1, 'status' => 1]);
}
// Abbruch durch unsere 64-KB-Grenze (Fehler 23) ist kein Fehler
if ($ok === false && !($errno === 23 && strlen($body) > 65536 && $status > 0)) {
return ['ok' => false, 'error' => ueberwachung_describe_error($error !== '' ? $error : 'curl error ' . $errno), 'ms' => $ms, 'cert_expires' => $cert] + $extra;
}
$extra['title'] = ueberwachung_page_title($body);
if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP-Status ' . ($status ?: 'unbekannt'), 'ms' => $ms, 'cert_expires' => $cert] + $extra;
if ($m['expect_text'] !== '' && !str_contains($body, (string) $m['expect_text'])) return ['ok' => false, 'error' => 'Der erwartete Text fehlt auf der Seite.', 'ms' => $ms, 'cert_expires' => $cert] + $extra;
if ($m['expect_text'] === '' && ($why = ueberwachung_default_page($extra['title'], $body))) return ['ok' => false, 'error' => $why, 'ms' => $ms, 'cert_expires' => $cert] + $extra;
return ['ok' => true, 'error' => null, 'ms' => $ms, 'cert_expires' => $cert] + $extra;
}
function ueberwachung_event(PDO $pdo, int $monitorId, string $kind, string $message): void
{
$pdo->prepare('INSERT INTO plugin_ueberwachung_events (monitor_id, kind, message, created_at) VALUES (?,?,?,?)')->execute([$monitorId, $kind, mb_substr($message, 0, 400), db_now()]);
}
/** Mail an den Administrator (für Tests über $GLOBALS['ueberwachung_mail'] austauschbar). */
function ueberwachung_alert(string $subject, string $html): void
{
$cfg = ueberwachung_config();
if (!$cfg['mail']) return;
foreach (preg_split('/[,;\s]+/', $cfg['alert_email'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) {
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) continue;
try {
if (isset($GLOBALS['ueberwachung_mail']) && is_callable($GLOBALS['ueberwachung_mail'])) ($GLOBALS['ueberwachung_mail'])($to, $subject, $html);
else send_mail($to, $subject, $html);
} catch (Throwable $e) {
error_log('ueberwachung mail: ' . $e->getMessage());
}
}
}
function ueberwachung_duration(int $seconds): string
{
if ($seconds < 90) return $seconds . ' Sekunden';
if ($seconds < 5400) return round($seconds / 60) . ' Minuten';
if ($seconds < 172800) return round($seconds / 3600, 1) . ' Stunden';
return round($seconds / 86400, 1) . ' Tage';
}
/**
* Legt ein Ticket ohne Ticket-Regeln, Absender-Sperre und Antwort-Mail an. Das ist der Rückfall, wenn das normale Anlegen scheitert:
* Eine Störungsmeldung darf nicht daran hängen, dass eine Regel („Spam ablehnen“) oder die Sperrliste die eigene Adresse trifft.
*/
function ueberwachung_create_direct(PDO $pdo, string $email, string $subject, string $message, ?int $deptId, ?int $prioId): int
{
$deptId = active_department_id($pdo, $deptId ?: (int) get_setting('default_department_id', '1'));
if (!$deptId) throw new RuntimeException('Es ist keine Abteilung eingerichtet.');
if (!$prioId) $prioId = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$p->execute([$prioId]);
$priority = $p->fetch();
if (!$priority) throw new RuntimeException('Die Priorität gibt es nicht.');
$sla = ['first_response_due_at' => null, 'resolution_due_at' => null];
try { $sla = calc_sla_dates($priority, null, $deptId, null); } catch (Throwable $e) { /* ohne Fristen */ }
$number = generate_ticket_number($pdo);
$now = db_now();
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
->execute([$number, mb_substr($subject, 0, 250), 'Überwachung', $email, $deptId, $prioId, 'open', 'api', '0.0.0.0', $sla['first_response_due_at'] ?? null, $sla['resolution_due_at'] ?? null, $now, $now]);
$id = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, $message, $now]);
log_activity($pdo, $id, null, 'Ticket von der Überwachung angelegt');
apply_tag_to_ticket($pdo, $id, 'Überwachung');
$d = $pdo->prepare('SELECT name FROM departments WHERE id = ?');
$d->execute([$deptId]);
try {
notify_staff_new_ticket(['ticket_number' => $number, 'subject' => $subject, 'requester_name' => 'Überwachung', 'requester_email' => $email, 'department_id' => $deptId, 'department_name' => $d->fetchColumn() ?: null]);
} catch (Throwable $e) { /* Benachrichtigung ist nachrangig */ }
try { webhook_dispatch($pdo, 'ticket.created', $id); } catch (Throwable $e) { /* dito */ }
return $id;
}
/** Legt ein Ticket an (erst auf dem normalen Weg, sonst direkt). Merkt sich den letzten Fehler für die Seite. @return int|null Ticket-ID */
function ueberwachung_open_ticket(PDO $pdo, string $subject, string $message): ?int
{
$cfg = ueberwachung_config();
if (!$cfg['create_tickets']) return null;
$first = preg_split('/[,;\s]+/', $cfg['alert_email'], -1, PREG_SPLIT_NO_EMPTY)[0] ?? '';
$email = filter_var($first, FILTER_VALIDATE_EMAIL) ? $first : 'ueberwachung@system.invalid';
$why = '';
try {
$r = public_ticket_create($pdo, [
'name' => 'Überwachung', 'email' => $email, 'subject' => mb_substr($subject, 0, 250), 'message' => $message,
'departmentId' => $cfg['department_id'] ?: null, 'priorityId' => $cfg['priority_id'] ?: null,
], '0.0.0.0', ['activity' => 'Ticket von der Überwachung angelegt', 'tag' => 'Überwachung']);
plugin_set_setting(UEBERWACHUNG, 'ticket_error', '');
return (int) $r['id'];
} catch (Throwable $e) {
$why = $e->getMessage();
error_log('ueberwachung ticket: ' . $why);
}
// Ist das Ticket trotz Fehler (zum Beispiel bei der Benachrichtigung) schon angelegt worden, nicht doppelt anlegen
$dup = $pdo->prepare("SELECT id FROM tickets WHERE subject = ? AND requester_name = 'Überwachung' AND created_at >= ? ORDER BY id DESC LIMIT 1");
$dup->execute([mb_substr($subject, 0, 250), date('Y-m-d H:i:s', time() - 120)]);
if ($id = $dup->fetchColumn()) { plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': ' . $why . ' (Ticket wurde trotzdem angelegt)'); return (int) $id; }
try {
$id = ueberwachung_create_direct($pdo, $email, $subject, $message, $cfg['department_id'] ?: null, $cfg['priority_id'] ?: null);
plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': Das Ticket musste am normalen Weg vorbei angelegt werden, weil dort abgelehnt wurde: ' . $why);
return $id;
} catch (Throwable $e) {
error_log('ueberwachung ticket (direkt): ' . $e->getMessage());
plugin_set_setting(UEBERWACHUNG, 'ticket_error', db_now() . ': Ticket konnte nicht angelegt werden: ' . $why . ' / ' . $e->getMessage());
return null;
}
}
/** Setzt das Ticket auf „Gelöst“ und vermerkt die Entwarnung (nur wenn es noch offen ist und die Einstellung es erlaubt). */
function ueberwachung_resolve_ticket(PDO $pdo, ?int $ticketId, string $note): void
{
if (!$ticketId) return;
$s = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
$s->execute([$ticketId]);
$status = $s->fetchColumn();
if ($status === false) return;
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticketId, $note, db_now()]);
log_activity($pdo, $ticketId, null, 'Überwachung: ' . $note);
if (!ueberwachung_config()['auto_resolve'] || in_array($status, ['resolved', 'closed'], true)) return;
$now = db_now();
$pdo->prepare("UPDATE tickets SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
webhook_dispatch($pdo, 'ticket.status_changed', $ticketId, ['previousStatus' => (string) $status]);
}
/** Wertet ein Prüfergebnis aus: Zustand, Ticket, Mail, Entwarnung. */
function ueberwachung_apply(PDO $pdo, array $m, array $res): void
{
$cfg = ueberwachung_config();
$now = db_now();
$id = (int) $m['id'];
$certStr = $res['cert_expires'] ? date('Y-m-d H:i:s', (int) $res['cert_expires']) : ($m['cert_expires'] ?? null);
$upd = $pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ?, last_ms = ?, cert_expires = ?, last_status = ?, last_title = ? WHERE id = ?');
$upd->execute([$now, $res['ms'], $certStr, !empty($res['status']) ? (int) $res['status'] : null, isset($res['title']) && $res['title'] !== '' ? mb_substr((string) $res['title'], 0, 150) : null, $id]);
if ($res['ok']) {
$wasDown = $m['state'] === 'down';
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'up', fails = 0, last_ok_at = ?, last_error = NULL, down_since = NULL, first_fail_at = NULL, ticket_id = ? WHERE id = ?")->execute([$now, $wasDown ? null : $m['ticket_id'], $id]);
if ($wasDown) {
$dur = $m['down_since'] ? ueberwachung_duration(max(0, time() - (int) strtotime((string) $m['down_since']))) : 'unbekannt';
ueberwachung_event($pdo, $id, 'up', 'Wieder erreichbar nach ' . $dur . '.');
ueberwachung_resolve_ticket($pdo, $m['ticket_id'] ? (int) $m['ticket_id'] : null, ueberwachung_up_note($m, $dur));
ueberwachung_alert(ueberwachung_subject('Entwarnung', $m) . ' ist wieder erreichbar', '<p><strong>' . e($m['name']) . '</strong> ist wieder erreichbar.</p>' . ueberwachung_table([['Dienst', (string) $m['name']], ['Adresse', (string) $m['target']], ['Ausfall', ueberwachung_dt((string) ($m['first_fail_at'] ?? $m['down_since'] ?? '')) . ' bis ' . date('d.m.Y H:i:s')], ['Dauer', $dur], ['Antwortzeit jetzt', (int) $res['ms'] . ' ms']]));
}
} else {
$fails = (int) $m['fails'] + 1;
$firstFail = ($fails === 1 || empty($m['first_fail_at'])) ? $now : (string) $m['first_fail_at'];
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET fails = ?, last_error = ?, first_fail_at = ? WHERE id = ?')->execute([$fails, mb_substr((string) $res['error'], 0, 300), $firstFail, $id]);
if ($fails >= $cfg['threshold'] && $m['state'] !== 'down') {
$body = ueberwachung_down_body($m, $res, $fails, $firstFail);
$ticket = ueberwachung_open_ticket($pdo, ueberwachung_subject('Ausfall', $m), $body);
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'down', down_since = ?, ticket_id = ? WHERE id = ?")->execute([$firstFail, $ticket, $id]);
ueberwachung_event($pdo, $id, 'down', 'Ausfall: ' . $res['error'] . ($ticket ? '' : ($cfg['create_tickets'] ? ' (Ticket konnte nicht angelegt werden, siehe Hinweis oben)' : ' (Tickets sind ausgeschaltet)')));
ueberwachung_alert(ueberwachung_subject('Ausfall', $m), $body);
} elseif ($fails < $cfg['threshold'] && $m['state'] === 'unknown') {
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET state = 'up' WHERE id = ? AND state = 'unknown'")->execute([$id]);
}
}
// Zertifikat
if ($res['cert_expires']) {
$days = (int) floor(((int) $res['cert_expires'] - time()) / 86400);
$warn = (int) $m['cert_warn_days'];
if ($days <= $warn && !$m['cert_ticket_id']) {
$text = $days < 0 ? 'Das Zertifikat von ' . $m['name'] . ' ist seit ' . abs($days) . ' Tagen abgelaufen.' : 'Das Zertifikat von ' . $m['name'] . ' läuft in ' . $days . ' Tagen ab (' . date('d.m.Y', (int) $res['cert_expires']) . ').';
$certBody = ueberwachung_cert_body($m, $res, $days);
$ticket = ueberwachung_open_ticket($pdo, ueberwachung_subject('Zertifikat', $m), $certBody);
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET cert_ticket_id = ? WHERE id = ?')->execute([$ticket ?: -1, $id]);
ueberwachung_event($pdo, $id, 'cert', $text);
ueberwachung_alert(ueberwachung_subject($days < 0 ? 'Zertifikat abgelaufen' : 'Zertifikat läuft bald ab', $m), $certBody);
} elseif ($days > $warn && $m['cert_ticket_id']) {
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET cert_ticket_id = NULL WHERE id = ?')->execute([$id]);
ueberwachung_event($pdo, $id, 'cert_ok', 'Zertifikat erneuert, gültig bis ' . date('d.m.Y', (int) $res['cert_expires']) . '.');
ueberwachung_resolve_ticket($pdo, (int) $m['cert_ticket_id'] > 0 ? (int) $m['cert_ticket_id'] : null, 'Zertifikat erneuert (' . $m['name'] . '), gültig bis ' . date('d.m.Y', (int) $res['cert_expires']) . '.');
ueberwachung_alert('Zertifikat erneuert: ' . $m['name'], '<p>Das Zertifikat von <strong>' . e($m['name']) . '</strong> wurde erneuert und ist gültig bis ' . e(date('d.m.Y', (int) $res['cert_expires'])) . '.</p>');
}
}
}
/** Prüft ein Ziel jetzt. */
function ueberwachung_check(PDO $pdo, int $id): bool
{
$s = $pdo->prepare('SELECT * FROM plugin_ueberwachung_monitors WHERE id = ?');
$s->execute([$id]);
$m = $s->fetch();
if (!$m) return false;
try {
$res = ueberwachung_probe($m);
} catch (Throwable $e) {
$res = ['ok' => false, 'error' => 'Prüfung fehlgeschlagen: ' . mb_substr($e->getMessage(), 0, 150), 'ms' => 0, 'cert_expires' => null];
}
ueberwachung_apply($pdo, $m, $res);
return true;
}
/** Wartezeit bis zur nächsten Prüfung in Sekunden: der eingestellte Abstand, nach einem ersten Fehlschlag (Ausfall noch nicht gemeldet) nur eine Minute. */
function ueberwachung_next_wait(array $m): int
{
if ((int) ($m['fails'] ?? 0) > 0 && ($m['state'] ?? '') !== 'down') return 45;
return max(45, (int) $m['interval_min'] * 60 - 15);
}
/** Hintergrundlauf: fällige Ziele prüfen (höchstens 6 pro Lauf, damit der Lauf kurz bleibt). */
function ueberwachung_tick(PDO $pdo): int
{
plugin_set_setting(UEBERWACHUNG, 'last_tick', (string) time());
$due = [];
foreach ($pdo->query('SELECT id, interval_min, last_check_at, fails, state FROM plugin_ueberwachung_monitors WHERE is_active = 1 ORDER BY last_check_at')->fetchAll() as $m) {
if ($m['last_check_at'] === null || time() - (int) strtotime((string) $m['last_check_at']) >= ueberwachung_next_wait($m)) $due[] = (int) $m['id'];
if (count($due) >= 6) break;
}
foreach ($due as $id) ueberwachung_check($pdo, $id);
if ($due && time() - (int) plugin_setting(UEBERWACHUNG, 'last_prune', '0') > 86400) {
plugin_set_setting(UEBERWACHUNG, 'last_prune', (string) time());
$pdo->prepare('DELETE FROM plugin_ueberwachung_events WHERE created_at < ?')->execute([date('Y-m-d H:i:s', time() - 90 * 86400)]);
}
return count($due);
}
plugin_add_hook('plugin.uninstall.' . UEBERWACHUNG, function () {
try {
get_db()->exec('DROP TABLE IF EXISTS plugin_ueberwachung_events');
get_db()->exec('DROP TABLE IF EXISTS plugin_ueberwachung_monitors');
} catch (Throwable $e) {
}
});