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
+10
View File
@@ -0,0 +1,10 @@
# Kein Direktzugriff auf Anwendungscode.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
/**
* Einheitliche Authentifizierung fuer alle API-Endpunkte.
*
* Drei akzeptierte Wege, in dieser Reihenfolge geprueft:
* 1. Shared Key aus der Konfiguration (Server-zu-Server, z. B. Cron)
* 2. Aktive WebUI-Session (nur wenn ausdruecklich erlaubt)
* 3. Master-/Sub-Token aus dc_tokens mit passendem Scope
*
* Frueher hatte jeder Endpunkt seine eigene Variante davon - mit jeweils
* leicht abweichendem Verhalten. Diese Klasse ersetzt alle.
*/
final class ApiAuth
{
/**
* Erzwingt Authentifizierung mit einem bestimmten Scope.
* Bricht die Anfrage bei Fehlschlag mit 401 ab.
*
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}
*/
public static function requireScope(
PDO $db,
string $scope,
?string $environment = null,
bool $allowSession = true
): array {
$context = self::resolve($db, $scope, $environment, $allowSession);
if ($context === null) {
Http::fail(
401,
'unauthorized',
sprintf('Authentifizierung erforderlich. Erwartet wird ein Token mit dem Recht "%s".', $scope),
null,
['required_scope' => $scope]
);
}
return $context;
}
/**
* Wie requireScope(), bricht aber nicht ab, sondern liefert null.
*
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}|null
*/
public static function resolve(
PDO $db,
string $scope,
?string $environment = null,
bool $allowSession = true
): ?array {
$presented = Http::bearerToken();
// 1. Shared Key
$sharedKey = (string)Config::get('security.shared_key', '');
if ($presented !== null && $sharedKey !== '' && hash_equals($sharedKey, $presented)) {
return [
'method' => 'shared_key',
'actor' => 'system:shared-key',
'token' => null,
'project_slug' => null,
'environment' => null,
];
}
// 2. WebUI-Session
if ($allowSession && Auth::isLoggedIn()) {
return [
'method' => 'session',
'actor' => Auth::username(),
'token' => null,
'project_slug' => null,
'environment' => null,
];
}
// 3. Agenten-Token
if ($presented !== null) {
$manager = new TokenManager($db);
$token = $manager->validateToken($presented, $scope, $environment);
if (is_array($token)) {
$name = isset($token['name']) && $token['name'] !== ''
? (string)$token['name']
: (string)$token['token_id'];
return [
'method' => 'token',
'actor' => 'agent:' . $name,
'token' => $token,
'project_slug' => self::stringOrNull($token['project_slug'] ?? null),
'environment' => self::stringOrNull($token['environment'] ?? null),
];
}
Logger::warning('Token abgelehnt', [
'scope' => $scope,
'ip' => Http::clientIp(),
'path' => Http::path(),
]);
}
return null;
}
/**
* Stellt sicher, dass ein projektgebundenes Token nur auf sein eigenes
* Projekt zugreift. Bricht sonst mit 403 ab.
*
* @param array{project_slug:?string,method:string} $context
*/
public static function enforceProject(array $context, ?string $requestedSlug): void
{
$bound = $context['project_slug'] ?? null;
if ($bound === null || $bound === '') {
return; // Token ist nicht projektgebunden
}
if ($requestedSlug === null || $requestedSlug === '' || $requestedSlug === $bound) {
return;
}
Http::fail(
403,
'project_forbidden',
sprintf('Dieses Token ist an das Projekt "%s" gebunden und darf nicht auf "%s" zugreifen.', $bound, $requestedSlug),
null,
['bound_project' => $bound]
);
}
/**
* Liefert den Projekt-Slug, auf den eine Anfrage eingeschraenkt werden muss,
* oder null bei uneingeschraenktem Zugriff.
*
* @param array{project_slug:?string} $context
*/
public static function projectFilter(array $context): ?string
{
$bound = $context['project_slug'] ?? null;
return is_string($bound) && $bound !== '' ? $bound : null;
}
private static function stringOrNull($value): ?string
{
if (!is_string($value)) {
return null;
}
return $value === '' ? null : $value;
}
}
+217 -19
View File
@@ -1,18 +1,55 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
class Auth
/**
* Session- und Anmeldeverwaltung fuer das WebUI.
*
* Haerteung gegenueber der Erstfassung:
* - Session-Cookie mit HttpOnly, SameSite=Lax und Secure (bei HTTPS)
* - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation)
* - Leerlauf- und Absolut-Timeout
* - Anmeldeversuche werden gezaehlt und pro IP gedrosselt
*/
final class Auth
{
/** Leerlauf-Timeout in Sekunden (2 Stunden). */
private const IDLE_TIMEOUT = 7200;
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
private const ABSOLUTE_TIMEOUT = 43200;
/** Fehlversuche pro IP, bevor gesperrt wird. */
private const MAX_ATTEMPTS = 10;
/** Sperrfenster in Sekunden. */
private const LOCKOUT_WINDOW = 900;
public static function startSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
$config = require __DIR__ . '/../../config/config.php';
session_name($config['security']['session_name'] ?? 'DC_SESSION_ID');
session_start();
if (session_status() !== PHP_SESSION_NONE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_name((string)Config::get('security.session_name', 'DC_SESSION_ID'));
session_start();
self::enforceTimeouts();
}
public static function isLoggedIn(): bool
@@ -23,39 +60,200 @@ class Auth
public static function requireLogin(): void
{
if (!self::isLoggedIn()) {
header('Location: /login.php');
exit;
if (self::isLoggedIn()) {
return;
}
if (!headers_sent()) {
header('Location: /login.php');
}
exit;
}
public static function username(): string
{
self::startSession();
$name = $_SESSION['dc_username'] ?? null;
return is_string($name) && $name !== '' ? $name : 'admin';
}
public static function userId(): int
{
self::startSession();
return (int)($_SESSION['dc_user_id'] ?? 0);
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
*/
public static function login(PDO $db, string $username, string $password): bool
{
self::startSession();
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u');
$ip = Http::clientIp();
if (self::isLockedOut($db, $ip)) {
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
return false;
}
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['dc_user_id'] = $user['id'];
$_SESSION['dc_username'] = $user['username'];
return true;
$found = is_array($user) && isset($user['password_hash']);
// Auch ohne Treffer wird ein Hash berechnet, damit die Antwortzeit
// keinen Rueckschluss auf die Existenz des Kontos erlaubt.
$hash = $found ? (string)$user['password_hash'] : self::dummyHash();
$verified = password_verify($password, $hash);
if (!$verified || !$found) {
self::recordAttempt($db, $ip, $username, false);
return false;
}
return false;
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
}
session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id'];
$_SESSION['dc_username'] = (string)$user['username'];
$_SESSION['dc_login_at'] = time();
$_SESSION['dc_last_seen'] = time();
self::recordAttempt($db, $ip, $username, true);
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]);
return true;
}
public static function logout(): void
{
self::startSession();
$_SESSION = [];
if (ini_get("session.use_cookies")) {
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => 'Lax',
]);
}
session_destroy();
}
/**
* Verbleibende Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
*/
public static function lockoutSeconds(PDO $db, string $ip): int
{
try {
$stmt = $db->prepare('
SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt
FROM dc_login_attempts
WHERE ip = :ip
AND success = 0
AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . self::LOCKOUT_WINDOW . ' SECOND)
');
$stmt->execute([':ip' => self::packIp($ip)]);
$row = $stmt->fetch();
if (!is_array($row) || (int)$row['failures'] < self::MAX_ATTEMPTS) {
return 0;
}
$last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false;
if ($last === false) {
return self::LOCKOUT_WINDOW;
}
$remaining = self::LOCKOUT_WINDOW - (time() - $last);
return $remaining > 0 ? $remaining : 0;
} catch (\Throwable $e) {
// Fehlt die Tabelle (Migration noch nicht gelaufen), darf die
// Anmeldung nicht blockiert werden.
Logger::warning('Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
return 0;
}
}
// ------------------------------------------------------------------
private static function isLockedOut(PDO $db, string $ip): bool
{
return self::lockoutSeconds($db, $ip) > 0;
}
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
{
try {
$stmt = $db->prepare('
INSERT INTO dc_login_attempts (ip, username, success, attempted_at)
VALUES (:ip, :username, :success, UTC_TIMESTAMP())
');
$stmt->execute([
':ip' => self::packIp($ip),
':username' => mb_substr($username, 0, 64),
':success' => $success ? 1 : 0,
]);
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen.
if ($success) {
$del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0');
$del->execute([':ip' => self::packIp($ip)]);
}
} catch (\Throwable $e) {
Logger::warning('Anmeldeversuch nicht protokolliert', ['error' => $e->getMessage()]);
}
}
/** Gueltiger Hash gegen einen Zufallswert, nur fuer Timing-Angleichung. */
private static function dummyHash(): string
{
static $hash = null;
if ($hash === null) {
$hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT);
}
return $hash;
}
private static function packIp(string $ip): string
{
$packed = @inet_pton($ip);
return $packed === false ? str_repeat("\0", 16) : $packed;
}
private static function enforceTimeouts(): void
{
if (empty($_SESSION['dc_user_id'])) {
return;
}
$now = time();
$loginAt = (int)($_SESSION['dc_login_at'] ?? $now);
$lastSeen = (int)($_SESSION['dc_last_seen'] ?? $now);
$expired = ($now - $lastSeen) > self::IDLE_TIMEOUT
|| ($now - $loginAt) > self::ABSOLUTE_TIMEOUT;
if ($expired) {
$_SESSION = [];
session_destroy();
session_start();
return;
}
$_SESSION['dc_last_seen'] = $now;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Zugriff auf die geladene Konfiguration ueber Punktpfade, z. B. Config::get('db.host').
*/
final class Config
{
/** @var array<string,mixed> */
private static array $data = [];
/** @param array<string,mixed> $data */
public static function load(array $data): void
{
self::$data = $data;
}
/** @return array<string,mixed> */
public static function all(): array
{
return self::$data;
}
/**
* @param string $path Punktseparierter Pfad, z. B. "security.shared_key"
* @param mixed $default
* @return mixed
*/
public static function get(string $path, $default = null)
{
$current = self::$data;
foreach (explode('.', $path) as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return $default;
}
$current = $current[$segment];
}
return $current;
}
/** Liefert die Datenbank-Zugangsdaten als Array. */
public static function db(): array
{
$db = self::get('db', []);
return is_array($db) ? $db : [];
}
public static function isDebug(): bool
{
return (bool)self::get('app.debug', false);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* CSRF-Schutz fuer alle zustandsaendernden Formulare im WebUI.
*
* Das Token haengt an der Session und ist fuer deren Lebensdauer stabil.
* API-Endpunkte, die per Bearer-Token authentifizieren, brauchen keinen
* CSRF-Schutz - dort gibt es keinen Cookie, der automatisch mitgeschickt wird.
*/
final class Csrf
{
private const SESSION_KEY = 'dc_csrf_token';
public static function token(): string
{
Auth::startSession();
if (empty($_SESSION[self::SESSION_KEY]) || !is_string($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
return $_SESSION[self::SESSION_KEY];
}
/** Fertiges verstecktes Formularfeld. */
public static function field(): string
{
return '<input type="hidden" name="csrf_token" value="'
. htmlspecialchars(self::token(), ENT_QUOTES, 'UTF-8') . '">';
}
/** Prueft ein uebergebenes Token gegen die Session. */
public static function isValid(?string $candidate): bool
{
Auth::startSession();
$expected = $_SESSION[self::SESSION_KEY] ?? null;
if (!is_string($expected) || $expected === '' || !is_string($candidate) || $candidate === '') {
return false;
}
return hash_equals($expected, $candidate);
}
/**
* Erzwingt ein gueltiges CSRF-Token bei POST-Anfragen.
* Bricht die Anfrage mit 419 ab, wenn es fehlt oder falsch ist.
*/
public static function requireValid(): void
{
if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
return;
}
$candidate = $_POST['csrf_token'] ?? Http::header('x-csrf-token');
if (is_array($candidate)) {
$candidate = null;
}
if (!self::isValid(is_string($candidate) ? $candidate : null)) {
Logger::warning('CSRF-Pruefung fehlgeschlagen', [
'ip' => Http::clientIp(),
'path' => Http::path(),
]);
Http::fail(419, 'csrf_invalid', 'Sicherheits-Token abgelaufen oder ungueltig. Bitte Seite neu laden.');
}
}
}
+90 -20
View File
@@ -1,46 +1,116 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
use PDOException;
use RuntimeException;
class Db
/**
* Zentrale PDO-Verbindung.
*
* Die Session-Zeitzone wird fest auf UTC gesetzt. Damit liefert NOW() echte
* UTC-Werte - passend zu den Spalten, die auf _utc enden - und alle
* Zeitvergleiche (Watchdog-Evaluator, Lease-Ablauf) rechnen auf derselben
* Basis. Die Darstellung im WebUI erfolgt in der App-Zeitzone.
*/
final class Db
{
private static ?PDO $instance = null;
public static function init(array $config): PDO
public static function init(array $config = []): PDO
{
if (self::$instance === null) {
$dbCfg = isset($config['db']) ? $config['db'] : $config;
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s', $dbCfg['host'], $dbCfg['dbname'], $dbCfg['charset'] ?? 'utf8mb4');
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
if (self::$instance !== null) {
return self::$instance;
}
try {
self::$instance = new PDO($dsn, $dbCfg['username'], $dbCfg['password'], $options);
} catch (PDOException $e) {
throw new \Exception('Database connection failed: ' . $e->getMessage());
if ($config === []) {
$dbConfig = Config::db();
} elseif (isset($config['db']) && is_array($config['db'])) {
$dbConfig = $config['db'];
} else {
$dbConfig = $config;
}
foreach (['host', 'dbname', 'username'] as $required) {
if (!isset($dbConfig[$required]) || $dbConfig[$required] === '') {
throw new RuntimeException('Datenbank-Konfiguration unvollstaendig: ' . $required . ' fehlt.');
}
}
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['dbname'],
$dbConfig['charset'] ?? 'utf8mb4'
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
];
try {
$pdo = new PDO($dsn, (string)$dbConfig['username'], (string)($dbConfig['password'] ?? ''), $options);
$pdo->exec("SET time_zone = '+00:00'");
} catch (PDOException $e) {
// Die Originalmeldung kann Host und Benutzername enthalten und
// gehoert deshalb ins Log, nicht in die Exception-Kette nach aussen.
Logger::error('Datenbankverbindung fehlgeschlagen', ['error' => $e->getMessage()]);
throw new RuntimeException('Datenbankverbindung fehlgeschlagen.', 0, $e);
}
self::$instance = $pdo;
return self::$instance;
}
public static function connect(array $config): PDO
/** Alias fuer init(); historisch an mehreren Stellen verwendet. */
public static function connect(array $config = []): PDO
{
return self::init($config);
}
public static function getInstance(): PDO
{
if (self::$instance === null) {
$config = require __DIR__ . '/../../config/config.php';
return self::init($config);
return self::$instance ?? self::init();
}
/**
* Fuehrt einen Callback in einer Transaktion aus.
* Verschachtelte Aufrufe laufen in der bereits offenen Transaktion mit.
*
* @template T
* @param callable(PDO):T $callback
* @return T
*/
public static function transaction(callable $callback)
{
$pdo = self::getInstance();
if ($pdo->inTransaction()) {
return $callback($pdo);
}
return self::$instance;
$pdo->beginTransaction();
try {
$result = $callback($pdo);
$pdo->commit();
return $result;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/** Aktueller UTC-Zeitstempel im MySQL-DATETIME-Format. */
public static function nowUtc(): string
{
return gmdate('Y-m-d H:i:s');
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
/**
* Meldet unbehandelte Fehler des Deploymentcenters in seinen eigenen Bugtracker.
*
* Damit taucht ein 500er kuenftig selbst im Dashboard auf, statt gesucht werden
* zu muessen. Der Reporter ist bewusst extrem defensiv: schlaegt er fehl,
* darf das die urspruengliche Fehlerbehandlung nicht stoeren.
*/
final class ErrorReporter
{
private static bool $reported = false;
public static function report(\Throwable $e): void
{
self::submit(
self::titleFor($e->getMessage(), basename($e->getFile()), $e->getLine()),
$e->getMessage(),
$e->getFile() . ':' . $e->getLine() . "\n" . $e->getTraceAsString(),
get_class($e)
);
}
/** @param array{type:int,message:string,file:string,line:int} $err */
public static function reportFatal(array $err): void
{
self::submit(
self::titleFor($err['message'], basename($err['file']), $err['line']),
$err['message'],
$err['file'] . ':' . $err['line'],
'FatalError'
);
}
private static function submit(string $title, string $message, string $trace, string $class): void
{
// Nur ein Report pro Request, und niemals rekursiv.
if (self::$reported) {
return;
}
self::$reported = true;
try {
$slug = (string)Config::get('bugtracker.self_project', 'deploymentcenter');
if ($slug === '') {
return;
}
$db = Db::getInstance();
$repo = new BugRepo($db);
$repo->reportItem([
'project_slug' => $slug,
'type' => 'bug',
'title' => $title,
'description' => sprintf(
"Automatisch erfasster Laufzeitfehler.\n\nPfad: %s\nMethode: %s\nException: %s",
Http::path(),
Http::method(),
$class
),
'error_message' => $message,
'stack_trace' => $trace,
'environment' => Config::isDebug() ? 'development' : 'production',
'severity' => 'high',
'build_version' => (string)Config::get('app.version', 'unknown'),
'created_by' => 'system:self-report',
'tags' => 'selfreport,runtime',
]);
} catch (\Throwable $ignored) {
// Selbstmeldung ist bestenfalls hilfreich, niemals kritisch.
@error_log('ErrorReporter fehlgeschlagen: ' . $ignored->getMessage());
}
}
private static function titleFor(string $message, string $file, int $line): string
{
$short = trim(preg_replace('/\s+/', ' ', $message) ?? $message);
if (mb_strlen($short) > 150) {
$short = mb_substr($short, 0, 147) . '...';
}
return sprintf('%s (%s:%d)', $short, $file, $line);
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Einheitliche HTTP-Ein- und Ausgabe fuer alle API-Endpunkte.
*
* Alle Antworten folgen der Form:
* Erfolg: { "status": "success", ...Daten }
* Fehler: { "status": "error", "error": { "code": "...", "message": "..." } }
*
* Der Fehlercode ist stabil und maschinenlesbar - Agenten sollen darauf
* reagieren, nicht auf den Klartext der Nachricht.
*/
final class Http
{
private static bool $jsonMode = false;
private static ?array $headerCache = null;
private static ?array $bodyCache = null;
// ------------------------------------------------------------------
// Ausgabe
// ------------------------------------------------------------------
/**
* Startet eine JSON-Antwort und setzt Sicherheits- sowie CORS-Header.
*
* @param string[] $methods Erlaubte HTTP-Methoden fuer CORS.
* @param bool $publicCors true = beliebige Herkunft (nur fuer reine
* Token-Endpunkte ohne Cookie-Auth).
*/
public static function beginJson(array $methods = ['GET', 'POST', 'OPTIONS'], bool $publicCors = false): void
{
self::$jsonMode = true;
if (headers_sent()) {
return;
}
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
if ($publicCors) {
// Bewusst ohne Access-Control-Allow-Credentials: diese Endpunkte
// authentifizieren ausschliesslich per Token-Header, niemals per Cookie.
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token, X-Master-Token, Idempotency-Key');
header('Access-Control-Allow-Methods: ' . implode(', ', $methods));
header('Access-Control-Max-Age: 600');
}
if (self::method() === 'OPTIONS') {
http_response_code(204);
exit;
}
}
/** Sendet eine Erfolgsantwort und beendet die Anfrage. */
public static function ok(array $data = [], int $status = 200): void
{
self::send(array_merge(['status' => 'success'], $data), $status);
}
/**
* Sendet eine Fehlerantwort und beendet die Anfrage.
*
* Der Exception-Text wird nur bei app.debug = true ausgeliefert;
* andernfalls landet er ausschliesslich im Log.
*/
public static function fail(
int $status,
string $code,
string $message,
?\Throwable $e = null,
array $extra = []
): void {
if ($e !== null) {
Logger::exception($e);
}
$error = ['code' => $code, 'message' => $message];
if ($e !== null && Config::isDebug()) {
$error['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
if ($extra !== []) {
$error = array_merge($error, $extra);
}
if (!self::$jsonMode) {
self::sendHtmlError($status, $message);
}
self::send(['status' => 'error', 'error' => $error], $status);
}
private static function send(array $payload, int $status): void
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
}
$json = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
);
echo $json === false
? '{"status":"error","error":{"code":"encoding_error","message":"Antwort nicht kodierbar."}}'
: $json;
exit;
}
private static function sendHtmlError(int $status, string $message): void
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: text/html; charset=utf-8');
}
echo '<!doctype html><meta charset="utf-8"><title>Fehler ' . $status . '</title>'
. '<body style="font-family:system-ui,sans-serif;background:#0a0d14;color:#EDEFF5;padding:3rem;">'
. '<h1 style="font-size:1.4rem;">Fehler ' . $status . '</h1>'
. '<p style="color:#8B93A7;">' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</p>'
. '<p style="color:#8B93A7;font-size:.85rem;">Details stehen im Server-Log unter var/log/.</p>'
. '</body>';
exit;
}
// ------------------------------------------------------------------
// Eingabe
// ------------------------------------------------------------------
public static function method(): string
{
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
}
public static function path(): string
{
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH);
return is_string($path) ? $path : '/';
}
/**
* Alle Request-Header mit kleingeschriebenen Namen.
*
* Bewusst aus $_SERVER aufgebaut statt ueber getallheaders(): letzteres
* existiert nicht in jeder SAPI und liefert die Schreibweise des Clients.
*
* @return array<string,string>
*/
public static function headers(): array
{
if (self::$headerCache !== null) {
return self::$headerCache;
}
$headers = [];
foreach ($_SERVER as $key => $value) {
if (!is_string($key) || !is_scalar($value)) {
continue;
}
if (strncmp($key, 'HTTP_', 5) === 0) {
$name = strtolower(str_replace('_', '-', substr($key, 5)));
$headers[$name] = (string)$value;
}
}
// Diese beiden kommen ohne HTTP_-Praefix an.
foreach (['CONTENT_TYPE' => 'content-type', 'CONTENT_LENGTH' => 'content-length'] as $src => $dst) {
if (isset($_SERVER[$src]) && is_scalar($_SERVER[$src])) {
$headers[$dst] = (string)$_SERVER[$src];
}
}
self::$headerCache = $headers;
return $headers;
}
public static function header(string $name): ?string
{
$headers = self::headers();
$key = strtolower($name);
return isset($headers[$key]) && $headers[$key] !== '' ? $headers[$key] : null;
}
/**
* Ermittelt das Agenten-Token aus X-Agent-Token, X-Master-Token
* oder einem Authorization-Bearer-Header.
*/
public static function bearerToken(): ?string
{
foreach (['x-agent-token', 'x-master-token', 'x-license-key', 'x-watchdog-key'] as $name) {
$value = self::header($name);
if ($value !== null) {
return trim($value);
}
}
$auth = self::header('authorization');
if ($auth !== null && preg_match('/^\s*Bearer\s+(\S+)/i', $auth, $m) === 1) {
return trim($m[1]);
}
return null;
}
/**
* Request-Body als Array.
*
* JSON hat Vorrang. Ist der Body leer, wird auf $_POST zurueckgefallen.
* Ist der Body vorhanden, aber kein gueltiges JSON, wird abgebrochen -
* ein stiller Fallback wuerde nur schwer auffindbare Fehler erzeugen.
*/
public static function body(): array
{
if (self::$bodyCache !== null) {
return self::$bodyCache;
}
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
self::$bodyCache = is_array($_POST) ? $_POST : [];
return self::$bodyCache;
}
$contentType = strtolower((string)self::header('content-type'));
if (
str_contains($contentType, 'application/x-www-form-urlencoded')
|| str_contains($contentType, 'multipart/form-data')
) {
self::$bodyCache = is_array($_POST) ? $_POST : [];
return self::$bodyCache;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
self::fail(400, 'invalid_json', 'Request-Body ist kein gueltiges JSON-Objekt.');
}
self::$bodyCache = $decoded;
return self::$bodyCache;
}
/**
* Liest einen Wert aus Body oder Query-String (Body hat Vorrang).
*
* @param mixed $default
* @return mixed
*/
public static function input(string $key, $default = null)
{
$body = self::body();
if (array_key_exists($key, $body)) {
return $body[$key];
}
if (isset($_GET[$key])) {
return $_GET[$key];
}
return $default;
}
/** Getrimmter String-Wert aus Body oder Query, oder null wenn leer. */
public static function str(string $key, ?string $default = null): ?string
{
$value = self::input($key, null);
if ($value === null || is_array($value)) {
return $default;
}
$value = trim((string)$value);
return $value === '' ? $default : $value;
}
public static function int(string $key, int $default = 0): int
{
$value = self::input($key, null);
if ($value === null || is_array($value)) {
return $default;
}
return (int)$value;
}
/** IP des Clients; Proxy-Header werden bewusst ignoriert (faelschbar). */
public static function clientIp(): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
return is_string($ip) && $ip !== '' ? $ip : '127.0.0.1';
}
/** Basis-URL der Installation, z. B. https://dc.mhdf.de */
public static function baseUrl(): string
{
$configured = (string)Config::get('app.url', '');
if ($configured !== '') {
return rtrim($configured, '/');
}
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
return ($https ? 'https' : 'http') . '://' . $host;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Minimaler Datei-Logger. Schlaegt das Schreiben fehl, wird still auf
* error_log() ausgewichen - Logging darf niemals die Anwendung stoppen.
*/
final class Logger
{
private static ?string $resolvedDir = null;
public static function error(string $message, array $context = []): void
{
self::write('ERROR', $message, $context);
}
public static function warning(string $message, array $context = []): void
{
self::write('WARN', $message, $context);
}
public static function info(string $message, array $context = []): void
{
self::write('INFO', $message, $context);
}
public static function exception(\Throwable $e): void
{
self::write('ERROR', sprintf(
'%s: %s in %s:%d',
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine()
), ['trace' => $e->getTraceAsString()]);
}
private static function write(string $level, string $message, array $context = []): void
{
$line = sprintf(
"[%s] %s %s%s\n",
gmdate('Y-m-d H:i:s'),
$level,
$message,
$context !== [] ? ' ' . self::encodeContext($context) : ''
);
$dir = self::dir();
if ($dir !== null) {
$file = $dir . '/dc-' . gmdate('Y-m-d') . '.log';
if (@file_put_contents($file, $line, FILE_APPEND | LOCK_EX) !== false) {
return;
}
}
@error_log(rtrim($line));
}
private static function encodeContext(array $context): string
{
$json = json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
return $json === false ? '{}' : $json;
}
private static function dir(): ?string
{
if (self::$resolvedDir !== null) {
return self::$resolvedDir === '' ? null : self::$resolvedDir;
}
$dir = defined('DC_VAR') ? DC_VAR . '/log' : null;
if ($dir === null) {
self::$resolvedDir = '';
return null;
}
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
self::$resolvedDir = '';
return null;
}
if (!is_writable($dir)) {
self::$resolvedDir = '';
return null;
}
self::$resolvedDir = $dir;
return $dir;
}
}
+427
View File
@@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
use PDOException;
/**
* Migrations-Runner.
*
* Ersetzt das frueher genutzte explode(';') - das zerlegte jede Migration,
* die ein Semikolon innerhalb eines String-Literals enthielt, in Fragmente
* und liess sie (durch verschlucktes catch) still fehlschlagen.
*
* Angewendete Migrationen werden in dc_migrations vermerkt und nicht erneut
* ausgefuehrt. Fehler brechen den Lauf ab, statt ignoriert zu werden.
*/
final class Migrator
{
/**
* MySQL-Fehlercodes, die bei additiven Migrationen unkritisch sind:
* Objekt existiert bereits. Alles andere ist ein echter Fehler.
*/
private const TOLERATED_ERRORS = [
1022, // Duplicate key
1050, // Table already exists
1060, // Duplicate column name
1061, // Duplicate key name
1062, // Duplicate entry for key
1091, // Can't DROP; check that column/key exists
1826, // Duplicate foreign key constraint name
];
/**
* Migrationen, die auf einer bereits bestehenden Installation nur vermerkt,
* aber nicht ausgefuehrt werden.
*
* Grund: schema.sql und Migration 004 legen nicht nur Tabellen an, sondern
* spielen auch Beispieldaten ein (Demo-Lizenzen, Demo-Monitore, Demo-Bugs).
* Auf einer produktiv genutzten Datenbank wuerden diese Zeilen dadurch
* wieder auftauchen, nachdem sie geloescht wurden - die Seeds arbeiten mit
* festen IDs und ON DUPLICATE KEY UPDATE. Die Struktur, die sie erzeugen,
* ist auf einer bestehenden Installation ohnehin vorhanden.
*/
private const BASELINE_ONLY = [
'000_schema',
'004_unified_tokens_and_bugtracker',
'v2_hardware_id',
];
/**
* Fuehrt Schema und ausstehende Migrationen aus.
*
* @return array{applied:list<array<string,mixed>>,skipped:list<string>,baselined:list<string>,failed:?array<string,mixed>}
*/
public static function migrate(PDO $db): array
{
self::ensureMigrationsTable($db);
$applied = self::appliedVersions($db);
$result = ['applied' => [], 'skipped' => [], 'baselined' => [], 'failed' => null];
// Bestehende Installation ohne Migrationsvermerk: Strukturmigrationen
// als Ausgangsstand vermerken, statt sie samt Beispieldaten auszufuehren.
if ($applied === [] && self::isExistingInstall($db)) {
$files = self::discoverFiles();
foreach (self::BASELINE_ONLY as $version) {
if (isset($files[$version])) {
self::recordApplied($db, $version, hash('sha256', 'baseline'), 0, 0);
$result['baselined'][] = $version;
}
}
Logger::info('Bestehende Installation als Ausgangsstand vermerkt', [
'versions' => $result['baselined'],
]);
$applied = self::appliedVersions($db);
}
foreach (self::discoverFiles() as $version => $path) {
if (isset($applied[$version])) {
$result['skipped'][] = $version;
continue;
}
$sql = @file_get_contents($path);
if ($sql === false) {
$result['failed'] = ['version' => $version, 'error' => 'Datei nicht lesbar: ' . $path];
return $result;
}
$statements = self::splitStatements($sql);
$executed = 0;
$tolerated = 0;
$started = microtime(true);
foreach ($statements as $index => $statement) {
try {
$db->exec($statement);
$executed++;
} catch (PDOException $e) {
$code = self::driverErrorCode($e);
if ($code !== null && in_array($code, self::TOLERATED_ERRORS, true)) {
$tolerated++;
continue;
}
Logger::error('Migration fehlgeschlagen', [
'version' => $version,
'statement' => $index + 1,
'sql' => mb_substr($statement, 0, 400),
'error' => $e->getMessage(),
]);
$result['failed'] = [
'version' => $version,
'statement' => $index + 1,
'error' => $e->getMessage(),
'sql' => mb_substr($statement, 0, 400),
];
return $result;
}
}
$durationMs = (int)round((microtime(true) - $started) * 1000);
self::recordApplied($db, $version, hash('sha256', $sql), $executed, $durationMs);
$result['applied'][] = [
'version' => $version,
'statements' => $executed,
'tolerated' => $tolerated,
'duration_ms' => $durationMs,
];
}
return $result;
}
/**
* Aktuell in der Datenbank vermerkte Migrationsversionen.
*
* Legt die Tabelle bewusst NICHT an - die Methode wird bei jedem
* Seitenaufruf des Dashboards ausgefuehrt und soll dabei kein DDL absetzen.
* Fehlt die Tabelle, gelten schlicht alle Migrationen als ausstehend.
*/
public static function status(PDO $db): array
{
$all = array_keys(self::discoverFiles());
if (!self::migrationsTableExists($db)) {
return ['applied' => [], 'pending' => $all];
}
$applied = self::appliedVersions($db);
return [
'applied' => array_values($applied),
'pending' => array_values(array_diff($all, array_keys($applied))),
];
}
/**
* Erkennt eine bereits produktiv genutzte Datenbank.
*
* Kriterium: die Kerntabelle dc_projects existiert und enthaelt Zeilen.
* Bei einer leeren Datenbank trifft das nicht zu, dort laeuft schema.sql
* regulaer durch und legt auch die Beispieldaten an.
*/
private static function isExistingInstall(PDO $db): bool
{
try {
$stmt = $db->query("SHOW TABLES LIKE 'dc_projects'");
if ($stmt === false || $stmt->fetchColumn() === false) {
return false;
}
return (int)$db->query('SELECT COUNT(*) FROM dc_projects')->fetchColumn() > 0;
} catch (\Throwable $e) {
return false;
}
}
private static ?bool $tableExistsCache = null;
private static function migrationsTableExists(PDO $db): bool
{
if (self::$tableExistsCache !== null) {
return self::$tableExistsCache;
}
try {
$stmt = $db->query("SHOW TABLES LIKE 'dc_migrations'");
self::$tableExistsCache = $stmt !== false && $stmt->fetchColumn() !== false;
} catch (\Throwable $e) {
self::$tableExistsCache = false;
}
return self::$tableExistsCache;
}
// ------------------------------------------------------------------
private static function ensureMigrationsTable(PDO $db): void
{
// Der Existenz-Cache in migrationsTableExists() waere sonst veraltet,
// wenn status() vor migrate() im selben Request lief.
self::$tableExistsCache = true;
$db->exec('
CREATE TABLE IF NOT EXISTS dc_migrations (
version VARCHAR(190) NOT NULL PRIMARY KEY,
checksum CHAR(64) NOT NULL,
statements INT NOT NULL DEFAULT 0,
duration_ms INT NOT NULL DEFAULT 0,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
');
}
/** @return array<string,array<string,mixed>> */
private static function appliedVersions(PDO $db): array
{
$rows = $db->query('SELECT version, checksum, applied_at FROM dc_migrations')->fetchAll();
$out = [];
foreach ($rows ?: [] as $row) {
$out[(string)$row['version']] = $row;
}
return $out;
}
private static function recordApplied(
PDO $db,
string $version,
string $checksum,
int $statements,
int $durationMs
): void {
$stmt = $db->prepare('
INSERT INTO dc_migrations (version, checksum, statements, duration_ms, applied_at)
VALUES (:version, :checksum, :statements, :duration, NOW())
ON DUPLICATE KEY UPDATE
checksum = VALUES(checksum),
statements = VALUES(statements),
duration_ms = VALUES(duration_ms),
applied_at = VALUES(applied_at)
');
$stmt->execute([
':version' => $version,
':checksum' => $checksum,
':statements' => $statements,
':duration' => $durationMs,
]);
}
/**
* Basis-Schema plus alle Migrationen, in Ausfuehrungsreihenfolge.
*
* @return array<string,string> version => absoluter Pfad
*/
private static function discoverFiles(): array
{
$files = [];
$schema = DC_ROOT . '/sql/schema.sql';
if (is_file($schema)) {
$files['000_schema'] = $schema;
}
$dir = DC_ROOT . '/sql/migrations';
if (is_dir($dir)) {
$found = glob($dir . '/*.sql') ?: [];
sort($found, SORT_NATURAL);
foreach ($found as $path) {
$files[basename($path, '.sql')] = $path;
}
}
return $files;
}
private static function driverErrorCode(PDOException $e): ?int
{
$info = $e->errorInfo;
if (is_array($info) && isset($info[1]) && is_numeric($info[1])) {
return (int)$info[1];
}
return null;
}
/**
* Zerlegt ein SQL-Skript in einzelne Statements.
*
* Beachtet String-Literale ('...', "..."), Backtick-Bezeichner sowie
* Zeilen- und Blockkommentare, damit Semikolons darin nicht als
* Statement-Ende missverstanden werden.
*
* @return list<string>
*/
public static function splitStatements(string $sql): array
{
$statements = [];
$buffer = '';
$length = strlen($sql);
$i = 0;
$inSingle = false;
$inDouble = false;
$inBacktick = false;
$inLineComment = false;
$inBlockComment = false;
while ($i < $length) {
$char = $sql[$i];
$next = ($i + 1 < $length) ? $sql[$i + 1] : '';
if ($inLineComment) {
if ($char === "\n") {
$inLineComment = false;
$buffer .= $char;
}
$i++;
continue;
}
if ($inBlockComment) {
if ($char === '*' && $next === '/') {
$inBlockComment = false;
$i += 2;
continue;
}
$i++;
continue;
}
if ($inSingle || $inDouble || $inBacktick) {
$buffer .= $char;
// Backslash-Escape innerhalb von Strings (nicht in Backticks).
if ($char === '\\' && !$inBacktick && $next !== '') {
$buffer .= $next;
$i += 2;
continue;
}
$quote = $inSingle ? "'" : ($inDouble ? '"' : '`');
if ($char === $quote) {
if ($next === $quote) {
// Verdoppeltes Anfuehrungszeichen = Escape, bleibt im String.
$buffer .= $next;
$i += 2;
continue;
}
$inSingle = $inDouble = $inBacktick = false;
}
$i++;
continue;
}
// Ausserhalb von Strings und Kommentaren
if ($char === '-' && $next === '-') {
$after = ($i + 2 < $length) ? $sql[$i + 2] : "\n";
if ($after === ' ' || $after === "\t" || $after === "\n" || $after === "\r") {
$inLineComment = true;
$i += 2;
continue;
}
}
if ($char === '#') {
$inLineComment = true;
$i++;
continue;
}
if ($char === '/' && $next === '*') {
$inBlockComment = true;
$i += 2;
continue;
}
if ($char === "'") {
$inSingle = true;
$buffer .= $char;
$i++;
continue;
}
if ($char === '"') {
$inDouble = true;
$buffer .= $char;
$i++;
continue;
}
if ($char === '`') {
$inBacktick = true;
$buffer .= $char;
$i++;
continue;
}
if ($char === ';') {
$trimmed = trim($buffer);
if ($trimmed !== '') {
$statements[] = $trimmed;
}
$buffer = '';
$i++;
continue;
}
$buffer .= $char;
$i++;
}
$trimmed = trim($buffer);
if ($trimmed !== '') {
$statements[] = $trimmed;
}
return $statements;
}
}
+332 -125
View File
@@ -1,21 +1,60 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use InvalidArgumentException;
use PDO;
class TokenManager
/**
* Verwaltung der Master-/Sub-Token-Hierarchie in dc_tokens.
*
* Korrekturen gegenueber der Erstfassung:
* - revokeToken()/deleteToken() nutzen getrennte Platzhalter. Derselbe
* benannte Parameter zweimal im Statement ist bei ATTR_EMULATE_PREPARES=false
* nicht zulaessig und warf HY093.
* - validateToken() vergleicht ausschliesslich den SHA-256-Hash, nicht mehr
* zusaetzlich den Klartext.
* - expires_at wird ausgewertet; die Spalte existierte, wurde aber ignoriert.
* - Scopes unterstuetzen Praefix-Wildcards (bugtracker:* deckt bugtracker:report ab).
*
* Hinweis: raw_token wird weiterhin gespeichert, damit das WebUI Tokens
* nachtraeglich anzeigen und kopieren kann. Das ist eine bewusste Abwaegung
* fuer ein Ein-Administrator-Werkzeug hinter Login. Wer das nicht moechte,
* setzt store_raw_tokens = false; dann ist das Token nur einmalig bei der
* Erstellung sichtbar.
*/
final class TokenManager
{
public const OWNER_TYPES = ['license', 'project', 'host', 'dev_agent', 'custom'];
public const ENVIRONMENTS = ['production', 'development', 'all'];
public const KNOWN_SCOPES = [
'*',
'bugtracker:report',
'bugtracker:read',
'bugtracker:manage',
'watchdog:ping',
'watchdog:read',
'updateservice:read',
'updateservice:publish',
'tokens:provision',
];
private PDO $db;
private bool $storeRaw;
public function __construct(PDO $db)
{
$this->db = $db;
$this->storeRaw = (bool)Config::get('security.store_raw_tokens', true);
}
/**
* Create a new Master Token.
*/
// ------------------------------------------------------------------
// Erstellung
// ------------------------------------------------------------------
public function createMasterToken(
string $name,
?string $projectSlug = null,
@@ -23,47 +62,58 @@ class TokenManager
string $ownerType = 'custom',
?string $ownerIdentity = null,
array $scopes = ['*'],
string $environment = 'all'
string $environment = 'all',
?string $expiresAt = null
): array {
$tokenId = 'tok_m_' . bin2hex(random_bytes(8));
$rawToken = 'dc_master_' . bin2hex(random_bytes(20));
$tokenHash = hash('sha256', $rawToken);
$name = trim($name);
if ($name === '') {
throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
}
$tokenId = 'tok_m_' . bin2hex(random_bytes(8));
$rawToken = 'dc_master_' . bin2hex(random_bytes(24));
$stmt = $this->db->prepare('
INSERT INTO dc_tokens (
token_id, parent_token_id, token_hash, raw_token, name,
project_slug, license_key, owner_type, owner_identity,
type, scopes, environment, created_at
type, scopes, environment, expires_at, created_at
) VALUES (
:id, NULL, :hash, :raw, :name,
:proj, :lic, :type, :identity,
"master", :scopes, :env, NOW()
:proj, :lic, :owner_type, :identity,
"master", :scopes, :env, :expires, UTC_TIMESTAMP()
)
');
$stmt->execute([
':id' => $tokenId,
':hash' => $tokenHash,
':raw' => $rawToken,
':name' => $name,
':proj' => !empty($projectSlug) ? $projectSlug : null,
':lic' => !empty($licenseKey) ? $licenseKey : null,
':type' => in_array($ownerType, ['license', 'project', 'host', 'dev_agent', 'custom']) ? $ownerType : 'custom',
':identity' => !empty($ownerIdentity) ? $ownerIdentity : null,
':scopes' => json_encode(!empty($scopes) ? $scopes : ['*']),
':env' => in_array($environment, ['production', 'development', 'all']) ? $environment : 'all',
':id' => $tokenId,
':hash' => hash('sha256', $rawToken),
':raw' => $this->storeRaw ? $rawToken : null,
':name' => $name,
':proj' => self::nullIfEmpty($projectSlug),
':lic' => self::nullIfEmpty($licenseKey),
':owner_type' => in_array($ownerType, self::OWNER_TYPES, true) ? $ownerType : 'custom',
':identity' => self::nullIfEmpty($ownerIdentity),
':scopes' => json_encode(self::normalizeScopes($scopes, ['*'])),
':env' => in_array($environment, self::ENVIRONMENTS, true) ? $environment : 'all',
':expires' => self::nullIfEmpty($expiresAt),
]);
Logger::info('Master-Token erstellt', ['token_id' => $tokenId, 'name' => $name]);
return [
'token_id' => $tokenId,
'raw_token' => $rawToken,
'name' => $name,
'type' => 'master',
'token_id' => $tokenId,
'raw_token' => $rawToken,
'name' => $name,
'type' => 'master',
'scopes' => self::normalizeScopes($scopes, ['*']),
'environment' => $environment,
];
}
/**
* Provision a Sub-Token using a Master-Token.
* Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
*/
public function provisionSubToken(
string $rawMasterToken,
@@ -72,171 +122,328 @@ class TokenManager
array $requestedScopes = [],
string $environment = 'all'
): array {
$masterHash = hash('sha256', $rawMasterToken);
$stmt = $this->db->prepare('
SELECT * FROM dc_tokens
WHERE (token_hash = :hash OR raw_token = :raw)
AND type = "master"
AND revoked = 0
');
$stmt->execute([':hash' => $masterHash, ':raw' => $rawMasterToken]);
$master = $stmt->fetch();
$master = $this->findByRawToken($rawMasterToken);
if (!$master) {
throw new \InvalidArgumentException('Invalid or revoked Master Token.');
if ($master === null || $master['type'] !== 'master' || (int)$master['revoked'] === 1) {
throw new InvalidArgumentException('Ungueltiges oder widerrufenes Master-Token.');
}
$masterScopes = json_decode($master['scopes'], true) ?: ['*'];
if (self::isExpired($master)) {
throw new InvalidArgumentException('Master-Token ist abgelaufen.');
}
// Determine effective scopes
$effectiveScopes = [];
if (in_array('*', $masterScopes)) {
$effectiveScopes = !empty($requestedScopes) ? $requestedScopes : ['*'];
$masterScopes = self::decodeScopes($master['scopes']);
$requested = self::normalizeScopes($requestedScopes, []);
if ($requested === []) {
$effectiveScopes = $masterScopes;
} elseif (in_array('*', $masterScopes, true)) {
$effectiveScopes = $requested;
} else {
if (empty($requestedScopes)) {
$effectiveScopes = $masterScopes;
} else {
$effectiveScopes = array_intersect($requestedScopes, $masterScopes);
}
// Nur Rechte durchreichen, die das Master-Token tatsaechlich besitzt.
$effectiveScopes = array_values(array_filter(
$requested,
static fn(string $scope): bool => self::scopeSatisfied($masterScopes, $scope)
));
}
if (empty($effectiveScopes)) {
throw new \InvalidArgumentException('Requested scopes are not allowed by this Master Token.');
if ($effectiveScopes === []) {
throw new InvalidArgumentException('Die angeforderten Rechte deckt dieses Master-Token nicht ab.');
}
// Determine effective environment
$effectiveEnv = $environment;
if ($master['environment'] !== 'all') {
$effectiveEnv = $master['environment'];
}
// Ist das Master-Token auf eine Umgebung festgelegt, gilt diese zwingend.
$effectiveEnv = $master['environment'] !== 'all'
? (string)$master['environment']
: (in_array($environment, self::ENVIRONMENTS, true) ? $environment : 'all');
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(20));
$subHash = hash('sha256', $rawSubToken);
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(24));
$ins = $this->db->prepare('
$stmt = $this->db->prepare('
INSERT INTO dc_tokens (
token_id, parent_token_id, token_hash, raw_token, name,
project_slug, license_key, owner_type, owner_identity,
type, scopes, environment, created_at
type, scopes, environment, expires_at, created_at
) VALUES (
:id, :parent_id, :hash, :raw, :name,
:proj, :lic, :owner_type, :identity,
"sub", :scopes, :env, NOW()
"sub", :scopes, :env, :expires, UTC_TIMESTAMP()
)
');
$ins->execute([
':id' => $subTokenId,
':parent_id' => $master['token_id'],
':hash' => $subHash,
':raw' => $rawSubToken,
':name' => $name,
':proj' => $master['project_slug'],
':lic' => $master['license_key'],
':owner_type'=> $master['owner_type'],
':identity' => !empty($instanceIdentity) ? $instanceIdentity : $master['owner_identity'],
':scopes' => json_encode(array_values($effectiveScopes)),
':env' => $effectiveEnv,
$stmt->execute([
':id' => $subTokenId,
':parent_id' => $master['token_id'],
':hash' => hash('sha256', $rawSubToken),
':raw' => $this->storeRaw ? $rawSubToken : null,
':name' => trim($name) !== '' ? trim($name) : 'Auto-Provisioned Sub-Token',
':proj' => $master['project_slug'],
':lic' => $master['license_key'],
':owner_type' => $master['owner_type'],
':identity' => self::nullIfEmpty($instanceIdentity) ?? $master['owner_identity'],
':scopes' => json_encode($effectiveScopes),
':env' => $effectiveEnv,
// Ein Sub-Token ueberlebt sein Master-Token nicht.
':expires' => $master['expires_at'],
]);
Logger::info('Sub-Token provisioniert', [
'token_id' => $subTokenId,
'parent' => $master['token_id'],
]);
return [
'token_id' => $subTokenId,
'raw_token' => $rawSubToken,
'name' => $name,
'scopes' => array_values($effectiveScopes),
'environment'=> $effectiveEnv,
'type' => 'sub',
'token_id' => $subTokenId,
'raw_token' => $rawSubToken,
'name' => $name,
'scopes' => $effectiveScopes,
'environment' => $effectiveEnv,
'expires_at' => $master['expires_at'],
'type' => 'sub',
];
}
// ------------------------------------------------------------------
// Validierung
// ------------------------------------------------------------------
/**
* Validate any Token (Master or Sub) and check cascading revocation of parent tokens.
* Prueft ein Token auf Gueltigkeit, Rechte und Umgebung.
*
* @return array<string,mixed>|null Der Token-Datensatz oder null.
*/
public function validateToken(string $rawToken, ?string $requiredScope = null, ?string $environment = null): ?array
{
$hash = hash('sha256', $rawToken);
$token = $this->findByRawToken($rawToken);
$stmt = $this->db->prepare('
SELECT t.*, p.revoked as parent_revoked
FROM dc_tokens t
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
WHERE (t.token_hash = :hash OR t.raw_token = :raw)
AND t.revoked = 0
');
$stmt->execute([':hash' => $hash, ':raw' => $rawToken]);
$token = $stmt->fetch();
if (!$token) {
if ($token === null || (int)$token['revoked'] === 1) {
return null;
}
// Cascading Revocation Check
if ($token['type'] === 'sub' && !empty($token['parent_token_id']) && (int)$token['parent_revoked'] === 1) {
// Kaskadierende Sperre: ein widerrufenes Master-Token entwertet seine Kinder.
if ($token['type'] === 'sub' && (int)($token['parent_revoked'] ?? 0) === 1) {
return null;
}
// Scope Check
if ($requiredScope !== null) {
$scopes = json_decode($token['scopes'], true) ?: [];
if (!in_array('*', $scopes) && !in_array($requiredScope, $scopes)) {
return null;
}
if (self::isExpired($token)) {
return null;
}
if ($requiredScope !== null && !self::scopeSatisfied(self::decodeScopes($token['scopes']), $requiredScope)) {
return null;
}
// Environment Check
if ($environment !== null && $token['environment'] !== 'all' && $token['environment'] !== $environment) {
return null;
}
// Update Last Used Timestamp
$upd = $this->db->prepare('UPDATE dc_tokens SET last_used_at = NOW() WHERE id = :id');
$upd->execute([':id' => $token['id']]);
$this->touch((string)$token['token_id']);
return $token;
}
/**
* Revoke a Token (Master or Sub). If Master, cascade revokes all child Sub-Tokens via DB foreign key or query.
*/
public function revokeToken(string $tokenId): bool
/** Sucht ein Token ausschliesslich ueber den Hash des Klartextwerts. */
public function findByRawToken(string $rawToken): ?array
{
$stmt = $this->db->prepare('UPDATE dc_tokens SET revoked = 1 WHERE token_id = :id OR parent_token_id = :id');
return $stmt->execute([':id' => $tokenId]);
$rawToken = trim($rawToken);
if ($rawToken === '') {
return null;
}
$stmt = $this->db->prepare('
SELECT t.*, COALESCE(p.revoked, 0) AS parent_revoked
FROM dc_tokens t
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
WHERE t.token_hash = :hash
LIMIT 1
');
$stmt->execute([':hash' => hash('sha256', $rawToken)]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
/**
* Permanently delete a Token (Master or Sub). If Master, child Sub-Tokens are deleted via cascade.
*/
public function deleteToken(string $tokenId): bool
private function touch(string $tokenId): void
{
$stmt = $this->db->prepare('DELETE FROM dc_tokens WHERE token_id = :id OR parent_token_id = :id');
return $stmt->execute([':id' => $tokenId]);
try {
$stmt = $this->db->prepare('UPDATE dc_tokens SET last_used_at = UTC_TIMESTAMP() WHERE token_id = :id');
$stmt->execute([':id' => $tokenId]);
} catch (\Throwable $e) {
// Die Nutzungsstatistik darf keinen Request scheitern lassen.
Logger::warning('last_used_at nicht aktualisiert', ['token_id' => $tokenId]);
}
}
// ------------------------------------------------------------------
// Verwaltung
// ------------------------------------------------------------------
/**
* Get all Master Tokens with child count.
* Widerruft ein Token und alle davon abgeleiteten Sub-Tokens.
* Getrennte Platzhalter, da derselbe Parametername sonst HY093 ausloest.
*/
public function revokeToken(string $tokenId): int
{
$stmt = $this->db->prepare('
UPDATE dc_tokens
SET revoked = 1
WHERE token_id = :token_id OR parent_token_id = :parent_id
');
$stmt->execute([':token_id' => $tokenId, ':parent_id' => $tokenId]);
$count = $stmt->rowCount();
Logger::info('Token widerrufen', ['token_id' => $tokenId, 'affected' => $count]);
return $count;
}
/** Loescht ein Token samt Sub-Tokens dauerhaft. */
public function deleteToken(string $tokenId): int
{
$stmt = $this->db->prepare('
DELETE FROM dc_tokens
WHERE token_id = :token_id OR parent_token_id = :parent_id
');
$stmt->execute([':token_id' => $tokenId, ':parent_id' => $tokenId]);
$count = $stmt->rowCount();
Logger::info('Token geloescht', ['token_id' => $tokenId, 'affected' => $count]);
return $count;
}
/** @return list<array<string,mixed>> */
public function getAllMasterTokens(): array
{
$stmt = $this->db->query('
SELECT m.*, COUNT(s.id) as sub_token_count
FROM dc_tokens m
LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id
WHERE m.type = "master"
GROUP BY m.id
SELECT m.*, COUNT(s.id) AS sub_token_count
FROM dc_tokens m
LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id
WHERE m.type = "master"
GROUP BY m.id
ORDER BY m.created_at DESC
');
return $stmt->fetchAll() ?: [];
}
/**
* Get all Tokens (Master & Sub).
*/
/** @return list<array<string,mixed>> */
public function getAllTokens(): array
{
$stmt = $this->db->query('SELECT * FROM dc_tokens ORDER BY created_at DESC');
return $stmt->fetchAll() ?: [];
}
// ------------------------------------------------------------------
// Hilfsfunktionen
// ------------------------------------------------------------------
/**
* Rechte, die andere Rechte einschliessen.
*
* Wer Items bearbeiten darf, muss sie auch lesen koennen - sonst ist das
* Recht wertlos. Ohne diese Zuordnung braeuchte jedes Token beide Eintraege
* einzeln, und ein im WebUI mit "Bugtracker Manage" erzeugtes Token
* scheiterte an jeder Abfrage.
*
* @var array<string,list<string>>
*/
private const IMPLIED_SCOPES = [
'bugtracker:manage' => ['bugtracker:read', 'bugtracker:report'],
'bugtracker:report' => ['bugtracker:read'],
'updateservice:publish' => ['updateservice:read'],
'watchdog:evaluate' => ['watchdog:read'],
'watchdog:ping' => ['watchdog:read'],
];
/**
* Prueft, ob eine Scope-Liste ein konkretes Recht abdeckt.
*
* "*" deckt alles ab, "bugtracker:*" alle bugtracker-Rechte, und
* uebergeordnete Rechte schliessen die jeweils schwaecheren ein.
*
* @param list<string> $granted
*/
public static function scopeSatisfied(array $granted, string $required): bool
{
foreach ($granted as $scope) {
if ($scope === '*' || $scope === $required) {
return true;
}
// Praefix-Wildcard: "bugtracker:*" deckt "bugtracker:read" ab
if (str_ends_with($scope, ':*')) {
$prefix = substr($scope, 0, -1);
if (str_starts_with($required, $prefix)) {
return true;
}
}
if (in_array($required, self::IMPLIED_SCOPES[$scope] ?? [], true)) {
return true;
}
}
return false;
}
/**
* @param mixed $raw
* @return list<string>
*/
public static function decodeScopes($raw): array
{
if (is_array($raw)) {
return self::normalizeScopes($raw, ['*']);
}
if (!is_string($raw) || $raw === '') {
return ['*'];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? self::normalizeScopes($decoded, ['*']) : ['*'];
}
/**
* @param mixed $scopes
* @param list<string> $fallback
* @return list<string>
*/
private static function normalizeScopes($scopes, array $fallback): array
{
if (!is_array($scopes)) {
return $fallback;
}
$clean = [];
foreach ($scopes as $scope) {
if (!is_string($scope)) {
continue;
}
$scope = trim($scope);
if ($scope !== '' && !in_array($scope, $clean, true)) {
$clean[] = $scope;
}
}
return $clean === [] ? $fallback : $clean;
}
/** @param array<string,mixed> $token */
public static function isExpired(array $token): bool
{
$expires = $token['expires_at'] ?? null;
if ($expires === null || $expires === '') {
return false;
}
$ts = strtotime((string)$expires . ' UTC');
return $ts !== false && $ts < time();
}
private static function nullIfEmpty(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
}
File diff suppressed because it is too large Load Diff
+64 -28
View File
@@ -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;
}
}
}
+214
View File
@@ -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]);
}
}
}
+164 -32
View File
@@ -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;
}
}
+156
View File
@@ -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;
}
}
+261
View File
@@ -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';
}
}
+48 -15
View File
@@ -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();
}
}
+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;
}
}
+56 -22
View File
@@ -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');
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* Deploymentcenter Bootstrap
*
* Einziger Einstiegspunkt fuer Autoloading, Konfiguration und Fehlerbehandlung.
* Jede Datei unter public/ bindet ausschliesslich diese Datei ein.
*/
declare(strict_types=1);
if (defined('DC_BOOTSTRAPPED')) {
return;
}
define('DC_BOOTSTRAPPED', true);
define('DC_ROOT', dirname(__DIR__));
define('DC_SRC', DC_ROOT . '/src');
define('DC_VAR', DC_ROOT . '/var');
// --- PSR-4 Autoloader: Deploymentcenter\Foo\Bar -> src/Foo/Bar.php ---
spl_autoload_register(static function (string $class): void {
$prefix = 'Deploymentcenter\\';
$len = strlen($prefix);
if (strncmp($class, $prefix, $len) !== 0) {
return;
}
$relative = substr($class, $len);
$path = DC_SRC . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require_once $path;
}
});
// --- Konfiguration laden ---
$dcConfigFile = DC_ROOT . '/config/config.php';
if (!is_file($dcConfigFile)) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "Konfiguration fehlt.\n\n"
. "Bitte config/config.example.php nach config/config.php kopieren und ausfuellen.\n";
exit(1);
}
/** @var array $dcConfig */
$dcConfig = require $dcConfigFile;
if (!is_array($dcConfig)) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "config/config.php muss ein Array zurueckgeben.\n";
exit(1);
}
Deploymentcenter\Core\Config::load($dcConfig);
date_default_timezone_set((string)Deploymentcenter\Core\Config::get('app.timezone', 'UTC'));
// --- Fehleranzeige: niemals an den Client, immer ins Log ---
$dcDebug = (bool)Deploymentcenter\Core\Config::get('app.debug', false);
ini_set('display_errors', $dcDebug ? '1' : '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);
set_exception_handler(static function (\Throwable $e): void {
// Http::fail() protokolliert die Exception bereits - hier nur die
// Selbstmeldung in den eigenen Bugtracker anstossen.
Deploymentcenter\Core\ErrorReporter::report($e);
if (!headers_sent()) {
http_response_code(500);
}
Deploymentcenter\Core\Http::fail(500, 'internal_error', 'Interner Serverfehler.', $e);
});
register_shutdown_function(static function (): void {
$err = error_get_last();
if ($err === null) {
return;
}
if (!in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
return;
}
Deploymentcenter\Core\Logger::error(
sprintf('Fatal: %s in %s:%d', $err['message'], $err['file'], $err['line'])
);
Deploymentcenter\Core\ErrorReporter::reportFatal($err);
});
unset($dcConfigFile, $dcConfig, $dcDebug);