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:
co-authored by
Claude Opus 5
parent
a21536f495
commit
e7fbc85db4
+914
-268
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\License;
|
||||
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use PDO;
|
||||
|
||||
class RateLimiter
|
||||
/**
|
||||
* Einfache Zaehler-Drosselung pro IP und Zeitfenster.
|
||||
*
|
||||
* Korrekturen gegenueber der Erstfassung:
|
||||
* - beginTransaction() wurde ungeprueft aufgerufen; lief bereits eine
|
||||
* Transaktion, warf das. Im catch-Zweig folgte rollBack(), das ohne aktive
|
||||
* Transaktion selbst wirft - eine Exception aus dem Exception-Handler.
|
||||
* - Der Zaehler laeuft jetzt ohne explizite Transaktion ueber ein atomares
|
||||
* INSERT ... ON DUPLICATE KEY UPDATE. Das ist kuerzer, schneller und
|
||||
* braucht keine Sperren.
|
||||
* - Wird jetzt auch vom Bugtracker-Ingest genutzt, nicht nur vom Lizenzmodul.
|
||||
*/
|
||||
final class RateLimiter
|
||||
{
|
||||
private PDO $db;
|
||||
private int $limit;
|
||||
private int $windowSeconds;
|
||||
private string $bucket;
|
||||
|
||||
public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60)
|
||||
public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60, string $bucket = 'default')
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->limit = $limit;
|
||||
$this->windowSeconds = $windowSeconds;
|
||||
$this->limit = max(1, $limit);
|
||||
$this->windowSeconds = max(1, $windowSeconds);
|
||||
$this->bucket = $bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt einen Zugriff und meldet, ob er erlaubt ist.
|
||||
*
|
||||
* Faellt die Pruefung selbst aus (z. B. weil die Tabelle fehlt), wird der
|
||||
* Zugriff durchgelassen - eine kaputte Drosselung darf den Dienst nicht
|
||||
* lahmlegen.
|
||||
*/
|
||||
public function check(string $ip): bool
|
||||
{
|
||||
$packedIp = inet_pton($ip);
|
||||
if ($packedIp === false) {
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fensteranfang auf ein festes Raster runden, damit alle Anfragen
|
||||
// desselben Intervalls auf dieselbe Zeile treffen.
|
||||
$now = time();
|
||||
$windowStart = date('Y-m-d H:i:s', $now - ($now % $this->windowSeconds));
|
||||
$windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds));
|
||||
|
||||
$this->db->beginTransaction();
|
||||
// Der Bucket unterscheidet Endpunkte mit eigenen Limits.
|
||||
$key = $this->bucket === 'default' ? $packed : substr(hash('sha256', $this->bucket . $ip, true), 0, 16);
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare('SELECT request_count FROM license_api_rate_limit WHERE ip = :ip AND window_start = :ws FOR UPDATE');
|
||||
$stmt->execute([':ip' => $packedIp, ':ws' => $windowStart]);
|
||||
$count = $stmt->fetchColumn();
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO license_api_rate_limit (ip, window_start, request_count)
|
||||
VALUES (:ip, :window_start, 1)
|
||||
ON DUPLICATE KEY UPDATE request_count = request_count + 1
|
||||
');
|
||||
$stmt->execute([':ip' => $key, ':window_start' => $windowStart]);
|
||||
|
||||
if ($count === false) {
|
||||
$ins = $this->db->prepare('INSERT INTO license_api_rate_limit (ip, window_start, request_count) VALUES (:ip, :ws, 1)');
|
||||
$ins->execute([':ip' => $packedIp, ':ws' => $windowStart]);
|
||||
$this->db->commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int)$count >= $this->limit) {
|
||||
$this->db->commit();
|
||||
return false;
|
||||
}
|
||||
|
||||
$upd = $this->db->prepare('UPDATE license_api_rate_limit SET request_count = request_count + 1 WHERE ip = :ip AND window_start = :ws');
|
||||
$upd->execute([':ip' => $packedIp, ':ws' => $windowStart]);
|
||||
$this->db->commit();
|
||||
return true;
|
||||
$read = $this->db->prepare('
|
||||
SELECT request_count FROM license_api_rate_limit
|
||||
WHERE ip = :ip AND window_start = :window_start
|
||||
');
|
||||
$read->execute([':ip' => $key, ':window_start' => $windowStart]);
|
||||
$count = (int)$read->fetchColumn();
|
||||
|
||||
return $count <= $this->limit;
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->rollBack();
|
||||
Logger::warning('Rate-Limit-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Entfernt Zeilen aelterer Zeitfenster. */
|
||||
public function purge(int $olderThanSeconds = 3600): int
|
||||
{
|
||||
try {
|
||||
$seconds = max(60, $olderThanSeconds);
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM license_api_rate_limit WHERE window_start < (UTC_TIMESTAMP() - INTERVAL ' . $seconds . ' SECOND)'
|
||||
);
|
||||
$stmt->execute();
|
||||
return $stmt->rowCount();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Notify;
|
||||
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Ausgehende Webhooks.
|
||||
*
|
||||
* Ereignisgesteuerte Benachrichtigung statt Polling: Agenten und Chat-Kanaele
|
||||
* (Telegram, Matrix, n8n, ...) koennen sich registrieren und werden bei neuen
|
||||
* oder kritischen Items sowie bei Watchdog-Zustandswechseln informiert.
|
||||
*
|
||||
* Jede Zustellung traegt eine HMAC-SHA256-Signatur im Header X-DC-Signature,
|
||||
* damit der Empfaenger die Echtheit pruefen kann:
|
||||
* signature = hex(hmac_sha256(secret, timestamp . '.' . body))
|
||||
* Der Header X-DC-Timestamp enthaelt den zugehoerigen Unix-Zeitstempel.
|
||||
*/
|
||||
final class WebhookDispatcher
|
||||
{
|
||||
/** Bekannte Ereignisnamen. */
|
||||
public const EVENTS = [
|
||||
'bug.created',
|
||||
'bug.critical',
|
||||
'bug.resolved',
|
||||
'feature.created',
|
||||
'monitor.down',
|
||||
'monitor.recovered',
|
||||
'release.published',
|
||||
];
|
||||
|
||||
private const TIMEOUT_SECONDS = 4;
|
||||
private const MAX_TARGETS = 10;
|
||||
|
||||
/** Verhindert, dass eine Kette von Ereignissen einen Request blockiert. */
|
||||
private static int $dispatchedThisRequest = 0;
|
||||
|
||||
public static function dispatch(PDO $db, string $event, array $payload): void
|
||||
{
|
||||
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targets = self::targetsFor($db, $event, $payload['project_slug'] ?? null);
|
||||
if ($targets === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = [
|
||||
'event' => $event,
|
||||
'timestamp' => gmdate('c'),
|
||||
'source' => (string)Config::get('app.url', ''),
|
||||
'data' => $payload,
|
||||
];
|
||||
|
||||
$json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
Logger::warning('Webhook-Payload nicht kodierbar', ['event' => $event]);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($targets as $target) {
|
||||
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
|
||||
break;
|
||||
}
|
||||
self::$dispatchedThisRequest++;
|
||||
self::deliver($db, $target, $json);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private static function targetsFor(PDO $db, string $event, $projectSlug): array
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, name, url, secret, events, project_slug
|
||||
FROM dc_webhooks
|
||||
WHERE enabled = 1
|
||||
AND (project_slug IS NULL OR project_slug = :slug)
|
||||
LIMIT 25
|
||||
');
|
||||
$stmt->execute([':slug' => is_string($projectSlug) ? $projectSlug : '']);
|
||||
$rows = $stmt->fetchAll() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Tabelle fehlt (Migration noch nicht gelaufen) - kein Grund zu scheitern.
|
||||
return [];
|
||||
}
|
||||
|
||||
$matching = [];
|
||||
foreach ($rows as $row) {
|
||||
$subscribed = array_map('trim', explode(',', (string)$row['events']));
|
||||
if (in_array($event, $subscribed, true) || in_array('*', $subscribed, true)) {
|
||||
$matching[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $matching;
|
||||
}
|
||||
|
||||
private static function deliver(PDO $db, array $target, string $json): void
|
||||
{
|
||||
$secret = (string)($target['secret'] ?? '');
|
||||
if ($secret === '') {
|
||||
$secret = (string)Config::get('security.webhook_key', '');
|
||||
}
|
||||
|
||||
$timestamp = (string)time();
|
||||
$signature = $secret !== ''
|
||||
? hash_hmac('sha256', $timestamp . '.' . $json, $secret)
|
||||
: '';
|
||||
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'User-Agent: Deploymentcenter-Webhook/1.0',
|
||||
'X-DC-Timestamp: ' . $timestamp,
|
||||
];
|
||||
if ($signature !== '') {
|
||||
$headers[] = 'X-DC-Signature: sha256=' . $signature;
|
||||
}
|
||||
|
||||
[$ok, $status, $error] = self::post((string)$target['url'], $json, $headers);
|
||||
|
||||
self::recordResult($db, (int)$target['id'], $ok, $status, $error);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array{0:bool,1:int,2:?string}
|
||||
*/
|
||||
private static function post(string $url, string $json, array $headers): array
|
||||
{
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
return [false, 0, 'Ungueltige URL'];
|
||||
}
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return [false, 0, 'curl_init fehlgeschlagen'];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$error = $response === false ? curl_error($ch) : null;
|
||||
curl_close($ch);
|
||||
|
||||
return [$status >= 200 && $status < 300, $status, $error];
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $json,
|
||||
'timeout' => self::TIMEOUT_SECONDS,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = @file_get_contents($url, false, $context);
|
||||
$status = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
|
||||
return [
|
||||
$result !== false && $status >= 200 && $status < 300,
|
||||
$status,
|
||||
$result === false ? 'Anfrage fehlgeschlagen' : null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function recordResult(PDO $db, int $webhookId, bool $ok, int $status, ?string $error): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
UPDATE dc_webhooks
|
||||
SET last_status = :status,
|
||||
last_error = :error,
|
||||
last_fired_at = UTC_TIMESTAMP(),
|
||||
failure_count = IF(:ok = 1, 0, failure_count + 1),
|
||||
enabled = IF(:ok2 = 1, enabled, IF(failure_count + 1 >= 20, 0, enabled))
|
||||
WHERE id = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
':status' => $ok ? 'ok (' . $status . ')' : 'failed (' . $status . ')',
|
||||
':error' => $error !== null ? mb_substr($error, 0, 500) : null,
|
||||
':ok' => $ok ? 1 : 0,
|
||||
':ok2' => $ok ? 1 : 0,
|
||||
':id' => $webhookId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Webhook-Ergebnis nicht gespeichert', ['id' => $webhookId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\UpdateService;
|
||||
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
|
||||
use PDO;
|
||||
|
||||
class UpdateManager
|
||||
/**
|
||||
* Release-Verwaltung des UpdateService.
|
||||
*
|
||||
* Der Versionsvergleich findet jetzt in PHP ueber Version::compare() statt.
|
||||
* Zuvor verglich SQL lexikografisch ("1.9.0" > "1.10.0"), was Clients ein
|
||||
* Downgrade als Update anbot.
|
||||
*/
|
||||
final class UpdateManager
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
@@ -13,19 +25,48 @@ class UpdateManager
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt das neueste Release, das echt neuer ist als die uebergebene Version.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM updateservice_releases
|
||||
WHERE product_slug = :slug AND channel = :channel AND version > :ver
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
SELECT * FROM updateservice_releases
|
||||
WHERE product_slug = :slug AND channel = :channel
|
||||
');
|
||||
$stmt->execute([':slug' => $productSlug, ':channel' => $channel, ':ver' => $currentVersion]);
|
||||
$latest = $stmt->fetch();
|
||||
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
|
||||
$releases = $stmt->fetchAll() ?: [];
|
||||
|
||||
return $latest ?: null;
|
||||
if ($releases === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$latest = Version::highest($releases);
|
||||
if ($latest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Version::isNewer((string)$latest['version'], $currentVersion) ? $latest : null;
|
||||
}
|
||||
|
||||
/** Hoechstes Release eines Kanals, unabhaengig von der Client-Version. */
|
||||
public function latestRelease(string $productSlug, string $channel = 'prod'): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM updateservice_releases
|
||||
WHERE product_slug = :slug AND channel = :channel
|
||||
');
|
||||
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
|
||||
return Version::highest($stmt->fetchAll() ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Release an oder aktualisiert es.
|
||||
*
|
||||
* @return array{id:int,created:bool,auto_resolved:int}
|
||||
*/
|
||||
public function addRelease(
|
||||
string $productSlug,
|
||||
string $version,
|
||||
@@ -36,51 +77,142 @@ class UpdateManager
|
||||
?string $gitCommit = null,
|
||||
int $sizeBytes = 0,
|
||||
?string $manifestJson = null,
|
||||
bool $isCritical = false
|
||||
): bool {
|
||||
bool $isCritical = false,
|
||||
string $author = 'admin'
|
||||
): array {
|
||||
$existing = $this->findRelease($productSlug, $version, $channel);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO updateservice_releases (
|
||||
product_slug, version, channel, release_notes, download_url, sha256_hash, git_commit, size_bytes, manifest_json, is_critical
|
||||
product_slug, version, channel, release_notes, download_url,
|
||||
sha256_hash, git_commit, size_bytes, manifest_json, is_critical
|
||||
) VALUES (
|
||||
:slug, :version, :channel, :notes, :url, :hash, :git, :size, :manifest, :critical
|
||||
:slug, :version, :channel, :notes, :url,
|
||||
:hash, :git, :size, :manifest, :critical
|
||||
) ON DUPLICATE KEY UPDATE
|
||||
release_notes = VALUES(release_notes),
|
||||
download_url = VALUES(download_url),
|
||||
sha256_hash = VALUES(sha256_hash),
|
||||
git_commit = VALUES(git_commit),
|
||||
size_bytes = VALUES(size_bytes),
|
||||
download_url = VALUES(download_url),
|
||||
sha256_hash = VALUES(sha256_hash),
|
||||
git_commit = VALUES(git_commit),
|
||||
size_bytes = VALUES(size_bytes),
|
||||
manifest_json = VALUES(manifest_json),
|
||||
is_critical = VALUES(is_critical)
|
||||
is_critical = VALUES(is_critical)
|
||||
');
|
||||
|
||||
return $stmt->execute([
|
||||
$stmt->execute([
|
||||
':slug' => $productSlug,
|
||||
':version' => $version,
|
||||
':channel' => $channel,
|
||||
':notes' => $releaseNotes,
|
||||
':url' => $downloadUrl,
|
||||
':hash' => $sha256Hash,
|
||||
':git' => $gitCommit,
|
||||
':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
|
||||
':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
|
||||
':size' => $sizeBytes,
|
||||
':manifest' => $manifestJson,
|
||||
':critical' => $isCritical ? 1 : 0,
|
||||
]);
|
||||
|
||||
$release = $this->findRelease($productSlug, $version, $channel);
|
||||
$releaseId = $release !== null ? (int)$release['id'] : 0;
|
||||
|
||||
// Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind,
|
||||
// schliessen sich mit der Veroeffentlichung selbst.
|
||||
$autoResolved = 0;
|
||||
try {
|
||||
$bugRepo = new BugRepo($this->db);
|
||||
$autoResolved = $bugRepo->resolveByBuild($productSlug, $version, $releaseId, $author);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Auto-Resolve beim Release fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
try {
|
||||
WebhookDispatcher::dispatch($this->db, 'release.published', [
|
||||
'project_slug' => $productSlug,
|
||||
'version' => $version,
|
||||
'channel' => $channel,
|
||||
'is_critical' => $isCritical,
|
||||
'download_url' => $downloadUrl,
|
||||
'auto_resolved' => $autoResolved,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Release-Webhook fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
Logger::info('Release veroeffentlicht', [
|
||||
'product' => $productSlug,
|
||||
'version' => $version,
|
||||
'channel' => $channel,
|
||||
'author' => $author,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $releaseId,
|
||||
'created' => $existing === null,
|
||||
'auto_resolved' => $autoResolved,
|
||||
];
|
||||
}
|
||||
|
||||
public function getReleases(?string $productSlug = null, ?string $channel = null): array
|
||||
public function findRelease(string $productSlug, string $version, string $channel): ?array
|
||||
{
|
||||
if ($productSlug && $channel) {
|
||||
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug AND channel = :channel ORDER BY created_at DESC');
|
||||
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
|
||||
} elseif ($productSlug) {
|
||||
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug ORDER BY created_at DESC');
|
||||
$stmt->execute([':slug' => $productSlug]);
|
||||
} elseif ($channel) {
|
||||
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE channel = :channel ORDER BY created_at DESC');
|
||||
$stmt->execute([':channel' => $channel]);
|
||||
} else {
|
||||
$stmt = $this->db->query('SELECT * FROM updateservice_releases ORDER BY created_at DESC');
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM updateservice_releases
|
||||
WHERE product_slug = :slug AND version = :version AND channel = :channel
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':slug' => $productSlug, ':version' => $version, ':channel' => $channel]);
|
||||
$row = $stmt->fetch();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
public function deleteRelease(int $id): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM updateservice_releases WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases, nach Version absteigend sortiert.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getReleases(?string $productSlug = null, ?string $channel = null, int $limit = 200): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($productSlug !== null && $productSlug !== '') {
|
||||
$where[] = 'product_slug = :slug';
|
||||
$params[':slug'] = $productSlug;
|
||||
}
|
||||
return $stmt->fetchAll() ?: [];
|
||||
if ($channel !== null && $channel !== '') {
|
||||
$where[] = 'channel = :channel';
|
||||
$params[':channel'] = $channel;
|
||||
}
|
||||
|
||||
$sql = 'SELECT * FROM updateservice_releases';
|
||||
if ($where !== []) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
$sql .= ' ORDER BY product_slug ASC, channel ASC, created_at DESC LIMIT ' . max(1, min($limit, 1000));
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$releases = $stmt->fetchAll() ?: [];
|
||||
|
||||
// Innerhalb einer Produkt/Kanal-Gruppe nach echter Versionsordnung sortieren.
|
||||
usort($releases, static function (array $a, array $b): int {
|
||||
$bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']);
|
||||
if ($bySlug !== 0) {
|
||||
return $bySlug;
|
||||
}
|
||||
$byChannel = strcmp((string)$a['channel'], (string)$b['channel']);
|
||||
if ($byChannel !== 0) {
|
||||
return $byChannel;
|
||||
}
|
||||
return Version::compare((string)$b['version'], (string)$a['version']);
|
||||
});
|
||||
|
||||
return $releases;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\UpdateService;
|
||||
|
||||
/**
|
||||
* Semantischer Versionsvergleich.
|
||||
*
|
||||
* Der frueher genutzte SQL-Ausdruck "version > :ver" verglich lexikografisch.
|
||||
* Damit galt '1.9.0' als neuer als '1.10.0' und Clients bekamen ein Downgrade
|
||||
* als Update angeboten. Der .NET-Client verglich bereits korrekt - Server und
|
||||
* Client waren sich also uneinig.
|
||||
*
|
||||
* Unterstuetzt: "1.2.3", "v1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "1.2".
|
||||
*/
|
||||
final class Version
|
||||
{
|
||||
/**
|
||||
* @return int -1 wenn $a < $b, 0 bei Gleichstand, 1 wenn $a > $b
|
||||
*/
|
||||
public static function compare(string $a, string $b): int
|
||||
{
|
||||
[$coreA, $preA] = self::parse($a);
|
||||
[$coreB, $preB] = self::parse($b);
|
||||
|
||||
$length = max(count($coreA), count($coreB));
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$partA = $coreA[$i] ?? 0;
|
||||
$partB = $coreB[$i] ?? 0;
|
||||
if ($partA !== $partB) {
|
||||
return $partA <=> $partB;
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Version ohne Vorabkennung ist hoeher als dieselbe mit
|
||||
// (1.0.0 > 1.0.0-rc.1), so verlangt es die Semver-Spezifikation.
|
||||
if ($preA === [] && $preB === []) {
|
||||
return 0;
|
||||
}
|
||||
if ($preA === []) {
|
||||
return 1;
|
||||
}
|
||||
if ($preB === []) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return self::comparePrerelease($preA, $preB);
|
||||
}
|
||||
|
||||
public static function isNewer(string $candidate, string $current): bool
|
||||
{
|
||||
return self::compare($candidate, $current) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waehlt die hoechste Version aus einer Liste von Release-Datensaetzen.
|
||||
*
|
||||
* @param list<array<string,mixed>> $releases
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function highest(array $releases, string $versionKey = 'version'): ?array
|
||||
{
|
||||
$best = null;
|
||||
foreach ($releases as $release) {
|
||||
if (!isset($release[$versionKey]) || !is_string($release[$versionKey])) {
|
||||
continue;
|
||||
}
|
||||
if ($best === null || self::compare($release[$versionKey], (string)$best[$versionKey]) > 0) {
|
||||
$best = $release;
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Version in numerischen Kern und Vorabkennung.
|
||||
*
|
||||
* @return array{0:list<int>,1:list<string>}
|
||||
*/
|
||||
private static function parse(string $version): array
|
||||
{
|
||||
$version = trim($version);
|
||||
$version = ltrim($version, 'vV');
|
||||
|
||||
// Build-Metadaten sind fuer die Rangfolge irrelevant.
|
||||
$plus = strpos($version, '+');
|
||||
if ($plus !== false) {
|
||||
$version = substr($version, 0, $plus);
|
||||
}
|
||||
|
||||
$prerelease = [];
|
||||
$dash = strpos($version, '-');
|
||||
if ($dash !== false) {
|
||||
$preString = substr($version, $dash + 1);
|
||||
$version = substr($version, 0, $dash);
|
||||
$prerelease = $preString === '' ? [] : explode('.', $preString);
|
||||
}
|
||||
|
||||
$core = [];
|
||||
foreach (explode('.', $version) as $part) {
|
||||
$core[] = (int)preg_replace('/\D/', '', $part);
|
||||
}
|
||||
|
||||
if ($core === []) {
|
||||
$core = [0];
|
||||
}
|
||||
|
||||
return [$core, $prerelease];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $a
|
||||
* @param list<string> $b
|
||||
*/
|
||||
private static function comparePrerelease(array $a, array $b): int
|
||||
{
|
||||
$length = max(count($a), count($b));
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
// Weniger Bestandteile = niedrigere Rangfolge (rc < rc.1)
|
||||
if (!isset($a[$i])) {
|
||||
return -1;
|
||||
}
|
||||
if (!isset($b[$i])) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$partA = $a[$i];
|
||||
$partB = $b[$i];
|
||||
|
||||
$numericA = ctype_digit($partA);
|
||||
$numericB = ctype_digit($partB);
|
||||
|
||||
if ($numericA && $numericB) {
|
||||
$cmp = (int)$partA <=> (int)$partB;
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rein numerische Bestandteile rangieren unter alphanumerischen.
|
||||
if ($numericA !== $numericB) {
|
||||
return $numericA ? -1 : 1;
|
||||
}
|
||||
|
||||
$cmp = strcmp($partA, $partB);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp > 0 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Watchdog;
|
||||
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Watchdog-Evaluator.
|
||||
*
|
||||
* Diese Komponente fehlte bislang vollstaendig. Der Zustand eines Monitors
|
||||
* aenderte sich ausschliesslich beim Eintreffen eines Heartbeats - ein
|
||||
* ausgefallener Server blieb im Dashboard damit fuer immer gruen. Die Spalten
|
||||
* expected_interval_sec, is_muted, suppress_until_utc und expect_running waren
|
||||
* angelegt, wurden aber von keiner Zeile Code ausgewertet.
|
||||
*
|
||||
* Der Evaluator vergleicht last_seen_utc mit dem erwarteten Intervall und
|
||||
* stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen
|
||||
* im Event-Log und loesen Webhooks aus.
|
||||
*
|
||||
* Aufruf per Cron (empfohlen minuetlich):
|
||||
* curl -H "Authorization: Bearer <SHARED_KEY>" https://dc.example.com/api/watchdog/v1/evaluate
|
||||
*/
|
||||
final class Evaluator
|
||||
{
|
||||
/** Ab dem Wievielfachen des Intervalls gilt ein Monitor als auffaellig. */
|
||||
private const WARNING_FACTOR = 2.0;
|
||||
|
||||
/** Ab dem Wievielfachen des Intervalls gilt ein Monitor als ausgefallen. */
|
||||
private const DOWN_FACTOR = 4.0;
|
||||
|
||||
/** Kulanz fuer neu angelegte Monitore, die noch nie gemeldet haben. */
|
||||
private const FIRST_CONTACT_GRACE = 3;
|
||||
|
||||
/**
|
||||
* Fuehrt einen Evaluationslauf durch.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function run(PDO $db): array
|
||||
{
|
||||
$started = microtime(true);
|
||||
|
||||
$monitorRepo = new MonitorRepo($db);
|
||||
$eventLog = new EventLog($db);
|
||||
|
||||
$candidates = $monitorRepo->getEvaluationCandidates();
|
||||
|
||||
$changes = [];
|
||||
$checked = 0;
|
||||
|
||||
foreach ($candidates as $monitor) {
|
||||
$checked++;
|
||||
|
||||
$current = (string)$monitor['state'];
|
||||
$target = self::desiredState($monitor);
|
||||
|
||||
if ($target === null || $target === $current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reason = self::reasonFor($monitor, $target);
|
||||
$monitorRepo->setState((int)$monitor['id'], $target, $reason);
|
||||
|
||||
$eventLog->logEvent(
|
||||
(string)$monitor['source'],
|
||||
(string)$monitor['instance'],
|
||||
self::eventKindFor($current, $target),
|
||||
$current,
|
||||
$target,
|
||||
$target === 'down' ? 'alarm' : ($target === 'warning' ? 'warning' : 'info'),
|
||||
$reason
|
||||
);
|
||||
|
||||
$changes[] = [
|
||||
'source' => $monitor['source'],
|
||||
'from' => $current,
|
||||
'to' => $target,
|
||||
'reason' => $reason,
|
||||
];
|
||||
|
||||
// Stummgeschaltete Monitore erscheinen im Dashboard, loesen aber
|
||||
// keine Benachrichtigung aus.
|
||||
if (empty($monitor['is_muted'])) {
|
||||
self::notify($db, $monitor, $current, $target, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Abgelaufene Bugtracker-Leases freigeben, damit haengengebliebene
|
||||
// Agenten kein Item dauerhaft blockieren.
|
||||
$releasedLeases = 0;
|
||||
try {
|
||||
$releasedLeases = (new BugRepo($db))->expireStaleLeases();
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Lease-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$durationMs = (int)round((microtime(true) - $started) * 1000);
|
||||
self::recordRun($db, $durationMs, count($changes));
|
||||
|
||||
if ($changes !== []) {
|
||||
Logger::info('Watchdog-Evaluator: Zustandswechsel', ['changes' => $changes]);
|
||||
}
|
||||
|
||||
return [
|
||||
'checked' => $checked,
|
||||
'changed' => count($changes),
|
||||
'changes' => $changes,
|
||||
'released_leases' => $releasedLeases,
|
||||
'duration_ms' => $durationMs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Zustand, den ein Monitor haben sollte.
|
||||
* null bedeutet: keine Aenderung noetig.
|
||||
*
|
||||
* @param array<string,mixed> $monitor
|
||||
*/
|
||||
private static function desiredState(array $monitor): ?string
|
||||
{
|
||||
// Bewusst gestoppte Dienste werden nicht als Ausfall gewertet.
|
||||
if ((int)($monitor['expect_running'] ?? 1) === 0) {
|
||||
return null;
|
||||
}
|
||||
if ((string)$monitor['state'] === 'stopped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$interval = max(10, (int)($monitor['expected_interval_sec'] ?? 60));
|
||||
$lastSeen = $monitor['last_seen_utc'] ?? null;
|
||||
|
||||
if ($lastSeen === null || $lastSeen === '') {
|
||||
// Noch nie ein Heartbeat: erst nach einer Kulanzfrist als down werten.
|
||||
$created = $monitor['created_utc'] ?? null;
|
||||
$createdTs = is_string($created) ? strtotime($created . ' UTC') : false;
|
||||
|
||||
if ($createdTs === false) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$age = time() - $createdTs;
|
||||
return $age > ($interval * self::FIRST_CONTACT_GRACE) ? 'down' : 'unknown';
|
||||
}
|
||||
|
||||
$lastSeenTs = strtotime((string)$lastSeen . ' UTC');
|
||||
if ($lastSeenTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$age = time() - $lastSeenTs;
|
||||
|
||||
if ($age > $interval * self::DOWN_FACTOR) {
|
||||
return 'down';
|
||||
}
|
||||
if ($age > $interval * self::WARNING_FACTOR) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
// Innerhalb des Intervalls: der Heartbeat selbst bestimmt den Zustand.
|
||||
// Ein zuvor als down/warning markierter Monitor, der wieder meldet,
|
||||
// wird bereits durch upsertHeartbeat() auf up gesetzt.
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $monitor */
|
||||
private static function reasonFor(array $monitor, string $target): string
|
||||
{
|
||||
$interval = max(10, (int)($monitor['expected_interval_sec'] ?? 60));
|
||||
$lastSeen = $monitor['last_seen_utc'] ?? null;
|
||||
|
||||
if ($lastSeen === null || $lastSeen === '') {
|
||||
return sprintf('Seit Anlage kein Heartbeat empfangen (erwartet alle %ds).', $interval);
|
||||
}
|
||||
|
||||
$lastSeenTs = strtotime((string)$lastSeen . ' UTC');
|
||||
$age = $lastSeenTs !== false ? time() - $lastSeenTs : 0;
|
||||
|
||||
return sprintf(
|
||||
'Letzter Heartbeat vor %s (erwartet alle %ds) -> %s.',
|
||||
self::humanDuration($age),
|
||||
$interval,
|
||||
$target
|
||||
);
|
||||
}
|
||||
|
||||
private static function eventKindFor(string $from, string $to): string
|
||||
{
|
||||
if ($to === 'down') {
|
||||
return 'crash_suspected';
|
||||
}
|
||||
if ($to === 'warning') {
|
||||
return 'warning_raised';
|
||||
}
|
||||
if ($from === 'down' || $from === 'warning') {
|
||||
return 'recovered';
|
||||
}
|
||||
return 'warning_cleared';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $monitor */
|
||||
private static function notify(PDO $db, array $monitor, string $from, string $to, string $reason): void
|
||||
{
|
||||
$event = $to === 'down' ? 'monitor.down' : ($to === 'up' ? 'monitor.recovered' : null);
|
||||
if ($event === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
WebhookDispatcher::dispatch($db, $event, [
|
||||
'source' => $monitor['source'],
|
||||
'instance' => $monitor['instance'],
|
||||
'group' => $monitor['group_key'] ?? null,
|
||||
'from_state' => $from,
|
||||
'to_state' => $to,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Monitor-Webhook fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function recordRun(PDO $db, int $durationMs, int $changes): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, running, last_status, last_duration_ms, enabled)
|
||||
VALUES ("evaluator", 60, UTC_TIMESTAMP(), 0, :status, :duration, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_run_utc = UTC_TIMESTAMP(),
|
||||
running = 0,
|
||||
last_status = VALUES(last_status),
|
||||
last_duration_ms = VALUES(last_duration_ms)
|
||||
');
|
||||
$stmt->execute([
|
||||
':status' => $changes > 0 ? 'ok (' . $changes . ' Wechsel)' : 'ok',
|
||||
':duration' => $durationMs,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Evaluator-Lauf nicht protokolliert', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function humanDuration(int $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return $seconds . ' s';
|
||||
}
|
||||
if ($seconds < 3600) {
|
||||
return intdiv($seconds, 60) . ' min';
|
||||
}
|
||||
if ($seconds < 86400) {
|
||||
return intdiv($seconds, 3600) . ' h';
|
||||
}
|
||||
return intdiv($seconds, 86400) . ' Tage';
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Watchdog;
|
||||
|
||||
use PDO;
|
||||
|
||||
class EventLog
|
||||
/**
|
||||
* Chronologisches Ereignisprotokoll des Watchdog-Moduls.
|
||||
*/
|
||||
final class EventLog
|
||||
{
|
||||
public const KINDS = [
|
||||
'started', 'stopped_graceful', 'crash_suspected', 'hard_error',
|
||||
'recovered', 'warning_raised', 'warning_cleared',
|
||||
'maintenance_start', 'maintenance_end', 'watchdog_started',
|
||||
];
|
||||
|
||||
public const SEVERITIES = ['info', 'warning', 'alarm'];
|
||||
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
@@ -23,25 +36,25 @@ class EventLog
|
||||
?string $message = null,
|
||||
$meta = null
|
||||
): int {
|
||||
$metaJson = is_array($meta) || is_object($meta) ? json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
|
||||
$nowUtc = date('Y-m-d H:i:s');
|
||||
$metaJson = (is_array($meta) || is_object($meta))
|
||||
? json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
: null;
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO watchdog_event_log (
|
||||
source, instance, kind, from_state, to_state, severity, at_utc, message, meta_json
|
||||
) VALUES (
|
||||
:source, :instance, :kind, :from_state, :to_state, :severity, :now, :message, :meta
|
||||
:source, :instance, :kind, :from_state, :to_state, :severity, UTC_TIMESTAMP(), :message, :meta
|
||||
)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
':source' => $source,
|
||||
':instance' => $instance,
|
||||
':kind' => $kind,
|
||||
':source' => mb_substr($source, 0, 100),
|
||||
':instance' => mb_substr($instance, 0, 100),
|
||||
':kind' => in_array($kind, self::KINDS, true) ? $kind : 'hard_error',
|
||||
':from_state' => $fromState,
|
||||
':to_state' => $toState,
|
||||
':severity' => $severity,
|
||||
':now' => $nowUtc,
|
||||
':severity' => in_array($severity, self::SEVERITIES, true) ? $severity : 'info',
|
||||
':message' => $message,
|
||||
':meta' => $metaJson,
|
||||
]);
|
||||
@@ -49,29 +62,49 @@ class EventLog
|
||||
return (int)$this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function getRecentEvents(int $limit = 50, ?string $source = null, ?string $instance = null): array
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getRecentEvents(int $limit = 50, ?string $source = null, ?string $instance = null, ?string $severity = null): array
|
||||
{
|
||||
$sql = 'SELECT * FROM watchdog_event_log';
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($source !== null) {
|
||||
if ($source !== null && $source !== '') {
|
||||
$where[] = 'source = :source';
|
||||
$params[':source'] = $source;
|
||||
}
|
||||
if ($instance !== null) {
|
||||
if ($instance !== null && $instance !== '') {
|
||||
$where[] = 'instance = :instance';
|
||||
$params[':instance'] = $instance;
|
||||
}
|
||||
if ($severity !== null && in_array($severity, self::SEVERITIES, true)) {
|
||||
$where[] = 'severity = :severity';
|
||||
$params[':severity'] = $severity;
|
||||
}
|
||||
|
||||
if (!empty($where)) {
|
||||
$sql = 'SELECT * FROM watchdog_event_log';
|
||||
if ($where !== []) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY at_utc DESC LIMIT ' . (int)$limit;
|
||||
// Limit wird als Integer interpoliert; der Wert ist durch max/min begrenzt.
|
||||
$sql .= ' ORDER BY at_utc DESC, id DESC LIMIT ' . max(1, min($limit, 1000));
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/** Loescht Eintraege, die aelter als die angegebene Anzahl Tage sind. */
|
||||
public function purgeOlderThan(int $days): int
|
||||
{
|
||||
$days = max(1, min($days, 3650));
|
||||
$stmt = $this->db->prepare(
|
||||
'DELETE FROM watchdog_event_log WHERE at_utc < (UTC_TIMESTAMP() - INTERVAL ' . $days . ' DAY)'
|
||||
);
|
||||
$stmt->execute();
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Modules\Watchdog;
|
||||
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use PDO;
|
||||
|
||||
class TokenManager
|
||||
/**
|
||||
* Alt-Tokens des Watchdog-Moduls (Tabelle watchdog_agent_tokens).
|
||||
*
|
||||
* Diese Tabelle existiert parallel zur zentralen dc_tokens-Hierarchie. Neue
|
||||
* Integrationen sollten Master-/Sub-Tokens mit dem Scope "watchdog:ping"
|
||||
* verwenden; der Watchdog-Endpunkt akzeptiert beide. Diese Klasse bleibt
|
||||
* bestehen, damit bereits ausgerollte Agenten weiterlaufen.
|
||||
*
|
||||
* Die Validierung vergleicht nur noch den SHA-256-Hash, nicht mehr zusaetzlich
|
||||
* den Klartext.
|
||||
*/
|
||||
final class TokenManager
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
@@ -13,57 +27,77 @@ class TokenManager
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public function createToken(string $source, ?string $name = null, string $notes = ''): array
|
||||
public function createToken(string $source, ?string $name = null): array
|
||||
{
|
||||
$tokenId = 'tok_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'wd_live_' . bin2hex(random_bytes(18));
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
|
||||
$tokenName = !empty($name) ? $name : ("Token for " . ($source ?: 'General'));
|
||||
$tokenId = 'tok_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'wd_live_' . bin2hex(random_bytes(24));
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO watchdog_agent_tokens (
|
||||
token_id, token_hash, raw_token, name, monitor_source, created_at_utc
|
||||
) VALUES (
|
||||
:id, :hash, :raw, :name, :source, NOW()
|
||||
:id, :hash, :raw, :name, :source, UTC_TIMESTAMP()
|
||||
)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
':id' => $tokenId,
|
||||
':hash' => $tokenHash,
|
||||
':hash' => hash('sha256', $rawToken),
|
||||
':raw' => $rawToken,
|
||||
':name' => $tokenName,
|
||||
':source' => !empty($source) ? $source : null,
|
||||
':name' => $name !== null && trim($name) !== '' ? trim($name) : ('Token fuer ' . ($source !== '' ? $source : 'Allgemein')),
|
||||
':source' => $source !== '' ? $source : null,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token_id' => $tokenId,
|
||||
'raw_token' => $rawToken,
|
||||
];
|
||||
return ['token_id' => $tokenId, 'raw_token' => $rawToken];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft ein Alt-Token. Ist es an eine Source gebunden, darf es nur
|
||||
* fuer genau diese verwendet werden.
|
||||
*/
|
||||
public function validateToken(string $rawToken, string $targetSource): bool
|
||||
{
|
||||
$hash = hash('sha256', $rawToken);
|
||||
$stmt = $this->db->prepare('SELECT * FROM watchdog_agent_tokens WHERE (token_hash = :hash OR raw_token = :raw) AND revoked = 0');
|
||||
$stmt->execute([':hash' => $hash, ':raw' => $rawToken]);
|
||||
$rawToken = trim($rawToken);
|
||||
if ($rawToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT token_id, monitor_source
|
||||
FROM watchdog_agent_tokens
|
||||
WHERE token_hash = :hash AND revoked = 0
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':hash' => hash('sha256', $rawToken)]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) {
|
||||
if (!is_array($row)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($row['monitor_source']) && $row['monitor_source'] !== $targetSource) {
|
||||
$boundSource = $row['monitor_source'] ?? null;
|
||||
if (is_string($boundSource) && $boundSource !== '' && $boundSource !== $targetSource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$upd = $this->db->prepare('UPDATE watchdog_agent_tokens SET last_used_at_utc = NOW() WHERE token_id = :id');
|
||||
$upd->execute([':id' => $row['token_id']]);
|
||||
try {
|
||||
$upd = $this->db->prepare('UPDATE watchdog_agent_tokens SET last_used_at_utc = UTC_TIMESTAMP() WHERE token_id = :id');
|
||||
$upd->execute([':id' => $row['token_id']]);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('last_used_at_utc nicht aktualisiert', ['token_id' => $row['token_id']]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function revokeToken(string $tokenId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE token_id = :id');
|
||||
$stmt->execute([':id' => $tokenId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function getAllTokens(): array
|
||||
{
|
||||
$stmt = $this->db->query('SELECT * FROM watchdog_agent_tokens ORDER BY created_at_utc DESC');
|
||||
|
||||
Reference in New Issue
Block a user