$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. * Note: Redirects (3xx) are treated as not OK here because we intentionally don't follow redirects. * Otherwise endpoints like /nextcloud (301 -> /nextcloud/ -> 503) would look "online". */ function is_reachable_http_code(?int $code): bool { if ($code === null) return false; // Explicitly DOWN: server-side failures like 503 Service Unavailable. if ($code >= 500 && $code < 600) return false; // UP: normal success if ($code >= 200 && $code < 300) return true; // UP: service is there but requires auth if ($code === 401 || $code === 403) return true; // UP: rate limited, but reachable if ($code === 429) return true; // Redirects are handled separately (we do not follow them) 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, headers?: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' => $method, 'timeout' => $timeoutSeconds, 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 0, 'header' => $headers, ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, ], ]); 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; }); $fp = @fopen($url, 'rb', false, $ctx); 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); if (isset($http_response_header) && is_array($http_response_header)) { $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'; } } if ($code === null && $error === null) { $error = 'Unbekannter Fehler'; } $res = ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error]; if (isset($http_response_header) && is_array($http_response_header)) { $res['headers'] = $http_response_header; } return $res; } /** * HTTP check (no redirects). * - Tries HEAD first. * - If result is a redirect, probes the Location once, because we don't follow redirects automatically. * - If HEAD fails, tries a minimal GET with Range. * @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 we got a redirect, probe the Location target once, because we don't follow redirects automatically. if ($head['code'] !== null && $head['code'] >= 300 && $head['code'] < 400) { $loc = null; if (!empty($head['headers']) && is_array($head['headers'])) { foreach ($head['headers'] as $h) { if (stripos((string)$h, 'Location:') === 0) { $loc = trim(substr((string)$h, strlen('Location:'))); break; } } } if (is_string($loc) && $loc !== '') { // Support relative redirects if (str_starts_with($loc, '/')) { $p = parse_url($url); if (is_array($p) && !empty($p['scheme']) && !empty($p['host'])) { $port = !empty($p['port']) ? ':' . (string)$p['port'] : ''; $loc = strtolower((string)$p['scheme']) . '://' . (string)$p['host'] . $port . $loc; } } // Probe once; no further redirects. $probe = check_http_request($loc, 'HEAD', $timeoutSeconds); if ($probe['code'] === null) { $probe = check_http_request($loc, 'GET', $timeoutSeconds, "Range: bytes=0-0\r\n"); } if ($probe['code'] !== null && ($probe['code'] < 300 || $probe['code'] >= 400)) { // return the probe result (drop headers) if (!$probe['ok'] && empty($probe['error'])) { $probe['error'] = 'HTTP ' . (string)$probe['code']; } return ['ok' => (bool)$probe['ok'], 'code' => $probe['code'], 'ms' => $probe['ms'], 'error' => $probe['error']]; } return ['ok' => false, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => 'Redirect']; } return ['ok' => false, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => 'Redirect']; } if ($head['ok']) { return ['ok' => true, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => null]; } // 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 ['ok' => $get['ok'], 'code' => $get['code'], 'ms' => $get['ms'], 'error' => $get['error']]; } // otherwise fall back to the HEAD info return ['ok' => false, 'code' => $head['code'], 'ms' => $head['ms'], 'error' => $head['error']]; } function build_server_status(array $targets): array { $allowedHosts = []; foreach ($targets as $t) { if (!empty($t['url'])) { $p = parse_url((string)$t['url']); if (is_array($p) && !empty($p['host'])) { $allowedHosts[] = strtolower((string)$p['host']); } } } $allowedHosts = array_values(array_unique($allowedHosts)); $results = []; foreach ($targets as $t) { $url = (string)($t['url'] ?? ''); if ($url === '' || !is_allowed_http_url($url, $allowedHosts)) { $results[] = ['url' => $url, 'state' => 'unknown', 'ms' => null, 'detail' => 'Nicht erlaubt']; continue; } $r = check_http_head($url); $results[] = [ 'url' => $url, 'state' => $r['ok'] ? 'up' : 'down', 'ms' => $r['ms'], 'detail' => $r['ok'] ? null : ($r['error'] ?? null), ]; } return $results; }