d788400bfd
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Authored-by: Moko Consulting
296 lines
6.7 KiB
PHP
296 lines
6.7 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* 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<string, mixed> */
|
|
private array $args = [];
|
|
|
|
/** @var array<array{url: string, status: int, time: float, result: string, error: string}> */
|
|
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 <file> or --url <single-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<string, mixed>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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());
|