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.
+413 -22
View File
@@ -6,6 +6,26 @@
> jetzt der semantischen Versionsordnung, `1.10.0` gilt also korrekt als neuer
> als `1.9.0`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**.
> **⚠️ Geändert in Version 2.1**
> - `Deploymentcenter.BuildInfo.targets` erzeugt die Klasse jetzt im Namensraum
> des einbindenden Projekts. Die vorherige Fassung war nicht einbindbar
> (CS0433), siehe **[§2B](#b-msbuild-buildinfo-generierung)**.
> - Der API-Rückfall in `CheckForUpdateAsync` liest die Antwort jetzt korrekt.
> Fehlte die `latest.json`, kamen zuvor weder Download-Adresse noch Prüfsumme,
> Changelog oder Kritikalität an, siehe **[§2C](#c-zwei-quellen-zwei-formate)**.
> **⚠️ Geändert in Version 2.2 — bitte vollständig lesen, bevor das nächste
> Release gebaut wird.** Umstellungsschritte: **[UPGRADE.md §15](./UPGRADE.md#15-umstellung-auf-22)**.
> - **Releases tragen eine Plattform.** Ohne sie überschrieben sich `win-x64`
> und `linux-x64` gegenseitig, siehe **[§1A](#1a-plattformen)**.
> - **Konfigurationsdateien überleben ein Update.** Bisher überschrieb jedes
> Update die eingerichteten Werte des Zielsystems, siehe **[§3A](#3a-ausschließen-oder-schützen)**.
> - **Anwenden mit Rollback.** Ein Abbruch hinterlässt keine halbe Installation
> mehr, und entfernte Dateien werden aufgeräumt, siehe **[§4B](#4b-wie-ein-update-angewendet-wird)**.
> - **Releases sind signiert**, siehe **[§6](#6-signatur-der-releases)**.
> - `LaunchUpdateAgent` übergibt jetzt Neustart, Prozesskennung und Plattform,
> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**.
Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
@@ -16,10 +36,56 @@ Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites,
- **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität.
- **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`).
- **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung).
- **Plattform-Dimension**: je Kanal getrennte Pakete für `win-x64`, `linux-x64` usw.
- **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt.
---
## 1A. Plattformen
Ein Release wird durch **vier** Angaben bestimmt: Projekt, Kanal, Version und
Plattform. Die Plattform ist eine .NET-Laufzeitkennung (`win-x64`,
`linux-x64`, `linux-musl-arm64`, `osx-arm64`, …) oder `any` für Pakete, die
überall laufen.
Zuvor gab es diese Dimension nicht. Wer für zwei Plattformen baute, veröffentlichte
beide Pakete unter derselben Version im selben Kanal — das zweite überschrieb
das erste, und ein Linux-System zog sich das Windows-Paket. Behelfe waren
getrennte Projekt-Slugs (`myapp-win`, `myapp-linux`) oder zweckentfremdete
Kanäle; beides trug nicht weit.
### Auswahlregel
| Client schickt | bekommt |
|---|---|
| `platform=win-x64` | Pakete mit `win-x64`, ersatzweise `any` |
| `platform=linux-x64` | Pakete mit `linux-x64`, ersatzweise `any` |
| nichts | **ausschließlich** `any` |
Der letzte Fall ist Absicht. Ein Client, der seine Kennung nicht nennt, soll
lieber kein Update bekommen als das Paket einer fremden Plattform. Alle vor
Version 2.2 veröffentlichten Releases stehen auf `any` und bleiben damit für
bereits ausgelieferte Anwendungen erreichbar.
Je Version gewinnt das plattformgenaue Paket; gibt es keines, wird das
plattformunabhängige genommen.
### Ablage
Plattformunabhängige Releases behalten den bisherigen Pfad, alle anderen
bekommen eine Zwischenebene:
```text
/releases/myapp/prod/1.4.3/package.tar.gz ← platform = any
/releases/myapp/prod/win-x64/1.4.3/package.tar.gz ← platform = win-x64
/releases/myapp/prod/linux-x64/1.4.3/package.tar.gz ← platform = linux-x64
```
Ohne diese Rücksicht wären alle bestehenden Installationen von einem Tag auf
den anderen von ihren Updates abgeschnitten gewesen.
---
## 2. Integration in .NET Client-Anwendungen
### A. Referenz auf `Deploymentcenter.Client`
@@ -37,32 +103,118 @@ var checkResult = await updateClient.CheckForUpdateAsync(
projectId: "myapp",
currentVersion: BuildInfo.Version,
channel: "prod"
// platform: entfällt - ohne Angabe die Kennung des laufenden Systems
);
if (checkResult.UpdateAvailable)
{
Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!");
// UpdateAgent starten und Hauptanwendung beenden
var agentPath = UpdateClient.ResolveAgentPath();
if (agentPath == null)
{
Console.WriteLine("[UPDATE] Kein update-agent gefunden.");
return;
}
UpdateClient.LaunchUpdateAgent(
agentPath: "update-agent.exe",
projectId: "myapp",
channel: "prod",
action: "update",
version: "latest",
agentPath: agentPath,
projectId: "myapp",
channel: "prod",
action: "update",
version: "latest",
currentVersion: BuildInfo.Version,
exitCurrentApp: true
);
}
```
**Nicht mehr `"update-agent.exe"` fest verdrahten.** `ResolveAgentPath()`
liefert den plattformrichtigen Namen — unter Linux und macOS trägt das Binary
keine Endung, ein fester `.exe`-Name wird dort nie gefunden, und die Anwendung
meldet stumm „kein Agent vorhanden".
Drei Dinge erledigt `LaunchUpdateAgent` seit 2.2 von selbst:
| Was | Warum |
|---|---|
| `--restart` mit dem Pfad der eigenen Anwendung | Zuvor wurde der Parameter nie übergeben. Der Agent unterstützte ihn, bekam ihn aber nie zu sehen — die Anwendung schloss sich und blieb zu |
| `--wait-for-pid` mit der eigenen Prozesskennung | Zuvor wurde der Agent gestartet und sofort `Environment.Exit(0)` gerufen. Bei langsamem Herunterfahren (Kestrel, EF, Log-Flush) kopierte er über noch gesperrte Dateien |
| `--platform` mit der Kennung des Systems | Verhindert, dass ein Paket der falschen Plattform gezogen wird |
Abschaltbar über `restartPath: ""` bzw. `waitForCurrentProcess: false`, wenn
ein Dienst-Manager den Neustart übernimmt.
`currentVersion` ist wichtig für Installationen ohne `manifest.json` — siehe
**[§4A](#4a-welche-version-ist-installiert)**.
### B. MSBuild BuildInfo Generierung
Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden:
```xml
<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
```
Die Klasse entsteht im Namensraum deines Projekts (`$(RootNamespace)`), nicht im
SDK. Du erreichst sie also ohne `using`:
```csharp
Console.WriteLine(BuildInfo.Version); // "1.4.3" aus <Version>
Console.WriteLine(BuildInfo.Summary); // v1.4.3 (5f9b0c5) built on ... [prod]
```
Verfügbare Werte: `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`,
`Channel`, `Summary`. Ohne Git-Arbeitskopie stehen die Commit-Felder auf
`UNKNOWN`, der Build läuft trotzdem durch.
Überschreibbare MSBuild-Eigenschaften:
| Eigenschaft | Wirkung |
|---|---|
| `DeploymentcenterBuildInfoNamespace` | Zielnamensraum (Vorgabe: `$(RootNamespace)`) |
| `DeploymentcenterBuildInfoClass` | Klassenname (Vorgabe: `BuildInfo`) |
| `BuildChannel` | `prod`, `beta`, `dev` (Vorgabe: `prod`) |
| `GenerateDeploymentcenterBuildInfo` | auf `false` setzen, um die Erzeugung abzuschalten |
> **Nicht auf `Deploymentcenter.Client.Models` zeigen lassen.** Dort liefert das
> SDK bereits eine gleichnamige Klasse aus; `partial` verbindet Teilklassen nur
> innerhalb derselben Assembly. Genau daran scheiterte die vorherige Fassung des
> Targets: sie erzeugte fest in diesen Namensraum, wodurch im Consumer ein
> zweiter Typ mit demselben vollen Namen entstand (CS0433) und der generierte
> statische Konstruktor Eigenschaften setzte, die es dort nicht gab (CS0103).
> Ein Import war damit unmöglich.
### C. Zwei Quellen, zwei Formate
`CheckForUpdateAsync` fragt zuerst die statische
`/releases/{projectId}/{channel}/latest.json` ab und fällt auf
`/api/updateservice/v1/check` zurück. **Die beiden liefern unterschiedliche
Feldnamen:**
| Bedeutung | `latest.json` (Packager) | API-Antwort (Datenbank) |
|---|---|---|
| Download-Adresse | `packageUrl` | `download_url` |
| Prüfsumme | `sha256` | `sha256_hash` |
| Änderungshinweise | `changelog` | `release_notes` |
| Größe | `sizeBytes` | `size_bytes` |
| Kritisch | `isCritical` | `is_critical` **auf oberster Ebene** |
Das SDK bildet beide ab: `VersionInfo` für die `latest.json`, `ApiReleaseInfo`
für die API-Antwort. `UpdateCheckResult.LatestRelease` ist in beiden Fällen ein
`VersionInfo` — für die API wird übersetzt.
> Wer die API selbst anspricht, muss das berücksichtigen. Die vorherige SDK-Fassung
> deserialisierte die API-Antwort direkt nach `VersionInfo`; von beiden Formaten
> stimmt nur `version` überein. Über den API-Weg kam deshalb nichts weiter an —
> und `IsCritical` wurde aus dem Release-Objekt statt vom Wurzelfeld gelesen und
> war damit immer `false`. Da dieser Zweig genau der Rückfall ist, wenn die
> `latest.json` fehlt, degradierte die Update-Prüfung still.
---
## 3. Packaging & Deployment CLI (`pack-and-deploy`)
@@ -72,10 +224,16 @@ Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeu
### Aufruf-Beispiel:
```bash
# Automatisierter Release-Publish via CLI
pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung"
pack-and-deploy --project myapp --version 1.4.0 --channel prod \
--platform win-x64 \
--publish-dir ./bin/Release/net8.0/win-x64/publish \
--changelog "Fehlerbehebungen und Performance-Optimierung"
```
Ohne `--platform` versucht der Packager, die Kennung aus dem Publish-Pfad zu
lesen (`.../net8.0/linux-x64/publish``linux-x64`). Gelingt das nicht, gilt
das Release als plattformunabhängig und es erscheint eine Warnung.
### Konfiguration (`packager.config.json`)
> Diese Datei enthält Zugangsdaten und ist per `.gitignore` von der
@@ -98,13 +256,19 @@ pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./b
"appsettings.Development.json",
"*.log",
"logs/**"
],
"preservePatterns": [
"appsettings.json",
"settings.json",
".env"
]
}
```
`apiToken` braucht das Recht `updateservice:publish`. Ohne Token baut und lädt
der Packager das Paket zwar hoch, meldet es aber nicht beim Deploymentcenter an
und beendet sich mit Rückgabewert 2.
und beendet sich mit Rückgabewert 2. Ohne Registrierung entsteht auch **keine
Signatur**.
### Alternative: Umgebungsvariablen
@@ -118,7 +282,8 @@ export DC_FTP_PASS='...'
export DC_TOKEN='dc_sub_...'
pack-and-deploy --project myapp --version 1.4.0 --channel prod \
--publish-dir ./bin/Release/net8.0/publish
--platform linux-x64 \
--publish-dir ./bin/Release/net8.0/linux-x64/publish
```
### Rückgabewerte
@@ -126,7 +291,7 @@ pack-and-deploy --project myapp --version 1.4.0 --channel prod \
| Wert | Bedeutung |
|---|---|
| `0` | Paket gebaut, hochgeladen und im Deploymentcenter registriert |
| `1` | Konfiguration unvollständig oder Publish-Verzeichnis fehlt — nichts wurde ausgeführt |
| `1` | Konfiguration unvollständig, Publish-Verzeichnis fehlt oder Versionskonflikt — nichts wurde ausgeführt |
| `2` | Teilweise fehlgeschlagen: FTP-Upload oder Registrierung ging schief |
Zuvor lieferte das Werkzeug in allen Fällen `0` und meldete „successfully
@@ -134,6 +299,65 @@ published", selbst wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
---
## 3A. Ausschließen oder schützen
Das sind zwei verschiedene Dinge, und die Unterscheidung ist der Grund, warum
Updates bisher Konfigurationen zerstört haben.
| | `excludePatterns` | `preservePatterns` |
|---|---|---|
| Im Paket? | nein | ja |
| Bei der Erstinstallation? | fehlt | wird geschrieben |
| Beim Update? | — | vorhandene Datei bleibt unangetastet |
| Wofür | Build-Artefakte, Logs, Entwicklungs-Einstellungen | Konfigurationsvorlagen |
Eine `appsettings.json` gehört ins Paket — sonst ist eine Erstinstallation
unvollständig. Sie darf beim Update nur nicht über die eingerichteten Werte des
Zielsystems geschrieben werden. Genau dafür ist `preservePatterns` da; die
Liste wandert ins `manifest.json` und wird vom Agenten ausgewertet.
> **Vorher:** `IsExcluded` verstand ausschließlich `*.endung` und exakte
> Namen. Die mitgelieferte Beispielkonfiguration enthielt `logs/**` und
> `scratch/**` — beides traf **nie** zu. Und eine `settings.json` mit
> Datenbankpasswort und DC-Token stand auf keiner der beiden Listen: sie wurde
> mitgeliefert und überschrieb beim Update die Konfiguration jedes Zielsystems.
Die Muster sind jetzt echte Globs:
| Muster | trifft |
|---|---|
| `*.pdb` | jede `.pdb` in jedem Unterverzeichnis |
| `logs/**` | alles unterhalb von `logs/` |
| `wwwroot/*.css` | nur direkt in `wwwroot/`, nicht darunter |
| `wwwroot/**/*.css` | auch in Unterverzeichnissen |
| `appsettings*.json` | `appsettings.json`, `appsettings.Production.json`, … |
Der Packager warnt zusätzlich von sich aus, wenn eine Datei nach Zugangsdaten
aussieht und auf keiner der beiden Listen steht.
### Versionsgegenprobe
Der Packager liest die Version aus der Hauptassembly und bricht bei einer
Abweichung zu `--version` ab:
```
[FEHLER] Versionskonflikt:
--version sagt : 1.0.2
MyApp.dll sagt : 1.0.1
```
Der Grund dafür ist unangenehm genug, um dafür abzubrechen: Wird `1.0.1` als
`1.0.2` veröffentlicht, aktualisieren alle Clients, melden danach weiterhin
`1.0.1`, halten das Release erneut für neu — und aktualisieren bei jedem Start
wieder. Eine Endlosschleife über die gesamte Installationsbasis.
Üblicher Auslöser: `<Version>` steht nur in einem der beteiligten Projekte. Der
Wert gehört in die `Directory.Build.props`. Notausgang für bewusste
Abweichungen: `--ignore-version-mismatch`. Lässt sich die Assembly nicht
bestimmen, wird nur gewarnt — `--main-assembly` gibt sie gezielt an.
---
## 4. Standalone UpdateAgent (`update-agent`)
Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden.
@@ -157,16 +381,92 @@ update-agent --project myapp --channel prod --action repair
update-agent --project myapp --channel prod --action list
```
Zusätzliche Parameter seit 2.2:
| Parameter | Wirkung |
|---|---|
| `--platform <rid>` | Laufzeitkennung; Vorgabe ist die des laufenden Systems |
| `--current-version <ver>` | Installierte Version, wenn keine `manifest.json` vorliegt |
| `--wait-for-pid <pid>` | Vor dem Anwenden auf das Ende dieses Prozesses warten |
| `--wait-timeout <sek>` | Geduld dabei (Vorgabe 60). Läuft der Prozess danach noch, wird **nichts** verändert |
| `--pubkey <datei>` | Öffentlicher Schlüssel zur Signaturprüfung |
| `--require-signature` | Ohne gültige Signatur nicht installieren |
---
## 4a. Prüf-Endpunkte (für eigene Anbindungen)
## 4A. Welche Version ist installiert?
Es gab zwei Antworten darauf, und sie widersprachen sich: Die Anwendung
verglich `BuildInfo.Version` (einkompiliert), der Agent las `manifest.json` im
Zielverzeichnis. Fehlte diese Datei — etwa bei einer von Hand aufgesetzten
Installation — meldete der Agent „Unbekannt" und hielt **jede** Version für
neuer. Die Reparatur suchte dann auf dem Server nach einer Version namens
„Unbekannt" und brach genau dann ab, wenn man sie braucht.
Die Reihenfolge ist jetzt:
1. `--current-version`, falls übergeben — die Anwendung kennt ihre eigene Version am sichersten
2. `manifest.json` im Zielverzeichnis
3. sonst `0.0.0`, und die Reparatur greift auf `latest` zurück
Deshalb sollte `LaunchUpdateAgent` immer `currentVersion: BuildInfo.Version`
mitgeben.
---
## 4B. Wie ein Update angewendet wird
Die Stelle war als „Atomic Replace with Backup" kommentiert und war
tatsächlich eine Kopierschleife: kein Backup, kein Rollback, kein Aufräumen.
Brach sie in der Mitte ab — gesperrte Datei, volle Platte —, blieb eine halb
aktualisierte Installation zurück, aus der kein Weg zurückführte.
Der Ablauf ist jetzt:
1. **Plan bilden.** Welche Dateien werden geschrieben, welche sind geschützt,
welche gehören nicht mehr zum Release?
2. **Sichern.** Jede Datei, die überschrieben oder entfernt wird, wandert
vorher nach `.dc-update-backup/`.
3. **Anwenden.** Schreiben, dann verwaiste Dateien entfernen.
4. **Bei einem Fehler:** vollständiger Rollback aus dem Backup, danach wird die
Ursache gemeldet. Die Installation bleibt auf dem alten Stand lauffähig.
5. **Bei Erfolg:** Backup löschen, leer gewordene Verzeichnisse entfernen.
### Verwaiste Dateien
Eine DLL, die es im neuen Release nicht mehr gibt, blieb bisher für immer im
Verzeichnis liegen — bei .NET ein realer Weg in kaputte Assembly-Auflösung.
Sie wird jetzt entfernt, aber **nur**, wenn sie in der `manifest.json` der
Vorversion stand. Ohne dieses Wissen wird nichts gelöscht; Dateien, die nicht
aus einem Release stammen, bleiben in jedem Fall unangetastet.
### Der Agent im Paket
`--target-dir` zeigt in der Vorgabe auf das Verzeichnis des Agenten selbst.
Liegt der Agent im Paket, kopierte er sich also unter laufendem Betrieb über
sich selbst — unter Windows eine Zugriffsverletzung mitten im Update.
Eine laufende ausführbare Datei lässt sich unter Windows nicht überschreiben,
aber umbenennen. Der Agent legt sich deshalb als `update-agent.exe.dc-old` zur
Seite, schreibt die neue Fassung und entfernt den Rest beim nächsten Start.
### Ausführungsrechte
Wird unter Windows für `linux-x64` gebaut, kennt das tar-Archiv keine
Unix-Rechte und alles landet als `644` — die Anwendung ließe sich auf dem
Zielsystem nicht starten. Der Agent setzt das Ausführungsbit beim Anwenden für
Dateien ohne Endung (der .NET-Apphost) und für `*.sh`.
---
## 4C. Prüf-Endpunkte (für eigene Anbindungen)
Die Lese-Endpunkte sind bewusst **ohne Token** erreichbar, damit ausgelieferte
Anwendungen ohne Anpassung weiter nach Updates suchen können. Sie liefern nur
Release-Metadaten, die über die Download-URL ohnehin öffentlich sind.
```bash
GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod
GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod&platform=win-x64
```
```json
@@ -175,25 +475,33 @@ GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod
"update_available": true,
"current_version": "1.4.2",
"latest_version": "1.4.3",
"platform": "win-x64",
"is_critical": false,
"latest_release": {
"version": "1.4.3",
"download_url": "https://dc.mhdf.de/releases/myapp/prod/1.4.3/package.tar.gz",
"platform": "win-x64",
"download_url": "https://dc.mhdf.de/releases/myapp/prod/win-x64/1.4.3/package.tar.gz",
"sha256_hash": "e3b0c442...",
"git_commit": "a21536f",
"size_bytes": 8412160,
"release_notes": "Behebt den Login-Fehler.",
"manifest_signature": "hsuQVhef...",
"is_critical": 0
}
}
```
> **Ohne `platform` werden ausschließlich Releases mit `platform=any`
> berücksichtigt.** Wer die Endpunkte selbst anspricht und für mehrere
> Plattformen ausliefert, muss den Parameter mitschicken.
Weitere Endpunkte:
| Aufruf | Zweck |
|---|---|
| `GET .../latest?product=myapp&channel=prod` | Höchstes Release, unabhängig von der Client-Version |
| `GET .../releases?product=myapp` | Alle Releases, nach Versionsordnung sortiert |
| `GET .../latest?product=myapp&channel=prod&platform=win-x64` | Höchstes Release, unabhängig von der Client-Version |
| `GET .../releases?product=myapp` | Alle Releases, nach Versionsordnung sortiert. `platform` filtert hier **exakt** — der Endpunkt listet den Bestand, er wählt kein Paket aus |
| `GET .../pubkey` | Öffentlicher Schlüssel zur Signaturprüfung |
### Versionsvergleich
@@ -232,14 +540,97 @@ v1.4.3", der Packager veröffentlicht v1.4.3, und das Item schließt sich selbst
```text
/var/www/releases/ (oder /public_html/releases/)
└── {ProjectId}/ # z.B. myapp, polytrader
└── {ProjectId}/ # z.B. myapp, polytrader
├── prod/
│ ├── latest.json # Kanal-Übersicht & neueste Version
│ ├── 1.4.0/
│ │ ├── package.tar.gz # Das gezippte Release
│ ├── latest.json # nur platform = any
│ ├── 1.4.0/ # nur platform = any
│ │ ├── package.tar.gz
│ │ ├── package.tar.gz.sha256
│ │ └── manifest.json # Einzeldateien + Hashes
── 1.3.9/
│ │ └── manifest.json # Einzeldateien, Hashes, preserve-Liste
── win-x64/
│ │ ├── latest.json # eigene Historie je Plattform
│ │ └── 1.4.0/
│ │ ├── package.tar.gz
│ │ ├── package.tar.gz.sha256
│ │ └── manifest.json
│ └── linux-x64/
│ ├── latest.json
│ └── 1.4.0/
├── beta/
└── dev/
```
Jede Plattform führt ihre eigene `latest.json`. Der Agent fragt zuerst den
plattformspezifischen Pfad ab und fällt auf den plattformlosen zurück — Pakete
einer fremden Plattform werden dabei verworfen.
### Aufbewahrung
`latest.json` führt die letzten 15 Versionen. Ältere Versionsverzeichnisse
bleiben auf dem Server liegen, sind über den Agenten aber nicht mehr
auswählbar. Der Packager weist beim Herausfallen einer Version ausdrücklich
darauf hin; wer weiter zurück muss, holt das Paket von Hand.
---
## 6. Signatur der Releases
Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer
den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht beide gemeinsam
aus — der Hash schützt dann gegen Übertragungsfehler, nicht gegen Manipulation.
Ausgerechnet auf dem Pfad, der fremden Code ausführt.
### Warum kein HMAC
Beim Lizenzmodul wird mit HMAC signiert, und das geht dort auf, weil der
**Server** prüft. Ein Update wird auf dem Zielsystem geprüft. Ein HMAC bräuchte
dort denselben geheimen Schlüssel wie auf dem Server; wer ihn ausliest, kann
beliebige Pakete signieren — die Signatur verlöre genau die Eigenschaft, wegen
der es sie gibt.
Deshalb asymmetrisch: der Server signiert mit einem privaten RSA-Schlüssel, der
Agent prüft mit dem öffentlichen.
### Einrichten
```bash
openssl genrsa -out /etc/dc/release-signing.pem 2048
chmod 600 /etc/dc/release-signing.pem
```
```php
// config/config.php
'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', '/etc/dc/release-signing.pem'),
```
Signiert wird beim Veröffentlichen, serverseitig. **Der Packager bekommt den
Schlüssel nicht zu sehen** — er läuft auf Entwicklerrechnern, und der Schlüssel
wäre so gut geschützt wie das schwächste dieser Systeme.
Signiert wird eine kanonische Zeile, nicht das Manifest-JSON: JSON-Ausgabe ist
nicht bytestabil (Schlüsselreihenfolge, Escaping, Zahlenformat), eine Signatur
darüber wäre unzuverlässig prüfbar.
```
dc-release-v1\n{product_slug}\n{version}\n{channel}\n{platform}\n
{sha256_hash klein}\n{download_url}\n{size_bytes}
```
### Prüfen
Der Agent holt den öffentlichen Schlüssel einmalig von
`/api/updateservice/v1/pubkey` und legt ihn als `dc-release-pubkey.pem` neben
sich ab. Meldet der Server später einen **anderen** Schlüssel, wird gewarnt und
weiterhin der hinterlegte benutzt — ein untergeschobener Server fällt damit
auf. War der Wechsel beabsichtigt, die Datei löschen.
| Lage | Verhalten |
|---|---|
| Signatur gültig | Installation läuft |
| Signatur ungültig | **Abbruch**, immer |
| Release unsigniert | Hinweis, Installation läuft |
| Kein öffentlicher Schlüssel | Hinweis, Installation läuft |
| `--require-signature` gesetzt | Die letzten beiden Fälle brechen ebenfalls ab |
Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die
Vertrauenskette, und darauf wird bei jedem Update hingewiesen.
+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.
+102 -3
View File
@@ -3,7 +3,7 @@
Überwachung von Anwendungen, Diensten und Servern über Heartbeats.
> **Stand:** Version 2.0 — vollständig überarbeitet. Wer eine ältere Integration
> betreibt, findet die Änderungen in Abschnitt 8.
> betreibt, findet die Änderungen in Abschnitt 9.
---
@@ -26,10 +26,11 @@ ausgefallen.
> Dienst bliebe dauerhaft grün.
>
> ```bash
> * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
> * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
> ```
>
> Fehlt der Job, zeigt das WebUI oben einen Warnhinweis.
> Fehlt der Job, zeigt das WebUI oben einen Warnhinweis. Details und die
> HTTP-Variante: **[§11](#11-der-evaluator-tick)**.
---
@@ -72,6 +73,7 @@ Content-Type: application/json
"message": "Verarbeite Warteschlange",
"group": "Applications",
"os": ".NET 8 Service",
"version": "1.4.3",
"checks": { "db": { "ok": true },
"market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" } },
@@ -89,9 +91,31 @@ Content-Type: application/json
| `message` | nein | Kurztext, erscheint im Dashboard |
| `group` | nein | Gruppierung im Dashboard |
| `os` | nein | Plattform, steuert auch die Icon-Erkennung |
| `version` | nein | Version der laufenden Anwendung (Alias: `app_version`) |
| `checks` | nein | Selbst ermittelter Gesundheitszustand, siehe 4. |
| `metrics` | nein | Numerische Werte, siehe 5. |
### Welche Version läuft dort?
`version` erscheint als eigene Spalte in der Monitorliste und kommt im
Heartbeat-Antwortobjekt als `app_version` zurück. Damit ist auf einen Blick
sichtbar, ob ein Ausfall zeitlich zu einem Rollout passt — „Monitor X ist seit
dem Rollout von 1.4.3 unten".
Das Feld ist optional und **überschreibt einen früheren Wert nicht mit `null`**:
ein Agent, der es nicht mitschickt, löscht die zuletzt gemeldete Version nicht.
Bestehende Agenten laufen also unverändert weiter.
```csharp
// Am einfachsten aus der vom Build erzeugten Klasse, siehe
// UPDATESERVICE_INTEGRATION_GUIDE §2B
version = BuildInfo.Version
```
> Der Fehler-Stream führt `build`, der Bugtracker `build_version`, die
> Aktivierungsliste des Lizenzmoduls `app_version` — nur der Watchdog konnte
> bis 2.1 nicht sagen, welche Version tatsächlich läuft.
### Zulässige Werte für `status`
| Wert | Zustand | Wirkung |
@@ -217,6 +241,7 @@ public sealed class WatchdogReporter
interval = 60,
message,
os = Environment.OSVersion.ToString(),
version = BuildInfo.Version, // erscheint in der Monitorliste
checks,
metrics
};
@@ -372,6 +397,8 @@ Zulässige `kind`-Werte: `started`, `stopped_graceful`, `crash_suspected`,
| Geplantes Beenden | nicht möglich | `status: "stopped"` bzw. `"maintenance"` |
| Alarme bei Ausfall eines Hosts | eine Meldung je Kind | Kinder werden unterdrückt |
| Antwortformat | uneinheitlich | `{"status":"success",…}` bzw. `{"status":"error","error":{"code":…}}` |
| Laufende Version | nicht übermittelbar | Feld `version` (seit 2.1) |
| OpenAPI-Beschreibung | `/event`, `/events`, `/status` fehlten, ebenso `group` und `type` am Ping | vollständig |
Bestehende Agenten mit `wd_live_`-Token und einfachem `ok`-Ping laufen
unverändert weiter — die neuen Felder sind alle optional.
@@ -389,3 +416,75 @@ unverändert weiter — die neuen Felder sind alle optional.
4. **`checks` nutzen.** Ein Heartbeat sagt nur, dass ein Thread läuft.
5. **Hierarchie pflegen**, wenn Dienste auf gemeinsamen Hosts laufen — sonst
bringt ein Hostausfall eine Alarmlawine.
6. **`version` mitschicken.** Ohne sie lässt sich ein Ausfall nicht mit einem
Rollout in Verbindung bringen.
---
## 11. Der Evaluator-Tick
Der Evaluator ist die Komponente, die Monitore anhand ihres erwarteten
Intervalls herabstuft. Er läuft **nicht** von selbst — ohne einen Cron-Eintrag
sind die Zustände im Dashboard wertlos.
Er erledigt in einem Durchlauf:
- Monitore anhand `expected_interval_sec` auf `warning` bzw. `down` stufen
- Zustandswechsel im Ereignisprotokoll festhalten und Webhooks auslösen
- Alarme für Kinder eines ausgefallenen Hosts unterdrücken
- abgelaufene Bugtracker-Leases freigeben
- stündlich den Metrik-Verlauf abräumen
### Interner Aufruf (empfohlen)
```bash
* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
```
Entspricht dem früheren `watchdog/cli/tick.php`. Kein Schlüssel im Crontab,
keine Abhängigkeit von Webserver, TLS oder DNS — und damit auch dann
lauffähig, wenn der Webserver gerade das Problem ist.
| Option | Wirkung |
|---|---|
| `-q`, `--quiet` | Ausgabe nur bei Zustandswechseln und Fehlern (für Cron) |
| `-j`, `--json` | Ergebnis maschinenlesbar |
| `--no-lock` | Sperre gegen überlappende Läufe übergehen (nur zur Fehlersuche) |
| `-h`, `--help` | Hilfe |
| Rückgabewert | Bedeutung |
|---|---|
| `0` | Lauf erfolgreich |
| `1` | Fehler — Meldung auf `stderr`, Einzelheiten im Log unter `var/log/` |
| `2` | Übersprungen, es lief bereits ein Tick |
Das Skript sperrt sich über `var/watchdog-tick.lock` selbst. Braucht ein Lauf
länger als eine Minute, überspringt der nächste Cron-Aufruf — sonst würden
zwei Evaluatoren dieselben Zustandswechsel doppelt melden.
> Der Aufruf ist auf `PHP_SAPI === 'cli'` beschränkt und `cli/` ist zusätzlich
> per `.htaccess` gesperrt. Über den Webserver ist das Skript also nicht
> erreichbar — sonst ließe sich ein Evaluationslauf ohne jede Authentifizierung
> auslösen, während der HTTP-Endpunkt dafür bewusst den Shared Key verlangt.
### Über die Schnittstelle
Sinnvoll, wenn der Cron auf einer anderen Maschine läuft:
```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** — zwei parallele
Zeitpläne bringen keinen Gewinn, nur die Gefahr überlappender Läufe.
### Prüfen, ob er läuft
```bash
php cli/tick.php --json
curl https://dc.mhdf.de/api/health -H "Authorization: Bearer <SHARED_KEY>"
```
`/api/health` meldet unter `checks.evaluator` den letzten Lauf. Im WebUI zeigt
das Watchdog-Modul denselben Zustand als Abzeichen **AKTIV** / **INAKTIV**.