fix(security, core): Auth-Pflicht für Ingest-APIs, 500er-Ursachen beheben, Agenten-Workflow

Sicherheit
- install_db.php war ohne Authentifizierung erreichbar und setzte bei jedem
  Aufruf das Admin-Passwort auf einen fest im Code stehenden Wert zurück.
  Jetzt Auth-Pflicht; ein Konto wird nur bei leerer Benutzertabelle angelegt.
- Stored XSS im Bugtracker-Detail-Modal: Titel, Beschreibung, Fehlermeldung,
  Stacktrace und Kommentare gingen ungefiltert durch innerHTML.
- report.php, projects.php und das Veröffentlichen von Releases verlangen jetzt
  zwingend ein Token. Publish war zuvor völlig ungeschützt.
- CSRF-Token in allen Formularen, Session-Regenerierung nach Login,
  Drosselung fehlgeschlagener Anmeldeversuche.
- Zugangsdaten aus der Versionskontrolle entfernt (Serverdaten.txt,
  config.php, .htpasswd, deploy_config.json). Historie enthält sie weiterhin,
  Rotation erforderlich (siehe docs/UPGRADE.md).
- Token-Validierung nur noch über SHA-256-Hash; expires_at wird ausgewertet.

Behobene 500er
- Audit::log() war in index.php weder eingebunden noch importiert. Jeder
  Klick auf "Aktivierung freigeben" endete in einem Fatal Error.
- Derselbe benannte PDO-Platzhalter mehrfach je Statement (:id in
  revokeToken/deleteToken, :q siebenfach in der Volltextsuche). Bei
  EMULATE_PREPARES=false ist das nicht zulässig und warf HY093.
- Migration 005 nutzte dynamisches SQL, dessen Semikolons in String-Literalen
  vom alten explode(';')-Installer als Statement-Ende gelesen wurden. Sie
  schlug still fehl, wodurch push_id/target_agent/tags dauerhaft fehlten.
- Monitor-Umbenennung ohne Transaktion, verschachtelte Transaktionen im
  RateLimiter.

Funktionale Korrekturen
- Der Watchdog-Evaluator fehlte vollständig: Monitor-Zustände änderten sich nur
  beim Eintreffen eines Heartbeats, ein ausgefallenes System blieb dauerhaft
  "up". Erster Lauf auf dem Produktivsystem: 7 von 10 Monitoren waren
  tatsächlich seit über einem Tag nicht erreichbar.
- Das Feld "os" fehlte im Monitor-Dialog, wurde aber gespeichert und löschte
  damit bei jedem Speichern das Betriebssystem.
- Der Resolve-Dialog existierte im HTML nicht; der Button war funktionslos.
- Versionsvergleich erfolgte lexikografisch, wodurch 1.9.0 als neuer galt
  als 1.10.0.
- Schreiboperationen meldeten Erfolg auch für nicht existierende IDs.
- Post/Redirect/Get gegen doppelte Einträge beim Neuladen.

Neue Struktur
- src/bootstrap.php mit PSR-4-Autoloader ersetzt die require-Ketten.
- Core: Config, Http, Csrf, ApiAuth, Logger, Migrator, ErrorReporter.
- Migrator mit zeichenweisem SQL-Parser, dc_migrations und Baseline-Verfahren,
  damit bestehende Installationen keine Beispieldaten zurückbekommen.

Agenten-Workflow
- Claim/Lease: Items werden exklusiv übernommen, damit nicht zwei Agenten am
  selben Problem arbeiten. action=next holt und reserviert in einem Zug.
- Idempotenz über client_ref, Deduplizierung auch für Feature Requests,
  Erkennung von Regressionen, automatische Eskalation des Schweregrads.
- Strukturierter Code-Kontext (repo_url, commit_sha, file_path, line_no).
- Delta-Abfragen über updated_since, Pagination, Bulk-Update.
- Beim Veröffentlichen eines Releases schließen sich Items mit passendem
  resolved_in_build selbst.
- Ausgehende Webhooks mit HMAC-Signatur, /api/health, /api/openapi.json.
- Unbehandelte Fehler meldet die Plattform in ihren eigenen Bugtracker.

WebUI
- Serverseitige Filterung mit Pagination statt Rendern aller Datensätze.
- Migrations-Schranke, Evaluator-Warnung, Übersicht aktiver Agenten.

