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
+148
View File
@@ -515,6 +515,154 @@ $spec = [
],
],
'/api/setup/v1/login' => [
'post' => [
'tags' => ['Setup'],
'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen',
'description' =>
"Ohne Token erreichbar - hier entsteht das Token erst.\n\n"
. "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am "
. "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem "
. "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur "
. "gesamten Verwaltung.\n\n"
. "Das Token laeuft nach 30 Minuten ab, traegt nur `setup:install` und wird "
. "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n"
. "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine "
. "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen "
. "waere ein Rueckschritt.",
'security' => [],
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['username', 'password'],
'properties' => [
'username' => ['type' => 'string'],
'password' => ['type' => 'string', 'format' => 'password'],
'hostname' => ['type' => 'string', 'description' => 'Rechnername, erscheint im Token-Namen und im Protokoll.'],
],
]]],
],
'responses' => [
'201' => [
'description' => 'Angemeldet',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'setup_token' => ['type' => 'string'],
'expires_at' => ['type' => 'string', 'format' => 'date-time'],
'expires_in' => ['type' => 'integer', 'example' => 1800],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
'role' => ['type' => 'string', 'enum' => ['admin', 'installer']],
],
]]],
],
'401' => $errorResponse,
'429' => $errorResponse,
'501' => $errorResponse,
],
],
],
'/api/setup/v1/catalog' => [
'get' => [
'tags' => ['Setup'],
'summary' => 'Installierbare Anwendungen fuer dieses System',
'description' =>
"Scope `setup:catalog`. Zeigt ausschliesslich, was zur angefragten "
. "Laufzeitkennung passt - ein Projekt, von dem es nur ein Windows-Paket gibt, "
. "taucht auf einem Linux-Rechner gar nicht erst auf.\n\n"
. "Eine WebUI-Session wird hier bewusst **nicht** akzeptiert.",
'parameters' => [
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'],
'description' => 'Laufzeitkennung des Zielsystems. Ohne Angabe gelten nur Releases mit platform=any.'],
],
'responses' => [
'200' => [
'description' => 'Katalog',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'platform' => ['type' => 'string'],
'count' => ['type' => 'integer'],
'projects' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'slug' => ['type' => 'string'],
'name' => ['type' => 'string'],
'notes' => ['type' => 'string', 'nullable' => true],
'channels' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'channel' => ['type' => 'string'],
'version' => ['type' => 'string'],
'platform' => ['type' => 'string'],
'size_bytes' => ['type' => 'integer'],
'is_critical' => ['type' => 'boolean'],
'signed' => ['type' => 'boolean'],
'download_url' => ['type' => 'string'],
],
],
],
],
],
],
],
]]],
],
'401' => $errorResponse,
],
],
],
'/api/setup/v1/token' => [
'post' => [
'tags' => ['Setup'],
'summary' => 'Dauertoken fuer die eingerichtete Anwendung',
'description' =>
"Scope `setup:install`. Stellt das Token aus, mit dem die frisch installierte "
. "Anwendung spaeter Heartbeats sendet und Fehler meldet.\n\n"
. "**Welche Rechte vergeben werden, entscheidet der Server.** Die angefragten "
. "`scopes` koennen nur einschraenken; erlaubt sind ausschliesslich "
. "`watchdog:ping`, `watchdog:read`, `bugtracker:report` und "
. "`updateservice:read`. Andernfalls waere der Umweg ueber ein kurzlebiges "
. "Setup-Token wirkungslos - wer einmal installieren darf, stellte sich sonst "
. "ein Token mit allen Rechten aus und behielte es.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['project'],
'properties' => [
'project' => ['type' => 'string'],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
'hostname' => ['type' => 'string'],
],
]]],
],
'responses' => [
'201' => [
'description' => 'Token ausgestellt - wird nur einmal ausgeliefert',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'token' => ['type' => 'string'],
'project' => ['type' => 'string'],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
]]],
],
'401' => $errorResponse,
'403' => $errorResponse,
'404' => $errorResponse,
],
],
],
'/api/watchdog/v1/ping' => [
'post' => [
'tags' => ['Watchdog'],
+253
View File
@@ -0,0 +1,253 @@
<?php
/**
* Setup API - Erstinstallation von Anwendungen
*
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
* GET /api/setup/v1/catalog Was ist hier installierbar?
* POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung
*
* Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste
* vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde,
* die eine Erstinstallation umstaendlich macht. Zugangsdaten hat der
* Einrichtende ohnehin.
*
* Das ausgestellte Token laeuft nach 30 Minuten ab und traegt nur
* setup:install. Es liegt auf einem fremden Rechner; alles Langlebige waere
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
*
* ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der
* Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI
* anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt,
* verteilt den Zugang zur gesamten Verwaltung.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\Setup\SetupCatalog;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
$db = Db::init();
/** Gueltigkeit des ausgestellten Tokens in Sekunden. */
const SETUP_TOKEN_TTL = 1800;
switch (resolveSetupAction()) {
case 'login':
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Die Anmeldung erwartet POST.');
}
// Deutlich enger als die uebrigen Endpunkte: hier werden Passwoerter
// geprueft. Die Sperre aus Auth greift zusaetzlich pro IP, sie zaehlt
// aber erst ab dem zehnten Fehlversuch.
$limiter = new RateLimiter($db, 10, 60, 'setup_login');
if (!$limiter->check(Http::clientIp())) {
Http::fail(429, 'rate_limited', 'Zu viele Anmeldeversuche. Bitte kurz warten.');
}
$username = Http::str('username');
$password = Http::str('password');
if ($username === null || $password === null || $username === '' || $password === '') {
Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.');
}
$lockout = Auth::lockoutSeconds($db, Http::clientIp());
if ($lockout > 0) {
Http::fail(429, 'locked_out', sprintf(
'Zu viele fehlgeschlagene Anmeldungen. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($lockout / 60)
));
}
$user = Auth::verifyCredentials($db, $username, $password);
if ($user === null) {
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches
// Passwort und deaktiviertes Konto.
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
}
// TOTP ist in dc_users vorgesehen, wird aber noch von keinem
// Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier
// durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein
// stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt.
if (!empty($user['totp_secret'])) {
Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [
'username' => $username,
'ip' => Http::clientIp(),
]);
Http::fail(
501,
'totp_not_supported',
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung '
. 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.'
);
}
$manager = new TokenManager($db);
$hostname = Http::str('hostname') ?? Http::str('instance_id');
$token = $manager->createEphemeralToken(
sprintf('Setup %s%s', $username, $hostname !== null ? ' @ ' . $hostname : ''),
['setup:install'],
SETUP_TOKEN_TTL,
$hostname
);
Logger::info('Setup-Anmeldung erfolgreich', [
'username' => $username,
'role' => Auth::roleOf($user),
'hostname' => $hostname,
'ip' => Http::clientIp(),
'token_id' => $token['token_id'],
]);
Http::ok([
'setup_token' => $token['raw_token'],
'token_id' => $token['token_id'],
'expires_at' => $token['expires_at'],
'expires_in' => SETUP_TOKEN_TTL,
'scopes' => $token['scopes'],
'role' => Auth::roleOf($user),
'message' => 'Angemeldet. Das Token laeuft nach 30 Minuten ab und wird nicht gespeichert.',
], 201);
case 'catalog':
// allowSession bewusst aus: dieser Weg ist fuer den Installer, nicht
// fuer einen angemeldeten Browser.
$context = ApiAuth::requireScope($db, 'setup:catalog', null, false);
$platform = Http::str('platform') ?? Http::str('rid');
$catalog = (new SetupCatalog($db))->forPlatform(
$platform,
ApiAuth::projectFilter($context)
);
Http::ok([
'platform' => UpdateManager::normalizePlatform($platform),
'count' => count($catalog),
'projects' => $catalog,
'message' => $catalog === []
? 'Fuer diese Plattform ist nichts hinterlegt. Releases ohne Plattformangabe gelten als "any".'
: sprintf('%d Anwendung(en) installierbar.', count($catalog)),
]);
case 'token':
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
}
$context = ApiAuth::requireScope($db, 'setup:install', null, false);
$project = Http::str('project') ?? Http::str('product_slug');
if ($project === null || $project === '') {
Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.');
}
ApiAuth::enforceProject($context, $project);
$exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1');
$exists->execute([':slug' => $project]);
if ($exists->fetch() === false) {
Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project));
}
$requested = Http::input('scopes', []);
if (is_string($requested)) {
$requested = array_map('trim', explode(',', $requested));
}
if (!is_array($requested) || $requested === []) {
$requested = ['watchdog:ping'];
}
// Was ein Installer ausstellen darf, ist fest umrissen und nicht
// verhandelbar. Wuerde die Anfrage die Rechte bestimmen, waere der
// Umweg ueber ein kurzlebiges Setup-Token wirkungslos: wer einmal
// installieren darf, koennte sich sonst ein Token mit allen Rechten
// ausstellen und behalten.
$allowed = ['watchdog:ping', 'watchdog:read', 'bugtracker:report', 'updateservice:read'];
$granted = array_values(array_intersect(
array_map(static fn($s): string => strtolower(trim((string)$s)), $requested),
$allowed
));
if ($granted === []) {
Http::fail(403, 'scopes_not_allowed', 'Keines der angeforderten Rechte darf ueber den Setup-Weg vergeben werden.', null, [
'allowed' => $allowed,
]);
}
$hostname = Http::str('hostname') ?? Http::str('instance_id');
$manager = new TokenManager($db);
$appToken = $manager->createMasterToken(
sprintf('%s @ %s', $project, $hostname ?? 'unbekannt'),
$project,
null,
'host',
$hostname,
$granted,
Http::str('environment') ?? 'all'
);
Logger::info('Anwendungstoken ueber den Setup-Weg ausgestellt', [
'project' => $project,
'hostname' => $hostname,
'scopes' => $granted,
'actor' => $context['actor'],
'token_id' => $appToken['token_id'],
]);
Http::ok([
'token' => $appToken['raw_token'],
'token_id' => $appToken['token_id'],
'project' => $project,
'scopes' => $granted,
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
], 201);
default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['login', 'catalog', 'token'],
]);
}
/**
* Bestimmt die Aktion aus dem Pfadsegment oder ?action=.
* Feste Liste statt Teilstring-Suche.
*/
function resolveSetupAction(): string
{
$explicit = Http::str('action');
if ($explicit !== null) {
return strtolower($explicit);
}
$segments = array_values(array_filter(
explode('/', trim(Http::path(), '/')),
static fn(string $s): bool => $s !== ''
));
$last = strtolower((string)end($segments));
return match ($last) {
'login', 'catalog', 'token' => $last,
default => 'unknown',
};
}
+125 -2
View File
@@ -244,9 +244,132 @@ switch ($action) {
),
], $result['created'] ? 201 : 200);
case 'update':
case 'edit':
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Das Bearbeiten eines Releases erwartet POST.');
}
$context = ApiAuth::requireScope($db, 'updateservice:publish');
$releaseId = Http::int('release_id', Http::int('id', 0));
if ($releaseId <= 0) {
Http::fail(400, 'missing_release_id', 'Der Parameter "release_id" oder "id" ist erforderlich.');
}
$existing = $manager->getReleaseById($releaseId);
if ($existing === null) {
Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId));
}
$product = Http::str('product_slug') ?? Http::str('product') ?? $existing['product_slug'];
$version = Http::str('version') ?? $existing['version'];
$url = Http::str('download_url') ?? $existing['download_url'];
$channel = Http::str('channel') ?? $existing['channel'];
$platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid') ?? $existing['platform']);
$notes = Http::str('release_notes') ?? $existing['release_notes'];
$hash = Http::str('sha256_hash') ?? $existing['sha256_hash'];
$git = Http::str('git_commit') ?? $existing['git_commit'];
$size = Http::int('size_bytes', (int)$existing['size_bytes']);
$critical = Http::input('is_critical', null) !== null
? filter_var(Http::input('is_critical'), FILTER_VALIDATE_BOOLEAN)
: (bool)$existing['is_critical'];
ApiAuth::enforceProject($context, (string)$product);
if ($hash !== null && $hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.');
}
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
(string)$product,
(string)$version,
(string)$channel,
(string)$platform,
$hash !== '' ? $hash : null,
(string)$url,
$size
));
try {
$ok = $manager->updateRelease(
$releaseId,
(string)$product,
(string)$version,
(string)$channel,
$notes,
(string)$url,
$hash !== '' ? $hash : null,
$git,
$size,
$existing['manifest_json'] ?? null,
$critical,
$context['actor'],
(string)$platform,
$signature
);
} catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') {
Http::fail(409, 'conflict', 'Ein Release mit dieser Kombination aus Produkt, Version, Kanal und Plattform existiert bereits.');
}
throw $e;
}
Http::ok([
'release_id' => $releaseId,
'updated' => $ok,
'platform' => $platform,
'signed' => $signature !== null,
'message' => sprintf('Release %s (%s, %s) fuer "%s" erfolgreich aktualisiert.', $version, $channel, $platform, $product),
]);
case 'delete':
if (Http::method() !== 'POST' && Http::method() !== 'DELETE') {
Http::fail(405, 'method_not_allowed', 'Das Loeschen erwartet POST oder DELETE.');
}
$context = ApiAuth::requireScope($db, 'updateservice:publish');
$releaseId = Http::int('release_id', Http::int('id', 0));
if ($releaseId <= 0) {
$product = Http::str('product_slug') ?? Http::str('product');
$version = Http::str('version');
$channel = Http::str('channel') ?? 'prod';
$platform = Http::str('platform') ?? Http::str('rid');
if ($product !== null && $version !== null) {
$found = $manager->findRelease($product, $version, $channel, $platform);
if ($found !== null) {
$releaseId = (int)$found['id'];
}
}
}
if ($releaseId <= 0) {
Http::fail(400, 'missing_release_id', 'Ungueltige oder fehlende release_id.');
}
$existing = $manager->getReleaseById($releaseId);
if ($existing === null) {
Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId));
}
ApiAuth::enforceProject($context, (string)$existing['product_slug']);
$deleted = $manager->deleteRelease($releaseId);
if (!$deleted) {
Http::fail(500, 'delete_failed', 'Das Release konnte nicht geloescht werden.');
}
Http::ok([
'release_id' => $releaseId,
'deleted' => true,
'message' => sprintf('Release v%s (%s, %s) fuer "%s" geloescht.', $existing['version'], $existing['channel'], $existing['platform'] ?? 'any', $existing['product_slug']),
]);
default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'],
'available' => ['check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'delete'],
]);
}
@@ -270,7 +393,7 @@ function resolveUpdateAction(): string
$last = strtolower((string)end($segments));
return match ($last) {
'check', 'latest', 'releases', 'pubkey', 'publish' => $last,
'check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last,
'publish_release' => 'publish',
default => 'check',
};
+13 -5
View File
@@ -188,12 +188,20 @@ switch ($action) {
// ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben.
ApiAuth::requireScope($db, 'watchdog:evaluate');
$reportRes = null;
if (Http::str('force_report') === '1' || Http::str('report') === '1') {
$reportRes = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($db, true);
}
$result = Evaluator::run($db);
Http::ok($result + ['message' => sprintf(
'%d Monitor(e) geprueft, %d Zustandswechsel.',
$result['checked'],
$result['changed']
)]);
Http::ok($result + [
'rocketchat_report' => $reportRes,
'message' => sprintf(
'%d Monitor(e) geprueft, %d Zustandswechsel.',
$result['checked'],
$result['changed']
)
]);
default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
+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;