Website-fabianschieder/includes/lib/server_status.php

296 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
/**
* Serverstatus (dependency-free, mit Cache & SSRF-Guards)
*
* Contract:
* - Input: allowlist targets (host/url/port)
* - Output: array of results with state + latency
* - Safety: no user input, no redirects, blocks private/loopback IPs
*/
/** @return bool true if IP is public-ish */
function is_public_ip(string $ip): bool
{
// FILTER_FLAG_NO_PRIV_RANGE + NO_RES_RANGE covers most private/reserved ranges.
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return true;
}
// Fallback: treat unknown/invalid as not allowed.
return false;
}
/** Resolve host to IPs (A/AAAA) */
function resolve_host_ips(string $host): array
{
$ips = [];
// IPv4
$a = @dns_get_record($host, DNS_A);
if (is_array($a)) {
foreach ($a as $rec) {
if (!empty($rec['ip'])) {
$ips[] = $rec['ip'];
}
}
}
// IPv6
$aaaa = @dns_get_record($host, DNS_AAAA);
if (is_array($aaaa)) {
foreach ($aaaa as $rec) {
if (!empty($rec['ipv6'])) {
$ips[] = $rec['ipv6'];
}
}
}
// Deduplicate
$ips = array_values(array_unique(array_filter($ips)));
return $ips;
}
function is_allowed_http_url(string $url, array $allowedHosts): bool
{
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
return false;
}
$scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return false;
}
$host = strtolower((string)$parts['host']);
if (!in_array($host, $allowedHosts, true)) {
return false;
}
// Resolve and ensure all resolved IPs are public.
$ips = resolve_host_ips($host);
if (empty($ips)) {
return false;
}
foreach ($ips as $ip) {
if (!is_public_ip($ip)) {
return false;
}
}
return true;
}
/**
* Extract first HTTP status code from a response header array.
* @param array<int,string> $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}
*/
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';
}
return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $error];
}
/**
* HTTP check (no redirects).
* - Tries HEAD first.
* - If result is a redirect, probes the Location once (still without following further redirects).
* - 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 (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header 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 the target is clearly reachable/unreachable, use that.
if ($probe['code'] !== null && ($probe['code'] < 300 || $probe['code'] >= 400)) {
return $probe;
}
// Otherwise keep the redirect info, but don't mark it UP.
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 $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 = [];
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;
}