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 -9
View File
@@ -16,6 +16,18 @@
>
> Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**
> **⚠️ Änderungen in Version 2.1 — betreffen jede bestehende Integration**
> - **Ein HTTP-Fehler ist kein Lizenzurteil mehr.** `ValidateAsync` fällt jetzt
> bei jedem erfolglosen Aufruf auf den Offline-Cache zurück, nicht nur bei
> einer Exception. Der Status `unknown_error` entfällt, siehe **[§6](#6-fehlerfälle-richtig-behandeln)**.
> - **`cache_ttl_hours` wird ausgewertet.** Die Offline-Gnadenfrist ist damit
> tatsächlich begrenzt, siehe **[§3](#3-zustandsspeicher-statestorecs--cache-härtung)**.
> - `ValidateAsync` nimmt `appVersion` und einen `CancellationToken` entgegen.
> - Neu: `LicenseClient.TryGetCachedKey()` und `RevalidateAsync()`.
> - `DeactivateAsync` sendet den Schlüssel nur noch als `Authorization: Bearer`.
> Der zusätzliche `X-Watchdog-Key` ist entfallen.
> - Standard-Zeitgrenze von 15 s statt 100 s.
---
## 0. Antwortformat — bitte beachten
@@ -123,12 +135,18 @@ public class Program
string licenseKey = "LLAB1-98A72-B3C4D-5E6F7-89012";
// 3. Online-Validierung durchführen
LicenseValidationResult res = await client.ValidateAsync(ProductSlug, licenseKey, ServerUrl);
LicenseValidationResult res = await client.ValidateAsync(
ProductSlug, licenseKey, ServerUrl, appVersion: BuildInfo.Version);
if (res.IsValid)
{
Console.WriteLine($"[✔] Lizenz gültig! (Status: {res.Status}, Cached: {res.IsCached})");
}
else if (res.IsTransient)
{
// Kein Urteil, nur keine Verbindung. Weiterlaufen lassen.
Console.WriteLine($"[!] Lizenz vorerst nicht prüfbar: {res.Message}");
}
else
{
Console.WriteLine($"[✖] Lizenz ungültig: {res.Message}");
@@ -138,6 +156,53 @@ public class Program
}
```
### 2.2 Signatur
```csharp
Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default);
```
`appVersion` landet in der Aktivierungsliste des WebUI. Ohne Angabe greift
`LicenseClient.DefaultAppVersion` und danach die Version der Startassembly.
Einmal beim Start setzen genügt:
```csharp
LicenseClient.DefaultAppVersion = BuildInfo.Version;
```
> Vorher stand hier fest `"1.0.0"`. In der Verwaltungsansicht trug dadurch jede
> Installation dieselbe Version, obwohl die Spalte `app_version` dafür da ist.
### 2.3 Ergebnisfelder
| Feld | Bedeutung |
|---|---|
| `IsValid` | Darf die Anwendung laufen? |
| `Status` | `valid`, `valid_offline`, `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `cache_expired`, `clock_rollback`, `server_unavailable` |
| `IsTransient` | **Neu.** Wahr, wenn kein Urteil vorliegt, sondern nur die Verbindung scheiterte |
| `IsCached` | Ergebnis stammt aus dem Offline-Cache |
| `ExpiresAt` | Ablauf der Lizenz (Unix-Zeit) |
| `CacheExpiresAt` | **Neu.** Ende der Offline-Gnadenfrist (Unix-Zeit) |
### 2.4 Zwischengespeicherten Schlüssel lesen
```csharp
// Für --license-status: was liegt lokal vor?
string? key = LicenseClient.TryGetCachedKey(ProductSlug);
LocalCacheData? state = LicenseClient.TryGetCachedState(ProductSlug);
// Erneut prüfen, ohne den Schlüssel selbst vorhalten zu müssen
var res = await client.RevalidateAsync(ProductSlug, ServerUrl);
```
`ValidateAsync` legt den Schlüssel bei erfolgreicher Prüfung selbst im
verschlüsselten Cache ab — eine eigene Ablage braucht es dafür nicht.
---
## 3. Zustandsspeicher (`StateStore.cs`) & Cache-Härtung
@@ -148,11 +213,46 @@ public class Program
- **Linux**: Dateirechte `0600` (`chmod 600 state.dat`).
- **Sicherheitsvorgabe**: Kein Klartext-Rückfall! Beschädigte oder manipulierte Cache-Dateien werden strikt als Cache-Fehltreffer behandelt.
### 3.1 Offline-Gnadenfrist
Der Server meldet in jeder Antwort `cache_ttl_hours` — den projektspezifischen
Wert aus `dc_projects.default_cache_ttl_hours`, den du im WebUI unter
**Projekte** einstellst. Der Client trägt daraus `CacheExpiresAt` in den Cache
ein: **frühestens** die TTL, spätestens der Ablauf der Lizenz selbst.
> Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum der Lizenz als
> Cache-Ablauf eingetragen. Bei einer Lizenz bis 2040 war die Frist ohne
> Serverkontakt damit praktisch unbegrenzt — die Einstellung im WebUI blieb
> wirkungslos.
Wer eine eigene Anbindung schreibt: `cache_ttl_hours` auswerten, nicht durch
`expires_at` ersetzen.
### 3.2 Schema-Stand des Caches
`state.dat` trägt jetzt Schema 3 (zusätzlich `CacheExpiresAt`, `CacheTtlHours`).
Schema 2 wird weiterhin gelesen; die Frist wird dort aus `IssuedAt` plus der
Standard-TTL von 168 h abgeleitet. Ein SDK-Wechsel erzwingt also **keine**
Online-Prüfung.
### 3.3 Migration fremder Cache-Dateien
Eine Datei ohne `LLS2`-Kopf wird nur übernommen, wenn ihr `ProductSlug` zu
diesem Produkt passt und sie einen Lizenzschlüssel enthält.
> Vorher wurde beliebiges JSON nach `LocalCacheData` deserialisiert und sofort
> zurückgeschrieben. Passte kein einziges Feld, entstand ein Standardobjekt, das
> die ursprüngliche Datei überschrieb. Da LicenseLabrador denselben Pfad und
> Dateinamen verwendet — `GetStorageDirectory` berücksichtigt eigens
> `LICENSELABRADOR_STORAGE_DIR` —, zerstörte das dessen Cache still.
---
## 4. Kopfloser Betrieb (Headless Services / systemd)
Für Hintergrunddienste (ohne GUI) stehen folgende CLI-Schalter am Anwendungshost zur Verfügung:
> **Diese Schalter baut deine Anwendung selbst** — das SDK bringt keine
> CLI mit. Es liefert die Bausteine dafür; die folgende Empfehlung ist eine
> Konvention, damit alle Dienste gleich zu bedienen sind.
```bash
# Status der Hardware-ID und des lokalen Caches ausgeben
@@ -165,6 +265,52 @@ my-service --license-set-key LLAB1-98A72-B3C4D-5E6F7-89012
my-service --license-deactivate
```
Umsetzung mit den Bausteinen des SDK:
```csharp
var client = new LicenseClient();
switch (args[0])
{
case "--license-status":
{
var hw = HardwareId.GetHardwareId(ProductSlug);
var state = LicenseClient.TryGetCachedState(ProductSlug);
Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})");
Console.WriteLine($"Schlüssel: {LicenseClient.TryGetCachedKey(ProductSlug) ?? "keiner"}");
Console.WriteLine($"Cache: {(state == null ? "leer" : state.Status)}");
if (state != null && state.CacheExpiresAt > 0)
{
var until = DateTimeOffset.FromUnixTimeSeconds(state.CacheExpiresAt);
Console.WriteLine($"Offline noch bis: {until:u}");
}
return 0;
}
case "--license-set-key":
// ValidateAsync legt den Schlüssel bei Erfolg selbst im Cache ab.
var res = await client.ValidateAsync(ProductSlug, args[1], ServerUrl);
Console.WriteLine(res.Message);
return res.IsValid ? 0 : 1;
case "--license-deactivate":
string? key = LicenseClient.TryGetCachedKey(ProductSlug);
if (key == null) { Console.Error.WriteLine("Kein Schlüssel hinterlegt."); return 1; }
bool ok = await client.DeactivateAsync(
ProductSlug, key, ServerUrl,
Environment.GetEnvironmentVariable("DC_SHARED_KEY") ?? "");
return ok ? 0 : 1;
}
```
> `TryGetCachedKey` und `TryGetCachedState` sind neu in 2.1. Vorher gab es
> keinen Weg, den Schlüssel aus dem eigenen Cache zu lesen — jede Anwendung
> musste ihn ein zweites Mal selbst ablegen oder direkt `StateStore.Load()`
> aufrufen.
### Deaktivierung braucht Authentifizierung
`/api/license/v1/deactivate` gibt einen Aktivierungsplatz frei und ist deshalb
@@ -192,13 +338,12 @@ curl -X POST https://dc.mhdf.de/api/license/v1/deactivate \
Alternativ genügt für Einzelfälle der Knopf **Freigeben** in der Hardware-Liste
des WebUI — das ist der übliche Weg und braucht keinen Schlüssel im Feld.
### Was passiert bei Ratenbegrenzung
`/validate` ist auf 120 Anfragen pro Minute und IP begrenzt. Darüber kommt
`429` mit `{"status":"error","error":{"code":"rate_limited"}}` — hier greift
ausnahmsweise das Umschlagformat, weil die Drosselung vor der Lizenzlogik
zuschlägt. Ein Client sollte in dem Fall den lokalen Cache verwenden und es
später erneut versuchen, statt die Anwendung zu blockieren.
> **Nur noch `Authorization: Bearer`.** Frühere SDK-Fassungen schickten denselben
> Wert zusätzlich als `X-Watchdog-Key`. Der Name führte in die Irre — hier gehört
> der `shared_key` hin, kein Watchdog-Token — und der Server prüft
> `X-Watchdog-Key` **vor** `Authorization`. Ein dort versehentlich eingetragenes
> Watchdog-Token hätte das richtige Bearer-Token stillschweigend verdrängt und
> zu einem unerklärlichen 401 geführt.
---
@@ -208,3 +353,66 @@ Wenn ein bestehender Windows-Client auf Hardware-ID v2 aktualisiert wird:
- Der Client schickt `hardware_id` (v2) **und** `legacy_hardware_id` (v1) mit.
- Der Server findet die alte Aktivierung unter `legacy_hardware_id` und zieht den Datenbank-Eintrag lautlos auf v2 um.
- Es wird kein zusätzlicher Aktivierungsplatz verbraucht!
---
## 6. Fehlerfälle richtig behandeln
**Nur ein gelesenes Urteil ist ein Urteil.** Ein HTTP-Fehler sagt nichts über
die Lizenz aus — er sagt, dass die Frage nicht beantwortet wurde.
`ValidateAsync` fällt deshalb bei **jedem** erfolglosen Aufruf auf den
Offline-Cache zurück: Netzfehler, Zeitüberschreitung, `429`, `5xx`, und auch bei
einer erfolgreichen Antwort, die sich nicht auswerten lässt (etwa der
Fehlerseite eines Proxys mit Statuscode 200).
```csharp
var res = await client.ValidateAsync(ProductSlug, key, ServerUrl);
if (res.IsValid)
{
// valid oder valid_offline — weiterarbeiten
}
else if (res.IsTransient)
{
// server_unavailable, cache_expired: kein Urteil, nur keine Auskunft.
// Warnen, weiterlaufen lassen, später erneut prüfen.
logger.Warn(res.Message);
}
else
{
// revoked, expired, not_found, activation_limit, suspended, clock_rollback
// Das ist ein Urteil. Jetzt darf beendet werden.
Environment.Exit(1);
}
```
| Status | `IsTransient` | Bedeutung |
|---|---|---|
| `valid` | | Vom Server bestätigt |
| `valid_offline` | | Aus dem Cache, Gnadenfrist läuft noch |
| `server_unavailable` | **ja** | Keine Verbindung **und** kein brauchbarer Cache |
| `cache_expired` | **ja** | Gnadenfrist abgelaufen, Server weiterhin stumm |
| `revoked`, `expired`, `not_found`, `activation_limit`, `suspended` | nein | Endgültig |
| `clock_rollback` | nein | Systemuhr zurückgedreht, Online-Prüfung nötig |
> **Was sich geändert hat:** Der Cache-Zweig lag zuvor ausschließlich im
> `catch`. Ein `429` oder `500` wirft aber keine Exception — die Prüfung fiel aus
> dem Erfolgszweig heraus und endete als `unknown_error`, **ohne den Cache auch
> nur zu befragen**. Ein Server-Schluckauf entzog damit die Lizenz, ein gezogenes
> Netzkabel nicht. Der Status `unknown_error` existiert nicht mehr.
### Ratenbegrenzung
`/validate` ist auf 120 Anfragen pro Minute und IP begrenzt. Darüber kommt
`429` mit `{"status":"error","error":{"code":"rate_limited"}}` — hier greift
ausnahmsweise das Umschlagformat, weil die Drosselung vor der Lizenzlogik
zuschlägt. Hinter einem NAT mit vielen Installationen ist das erreichbar. Der
Client verwendet in diesem Fall den lokalen Cache und versucht es später erneut.
### Zeitgrenze
Der intern erzeugte `HttpClient` hat jetzt 15 s statt der 100 s des .NET-Standards
— sonst stand eine Anwendung beim Start bis zu anderthalb Minuten still, wenn
der Server nicht antwortete. Ein selbst übergebener `HttpClient` wird nicht
verändert; dessen Zeitgrenze verantwortet der Aufrufer.