From 6e633e0308583706ec979340b50c567401efeb11 Mon Sep 17 00:00:00 2001 From: Fabian Schieder Date: Sat, 28 Feb 2026 19:29:59 +0100 Subject: [PATCH] Add server status monitoring functionality with visual indicators for improved user awareness --- index.php | 276 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- style.css | 81 +++++++++++++++- 2 files changed, 347 insertions(+), 10 deletions(-) diff --git a/index.php b/index.php index 912cb70..338ad19 100644 --- a/index.php +++ b/index.php @@ -64,7 +64,226 @@ $projects = [ ], ], ]; - ?> + +// ── 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 + */ + +$serverStatusTargets = [ + [ + 'title' => 'Gitea', + 'url' => 'https://fabianschieder.com/git', + ], + [ + 'title' => 'Nextcloud', + 'url' => 'https://fabianschieder.com/nextcloud', + ], + [ + 'title' => 'Home Assistant', + 'url' => 'http://homeassistant.fabianschieder.com', + ], + [ + 'title' => 'NAS', + 'url' => 'http://nas.fabianschieder.com', + ], + [ + 'title' => 'Cockpit', + 'url' => 'https://cockpit.fabianschieder.com', + ], + [ + 'title' => 'Geizkragen', + 'url' => 'https://geizkragen.store', + ], +]; + +/** @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; +} + +/** + * HTTP HEAD check (no 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 +{ + $start = microtime(true); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'HEAD', + 'timeout' => $timeoutSeconds, + 'ignore_errors' => true, + 'follow_location' => 0, + 'max_redirects' => 0, + 'header' => "User-Agent: ServerStatus/1.0\r\n", + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $code = null; + $error = null; + + set_error_handler(static function (int $severity, string $message) use (&$error): bool { + $error = $message; + return true; + }); + $fp = @fopen($url, 'rb', false, $ctx); + restore_error_handler(); + + if ($fp !== false) { + 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; + } + } + } + + $ok = ($code !== null) && ($code >= 200 && $code < 400); + if ($code === null && $error === null) { + $error = 'Unbekannter Fehler'; + } + + return ['ok' => $ok, 'code' => $code, 'ms' => $ms, 'error' => $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; +} + +$cacheTtlSeconds = 30; +$cacheKey = 'server_status_v1'; +$cacheFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $cacheKey . '.json'; + +$serverStatus = null; +$cacheOk = false; +if (is_file($cacheFile)) { + $raw = @file_get_contents($cacheFile); + if ($raw !== false) { + $decoded = json_decode($raw, true); + if (is_array($decoded) && isset($decoded['ts'], $decoded['data']) && is_array($decoded['data'])) { + $age = time() - (int)$decoded['ts']; + if ($age >= 0 && $age <= $cacheTtlSeconds) { + $serverStatus = $decoded['data']; + $cacheOk = true; + } + } + } +} + +if (!is_array($serverStatus)) { + $serverStatus = build_server_status($serverStatusTargets); + @file_put_contents($cacheFile, json_encode(['ts' => time(), 'data' => $serverStatus], JSON_UNESCAPED_SLASHES)); +} + +// Index: url => status +$serverStatusByUrl = []; +if (is_array($serverStatus)) { + foreach ($serverStatus as $svc) { + if (!empty($svc['url']) && is_string($svc['url'])) { + $serverStatusByUrl[$svc['url']] = $svc; + } + } +} +?> @@ -110,6 +329,40 @@ $projects = [
+ +

-

+

+ +

- + +
+ + + +
+ + +
@@ -156,9 +419,8 @@ $projects = [ let wi = 0, ci = 0, deleting = false; function type() { - const word = words[wi]; - const display = deleting ? word.slice(0, ci--) : word.slice(0, ci++); - el.textContent = display; + const word = words[wi]; + el.textContent = deleting ? word.slice(0, ci--) : word.slice(0, ci++); let delay = deleting ? 60 : 110; if (!deleting && ci > word.length) { delay = 1400; deleting = true; } @@ -248,3 +510,5 @@ document.querySelectorAll('.card').forEach(card => { + + diff --git a/style.css b/style.css index 2e6a21b..bd7f46c 100644 --- a/style.css +++ b/style.css @@ -373,7 +373,73 @@ footer:hover { color: var(--text); } -/* ===== RESPONSIVE ===== */ +/* ===== SERVER STATUS ===== */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: 0.6rem; + padding: 0.18rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.2px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.04); + color: var(--text-muted); + vertical-align: middle; + white-space: nowrap; +} + +.status-badge::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + opacity: 0.9; +} + +.status-badge--up { + color: #22c55e; + border-color: rgba(34,197,94,0.22); + background: rgba(34,197,94,0.10); +} + +.status-badge--down { + color: #ef4444; + border-color: rgba(239,68,68,0.22); + background: rgba(239,68,68,0.10); +} + +.status-badge--unknown { + color: #a1a1aa; + border-color: rgba(161,161,170,0.22); + background: rgba(161,161,170,0.08); +} + +.status-meta { + color: color-mix(in srgb, var(--text-muted) 82%, transparent); + margin-left: 0.25rem; + white-space: nowrap; +} + +.status-hint { + font-size: 0.72rem; + color: var(--text-muted); + font-weight: 600; + text-transform: none; + letter-spacing: 0; +} + +/* ===== SERVER STATUS (right indicator) ===== */ +.status-right { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + @media (max-width: 480px) { body { padding: 1.5rem 0.75rem 3rem; @@ -382,6 +448,16 @@ footer:hover { .card-body p { display: none; } + + .status-badge { + margin-left: 0.45rem; + font-size: 0.68rem; + padding: 0.15rem 0.45rem; + } + + .status-meta { + display: none; + } } /* ===== PARTICLE CANVAS ===== */ @@ -465,6 +541,3 @@ body::before { @keyframes ripple-anim { to { transform: scale(4); opacity: 0; } } - - -