Files
deskhub/DeskHub-Webseite/tests/plugin_ueberwachung.php
T
2026-10-24 20:14:43 +00:00

420 lines
36 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
declare(strict_types=1);
/**
* Prüft das Plugin "Website- und Server-Überwachung": Eingaben, Ausfall nach mehreren Fehlschlägen (Ticket, Mail), Entwarnung (Ticket gelöst),
* Zertifikatswarnung und Erneuerung, Pausieren, Fälligkeit, und die echten Prüfungen gegen einen lokalen Webserver und Port.
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-ue-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
require_once __DIR__ . '/fixtures/paths.php';
$src = plugin_source_dir_or_skip('ueberwachung', 'plugin_ueberwachung');
$zipPath = sys_get_temp_dir() . '/ue-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
}
$zip->close();
$r = plugin_install_zip($pdo, $zipPath);
$check($r['ok'] && $r['slug'] === 'ueberwachung', 'Plugin lässt sich installieren: ' . json_encode($r));
$act = plugin_set_active($pdo, 'ueberwachung', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
$state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Menüpunkt steht im Adminportal und nur für Administratoren');
set_setting('admin_email', 'admin@firma.example');
$mails = [];
$GLOBALS['ueberwachung_mail'] = function (string $to, string $subject, string $html) use (&$mails): void { $mails[] = compact('to', 'subject', 'html'); };
$answer = ['ok' => true, 'error' => null, 'ms' => 40, 'cert_expires' => null];
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
$mon = fn(int $id) => $pdo->query("SELECT * FROM plugin_ueberwachung_monitors WHERE id = {$id}")->fetch();
$ticket = fn(?int $id) => $id ? $pdo->query("SELECT * FROM tickets WHERE id = " . (int) $id)->fetch() : null;
// Eingaben
$bad = fn(array $in) => !ueberwachung_save($pdo, $in)['ok'];
$check($bad(['name' => '', 'type' => 'http', 'target' => 'https://x.example/']), 'Name ist Pflicht');
$check($bad(['name' => 'A', 'type' => 'http', 'target' => 'ftp://x.example']) && $bad(['name' => 'A', 'type' => 'http', 'target' => 'javascript:alert(1)']) && $bad(['name' => 'A', 'type' => 'http', 'target' => 'https://']), 'nur http(s)-Adressen');
$check($bad(['name' => 'A', 'type' => 'port', 'target' => 'host']) && $bad(['name' => 'A', 'type' => 'port', 'target' => 'host:99999']) && $bad(['name' => 'A', 'type' => 'port', 'target' => 'a b:80']) && $bad(['name' => 'A', 'type' => 'xyz', 'target' => 'h:80']), 'Port, Host und Art werden geprüft');
$web = ueberwachung_save($pdo, ['name' => 'Support', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 0, 'timeout_sec' => 999, 'cert_warn_days' => 14]);
$m1 = $mon($web['id']);
$check($web['ok'] && (int) $m1['interval_min'] === 1 && (int) $m1['timeout_sec'] === 30, 'Werte werden begrenzt');
$check(ueberwachung_save($pdo, ['name' => 'Support neu', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 5], $web['id'])['ok'] && $mon($web['id'])['name'] === 'Support neu', 'Ändern');
ueberwachung_save($pdo, ['name' => 'Support', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 5], $web['id']);
$id = $web['id'];
// Erste Prüfung in Ordnung
ueberwachung_check($pdo, $id);
$m = $mon($id);
$check($m['state'] === 'up' && (int) $m['fails'] === 0 && (int) $m['last_ms'] === 40 && $m['last_check_at'] !== null, 'erste Prüfung: in Ordnung');
$check($mails === [] && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'ohne Fehler keine Mail und kein Ticket');
// Ein einzelner Fehler löst noch nichts aus (Schwelle 2)
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 90, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
$m = $mon($id);
$check($m['state'] === 'up' && (int) $m['fails'] === 1 && $mails === [] && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'ein einzelner Fehlschlag meldet noch nichts');
// Kurzer Aussetzer behoben
$answer = ['ok' => true, 'error' => null, 'ms' => 50, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
$check((int) $mon($id)['fails'] === 0 && $mails === [], 'ein Aussetzer ohne Folgen setzt den Zähler zurück');
// Ausfall: zweiter Fehlschlag in Folge
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 90, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
ueberwachung_check($pdo, $id);
$m = $mon($id);
$t = $ticket($m['ticket_id'] ? (int) $m['ticket_id'] : null);
$check($m['state'] === 'down' && $m['down_since'] !== null && $t && str_contains($t['subject'], 'Ausfall: Support'), 'Ausfall: Zustand „down“ und Ticket');
$check($t && $t['status'] === 'open' && $t['requester_name'] === 'Überwachung' && $t['requester_email'] === 'admin@firma.example', 'Ticket ist offen, Absender „Überwachung“');
$tag = (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = " . (int) $m['ticket_id'] . " AND g.name = 'Überwachung'")->fetchColumn();
$check($tag === 1, 'Ticket trägt den Tag „Überwachung“');
$check(count($mails) === 1 && str_contains($mails[0]['subject'], 'Ausfall: Support') && $mails[0]['to'] === 'admin@firma.example' && str_contains($mails[0]['html'], 'HTTP-Status 503'), 'Mail an den Administrator');
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE kind = 'down'")->fetchColumn() === 1, 'Ereignis im Protokoll');
// Weiter fehlerhaft: kein zweites Ticket, keine zweite Mail
ueberwachung_check($pdo, $id);
ueberwachung_check($pdo, $id);
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 1 && count($mails) === 1, 'anhaltender Ausfall erzeugt kein weiteres Ticket und keine weitere Mail');
// Entwarnung
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET down_since = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 780), $id]);
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
$m = $mon($id);
$t = $ticket((int) $pdo->query('SELECT id FROM tickets ORDER BY id LIMIT 1')->fetchColumn());
$check($m['state'] === 'up' && $m['down_since'] === null && $m['ticket_id'] === null, 'wieder erreichbar: Zustand zurückgesetzt');
$check($t['status'] === 'resolved' && $t['resolved_at'] !== null, 'Ticket ist automatisch gelöst');
$note = $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} AND is_internal_note = 1 ORDER BY id DESC LIMIT 1")->fetchColumn();
$check(str_contains((string) $note, 'Wieder erreichbar') && str_contains((string) $note, '13 Minuten'), 'interne Notiz nennt die Dauer: ' . $note);
$check(count($mails) === 2 && str_contains($mails[1]['subject'], 'Entwarnung') && str_contains($mails[1]['html'], '13 Minuten'), 'Entwarnungs-Mail mit Dauer');
// Neuer Ausfall nach Entwarnung legt ein neues Ticket an
$answer = ['ok' => false, 'error' => 'Nicht erreichbar: Zeitüberschreitung', 'ms' => 8000, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
ueberwachung_check($pdo, $id);
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 2 && $mon($id)['state'] === 'down', 'ein neuer Ausfall bekommt ein neues Ticket');
// ohne automatisches Lösen bleibt das Ticket offen
plugin_set_setting('ueberwachung', 'auto_resolve', '0');
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
$last = $ticket((int) $pdo->query('SELECT MAX(id) FROM tickets')->fetchColumn());
$threads = $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$last['id']}")->fetchAll(PDO::FETCH_COLUMN);
$check($last['status'] === 'open' && count(array_filter($threads, fn($t) => str_contains((string) $t, 'Wieder erreichbar'))) === 1, 'ohne „automatisch lösen“ bleibt das Ticket offen, die Notiz steht trotzdem: ' . json_encode([$last['id'], $last['status'], $threads], JSON_UNESCAPED_UNICODE));
plugin_set_setting('ueberwachung', 'auto_resolve', '1');
// Ohne Tickets und ohne Mail
plugin_set_setting('ueberwachung', 'create_tickets', '0');
plugin_set_setting('ueberwachung', 'mail', '0');
$before = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), count($mails)];
$answer = ['ok' => false, 'error' => 'x', 'ms' => 1, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
ueberwachung_check($pdo, $id);
$check($mon($id)['state'] === 'down' && [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), count($mails)] === $before, 'Einstellungen: keine Tickets und keine Mails möglich, der Zustand wird trotzdem erfasst');
$answer = ['ok' => true, 'error' => null, 'ms' => 1, 'cert_expires' => null];
ueberwachung_check($pdo, $id);
plugin_set_setting('ueberwachung', 'create_tickets', '1');
plugin_set_setting('ueberwachung', 'mail', '1');
// Zertifikat
$mails = [];
$ticketsBefore = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => time() + 60 * 86400];
ueberwachung_check($pdo, $id);
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore && $mon($id)['cert_expires'] !== null && $mon($id)['cert_ticket_id'] === null, 'Zertifikat noch 60 Tage gültig: nichts zu melden');
$answer['cert_expires'] = time() + 9 * 86400 + 3600;
ueberwachung_check($pdo, $id);
$m = $mon($id);
$ct = $ticket((int) $m['cert_ticket_id']);
$check($ct && str_contains($ct['subject'], 'Zertifikat: Support') && str_contains($ct['subject'] . $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$ct['id']} LIMIT 1")->fetchColumn(), 'läuft in 9 Tagen ab'), 'Zertifikat läuft in 9 Tagen ab: Ticket');
$check(count($mails) === 1 && str_contains($mails[0]['subject'], 'Zertifikat'), 'Mail zur Zertifikatswarnung');
ueberwachung_check($pdo, $id);
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore + 1 && count($mails) === 1, 'die Warnung kommt nur einmal');
$answer['cert_expires'] = time() + 90 * 86400;
ueberwachung_check($pdo, $id);
$ct = $ticket((int) $ct['id']);
$check($ct['status'] === 'resolved' && $mon($id)['cert_ticket_id'] === null && str_contains(end($mails)['subject'], 'erneuert'), 'Zertifikat erneuert: Ticket gelöst, Entwarnung');
$answer['cert_expires'] = time() - 2 * 86400;
ueberwachung_check($pdo, $id);
$check($mon($id)['cert_ticket_id'] !== null && (int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE kind = 'cert' AND message LIKE '%abgelaufen%'")->fetchColumn() === 1, 'abgelaufenes Zertifikat wird als abgelaufen gemeldet');
$answer['cert_expires'] = null;
// Ticket-Anlage trotz Sperre: die eigene Adresse steht auf der Sperrliste, das normale Anlegen lehnt ab, der Rückfall legt das Ticket trotzdem an
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'admin@firma.example', db_now()]);
$mails = [];
$tCount = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
$fb = ueberwachung_save($pdo, ['name' => 'Rückfall', 'type' => 'port', 'target' => 'rueckfall.example:25'])['id'];
$answer = ['ok' => false, 'error' => 'Nicht erreichbar: Verbindung abgelehnt', 'ms' => 5, 'cert_expires' => null];
ueberwachung_check($pdo, $fb);
ueberwachung_check($pdo, $fb);
$fm = $mon($fb);
$ft = $ticket($fm['ticket_id'] ? (int) $fm['ticket_id'] : null);
$check($fm['state'] === 'down' && $ft && str_contains($ft['subject'], 'Ausfall: Rückfall') && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $tCount + 1, 'trotz Sperrliste wird das Ausfall-Ticket angelegt (Rückfall)');
$check($ft && $ft['status'] === 'open' && $ft['requester_name'] === 'Überwachung' && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ft['id']}")->fetchColumn() === 1, 'Rückfall-Ticket hat Nachricht und Absender');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$ft['id']} AND g.name = 'Überwachung'")->fetchColumn() === 1, 'Rückfall-Ticket trägt den Tag');
$err = (string) plugin_setting('ueberwachung', 'ticket_error', '');
$check(str_contains($err, 'am normalen Weg vorbei') && str_contains($err, 'gesperrt'), 'der Grund steht auf der Seite: ' . $err);
$check(count($mails) === 1, 'die Mail an den Administrator kommt trotzdem');
$answer = ['ok' => true, 'error' => null, 'ms' => 5, 'cert_expires' => null];
ueberwachung_check($pdo, $fb);
$check($ticket((int) $ft['id'])['status'] === 'resolved', 'Entwarnung löst auch das Rückfall-Ticket');
// Ohne Sperre geht es auf dem normalen Weg und der Hinweis verschwindet
$pdo->exec('DELETE FROM banlist');
$tid = ueberwachung_open_ticket($pdo, 'Test: Überwachung', 'Testticket');
$check($tid && plugin_setting('ueberwachung', 'ticket_error', '') === '' && $ticket($tid)['requester_email'] === 'admin@firma.example', 'ohne Sperre: normaler Weg, Hinweis weg');
// Ist die gewählte Priorität ungültig, scheitern beide Wege: Fehler wird sichtbar statt still verschluckt
plugin_set_setting('ueberwachung', 'priority_id', '99999');
$none = ueberwachung_open_ticket($pdo, 'Test 2', 'x');
$check($none === null && str_contains((string) plugin_setting('ueberwachung', 'ticket_error', ''), 'konnte nicht angelegt werden'), 'scheitert alles, steht der Grund auf der Seite: ' . plugin_setting('ueberwachung', 'ticket_error', ''));
plugin_set_setting('ueberwachung', 'priority_id', '0');
plugin_set_setting('ueberwachung', 'ticket_error', '');
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
// ── Inhalt der Tickets: Name und Adresse, Tabelle, lesbare Fehler, Hinweise ──
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
$mails = [];
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
$ct = ueberwachung_save($pdo, ['name' => 'E-Book <b>Shop</b>', 'type' => 'http', 'target' => 'https://e-book.example.net/start', 'interval_min' => 5, 'expect_text' => 'Willkommen'])['id'];
$answer = ['ok' => true, 'error' => null, 'ms' => 120, 'cert_expires' => null];
ueberwachung_check($pdo, $ct);
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 2500, 'cert_expires' => time() + 40 * 86400, 'status' => 503, 'issuer' => "Let's Encrypt", 'cert_subject' => 'e-book.example.net'];
ueberwachung_check($pdo, $ct);
$first = $mon($ct)['first_fail_at'];
ueberwachung_check($pdo, $ct);
$m = $mon($ct);
$t = $ticket((int) $m['ticket_id']);
$msg = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} ORDER BY id LIMIT 1")->fetchColumn();
$check($t['subject'] === 'Ausfall: E-Book <b>Shop</b> (e-book.example.net)', 'Betreff nennt Name und Adresse der Seite: ' . $t['subject']);
$check($first !== null && $m['down_since'] === $first, 'Beginn des Ausfalls ist der erste Fehlschlag');
foreach (['<table', '<strong>Dienst</strong>', 'E-Book &lt;b&gt;Shop&lt;/b&gt;', 'https://e-book.example.net/start', 'Webseite (HTTP/HTTPS)', 'HTTP-Status 503', '<strong>HTTP-Status</strong>', '503', 'Fehlgeschlagene Prüfungen in Folge', '2 (Prüfung alle 5 Minuten', 'Letzter erfolgreicher Abruf', 'Erwarteter Text', 'Willkommen', 'Zertifikat gültig bis', 'Aussteller', 'Let&#039;s Encrypt', 'Mögliche Ursachen und nächste Schritte', '<li>', 'automatisch gelöst'] as $needle) {
$check(str_contains($msg, $needle), "Ticket enthält „{$needle}“");
}
$check(!str_contains($msg, '<b>Shop</b>'), 'der Name wird im Ticket maskiert');
$check(!str_contains($msg, "\n\nDiese Meldung"), 'kein loser Fließtext mehr');
$check(str_contains((string) $mails[0]['html'], '<table') && str_contains($mails[0]['subject'], 'Ausfall: E-Book'), 'die Mail an den Administrator hat dieselbe Tabelle');
// Entwarnung nennt Beginn, Ende und Dauer
$answer = ['ok' => true, 'error' => null, 'ms' => 130, 'cert_expires' => null];
ueberwachung_check($pdo, $ct);
$note = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} AND is_internal_note = 1 ORDER BY id DESC LIMIT 1")->fetchColumn();
$check(str_contains($note, 'Wieder erreichbar') && preg_match('/Von \d\d\.\d\d\.\d{4} \d\d:\d\d:\d\d bis \d\d\.\d\d\.\d{4}/', $note) === 1, 'Notiz bei Entwarnung nennt von/bis: ' . $note);
$check(str_contains(end($mails)['subject'], 'Entwarnung: E-Book') && str_contains(end($mails)['html'], '<table'), 'Entwarnungs-Mail mit Tabelle');
$check($mon($ct)['first_fail_at'] === null, 'erster Fehlschlag wird nach der Entwarnung zurückgesetzt');
// Zertifikat-Ticket
$answer = ['ok' => true, 'error' => null, 'ms' => 100, 'cert_expires' => time() + 5 * 86400 + 600, 'issuer' => "Let's Encrypt", 'cert_subject' => 'e-book.example.net'];
ueberwachung_check($pdo, $ct);
$cert = $ticket((int) $mon($ct)['cert_ticket_id']);
$certMsg = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$cert['id']} ORDER BY id LIMIT 1")->fetchColumn();
$check(str_starts_with($cert['subject'], 'Zertifikat: E-Book') && str_contains($certMsg, 'läuft in 5 Tagen ab') && str_contains($certMsg, 'Aussteller') && str_contains($certMsg, 'Warnung ab') && str_contains($certMsg, 'Zertifikat erneuern'), 'Zertifikat-Ticket mit Tabelle und Hinweisen');
// Lesbare Fehlertexte
$d = fn(string $raw) => ueberwachung_describe_error($raw);
$check(str_contains($d('fopen(https://x.example): Failed to open stream: php_network_getaddresses: getaddrinfo failed'), 'DNS'), 'Fehlertext: DNS');
$check(str_contains($d('Connection refused'), 'Verbindung abgelehnt') && str_contains($d('Connection timed out'), 'Zeitüberschreitung') && str_contains($d('No route to host'), 'nicht erreichbar'), 'Fehlertexte: abgelehnt, Zeitüberschreitung, keine Route');
$check(str_starts_with($d('SSL operation failed with code 1. certificate verify failed'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig'), 'Fehlertext: Zertifikat ungültig');
$check(str_starts_with($d('SSL: certificate has expired'), 'Zertifikat/TLS-Fehler (Zertifikat abgelaufen'), 'Fehlertext: Zertifikat abgelaufen');
$check(str_contains($d('Could not resolve host: e-book.example.net'), 'DNS') && str_contains($d('Operation timed out after 8001 milliseconds'), 'Zeitüberschreitung') && str_contains($d('Failed to connect to x port 443: Connection refused'), 'abgelehnt'), 'cURL-Meldungen werden übersetzt');
$check(ueberwachung_default_page('Welcome to nginx!') !== null && ueberwachung_default_page('Apache2 Ubuntu Default Page: It works') !== null && ueberwachung_default_page('502 Bad Gateway') !== null && ueberwachung_default_page('Index of /') !== null, 'Standard- und Fehlerseiten werden am Titel erkannt');
$check(ueberwachung_default_page('Default Site') !== null && ueberwachung_default_page('Start', "<p>If you're seeing this site then you're trying to access a host that isn't set up yet.</p>") !== null, 'Titel „Default Site“ und der Standardtext des Proxy Managers werden erkannt');
$check(ueberwachung_next_wait(['fails' => 0, 'state' => 'up', 'interval_min' => 3]) === 165 && ueberwachung_next_wait(['fails' => 1, 'state' => 'up', 'interval_min' => 3]) === 45 && ueberwachung_next_wait(['fails' => 2, 'state' => 'down', 'interval_min' => 3]) === 165 && ueberwachung_next_wait(['fails' => 0, 'state' => 'up', 'interval_min' => 1]) === 45, 'nach dem ersten Fehlschlag wird nach 45 Sekunden nachgeprüft, sonst im eingestellten Abstand');
$check(ueberwachung_default_page('E-Book Shop – Startseite') === null && ueberwachung_default_page('') === null && ueberwachung_default_page('Nginx Proxy Manager') === null, 'normale Titel und der Nginx Proxy Manager selbst gelten nicht als Standardseite');
$check(ueberwachung_page_title("<html><head>\n<title> Mein &amp; Dein\n Shop </title>") === 'Mein & Dein Shop', 'Seitentitel wird sauber gelesen');
$check(str_contains(ueberwachung_hints(['type' => 'http'], 'Der Server antwortet mit einer Standard- oder Fehlerseite statt mit der Anwendung')[0], 'Reverse-Proxy'), 'Hinweis für Standardseiten nennt den Reverse-Proxy');
$tlsBad = ['type' => 'http', 'target' => 'https://a.example/', 'verify_tls' => 1, 'last_check_at' => '2026-01-01 00:00:00', 'last_error' => 'Zertifikat/TLS-Fehler (Zertifikat ungültig): x'];
$tlsOk = ['type' => 'http', 'target' => 'https://b.example/', 'verify_tls' => 1, 'last_check_at' => '2026-01-01 00:00:00', 'last_error' => null];
$check(ueberwachung_ca_warning([$tlsBad], true) === true, 'Warnung: alle HTTPS-Ziele scheitern am Zertifikat und das System vermutet ein fehlendes CA-Bundle');
$check(ueberwachung_ca_warning([$tlsBad, $tlsOk], true) === false, 'keine Warnung, sobald irgendein HTTPS-Ziel mit Prüfung erreicht wird (die Prüfung funktioniert)');
$check(ueberwachung_ca_warning([$tlsBad], false) === false && ueberwachung_ca_warning([], true) === false, 'keine Warnung ohne Verdacht des Systems oder ohne Ziele');
$check(ueberwachung_ca_warning([['verify_tls' => 0] + $tlsBad], true) === false && ueberwachung_ca_warning([['target' => 'http://c.example/'] + $tlsBad], true) === false, 'Ziele ohne Zertifikatsprüfung und ohne HTTPS zählen nicht');
$check(str_starts_with($d('SSL certificate problem: self-signed certificate'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig') && str_starts_with($d('SSL: no alternative certificate subject name matches target host name'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig'), 'cURL-Zertifikatsfehler werden übersetzt');
$check(str_contains($d('fopen(): SSL operation failed with code 1. OpenSSL Error messages: error:0A000086:SSL routines::certificate verify failed | fopen(https://x): Failed to open stream: operation failed'), 'Zertifikat ungültig'), 'alle PHP-Warnungen zusammen: die Zertifikatsursache geht nicht mehr verloren');
$vt = ueberwachung_validate(['name' => 'A', 'type' => 'http', 'target' => 'https://x.example/', 'verify_tls' => 0]);
$vt2 = ueberwachung_validate(['name' => 'A', 'type' => 'http', 'target' => 'https://x.example/']);
$check($vt['data']['verify_tls'] === 0 && $vt2['data']['verify_tls'] === 1, 'Zertifikatsprüfung lässt sich je Ziel abschalten (Standard: an)');
$generic = $d('fopen(https://e-book.viper.ipv64.net): Failed to open stream: operation failed');
$check(str_starts_with($generic, 'Nicht erreichbar:') && !str_contains($generic, 'fopen') && !str_contains($generic, 'e-book'), 'allgemeiner Fehler ohne technische Reste: ' . $generic);
$check(ueberwachung_subject('Ausfall', ['name' => 'e-book.example.net', 'target' => 'https://e-book.example.net/']) === 'Ausfall: e-book.example.net', 'Betreff ohne doppelte Adresse, wenn sie schon im Namen steht');
$check(ueberwachung_subject('Ausfall', ['name' => 'Mail', 'target' => 'mail.example.de:993']) === 'Ausfall: Mail (mail.example.de)', 'Betreff bei Port-Zielen');
$check(ueberwachung_hints(['type' => 'http'], 'HTTP-Status 503')[0] !== ueberwachung_hints(['type' => 'port'], 'Nicht erreichbar: x')[0], 'Hinweise passen zur Art des Fehlers');
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
// Nachträgliche Änderung des Prüfabstands wird gespeichert und gilt für die Fälligkeit
$GLOBALS['probeCalls'] = 0;
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { $GLOBALS['probeCalls']++; return $answer; };
$answer = ['ok' => true, 'error' => null, 'ms' => 5, 'cert_expires' => null];
$iv = ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 5])['id'];
ueberwachung_check($pdo, $iv);
$check((int) $mon($iv)['interval_min'] === 5, 'Prüfabstand 5 Minuten gespeichert');
$check(ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 3], $iv)['ok'] && (int) $mon($iv)['interval_min'] === 3, 'nachträgliche Änderung auf 3 Minuten wird gespeichert');
$check($mon($iv)['last_check_at'] !== null && $mon($iv)['name'] === 'Abstand', 'Änderung löscht weder Name noch bisherige Ergebnisse');
$calls = $GLOBALS['probeCalls'];
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 120), $iv]);
ueberwachung_tick($pdo);
$check($GLOBALS['probeCalls'] === $calls, 'nach 2 Minuten noch nicht fällig (Abstand 3)');
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 175), $iv]);
ueberwachung_tick($pdo);
$check($GLOBALS['probeCalls'] === $calls + 1, 'nach knapp 3 Minuten ist die Prüfung fällig (sobald der Hintergrunddienst läuft)');
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 175), $iv]);
ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 5], $iv);
$calls = $GLOBALS['probeCalls'];
ueberwachung_tick($pdo);
$check($GLOBALS['probeCalls'] === $calls, 'zurück auf 5 Minuten: nach knapp 3 Minuten nicht fällig');
// Das Plugin hängt am Minuten-Ereignis (eingestellter Abstand) und am 5-Minuten-Ereignis (Rückfall), ohne doppelt zu prüfen
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $iv]);
$calls = $GLOBALS['probeCalls'];
plugin_hook('cron.minute', ['time' => time()]);
$check($GLOBALS['probeCalls'] === $calls + 1, 'das Minuten-Ereignis prüft fällige Ziele');
plugin_hook('cron.run', ['time' => time()]);
$check($GLOBALS['probeCalls'] === $calls + 1, 'das 5-Minuten-Ereignis direkt danach prüft nicht noch einmal');
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $iv]);
plugin_hook('cron.run', ['time' => time()]);
$check($GLOBALS['probeCalls'] === $calls + 2, 'Rückfall: auch das 5-Minuten-Ereignis allein prüft fällige Ziele (ältere DeskHub-Dateien)');
// Nach einem ersten Fehlschlag wird schon nach knapp einer Minute nachgeprüft (Abstand 5 Minuten)
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET fails = 1, state = 'up', last_check_at = ? WHERE id = ?")->execute([date('Y-m-d H:i:s', time() - 50), $iv]);
$calls = $GLOBALS['probeCalls'];
ueberwachung_tick($pdo);
$check($GLOBALS['probeCalls'] === $calls + 1, 'nach einem ersten Fehlschlag wird nach ~1 Minute nachgeprüft statt erst nach 5');
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET fails = 0, state = 'up', last_check_at = ? WHERE id = ?")->execute([date('Y-m-d H:i:s', time() - 50), $iv]);
$calls = $GLOBALS['probeCalls'];
ueberwachung_tick($pdo);
$check($GLOBALS['probeCalls'] === $calls, 'ohne Fehlschlag gilt weiter der eingestellte Abstand');
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
// Fälligkeit und Pausieren
$GLOBALS['probeCalls'] = 0;
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { $GLOBALS['probeCalls']++; return $answer; };
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
$a = ueberwachung_save($pdo, ['name' => 'A', 'type' => 'port', 'target' => 'a.example:25', 'interval_min' => 5])['id'];
$b = ueberwachung_save($pdo, ['name' => 'B', 'type' => 'port', 'target' => 'b.example:25', 'interval_min' => 5, 'is_active' => 0])['id'];
$check(ueberwachung_tick($pdo) === 1 && $GLOBALS['probeCalls'] === 1, 'Hintergrundlauf prüft nur aktive Ziele');
$check(ueberwachung_tick($pdo) === 0, 'frisch geprüfte Ziele sind nicht fällig');
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $a]);
$check(ueberwachung_tick($pdo) === 1, 'nach dem Intervall ist es wieder fällig');
$check(!ueberwachung_check($pdo, 99999), 'unbekanntes Ziel');
ueberwachung_delete($pdo, $a);
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_ueberwachung_monitors')->fetchColumn() === 1 && (int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE monitor_id = {$a}")->fetchColumn() === 0, 'Löschen entfernt Ziel und Ereignisse');
// Fehler in der Prüfung selbst
$GLOBALS['ueberwachung_probe'] = function (array $m): array { throw new RuntimeException('kaputt'); };
$c = ueberwachung_save($pdo, ['name' => 'C', 'type' => 'port', 'target' => 'c.example:25'])['id'];
ueberwachung_check($pdo, $c);
$check(str_contains((string) $mon($c)['last_error'], 'Prüfung fehlgeschlagen') && (int) $mon($c)['fails'] === 1, 'Ausnahmen in der Prüfung werden als Fehler gewertet');
unset($GLOBALS['ueberwachung_probe'], $GLOBALS['ueberwachung_mail']);
// Selbstheilung: fehlt nach einem Update die Spalte (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
$pdo->exec('ALTER TABLE plugin_ueberwachung_monitors DROP COLUMN first_fail_at');
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_ueberwachung_002%'");
$colsOf = fn() => array_column($pdo->query('PRAGMA table_info(plugin_ueberwachung_monitors)')->fetchAll(), 'name');
$check(!in_array('first_fail_at', $colsOf(), true), 'Vorbereitung: Spalte fehlt');
ueberwachung_ensure_schema(true);
$check(in_array('first_fail_at', $colsOf(), true), 'Selbstheilung: die fehlende Spalte wird nachgeholt');
ueberwachung_ensure_schema(true);
$check(in_array('first_fail_at', $colsOf(), true), 'Selbstheilung ist wiederholbar');
// ── Echte Prüfungen gegen einen lokalen Webserver und Port ──────────────────
$router = sys_get_temp_dir() . '/ue-router-' . bin2hex(random_bytes(3)) . '.php';
file_put_contents($router, <<<'ROUTER'
<?php
$c = (int) ($_GET['code'] ?? 200);
http_response_code($c);
if (($_GET['page'] ?? '') === 'npm') {
echo '<!doctype html><html><head><title>Default Site</title></head><body><h1>Congratulations!</h1><p>You\'ve successfully started the Nginx Proxy Manager.</p><p>If you\'re seeing this site then you\'re trying to access a host that isn\'t set up yet.</p></body></html>';
return;
}
if (($_GET['page'] ?? '') === 'neutral') {
echo '<html><head><title>Start</title></head><body><p>You\'ve successfully started the Nginx Proxy Manager.</p></body></html>';
return;
}
if (($_GET['page'] ?? '') === 'default') {
echo '<html><head><title>Congratulations! You have successfully started</title></head><body>Willkommen</body></html>';
return;
}
echo $c === 200 ? '<html><head><title>Meine Anwendung</title></head><body>Willkommen beim Test-Server</body></html>' : 'Fehler';
ROUTER);
$port = 0;
for ($try = 0; $try < 20 && !$port; $try++) {
$p = random_int(20000, 60000);
$s = @stream_socket_server("tcp://127.0.0.1:{$p}");
if ($s) { fclose($s); $port = $p; }
}
$proc = $port ? @proc_open([PHP_BINARY, '-S', "127.0.0.1:{$port}", $router], [0 => ['pipe', 'r'], 1 => ['file', sys_get_temp_dir() . '/ue-srv-out.txt', 'w'], 2 => ['file', sys_get_temp_dir() . '/ue-srv-err.txt', 'w']], $pipes) : false;
$up = false;
if ($proc) {
for ($i = 0; $i < 40 && !$up; $i++) { usleep(100000); $c = @stream_socket_client("tcp://127.0.0.1:{$port}", $en, $es, 0.3); if ($c) { fclose($c); $up = true; } }
}
if ($up) {
$base = ['id' => 1, 'type' => 'http', 'expect_text' => '', 'timeout_sec' => 5, 'target' => ''];
$ok = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/"] + $base);
$check($ok['ok'] && $ok['ms'] >= 0 && $ok['cert_expires'] === null, 'echte Prüfung: Webseite erreichbar: ' . json_encode($ok));
$e500 = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
$check(!$e500['ok'] && str_contains((string) $e500['error'], '503'), 'echte Prüfung: HTTP 503 wird erkannt');
$txt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Willkommen'] + $base);
$check($txt['ok'], 'echte Prüfung: erwarteter Text vorhanden');
$noTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Nicht da'] + $base);
$check(!$noTxt['ok'] && str_contains((string) $noTxt['error'], 'Text'), 'echte Prüfung: fehlender Text wird erkannt');
$def = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default"] + $base);
$check(!$def['ok'] && str_contains((string) $def['error'], 'Standard- oder Fehlerseite') && str_contains((string) $def['error'], 'Congratulations'), 'echte Prüfung: Standardseite mit 200 gilt als Ausfall: ' . ($def['error'] ?? ''));
$npm = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=npm"] + $base);
$check(!$npm['ok'] && str_contains((string) $npm['error'], 'Default Site') && ($npm['status'] ?? 0) === 200, 'echte Prüfung: Standardseite des Nginx Proxy Managers („Default Site“, Status 200) gilt als Ausfall: ' . ($npm['error'] ?? ''));
$neutral = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=neutral"] + $base);
$check(!$neutral['ok'] && str_contains((string) $neutral['error'], 'Standardseite') && str_contains((string) $neutral['error'], 'Nginx Proxy Manager'), 'echte Prüfung: erkannt auch am Text der Seite, wenn der Titel nichtssagend ist');
$npmTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=npm", 'expect_text' => 'Congratulations'] + $base);
$check($npmTxt['ok'], 'mit erwartetem Text wird nichts als Standardseite gewertet');
$defTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default", 'expect_text' => 'Willkommen'] + $base);
$check($defTxt['ok'], 'mit erwartetem Text entfällt die Erkennung von Standardseiten');
$check(($ok['title'] ?? '') === 'Meine Anwendung' && ($ok['status'] ?? 0) === 200, 'Titel und Status werden mitgeliefert');
$GLOBALS['ueberwachung_no_curl'] = true;
$defS = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default"] + $base);
unset($GLOBALS['ueberwachung_no_curl']);
$check(!$defS['ok'] && str_contains((string) $defS['error'], 'Standard- oder Fehlerseite'), 'dasselbe ohne cURL');
$pOk = ueberwachung_probe(['type' => 'port', 'target' => "127.0.0.1:{$port}"] + $base);
$check($pOk['ok'], 'echte Prüfung: offener Port');
// Rückfall ohne cURL (reine PHP-Streams) liefert dieselben Ergebnisse
$GLOBALS['ueberwachung_no_curl'] = true;
$sOk = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/"] + $base);
$s503 = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
$sTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Nicht da'] + $base);
unset($GLOBALS['ueberwachung_no_curl']);
$check($sOk['ok'] && !$s503['ok'] && str_contains((string) $s503['error'], '503') && !$sTxt['ok'] && str_contains((string) $sTxt['error'], 'Text'), 'ohne cURL (Streams): erreichbar, 503 und fehlender Text werden richtig erkannt');
$viaCurl = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
$check(($viaCurl['status'] ?? 0) === 503, 'mit cURL steht der HTTP-Status im Ergebnis');
proc_terminate($proc);
proc_close($proc);
usleep(300000);
$pClosed = ueberwachung_probe(['type' => 'port', 'target' => "127.0.0.1:{$port}", 'timeout_sec' => 2] + $base);
$check(!$pClosed['ok'] && str_contains((string) $pClosed['error'], 'Nicht erreichbar'), 'echte Prüfung: geschlossener Port wird erkannt');
$gone = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'timeout_sec' => 2] + $base);
$check(!$gone['ok'] && str_contains((string) $gone['error'], 'Nicht erreichbar'), 'echte Prüfung: nicht erreichbare Webseite wird erkannt');
} else {
fwrite(STDERR, "Hinweis: Lokaler Testserver konnte nicht gestartet werden, echte Prüfungen übersprungen.\n");
if ($proc) { proc_terminate($proc); proc_close($proc); }
}
@unlink($router);
@unlink(sys_get_temp_dir() . '/ue-srv-out.txt');
@unlink(sys_get_temp_dir() . '/ue-srv-err.txt');
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
}
@rmdir($pluginDir);
@unlink($zipPath);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo 'Überwachung plugin checks passed.' . "\n";