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
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\UpdateService;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Logger;
/**
* Signiert Releases, damit ein Agent pruefen kann, dass Paket und Metadaten
* wirklich aus diesem Deploymentcenter stammen.
*
* Warum asymmetrisch und nicht HMAC wie beim Lizenzmodul: Beim Lizenzmodul
* prueft der Server, der Schluessel bleibt also hier. Ein Update wird dagegen
* auf dem Zielsystem geprueft. Ein dort hinterlegter HMAC-Schluessel liesse
* sich auslesen und zum Signieren beliebiger Pakete verwenden - die Signatur
* wuerde damit genau die Eigenschaft verlieren, wegen der es sie gibt.
*
* Signiert wird eine kanonische Zeile, nicht das Manifest-JSON: JSON-Ausgabe
* ist nicht bytestabil (Schluesselreihenfolge, Escaping, Zahlenformat), eine
* Signatur darueber waere unzuverlaessig pruefbar.
*
* Ohne hinterlegten Schluessel ist Signieren abgeschaltet. Releases bleiben
* dann unsigniert und installierbar; der Agent weist darauf hin.
*/
final class ReleaseSigner
{
/**
* Kanonische Darstellung eines Releases.
*
* Reihenfolge und Trennzeichen sind Teil des Formats und duerfen sich
* nicht aendern, ohne dass alle bestehenden Signaturen ungueltig werden.
* Der Paket-Hash ist der eigentlich zu schuetzende Wert - alles andere
* bindet ihn an genau ein Produkt, eine Version und eine Plattform.
*/
public static function canonical(
string $productSlug,
string $version,
string $channel,
string $platform,
?string $sha256Hash,
string $downloadUrl,
int $sizeBytes
): string {
return implode("\n", [
'dc-release-v1',
$productSlug,
$version,
$channel,
$platform,
strtolower((string)$sha256Hash),
$downloadUrl,
(string)$sizeBytes,
]);
}
/** Ist ein Signierschluessel hinterlegt? */
public static function isEnabled(): bool
{
return self::privateKeyPem() !== '';
}
/**
* Signiert die kanonische Zeile. Rueckgabe base64, oder null wenn kein
* Schluessel hinterlegt ist oder das Signieren fehlschlaegt.
*/
public static function sign(string $canonical): ?string
{
$pem = self::privateKeyPem();
if ($pem === '') {
return null;
}
if (!function_exists('openssl_sign')) {
Logger::warning('Release-Signatur nicht moeglich: OpenSSL-Erweiterung fehlt.');
return null;
}
$key = openssl_pkey_get_private($pem);
if ($key === false) {
Logger::warning('Release-Signatur nicht moeglich: privater Schluessel nicht lesbar.');
return null;
}
$signature = '';
$ok = openssl_sign($canonical, $signature, $key, OPENSSL_ALGO_SHA256);
if ($ok !== true || $signature === '') {
Logger::warning('Release-Signatur fehlgeschlagen.');
return null;
}
return base64_encode($signature);
}
/**
* Oeffentlicher Schluessel im PEM-Format, aus dem privaten abgeleitet.
* Er wird ueber /api/updateservice/v1/pubkey ausgeliefert.
*/
public static function publicKeyPem(): ?string
{
$pem = self::privateKeyPem();
if ($pem === '' || !function_exists('openssl_pkey_get_private')) {
return null;
}
$key = openssl_pkey_get_private($pem);
if ($key === false) {
return null;
}
$details = openssl_pkey_get_details($key);
if (!is_array($details) || !isset($details['key']) || !is_string($details['key'])) {
return null;
}
return $details['key'];
}
/**
* Fingerabdruck des oeffentlichen Schluessels (SHA256 ueber das PEM).
* Damit kann ein Agent den einmal geholten Schluessel wiedererkennen,
* statt ihm bei jedem Aufruf neu zu vertrauen.
*/
public static function publicKeyFingerprint(): ?string
{
$pub = self::publicKeyPem();
return $pub === null ? null : hash('sha256', $pub);
}
/**
* Der Schluessel darf als PEM direkt oder als Pfad zu einer Datei
* hinterlegt sein. Ein Pfad ist auf Servern meist die sauberere Ablage,
* weil der Schluessel dann nicht in der Konfiguration steht.
*/
private static function privateKeyPem(): string
{
$value = trim((string)Config::get('security.release_private_key', ''));
if ($value === '') {
return '';
}
if (str_contains($value, '-----BEGIN')) {
return $value;
}
if (is_file($value) && is_readable($value)) {
$contents = file_get_contents($value);
return $contents === false ? '' : trim($contents);
}
Logger::warning('security.release_private_key zeigt weder auf ein PEM noch auf eine lesbare Datei.');
return '';
}
}
+174 -50
View File
@@ -18,6 +18,9 @@ use PDO;
*/
final class UpdateManager
{
/** Kennung fuer plattformunabhaengige Releases. */
public const PLATFORM_ANY = 'any';
private PDO $db;
public function __construct(PDO $db)
@@ -25,19 +28,104 @@ final class UpdateManager
$this->db = $db;
}
/**
* Bringt eine Laufzeitkennung auf eine gueltige Form.
*
* Erwartet wird eine .NET-RID wie win-x64 oder linux-musl-arm64. Alles
* Leere wird zu 'any'; unbrauchbare Angaben ebenfalls, damit ein
* verunglueckter Parameter nicht dazu fuehrt, dass gar kein Release mehr
* gefunden wird.
*/
public static function normalizePlatform(?string $platform): string
{
$value = strtolower(trim((string)$platform));
if ($value === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,31}$/', $value) !== 1) {
return self::PLATFORM_ANY;
}
return $value;
}
/**
* Releases eines Kanals, gefiltert auf die passende Plattform.
*
* Die Regel lautet: je Version gewinnt das Paket der angefragten
* Plattform, sonst das plattformunabhaengige. Damit bleiben Releases von
* vor der Plattform-Migration ('any') fuer alle Clients erreichbar,
* waehrend ein Client mit Plattformangabe niemals das Paket einer fremden
* Plattform bekommt.
*
* Ein Client ohne Plattformangabe sieht ausschliesslich 'any'. Das ist
* bewusst streng: lieber kein Update als das Windows-Paket auf einem
* Linux-System.
*
* @return list<array<string,mixed>>
*/
private function releasesFor(string $productSlug, string $channel, ?string $platform): array
{
$requested = self::normalizePlatform($platform);
$candidates = $requested === self::PLATFORM_ANY
? [self::PLATFORM_ANY]
: [$requested, self::PLATFORM_ANY];
$placeholders = implode(', ', array_map(
static fn(int $i): string => ':platform' . $i,
array_keys($candidates)
));
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND channel = :channel
AND platform IN (' . $placeholders . ')
');
$params = [':slug' => $productSlug, ':channel' => $channel];
foreach ($candidates as $i => $candidate) {
$params[':platform' . $i] = $candidate;
}
$stmt->execute($params);
$rows = $stmt->fetchAll() ?: [];
// Je Version nur einen Datensatz behalten - den plattformgenauen.
$byVersion = [];
foreach ($rows as $row) {
$version = (string)($row['version'] ?? '');
if ($version === '') {
continue;
}
$rowPlatform = (string)($row['platform'] ?? self::PLATFORM_ANY);
$existing = $byVersion[$version] ?? null;
if ($existing === null) {
$byVersion[$version] = $row;
continue;
}
$existingPlatform = (string)($existing['platform'] ?? self::PLATFORM_ANY);
if ($existingPlatform === self::PLATFORM_ANY && $rowPlatform !== self::PLATFORM_ANY) {
$byVersion[$version] = $row;
}
}
return array_values($byVersion);
}
/**
* Ermittelt das neueste Release, das echt neuer ist als die uebergebene Version.
*
* @return array<string,mixed>|null
*/
public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array
{
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND channel = :channel
');
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
$releases = $stmt->fetchAll() ?: [];
public function checkUpdate(
string $productSlug,
string $currentVersion,
string $channel = 'prod',
?string $platform = null
): ?array {
$releases = $this->releasesFor($productSlug, $channel, $platform);
if ($releases === []) {
return null;
@@ -52,14 +140,9 @@ final class UpdateManager
}
/** Hoechstes Release eines Kanals, unabhaengig von der Client-Version. */
public function latestRelease(string $productSlug, string $channel = 'prod'): ?array
public function latestRelease(string $productSlug, string $channel = 'prod', ?string $platform = null): ?array
{
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND channel = :channel
');
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
return Version::highest($stmt->fetchAll() ?: []);
return Version::highest($this->releasesFor($productSlug, $channel, $platform));
}
/**
@@ -78,41 +161,50 @@ final class UpdateManager
int $sizeBytes = 0,
?string $manifestJson = null,
bool $isCritical = false,
string $author = 'admin'
string $author = 'admin',
?string $platform = null,
?string $manifestSignature = null
): array {
$existing = $this->findRelease($productSlug, $version, $channel);
$platform = self::normalizePlatform($platform);
$existing = $this->findRelease($productSlug, $version, $channel, $platform);
$stmt = $this->db->prepare('
INSERT INTO updateservice_releases (
product_slug, version, channel, release_notes, download_url,
sha256_hash, git_commit, size_bytes, manifest_json, is_critical
product_slug, version, channel, platform, release_notes, download_url,
sha256_hash, git_commit, size_bytes, manifest_json, manifest_signature,
is_critical
) VALUES (
:slug, :version, :channel, :notes, :url,
:hash, :git, :size, :manifest, :critical
:slug, :version, :channel, :platform, :notes, :url,
:hash, :git, :size, :manifest, :signature,
:critical
) ON DUPLICATE KEY UPDATE
release_notes = VALUES(release_notes),
download_url = VALUES(download_url),
sha256_hash = VALUES(sha256_hash),
git_commit = VALUES(git_commit),
size_bytes = VALUES(size_bytes),
manifest_json = VALUES(manifest_json),
is_critical = VALUES(is_critical)
release_notes = VALUES(release_notes),
download_url = VALUES(download_url),
sha256_hash = VALUES(sha256_hash),
git_commit = VALUES(git_commit),
size_bytes = VALUES(size_bytes),
manifest_json = VALUES(manifest_json),
manifest_signature = VALUES(manifest_signature),
is_critical = VALUES(is_critical)
');
$stmt->execute([
':slug' => $productSlug,
':version' => $version,
':channel' => $channel,
':notes' => $releaseNotes,
':url' => $downloadUrl,
':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
':size' => $sizeBytes,
':manifest' => $manifestJson,
':critical' => $isCritical ? 1 : 0,
':slug' => $productSlug,
':version' => $version,
':channel' => $channel,
':platform' => $platform,
':notes' => $releaseNotes,
':url' => $downloadUrl,
':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
':size' => $sizeBytes,
':manifest' => $manifestJson,
':signature' => $manifestSignature !== null && $manifestSignature !== '' ? $manifestSignature : null,
':critical' => $isCritical ? 1 : 0,
]);
$release = $this->findRelease($productSlug, $version, $channel);
$release = $this->findRelease($productSlug, $version, $channel, $platform);
$releaseId = $release !== null ? (int)$release['id'] : 0;
// Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind,
@@ -130,6 +222,7 @@ final class UpdateManager
'project_slug' => $productSlug,
'version' => $version,
'channel' => $channel,
'platform' => $platform,
'is_critical' => $isCritical,
'download_url' => $downloadUrl,
'auto_resolved' => $autoResolved,
@@ -139,10 +232,11 @@ final class UpdateManager
}
Logger::info('Release veroeffentlicht', [
'product' => $productSlug,
'version' => $version,
'channel' => $channel,
'author' => $author,
'product' => $productSlug,
'version' => $version,
'channel' => $channel,
'platform' => $platform,
'author' => $author,
]);
return [
@@ -152,14 +246,24 @@ final class UpdateManager
];
}
public function findRelease(string $productSlug, string $version, string $channel): ?array
{
public function findRelease(
string $productSlug,
string $version,
string $channel,
?string $platform = null
): ?array {
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND version = :version AND channel = :channel
AND platform = :platform
LIMIT 1
');
$stmt->execute([':slug' => $productSlug, ':version' => $version, ':channel' => $channel]);
$stmt->execute([
':slug' => $productSlug,
':version' => $version,
':channel' => $channel,
':platform' => self::normalizePlatform($platform),
]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
}
@@ -176,8 +280,12 @@ final class UpdateManager
*
* @return list<array<string,mixed>>
*/
public function getReleases(?string $productSlug = null, ?string $channel = null, int $limit = 200): array
{
public function getReleases(
?string $productSlug = null,
?string $channel = null,
int $limit = 200,
?string $platform = null
): array {
$where = [];
$params = [];
@@ -190,17 +298,26 @@ final class UpdateManager
$params[':channel'] = $channel;
}
// Hier wird bewusst exakt gefiltert statt aufgeloest: dieser Endpunkt
// listet den Bestand, er waehlt kein Paket aus.
if ($platform !== null && $platform !== '') {
$where[] = 'platform = :platform';
$params[':platform'] = self::normalizePlatform($platform);
}
$sql = 'SELECT * FROM updateservice_releases';
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY product_slug ASC, channel ASC, created_at DESC LIMIT ' . max(1, min($limit, 1000));
$sql .= ' ORDER BY product_slug ASC, channel ASC, platform ASC, created_at DESC LIMIT '
. max(1, min($limit, 1000));
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$releases = $stmt->fetchAll() ?: [];
// Innerhalb einer Produkt/Kanal-Gruppe nach echter Versionsordnung sortieren.
// Innerhalb einer Produkt/Kanal/Plattform-Gruppe nach echter
// Versionsordnung sortieren.
usort($releases, static function (array $a, array $b): int {
$bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']);
if ($bySlug !== 0) {
@@ -210,6 +327,13 @@ final class UpdateManager
if ($byChannel !== 0) {
return $byChannel;
}
$byPlatform = strcmp(
(string)($a['platform'] ?? self::PLATFORM_ANY),
(string)($b['platform'] ?? self::PLATFORM_ANY)
);
if ($byPlatform !== 0) {
return $byPlatform;
}
return Version::compare((string)$b['version'], (string)$a['version']);
});
+5 -1
View File
@@ -25,7 +25,11 @@ use PDO;
* stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen
* im Event-Log und loesen Webhooks aus.
*
* Aufruf per Cron (empfohlen minuetlich):
* Aufruf per Cron (empfohlen minuetlich), bevorzugt intern ohne HTTP:
* * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
*
* Alternativ ueber die Schnittstelle, wenn der Cron auf einer anderen Maschine
* laeuft als die Anwendung:
* curl -H "Authorization: Bearer <SHARED_KEY>" https://dc.example.com/api/watchdog/v1/evaluate
*/
final class Evaluator
+8 -3
View File
@@ -86,7 +86,8 @@ final class MonitorRepo
?string $message,
?string $groupKey = null,
?string $os = null,
$checks = null
$checks = null,
?string $appVersion = null
): array {
$metricsJson = (is_array($metrics) || is_object($metrics))
? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
@@ -139,11 +140,11 @@ final class MonitorRepo
INSERT INTO watchdog_monitors (
source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_seen_utc, last_status, last_message, metrics_json, health_json,
failing_checks, group_key, os, created_utc, updated_utc
failing_checks, group_key, os, app_version, created_utc, updated_utc
) VALUES (
:source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
UTC_TIMESTAMP(), :last_status, :message, :metrics, :health,
:failing, :group_key, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP()
:failing, :group_key, :os, :app_version, UTC_TIMESTAMP(), UTC_TIMESTAMP()
)
ON DUPLICATE KEY UPDATE
-- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
@@ -161,6 +162,9 @@ final class MonitorRepo
failing_checks = VALUES(failing_checks),
group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os),
-- COALESCE, damit ein Agent, der die Version nicht mitschickt,
-- eine frueher gemeldete nicht loescht.
app_version = COALESCE(VALUES(app_version), app_version),
updated_utc = VALUES(updated_utc)
');
@@ -177,6 +181,7 @@ final class MonitorRepo
':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null,
':group_key' => $groupKey,
':os' => $os,
':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null,
]);
$monitor = $this->getMonitor($source, $instance);