Zeitstempel liegen in der Datenbank durchgängig in UTC und werden für die
Anzeige in die App-Zeitzone umgerechnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-07 16:17:36 +02:00
co-authored by Claude Opus 5
parent a21536f495
commit e7fbc85db4
59 changed files with 8506 additions and 2410 deletions
+355 -86
View File
@@ -1,11 +1,49 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use PDO;
use RuntimeException;
class MonitorRepo
/**
* Monitore des Watchdog-Moduls.
*
* Korrekturen gegenueber der Erstfassung:
* - updateMonitor() nimmt jetzt ein Feld-Array entgegen und aktualisiert nur
* die tatsaechlich uebergebenen Spalten. Zuvor wurden alle Spalten fest
* geschrieben; fehlte ein Formularfeld (z. B. "os", das es im Bearbeiten-
* Dialog gar nicht gab), wurde die Spalte bei jedem Speichern auf NULL
* gesetzt - und damit auch die automatische Icon-Erkennung zerstoert.
* - Das Umbenennen einer Source laeuft in einer Transaktion. Vorher wurden
* erst Kinder und Tokens umgehaengt und danach umbenannt; scheiterte das
* Umbenennen am Unique-Key, zeigten die Kinder auf einen Parent, den es
* nicht mehr gab.
* - Beim Umbenennen wird auch das Event-Log mitgezogen, damit die Historie
* nicht abreisst.
*/
final class MonitorRepo
{
public const STATES = ['up', 'warning', 'down', 'error', 'stopped', 'maintenance', 'unknown'];
public const TYPES = ['heartbeat', 'host', 'hypervisor_node', 'guest'];
/** Spalten, die ueber updateMonitor() gesetzt werden duerfen. */
private const UPDATABLE = [
'source' => 'source',
'type' => 'type',
'group_key' => 'group_key',
'parent_source' => 'parent_source',
'os' => 'os',
'notes' => 'notes',
'url' => 'url',
'icon' => 'icon',
'expected_interval_sec' => 'expected_interval_sec',
'is_muted' => 'is_muted',
'expect_running' => 'expect_running',
];
private PDO $db;
public function __construct(PDO $db)
@@ -13,20 +51,27 @@ class MonitorRepo
$this->db = $db;
}
/** @return list<array<string,mixed>> */
public function getAllMonitors(): array
{
$stmt = $this->db->query('SELECT * FROM watchdog_monitors ORDER BY group_key ASC, source ASC');
$stmt = $this->db->query('
SELECT * FROM watchdog_monitors
ORDER BY COALESCE(group_key, "zzz") ASC, source ASC
');
return $stmt->fetchAll() ?: [];
}
public function getMonitor(string $source, string $instance = 'default'): ?array
{
$stmt = $this->db->prepare('SELECT * FROM watchdog_monitors WHERE source = :s AND instance = :i');
$stmt->execute([':s' => $source, ':i' => $instance]);
$stmt = $this->db->prepare('SELECT * FROM watchdog_monitors WHERE source = :source AND instance = :instance');
$stmt->execute([':source' => $source, ':instance' => $instance]);
$row = $stmt->fetch();
return $row ?: null;
return is_array($row) ? $row : null;
}
/**
* Nimmt einen Heartbeat entgegen und legt den Monitor bei Bedarf an.
*/
public function upsertHeartbeat(
string $source,
string $instance,
@@ -38,27 +83,47 @@ class MonitorRepo
?string $groupKey = null,
?string $os = null
): array {
$metricsJson = is_array($metrics) || is_object($metrics) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
$state = ($status === 'ok') ? 'up' : (($status === 'warning') ? 'warning' : 'down');
$metricsJson = (is_array($metrics) || is_object($metrics))
? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null;
$state = match ($status) {
'ok' => 'up',
'warning' => 'warning',
default => 'down',
};
$previous = $this->getMonitor($source, $instance);
$previousState = $previous !== null ? (string)$previous['state'] : null;
$type = in_array($type, self::TYPES, true) ? $type : 'heartbeat';
$intervalSec = max(10, min($intervalSec, 86400));
$stmt = $this->db->prepare('
INSERT INTO watchdog_monitors (
source, instance, type, state, expected_interval_sec, last_seen_utc,
last_status, last_message, metrics_json, group_key, os, created_utc, updated_utc
source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_seen_utc, last_status, last_message, metrics_json, group_key, os,
created_utc, updated_utc
) VALUES (
:source, :instance, :type, :state, :interval, NOW(),
:last_status, :message, :metrics, :group_key, :os, NOW(), NOW()
:source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
UTC_TIMESTAMP(), :last_status, :message, :metrics, :group_key, :os,
UTC_TIMESTAMP(), UTC_TIMESTAMP()
)
ON DUPLICATE KEY UPDATE
state = VALUES(state),
-- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
-- links nach rechts aus. last_state_change_utc muss den alten
-- Wert von state sehen, also vor dessen Zuweisung stehen.
last_state_change_utc = IF(state <> VALUES(state), UTC_TIMESTAMP(), last_state_change_utc),
state = VALUES(state),
down_since_utc = IF(VALUES(state) = "up", NULL, down_since_utc),
expected_interval_sec = VALUES(expected_interval_sec),
last_seen_utc = VALUES(last_seen_utc),
last_status = VALUES(last_status),
last_message = VALUES(last_message),
metrics_json = VALUES(metrics_json),
group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os),
updated_utc = VALUES(updated_utc)
last_seen_utc = VALUES(last_seen_utc),
last_status = VALUES(last_status),
last_message = VALUES(last_message),
metrics_json = VALUES(metrics_json),
group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os),
updated_utc = VALUES(updated_utc)
');
$stmt->execute([
@@ -67,101 +132,305 @@ class MonitorRepo
':type' => $type,
':state' => $state,
':interval' => $intervalSec,
':last_status' => $status,
':last_status' => in_array($status, ['ok', 'warning', 'error'], true) ? $status : 'error',
':message' => $message,
':metrics' => $metricsJson,
':group_key' => $groupKey,
':os' => $os,
]);
return $this->getMonitor($source, $instance);
}
public function updateMonitor(
string $oldSource,
string $newSource,
string $instance,
?string $type,
?string $groupKey,
?string $parentSource,
?string $os,
?string $notes,
?string $url,
?int $intervalSec,
?string $icon = null,
bool $isMuted = false
): bool {
// Cascade source renaming to children & agent tokens
if ($oldSource !== $newSource) {
$this->db->prepare('UPDATE watchdog_monitors SET parent_source = :new WHERE parent_source = :old')
->execute([':new' => $newSource, ':old' => $oldSource]);
$this->db->prepare('UPDATE watchdog_agent_tokens SET monitor_source = :new WHERE monitor_source = :old')
->execute([':new' => $newSource, ':old' => $oldSource]);
$monitor = $this->getMonitor($source, $instance);
if ($monitor === null) {
throw new RuntimeException('Monitor konnte nicht gespeichert werden: ' . $source);
}
$monitor['_previous_state'] = $previousState;
$monitor['_state_changed'] = $previousState !== null && $previousState !== $state;
return $monitor;
}
/**
* Legt einen Monitor manuell an (ohne Heartbeat).
*
* @param array<string,mixed> $fields
*/
public function createMonitor(string $source, array $fields = []): array
{
$stmt = $this->db->prepare('
UPDATE watchdog_monitors SET
source = :new_source,
type = COALESCE(:type, type),
group_key = :group_key,
parent_source = :parent_source,
os = :os,
notes = :notes,
url = :url,
expected_interval_sec = COALESCE(:interval, expected_interval_sec),
icon = COALESCE(:icon, icon),
is_muted = :is_muted,
updated_utc = NOW()
WHERE source = :old_source AND instance = :instance
INSERT INTO watchdog_monitors (
source, instance, type, state, expected_interval_sec,
group_key, parent_source, os, created_utc, updated_utc
) VALUES (
:source, "default", :type, "unknown", :interval,
:group_key, :parent_source, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP()
)
ON DUPLICATE KEY UPDATE
expected_interval_sec = VALUES(expected_interval_sec),
group_key = VALUES(group_key),
parent_source = VALUES(parent_source),
os = COALESCE(VALUES(os), os),
updated_utc = UTC_TIMESTAMP()
');
return $stmt->execute([
':new_source' => $newSource,
':type' => !empty($type) ? $type : null,
':group_key' => !empty($groupKey) ? $groupKey : null,
':parent_source' => !empty($parentSource) ? $parentSource : null,
':os' => !empty($os) ? $os : null,
':notes' => !empty($notes) ? $notes : null,
':url' => !empty($url) ? $url : null,
':interval' => $intervalSec,
':icon' => !empty($icon) ? $icon : null,
':is_muted' => $isMuted ? 1 : 0,
':old_source' => $oldSource,
':instance' => $instance,
$type = (string)($fields['type'] ?? 'heartbeat');
$stmt->execute([
':source' => $source,
':type' => in_array($type, self::TYPES, true) ? $type : 'heartbeat',
':interval' => max(10, min((int)($fields['expected_interval_sec'] ?? 60), 86400)),
':group_key' => self::nullIfEmpty($fields['group_key'] ?? null),
':parent_source' => self::nullIfEmpty($fields['parent_source'] ?? null),
':os' => self::nullIfEmpty($fields['os'] ?? null),
]);
return $this->getMonitor($source) ?? [];
}
/**
* Aktualisiert einen Monitor. Es werden ausschliesslich die in $fields
* enthaltenen Spalten geschrieben - alles andere bleibt unangetastet.
*
* @param array<string,mixed> $fields
*/
public function updateMonitor(string $oldSource, string $instance, array $fields): bool
{
$monitor = $this->getMonitor($oldSource, $instance);
if ($monitor === null) {
return false;
}
$newSource = isset($fields['source']) ? trim((string)$fields['source']) : $oldSource;
if ($newSource === '') {
$newSource = $oldSource;
}
$renaming = $newSource !== $oldSource;
if ($renaming) {
$conflict = $this->getMonitor($newSource, $instance);
if ($conflict !== null) {
throw new RuntimeException(
sprintf('Ein Monitor namens "%s" existiert bereits.', $newSource)
);
}
}
$set = [];
$params = [':old_source' => $oldSource, ':instance' => $instance];
foreach (self::UPDATABLE as $key => $column) {
if (!array_key_exists($key, $fields)) {
continue;
}
$value = $fields[$key];
if ($column === 'expected_interval_sec') {
$value = max(10, min((int)$value, 86400));
} elseif ($column === 'is_muted' || $column === 'expect_running') {
$value = !empty($value) ? 1 : 0;
} elseif ($column === 'type') {
if (!in_array((string)$value, self::TYPES, true)) {
continue;
}
} elseif ($column === 'source') {
$value = $newSource;
} else {
$value = self::nullIfEmpty($value);
}
$set[] = $column . ' = :f_' . $key;
$params[':f_' . $key] = $value;
}
if ($set === []) {
return true;
}
$set[] = 'updated_utc = UTC_TIMESTAMP()';
// Umbenennung und alle abhaengigen Aktualisierungen als eine Einheit.
$ownTransaction = !$this->db->inTransaction();
if ($ownTransaction) {
$this->db->beginTransaction();
}
try {
$stmt = $this->db->prepare(
'UPDATE watchdog_monitors SET ' . implode(', ', $set)
. ' WHERE source = :old_source AND instance = :instance'
);
$stmt->execute($params);
if ($renaming) {
$this->cascadeRename($oldSource, $newSource);
}
if ($ownTransaction) {
$this->db->commit();
}
} catch (\Throwable $e) {
if ($ownTransaction && $this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
return true;
}
/** Zieht Kinder, Agent-Tokens und Event-Log auf den neuen Namen um. */
private function cascadeRename(string $oldSource, string $newSource): void
{
$updates = [
'UPDATE watchdog_monitors SET parent_source = :new WHERE parent_source = :old',
'UPDATE watchdog_agent_tokens SET monitor_source = :new WHERE monitor_source = :old',
'UPDATE watchdog_event_log SET source = :new WHERE source = :old',
];
foreach ($updates as $sql) {
$this->db->prepare($sql)->execute([':new' => $newSource, ':old' => $oldSource]);
}
Logger::info('Monitor umbenannt', ['from' => $oldSource, 'to' => $newSource]);
}
public function setParentSource(string $source, ?string $parentSource, string $instance = 'default'): bool
{
$stmt = $this->db->prepare('UPDATE watchdog_monitors SET parent_source = :parent, updated_utc = NOW() WHERE source = :s AND instance = :i');
return $stmt->execute([':parent' => !empty($parentSource) ? $parentSource : null, ':s' => $source, ':i' => $instance]);
// Ein Monitor darf nicht sein eigener Parent sein.
if ($parentSource !== null && trim($parentSource) === $source) {
$parentSource = null;
}
$stmt = $this->db->prepare('
UPDATE watchdog_monitors
SET parent_source = :parent, updated_utc = UTC_TIMESTAMP()
WHERE source = :source AND instance = :instance
');
$stmt->execute([
':parent' => self::nullIfEmpty($parentSource),
':source' => $source,
':instance' => $instance,
]);
return $stmt->rowCount() > 0;
}
public function updateState(int $id, string $state, ?string $reason = null): bool
/**
* Setzt den Zustand eines Monitors und vermerkt den Wechselzeitpunkt.
*/
public function setState(int $id, string $state, ?string $reason = null): bool
{
$stmt = $this->db->prepare('UPDATE watchdog_monitors SET state = :state, metric_state_reason = :reason, updated_utc = NOW() WHERE id = :id');
return $stmt->execute([':state' => $state, ':reason' => $reason, ':id' => $id]);
if (!in_array($state, self::STATES, true)) {
return false;
}
$isDown = in_array($state, ['down', 'error'], true);
$isUp = $state === 'up';
// Reihenfolge beachten: last_state_change_utc und down_since_utc muessen
// den alten Wert von state sehen, stehen daher vor dessen Zuweisung.
$stmt = $this->db->prepare('
UPDATE watchdog_monitors
SET last_state_change_utc = IF(state <> :state_cmp, UTC_TIMESTAMP(), last_state_change_utc),
down_since_utc = CASE
WHEN :is_down = 1 AND down_since_utc IS NULL THEN UTC_TIMESTAMP()
WHEN :is_up = 1 THEN NULL
ELSE down_since_utc
END,
state = :state,
metric_state_reason = :reason,
updated_utc = UTC_TIMESTAMP()
WHERE id = :id
');
$stmt->execute([
':state_cmp' => $state,
':is_down' => $isDown ? 1 : 0,
':is_up' => $isUp ? 1 : 0,
':state' => $state,
':reason' => $reason,
':id' => $id,
]);
return $stmt->rowCount() > 0;
}
public function setMaintenance(string $source, string $instance, ?string $untilUtc): bool
{
$state = $untilUtc ? 'maintenance' : 'up';
$stmt = $this->db->prepare('UPDATE watchdog_monitors SET state = :state, suppress_until_utc = :until, updated_utc = NOW() WHERE source = :s AND instance = :i');
return $stmt->execute([':state' => $state, ':until' => $untilUtc, ':s' => $source, ':i' => $instance]);
$stmt = $this->db->prepare('
UPDATE watchdog_monitors
SET state = :state,
suppress_until_utc = :until,
updated_utc = UTC_TIMESTAMP()
WHERE source = :source AND instance = :instance
');
$stmt->execute([
':state' => $untilUtc !== null ? 'maintenance' : 'unknown',
':until' => $untilUtc,
':source' => $source,
':instance' => $instance,
]);
return $stmt->rowCount() > 0;
}
public function deleteMonitor(string $source, string $instance = 'default'): bool
{
// 1. Unlink any children
$this->db->prepare('UPDATE watchdog_monitors SET parent_source = NULL WHERE parent_source = :s')
->execute([':s' => $source]);
$ownTransaction = !$this->db->inTransaction();
if ($ownTransaction) {
$this->db->beginTransaction();
}
// 2. Revoke agent tokens for this monitor
$this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE monitor_source = :s')
->execute([':s' => $source]);
try {
// Kinder auf Top-Level heben, damit keine Waisen entstehen.
$this->db->prepare('UPDATE watchdog_monitors SET parent_source = NULL WHERE parent_source = :source')
->execute([':source' => $source]);
// 3. Delete monitor
$stmt = $this->db->prepare('DELETE FROM watchdog_monitors WHERE source = :s AND instance = :i');
return $stmt->execute([':s' => $source, ':i' => $instance]);
// Zugehoerige Agent-Tokens entwerten.
$this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE monitor_source = :source')
->execute([':source' => $source]);
$stmt = $this->db->prepare('DELETE FROM watchdog_monitors WHERE source = :source AND instance = :instance');
$stmt->execute([':source' => $source, ':instance' => $instance]);
$deleted = $stmt->rowCount() > 0;
if ($ownTransaction) {
$this->db->commit();
}
return $deleted;
} catch (\Throwable $e) {
if ($ownTransaction && $this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
/**
* Monitore, die der Evaluator pruefen muss.
*
* @return list<array<string,mixed>>
*/
public function getEvaluationCandidates(): array
{
$stmt = $this->db->query('
SELECT * FROM watchdog_monitors
WHERE state <> "maintenance"
AND (suppress_until_utc IS NULL OR suppress_until_utc < UTC_TIMESTAMP())
');
return $stmt->fetchAll() ?: [];
}
/** @param mixed $value */
private static function nullIfEmpty($value): ?string
{
if ($value === null || is_array($value) || is_object($value)) {
return null;
}
$value = trim((string)$value);
return $value === '' ? null : $value;
}
}