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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 10:33:26 +02:00
co-authored by Claude Opus 5
parent 2388b5abe1
commit c8f3e78635
34 changed files with 4704 additions and 21 deletions
+82 -9
View File
@@ -84,20 +84,26 @@ final class Auth
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
* Prueft Zugangsdaten, ohne eine Session anzufassen.
*
* Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht,
* aber ein kurzlebiges Token statt eines Session-Cookies ausstellt.
* Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie
* hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden
* zu haben.
*
* @return array<string,mixed>|null Der Benutzerdatensatz, oder null
*/
public static function login(PDO $db, string $username, string $password): bool
public static function verifyCredentials(PDO $db, string $username, string $password): ?array
{
self::startSession();
$ip = Http::clientIp();
if (self::isLockedOut($db, $ip)) {
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
return false;
return null;
}
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
$stmt = $db->prepare('SELECT * FROM dc_users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch();
@@ -110,7 +116,18 @@ final class Auth
if (!$verified || !$found) {
self::recordAttempt($db, $ip, $username, false);
return false;
return null;
}
// Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter
// darauf verweisen koennen - anmelden darf es sich nicht.
if ((int)($user['disabled'] ?? 0) === 1) {
self::recordAttempt($db, $ip, $username, false);
Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [
'username' => $username,
'ip' => $ip,
]);
return null;
}
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
@@ -119,19 +136,75 @@ final class Auth
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
}
self::recordAttempt($db, $ip, $username, true);
self::touchLastLogin($db, (int)$user['id']);
return $user;
}
/**
* Rolle eines Datensatzes aus dc_users.
*
* Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist -
* sonst waere nach dem Einspielen des Codes und vor der Migration niemand
* mehr anmeldeberechtigt.
*/
public static function roleOf(?array $user): string
{
$role = is_array($user) ? (string)($user['role'] ?? 'admin') : 'admin';
return $role === 'installer' ? 'installer' : 'admin';
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
*/
public static function login(PDO $db, string $username, string $password): bool
{
self::startSession();
$user = self::verifyCredentials($db, $username, $password);
if ($user === null) {
return false;
}
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu
// suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto
// gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette
// damit auch Zugriff auf Tokens, Lizenzen und Monitore.
if (self::roleOf($user) === 'installer') {
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
'username' => $username,
'ip' => Http::clientIp(),
]);
return false;
}
session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id'];
$_SESSION['dc_username'] = (string)$user['username'];
$_SESSION['dc_role'] = self::roleOf($user);
$_SESSION['dc_login_at'] = time();
$_SESSION['dc_last_seen'] = time();
self::recordAttempt($db, $ip, $username, true);
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]);
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => Http::clientIp()]);
return true;
}
private static function touchLastLogin(PDO $db, int $userId): void
{
try {
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
$stmt->execute([':id' => $userId]);
} catch (\Throwable $e) {
// Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund,
// die Anmeldung scheitern zu lassen.
Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]);
}
}
public static function logout(): void
{
self::startSession();
+85
View File
@@ -40,6 +40,11 @@ final class TokenManager
'updateservice:read',
'updateservice:publish',
'tokens:provision',
// Erstinstallation: Katalog lesen und eine Anwendung einrichten.
// Diese Rechte traegt ausschliesslich das kurzlebige Token aus
// /api/setup/v1/login - sie gehoeren nicht auf ein Dauertoken.
'setup:catalog',
'setup:install',
];
private PDO $db;
@@ -111,6 +116,82 @@ final class TokenManager
];
}
/**
* Erzeugt ein kurzlebiges Token ohne Elterntoken.
*
* Gedacht fuer die Erstinstallation: Der Installer meldet sich mit
* Benutzername und Passwort an und bekommt dafuer ein Token, das nach
* wenigen Minuten verfaellt. Bewusst kein Master-Token - es soll nichts
* weitervererben koennen - und bewusst mit Ablauf, weil es auf einem
* fremden Zielsystem im Speicher liegt.
*
* @param list<string> $scopes
*/
public function createEphemeralToken(
string $name,
array $scopes,
int $ttlSeconds,
?string $ownerIdentity = null
): array {
$name = trim($name);
if ($name === '') {
throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
}
$effectiveScopes = self::normalizeScopes($scopes, []);
if ($effectiveScopes === []) {
throw new InvalidArgumentException('Ein kurzlebiges Token ohne Rechte waere wirkungslos.');
}
// Eine Obergrenze verhindert, dass aus einem Setup-Token durch einen
// grosszuegigen Aufrufer ein Dauertoken wird.
$ttlSeconds = max(60, min($ttlSeconds, 3600));
$tokenId = 'tok_s_' . bin2hex(random_bytes(8));
$rawToken = 'dc_setup_' . bin2hex(random_bytes(24));
$expires = gmdate('Y-m-d H:i:s', time() + $ttlSeconds);
$stmt = $this->db->prepare('
INSERT INTO dc_tokens (
token_id, parent_token_id, token_hash, raw_token, name,
project_slug, license_key, owner_type, owner_identity,
type, scopes, environment, expires_at, created_at
) VALUES (
:id, NULL, :hash, NULL, :name,
NULL, NULL, "host", :identity,
"sub", :scopes, "all", :expires, UTC_TIMESTAMP()
)
');
// raw_token bleibt hier immer NULL, auch wenn die Konfiguration das
// Mitschreiben erlaubt: ein Setup-Token wird einmal ausgeliefert und
// muss nirgends nachschlagbar sein.
$stmt->execute([
':id' => $tokenId,
':hash' => hash('sha256', $rawToken),
':name' => $name,
':identity' => self::nullIfEmpty($ownerIdentity),
':scopes' => json_encode($effectiveScopes),
':expires' => $expires,
]);
Logger::info('Kurzlebiges Token erstellt', [
'token_id' => $tokenId,
'name' => $name,
'scopes' => $effectiveScopes,
'expires_at' => $expires,
]);
return [
'token_id' => $tokenId,
'raw_token' => $rawToken,
'name' => $name,
'type' => 'sub',
'scopes' => $effectiveScopes,
'expires_at' => $expires,
];
}
/**
* Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
@@ -353,6 +434,10 @@ final class TokenManager
'updateservice:publish' => ['updateservice:read'],
'watchdog:evaluate' => ['watchdog:read'],
'watchdog:ping' => ['watchdog:read'],
// Wer einrichten darf, muss den Katalog sehen und Releases lesen
// koennen - sonst gibt es nichts zu installieren.
'setup:install' => ['setup:catalog', 'updateservice:read'],
'setup:catalog' => ['updateservice:read'],
];
/**