Files
Deploymentcenter/public/api/health.php
T
Deploymentcenter BotandClaude Opus 5 df67932019 fix(2.9.3): Ankerpruefung vergleicht, statt nur auf "gesetzt" zu sehen
checks.webauthn_anchor meldete bisher nur, OB app.url oder
security.webauthn_rp_id einen Wert tragen. Das beantwortet die Frage
nicht, die dahintersteht: 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.

Die Pruefung vergleicht jetzt zusaetzlich mit dem Namen, unter dem die
Anfrage tatsaechlich hereinkam, und meldet configured und
matches_request getrennt. Dass dafuer der Host-Header herangezogen wird,
ist unbedenklich: Es ist eine Diagnose, keine Entscheidung. Wer ihn
faelscht, erzeugt eine falsche Warnung und sonst nichts - waehrend
WebauthnServer ihn weiterhin unveraendert ignoriert.

Damit ist die Auskunft vor einem Deployment belastbar, statt nur die
haelfte der Frage zu beantworten.

188 von 188 Testfaellen gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 15:52:51 +02:00

222 lines
8.2 KiB
PHP

<?php
/**
* GET /api/health
*
* Verfuegbarkeitspruefung fuer Monitoring und Agenten.
*
* Ohne Authentifizierung wird nur der Gesamtzustand gemeldet. Mit gueltigem
* Token, Shared Key oder angemeldeter Sitzung kommen Schema-Version,
* ausstehende Migrationen und Kennzahlen dazu.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\Migrator;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
Http::beginJson(['GET', 'OPTIONS'], true);
$checks = [];
$healthy = true;
// --- Datenbank ---
$db = null;
try {
$started = microtime(true);
$db = Db::init();
$db->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);