From 25abf317cb5ad168f3361327dc90cab0a07dcad0 Mon Sep 17 00:00:00 2001 From: Fabian Schieder Date: Sun, 1 Mar 2026 14:08:47 +0100 Subject: [PATCH] Refactor HTTP request handling and add smoke test for server status checks --- includes/lib/server_status.php | 91 ++++++++++++++++++++++++++++----- index.php | 2 +- scripts/smoke_server_status.php | 19 +++++++ 3 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 scripts/smoke_server_status.php diff --git a/includes/lib/server_status.php b/includes/lib/server_status.php index d9cfe65..fbab3d5 100644 --- a/includes/lib/server_status.php +++ b/includes/lib/server_status.php @@ -84,21 +84,59 @@ function is_allowed_http_url(string $url, array $allowedHosts): bool } /** - * HTTP HEAD check (no redirects). + * Extract first HTTP status code from a response header array. + * @param array $headers + */ +function extract_http_status_code(array $headers): ?int +{ + foreach ($headers as $h) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) { + return (int)$m[1]; + } + } + return null; +} + +/** + * Decide if a HTTP status code means the service is reachable. + * We treat 2xx/3xx as up and also 401/403 as up (service is there, but needs auth). + */ +function is_reachable_http_code(?int $code): bool +{ + if ($code === null) return false; + if ($code >= 200 && $code < 400) return true; + if ($code === 401 || $code === 403) return true; + return false; +} + +/** + * Perform a lightweight HTTP request (HEAD/GET) without following redirects. * @return array{ok:bool, code:int|null, ms:int|null, error:string|null} */ -function check_http_head(string $url, float $timeoutSeconds = 1.6): array +function check_http_request(string $url, string $method, float $timeoutSeconds = 1.6, ?string $extraHeaders = null): array { $start = microtime(true); + $http_response_header = null; // ensure local scope, avoid stale values + $code = null; + $error = null; + + $headers = "User-Agent: ServerStatus/1.1\r\n"; + if ($extraHeaders) { + $headers .= $extraHeaders; + if (!str_ends_with($headers, "\r\n")) { + $headers .= "\r\n"; + } + } + $ctx = stream_context_create([ 'http' => [ - 'method' => 'HEAD', + 'method' => $method, 'timeout' => $timeoutSeconds, 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 0, - 'header' => "User-Agent: ServerStatus/1.0\r\n", + 'header' => $headers, ], 'ssl' => [ 'verify_peer' => true, @@ -106,10 +144,8 @@ function check_http_head(string $url, float $timeoutSeconds = 1.6): array ], ]); - $code = null; - $error = null; - set_error_handler(static function (int $severity, string $message) use (&$error): bool { + // capture warning as error string (e.g. connection refused, timeout, DNS fail) $error = $message; return true; }); @@ -117,22 +153,26 @@ function check_http_head(string $url, float $timeoutSeconds = 1.6): array restore_error_handler(); if ($fp !== false) { + // For GET we don't want to download; the Range header should keep it tiny. fclose($fp); } $ms = (int)round((microtime(true) - $start) * 1000); - // Parse status code from $http_response_header if (isset($http_response_header) && is_array($http_response_header)) { - foreach ($http_response_header as $h) { - if (preg_match('#^HTTP/\S+\s+(\d{3})#i', (string)$h, $m)) { - $code = (int)$m[1]; - break; - } + $code = extract_http_status_code($http_response_header); + } + + $ok = is_reachable_http_code($code); + + // If we couldn't even open the stream, treat as hard failure, regardless of any weird headers. + if ($fp === false) { + $ok = false; + if ($error === null) { + $error = 'Verbindung fehlgeschlagen'; } } - $ok = ($code !== null) && ($code >= 200 && $code < 400); if ($code === null && $error === null) { $error = 'Unbekannter Fehler'; } @@ -140,6 +180,29 @@ function check_http_head(string $url, float $timeoutSeconds = 1.6): array return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error]; } +/** + * HTTP check (no redirects). Try HEAD first, then a minimal GET fallback (some servers lie on HEAD). + * @return array{ok:bool, code:int|null, ms:int|null, error:string|null} + */ +function check_http_head(string $url, float $timeoutSeconds = 1.6): array +{ + $head = check_http_request($url, 'HEAD', $timeoutSeconds); + if ($head['ok']) { + return $head; + } + + // If HEAD is not supported/misconfigured, a GET with Range is a cheap reachability check. + $get = check_http_request($url, 'GET', $timeoutSeconds, "Range: bytes=0-0\r\n"); + + // prefer the GET result if it produced a code (more reliable) + if ($get['code'] !== null) { + return $get; + } + + // otherwise fall back to the HEAD info + return $head; +} + function build_server_status(array $targets): array { $allowedHosts = []; diff --git a/index.php b/index.php index 61a0f43..47c0efd 100644 --- a/index.php +++ b/index.php @@ -16,7 +16,7 @@ require_once __DIR__ . '/includes/lib/view_helpers.php'; // ── Serverstatus (mit Cache) ────────────────────────────────────────────── $cacheTtlSeconds = 30; -$cacheKey = 'server_status_v1'; +$cacheKey = 'server_status_v2'; $cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json'; $serverStatus = null; diff --git a/scripts/smoke_server_status.php b/scripts/smoke_server_status.php new file mode 100644 index 0000000..700ca93 --- /dev/null +++ b/scripts/smoke_server_status.php @@ -0,0 +1,19 @@ + 'https://example.com'], + ['url' => 'https://example.com/does-not-exist-hopefully'], +]; + +foreach ($targets as $t) { + $r = check_http_head($t['url']); + echo $t['url'], " => ", ($r['ok'] ? 'UP' : 'DOWN'), " code=", ($r['code'] ?? 'null'), " ms=", ($r['ms'] ?? 'null'); + if (!empty($r['error'])) { + echo " error=", $r['error']; + } + echo PHP_EOL; +} +