feat(setup): Erstinstallation ueber den Update-Agent, Installationskonto, Downloads

Bisher gab es nur den Update-Weg: eine Anwendung musste bereits installiert und
eingerichtet sein, damit sich etwas aktualisieren liess. Die Erstinstallation
auf einem neuen System war Handarbeit - Paket kopieren, Konfiguration
abtippen, Token besorgen.

Setup-API (neu)
- POST /api/setup/v1/login tauscht Benutzername und Passwort gegen ein Token
  mit 30 Minuten Gueltigkeit und ausschliesslich setup:install. Es wird nicht
  mitgeschrieben und lebt im Installer nur im Speicher.
- GET /api/setup/v1/catalog zeigt nur, was zur Laufzeitkennung des anfragenden
  Systems passt. Ein Projekt mit ausschliesslich Windows-Paket taucht auf einem
  Linux-Rechner gar nicht erst auf.
- POST /api/setup/v1/token stellt das Dauertoken der Anwendung aus. Welche
  Rechte vergeben werden, entscheidet der Server; die Anfrage kann nur
  einschraenken. Sonst waere der Umweg ueber ein kurzlebiges Token wirkungslos.

Rollentrennung (Migration 012)
- dc_users bekommt role, disabled und last_login_at. Die Rolle "installer"
  darf sich ueber den Setup-Weg anmelden und nicht am WebUI. Die Zugangsdaten
  werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto
  verteilte man damit den Zugang zu Tokens, Lizenzen und Monitoren auf jeden
  Rechner, auf dem je etwas installiert wurde.
- Auth::verifyCredentials() prueft sessionfrei, damit Setup- und WebUI-Login
  nicht zwei verschiedene Haertungsgrade haben (Drosselung, Timing-Angleichung,
  Rehash gelten fuer beide).
- Konten mit hinterlegtem TOTP-Geheimnis werden am Setup-Weg mit 501
  abgewiesen. Eine TOTP-Pruefung gibt es im Deploymentcenter noch nicht; sie
  stillschweigend zu uebergehen waere ein Rueckschritt.
- Benutzerverwaltung im WebUI - es gab bisher gar keine, nur den einen von
  install_db.php angelegten Admin. Das letzte aktive Administratorkonto laesst
  sich weder deaktivieren noch loeschen.

Installer
- update-agent --action install fuehrt durch Anmeldung, Auswahl,
  Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber
  denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und
  Rollback. Ein zweiter Download-Weg waere ein zweiter Ort fuer dieselben
  Fehler.
- --action configure holt die Einrichtung nachtraeglich.
- setup.json im Paket beschreibt die benoetigten Werte. Bewusst im Paket und
  nicht zentral: so ist sie mit der Anwendung versioniert.
- Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... ->
  provision -> fragen. Platzhalter wie changeme oder <dein-wert> gelten dabei
  nicht als eingerichtet, sonst liefe die Anwendung mit der Vorlage los.
- SetupWriter erhaelt vorhandene Inhalte. Eine appsettings.json fuehrt neben
  den abgefragten Werten meist Logging und anderes; sie neu zu erzeugen waere
  bequemer und verloere das - bei einer Neuinstallation ohne Backup.
  int und bool landen als JSON-Typ, nicht als Zeichenkette.

Downloads
- scripts/build_installer.ps1 baut selbstenthaltende Einzeldateien fuer
  win-x64, linux-x64 und linux-arm64 (rund 34 MB, .NET-Laufzeit inbegriffen).
  Ohne NativeAOT und ohne Trimming: Spectre.Console loest ueber Reflexion auf
  und braeche sonst erst beim Anwender.
- scripts/upload_installer.py laedt sie nach /installer/. Getrennt von
  deploy.py, das client-dotnet bewusst ausklammert.
- Bereich "Installer" auf der UpdateService-Seite mit Groessen, Pruefsummen
  und den wget-Befehlen; die Angaben stammen aus installer.json statt aus fest
  eingetragenem Text.
- install.sh und install.ps1 laden, pruefen die Pruefsumme und legen ab -
  sie richten bewusst nichts selbst ein. Das Manifest wird BOM-frei
  geschrieben, sonst scheitert json_decode() daran.

