420 lines
36 KiB
PHP
420 lines
36 KiB
PHP
<?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 <b>Shop</b>', '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'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 & 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";
|