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
+855
View File
@@ -224,6 +224,31 @@ function dc_flash(string $message, string $type = 'success'): void
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
}
/**
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
*
* Grundlage fuer die Sperren beim Deaktivieren und Loeschen: Faellt das
* letzte Konto weg, das sich am WebUI anmelden kann, kommt niemand mehr an
* die Oberflaeche - auch nicht, um den Schritt rueckgaengig zu machen.
* Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
* nicht anmelden.
*/
function dc_active_admin_count(PDO $pdo): int
{
try {
$stmt = $pdo->query("SELECT COUNT(*) FROM dc_users WHERE role = 'admin' AND disabled = 0");
return (int)$stmt->fetchColumn();
} catch (Throwable $e) {
// Vor Migration 012 gibt es weder role noch disabled; dann sind alle
// Konten Administratoren.
try {
return (int)$pdo->query('SELECT COUNT(*) FROM dc_users')->fetchColumn();
} catch (Throwable $inner) {
return 0;
}
}
}
/**
* Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit
* ein Neuladen das Formular nicht erneut abschickt.
@@ -767,6 +792,122 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#tab-updateservice');
}
case 'edit_release': {
$releaseId = (int)($_POST['release_id'] ?? 0);
if ($releaseId <= 0) {
dc_flash('Ungültige Release-ID.', 'danger');
dc_redirect('#tab-updateservice');
}
$updateManager = new UpdateManager($pdo);
$existing = $updateManager->getReleaseById($releaseId);
if ($existing === null) {
dc_flash('Das zu bearbeitende Release wurde nicht gefunden.', 'danger');
dc_redirect('#tab-updateservice');
}
$productSlug = trim((string)($_POST['product_slug'] ?? ''));
$version = trim((string)($_POST['version'] ?? ''));
$url = trim((string)($_POST['download_url'] ?? ''));
if ($productSlug === '' || $version === '' || $url === '') {
dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger');
dc_redirect('#tab-updateservice');
}
$hash = trim((string)($_POST['sha256_hash'] ?? ''));
if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger');
dc_redirect('#tab-updateservice');
}
$channel = trim((string)($_POST['channel'] ?? 'prod'));
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
$size = (int)($_POST['size_bytes'] ?? 0);
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
$productSlug,
$version,
$channel,
$platform,
$hash !== '' ? $hash : null,
$url,
$size
));
try {
$ok = $updateManager->updateRelease(
$releaseId,
$productSlug,
$version,
$channel,
trim((string)($_POST['release_notes'] ?? '')),
$url,
$hash !== '' ? $hash : null,
trim((string)($_POST['git_commit'] ?? '')),
$size,
$existing['manifest_json'] ?? null,
isset($_POST['is_critical']),
$actor,
$platform,
$signature
);
if ($ok) {
dc_flash(sprintf(
'Release %s (%s, %s) für "%s" erfolgreich aktualisiert.%s',
e($version),
e($channel),
e($platform),
e($productSlug),
$signature === null
? ' Hinweis: unsigniert, kein Signierschlüssel hinterlegt.'
: ''
));
} else {
dc_flash('Das Release konnte nicht aktualisiert werden.', 'danger');
}
} catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') {
dc_flash('Ein Release mit dieser Kombination aus Projekt, Version, Kanal und Plattform existiert bereits.', 'danger');
} else {
dc_flash('Fehler beim Aktualisieren des Releases: ' . $e->getMessage(), 'danger');
}
}
dc_redirect('#tab-updateservice');
}
case 'delete_release': {
$releaseId = (int)($_POST['release_id'] ?? 0);
if ($releaseId <= 0) {
dc_flash('Ungültige Release-ID.', 'danger');
dc_redirect('#tab-updateservice');
}
$updateManager = new UpdateManager($pdo);
$existing = $updateManager->getReleaseById($releaseId);
if ($existing === null) {
dc_flash('Das zu löschende Release wurde nicht gefunden.', 'danger');
dc_redirect('#tab-updateservice');
}
$deleted = $updateManager->deleteRelease($releaseId);
if ($deleted) {
dc_flash(sprintf(
'Release v%s (%s, %s) für "%s" erfolgreich gelöscht.',
e($existing['version']),
e($existing['channel']),
e($existing['platform'] ?? 'any'),
e($existing['product_slug'])
));
} else {
dc_flash('Das Release konnte nicht gelöscht werden.', 'danger');
}
dc_redirect('#tab-updateservice');
}
// ------------------------------------------------------ Tokens
case 'create_master_token': {
$name = trim((string)($_POST['name'] ?? ''));
@@ -1025,6 +1166,199 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#tab-bugtracker');
}
case 'save_rocketchat_settings': {
$enabled = isset($_POST['rocketchat_enabled']) ? '1' : '0';
$url = trim((string)($_POST['rocketchat_url'] ?? ''));
$username = trim((string)($_POST['rocketchat_username'] ?? ''));
$password = (string)($_POST['rocketchat_password'] ?? '');
$statusChannel = trim((string)($_POST['rocketchat_status_channel'] ?? ''));
$alertChannel = trim((string)($_POST['rocketchat_alert_channel'] ?? ''));
$verifySsl = isset($_POST['rocketchat_verify_ssl']) ? '1' : '0';
$reportInterval = max(300, (int)($_POST['rocketchat_report_interval'] ?? 43200));
$settings = [
'rocketchat_enabled' => $enabled,
'rocketchat_url' => $url,
'rocketchat_username' => $username,
'rocketchat_password' => $password,
'rocketchat_status_channel' => $statusChannel,
'rocketchat_alert_channel' => $alertChannel,
'rocketchat_verify_ssl' => $verifySsl,
'rocketchat_report_interval' => (string)$reportInterval,
];
$stmt = $pdo->prepare("
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
");
foreach ($settings as $k => $v) {
$stmt->execute([':k' => $k, ':v' => $v]);
}
$stmtCron = $pdo->prepare("
INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled)
VALUES ('rocketchat_status_report', :interval, :enabled)
ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec), enabled = VALUES(enabled)
");
$stmtCron->execute([':interval' => $reportInterval, ':enabled' => (int)$enabled]);
dc_flash('Rocket.Chat Einstellungen erfolgreich gespeichert.', 'success');
dc_redirect('#sub-system-rocketchat');
}
case 'test_rocketchat_connection': {
$config = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::testConnection($config);
dc_flash($res['message'], $res['success'] ? 'success' : 'danger');
dc_redirect('#sub-system-rocketchat');
}
case 'send_rocketchat_report_now': {
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($pdo, true);
dc_flash($res['reason'], $res['sent'] ? 'success' : 'warning');
dc_redirect('#sub-system-rocketchat');
}
// ------------------------------------------------- Benutzerkonten
case 'add_user': {
$newUser = trim((string)($_POST['username'] ?? ''));
$newPass = (string)($_POST['password'] ?? '');
$newRole = ($_POST['role'] ?? 'installer') === 'admin' ? 'admin' : 'installer';
if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newUser) !== 1) {
dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger');
dc_redirect('#sub-system-users');
}
// Ein Installationskonto wird auf fremden Rechnern eingegeben.
// Ein kurzes Passwort waere dort schnell mitgelesen und liesse
// sich ebenso schnell durchprobieren.
if (strlen($newPass) < 12) {
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
dc_redirect('#sub-system-users');
}
$exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u LIMIT 1');
$exists->execute([':u' => $newUser]);
if ($exists->fetch() !== false) {
dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newUser)), 'danger');
dc_redirect('#sub-system-users');
}
$ins = $pdo->prepare('
INSERT INTO dc_users (username, password_hash, role, created_at)
VALUES (:u, :h, :r, UTC_TIMESTAMP())
');
$ins->execute([
':u' => $newUser,
':h' => password_hash($newPass, PASSWORD_DEFAULT),
':r' => $newRole,
]);
Logger::info('Benutzerkonto angelegt', [
'username' => $newUser,
'role' => $newRole,
'actor' => $actor,
]);
dc_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s',
e($newUser),
e($newRole),
$newRole === 'installer'
? ' Es kann sich nicht am WebUI anmelden, sondern nur Anwendungen einrichten.'
: ''
));
dc_redirect('#sub-system-users');
}
case 'set_user_password': {
$userId = (int)($_POST['user_id'] ?? 0);
$newPass = (string)($_POST['password'] ?? '');
if (strlen($newPass) < 12) {
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
dc_redirect('#sub-system-users');
}
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger');
dc_redirect('#sub-system-users');
}
case 'toggle_user_disabled': {
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username, role, disabled FROM dc_users WHERE id = :id LIMIT 1');
$row->execute([':id' => $userId]);
$target = $row->fetch();
if (!is_array($target)) {
dc_flash('Konto nicht gefunden.', 'danger');
dc_redirect('#sub-system-users');
}
$disable = (int)$target['disabled'] === 0;
// Ohne diese Sperre liesse sich das letzte handlungsfaehige
// Administratorkonto abschalten - danach kaeme niemand mehr an
// die Oberflaeche, auch nicht, um es rueckgaengig zu machen.
if ($disable && $target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
dc_flash('Das letzte aktive Administratorkonto kann nicht deaktiviert werden.', 'danger');
dc_redirect('#sub-system-users');
}
$upd = $pdo->prepare('UPDATE dc_users SET disabled = :d WHERE id = :id');
$upd->execute([':d' => $disable ? 1 : 0, ':id' => $userId]);
Logger::info('Konto ' . ($disable ? 'deaktiviert' : 'aktiviert'), [
'username' => $target['username'],
'actor' => $actor,
]);
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
$disable ? 'deaktiviert' : 'wieder aktiviert'));
dc_redirect('#sub-system-users');
}
case 'delete_user': {
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1');
$row->execute([':id' => $userId]);
$target = $row->fetch();
if (!is_array($target)) {
dc_flash('Konto nicht gefunden.', 'danger');
dc_redirect('#sub-system-users');
}
if ($userId === Auth::userId()) {
dc_flash('Das eigene Konto kann nicht geloescht werden.', 'danger');
dc_redirect('#sub-system-users');
}
if ($target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
dc_flash('Das letzte Administratorkonto kann nicht geloescht werden.', 'danger');
dc_redirect('#sub-system-users');
}
$del = $pdo->prepare('DELETE FROM dc_users WHERE id = :id');
$del->execute([':id' => $userId]);
Logger::info('Benutzerkonto geloescht', [
'username' => $target['username'],
'actor' => $actor,
]);
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
dc_redirect('#sub-system-users');
}
default:
dc_flash('Unbekannte Aktion.', 'danger');
dc_redirect();
@@ -1271,6 +1605,39 @@ $recentEvents = $eventLog->getRecentEvents(100);
$updateManager = new UpdateManager($pdo);
$releases = $updateManager->getReleases();
// Benutzerkonten fuer die Verwaltung. Der Passwort-Hash wird bewusst nicht
// mitgelesen - er hat in der Ansicht nichts verloren.
try {
$users = $pdo->query('
SELECT id, username, role, disabled, last_login_at, created_at
FROM dc_users ORDER BY role ASC, username ASC
')->fetchAll() ?: [];
} catch (Throwable $e) {
// Vor Migration 012 fehlen role, disabled und last_login_at.
$users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: [];
}
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
// werden nicht von deploy.py uebertragen, sondern von
// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf
// den Webserver, die uebersetzten Dateien schon.
$installerManifest = null;
$installerPath = dirname(__DIR__) . '/installer/installer.json';
if (is_file($installerPath)) {
$raw = (string)file_get_contents($installerPath);
// Ein vorangestelltes BOM laesst json_decode() scheitern. Windows-Werkzeuge
// schreiben es gern mit; die Datei ist deswegen nicht kaputt.
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
$decoded = json_decode($raw, true);
if (is_array($decoded) && isset($decoded['binaries']) && is_array($decoded['binaries'])) {
$installerManifest = $decoded;
}
}
$coreTokenManager = new CoreTokenManager($pdo);
$coreMasterTokens = $coreTokenManager->getAllMasterTokens();
$coreAllTokens = $coreTokenManager->getAllTokens();
@@ -1391,6 +1758,10 @@ $evaluatorLastRun = is_array($evaluatorJob) ? ($evaluatorJob['last_run_utc'] ??
$evaluatorStale = $evaluatorLastRun === null
|| (time() - (int)strtotime((string)$evaluatorLastRun . ' UTC')) > 900;
// Rocket.Chat Konfiguration und Cron-Status
$rcConfig = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
$rcCronJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'")->fetch() ?: [];
$baseUrl = Http::baseUrl();
$csrfField = Csrf::field();
?>
@@ -2625,6 +2996,7 @@ Antwort:
<th>Release Notes</th>
<th>Download & SHA256</th>
<th>Datum</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
@@ -2664,6 +3036,10 @@ Antwort:
<?php endif; ?>
</td>
<td><?= htmlspecialchars($r['created_at']) ?></td>
<td style="white-space:nowrap;">
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditReleaseModal(<?= htmlspecialchars(json_encode($r, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)) ?>)" title="Release bearbeiten">✏️ Bearbeiten</button>
<button type="button" class="btn btn-sm btn-danger" onclick="openDeleteReleaseModal(<?= (int)$r['id'] ?>, '<?= htmlspecialchars(addslashes($r['version'])) ?>', '<?= htmlspecialchars(addslashes($r['product_slug'])) ?>')" title="Release löschen">🗑️ Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -2671,6 +3047,110 @@ Antwort:
</div>
</div>
<div id="sub-update-installer" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Ein Werkzeug für alles: Dasselbe Programm richtet eine Anwendung erstmalig
ein und aktualisiert sie später. Es bringt die .NET-Laufzeit mit auf dem
Zielsystem muss nichts vorinstalliert sein.
</p>
<?php if ($installerManifest === null): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Noch nichts hinterlegt.</strong> Die Binaries werden mit
<code>scripts/build_installer.ps1</code> gebaut und mit
<code>scripts/upload_installer.py</code> nach <code>/installer/</code>
hochgeladen.
</div>
<?php else: ?>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem;">
Version <strong><?= e((string)($installerManifest['version'] ?? '?')) ?></strong>
· Build <code><?= e((string)($installerManifest['gitCommit'] ?? '?')) ?></code>
· <?= e(substr((string)($installerManifest['buildDateUtc'] ?? ''), 0, 10)) ?>
</div>
<table>
<thead>
<tr>
<th>Plattform</th>
<th>Größe</th>
<th>SHA256</th>
<th>Download</th>
</tr>
</thead>
<tbody>
<?php foreach ($installerManifest['binaries'] as $bin): ?>
<?php
$binFile = (string)($bin['file'] ?? '');
$binHash = (string)($bin['sha256'] ?? '');
$binUrl = $baseUrl . '/installer/' . rawurlencode($binFile);
?>
<tr>
<td><code><?= e((string)($bin['platform'] ?? '?')) ?></code></td>
<td style="font-family:'Roboto Mono',monospace; font-size:0.8rem;">
<?= number_format(((int)($bin['sizeBytes'] ?? 0)) / (1024 * 1024), 1, ',', '.') ?> MB
</td>
<td>
<code style="font-size:0.65rem; color:var(--text-muted); word-break:break-all;" title="<?= e($binHash) ?>">
<?= e(substr($binHash, 0, 24)) ?>
</code>
</td>
<td>
<a href="<?= e($binUrl) ?>" class="btn btn-sm">⬇️ Herunterladen</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-top:1rem;">
<strong>Prüfsumme vergleichen.</strong> „Programm herunterladen und
Zugangsdaten eingeben“ ist genau das Muster, das Phishing nachahmt.
Die vollständigen Hashes stehen im Tooltip und unter
<a href="<?= e($baseUrl) ?>/installer/installer.json" target="_blank" rel="noopener" style="color:#bfdbfe;">installer.json</a>.
</div>
<?php endif; ?>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Direkt auf dem Zielsystem</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:0.75rem;">
<strong>Linux</strong> lädt das passende Binary, prüft die Prüfsumme und legt es ab:
</p>
<div class="prompt-box">wget -qO- <?= e($baseUrl) ?>/installer/install.sh | sh</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
Oder ohne Skript, direkt das Binary:
</p>
<div class="prompt-box">wget <?= e($baseUrl) ?>/installer/update-agent-linux-x64 -O update-agent
chmod +x update-agent
./update-agent --action install</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
<strong>Windows</strong> (PowerShell):
</p>
<div class="prompt-box">irm <?= e($baseUrl) ?>/installer/install.ps1 | iex</div>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Die Skripte richten <strong>nichts</strong> von selbst ein sie laden, prüfen
und sagen, wie es weitergeht. Ein Skript aus dem Netz, das ungefragt eine
Anwendung aufsetzt und dabei nach Zugangsdaten fragt, wäre genau das, wovor
man Nutzer sonst warnt.
</p>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
gebraucht anzulegen unter <em>System 👤 Benutzer</em>. Ein
Administratorkonto funktioniert auch, gehört aber nicht auf ein Zielsystem:
seine Zugangsdaten würden damit auf jedem Rechner landen, auf dem je etwas
installiert wurde.
</p>
</div>
</div>
<div id="sub-update-publish" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">Neues Software-Release veröffentlichen</h2></div>
@@ -2740,6 +3220,106 @@ Antwort:
</form>
</div>
</div>
<!-- Edit Release Modal -->
<div class="modal-backdrop" id="editReleaseModal">
<div class="modal-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
<h3 style="margin:0;">✏️ Release bearbeiten</h3>
<button type="button" onclick="closeEditReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">&times;</button>
</div>
<form method="POST" action="index.php#sub-update-releases">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_release">
<input type="hidden" name="release_id" id="edit_release_id" value="">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt-Slug</label>
<select name="product_slug" id="edit_release_product_slug" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Release Kanal</label>
<select name="channel" id="edit_release_channel" class="form-input" required>
<option value="prod">prod (Produktiv)</option>
<option value="beta">beta (Vorab-Test)</option>
<option value="dev">dev (Entwicklung)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Plattform</label>
<select name="platform" id="edit_release_platform" class="form-input">
<option value="any">any (plattformunabhängig)</option>
<option value="win-x64">win-x64</option>
<option value="win-arm64">win-arm64</option>
<option value="linux-x64">linux-x64</option>
<option value="linux-arm64">linux-arm64</option>
<option value="linux-musl-x64">linux-musl-x64</option>
<option value="osx-x64">osx-x64</option>
<option value="osx-arm64">osx-arm64</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Version (z. B. 1.2.0)</label>
<input type="text" name="version" id="edit_release_version" class="form-input" required placeholder="1.2.0">
</div>
<div class="form-group">
<label class="form-label">Git Commit (Kurz-Hash)</label>
<input type="text" name="git_commit" id="edit_release_git_commit" class="form-input" placeholder="a1b2c3d">
</div>
<div class="form-group">
<label class="form-label">Download URL</label>
<input type="url" name="download_url" id="edit_release_download_url" class="form-input" required placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label">SHA256 Hash (Optional)</label>
<input type="text" name="sha256_hash" id="edit_release_sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
</div>
<div class="form-group">
<label class="form-label">Größe (Bytes)</label>
<input type="number" name="size_bytes" id="edit_release_size_bytes" class="form-input" min="0" placeholder="0">
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Release Notes / Changelog</label>
<textarea name="release_notes" id="edit_release_release_notes" class="form-input" rows="3" placeholder="Changelog..."></textarea>
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" name="is_critical" id="edit_release_is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
</label>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
<button type="button" class="btn btn-secondary" onclick="closeEditReleaseModal()">Abbrechen</button>
<button type="submit" class="btn">Änderungen Speichern</button>
</div>
</form>
</div>
</div>
<!-- Delete Release Modal -->
<div class="modal-backdrop" id="deleteReleaseModal">
<div class="modal-card" style="max-width:480px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
<h3 style="margin:0; color:#ef4444;">🗑️ Release löschen</h3>
<button type="button" onclick="closeDeleteReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">&times;</button>
</div>
<p>Möchten Sie das Release <strong id="delete_release_version_display"></strong> für <strong id="delete_release_product_display"></strong> wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</p>
<form method="POST" action="index.php#sub-update-releases" style="margin-top:1.5rem;">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_release">
<input type="hidden" name="release_id" id="delete_release_id" value="">
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
<button type="button" class="btn btn-secondary" onclick="closeDeleteReleaseModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">Release unwiderruflich löschen</button>
</div>
</form>
</div>
</div>
</div>
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
@@ -3853,6 +4433,229 @@ Antwort:
</div>
</div>
<div id="sub-system-rocketchat" class="subtab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">💬 Rocket.Chat Benachrichtigungen &amp; Statusberichte</h2>
</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
Hier konfigurieren Sie die automatischen Rocket.Chat-Benachrichtigungen. Alle 12 Stunden wird ein detaillierter Systemstatusbericht an den Status-Kanal gesendet. Kritische Systemausfälle und Bugs landen sofort im Alarm-Kanal.
</p>
<form method="POST">
<?= $csrfField ?>
<input type="hidden" name="action" value="save_rocketchat_settings">
<div class="form-grid">
<div class="form-group" style="grid-column: span 2;">
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="rocketchat_enabled" value="1" <?= !empty($rcConfig['enabled']) ? 'checked' : '' ?>>
<strong>Rocket.Chat Benachrichtigungen aktivieren</strong>
</label>
</div>
<div class="form-group">
<label class="form-label">Rocket.Chat Server-URL</label>
<input type="url" name="rocketchat_url" class="form-input" required value="<?= e($rcConfig['url']) ?>" placeholder="https://chat.wh1.mhdf.de">
</div>
<div class="form-group">
<label class="form-label">Benutzername</label>
<input type="text" name="rocketchat_username" class="form-input" required value="<?= e($rcConfig['username']) ?>" placeholder="deploymentcenter">
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input type="password" name="rocketchat_password" class="form-input" required value="<?= e($rcConfig['password']) ?>">
</div>
<div class="form-group">
<label class="form-label">SSL-Zertifikat verifizieren</label>
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer; font-weight:normal; margin-top:0.3rem;">
<input type="checkbox" name="rocketchat_verify_ssl" value="1" <?= !empty($rcConfig['verify_ssl']) ? 'checked' : '' ?>>
SSL-Zertifikat prüfen (bei selbstsignierten/unsicheren Zertifikaten deaktivieren)
</label>
</div>
<div class="form-group">
<label class="form-label">Status-Kanal (12h Bericht)</label>
<input type="text" name="rocketchat_status_channel" class="form-input" required value="<?= e($rcConfig['status_channel']) ?>" placeholder="#DC-Systemstatus">
</div>
<div class="form-group">
<label class="form-label">Alarm-Kanal (Kritische Ausfälle &amp; Bugs)</label>
<input type="text" name="rocketchat_alert_channel" class="form-input" required value="<?= e($rcConfig['alert_channel']) ?>" placeholder="#DC-Alerts">
</div>
<div class="form-group" style="grid-column: span 2;">
<label class="form-label">Statusbericht Intervall (Sekunden)</label>
<input type="number" name="rocketchat_report_interval" class="form-input" min="300" step="60" value="<?= e((string)$rcConfig['report_interval']) ?>">
<span style="font-size:0.75rem; color:var(--text-muted);">Standard: 43200 Sekunden (12 Stunden)</span>
</div>
</div>
<div style="margin-top:1.5rem; display:flex; gap:1rem;">
<button type="submit" class="btn btn-primary">💾 Einstellungen Speichern</button>
</div>
</form>
</div>
<div class="card" style="margin-top:1.5rem;">
<div class="card-header">
<h2 class="card-title">🧪 Aktionen &amp; Testlauf</h2>
</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
Hier können Sie die Erreichbarkeit Ihres Rocket.Chat-Servers testen oder sofort einen 12-Stunden-Statusbericht auslösen.
</p>
<div style="display:flex; flex-wrap:wrap; gap:1rem;">
<form method="POST">
<?= $csrfField ?>
<input type="hidden" name="action" value="test_rocketchat_connection">
<button type="submit" class="btn btn-secondary">🧪 Verbindung &amp; Kanäle Testen</button>
</form>
<form method="POST">
<?= $csrfField ?>
<input type="hidden" name="action" value="send_rocketchat_report_now">
<button type="submit" class="btn btn-secondary">📊 12h Statusbericht Jetzt Senden</button>
</form>
</div>
<div style="margin-top:1.5rem; background:rgba(0,0,0,0.2); padding:1rem; border-radius:8px; font-size:0.85rem; color:var(--text-muted);">
<strong>📌 Status des automatischen 12h Cron-Jobs:</strong><br>
Letzter Bericht: <?= !empty($rcCronJob['last_run_utc']) ? e(dc_dt((string)$rcCronJob['last_run_utc'])) . ' (' . e(dc_ago((string)$rcCronJob['last_run_utc'])) . ')' : 'Noch nie gelaufen' ?><br>
Letzter Status: <span class="badge badge-<?= ($rcCronJob['last_status'] ?? '') === 'ok' ? 'up' : 'warning' ?>"><?= e((string)($rcCronJob['last_status'] ?? 'bereit')) ?></span><br>
Nächster geplanter Lauf: In ca. <?= !empty($rcCronJob['last_run_utc']) ? max(0, (int)round(($rcConfig['report_interval'] - (time() - strtotime((string)$rcCronJob['last_run_utc'] . ' UTC'))) / 3600)) . ' Std.' : 'sofort beim nächsten Tick' ?>
</div>
</div>
</div>
<div id="sub-system-users" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
<?php if (!$usersSchemaReady): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Migration 012 fehlt.</strong> Rollen, Deaktivierung und der Zeitpunkt
der letzten Anmeldung stehen erst danach zur Verfügung. Bis dahin gelten alle
Konten als Administratoren.
</div>
<?php endif; ?>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Für die Erstinstallation von Anwendungen gibt es die Rolle
<strong>installer</strong>. Solche Konten melden sich ausschließlich über
<code>/api/setup/v1/login</code> an und bekommen dafür ein Token, das nach
30 Minuten verfällt. <strong>Am WebUI können sie sich nicht anmelden.</strong>
Der Grund: Die Zugangsdaten werden auf jedem Zielsystem eingegeben mit einem
Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und
Monitoren auf jeden Rechner, auf dem je etwas installiert wurde.
</p>
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzte Anmeldung</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<?php
$uRole = (string)($u['role'] ?? 'admin');
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
$isSelf = (int)$u['id'] === Auth::userId();
?>
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : '' ?>>
<td>
<strong><?= e((string)$u['username']) ?></strong>
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
</td>
<td>
<span class="badge <?= $uRole === 'admin' ? 'badge-up' : 'badge-warning' ?>">
<?= e(strtoupper($uRole)) ?>
</span>
</td>
<td><?= $uDisabled ? '<span class="badge badge-down">DEAKTIVIERT</span>' : 'aktiv' ?></td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
<td>
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="set_user_password">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<input type="password" name="password" class="form-input" required minlength="12"
placeholder="Neues Passwort" style="width:170px; padding:0.3rem 0.5rem; font-size:0.8rem;">
<button type="submit" class="btn btn-sm btn-secondary">Setzen</button>
</form>
<?php if (!$isSelf): ?>
<form method="POST" action="index.php" style="display:inline;">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_user_disabled">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<?= $uDisabled ? 'Aktivieren' : 'Deaktivieren' ?>
</button>
</form>
<form method="POST" action="index.php" style="display:inline;"
onsubmit="return confirm('Konto <?= e((string)$u['username']) ?> endgültig löschen?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Neues Konto anlegen</h2></div>
<form method="POST" action="index.php">
<?= $csrfField ?>
<input type="hidden" name="action" value="add_user">
<input type="hidden" name="_return" value="sub-system-users">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Benutzername</label>
<input type="text" name="username" class="form-input" required
pattern="[A-Za-z0-9._\-]{3,64}" placeholder="installer">
</div>
<div class="form-group">
<label class="form-label">Rolle</label>
<select name="role" class="form-input">
<option value="installer" selected>installer (nur Erstinstallation)</option>
<option value="admin">admin (volle Verwaltung)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Passwort (mindestens 12 Zeichen)</label>
<input type="password" name="password" class="form-input" required minlength="12">
</div>
</div>
<button type="submit" class="btn">Konto anlegen</button>
</form>
</div>
</div>
<div id="sub-system-swagger" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">📖 API-Dokumentation</h2></div>
@@ -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;