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:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user