#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Monitoring * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /monitoring/uptime-probe.php * VERSION: 01.00.00 * BRIEF: Check uptime and response time for a list of URLs */ declare(strict_types=1); final class UptimeProbe { /** @var array */ private array $args = []; /** @var array */ private array $results = []; public function run(): int { $this->args = $this->parseArgs(); $urls = $this->resolveUrls(); if (count($urls) === 0) { $this->log('No URLs provided. Use --urls or --url .'); return 1; } $timeout = (int) ($this->args['timeout'] ?? 15); foreach ($urls as $url) { $this->log("Probing: {$url}"); $entry = $this->probe($url, $timeout); $this->results[] = $entry; } $hasFailure = false; foreach ($this->results as $r) { if ($r['result'] === 'FAIL') { $hasFailure = true; break; } } if (!empty($this->args['json'])) { $this->outputJson(); } else { $this->outputTable(); } if ($hasFailure && !empty($this->args['notify'])) { $this->sendNotification(); } return $hasFailure ? 1 : 0; } /** * @return array */ private function parseArgs(): array { $args = [ 'urls' => null, 'url' => null, 'timeout' => 15, 'notify' => null, 'json' => false, ]; $argv = $_SERVER['argv'] ?? []; $argc = count($argv); for ($i = 1; $i < $argc; $i++) { switch ($argv[$i]) { case '--urls': $args['urls'] = $argv[++$i] ?? null; break; case '--url': $args['url'] = $argv[++$i] ?? null; break; case '--timeout': $args['timeout'] = (int) ($argv[++$i] ?? 15); break; case '--notify': $args['notify'] = $argv[++$i] ?? null; break; case '--json': $args['json'] = true; break; } } return $args; } /** * @return list */ private function resolveUrls(): array { $urls = []; if (!empty($this->args['urls'])) { $file = $this->args['urls']; if (!is_file($file) || !is_readable($file)) { $this->log("Cannot read URL file: {$file}"); return []; } $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if ($lines !== false) { foreach ($lines as $line) { $line = trim($line); if ($line !== '' && $line[0] !== '#') { $urls[] = $line; } } } } if (!empty($this->args['url'])) { $urls[] = trim($this->args['url']); } return $urls; } /** * @return array{url: string, status: int, time: float, result: string, error: string} */ private function probe(string $url, int $timeout): array { $entry = [ 'url' => $url, 'status' => 0, 'time' => 0.0, 'result' => 'FAIL', 'error' => '', ]; $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'follow_location' => 1, 'max_redirects' => 5, 'ignore_errors' => true, ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, ], ]); $start = microtime(true); $body = @file_get_contents($url, false, $context); $elapsed = round(microtime(true) - $start, 3); $entry['time'] = $elapsed; if ($body === false) { $entry['error'] = 'Connection failed or timed out'; return $entry; } // Parse status code from $http_response_header $statusCode = 0; if (isset($http_response_header) && is_array($http_response_header)) { foreach ($http_response_header as $header) { if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $m)) { $statusCode = (int) $m[1]; } } } $entry['status'] = $statusCode; // Check for PHP fatal errors in the response body $fatalPatterns = [ 'Fatal error:', 'Parse error:', 'Uncaught Exception', 'Uncaught Error', 'Stack trace:', ]; foreach ($fatalPatterns as $pattern) { if (stripos($body, $pattern) !== false) { $entry['error'] = "PHP fatal error detected in response body"; return $entry; } } if ($statusCode >= 200 && $statusCode < 400) { $entry['result'] = 'PASS'; } else { $entry['error'] = "HTTP {$statusCode}"; } return $entry; } private function outputTable(): void { $colUrl = 4; $colStatus = 6; $colTime = 6; $colResult = 6; foreach ($this->results as $r) { $colUrl = max($colUrl, strlen($r['url'])); } $colUrl = min($colUrl, 60); $fmt = "%-{$colUrl}s | %-{$colStatus}s | %-{$colTime}s | %-{$colResult}s"; $this->log(''); $this->log(sprintf($fmt, 'URL', 'Status', 'Time', 'Result')); $this->log(str_repeat('-', $colUrl + $colStatus + $colTime + $colResult + 10)); foreach ($this->results as $r) { $urlDisplay = strlen($r['url']) > 60 ? substr($r['url'], 0, 57) . '...' : $r['url']; $timeStr = $r['time'] . 's'; $statusStr = $r['status'] > 0 ? (string) $r['status'] : 'ERR'; $this->log(sprintf($fmt, $urlDisplay, $statusStr, $timeStr, $r['result'])); } $this->log(''); } private function outputJson(): void { echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } private function sendNotification(): void { $ntfyUrl = $this->args['notify']; if (empty($ntfyUrl)) { return; } $failures = []; foreach ($this->results as $r) { if ($r['result'] === 'FAIL') { $failures[] = $r['url'] . ' (' . ($r['error'] ?: 'HTTP ' . $r['status']) . ')'; } } $message = "Uptime probe failures:\n" . implode("\n", $failures); $ch = curl_init($ntfyUrl); if ($ch === false) { $this->log('Failed to initialise curl for notification.'); return; } curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $message, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Title: Uptime Probe Alert', 'Priority: high', ], ]); $result = curl_exec($ch); if ($result === false) { $this->log('Notification failed: ' . curl_error($ch)); } else { $this->log('Notification sent.'); } curl_close($ch); } private function log(string $message): void { fwrite(STDERR, $message . "\n"); } } $probe = new UptimeProbe(); exit($probe->run());