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
+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.