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
+83 -6
View File
@@ -32,6 +32,7 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\Evaluator;
use Deploymentcenter\Modules\Watchdog\EventLog;
@@ -716,25 +717,49 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$updateManager = new UpdateManager($pdo);
$channel = trim((string)($_POST['channel'] ?? 'prod'));
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
$size = (int)($_POST['size_bytes'] ?? 0);
// Auch der Weg ueber die Oberflaeche wird signiert, sonst
// haetten von Hand eingetragene Releases keine Vertrauenskette.
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
$productSlug,
$version,
$channel,
$platform,
$hash !== '' ? $hash : null,
$url,
$size
));
$result = $updateManager->addRelease(
$productSlug,
$version,
trim((string)($_POST['channel'] ?? 'prod')),
$channel,
trim((string)($_POST['release_notes'] ?? '')),
$url,
$hash !== '' ? $hash : null,
trim((string)($_POST['git_commit'] ?? '')),
(int)($_POST['size_bytes'] ?? 0),
$size,
null,
isset($_POST['is_critical']),
$actor
$actor,
$platform,
$signature
);
dc_flash(sprintf(
'Release %s fuer "%s" %s.%s',
'Release %s (%s, %s) fuer "%s" %s.%s%s',
e($version),
e($channel),
e($platform),
e($productSlug),
$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'])
: ''
@@ -1654,7 +1679,18 @@ $csrfField = Csrf::field();
Der Evaluator lief noch nie.
<?php endif; ?>
<div style="font-size:0.78rem; margin-top:0.5rem; font-family:'Roboto Mono',monospace; opacity:0.85;">
Cron einrichten: * * * * * curl -fsS -H "Authorization: Bearer &lt;SHARED_KEY&gt;" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate &gt; /dev/null
<!--
Der interne Aufruf steht zuerst: er braucht keinen Schluessel
im Crontab - der taucht sonst in der Prozessliste jedes
Nutzers auf - und laeuft auch dann, wenn der Webserver
gerade nicht erreichbar ist.
-->
Empfohlen (intern):<br>
* * * * * /usr/bin/php <?= e(DC_ROOT) ?>/cli/tick.php --quiet
<div style="margin-top:0.45rem; opacity:0.75;">
Alternativ über HTTP:<br>
* * * * * curl -fsS -H "Authorization: Bearer &lt;SHARED_KEY&gt;" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate &gt; /dev/null
</div>
</div>
</div>
<form method="POST" action="index.php#tab-watchdog" style="flex-shrink:0;">
@@ -1698,6 +1734,7 @@ $csrfField = Csrf::field();
<th>Hierarchie / Entity Source</th>
<th>Typ</th>
<th>Status</th>
<th>Version</th>
<th>Letzte Meldung</th>
<th>Zuletzt Gesehen</th>
</tr>
@@ -1727,6 +1764,13 @@ $csrfField = Csrf::field();
<?= strtoupper($m['state']) ?>
</span>
</td>
<td>
<?php if (!empty($m['app_version'])): ?>
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
<?php else: ?>
<span style="color:var(--text-muted);"></span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td>
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
</tr>
@@ -2358,6 +2402,7 @@ Antwort:
<th>Name</th>
<th>Typ</th>
<th>Status</th>
<th>Version</th>
<th>Zuletzt gesehen</th>
<th>Kurz-Info / Message</th>
<th>Aktionen</th>
@@ -2383,6 +2428,13 @@ Antwort:
</td>
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
<td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'down' ?>"><?= strtoupper($m['state']) ?></span></td>
<td>
<?php if (!empty($m['app_version'])): ?>
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
<?php else: ?>
<span style="color:var(--text-muted);"></span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
<td><span style="color:var(--text-muted); font-size:0.8rem;"><?= htmlspecialchars($m['last_message'] ?? '-') ?></span></td>
<td style="display:flex; gap:0.35rem;">
@@ -2566,6 +2618,7 @@ Antwort:
<tr>
<th>Projekt</th>
<th>Kanal</th>
<th>Plattform</th>
<th>Version</th>
<th>Git Commit</th>
<th>Größe</th>
@@ -2588,7 +2641,15 @@ Antwort:
<tr>
<td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td>
<td><span class="badge <?= $channelClass ?>"><?= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?></span></td>
<td><code>v<?= htmlspecialchars($r['version']) ?></code> <?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?></td>
<td>
<?php $platformLabel = (string)($r['platform'] ?? 'any'); ?>
<code style="font-size:0.75rem;<?= $platformLabel === 'any' ? 'color:var(--text-muted);' : '' ?>"><?= htmlspecialchars($platformLabel) ?></code>
</td>
<td>
<code>v<?= htmlspecialchars($r['version']) ?></code>
<?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?>
<?php if (empty($r['manifest_signature'])): ?><span class="badge badge-warning" title="Ohne Signatur - der Agent kann die Herkunft nicht prüfen.">UNSIGNIERT</span><?php endif; ?>
</td>
<td><code style="color:var(--text-muted);"><?= htmlspecialchars($r['git_commit'] ?? 'n/a') ?></code></td>
<td><span style="font-family:'Roboto Mono', monospace; font-size:0.8rem;"><?= $sizeFormatted ?></span></td>
<td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td>
@@ -2633,6 +2694,22 @@ Antwort:
<option value="dev">dev (Entwicklung)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Plattform</label>
<select name="platform" class="form-input">
<option value="any" selected>any (plattformunabhängig)</option>
<option value="win-x64">win-x64</option>
<option value="win-arm64">win-arm64</option>
<option value="linux-x64">linux-x64</option>
<option value="linux-arm64">linux-arm64</option>
<option value="linux-musl-x64">linux-musl-x64</option>
<option value="osx-x64">osx-x64</option>
<option value="osx-arm64">osx-arm64</option>
</select>
<div style="font-size:0.7rem; color:var(--text-muted); margin-top:0.25rem;">
Clients ohne Plattformangabe sehen ausschließlich <code>any</code>.
</div>
</div>
<div class="form-group">
<label class="form-label">Version (z. B. 1.2.0)</label>
<input type="text" name="version" class="form-input" required placeholder="1.2.0">