Files
Deploymentcenter/public/api/setup/v1/index.php
T

392 lines
15 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.');
}
$ipLockout = Auth::lockoutSeconds($db, Http::clientIp());
if ($ipLockout > 0) {
Http::fail(429, 'locked_out', sprintf(
'Zu viele fehlgeschlagene Anmeldungen von diesem Netzwerk. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($ipLockout / 60)
), null, ['retry_after_seconds' => $ipLockout]);
}
$accLockout = Auth::accountLockoutSeconds($db, $username);
if ($accLockout > 0) {
Http::fail(429, 'account_locked', sprintf(
'Dieses Benutzerkonto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt. Bitte in %d Minute(n) erneut versuchen.',
(int)ceil($accLockout / 60)
), null, ['retry_after_seconds' => $accLockout]);
}
$user = Auth::verifyCredentials($db, $username, $password);
if ($user === null) {
$postAccLockout = Auth::accountLockoutSeconds($db, $username);
if ($postAccLockout > 0) {
Http::fail(429, 'account_locked', sprintf(
'Zu viele Fehlversuche. Dieses Benutzerkonto wurde für %d Minute(n) gesperrt.',
(int)ceil($postAccLockout / 60)
), null, ['retry_after_seconds' => $postAccLockout]);
}
// 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',
};
}