@@ -3944,6 +4747,7 @@ SYSTEM
],
'updateservice': [
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
+ { id: 'sub-update-installer', label: '⬇️ Installer' },
{ id: 'sub-update-publish', label: '➕ Release Veröffentlichen' }
],
'bugtracker': [
@@ -3959,6 +4763,8 @@ SYSTEM
],
'system': [
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
+ { id: 'sub-system-users', label: '👤 Benutzer' },
+ { id: 'sub-system-rocketchat', label: '💬 Rocket.Chat' },
{ id: 'sub-system-swagger', label: '📖 API Swagger Docs' },
{ id: 'sub-system-migration', label: '🗄️ DB Migration' }
]
@@ -4373,6 +5179,55 @@ SYSTEM
document.getElementById('projSubmitBtn').innerText = 'Projekt Anlegen';
}
+ // UpdateService Release Modal Functions
+ function openEditReleaseModal(r) {
+ const setVal = (id, val) => {
+ const el = document.getElementById(id);
+ if (el) el.value = val;
+ };
+
+ setVal('edit_release_id', r.id || 0);
+ setVal('edit_release_product_slug', r.product_slug || '');
+ setVal('edit_release_channel', r.channel || 'prod');
+ setVal('edit_release_platform', r.platform || 'any');
+ setVal('edit_release_version', r.version || '');
+ setVal('edit_release_git_commit', r.git_commit || '');
+ setVal('edit_release_download_url', r.download_url || '');
+ setVal('edit_release_sha256_hash', r.sha256_hash || '');
+ setVal('edit_release_size_bytes', r.size_bytes || 0);
+ setVal('edit_release_release_notes', r.release_notes || '');
+
+ const critEl = document.getElementById('edit_release_is_critical');
+ if (critEl) critEl.checked = Boolean(Number(r.is_critical));
+
+ const modal = document.getElementById('editReleaseModal');
+ if (modal) modal.classList.add('active');
+ }
+
+ function closeEditReleaseModal() {
+ const modal = document.getElementById('editReleaseModal');
+ if (modal) modal.classList.remove('active');
+ }
+
+ function openDeleteReleaseModal(id, version, product) {
+ const idEl = document.getElementById('delete_release_id');
+ if (idEl) idEl.value = id;
+
+ const verEl = document.getElementById('delete_release_version_display');
+ if (verEl) verEl.textContent = 'v' + version;
+
+ const prodEl = document.getElementById('delete_release_product_display');
+ if (prodEl) prodEl.textContent = product;
+
+ const modal = document.getElementById('deleteReleaseModal');
+ if (modal) modal.classList.add('active');
+ }
+
+ function closeDeleteReleaseModal() {
+ const modal = document.getElementById('deleteReleaseModal');
+ if (modal) modal.classList.remove('active');
+ }
+
// Project Safety Deletion Modal
function openDeleteProjectModal(id, slug, name) {
document.getElementById('deleteProjId').value = id;
diff --git a/scripts/build_installer.ps1 b/scripts/build_installer.ps1
new file mode 100644
index 0000000..4295f12
--- /dev/null
+++ b/scripts/build_installer.ps1
@@ -0,0 +1,121 @@
+<#
+ Baut den Update-Agent als eigenstaendige Installer-Binaries und legt sie
+ samt Pruefsummen, Manifest und Bootstrap-Skripten zum Upload bereit.
+
+ pwsh scripts/build_installer.ps1
+ python scripts/upload_installer.py .\artifacts\installer
+
+ Selbstenthaltend und als Einzeldatei: Auf einem frisch aufgesetzten
+ Zielsystem ist keine .NET-Laufzeit vorhanden, und ein Installer, der erst
+ eine Laufzeit nachinstalliert, hat sein Versprechen schon gebrochen.
+
+ Bewusst OHNE NativeAOT und ohne Trimming: Spectre.Console loest seine
+ Eingabeaufforderungen ueber Reflexion auf. Getrimmt baut das zwar, bricht
+ aber erst beim Anwender - ein groesseres Binary ist der bessere Handel.
+#>
+
+param(
+ [string] $Version = '2.3.0',
+ [string] $OutputDir = (Join-Path $PSScriptRoot '..\artifacts\installer'),
+ [string[]] $Runtimes = @('win-x64', 'linux-x64', 'linux-arm64')
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
+$project = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.UpdateAgent\Deploymentcenter.UpdateAgent.csproj'
+$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-build-" + [guid]::NewGuid().ToString('N'))
+
+if (-not (Test-Path $project)) {
+ throw "Projekt nicht gefunden: $project"
+}
+
+New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
+Get-ChildItem $OutputDir -File | Remove-Item -Force
+
+$gitCommit = 'UNKNOWN'
+try { $gitCommit = (git -C $repoRoot rev-parse --short HEAD).Trim() } catch { }
+
+$binaries = @()
+
+try {
+ foreach ($rid in $Runtimes) {
+ Write-Host "Baue $rid ..." -ForegroundColor Cyan
+
+ $ridOut = Join-Path $staging $rid
+
+ dotnet publish $project `
+ -c Release -r $rid `
+ --self-contained true `
+ -p:PublishSingleFile=true `
+ -p:EnableCompressionInSingleFile=true `
+ -p:DebugType=None `
+ -o $ridOut `
+ -v q --nologo
+
+ if ($LASTEXITCODE -ne 0) {
+ throw "dotnet publish fuer $rid ist fehlgeschlagen."
+ }
+
+ $isWindows = $rid.StartsWith('win')
+ $sourceName = if ($isWindows) { 'update-agent.exe' } else { 'update-agent' }
+ $targetName = if ($isWindows) { "update-agent-$rid.exe" } else { "update-agent-$rid" }
+
+ $source = Join-Path $ridOut $sourceName
+ $target = Join-Path $OutputDir $targetName
+
+ Copy-Item $source $target -Force
+
+ $hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLower()
+ $size = (Get-Item $target).Length
+
+ # Die Pruefsumme liegt als eigene Datei daneben, damit install.sh sie
+ # ohne JSON-Parser lesen kann.
+ [System.IO.File]::WriteAllText("$target.sha256", $hash)
+
+ $binaries += [ordered]@{
+ platform = $rid
+ file = $targetName
+ sha256 = $hash
+ sizeBytes = $size
+ }
+
+ Write-Host (" {0,-22} {1,6:N1} MB {2}" -f $targetName, ($size / 1MB), $hash.Substring(0, 16))
+ }
+
+ $manifest = [ordered]@{
+ tool = 'update-agent'
+ version = $Version
+ gitCommit = $gitCommit
+ buildDateUtc = (Get-Date).ToUniversalTime().ToString('o')
+ binaries = $binaries
+ }
+
+ # Bewusst ueber WriteAllText mit einer BOM-freien Kodierung: Out-File
+ # -Encoding utf8 stellt unter Windows PowerShell 5.1 ein BOM voran, und
+ # PHPs json_decode() scheitert daran. Die Downloadseite haette dann
+ # dauerhaft "noch nichts hinterlegt" gemeldet.
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+ [System.IO.File]::WriteAllText(
+ (Join-Path $OutputDir 'installer.json'),
+ ($manifest | ConvertTo-Json -Depth 5),
+ $utf8NoBom)
+
+ # Die Bootstrap-Skripte liegen versioniert im Repository und werden hier
+ # nur mitgenommen.
+ foreach ($script in @('install.sh', 'install.ps1')) {
+ $path = Join-Path $repoRoot "scripts\installer\$script"
+ if (Test-Path $path) {
+ Copy-Item $path (Join-Path $OutputDir $script) -Force
+ } else {
+ Write-Warning "$script nicht gefunden unter scripts\installer\ - wird nicht mit ausgeliefert."
+ }
+ }
+
+ Write-Host ''
+ Write-Host "Fertig. Ergebnis in $OutputDir" -ForegroundColor Green
+ Write-Host "Hochladen mit: python scripts/upload_installer.py `"$OutputDir`""
+}
+finally {
+ Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue
+}
diff --git a/scripts/installer/install.ps1 b/scripts/installer/install.ps1
new file mode 100644
index 0000000..1064872
--- /dev/null
+++ b/scripts/installer/install.ps1
@@ -0,0 +1,72 @@
+<#
+ Holt den Deploymentcenter Update-Agent und legt ihn ausfuehrbar ab.
+
+ irm https://dc.mhdf.de/installer/install.ps1 | iex
+
+ Das Skript fuehrt die Installation NICHT selbst aus. Es laedt das Binary,
+ prueft die Pruefsumme und sagt, wie es weitergeht - was danach passiert,
+ entscheidet der Mensch davor. Ein Skript aus dem Netz, das ungefragt eine
+ Anwendung einrichtet und dabei nach Zugangsdaten fragt, waere genau das
+ Muster, vor dem man Nutzer sonst warnt.
+#>
+
+$ErrorActionPreference = 'Stop'
+
+$baseUrl = if ($env:DC_BASE_URL) { $env:DC_BASE_URL.TrimEnd('/') } else { 'https://dc.mhdf.de' }
+$targetDir = if ($env:DC_INSTALL_DIR) { $env:DC_INSTALL_DIR } else { $PWD.Path }
+
+# ------------------------------------------------------------------ Plattform
+$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture
+
+switch ($arch) {
+ 'X64' { $rid = 'win-x64' }
+ default {
+ Write-Error "Nicht unterstuetzte Architektur: $arch. Verfuegbar ist derzeit win-x64."
+ return
+ }
+}
+
+$binary = "update-agent-$rid.exe"
+$target = Join-Path $targetDir 'update-agent.exe'
+$temp = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-" + [guid]::NewGuid().ToString('N'))
+
+New-Item -ItemType Directory -Path $temp -Force | Out-Null
+
+try {
+ Write-Host "Lade $binary von $baseUrl ..."
+
+ $tempBinary = Join-Path $temp 'agent.exe'
+ Invoke-WebRequest -Uri "$baseUrl/installer/$binary" -OutFile $tempBinary -UseBasicParsing
+ $expected = (Invoke-WebRequest -Uri "$baseUrl/installer/$binary.sha256" -UseBasicParsing).Content.Trim().ToLower()
+
+ # --------------------------------------------------------- Pruefsumme
+ $actual = (Get-FileHash $tempBinary -Algorithm SHA256).Hash.ToLower()
+
+ if ($actual -ne $expected) {
+ Write-Error "ABBRUCH: Pruefsumme stimmt nicht ueberein.`n erwartet: $expected`n erhalten: $actual"
+ return
+ }
+
+ Write-Host 'Pruefsumme in Ordnung.'
+
+ # ------------------------------------------------------------ Ablegen
+ Move-Item -Path $tempBinary -Destination $target -Force
+
+ # Von Windows als "aus dem Internet" markierte Dateien loesen beim Start
+ # eine Sicherheitswarnung aus. Die Herkunft ist hier durch die gepruefte
+ # Pruefsumme belegt.
+ try { Unblock-File -Path $target -ErrorAction SilentlyContinue } catch { }
+
+ Write-Host ''
+ Write-Host "Abgelegt: $target"
+ Write-Host ''
+ Write-Host 'Weiter mit:'
+ Write-Host " & '$target' --action install"
+ Write-Host ''
+ Write-Host "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
+ Write-Host 'gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein'
+ Write-Host 'Zielsystem.'
+}
+finally {
+ Remove-Item -Recurse -Force $temp -ErrorAction SilentlyContinue
+}
diff --git a/scripts/installer/install.sh b/scripts/installer/install.sh
new file mode 100644
index 0000000..7b029a6
--- /dev/null
+++ b/scripts/installer/install.sh
@@ -0,0 +1,109 @@
+#!/bin/sh
+#
+# Holt den Deploymentcenter Update-Agent und legt ihn ausfuehrbar ab.
+#
+# wget -qO- https://dc.mhdf.de/installer/install.sh | sh
+#
+# Das Skript fuehrt die Installation NICHT selbst aus. Es laedt das Binary,
+# prueft die Pruefsumme und sagt, wie es weitergeht - was danach passiert,
+# entscheidet der Mensch davor. Ein Skript aus dem Netz, das ungefragt eine
+# Anwendung einrichtet und dabei nach Zugangsdaten fragt, waere genau das
+# Muster, vor dem man Nutzer sonst warnt.
+
+set -eu
+
+BASE_URL="${DC_BASE_URL:-https://dc.mhdf.de}"
+TARGET_DIR="${DC_INSTALL_DIR:-}"
+
+# ---------------------------------------------------------------- Plattform
+os="$(uname -s)"
+arch="$(uname -m)"
+
+case "$os" in
+ Linux) ;;
+ *)
+ echo "Nicht unterstuetzt: $os" >&2
+ echo "Fuer Windows: https://dc.mhdf.de/installer/install.ps1" >&2
+ exit 1
+ ;;
+esac
+
+case "$arch" in
+ x86_64|amd64) rid="linux-x64" ;;
+ aarch64|arm64) rid="linux-arm64" ;;
+ *)
+ echo "Nicht unterstuetzte Architektur: $arch" >&2
+ exit 1
+ ;;
+esac
+
+binary="update-agent-$rid"
+
+# ------------------------------------------------------------- Zielverzeichnis
+if [ -z "$TARGET_DIR" ]; then
+ if [ -w /usr/local/bin ] 2>/dev/null; then
+ TARGET_DIR="/usr/local/bin"
+ else
+ TARGET_DIR="$PWD"
+ fi
+fi
+
+target="$TARGET_DIR/update-agent"
+
+# --------------------------------------------------------------- Werkzeuge
+if command -v curl >/dev/null 2>&1; then
+ fetch() { curl -fsSL "$1" -o "$2"; }
+elif command -v wget >/dev/null 2>&1; then
+ fetch() { wget -q "$1" -O "$2"; }
+else
+ echo "Weder curl noch wget vorhanden." >&2
+ exit 1
+fi
+
+tmp="$(mktemp -d)"
+# shellcheck disable=SC2064
+trap "rm -rf '$tmp'" EXIT INT TERM
+
+echo "Lade $binary von $BASE_URL ..."
+fetch "$BASE_URL/installer/$binary" "$tmp/agent"
+fetch "$BASE_URL/installer/$binary.sha256" "$tmp/agent.sha256"
+
+# ------------------------------------------------------------- Pruefsumme
+expected="$(tr -d ' \t\r\n' < "$tmp/agent.sha256")"
+
+if command -v sha256sum >/dev/null 2>&1; then
+ actual="$(sha256sum "$tmp/agent" | cut -d' ' -f1)"
+elif command -v shasum >/dev/null 2>&1; then
+ actual="$(shasum -a 256 "$tmp/agent" | cut -d' ' -f1)"
+else
+ actual=""
+fi
+
+if [ -z "$actual" ]; then
+ echo "WARNUNG: Keine SHA256-Pruefung moeglich (weder sha256sum noch shasum)." >&2
+elif [ "$actual" != "$expected" ]; then
+ echo "ABBRUCH: Pruefsumme stimmt nicht ueberein." >&2
+ echo " erwartet: $expected" >&2
+ echo " erhalten: $actual" >&2
+ exit 1
+else
+ echo "Pruefsumme in Ordnung."
+fi
+
+# ------------------------------------------------------------------ Ablegen
+chmod +x "$tmp/agent"
+
+if ! mv "$tmp/agent" "$target" 2>/dev/null; then
+ echo "Kein Schreibrecht in $TARGET_DIR - versuche es mit sudo oder setze DC_INSTALL_DIR." >&2
+ exit 1
+fi
+
+echo ""
+echo "Abgelegt: $target"
+echo ""
+echo "Weiter mit:"
+echo " $target --action install"
+echo ""
+echo "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
+echo "gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein"
+echo "Zielsystem."
diff --git a/scripts/upload_installer.py b/scripts/upload_installer.py
new file mode 100644
index 0000000..b0c5f5b
--- /dev/null
+++ b/scripts/upload_installer.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""
+Laedt die gebauten Installer-Binaries in den Webroot unter /installer/.
+
+Getrennt von deploy.py, weil dieses client-dotnet bewusst ausklammert: der
+Quelltext des Agenten gehoert nicht auf den Webserver, die uebersetzten
+Binaries schon.
+
+Aufruf:
+ python scripts/upload_installer.py
+
+Erwartet im Verzeichnis: die Binaries, je eine .sha256 dazu, installer.json
+sowie install.sh und install.ps1. Erzeugt wird das alles von
+scripts/build_installer.ps1.
+"""
+
+import ftplib
+import hashlib
+import json
+import os
+import ssl
+import sys
+from pathlib import Path
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+CONFIG_FILE = SCRIPT_DIR / 'deploy_config.json'
+REMOTE_DIR = '/installer'
+
+
+def load_config():
+ if not CONFIG_FILE.exists():
+ print(f'Fehler: {CONFIG_FILE} fehlt.')
+ sys.exit(1)
+ with open(CONFIG_FILE, 'r', encoding='utf-8') as handle:
+ return json.load(handle)
+
+
+def connect(config):
+ if config.get('secure', False):
+ ftp = ftplib.FTP_TLS(context=ssl.create_default_context())
+ ftp.connect(config['host'], config.get('port', 21))
+ ftp.login(config['user'], config['pass'])
+ ftp.prot_p()
+ else:
+ ftp = ftplib.FTP()
+ ftp.connect(config['host'], config.get('port', 21))
+ ftp.login(config['user'], config['pass'])
+ return ftp
+
+
+def ensure_remote_dir(ftp, path):
+ current = ''
+ for part in [p for p in path.strip('/').split('/') if p]:
+ current += '/' + part
+ try:
+ ftp.cwd(current)
+ except ftplib.error_perm:
+ try:
+ ftp.mkd(current)
+ print(f'Verzeichnis angelegt: {current}')
+ except Exception as exc: # noqa: BLE001
+ print(f'Warnung: {current} nicht anlegbar: {exc}')
+
+
+def sha256(path):
+ digest = hashlib.sha256()
+ with open(path, 'rb') as handle:
+ while chunk := handle.read(65536):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__)
+ sys.exit(1)
+
+ source = Path(sys.argv[1]).resolve()
+ if not source.is_dir():
+ print(f'Fehler: {source} ist kein Verzeichnis.')
+ sys.exit(1)
+
+ files = sorted(p for p in source.iterdir() if p.is_file())
+ if not files:
+ print(f'Fehler: In {source} liegt nichts.')
+ sys.exit(1)
+
+ config = load_config()
+
+ print(f'Verbinde mit {config["host"]} ...')
+ ftp = connect(config)
+ print('Angemeldet.')
+
+ ensure_remote_dir(ftp, REMOTE_DIR)
+
+ uploaded = 0
+ try:
+ for path in files:
+ remote = f'{REMOTE_DIR}/{path.name}'
+ size_mb = path.stat().st_size / (1024 * 1024)
+ print(f' {path.name} ({size_mb:.1f} MB) ...')
+
+ ftp.cwd('/')
+ with open(path, 'rb') as handle:
+ ftp.storbinary(f'STOR {remote}', handle, blocksize=262144)
+
+ uploaded += 1
+ finally:
+ try:
+ ftp.quit()
+ except Exception: # noqa: BLE001
+ pass
+
+ print(f'\n{uploaded} von {len(files)} Datei(en) uebertragen nach {REMOTE_DIR}.')
+
+ # Zur Kontrolle: die Pruefsummen, die auch auf der Downloadseite stehen.
+ print('\nPruefsummen:')
+ for path in files:
+ if path.suffix in ('.sha256', '.json', '.ps1', '.sh'):
+ continue
+ print(f' {path.name} {sha256(path)}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/sql/migrations/010_rocketchat_notifications.sql b/sql/migrations/010_rocketchat_notifications.sql
new file mode 100644
index 0000000..c5b8bc7
--- /dev/null
+++ b/sql/migrations/010_rocketchat_notifications.sql
@@ -0,0 +1,17 @@
+-- 010_rocketchat_notifications.sql: Rocket.Chat Benachrichtigungen & 12h Statusbericht Cron-Job
+
+INSERT INTO dc_settings (skey, svalue) VALUES
+('rocketchat_enabled', '1'),
+('rocketchat_url', 'https://chat.wh1.mhdf.de'),
+('rocketchat_username', 'deploymentcenter'),
+('rocketchat_password', 'cNt.m.KcWHrb8_X9Tv8-'),
+('rocketchat_status_channel', '#DC-Systemstatus'),
+('rocketchat_alert_channel', '#DC-Alerts'),
+('rocketchat_verify_ssl', '0'),
+('rocketchat_report_interval', '43200')
+ON DUPLICATE KEY UPDATE svalue = VALUES(svalue);
+
+-- Cronjob fuer den 12-Stunden-Systemstatusbericht (43200 Sekunden)
+INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled)
+VALUES ('rocketchat_status_report', 43200, 1)
+ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec);
diff --git a/sql/migrations/011_update_rocketchat_credentials.sql b/sql/migrations/011_update_rocketchat_credentials.sql
new file mode 100644
index 0000000..aa5e070
--- /dev/null
+++ b/sql/migrations/011_update_rocketchat_credentials.sql
@@ -0,0 +1,12 @@
+-- 011_update_rocketchat_credentials.sql: Aktualisiere Rocket.Chat Zugangsdaten & Kanaele
+
+INSERT INTO dc_settings (skey, svalue) VALUES
+('rocketchat_enabled', '1'),
+('rocketchat_url', 'https://chat.wh1.mhdf.de'),
+('rocketchat_username', 'deploymentcenter'),
+('rocketchat_password', 'cNt.m.KcWHrb8_X9Tv8-'),
+('rocketchat_status_channel', '#DC-Systemstatus'),
+('rocketchat_alert_channel', '#DC-Alerts'),
+('rocketchat_verify_ssl', '0'),
+('rocketchat_report_interval', '43200')
+ON DUPLICATE KEY UPDATE svalue = VALUES(svalue);
diff --git a/sql/migrations/012_user_roles.sql b/sql/migrations/012_user_roles.sql
new file mode 100644
index 0000000..6e66b89
--- /dev/null
+++ b/sql/migrations/012_user_roles.sql
@@ -0,0 +1,39 @@
+-- Migration 012: Benutzerrollen
+--
+-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091.
+
+-- ---------------------------------------------------------------------------
+-- Wozu darf sich dieses Konto anmelden?
+-- ---------------------------------------------------------------------------
+-- Die Erstinstallation einer Anwendung laeuft ueber ein Konsolenwerkzeug, das
+-- auf dem jeweiligen Zielsystem nach Benutzername und Passwort fragt. Wuerde
+-- man dort das Admin-Konto eingeben, verteilte man die Zugangsdaten zur
+-- gesamten Verwaltungsoberflaeche auf jeden Rechner, auf dem je etwas
+-- installiert wurde.
+--
+-- 'installer' ist deshalb eine eigene Rolle: sie darf ueber
+-- /api/setup/v1/login ein kurzlebiges Token ziehen und Anwendungen
+-- einrichten - und sonst nichts. Insbesondere ist die Anmeldung am WebUI fuer
+-- sie gesperrt.
+--
+-- Bestandsschutz: alle vorhandenen Konten werden 'admin', damit sich am
+-- bisherigen Verhalten nichts aendert.
+ALTER TABLE dc_users
+ ADD COLUMN role ENUM('admin','installer') NOT NULL DEFAULT 'admin' AFTER password_hash;
+
+-- ---------------------------------------------------------------------------
+-- Wann war dieses Konto zuletzt aktiv?
+-- ---------------------------------------------------------------------------
+-- Ein Installationskonto wird selten benutzt. Ohne diese Angabe laesst sich
+-- nicht erkennen, ob ein angelegtes Konto noch gebraucht wird oder seit einem
+-- Jahr ungenutzt herumliegt.
+ALTER TABLE dc_users
+ ADD COLUMN last_login_at DATETIME NULL AFTER role;
+
+-- Ein deaktiviertes Konto bleibt erhalten (die Protokolle verweisen darauf),
+-- kann sich aber nicht mehr anmelden.
+ALTER TABLE dc_users
+ ADD COLUMN disabled TINYINT(1) NOT NULL DEFAULT 0 AFTER last_login_at;
+
+ALTER TABLE dc_users
+ ADD KEY ix_users_role (role);
diff --git a/sql/schema.sql b/sql/schema.sql
index 375bf1e..641751b 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -8,8 +8,17 @@ CREATE TABLE IF NOT EXISTS dc_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
+ -- 'installer' darf ausschliesslich ueber /api/setup/v1/login Anwendungen
+ -- einrichten, nicht aber das WebUI benutzen. Sonst muesste man auf jedem
+ -- Zielsystem die Zugangsdaten zur gesamten Verwaltung eingeben.
+ role ENUM('admin','installer') NOT NULL DEFAULT 'admin',
+ last_login_at DATETIME NULL,
+ disabled TINYINT(1) NOT NULL DEFAULT 0,
+ -- Vorgesehen, aber noch nicht ausgewertet: es gibt bislang keine
+ -- TOTP-Pruefung im Anmeldeweg.
totp_secret VARCHAR(64) NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY ix_users_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS dc_settings (
diff --git a/src/Core/Auth.php b/src/Core/Auth.php
index 95001ce..7de128d 100644
--- a/src/Core/Auth.php
+++ b/src/Core/Auth.php
@@ -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|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();
diff --git a/src/Core/TokenManager.php b/src/Core/TokenManager.php
index a4cfb0c..e35e729 100644
--- a/src/Core/TokenManager.php
+++ b/src/Core/TokenManager.php
@@ -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 $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'],
];
/**
diff --git a/src/Modules/Notify/RocketChatNotifier.php b/src/Modules/Notify/RocketChatNotifier.php
new file mode 100644
index 0000000..d4881d5
--- /dev/null
+++ b/src/Modules/Notify/RocketChatNotifier.php
@@ -0,0 +1,506 @@
+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 $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 $overrideConfig
+ * @param list> $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 $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 $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 $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,
+ ];
+ }
+}
diff --git a/src/Modules/Notify/WebhookDispatcher.php b/src/Modules/Notify/WebhookDispatcher.php
index 5b0193d..9c19e05 100644
--- a/src/Modules/Notify/WebhookDispatcher.php
+++ b/src/Modules/Notify/WebhookDispatcher.php
@@ -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;
}
diff --git a/src/Modules/Setup/SetupCatalog.php b/src/Modules/Setup/SetupCatalog.php
new file mode 100644
index 0000000..d32ba79
--- /dev/null
+++ b/src/Modules/Setup/SetupCatalog.php
@@ -0,0 +1,147 @@
+db = $db;
+ }
+
+ /**
+ * @return list>
+ */
+ 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;
+ }
+}
diff --git a/src/Modules/UpdateService/UpdateManager.php b/src/Modules/UpdateService/UpdateManager.php
index 1cab3bc..99c04fc 100644
--- a/src/Modules/UpdateService/UpdateManager.php
+++ b/src/Modules/UpdateService/UpdateManager.php
@@ -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;
}
/**
diff --git a/src/Modules/Watchdog/Evaluator.php b/src/Modules/Watchdog/Evaluator.php
index e7c2e5e..7f3bd8e 100644
--- a/src/Modules/Watchdog/Evaluator.php
+++ b/src/Modules/Watchdog/Evaluator.php
@@ -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));