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:
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
],
|
||||
];
|
||||
Reference in New Issue
Block a user