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>
401 lines
15 KiB
PHP
401 lines
15 KiB
PHP
<?php
|
|
|
|
/**
|
|
* UpdateService API
|
|
*
|
|
* GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod&platform=win-x64
|
|
* GET /api/updateservice/v1/latest?product=myapp&channel=prod&platform=win-x64
|
|
* GET /api/updateservice/v1/releases?product=myapp
|
|
* GET /api/updateservice/v1/pubkey
|
|
* POST /api/updateservice/v1/publish (Scope updateservice:publish)
|
|
*
|
|
* SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig
|
|
* ungeschuetzt. Jeder konnte download_url und sha256_hash eines bestehenden
|
|
* Releases ueberschreiben und damit allen Clients ein beliebiges Paket
|
|
* unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish".
|
|
*
|
|
* PLATTFORM: Releases tragen seit Migration 009 eine Laufzeitkennung. Ein
|
|
* Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform oder
|
|
* plattformunabhaengige ('any'). Ein Client ohne Angabe sieht ausschliesslich
|
|
* 'any' - lieber kein Update als das Paket einer fremden Plattform.
|
|
*
|
|
* Die Lese-Endpunkte bleiben ohne Token erreichbar, damit bereits ausgerollte
|
|
* Client-Anwendungen weiter nach Updates suchen koennen. Sie liefern nur
|
|
* Release-Metadaten, die ueber die Download-URL ohnehin oeffentlich sind.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\ApiAuth;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Modules\License\RateLimiter;
|
|
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
|
|
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
|
use Deploymentcenter\Modules\UpdateService\Version;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
|
|
|
$db = Db::init();
|
|
|
|
$limiter = new RateLimiter($db, 240, 60, 'updateservice');
|
|
if (!$limiter->check(Http::clientIp())) {
|
|
Http::fail(429, 'rate_limited', 'Zu viele Anfragen.');
|
|
}
|
|
|
|
$manager = new UpdateManager($db);
|
|
$action = resolveUpdateAction();
|
|
|
|
switch ($action) {
|
|
|
|
case 'check':
|
|
$product = Http::str('product') ?? Http::str('product_slug');
|
|
if ($product === null) {
|
|
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
|
|
}
|
|
|
|
$current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0';
|
|
$channel = Http::str('channel') ?? 'prod';
|
|
$platform = Http::str('platform') ?? Http::str('rid');
|
|
|
|
$latest = $manager->checkUpdate($product, $current, $channel, $platform);
|
|
|
|
if ($latest === null) {
|
|
$installed = $manager->latestRelease($product, $channel, $platform);
|
|
Http::ok([
|
|
'update_available' => false,
|
|
'current_version' => $current,
|
|
'latest_version' => $installed !== null ? $installed['version'] : $current,
|
|
'platform' => UpdateManager::normalizePlatform($platform),
|
|
'message' => 'Anwendung ist aktuell.',
|
|
]);
|
|
}
|
|
|
|
Http::ok([
|
|
'update_available' => true,
|
|
'current_version' => $current,
|
|
'latest_version' => $latest['version'],
|
|
'platform' => $latest['platform'] ?? UpdateManager::PLATFORM_ANY,
|
|
'is_critical' => (bool)$latest['is_critical'],
|
|
'latest_release' => $latest,
|
|
]);
|
|
|
|
case 'latest':
|
|
$product = Http::str('product') ?? Http::str('product_slug');
|
|
if ($product === null) {
|
|
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
|
|
}
|
|
|
|
$release = $manager->latestRelease(
|
|
$product,
|
|
Http::str('channel') ?? 'prod',
|
|
Http::str('platform') ?? Http::str('rid')
|
|
);
|
|
if ($release === null) {
|
|
Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product));
|
|
}
|
|
|
|
Http::ok(['release' => $release]);
|
|
|
|
case 'releases':
|
|
$releases = $manager->getReleases(
|
|
Http::str('product') ?? Http::str('product_slug'),
|
|
Http::str('channel'),
|
|
Http::int('limit', 200),
|
|
Http::str('platform') ?? Http::str('rid')
|
|
);
|
|
Http::ok(['count' => count($releases), 'releases' => $releases]);
|
|
|
|
case 'pubkey':
|
|
// Oeffentlicher Schluessel zum Pruefen der Release-Signaturen.
|
|
// Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn,
|
|
// bevor er irgendetwas anderes vertrauen kann.
|
|
$pub = ReleaseSigner::publicKeyPem();
|
|
|
|
if ($pub === null) {
|
|
Http::fail(
|
|
404,
|
|
'signing_disabled',
|
|
'Auf diesem Deploymentcenter ist kein Signierschluessel hinterlegt '
|
|
. '(security.release_private_key). Releases werden unsigniert ausgeliefert.'
|
|
);
|
|
}
|
|
|
|
Http::ok([
|
|
'algorithm' => 'RSA-SHA256',
|
|
'format' => 'canonical-line-v1',
|
|
'public_key' => $pub,
|
|
'fingerprint' => ReleaseSigner::publicKeyFingerprint(),
|
|
]);
|
|
|
|
case 'publish':
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.');
|
|
}
|
|
|
|
$context = ApiAuth::requireScope($db, 'updateservice:publish');
|
|
|
|
$product = Http::str('product_slug') ?? Http::str('product');
|
|
$version = Http::str('version');
|
|
$url = Http::str('download_url');
|
|
|
|
$missing = [];
|
|
if ($product === null) { $missing[] = 'product_slug'; }
|
|
if ($version === null) { $missing[] = 'version'; }
|
|
if ($url === null) { $missing[] = 'download_url'; }
|
|
|
|
if ($missing !== []) {
|
|
Http::fail(400, 'missing_fields', 'Pflichtfelder fehlen: ' . implode(', ', $missing), null, [
|
|
'missing' => $missing,
|
|
]);
|
|
}
|
|
|
|
ApiAuth::enforceProject($context, $product);
|
|
|
|
// Eine Version ohne Ziffern wuerde beim Vergleich als 0.0.0 gelten und
|
|
// die Rangfolge aller Releases dieses Produkts durcheinanderbringen.
|
|
if (preg_match('/^v?\d+(\.\d+)*([-+].*)?$/i', $version) !== 1) {
|
|
Http::fail(400, 'invalid_version', sprintf(
|
|
'Version "%s" ist nicht interpretierbar. Erwartet wird eine Form wie 1.4.3, v1.4.3 oder 1.4.3-beta.1.',
|
|
$version
|
|
));
|
|
}
|
|
|
|
$hash = Http::str('sha256_hash');
|
|
if ($hash !== null && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
|
|
Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.');
|
|
}
|
|
|
|
$channel = Http::str('channel') ?? 'prod';
|
|
$platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid'));
|
|
$size = Http::int('size_bytes', 0);
|
|
|
|
// Das Dateimanifest wandert mit in die Datenbank. Bisher blieb die
|
|
// Spalte manifest_json immer leer - damit konnte die API kein
|
|
// vollwertiger Rueckfall fuer den Agenten sein, wenn die statische
|
|
// latest.json fehlt.
|
|
$manifestJson = null;
|
|
$manifestRaw = Http::input('manifest_json', null);
|
|
|
|
if (is_array($manifestRaw)) {
|
|
$encoded = json_encode($manifestRaw, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
$manifestJson = $encoded === false ? null : $encoded;
|
|
} elseif (is_string($manifestRaw) && trim($manifestRaw) !== '') {
|
|
if (json_decode($manifestRaw) === null && json_last_error() !== JSON_ERROR_NONE) {
|
|
Http::fail(400, 'invalid_manifest', 'manifest_json ist kein gueltiges JSON.');
|
|
}
|
|
$manifestJson = $manifestRaw;
|
|
}
|
|
|
|
if ($manifestJson !== null && strlen($manifestJson) > 4 * 1024 * 1024) {
|
|
Http::fail(400, 'manifest_too_large', 'manifest_json ueberschreitet 4 MB.');
|
|
}
|
|
|
|
// Signiert wird serverseitig. Der Packager laeuft auf Entwickler-
|
|
// rechnern; ein dort hinterlegter Signierschluessel waere so gut
|
|
// geschuetzt wie das schwaechste dieser Systeme.
|
|
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
|
|
$product,
|
|
$version,
|
|
$channel,
|
|
$platform,
|
|
$hash,
|
|
$url,
|
|
$size
|
|
));
|
|
|
|
$result = $manager->addRelease(
|
|
$product,
|
|
$version,
|
|
$channel,
|
|
Http::str('release_notes'),
|
|
$url,
|
|
$hash,
|
|
Http::str('git_commit'),
|
|
$size,
|
|
$manifestJson,
|
|
filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN),
|
|
$context['actor'],
|
|
$platform,
|
|
$signature
|
|
);
|
|
|
|
Http::ok([
|
|
'release_id' => $result['id'],
|
|
'created' => $result['created'],
|
|
'platform' => $platform,
|
|
'signed' => $signature !== null,
|
|
'auto_resolved' => $result['auto_resolved'],
|
|
'message' => sprintf(
|
|
'Release %s (%s, %s) fuer "%s" %s.%s%s',
|
|
$version,
|
|
$channel,
|
|
$platform,
|
|
$product,
|
|
$result['created'] ? 'veroeffentlicht' : 'aktualisiert',
|
|
$signature === null
|
|
? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.'
|
|
: '',
|
|
$result['auto_resolved'] > 0
|
|
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
|
|
: ''
|
|
),
|
|
], $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', 'update', 'delete'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Bestimmt die Aktion aus dem Pfadsegment oder ?action=.
|
|
* Feste Liste statt Teilstring-Suche.
|
|
*/
|
|
function resolveUpdateAction(): string
|
|
{
|
|
$explicit = Http::str('action');
|
|
if ($explicit !== null) {
|
|
// Altes Feld hiess publish_release
|
|
return $explicit === 'publish_release' ? 'publish' : 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) {
|
|
'check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last,
|
|
'publish_release' => 'publish',
|
|
default => 'check',
|
|
};
|
|
}
|