query('SELECT 1')->fetchColumn(); $checks['database'] = [ 'ok' => true, 'latency_ms' => (int)round((microtime(true) - $started) * 1000), ]; } catch (Throwable $e) { Logger::error('Health-Check: Datenbank nicht erreichbar', ['error' => $e->getMessage()]); $checks['database'] = ['ok' => false, 'error' => 'nicht erreichbar']; $healthy = false; } // --- Schreibbarkeit des Log-Verzeichnisses --- $logDir = DC_VAR . '/log'; $checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)]; // --- Ist die eigene Adresse fest hinterlegt? --- // Seit 2.9.3 weigert sich WebauthnServer, die Kennung aus dem Host-Header // abzuleiten: Aus diesem Wert entstehen rpId und expectedOrigin, also die // beiden Anker, gegen die die Anmeldezeremonie geprueft wird. Wer den Header // setzt, bestimmte sonst selbst, wogegen seine Anmeldung geprueft wird. // // Die Folge ist aber, dass eine Anlage ohne app.url den Hardwarekey nicht // mehr annimmt. Das hier ist die Stelle, an der man das VORHER sieht statt // beim naechsten Anmeldeversuch. Gemeldet wird nur, OB ein Wert gesetzt ist - // nicht welcher; eine Gesundheitsauskunft ohne Anmeldung soll keine // Konfiguration ausplaudern. $appUrl = trim((string)Config::get('app.url', '')); $rpIdConf = trim((string)Config::get('security.webauthn_rp_id', '')); $gesetzt = $appUrl !== '' || $rpIdConf !== ''; // "Gesetzt" allein genuegt als Auskunft nicht. Ein Tippfehler in app.url // weist den Hardwarekey genauso ab wie ein fehlender Wert - der Browser // traegt den echten Ursprung in clientDataJSON ein, und der muss zu // expectedOrigin passen. Deshalb wird hier zusaetzlich verglichen, unter // welchem Namen diese Anfrage tatsaechlich hereingekommen ist. // // Dass dafuer der Host-Header herangezogen wird, ist hier unbedenklich: Es // ist eine Diagnose, keine Entscheidung. Wer ihn faelscht, erzeugt eine // falsche Warnung und sonst nichts - waehrend WebauthnServer ihn // unveraendert ignoriert. $erwarteterHost = $rpIdConf !== '' ? $rpIdConf : (string)(parse_url($appUrl, PHP_URL_HOST) ?: ''); $tatsaechlicherHost = strtolower((string)(explode(':', (string)($_SERVER['HTTP_HOST'] ?? ''))[0])); $passt = $erwarteterHost !== '' && strtolower($erwarteterHost) === $tatsaechlicherHost; $hinweis = null; if (!$gesetzt) { $hinweis = 'app.url oder security.webauthn_rp_id fehlt - der Hardwarekey wird abgewiesen.'; } elseif (!$passt) { $hinweis = sprintf( 'Die hinterlegte Adresse ("%s") ist eine andere als die, unter der diese Anfrage ' . 'hereinkam. Ein Hardwarekey, der unter der einen registriert wurde, wird unter der ' . 'anderen abgewiesen.', $erwarteterHost ); } $checks['webauthn_anchor'] = [ 'ok' => $gesetzt && $passt, 'configured' => $gesetzt, 'matches_request' => $passt, 'hint' => $hinweis, ]; // Bewusst KEIN $healthy = false: Eine Anlage ohne Hardwarekeys laeuft damit // einwandfrei. Erst wer einen registriert, braucht den Anker - und dann sagt // es ihm diese Zeile. // --- Schema vorhanden? --- // Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen // sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten // Migration gibt es dc_tokens nicht - und daran starb die Rechtepruefung // weiter unten mit einer PDOException. Ausgerechnet der Endpunkt, der ueber // kaputte Zustaende Auskunft geben soll, antwortete dann mit HTTP 500 statt // mit healthy=false und einer Begruendung. Betroffen war jede Neuinstallation // zwischen Deployment und erster Migration. $schemaReady = false; if ($db !== null) { try { $schemaReady = $db->query("SHOW TABLES LIKE 'dc_tokens'")->fetchColumn() !== false; } catch (Throwable $e) { Logger::error('Health-Check: Schemapruefung fehlgeschlagen', ['error' => $e->getMessage()]); } $checks['schema'] = $schemaReady ? ['ok' => true] : [ 'ok' => false, 'message' => 'Datenbankschema fehlt - die Migrationen wurden noch nicht ausgefuehrt (install_db.php aufrufen).', ]; if (!$schemaReady) { $healthy = false; } } $response = [ 'healthy' => $healthy, 'app' => Config::get('app.name', 'Deploymentcenter'), 'version' => Config::get('app.version', 'unknown'), 'time_utc' => gmdate('c'), 'checks' => $checks, ]; // --- Detailinformationen nur fuer Authentifizierte --- // Die Rechtepruefung selbst bleibt zusaetzlich abgesichert: ein halb // migriertes Schema besteht die Pruefung oben, kann hier aber trotzdem ueber // eine fehlende Spalte stolpern. Ein Fehlschlag wird wie "nicht // authentifiziert" behandelt - dann kommt die Kurzfassung heraus, und die // Antwort bleibt auswertbar. $authenticated = false; if ($db !== null && $schemaReady) { try { $authenticated = ApiAuth::resolve($db, 'bugtracker:read') !== null; } catch (Throwable $e) { Logger::warning('Health-Check: Rechtepruefung nicht moeglich', ['error' => $e->getMessage()]); $response['checks']['schema'] = [ 'ok' => false, 'message' => 'Datenbankschema unvollstaendig - die Rechtepruefung war nicht moeglich.', ]; $response['healthy'] = false; } } if ($authenticated) { try { $status = Migrator::status($db); $response['schema'] = [ 'applied_count' => count($status['applied']), 'pending' => $status['pending'], ]; if ($status['pending'] !== []) { $response['healthy'] = false; $response['checks']['migrations'] = [ 'ok' => false, 'message' => 'Ausstehende Migrationen: ' . implode(', ', $status['pending']), ]; } else { $response['checks']['migrations'] = ['ok' => true]; } $repo = new BugRepo($db); $response['bugtracker'] = $repo->getStats(); $monitors = $db->query(' SELECT state, COUNT(*) AS total FROM watchdog_monitors GROUP BY state ')->fetchAll() ?: []; $byState = []; foreach ($monitors as $row) { $byState[(string)$row['state']] = (int)$row['total']; } $response['watchdog'] = ['monitors_by_state' => $byState]; $lastRun = $db->query(" SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'evaluator' ")->fetchColumn(); $response['watchdog']['evaluator_last_run_utc'] = $lastRun !== false ? $lastRun : null; // Laeuft der Evaluator nicht, sind alle Monitor-Zustaende wertlos. if ($lastRun === false || $lastRun === null) { $response['checks']['evaluator'] = [ 'ok' => false, 'message' => 'Der Evaluator lief noch nie. Cron-Job auf /api/watchdog/v1/evaluate einrichten.', ]; } else { $age = time() - (int)strtotime((string)$lastRun . ' UTC'); $response['checks']['evaluator'] = [ 'ok' => $age < 900, 'age_seconds' => $age, 'message' => $age < 900 ? null : 'Letzter Lauf liegt zu lange zurueck.', ]; } } catch (Throwable $e) { Logger::warning('Health-Check: Detailabfrage fehlgeschlagen', ['error' => $e->getMessage()]); $response['detail_error'] = 'Detailinformationen konnten nicht ermittelt werden.'; } } Http::ok($response, $response['healthy'] ? 200 : 503);