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:
co-authored by
Claude Opus 5
parent
2388b5abe1
commit
c8f3e78635
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user