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 Shop', '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 Shop (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 (['Dienst', 'E-Book <b>Shop</b>', 'https://e-book.example.net/start', 'Webseite (HTTP/HTTPS)', 'HTTP-Status 503', 'HTTP-Status', '503', 'Fehlgeschlagene Prüfungen in Folge', '2 (Prüfung alle 5 Minuten', 'Letzter erfolgreicher Abruf', 'Erwarteter Text', 'Willkommen', 'Zertifikat gültig bis', 'Aussteller', 'Let's Encrypt', 'Mögliche Ursachen und nächste Schritte', '
  • ', 'automatisch gelöst'] as $needle) { $check(str_contains($msg, $needle), "Ticket enthält „{$needle}“"); } $check(!str_contains($msg, 'Shop'), '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'], ' 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'], ' 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', "

    If you're seeing this site then you're trying to access a host that isn't set up yet.

    ") !== 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("\n Mein & Dein\n Shop ") === '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' Default Site

    Congratulations!

    You\'ve successfully started the Nginx Proxy Manager.

    If you\'re seeing this site then you\'re trying to access a host that isn\'t set up yet.

    '; return; } if (($_GET['page'] ?? '') === 'neutral') { echo 'Start

    You\'ve successfully started the Nginx Proxy Manager.

    '; return; } if (($_GET['page'] ?? '') === 'default') { echo 'Congratulations! You have successfully startedWillkommen'; return; } echo $c === 200 ? 'Meine AnwendungWillkommen beim Test-Server' : '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";