Files
Deploymentcenter/public/api/setup/v1/index.php
T
Deploymentcenter BotandClaude Opus 5 687ee0cefc feat(installer): Laufzeitpruefung, Host-Ueberwachung, Zugang fuer Adminkonten
Drei Dinge, die beim ersten Lauf des Installers auf einer Linux-Maschine
auffielen.

1. Die Release-Ablage wies Administratorkonten ab. ReleaseGuard nahm nur
   die Rolle 'installer' in die .htpasswd auf, waehrend Installskripte und
   Agent ausdruecklich sagten, ein Administratorkonto tue es auch:
   Anmeldung und Katalog gelangen, erst der Download endete mit 401 - und
   die Meldung sprach von abgelaufenen Lizenzen, die es bei einer
   Erstinstallation gar nicht geben kann. Adminkonten zaehlen jetzt zu den
   Installationskonten. FORMAT_VERSION auf 3, damit reconcile() die
   Dateien sofort neu schreibt statt erst beim naechsten turnusmaessigen
   Lauf; ein neu angelegtes Konto landet ausserdem unabhaengig von seiner
   Rolle sofort darin. Bei einem 401 mit Benutzerzugangsdaten nennt der
   Client jetzt Konto und zugangsberechtigte Rollen, und der Agent bricht
   ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar
   Schritte spaeter ein zweites Mal zu zeigen.

2. Der Installer prueft die .NET-Laufzeit. Bisher endete eine gelungene
   Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich
   nicht starten laesst - und die Fehlersuche begann beim
   Deploymentcenter, weil das der letzte bewusste Schritt war. Gelesen
   wird die runtimeconfig.json der Anwendung und mit "dotnet
   --list-runtimes" verglichen; fehlt etwas, nennt der Installer den
   Installationsbefehl fuer diese Plattform. Eigenstaendig
   veroeffentlichte Pakete werden nicht bemaengelt, rollForward wird
   beachtet.

3. Die Ueberwachung der Maschine entsteht im Installer. Zwei Fragen -
   Name im Dashboard und ob eingeplant werden soll - statt fuenf Schritten
   in der Oberflaeche an einem anderen Rechner. Monitor, Token mit genau
   watchdog:ping, Skript, Dateirechte, ein Heartbeat zur Probe und der
   Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen
   ohne Installation: --action monitor.

Die Agent-Skripte werden jetzt in src/Modules/Watchdog/AgentScript.php
erzeugt - von Oberflaeche und Installer gemeinsam - und melden Last,
Speicher, Plattenbelegung und Laufzeit mit, statt nur "status: ok". Beim
Ausfuehren fielen zwei Fehler auf, die dort behoben sind: df -P verrutscht
bei Geraetenamen mit Leerzeichen (gezaehlt wird jetzt von hinten), und
ohne LC_ALL=C erzeugt awk auf einem deutschen System "12,5" und damit
kaputtes JSON.

Neu: POST /api/setup/v1/agent. SDK 2.6.0 mit
SetupClient.RequestWatchdogAgentAsync().

Die OpenAPI-Beschreibung des neuen Endpunkts bleibt zunaechst aussen vor:
public/api/openapi.php traegt gerade auch fremde, noch nicht committete
Aenderungen aus einer parallel laufenden Arbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:51:37 +02:00

376 lines
14 KiB
PHP

<?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
* POST /api/setup/v1/agent Watchdog-Agent fuer die Maschine selbst
*
* 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;
use Deploymentcenter\Modules\Watchdog\AgentScript;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
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);
case 'agent':
// Der Watchdog-Agent fuer die Maschine, auf der gerade installiert
// wird.
//
// Bisher gab es diesen Weg nur ueber die Oberflaeche: Monitor von Hand
// anlegen, Token erzeugen, Skript herunterladen, auf den Host kopieren,
// Cron-Eintrag schreiben. Fuenf Schritte an einem anderen Rechner, fuer
// etwas, das genau dann ansteht, wenn man ohnehin auf dem Zielsystem
// sitzt - entsprechend oft unterblieb es, und die frisch installierte
// Anwendung lief auf einem unbeobachteten Host.
//
// Ausgestellt wird dasselbe wie bei 'token': ein Token mit genau
// watchdog:ping. Es liegt anschliessend im Klartext im Skript auf dem
// Zielsystem - mehr Rechte duerfen daran nicht haengen.
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));
}
// Der Name ist das, was spaeter im Dashboard steht. Gesaeubert wird in
// AgentScript - hier wird nur geprueft, ob ueberhaupt etwas uebrig
// bleibt, damit nicht stillschweigend ein Monitor "host" entsteht.
$rawSource = trim((string)(Http::str('source') ?? ''));
if ($rawSource === '') {
Http::fail(400, 'missing_source', 'Der Parameter "source" (Name des Monitors) wird benoetigt.');
}
$source = AgentScript::sanitizeSource($rawSource);
$monitorRepo = new MonitorRepo($db);
$existing = $monitorRepo->getMonitor($source);
// Ein Name, den es schon gibt, ist nicht zwangslaeufig ein Fehler -
// eine Neuinstallation derselben Maschine trifft ihren eigenen Monitor
// wieder. Nur entscheiden muss das der Aufrufer, nicht dieser
// Endpunkt: sonst uebernimmt eine zweite Maschine unbemerkt die
// Historie der ersten.
$overwrite = filter_var(Http::input('overwrite', false), FILTER_VALIDATE_BOOLEAN);
if ($existing !== null && !$overwrite) {
Http::fail(409, 'monitor_exists', sprintf(
'Ein Monitor "%s" ist bereits angelegt (Zustand: %s). Mit overwrite=true uebernehmen.',
$source,
(string)($existing['state'] ?? 'unbekannt')
), null, [
'source' => $source,
'state' => $existing['state'] ?? null,
'last_seen' => $existing['last_seen_utc'] ?? null,
]);
}
$os = AgentScript::normalizeOs(Http::str('os'));
$interval = Http::int('interval', 60);
$manager = new TokenManager($db);
$agentToken = $manager->createMasterToken(
sprintf('Watchdog %s', $source),
$project,
null,
'host',
$source,
['watchdog:ping'],
Http::str('environment') ?? 'all'
);
$monitorRepo->createMonitor($source, [
'type' => 'host',
'os' => $os,
'expected_interval_sec' => $interval,
'group_key' => Http::str('group'),
'parent_source' => Http::str('parent'),
]);
$agent = AgentScript::build(
$os,
$source,
$agentToken['raw_token'],
Http::baseUrl() . '/api/watchdog/v1/ping',
$interval
);
Logger::info('Watchdog-Agent ueber den Setup-Weg ausgestellt', [
'project' => $project,
'source' => $source,
'os' => $os,
'existing' => $existing !== null,
'actor' => $context['actor'],
'token_id' => $agentToken['token_id'],
]);
Http::ok([
'source' => $source,
'os' => $agent['os'],
'filename' => $agent['filename'],
'script' => $agent['script'],
'schedule' => $agent['schedule'],
'interval' => $agent['interval'],
'token_id' => $agentToken['token_id'],
'existing' => $existing !== null,
'message' => sprintf(
'Monitor "%s" eingerichtet. Das Skript enthaelt das Token und wird nur einmal ausgeliefert.',
$source
),
], 201);
default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['login', 'catalog', 'token', 'agent'],
]);
}
/**
* 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', 'agent' => $last,
default => 'unknown',
};
}