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
+217
View File
@@ -3,6 +3,16 @@
Diese Fassung enthält Sicherheitskorrekturen, die das Verhalten der
Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen.
> **Version 2.1** ergänzt Korrekturen im .NET-SDK und ein neues Feld am
> Heartbeat. Sie erfordert keine Konfigurationsänderung, aber eine
> Datenbankmigration und ein Nachziehen der Clients:
> **[§14 Umstellung auf 2.1](#14-umstellung-auf-21)**.
>
> **Version 2.2** überarbeitet den UpdateService grundlegend: Plattform-Dimension,
> signierte Releases, Staging-Swap mit Rollback und geschützte Konfigurationsdateien.
> **Vor dem nächsten Release einzuspielen** —
> **[§15 Umstellung auf 2.2](#15-umstellung-auf-22)**.
---
## 1. Vor dem Deployment: Zugangsdaten wechseln
@@ -102,10 +112,38 @@ Die Migration ist additiv und legt an bzw. korrigiert:
bisher vollständig — der Zustand änderte sich nur beim Eintreffen eines
Heartbeats, ein ausgefallener Server blieb dauerhaft grün.
### Empfohlen: interner Aufruf (seit 2.1)
```bash
* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
```
Das entspricht dem früheren `watchdog/cli/tick.php`. Gegenüber dem HTTP-Weg:
- **Kein Schlüssel im Crontab.** `ps` zeigt die Argumente laufender Prozesse
jedem Nutzer der Maschine — bei einem `curl -H "Authorization: Bearer …"`
also auch den Shared Key.
- **Keine Abhängigkeit von Webserver, TLS oder DNS.** Der Evaluator läuft auch
dann, wenn der Webserver klemmt — also gerade dann, wenn das Erkennen von
Ausfällen wichtig ist.
- **Keine überlappenden Läufe.** Das Skript sperrt sich per `flock` selbst; ein
langsamer Vorlauf führt nicht zu doppelten Alarmen.
`--quiet` gibt nur bei Zustandswechseln und Fehlern etwas aus — sonst erzeugt
ein minütlicher Job 1440 Cron-Mails am Tag. Rückgabewerte: `0` erfolgreich,
`1` Fehler, `2` übersprungen (es lief bereits ein Tick).
### Alternativ: über die Schnittstelle
Sinnvoll, wenn der Cron auf einer anderen Maschine läuft als die Anwendung:
```bash
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
```
Beide Wege rufen denselben Code auf und schreiben denselben Lauf-Vermerk in
`watchdog_cron_jobs` — nur einen von beiden einrichten.
Solange der Job fehlt, zeigt das WebUI oben einen Warnhinweis mit einer
Schaltfläche für einen einmaligen Lauf.
@@ -279,3 +317,182 @@ X-DC-Signature: sha256=<hex(hmac_sha256(secret, timestamp + "." + body))>
```
Nach 20 Fehlversuchen in Folge deaktiviert sich ein Webhook selbst.
---
## 14. Umstellung auf 2.1
Diese Fassung korrigiert Fehler im .NET-SDK, ergänzt ein Feld am Heartbeat und
vervollständigt die OpenAPI-Beschreibung. **Keine Konfigurationsänderung nötig.**
### 14.1 Serverseitig
- [ ] Migration **008** ausführen (Spalte `app_version` in `watchdog_monitors`).
Läuft wie die übrigen über den Migrator, siehe [§4](#4-migration-ausführen).
- [ ] `/api/openapi.json` abrufen und prüfen, dass die Lizenz-Endpunkte und
`/api/watchdog/v1/event`, `/events`, `/status` jetzt enthalten sind.
Bestehende Agenten laufen unverändert weiter; alle neuen Felder sind optional.
### 14.2 Clientseitig (`Deploymentcenter.Client`)
Die folgenden Änderungen betreffen jede bestehende Integration:
| Änderung | Was zu tun ist |
|---|---|
| `unknown_error` entfällt | Wer diesen Status abfängt, prüft stattdessen `IsTransient` |
| Cache-Rückfall bei jedem HTTP-Fehler | Eigene Behelfslösungen für 429/5xx können entfallen |
| `cache_ttl_hours` wird ausgewertet | Offline-Gnadenfrist prüfen: Vorgabe je Projekt im WebUI |
| `app_version` ist Parameter | `LicenseClient.DefaultAppVersion` beim Start setzen |
| `X-Watchdog-Key` entfällt bei `DeactivateAsync` | Nur noch `Authorization: Bearer` |
| `BuildInfo.targets` erzeugt in `$(RootNamespace)` | Import ist jetzt möglich; ggf. `using` entfernen |
| API-Rückfall der Update-Prüfung liefert vollständige Daten | Eigene Umgehungen können entfallen |
Ausführlich: **[LICENSE_INTEGRATION_GUIDE §6](./LICENSE_INTEGRATION_GUIDE.md)**
und **[UPDATESERVICE_INTEGRATION_GUIDE §2B/§2C](./UPDATESERVICE_INTEGRATION_GUIDE.md)**.
### 14.3 Cache-Schema
`state.dat` trägt jetzt Schema 3. Schema 2 wird weiterhin gelesen — ein
SDK-Wechsel erzwingt **keine** erneute Online-Prüfung. Ein Rückschritt auf ein
älteres SDK dagegen schon: das kennt Schema 3 nicht und behandelt den Cache als
Fehltreffer.
---
## 15. Umstellung auf 2.2
Diese Fassung behebt eine Reihe von Fehlern im Update-Weg, die zusammengenommen
verhindert haben, dass für mehr als eine Plattform ausgeliefert werden konnte —
und die im Fehlerfall halb aktualisierte Installationen hinterließen.
**Diese Umstellung gehört vor das nächste Release**, nicht danach.
### 15.1 Was sich ändert
| Bereich | Vorher | Jetzt |
|---|---|---|
| Plattformen | nicht vorhanden — `win-x64` und `linux-x64` überschrieben sich | eigene Dimension `platform`, getrennte Ablage |
| Konfigurationsdateien | wurden beim Update überschrieben | `preservePatterns` schützt sie |
| Ausschlussmuster | nur `*.endung` und exakte Namen | echte Globs (`**`, `*`, `?`) |
| Abbruch beim Anwenden | halb aktualisierte Installation | Backup, automatischer Rollback |
| Entfernte Dateien | blieben für immer liegen | werden aufgeräumt |
| Agent im Paket | überschrieb sich selbst | wird zur Seite gelegt und ersetzt |
| Übergabe an den Agenten | Wettlauf, kein Neustart | PID-Handschlag und `--restart` |
| Versionsangabe | ungeprüft | wird gegen die Assembly gehalten |
| Paketherkunft | nur SHA256 aus derselben Quelle | RSA-Signatur des Servers |
| `manifest_json` | Spalte blieb immer leer | wird befüllt, API ist vollwertiger Rückfall |
### 15.2 Serverseitig
- [ ] Migration **009** ausführen (`platform`, `manifest_signature`, neuer
Unique-Key). Additiv; bestehende Releases erhalten `platform = 'any'` und
bleiben für alle Clients erreichbar.
- [ ] Signierschlüssel erzeugen und in `config/config.php` eintragen:
```bash
openssl genrsa -out /etc/dc/release-signing.pem 2048
chmod 600 /etc/dc/release-signing.pem
chown www-data:www-data /etc/dc/release-signing.pem
```
```php
'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', '/etc/dc/release-signing.pem'),
```
- [ ] Prüfen, dass der öffentliche Schlüssel ausgeliefert wird:
```bash
curl https://dc.mhdf.de/api/updateservice/v1/pubkey
```
> **Bewusst RSA und kein HMAC.** Bei einem HMAC braucht der Prüfende denselben
> geheimen Schlüssel wie der Signierende. Der Agent läuft auf fremden Systemen;
> ein dort hinterlegter Schlüssel ließe sich auslesen und zum Signieren
> beliebiger Pakete verwenden — die Signatur wäre wertlos. Beim Lizenzmodul geht
> HMAC auf, weil dort der Server prüft.
Ohne Schlüssel bleibt alles funktionsfähig; Releases sind dann unsigniert, und
der Agent weist bei jedem Update darauf hin.
### 15.3 Packager
- [ ] `packager.config.json` um `preservePatterns` ergänzen (Vorlage in
`packager.config.example.json`).
- [ ] **Prüfen, welche Konfigurationsdateien im Publish-Verzeichnis liegen.**
Der Packager warnt jetzt von sich aus vor Dateien, die nach Zugangsdaten
aussehen und weder ausgeschlossen noch geschützt sind. Diese Warnung ernst
nehmen: eine `settings.json` mit Datenbankpasswort, die bisher mitgeliefert
wurde, hat beim Update die Konfiguration jedes Zielsystems überschrieben.
- [ ] Aufrufe um `--platform` ergänzen:
```bash
pack-and-deploy --project myapp --version 1.4.3 --channel prod \
--platform win-x64 \
--publish-dir ./bin/Release/net8.0/win-x64/publish
```
Ohne `--platform` versucht der Packager, die Kennung aus dem Publish-Pfad
abzuleiten. Gelingt das nicht, gilt das Release als plattformunabhängig und es
erscheint eine Warnung.
- [ ] Die Version wird jetzt gegen die Hauptassembly geprüft. Weicht sie ab,
bricht der Vorgang ab. Das ist der Fall, in dem `<Version>` nur in einem
der beteiligten Projekte steht — der Wert gehört in die
`Directory.Build.props`, damit alle denselben tragen. Notausgang für
bewusste Abweichungen: `--ignore-version-mismatch`.
### 15.4 Clientseitig
`UpdateClient.CheckForUpdateAsync` hat einen zusätzlichen Parameter `platform`
**vor** dem `CancellationToken`. Wer den Token bisher positionell übergeben hat,
bekommt einen Übersetzungsfehler — kein stilles Fehlverhalten.
Der Aufruf des Agenten sollte umgestellt werden:
```csharp
var agentPath = UpdateClient.ResolveAgentPath(); // plattformrichtiger Name
if (agentPath != null && checkResult.UpdateAvailable)
{
UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: "myapp",
channel: "prod",
currentVersion: BuildInfo.Version, // für Ziele ohne manifest.json
exitCurrentApp: true); // Neustart und PID-Handschlag
// sind jetzt Vorgabe
}
```
| Änderung | Wirkung |
|---|---|
| `ResolveAgentPath()` | Findet `update-agent` bzw. `update-agent.exe` — ein fest verdrahteter `.exe`-Name wird unter Linux nie gefunden |
| `--restart` wird übergeben | Die Anwendung startet nach dem Update wieder. Bisher blieb sie einfach zu |
| `--wait-for-pid` wird übergeben | Der Agent wartet auf das Ende der Anwendung, statt über gesperrte Dateien zu kopieren |
| `--platform` wird übergeben | Es kann kein Paket einer fremden Plattform mehr gezogen werden |
### 15.5 Den Agenten aus dem Paket nehmen
Liegt `update-agent` im Publish-Verzeichnis, wandert er ins Paket und wird beim
Update mit ausgeliefert. Das ist zulässig — der Agent legt seine laufende Datei
inzwischen zur Seite und ersetzt sie —, aber es lohnt sich, bewusst zu
entscheiden:
- **Mitliefern:** Der Agent aktualisiert sich selbst mit. Die alte Fassung bleibt
als `update-agent.exe.dc-old` liegen und wird beim nächsten Start entfernt.
- **Ausschließen:** `"update-agent*"` in `excludePatterns`. Dann wird der Agent
getrennt gepflegt.
### 15.6 Prüfen
- [ ] `curl "https://dc.mhdf.de/api/updateservice/v1/check?product=myapp&version=0.0.0&channel=prod&platform=win-x64"`
liefert das Windows-Paket, mit `platform=linux-x64` das Linux-Paket.
- [ ] Ohne `platform` kommen ausschließlich Releases mit `platform=any`. Das ist
Absicht: ein Client, der seine Kennung nicht nennt, soll lieber kein Update
bekommen als das falsche.
- [ ] Im WebUI zeigt die Release-Übersicht eine Spalte **Plattform** und
kennzeichnet unsignierte Releases.
- [ ] Ein Update auf einem Testsystem: `appsettings.json` behält danach die
eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr
gibt, ist verschwunden.