feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback

Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen
verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im
Fehlerfall halb aktualisierte Installationen hinterliessen.

Server
- Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das
  zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version,
  weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System
  zog sich damit das Windows-Paket.
- Aufloesungsregel: je Version das plattformgenaue Paket, sonst das
  plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich
  'any' - lieber kein Update als das falsche.
- manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer,
  wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war.
- Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt
  /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf
  fremden Systemen und darf den Signierschluessel nicht besitzen.

Packager
- Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der
  bestehenden latest.json fehl, ersetzte ein leeres catch die komplette
  Historie durch einen einzigen Eintrag - ohne jede Meldung.
- Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der
  mitgelieferten Beispielkonfiguration nie zu.
- preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am
  Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten
  ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems.
- Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen.
- Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer
  Endlosschleife: Clients aktualisieren, melden weiter die alte Version,
  halten das Release erneut fuer neu.
- --platform mit Ableitung aus dem Publish-Pfad.

Agent
- Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als
  "Atomic Replace with Backup" kommentiert und war eine Kopierschleife.
- Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der
  Vorversion. Was nicht aus einem Release stammt, bleibt liegen.
- Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben.
- API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb
  die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte.
- Installierte Version aus --current-version oder manifest.json statt des
  Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen
  liess. Reparatur funktioniert damit auch ohne manifest.json.
- Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden.

SDK
- ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes
  "update-agent.exe" wird unter Linux nie gefunden.
- LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die
  Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit
  dem Herunterfahren) und --platform.

Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz-
Client und cli/tick.php samt Migration 008; 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-09 19:56:35 +02:00
co-authored by Claude Opus 5
parent 5f9b0c5596
commit 2388b5abe1
37 changed files with 5498 additions and 491 deletions
+99 -16
View File
@@ -3,9 +3,10 @@
/**
* UpdateService API
*
* GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod
* GET /api/updateservice/v1/latest?product=myapp&channel=prod
* 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
@@ -13,6 +14,11 @@
* 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.
@@ -26,6 +32,7 @@ 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;
@@ -49,17 +56,19 @@ switch ($action) {
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';
$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);
$latest = $manager->checkUpdate($product, $current, $channel, $platform);
if ($latest === null) {
$installed = $manager->latestRelease($product, $channel);
$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.',
]);
}
@@ -68,6 +77,7 @@ switch ($action) {
'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,
]);
@@ -78,7 +88,11 @@ switch ($action) {
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
}
$release = $manager->latestRelease($product, Http::str('channel') ?? 'prod');
$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));
}
@@ -89,10 +103,33 @@ switch ($action) {
$releases = $manager->getReleases(
Http::str('product') ?? Http::str('product_slug'),
Http::str('channel'),
Http::int('limit', 200)
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.');
@@ -131,30 +168,76 @@ switch ($action) {
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,
Http::str('channel') ?? 'prod',
$channel,
Http::str('release_notes'),
$url,
$hash,
Http::str('git_commit'),
Http::int('size_bytes', 0),
null,
$size,
$manifestJson,
filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN),
$context['actor']
$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) fuer "%s" %s.%s',
'Release %s (%s, %s) fuer "%s" %s.%s%s',
$version,
Http::str('channel') ?? 'prod',
$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'])
: ''
@@ -163,7 +246,7 @@ switch ($action) {
default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['check', 'latest', 'releases', 'publish'],
'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'],
]);
}
@@ -187,7 +270,7 @@ function resolveUpdateAction(): string
$last = strtolower((string)end($segments));
return match ($last) {
'check', 'latest', 'releases', 'publish' => $last,
'check', 'latest', 'releases', 'pubkey', 'publish' => $last,
'publish_release' => 'publish',
default => 'check',
};