diff --git a/.env.example b/.env.example index 00b890e..b800712 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ SMTP_FROM=monitor@0012.de # Empfänger für Alarme (persönliche Adresse) ALERT_EMAIL= +# --- Monitoring: Pull-Checks (monitor.php) --- +# Reminder wenn Target-Liste länger nicht geprüft (Tage seit last_reviewed_at) +TARGETS_REVIEW_INTERVAL_DAYS=90 + # --- Monitoring: Push von boka → Webspace --- # Token für ingest.php (stark, zufällig; identisch auf boka) MONITOR_INGEST_TOKEN= diff --git a/.gitignore b/.gitignore index b510fd2..515c390 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ server.env __pycache__/ *.pyc .DS_Store +/data/ diff --git a/config/monitor-lib.php b/config/monitor-lib.php new file mode 100644 index 0000000..9e2ab44 --- /dev/null +++ b/config/monitor-lib.php @@ -0,0 +1,378 @@ +> */ +function getTargetList(array $config): array +{ + return $config['targets'] ?? $config; +} + +/** @param list> $targets */ +function targetsHash(array $targets): string +{ + $normalized = array_map(static function (array $target): array { + return [ + 'name' => $target['name'], + 'url' => $target['url'], + 'expect' => $target['expect'] ?? 200, + ]; + }, $targets); + + return hash('sha256', json_encode($normalized, JSON_UNESCAPED_SLASHES) ?: ''); +} + +/** @return list */ +function normalizeExpect(mixed $expect): array +{ + if (is_array($expect)) { + return array_map(intval(...), $expect); + } + + return [(int) $expect]; +} + +/** @param array $target */ +function checkTarget(array $target): array +{ + $timeout = (int) ($target['timeout'] ?? 10); + $expect = normalizeExpect($target['expect'] ?? 200); + + $result = checkHttp((string) $target['url'], $timeout, $expect); + $sslDays = checkSslDays((string) $target['url'], $timeout); + + $result['name'] = (string) $target['name']; + $result['url'] = (string) $target['url']; + $result['ssl_days_remaining'] = $sslDays; + $result['checked_at'] = gmdate('c'); + $result['retried'] = false; + + if ($sslDays !== null && $sslDays < 14 && $result['status'] === 'ok') { + $result['status'] = 'warn'; + $result['message'] = "SSL expires in {$sslDays} days"; + } + + return $result; +} + +/** @param list $expect */ +function checkHttp(string $url, int $timeout, array $expect): array +{ + if (!function_exists('curl_init')) { + return httpResult('down', 0, 0, 'cURL not available'); + } + + $start = hrtime(true); + $handle = curl_init($url); + + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_USERAGENT => '0012-monitor/1.0', + ]); + + curl_exec($handle); + $httpCode = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $curlError = curl_error($handle); + curl_close($handle); + + $responseMs = (int) round((hrtime(true) - $start) / 1_000_000); + + if ($curlError !== '') { + return httpResult('down', $httpCode, $responseMs, sanitizeError($curlError)); + } + + if (in_array($httpCode, $expect, true)) { + return httpResult('ok', $httpCode, $responseMs, null); + } + + return httpResult('down', $httpCode, $responseMs, "HTTP {$httpCode}"); +} + +function httpResult(string $status, int $httpCode, int $responseMs, ?string $message): array +{ + return [ + 'status' => $status, + 'http_code' => $httpCode, + 'response_ms' => $responseMs, + 'message' => $message, + ]; +} + +function checkSslDays(string $url, int $timeout): ?int +{ + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + return null; + } + + $context = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $socket = @stream_socket_client( + "ssl://{$host}:443", + $errno, + $errstr, + $timeout, + STREAM_CLIENT_CONNECT, + $context + ); + + if ($socket === false) { + return null; + } + + $params = stream_context_get_params($socket); + fclose($socket); + + $certificate = $params['options']['ssl']['peer_certificate'] ?? null; + if ($certificate === false || $certificate === null) { + return null; + } + + $info = openssl_x509_parse($certificate); + if (!is_array($info) || !isset($info['validTo_time_t'])) { + return null; + } + + return (int) floor(((int) $info['validTo_time_t'] - time()) / 86400); +} + +function sanitizeError(string $error): string +{ + if (strlen($error) > 120) { + return substr($error, 0, 117) . '...'; + } + + return $error; +} + +function writeJsonFile(string $path, array $data): void +{ + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) { + throw new RuntimeException("Failed to create directory {$dir}"); + } + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Failed to encode JSON'); + } + + $tmp = $path . '.tmp'; + if (file_put_contents($tmp, $json . "\n") === false) { + throw new RuntimeException("Failed to write {$tmp}"); + } + + if (!rename($tmp, $path)) { + throw new RuntimeException("Failed to rename {$tmp} to {$path}"); + } +} + +function readJsonFile(string $path): ?array +{ + if (!is_readable($path)) { + return null; + } + + $data = json_decode((string) file_get_contents($path), true); + + return is_array($data) ? $data : null; +} + +function effectiveLastReviewedAt(array $config, string $currentHash, ?array $reviewState): string +{ + if ( + $reviewState !== null + && isset($reviewState['targets_hash']) + && is_string($reviewState['targets_hash']) + && $reviewState['targets_hash'] !== '' + && $reviewState['targets_hash'] !== $currentHash + ) { + return gmdate('Y-m-d'); + } + + $metaDate = $config['_meta']['last_reviewed_at'] ?? null; + if (is_string($metaDate) && $metaDate !== '') { + return $metaDate; + } + + return gmdate('Y-m-d'); +} + +/** @param list> $targets */ +function maybeSendReviewReminder(array $config, array $targets, string $currentHash, bool $verbose): void +{ + $intervalDays = (int) (env('TARGETS_REVIEW_INTERVAL_DAYS') ?: 90); + $statePath = privateDataDir() . '/review-state.json'; + $state = readJsonFile($statePath) ?? []; + + $lastReviewed = effectiveLastReviewedAt($config, $currentHash, $state); + $reviewedAt = strtotime($lastReviewed . ' UTC') ?: time(); + $daysSinceReview = (int) floor((time() - $reviewedAt) / 86400); + + $newState = [ + 'targets_hash' => $currentHash, + 'last_reminder_at' => $state['last_reminder_at'] ?? null, + ]; + + if ($daysSinceReview < $intervalDays) { + writeJsonFile($statePath, $newState); + + return; + } + + $lastReminder = $state['last_reminder_at'] ?? null; + if (is_string($lastReminder) && $lastReminder !== '') { + $reminderAt = strtotime($lastReminder) ?: 0; + $daysSinceReminder = (int) floor((time() - $reminderAt) / 86400); + if ($daysSinceReminder < 7) { + writeJsonFile($statePath, $newState); + + return; + } + } + + $domain = env('DOMAIN', '0012.de'); + $names = array_map(static fn (array $target): string => (string) $target['name'], $targets); + + $subject = "[{$domain}] Monitoring — Zeit für eine Target-Review?"; + $body = implode("\n", [ + "Deine Monitoring-Liste wurde zuletzt am {$lastReviewed} geprüft ({$daysSinceReview} Tage her).", + 'Aktuell überwacht: ' . count($targets) . ' Targets (' . implode(', ', $names) . ').', + '', + 'Vorschläge:', + '- Neue Stacks/Domains aus MONITORING.md (medium) nachziehen?', + '- Toten Eintrag entfernen?', + '- expect-Codes prüfen (401 bei Basic Auth)?', + '', + 'Nach der Prüfung: last_reviewed_at in config/targets.php auf heute setzen und deployen.', + ]); + + try { + sendAlert($subject, $body); + $newState['last_reminder_at'] = gmdate('c'); + if ($verbose) { + fwrite(STDERR, "Review reminder sent ({$daysSinceReview} days since last review)\n"); + } + } catch (Throwable $e) { + if ($verbose) { + fwrite(STDERR, 'Review reminder failed: ' . $e->getMessage() . "\n"); + } + } + + writeJsonFile($statePath, $newState); +} + +/** @param list> $failures */ +function sendDownAlert(array $failures): void +{ + $domain = env('DOMAIN', '0012.de'); + $count = count($failures); + $subject = "[{$domain}] Monitoring alert — {$count} target(s) down"; + + $lines = []; + foreach ($failures as $failure) { + $message = (string) ($failure['message'] ?? 'unknown error'); + $retried = !empty($failure['retried']) ? ' (retried)' : ''; + $lines[] = "- {$failure['name']} ({$failure['url']}): {$message}{$retried}"; + } + + sendAlert($subject, implode("\n", $lines)); +} + +function runMonitor(bool $verbose = false): int +{ + $started = hrtime(true); + $config = loadTargetsConfig(); + $targets = getTargetList($config); + $currentHash = targetsHash($targets); + + $results = []; + $failedIndexes = []; + + foreach ($targets as $index => $target) { + $result = checkTarget($target); + $results[$index] = $result; + + if ($result['status'] === 'down') { + $failedIndexes[] = $index; + } + + if ($verbose) { + fwrite(STDERR, sprintf("%s: %s\n", $result['name'], $result['status'])); + } + } + + if ($failedIndexes !== []) { + if ($verbose) { + fwrite(STDERR, "Waiting 120s before retry...\n"); + } + + sleep(120); + + foreach ($failedIndexes as $index) { + $result = checkTarget($targets[$index]); + $result['retried'] = true; + $results[$index] = $result; + + if ($verbose) { + fwrite(STDERR, sprintf("%s (retry): %s\n", $result['name'], $result['status'])); + } + } + } + + $checks = array_values($results); + $payload = [ + 'updated_at' => gmdate('c'), + 'duration_ms' => (int) round((hrtime(true) - $started) / 1_000_000), + 'checks' => $checks, + ]; + + try { + writeJsonFile(privateDataDir() . '/last-checks.json', $payload); + } catch (Throwable $e) { + if ($verbose) { + fwrite(STDERR, 'JSON write failed: ' . $e->getMessage() . "\n"); + } + + return 1; + } + + maybeSendReviewReminder($config, $targets, $currentHash, $verbose); + + $failures = array_values(array_filter($checks, static fn (array $check): bool => $check['status'] === 'down')); + if ($failures === []) { + return 0; + } + + try { + sendDownAlert($failures); + } catch (Throwable $e) { + if ($verbose) { + fwrite(STDERR, 'Down alert failed: ' . $e->getMessage() . "\n"); + } + + return 1; + } + + return 1; +} diff --git a/config/targets.php b/config/targets.php new file mode 100644 index 0000000..2b220b4 --- /dev/null +++ b/config/targets.php @@ -0,0 +1,23 @@ + [ + 'last_reviewed_at' => '2026-06-12', + ], + 'targets' => [ + ['name' => 'schwenk.online', 'url' => 'https://schwenk.online', 'expect' => 200], + ['name' => 'gitea', 'url' => 'https://gitea.schwenk.online', 'expect' => 200], + ['name' => 'pics', 'url' => 'https://pics.schwenk.online', 'expect' => 200], + ['name' => 'medis', 'url' => 'https://medis.schwenk.online', 'expect' => 200], + ['name' => 'portainer', 'url' => 'https://portainer.schwenk.online', 'expect' => [200, 401]], + ['name' => 'score.bsc-gp.de', 'url' => 'https://score.bsc-gp.de', 'expect' => 200], + ['name' => 'vfb.red', 'url' => 'https://vfb.red', 'expect' => 200], + ], +]; diff --git a/public/monitor.php b/public/monitor.php new file mode 100644 index 0000000..7b62148 --- /dev/null +++ b/public/monitor.php @@ -0,0 +1,19 @@ +