Enthaelt ausserdem die bislang nicht committete Arbeit an den
RocketChat-Benachrichtigungen (Migrationen 010 und 011) sowie die Loesch- und
Editierfunktion des UpdateService; die betroffenen Dateien liessen sich nicht
getrennt stagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 10:33:26 +02:00
co-authored by Claude Opus 5
parent 2388b5abe1
commit c8f3e78635
34 changed files with 4704 additions and 21 deletions
+82 -9
View File
@@ -84,20 +84,26 @@ final class Auth
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
* Prueft Zugangsdaten, ohne eine Session anzufassen.
*
* Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht,
* aber ein kurzlebiges Token statt eines Session-Cookies ausstellt.
* Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie
* hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden
* zu haben.
*
* @return array<string,mixed>|null Der Benutzerdatensatz, oder null
*/
public static function login(PDO $db, string $username, string $password): bool
public static function verifyCredentials(PDO $db, string $username, string $password): ?array
{
self::startSession();
$ip = Http::clientIp();
if (self::isLockedOut($db, $ip)) {
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
return false;
return null;
}
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
$stmt = $db->prepare('SELECT * FROM dc_users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch();
@@ -110,7 +116,18 @@ final class Auth
if (!$verified || !$found) {
self::recordAttempt($db, $ip, $username, false);
return false;
return null;
}
// Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter
// darauf verweisen koennen - anmelden darf es sich nicht.
if ((int)($user['disabled'] ?? 0) === 1) {
self::recordAttempt($db, $ip, $username, false);
Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [
'username' => $username,
'ip' => $ip,
]);
return null;
}
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
@@ -119,19 +136,75 @@ final class Auth
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
}
self::recordAttempt($db, $ip, $username, true);
self::touchLastLogin($db, (int)$user['id']);
return $user;
}
/**
* Rolle eines Datensatzes aus dc_users.
*
* Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist -
* sonst waere nach dem Einspielen des Codes und vor der Migration niemand
* mehr anmeldeberechtigt.
*/
public static function roleOf(?array $user): string
{
$role = is_array($user) ? (string)($user['role'] ?? 'admin') : 'admin';
return $role === 'installer' ? 'installer' : 'admin';
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
*/
public static function login(PDO $db, string $username, string $password): bool
{
self::startSession();
$user = self::verifyCredentials($db, $username, $password);
if ($user === null) {
return false;
}
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu
// suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto
// gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette
// damit auch Zugriff auf Tokens, Lizenzen und Monitore.
if (self::roleOf($user) === 'installer') {
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
'username' => $username,
'ip' => Http::clientIp(),
]);
return false;
}
session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id'];
$_SESSION['dc_username'] = (string)$user['username'];
$_SESSION['dc_role'] = self::roleOf($user);
$_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]);
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
return true;
}
private static function touchLastLogin(PDO $db, int $userId): void
{
try {
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
$stmt->execute([':id' => $userId]);
} catch (\Throwable $e) {
// Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund,
// die Anmeldung scheitern zu lassen.
Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]);
}
}
public static function logout(): void
{
self::startSession();
+85
View File
@@ -40,6 +40,11 @@ final class TokenManager
'updateservice:read',
'updateservice:publish',
'tokens:provision',
// Erstinstallation: Katalog lesen und eine Anwendung einrichten.
// Diese Rechte traegt ausschliesslich das kurzlebige Token aus
// /api/setup/v1/login - sie gehoeren nicht auf ein Dauertoken.
'setup:catalog',
'setup:install',
];
private PDO $db;
@@ -111,6 +116,82 @@ final class TokenManager
];
}
/**
* Erzeugt ein kurzlebiges Token ohne Elterntoken.
*
* Gedacht fuer die Erstinstallation: Der Installer meldet sich mit
* Benutzername und Passwort an und bekommt dafuer ein Token, das nach
* wenigen Minuten verfaellt. Bewusst kein Master-Token - es soll nichts
* weitervererben koennen - und bewusst mit Ablauf, weil es auf einem
* fremden Zielsystem im Speicher liegt.
*
* @param list<string> $scopes
*/
public function createEphemeralToken(
string $name,
array $scopes,
int $ttlSeconds,
?string $ownerIdentity = null
): array {
$name = trim($name);
if ($name === '') {
throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
}
$effectiveScopes = self::normalizeScopes($scopes, []);
if ($effectiveScopes === []) {
throw new InvalidArgumentException('Ein kurzlebiges Token ohne Rechte waere wirkungslos.');
}
// Eine Obergrenze verhindert, dass aus einem Setup-Token durch einen
// grosszuegigen Aufrufer ein Dauertoken wird.
$ttlSeconds = max(60, min($ttlSeconds, 3600));
$tokenId = 'tok_s_' . bin2hex(random_bytes(8));
$rawToken = 'dc_setup_' . bin2hex(random_bytes(24));
$expires = gmdate('Y-m-d H:i:s', time() + $ttlSeconds);
$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, expires_at, created_at
) VALUES (
:id, NULL, :hash, NULL, :name,
NULL, NULL, "host", :identity,
"sub", :scopes, "all", :expires, UTC_TIMESTAMP()
)
');
// raw_token bleibt hier immer NULL, auch wenn die Konfiguration das
// Mitschreiben erlaubt: ein Setup-Token wird einmal ausgeliefert und
// muss nirgends nachschlagbar sein.
$stmt->execute([
':id' => $tokenId,
':hash' => hash('sha256', $rawToken),
':name' => $name,
':identity' => self::nullIfEmpty($ownerIdentity),
':scopes' => json_encode($effectiveScopes),
':expires' => $expires,
]);
Logger::info('Kurzlebiges Token erstellt', [
'token_id' => $tokenId,
'name' => $name,
'scopes' => $effectiveScopes,
'expires_at' => $expires,
]);
return [
'token_id' => $tokenId,
'raw_token' => $rawToken,
'name' => $name,
'type' => 'sub',
'scopes' => $effectiveScopes,
'expires_at' => $expires,
];
}
/**
* Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
@@ -353,6 +434,10 @@ final class TokenManager
'updateservice:publish' => ['updateservice:read'],
'watchdog:evaluate' => ['watchdog:read'],
'watchdog:ping' => ['watchdog:read'],
// Wer einrichten darf, muss den Katalog sehen und Releases lesen
// koennen - sonst gibt es nichts zu installieren.
'setup:install' => ['setup:catalog', 'updateservice:read'],
'setup:catalog' => ['updateservice:read'],
];
/**
+506
View File
@@ -0,0 +1,506 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Notify;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Rocket.Chat Notifier.
*
* Verwaltet die Authentifizierung am Rocket.Chat-Server und versendet:
* 1. Regelmaessige System-Statusberichte (standardmaessig alle 12 Stunden)
* 2. Sofortige Warnmeldungen bei Ausfaellen oder kritischen Bugs (eigener Alarm-Kanal)
*/
final class RocketChatNotifier
{
private const TIMEOUT_SECONDS = 5;
/**
* Ermittelt die aktuelle Rocket.Chat-Konfiguration (Dateivorlage + Datenbank-Overrides).
*
* @return array{
* enabled: bool,
* url: string,
* username: string,
* password: string,
* status_channel: string,
* alert_channel: string,
* verify_ssl: bool,
* report_interval: int
* }
*/
public static function getConfig(PDO $db): array
{
$fileConfig = (array)Config::get('rocketchat', []);
$dbSettings = [];
try {
$stmt = $db->query("
SELECT skey, svalue FROM dc_settings
WHERE skey LIKE 'rocketchat_%'
");
if ($stmt !== false) {
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$dbSettings[$row['skey']] = $row['svalue'];
}
}
} catch (\Throwable $e) {
// Tabelle fehlt evtl. vor Migration
}
$enabled = isset($dbSettings['rocketchat_enabled'])
? (bool)(int)$dbSettings['rocketchat_enabled']
: (bool)($fileConfig['enabled'] ?? true);
$url = trim((string)($dbSettings['rocketchat_url'] ?? $fileConfig['url'] ?? 'https://chat.wh1.mhdf.de'));
$url = rtrim($url, '/');
if (str_ends_with($url, '/home')) {
$url = substr($url, 0, -5);
}
$username = trim((string)($dbSettings['rocketchat_username'] ?? $fileConfig['username'] ?? 'deploymentcenter'));
$password = (string)($dbSettings['rocketchat_password'] ?? $fileConfig['password'] ?? 'cNt.m.KcWHrb8_X9Tv8-');
$statusChannel = trim((string)($dbSettings['rocketchat_status_channel'] ?? $fileConfig['status_channel'] ?? '#DC-Systemstatus'));
$alertChannel = trim((string)($dbSettings['rocketchat_alert_channel'] ?? $fileConfig['alert_channel'] ?? '#DC-Alerts'));
$verifySsl = isset($dbSettings['rocketchat_verify_ssl'])
? (bool)(int)$dbSettings['rocketchat_verify_ssl']
: (bool)($fileConfig['verify_ssl'] ?? false);
$reportInterval = isset($dbSettings['rocketchat_report_interval'])
? (int)$dbSettings['rocketchat_report_interval']
: (int)($fileConfig['report_interval'] ?? 43200);
return [
'enabled' => $enabled,
'url' => $url,
'username' => $username,
'password' => $password,
'status_channel' => $statusChannel !== '' ? $statusChannel : '#general',
'alert_channel' => $alertChannel !== '' ? $alertChannel : '#general',
'verify_ssl' => $verifySsl,
'report_interval' => max(300, $reportInterval),
];
}
/**
* Fuehrt den Login am Rocket.Chat-Server aus und liefert Auth-Token und User-ID zurück.
*
* @param array<string,mixed> $config
* @return array{authToken: string, userId: string}|null
*/
public static function authenticate(array $config): ?array
{
$url = rtrim((string)($config['url'] ?? ''), '/') . '/api/v1/login';
$payload = json_encode([
'user' => (string)($config['username'] ?? ''),
'password' => (string)($config['password'] ?? ''),
]);
[$ok, $status, $response, $error] = self::httpPost($url, $payload, ['Content-Type: application/json'], (bool)($config['verify_ssl'] ?? false));
if (!$ok || $status !== 200 || $response === null) {
Logger::warning('Rocket.Chat Login fehlgeschlagen', [
'status' => $status,
'error' => $error ?? 'Keine Antwort',
]);
return null;
}
$json = json_decode($response, true);
if (!is_array($json) || empty($json['success'])) {
Logger::warning('Rocket.Chat Login ungueltige Antwort', ['json' => $json]);
return null;
}
$authToken = (string)($json['data']['authToken'] ?? '');
$userId = (string)($json['data']['userId'] ?? '');
if ($authToken === '' || $userId === '') {
return null;
}
return [
'authToken' => $authToken,
'userId' => $userId,
];
}
/**
* Sendet eine Nachricht (optional mit Attachments) an einen Rocket.Chat-Kanal.
*
* @param array<string,mixed> $overrideConfig
* @param list<array<string,mixed>> $attachments
*/
public static function send(PDO $db, string $channel, string $text, array $attachments = [], array $overrideConfig = []): bool
{
$config = array_merge(self::getConfig($db), $overrideConfig);
if (empty($config['enabled']) && empty($overrideConfig['ignore_enabled'])) {
return false;
}
$auth = self::authenticate($config);
if ($auth === null) {
return false;
}
$postUrl = rtrim((string)$config['url'], '/') . '/api/v1/chat.postMessage';
$body = [
'channel' => $channel,
'text' => $text,
];
if ($attachments !== []) {
$body['attachments'] = $attachments;
}
$jsonPayload = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($jsonPayload === false) {
return false;
}
$headers = [
'Content-Type: application/json',
'X-Auth-Token: ' . $auth['authToken'],
'X-User-Id: ' . $auth['userId'],
];
[$ok, $status, $response, $error] = self::httpPost($postUrl, $jsonPayload, $headers, (bool)$config['verify_ssl']);
if (!$ok || $status !== 200) {
Logger::error('Rocket.Chat Nachrichten-Versand fehlgeschlagen', [
'channel' => $channel,
'status' => $status,
'error' => $error,
]);
return false;
}
return true;
}
/**
* Erzeugt und versendet den 12-Stunden-Systemstatusbericht an Rocket.Chat.
*
* @return array{sent: bool, reason: string}
*/
public static function sendStatusReport(PDO $db, bool $force = false): array
{
$config = self::getConfig($db);
if (!$force && empty($config['enabled'])) {
return ['sent' => false, 'reason' => 'Rocket.Chat Benachrichtigungen sind deaktiviert.'];
}
$intervalSec = $config['report_interval'];
if (!$force) {
try {
$stmt = $db->prepare("SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'");
$stmt->execute();
$lastRun = $stmt->fetchColumn();
if ($lastRun !== false && $lastRun !== null) {
$lastRunTs = strtotime((string)$lastRun . ' UTC');
if ($lastRunTs !== false && (time() - $lastRunTs) < $intervalSec) {
return ['sent' => false, 'reason' => 'Statusbericht noch nicht faellig.'];
}
}
} catch (\Throwable $e) {
// Bei Tabellenfehler fortfahren
}
}
// Monitore ermitteln
$totalMonitors = 0;
$upCount = 0;
$warningCount = 0;
$downCount = 0;
$stoppedCount = 0;
$problemMonitors = [];
try {
$stmt = $db->query('SELECT source, instance, state, last_message, updated_utc FROM watchdog_monitors');
if ($stmt !== false) {
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
$totalMonitors = count($rows);
foreach ($rows as $m) {
$st = (string)($m['state'] ?? 'unknown');
if ($st === 'up') {
$upCount++;
} elseif ($st === 'warning') {
$warningCount++;
$problemMonitors[] = "⚠️ **{$m['source']}** ({$m['instance']}): Warning - " . ($m['last_message'] ?: 'Intervall ueberschritten');
} elseif ($st === 'down' || $st === 'error') {
$downCount++;
$problemMonitors[] = "🔴 **{$m['source']}** ({$m['instance']}): DOWN - " . ($m['last_message'] ?: 'Kein Heartbeat');
} elseif ($st === 'stopped') {
$stoppedCount++;
}
}
}
} catch (\Throwable $e) {
Logger::warning('Fehler beim Abrufen der Monitore fuer RocketChat-Bericht', ['error' => $e->getMessage()]);
}
// Bugtracker Statistiken
$openBugs = 0;
$criticalBugs = 0;
try {
$stmt = $db->query("SELECT COUNT(*) FROM bugtracker_items WHERE status IN ('open', 'planned', 'in_progress')");
if ($stmt !== false) {
$openBugs = (int)$stmt->fetchColumn();
}
$stmt = $db->query("SELECT COUNT(*) FROM bugtracker_items WHERE status IN ('open', 'planned', 'in_progress') AND severity = 'critical'");
if ($stmt !== false) {
$criticalBugs = (int)$stmt->fetchColumn();
}
} catch (\Throwable $e) {
// Ignorieren falls nicht verfuegbar
}
// Farbe und Titel bestimmen
$color = '#28a745'; // Gruen
$statusHeader = '🟢 **System-Statusbericht: Alle Systeme betriebsbereit**';
if ($downCount > 0) {
$color = '#dc3545'; // Rot
$statusHeader = "🔴 **System-Statusbericht: {$downCount} System(e) AUSGEFALLEN**";
} elseif ($warningCount > 0 || $criticalBugs > 0) {
$color = '#ffc107'; // Gelb
$statusHeader = "⚠️ **System-Statusbericht: Warnungen vorhanden**";
}
$appUrl = (string)Config::get('app.url', '');
$fields = [
['title' => 'Monitore Gesamt', 'value' => (string)$totalMonitors, 'short' => true],
['title' => 'Status UP', 'value' => (string)$upCount, 'short' => true],
['title' => 'Status WARNING', 'value' => (string)$warningCount, 'short' => true],
['title' => 'Status DOWN', 'value' => (string)$downCount, 'short' => true],
['title' => 'Offene Bugtracker-Items', 'value' => (string)$openBugs, 'short' => true],
['title' => 'Kritische Bugs', 'value' => (string)$criticalBugs, 'short' => true],
];
$detailText = '';
if ($problemMonitors !== []) {
$detailText .= "\n\n**Auffaellige Systeme:**\n" . implode("\n", array_slice($problemMonitors, 0, 10));
}
if ($appUrl !== '') {
$detailText .= "\n\n🔗 [Zum Deploymentcenter Dashboard]({$appUrl})";
}
$attachments = [
[
'color' => $color,
'title' => 'Deploymentcenter Statusübersicht (12-Stunden-Intervall)',
'text' => $statusHeader . $detailText,
'fields' => $fields,
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
]
];
$sent = self::send($db, $config['status_channel'], '📊 **Deploymentcenter Statusbericht**', $attachments);
if ($sent) {
self::recordReportRun($db, 'ok');
return ['sent' => true, 'reason' => 'Statusbericht erfolgreich versendet.'];
}
self::recordReportRun($db, 'failed');
return ['sent' => false, 'reason' => 'Versand des Statusberichts an Rocket.Chat fehlgeschlagen.'];
}
/**
* Sendet eine sofortige kritische Warnmeldung an den Alarm-Kanal.
*
* @param array<string,mixed> $details
*/
public static function sendAlert(PDO $db, string $title, string $message, string $severity = 'alarm', array $details = []): bool
{
$config = self::getConfig($db);
if (empty($config['enabled'])) {
return false;
}
$color = $severity === 'alarm' ? '#dc3545' : '#ffc107';
$icon = $severity === 'alarm' ? '🚨' : '⚠️';
$fields = [];
foreach ($details as $k => $v) {
if (is_scalar($v)) {
$fields[] = [
'title' => ucfirst((string)$k),
'value' => (string)$v,
'short' => true,
];
}
}
$appUrl = (string)Config::get('app.url', '');
$text = "{$icon} **[ALARM] {$title}**\n{$message}";
if ($appUrl !== '') {
$text .= "\n🔗 [Deploymentcenter Öffnen]({$appUrl})";
}
$attachments = [
[
'color' => $color,
'title' => "System-Warnung: {$title}",
'text' => $text,
'fields' => $fields,
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
]
];
return self::send($db, $config['alert_channel'], "{$icon} **Kritische Benachrichtigung vom Deploymentcenter**", $attachments);
}
/**
* Testet die Rocket.Chat Verbindungsdaten und schickt eine Testnachricht an beide Kanaele.
*
* @param array<string,mixed> $config
* @return array{success: bool, message: string}
*/
public static function testConnection(array $config): array
{
$config['ignore_enabled'] = true;
$auth = self::authenticate($config);
if ($auth === null) {
return [
'success' => false,
'message' => 'Login fehlgeschlagen: Die Anmeldedaten oder die Server-URL sind ungueltig.',
];
}
$statusChannel = (string)($config['status_channel'] ?? '#systemstatus');
$alertChannel = (string)($config['alert_channel'] ?? '#alerts');
$dbMock = new class extends PDO {
public function __construct() {}
};
// Standard-Dummy-PDO fuer den Testaufruf ohne DB-Abhangigkeit
$sentStatus = self::sendDirect($config, $auth, $statusChannel, '✅ **Rocket.Chat Verbindungstest**: Status-Kanal erreichbar.');
$sentAlert = self::sendDirect($config, $auth, $alertChannel, '🚨 **Rocket.Chat Verbindungstest**: Alarm-Kanal erreichbar.');
if ($sentStatus && $sentAlert) {
return [
'success' => true,
'message' => "Verbindung erfolgreich! Testnachrichten wurden an {$statusChannel} und {$alertChannel} gesendet.",
];
}
if ($sentStatus || $sentAlert) {
return [
'success' => true,
'message' => "Teilweise erfolgreich: Login klappte, aber mindestens ein Kanal war nicht erreichbar.",
];
}
return [
'success' => false,
'message' => 'Login erfolgreich, aber Nachrichten konnten in den angegebenen Kanaelen nicht gepostet werden.',
];
}
private static function sendDirect(array $config, array $auth, string $channel, string $text): bool
{
$postUrl = rtrim((string)$config['url'], '/') . '/api/v1/chat.postMessage';
$body = json_encode([
'channel' => $channel,
'text' => $text,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$headers = [
'Content-Type: application/json',
'X-Auth-Token: ' . $auth['authToken'],
'X-User-Id: ' . $auth['userId'],
];
[$ok, $status] = self::httpPost($postUrl, (string)$body, $headers, (bool)($config['verify_ssl'] ?? false));
return $ok && $status === 200;
}
private static function recordReportRun(PDO $db, string $status): void
{
try {
$stmt = $db->prepare('
INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, running, last_status, enabled)
VALUES ("rocketchat_status_report", 43200, UTC_TIMESTAMP(), 0, :status, 1)
ON DUPLICATE KEY UPDATE
last_run_utc = UTC_TIMESTAMP(),
running = 0,
last_status = VALUES(last_status)
');
$stmt->execute([':status' => $status]);
} catch (\Throwable $e) {
// Ignorieren falls DB unvollstaendig
}
}
/**
* @param list<string> $headers
* @return array{0:bool, 1:int, 2:?string, 3:?string}
*/
private static function httpPost(string $url, string $payload, array $headers, bool $verifySsl): array
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch === false) {
return [false, 0, null, 'curl_init fehlgeschlagen'];
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => $verifySsl,
CURLOPT_SSL_VERIFYHOST => $verifySsl ? 2 : 0,
]);
$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, is_string($response) ? $response : null, $error];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $payload,
'timeout' => self::TIMEOUT_SECONDS,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => $verifySsl,
'verify_peer_name' => $verifySsl,
],
]);
$response = @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 [
$response !== false && $status >= 200 && $status < 300,
$status,
$response !== false ? $response : null,
$response === false ? 'HTTP-Anfrage fehlgeschlagen' : null,
];
}
}
+28
View File
@@ -41,6 +41,34 @@ final class WebhookDispatcher
public static function dispatch(PDO $db, string $event, array $payload): void
{
// Kritische Ereignisse direkt an Rocket.Chat spiegeln
if ($event === 'bug.critical') {
RocketChatNotifier::sendAlert(
$db,
'Kritischer Bug gemeldet',
(string)($payload['title'] ?? $payload['error_message'] ?? 'Neuer kritischer Fehler'),
'alarm',
[
'Projekt' => $payload['project_slug'] ?? 'Unbekannt',
'Umgebung' => $payload['environment'] ?? 'production',
'Item-ID' => $payload['item_id'] ?? 'neu',
]
);
} elseif ($event === 'monitor.down') {
RocketChatNotifier::sendAlert(
$db,
'System-Ausfall erkannt (Watchdog)',
(string)($payload['source'] ?? 'Monitor') . ' (' . ($payload['instance'] ?? 'default') . '): ' . ($payload['reason'] ?? 'Heartbeat ausgeblieben'),
'alarm',
[
'Quelle' => $payload['source'] ?? 'unbekannt',
'Instanz' => $payload['instance'] ?? 'default',
'Von Zustand' => $payload['from_state'] ?? 'up',
'Nach Zustand' => $payload['to_state'] ?? 'down',
]
);
}
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
return;
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Setup;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\UpdateService\Version;
use PDO;
/**
* Stellt zusammen, was auf einem bestimmten System installierbar ist.
*
* Der Installer fragt diese Liste ab, nachdem er sich angemeldet hat, und
* zeigt sie zur Auswahl. Massgeblich ist dabei die Laufzeitkennung des
* Zielsystems: ein Projekt, von dem es nur ein Windows-Paket gibt, taucht auf
* einem Linux-Rechner gar nicht erst auf. Alles andere waere eine Auswahl,
* die beim Anklicken fehlschlaegt.
*/
final class SetupCatalog
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* @return list<array<string,mixed>>
*/
public function forPlatform(?string $platform, ?string $projectFilter = null): array
{
$requested = UpdateManager::normalizePlatform($platform);
$candidates = $requested === UpdateManager::PLATFORM_ANY
? [UpdateManager::PLATFORM_ANY]
: [$requested, UpdateManager::PLATFORM_ANY];
$placeholders = implode(', ', array_map(
static fn(int $i): string => ':platform' . $i,
array_keys($candidates)
));
$sql = '
SELECT r.*, p.name AS project_name, p.notes AS project_notes
FROM updateservice_releases r
LEFT JOIN dc_projects p ON p.slug = r.product_slug
WHERE r.platform IN (' . $placeholders . ')
';
$params = [];
foreach ($candidates as $i => $candidate) {
$params[':platform' . $i] = $candidate;
}
if ($projectFilter !== null && $projectFilter !== '') {
$sql .= ' AND r.product_slug = :slug';
$params[':slug'] = $projectFilter;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll() ?: [];
// Nach Projekt und Kanal buendeln; je Version gewinnt - wie im
// UpdateManager - das plattformgenaue Paket vor dem generischen.
$grouped = [];
foreach ($rows as $row) {
$slug = (string)$row['product_slug'];
$channel = (string)$row['channel'];
$version = (string)$row['version'];
$key = $slug . "\0" . $channel . "\0" . $version;
$existing = $grouped[$key] ?? null;
if ($existing === null) {
$grouped[$key] = $row;
continue;
}
$existingPlatform = (string)($existing['platform'] ?? UpdateManager::PLATFORM_ANY);
$rowPlatform = (string)($row['platform'] ?? UpdateManager::PLATFORM_ANY);
if ($existingPlatform === UpdateManager::PLATFORM_ANY
&& $rowPlatform !== UpdateManager::PLATFORM_ANY) {
$grouped[$key] = $row;
}
}
// Je Projekt und Kanal das hoechste Release ermitteln.
$byProject = [];
foreach ($grouped as $row) {
$slug = (string)$row['product_slug'];
$channel = (string)$row['channel'];
if (!isset($byProject[$slug])) {
$byProject[$slug] = [
'slug' => $slug,
'name' => (string)($row['project_name'] ?? $slug),
'notes' => $row['project_notes'] !== null ? (string)$row['project_notes'] : null,
'channels' => [],
];
}
$current = $byProject[$slug]['channels'][$channel] ?? null;
if ($current === null
|| Version::isNewer((string)$row['version'], (string)$current['version'])) {
$byProject[$slug]['channels'][$channel] = [
'channel' => $channel,
'version' => (string)$row['version'],
'platform' => (string)($row['platform'] ?? UpdateManager::PLATFORM_ANY),
'size_bytes' => (int)$row['size_bytes'],
'is_critical' => (bool)$row['is_critical'],
'signed' => !empty($row['manifest_signature']),
'release_notes'=> $row['release_notes'] !== null ? (string)$row['release_notes'] : null,
'released_at' => (string)$row['created_at'],
'download_url' => (string)$row['download_url'],
];
}
}
// Kanaele in eine verlaessliche Reihenfolge bringen: was am ehesten
// gewaehlt werden soll, steht vorn.
$order = ['prod' => 0, 'beta' => 1, 'dev' => 2];
$catalog = [];
foreach ($byProject as $entry) {
$channels = array_values($entry['channels']);
usort($channels, static function (array $a, array $b) use ($order): int {
$rankA = $order[$a['channel']] ?? 99;
$rankB = $order[$b['channel']] ?? 99;
return $rankA === $rankB ? strcmp($a['channel'], $b['channel']) : $rankA <=> $rankB;
});
$entry['channels'] = $channels;
$catalog[] = $entry;
}
usort($catalog, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return $catalog;
}
}
+89 -1
View File
@@ -268,11 +268,99 @@ final class UpdateManager
return is_array($row) ? $row : null;
}
public function getReleaseById(int $id): ?array
{
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE id = :id
LIMIT 1
');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
public function updateRelease(
int $id,
string $productSlug,
string $version,
string $channel = 'prod',
?string $releaseNotes = null,
string $downloadUrl = '',
?string $sha256Hash = null,
?string $gitCommit = null,
int $sizeBytes = 0,
?string $manifestJson = null,
bool $isCritical = false,
string $author = 'admin',
?string $platform = null,
?string $manifestSignature = null
): bool {
$platform = self::normalizePlatform($platform);
$stmt = $this->db->prepare('
UPDATE updateservice_releases SET
product_slug = :slug,
version = :version,
channel = :channel,
platform = :platform,
release_notes = :notes,
download_url = :url,
sha256_hash = :hash,
git_commit = :git,
size_bytes = :size,
manifest_json = :manifest,
manifest_signature = :signature,
is_critical = :critical
WHERE id = :id
');
$stmt->execute([
':id' => $id,
':slug' => $productSlug,
':version' => $version,
':channel' => $channel,
':platform' => $platform,
':notes' => $releaseNotes,
':url' => $downloadUrl,
':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
':size' => $sizeBytes,
':manifest' => $manifestJson,
':signature' => $manifestSignature !== null && $manifestSignature !== '' ? $manifestSignature : null,
':critical' => $isCritical ? 1 : 0,
]);
Logger::info('Release aktualisiert', [
'id' => $id,
'product' => $productSlug,
'version' => $version,
'channel' => $channel,
'platform' => $platform,
'author' => $author,
]);
return true;
}
public function deleteRelease(int $id): bool
{
$existing = $this->getReleaseById($id);
$stmt = $this->db->prepare('DELETE FROM updateservice_releases WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount() > 0;
$deleted = $stmt->rowCount() > 0;
if ($deleted && $existing !== null) {
Logger::info('Release geloescht', [
'id' => $id,
'product' => $existing['product_slug'],
'version' => $existing['version'],
'channel' => $existing['channel'],
'platform' => $existing['platform'] ?? self::PLATFORM_ANY,
]);
}
return $deleted;
}
/**
+8
View File
@@ -6,6 +6,7 @@ namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Notify\RocketChatNotifier;
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
use PDO;
@@ -150,6 +151,13 @@ final class Evaluator
$purgedMetrics = (new MetricStore($db))->purge();
}
// Rocket.Chat 12-Stunden-Statusbericht (sofern faellig)
try {
RocketChatNotifier::sendStatusReport($db, false);
} catch (\Throwable $e) {
Logger::warning('RocketChat-Statusbericht fehlgeschlagen', ['error' => $e->getMessage()]);
}
$durationMs = (int)round((microtime(true) - $started) * 1000);
self::recordRun($db, $durationMs, count($changes));