feat: external HTTPS monitoring pull via monitor.php

Add cron-ready pull checks for critical URLs with SSL expiry warnings,
retry-before-alert, JSON results in private/data, email alerts, and a
90-day target-list review reminder.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-06-12 10:05:43 +02:00
parent 69f183d356
commit c007d5ebdc
8 changed files with 456 additions and 21 deletions
+378
View File
@@ -0,0 +1,378 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/mail.php';
function loadTargetsConfig(): array
{
return require __DIR__ . '/targets.php';
}
/** @return list<array<string, mixed>> */
function getTargetList(array $config): array
{
return $config['targets'] ?? $config;
}
/** @param list<array<string, mixed>> $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<int> */
function normalizeExpect(mixed $expect): array
{
if (is_array($expect)) {
return array_map(intval(...), $expect);
}
return [(int) $expect];
}
/** @param array<string, mixed> $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<int> $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<array<string, mixed>> $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<array<string, mixed>> $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;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* External HTTPS monitoring targets.
* After manual infra review: bump _meta.last_reviewed_at and deploy.
*/
return [
'_meta' => [
'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],
],
];