$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; } /** * Parallel HTTP status checks using curl_multi if available. * @param array $urls * @return array}> map url => result */ function curl_multi_head_status(array $urls, float $timeoutSeconds): array { $out = []; if (empty($urls) || !function_exists('curl_multi_init')) { return $out; } $mh = curl_multi_init(); $handles = []; foreach ($urls as $url) { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_NOBODY => true, CURLOPT_CUSTOMREQUEST => 'HEAD', CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_CONNECTTIMEOUT_MS => (int)round($timeoutSeconds * 1000), CURLOPT_TIMEOUT_MS => (int)round($timeoutSeconds * 1000), CURLOPT_USERAGENT => 'ServerStatus/1.2', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); curl_multi_add_handle($mh, $ch); $handles[(string)$url] = $ch; } $running = null; do { $mrc = curl_multi_exec($mh, $running); if ($running) { // wait for activity (avoid busy loop) curl_multi_select($mh, 0.2); } } while ($running && $mrc === CURLM_OK); foreach ($handles as $url => $ch) { $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_error($ch); $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME); $ms = is_float($totalTime) ? (int)round($totalTime * 1000) : null; $rawHeaders = curl_multi_getcontent($ch); $headers = []; if (is_string($rawHeaders) && $rawHeaders !== '') { foreach (preg_split("/\r\n|\n|\r/", trim($rawHeaders)) as $line) { if ($line !== '') $headers[] = $line; } } $out[$url] = [ 'code' => ($code > 0 ? (int)$code : null), 'error' => ($err !== '' ? $err : null), 'ms' => $ms, 'headers' => $headers, ]; curl_multi_remove_handle($mh, $ch); curl_close($ch); } curl_multi_close($mh); return $out; } /** * HTTP check (no redirects). * @return array{ok:bool, code:int|null, ms:int|null, error:string|null} */ function check_http_head(string $url, float $timeoutSeconds = SERVER_STATUS_TIMEOUT_SECONDS): 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 = []; // Attempt fast parallel HEAD checks via cURL. $urls = []; foreach ($targets as $t) { $u = (string)($t['url'] ?? ''); if ($u !== '' && is_allowed_http_url($u, $allowedHosts)) { $urls[] = $u; } } $curlHeadMap = curl_multi_head_status($urls, SERVER_STATUS_TIMEOUT_SECONDS); 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; } // If we have a curl result, use it as a quick first pass. if (isset($curlHeadMap[$url])) { $c = $curlHeadMap[$url]; $code = $c['code']; $ok = is_reachable_http_code($code); // Handle redirects: probe location once using existing logic (stream-based) for correctness. if ($code !== null && $code >= 300 && $code < 400) { $r = check_http_head($url, SERVER_STATUS_TIMEOUT_SECONDS); $results[] = [ 'url' => $url, 'state' => $r['ok'] ? 'up' : 'down', 'ms' => $r['ms'], 'detail' => $r['ok'] ? null : ($r['error'] ?? null), ]; continue; } $results[] = [ 'url' => $url, 'state' => $ok ? 'up' : 'down', 'ms' => $c['ms'], 'detail' => $ok ? null : ($c['error'] ?? ($code !== null ? ('HTTP ' . (string)$code) : null)), ]; continue; } // Fallback: previous sequential stream-based check. $r = check_http_head($url, SERVER_STATUS_TIMEOUT_SECONDS); $results[] = [ 'url' => $url, 'state' => $r['ok'] ? 'up' : 'down', 'ms' => $r['ms'], 'detail' => $r['ok'] ? null : ($r['error'] ?? null), ]; } return $results; }