Fuenf von sechs Befunden einer externen Integration. Der sechste - unsignierte Lizenzurteile - ist bestaetigt, aber bewusst nicht Teil dieses Commits. 1. Lizenzschluessel stand in der Kommandozeile Der Agent nahm --license-key nur als Argument und las keine Umgebungsvariablen. "ps" zeigt argv jedem Benutzer der Maschine - exakt die Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Damit nahm das SDK einen bereits geloesten Sicherheitsbefund zurueck. Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und DC_DOWNLOAD_PASSWORD, Umgebung vor Argument. LaunchUpdateAgent uebergibt den Schluessel nicht mehr als Argument, sondern setzt die Variable auf dem eigenen Prozess: das Kind erbt den Umgebungsblock, danach wird sie wieder entfernt. Das funktioniert auch mit UseShellExecute=true, wo sich ProcessStartInfo.Environment nicht setzen laesst. 2. --wait-timeout war nicht durchgereicht Der Agent kannte den Parameter, LaunchUpdateAgent hatte keinen dafuer - es galten fest 60 Sekunden. Eine Anwendung, die allein fuer host.StopAsync 30 Sekunden braucht, kommt damit gefaehrlich nah an die Grenze. Neu: waitTimeoutSeconds. Ausserdem ist im Quelltext und in der Doku jetzt festgehalten, dass exitCurrentApp:true ueber Environment.Exit(0) laeuft und damit finally-Bloecke und IHostApplicationLifetime uebergeht - bei offenem Zustand die falsche Wahl. 3. ILicensePrompt war tot Der Konstruktor nahm es entgegen, legte es in _prompt ab und benutzte es nirgends. Wer darauf eine headless-Story aufbaute, baute auf Sand. Neu: EnsureLicensedAsync() - zwischengespeicherten Schluessel nehmen, sonst fragen, pruefen, bei Ablehnung erneut fragen. allowPrompt:false lehnt ohne Cache ab, statt auf eine Eingabe zu warten, die im Dienst nie kommt. Ein voruebergehender Netzfehler fuehrt nicht zur erneuten Abfrage - der Schluessel ist ja nicht falsch. 4. Der Packager warnte nur Er bricht jetzt ab. Anlass war ein echter API-Schluessel in einem oeffentlich abrufbaren Paket - und die Warnung war damals ausgerechnet unterdrueckt, weil die Datei auf der preserve-Liste stand. Zwei Stufen: Dateiname (appsettings.Local.json, master.key, *.pfx, *.db, server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_, dc_master_, AKIA, private Schluessel). Die Inhaltspruefung findet auch Dateien mit unverdaechtigem Namen. Platzhalter loesen bewusst nicht aus: "sk-DEIN-SCHLUESSEL-HIER" haette sonst jede ausgelieferte Vorlage blockiert, und --allow-secrets waere nach einer Woche Gewohnheit. Beim Erproben zuerst genau in diese Falle gelaufen. 5. BuildInfo.targets war nur per Pfad-Import zu haben Die Anleitung empfahl einen <Import> ins Nachbar-Repository - das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben Fassung stehen. Das Client-Projekt ist jetzt packbar und legt das Target unter build/ ins Paket, wo NuGet es selbst importiert. Ausserdem: Unauthorized wurde nur im statischen Zweig erkannt, im API-Zweig kam ein 401 als gewoehnlicher HTTP-Fehler an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
38 KiB
Deploymentcenter — UpdateService Integration & Deployment Guide
⚠️ Geändert in Version 2.0 — Das Veröffentlichen eines Releases läuft jetzt über
POST /api/updateservice/v1/publishund verlangt ein Token mit dem Scopeupdateservice:publish(zuvor völlig ungeschützt). Der Versionsvergleich folgt jetzt der semantischen Versionsordnung,1.10.0gilt also korrekt als neuer als1.9.0. Umstellungsschritte: UPGRADE.md.
⚠️ Geändert in Version 2.1
Deploymentcenter.BuildInfo.targetserzeugt die Klasse jetzt im Namensraum des einbindenden Projekts. Die vorherige Fassung war nicht einbindbar (CS0433), siehe §2B.- Der API-Rückfall in
CheckForUpdateAsyncliest die Antwort jetzt korrekt. Fehlte dielatest.json, kamen zuvor weder Download-Adresse noch Prüfsumme, Changelog oder Kritikalität an, siehe §2C.
⚠️ Geändert in Version 2.2 — bitte vollständig lesen, bevor das nächste Release gebaut wird. Umstellungsschritte: UPGRADE.md §15.
- Releases tragen eine Plattform. Ohne sie überschrieben sich
win-x64undlinux-x64gegenseitig, siehe §1A.- Konfigurationsdateien überleben ein Update. Bisher überschrieb jedes Update die eingerichteten Werte des Zielsystems, siehe §3A.
- Anwenden mit Rollback. Ein Abbruch hinterlässt keine halbe Installation mehr, und entfernte Dateien werden aufgeräumt, siehe §4B.
- Releases sind signiert, siehe §6.
LaunchUpdateAgentübergibt jetzt Neustart, Prozesskennung und Plattform, siehe §2A.
Das UpdateService-Modul des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema: statisch ausgelieferte Pakete plus eine PHP-API.
⚠️ Der Webserver ist nicht beliebig. Ältere Fassungen dieser Anleitung beschrieben den Stack durchgehend als „LEMP (Nginx + PHP)". Der Zugangsschutz aus §5A beruht auf
.htaccessund wird von Nginx vollständig ignoriert — dort wären die Release-Verzeichnisse offen und die.htpasswdsogar öffentlich abrufbar, während die Oberfläche „geschützt" meldete.
dc.mhdf.deläuft auf Apache mit aktivemAllowOverride, dort trägt es. Wer auf Nginx ausrollt, muss den Schutz in der Serverkonfiguration nachbilden — die Vorlage steht in §5A. Verlass dich nicht auf die Anzeige, sondern auf den Selbsttest: er ruft die eigene Paket-Adresse ohne Zugangsdaten ab und erwartet 401.
1. Übersicht & Architektur
- 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-x64usw. - Statische Verteilung: Downloads und Versionen-Manifeste (
latest.json,manifest.json,package.tar.gz) liefert der Webserver direkt aus, ohne PHP im Weg. - Zugangsschutz über den Lizenzschlüssel — setzt Apache voraus, siehe Kasten oben.
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:
/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
Binde das Projekt oder Paket Deploymentcenter.Client in deine Anwendung ein.
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
// Early Start Hook in Program.cs (oder App.xaml.cs)
var updateClient = new UpdateClient();
var checkResult = await updateClient.CheckForUpdateAsync(
baseUrl: "https://dc.mhdf.de",
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!");
var agentPath = UpdateClient.ResolveAgentPath();
if (agentPath == null)
{
Console.WriteLine("[UPDATE] Kein update-agent gefunden.");
return;
}
UpdateClient.LaunchUpdateAgent(
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.
B. MSBuild BuildInfo Generierung
Version, UTC-Build-Datum und Git-Commit entstehen automatisch zur Übersetzungszeit. Seit Version 2.5 genügt dafür der Paketverweis:
<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Deploymentcenter.Client" Version="2.5.0" />
</ItemGroup>
Das Targets-File liegt im Paket unter build/ und wird von NuGet selbst
importiert — ein <Import> von Hand entfällt.
Der bisherige Weg koppelte zwei Repositories. Die Anleitung empfahl
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />— das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben Fassung stehen. Wer nur das Paket bezieht, hatte das Target nicht. Der Import per Pfad funktioniert weiterhin, ist aber nicht mehr nötig.
Die Klasse entsteht im Namensraum deines Projekts ($(RootNamespace)), nicht im
SDK. Du erreichst sie also ohne using:
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.Modelszeigen lassen. Dort liefert das SDK bereits eine gleichnamige Klasse aus;partialverbindet 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 nurversionüberein. Über den API-Weg kam deshalb nichts weiter an — undIsCriticalwurde aus dem Release-Objekt statt vom Wurzelfeld gelesen und war damit immerfalse. Da dieser Zweig genau der Rückfall ist, wenn dielatest.jsonfehlt, degradierte die Update-Prüfung still.
3. Packaging & Deployment CLI (pack-and-deploy)
Woher das Werkzeug kommt. Frühere Fassungen dieser Anleitung benutzten
pack-and-deploy, als läge es im PATH — beziehbar war es nirgends. Es steht jetzt unter/installer/bereit:wget https://dc.mhdf.de/installer/pack-and-deploy-linux-x64 -O pack-and-deploy chmod +x pack-and-deployWer nicht von Hand aufrufen will, nimmt die Release-Vorlage: ein Skript zum Kopieren ins eigene Projekt, das je Plattform
dotnet publishundpack-and-deployverkettet und sich das Werkzeug selbst holt. Siehe Release-Anleitung für Agenten.
Das Packaging-Tool verpackt den dotnet publish-Output, berechnet Hashes, erzeugt das manifest.json und lädt alles per FTP auf den LEMP-Server.
Aufruf-Beispiel:
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
.gitignorevon der Versionskontrolle ausgeschlossen. Vorlage:packager.config.example.json. In der vorherigen Fassung standen die echten FTP-Zugangsdaten sowohl hier in der Anleitung als auch als Standardwerte im Quelltext vonProgram.cs.
{
"ftpHost": "ftp.example.com",
"ftpPort": 21,
"ftpUser": "ftp-user",
"ftpPass": "ftp-password",
"ftpRemoteBaseDir": "/public_html/releases",
"apiBaseUrl": "https://dc.mhdf.de",
"apiToken": "dc_sub_...",
"excludePatterns": [
"*.pdb",
"*.xml",
"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. Ohne Registrierung entsteht auch keine
Signatur.
Alternative: Umgebungsvariablen
Für CI-Läufe, in denen keine Datei abgelegt werden soll — sie haben Vorrang vor der Konfigurationsdatei:
export DC_FTP_HOST=ftp.example.com
export DC_FTP_USER=ftp-user
export DC_FTP_PASS='...'
export DC_TOKEN='dc_sub_...'
pack-and-deploy --project myapp --version 1.4.0 --channel prod \
--platform linux-x64 \
--publish-dir ./bin/Release/net8.0/linux-x64/publish
Rückgabewerte
| Wert | Bedeutung |
|---|---|
0 |
Paket gebaut, hochgeladen und im Deploymentcenter registriert |
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
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:
IsExcludedverstand ausschließlich*.endungund exakte Namen. Die mitgelieferte Beispielkonfiguration enthieltlogs/**undscratch/**— beides traf nie zu. Und einesettings.jsonmit 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 bricht bei Zugangsdaten ab
Seit Version 2.5 nicht mehr nur eine Warnung. Anlass war ein echter
API-Schlüssel, der so in ein öffentlich abrufbares Paket geriet — und die
Warnung wurde damals ausgerechnet unterdrückt, weil die Datei auf der
preserve-Liste stand.
| Stufe | Findet |
|---|---|
| Dateiname | appsettings.Local.json, secrets.json, master.key, *.pfx, *.db, server_settings.xml, … |
| Inhalt | gefüllte Password=-Felder, sk-…, ghp_…, dc_master_…, AKIA…, private Schlüssel |
Die Inhaltsprüfung findet auch Dateien mit unverdächtigem Namen. Platzhalter
wie sk-DEIN-SCHLUESSEL-HIER oder Password=changeme lösen nicht aus —
sonst würde jede ausgelieferte Vorlage den Build blockieren, und
--allow-secrets wäre nach einer Woche Gewohnheit.
Der Zugangsschutz macht das nicht harmlos. Ein Schlüssel im Paket ist dann für jeden Lizenznehmer lesbar statt für alle — besser, aber immer noch falsch.
Notausgang für bewusste Fälle: --allow-secrets.
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.
CLI Modus Befehle:
# Nach Updates suchen
update-agent --project myapp --channel prod --action check
# Auf neueste Version aktualisieren
update-agent --project myapp --channel prod --action update --version latest --target-dir /opt/myapp
# Rollback auf ältere Version
update-agent --project myapp --channel prod --action update --version 1.3.2
# Integritäts-Reparatur der aktuellen Installation
update-agent --project myapp --channel prod --action repair
# Alle verfügbaren Versionen auflisten
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. 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:
--current-version, falls übergeben — die Anwendung kennt ihre eigene Version am sicherstenmanifest.jsonim Zielverzeichnis- sonst
0.0.0, und die Reparatur greift auflatestzurü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:
- Plan bilden. Welche Dateien werden geschrieben, welche sind geschützt, welche gehören nicht mehr zum Release?
- Sichern. Jede Datei, die überschrieben oder entfernt wird, wandert
vorher nach
.dc-update-backup/. - Anwenden. Schreiben, dann verwaiste Dateien entfernen.
- Bei einem Fehler: vollständiger Rollback aus dem Backup, danach wird die Ursache gemeldet. Die Installation bleibt auf dem alten Stand lauffähig.
- 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.
GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod&platform=win-x64
{
"status": "success",
"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",
"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
platformwerden ausschließlich Releases mitplatform=anyberü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&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
Der Vergleich folgt der semantischen Versionsordnung. Konkret bedeutet das:
| Installiert | Verfügbar | Update? |
|---|---|---|
1.9.0 |
1.10.0 |
ja — zweistellige Minor ist höher |
1.10.0 |
1.9.0 |
nein |
1.0.0-rc.1 |
1.0.0 |
ja — Release schlägt Vorabversion |
1.0.0 |
1.0.0-rc.1 |
nein |
v1.4.2 |
v1.4.3 |
ja — führendes v wird ignoriert |
Zuvor verglich der Server lexikografisch.
1.9.0galt dadurch als neuer als1.10.0, und Clients bekamen ein Downgrade als Update angeboten. Derselbe Fehler steckte im .NET-Client beiv-präfigierten Versionen und ist dort ebenfalls behoben.
Verknüpfung mit dem Bugtracker
Beim Veröffentlichen schließen sich alle Bugtracker-Items, deren
resolved_in_build der veröffentlichten Version entspricht, automatisch. Die
Antwort nennt die Anzahl:
{ "status": "success", "release_id": 12, "created": true, "auto_resolved": 3,
"message": "Release 1.4.3 (prod) für \"myapp\" veröffentlicht. 3 Bugtracker-Item(s) automatisch geschlossen." }
Damit schließt sich der Kreis: Ein Agent markiert einen Bug als „gelöst in v1.4.3", der Packager veröffentlicht v1.4.3, und das Item schließt sich selbst.
5. LEMP Verzeichnisstruktur auf dem Server
/var/www/releases/ (oder /public_html/releases/)
└── {ProjectId}/ # z.B. myapp, polytrader
├── prod/
│ ├── latest.json # nur platform = any
│ ├── 1.4.0/ # nur platform = any
│ │ ├── package.tar.gz
│ │ ├── package.tar.gz.sha256
│ │ └── 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.
5A. Zugangsschutz der Release-Verzeichnisse
Neu in Version 2.4. Zuvor war
/releases/offen — damit jede ausgelieferte Anwendung ohne Zugangsdaten nach Updates suchen konnte. Das bedeutete aber auch: jeder im Internet konnte die vollständigen Pakete herunterladen, mitsamt allem, was versehentlich darin lag.
Der Zugang hängt jetzt am Lizenzschlüssel. Wer eine gültige Lizenz für ein Produkt hat, kommt an dessen Updates; wer keine hat, braucht auch keine. Die Anwendung kennt ihren Schlüssel ohnehin und versorgt sich damit selbst — es muss nichts zusätzlich verteilt werden.
Wie es aufgebaut ist
Je Produktverzeichnis eine eigene .htpasswd:
/releases/predictalytics/.htaccess ← AuthType Basic, Require valid-user
/releases/predictalytics/.htpasswd ← nur Schlüssel für predictalytics
/releases/warenwirtschaft/.htpasswd ← nur Schlüssel für warenwirtschaft
Bewusst getrennt. Eine gemeinsame Datei würde bedeuten, dass eine Lizenz für
Produkt A auch Produkt B öffnet. Da license_licenses.product_id jeden
Schlüssel ohnehin an genau ein Projekt bindet, fällt die Trennung beim Erzeugen
von selbst ab.
Darin stehen:
| Eintrag | Benutzername | Passwort |
|---|---|---|
| Gültige Lizenz | lic_ + erste 16 Hexzeichen von SHA-256(Schlüssel) |
der Schlüssel |
| Installationskonto | DC-Benutzername | dessen Passwort |
Der Benutzername ist eine Ableitung, nicht der Schlüssel selbst. Das htpasswd-Format hasht nur die Passwortspalte. Stünde der Lizenzschlüssel auch als Benutzername darin, wäre die Datei eine vollständige Klartext-Kundenliste und der bcrypt-Hash daneben bloße Dekoration — ein einziger Konfigurationsfehler vom Leak entfernt. So enthält sie nur eine Einwegableitung und einen Hash über einen hochentropen Schlüssel; selbst offengelegt ist damit nichts anzufangen.
Die Ableitung muss auf beiden Seiten zeichengenau übereinstimmen:
ReleaseGuard::licenseUsername() serverseitig,
ReleaseCredentials.UsernameForLicenseKey() im SDK. Weichen sie voneinander
ab, kommt niemand mehr an seine Updates.
Die Installationskonten stehen in jeder Datei — bei einer Erstinstallation
gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr
Hash wird unverändert aus dc_users übernommen: PHPs password_hash() erzeugt
bcrypt im Format $2y$, und genau das versteht Apache. Ein Klartextpasswort
wird nirgends gebraucht.
Was sich für Clients ändert
Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.
var checkResult = await updateClient.CheckForUpdateAsync(
baseUrl: "https://dc.mhdf.de",
projectId: "myapp",
currentVersion: BuildInfo.Version,
channel: "prod",
credentials: ReleaseCredentials.FromLicenseKey(meineLizenz));
if (checkResult.Unauthorized)
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr.
Console.WriteLine(checkResult.Message);
}
UpdateClient.LaunchUpdateAgent(
agentPath: UpdateClient.ResolveAgentPath()!,
projectId: "myapp",
licenseKey: meineLizenz); // reicht --license-key an den Agenten durch
Auf der Kommandozeile — bevorzugt über die Umgebung:
DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX \
update-agent --project myapp --action update
--license-key gibt es weiterhin, ist aber die schlechtere Wahl: Was in argv
steht, zeigt ps jedem Benutzer der Maschine. Aus genau diesem Grund verwirft
UPGRADE.md §5 den
Weg, den Shared Key im Crontab zu hinterlegen — für einen Lizenzschlüssel gilt
dasselbe. Umgebungsvariablen haben Vorrang vor den Argumenten.
LaunchUpdateAgent übergibt den Schlüssel deshalb nicht als Argument: Es
setzt DC_LICENSE_KEY auf dem eigenen Prozess, der Agent erbt den
Umgebungsblock beim Start, und danach wird die Variable wieder entfernt.
Ersatzweise DC_DOWNLOAD_USER und DC_DOWNLOAD_PASSWORD (oder
--download-user / --download-password) für ein Installationskonto — das
nutzt der Installer bei der Erstinstallation.
Wartezeit beim Übergeben
LaunchUpdateAgent gibt dem Agenten mit, wie lange er auf das Ende der
Anwendung warten soll. Vorgabe sind 60 Sekunden:
UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: "myapp",
licenseKey: meineLizenz,
waitTimeoutSeconds: 180, // reicht auch für ein langsames StopAsync
exitCurrentApp: false); // siehe unten
Läuft die Zeit ab, bricht der Agent ab, ohne etwas zu verändern — sicher,
aber eben auch kein Update. Wer allein für host.StopAsync 30 Sekunden
braucht, sollte den Wert heraufsetzen.
exitCurrentApp: truebeendet den Prozess mitEnvironment.Exit(0). Laufendefinally-Blöcke anderer Threads,IHostApplicationLifetimeund Destruktoren kommen dann nicht mehr zum Zug. Für eine Anwendung mit offenem Zustand — Positionen, Transaktionen, ungeschriebene Puffer — ist das die falsche Wahl. DortexitCurrentApp: falsesetzen und anschließend den eigenen geordneten Weg gehen; der Agent wartet ohnehin auf das Prozessende.
UpdateCheckResult.Unauthorized ist bewusst von einem gewöhnlichen Fehler
getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine
Netzwerkstörung, und man sucht an der falschen Stelle.
Wann neu erzeugt wird
Automatisch beim Anlegen, Bearbeiten, Widerrufen und Sperren einer Lizenz, beim Löschen eines Projekts und bei jeder Änderung an einem Installationskonto.
Abgelaufene Lizenzen lösen von sich aus nichts aus — der Zeitpunkt vergeht
einfach. Dafür gleicht cli/tick.php nach: Es prüft, ob seit dem letzten Lauf
etwas abgelaufen ist, und erzeugt spätestens alle sechs Stunden ohnehin neu.
Ein minütlicher Aufruf ist unbedenklich, im Normalfall passiert nichts.
Von Hand: WebUI → UpdateService → 🔒 Zugangsschutz. Dort steht auch, welche Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind.
| Anzeige | Bedeutung |
|---|---|
| GESCHÜTZT | die Dateien liegen vor — das allein beweist nichts |
| OFFEN | keine .htaccess — jeder im Internet kann laden |
| GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten |
Der Selbsttest ist die einzige belastbare Aussage
Dass .htaccess und .htpasswd existieren, sagt nichts darüber, ob sie
ausgewertet werden. Unter Nginx werden sie ignoriert, bei abgeschaltetem
AllowOverride ebenso, und ein Tippfehler in der Datei führt zu 500 statt 401.
In allen drei Fällen stünde in der Übersicht „GESCHÜTZT", während die Pakete
offen im Netz lägen.
Der Selbsttest ruft deshalb die eigene Paket-Adresse ohne Zugangsdaten ab
und erwartet 401. Er läuft bei jedem manuellen Erzeugen mit und nach jeder
automatischen Neuerzeugung durch cli/tick.php; das Ergebnis steht in der
Oberfläche und bei Fehlschlag im Log.
Von Hand nachprüfen:
curl -I https://dc.mhdf.de/releases/<produkt>/prod/<rid>/<version>/package.tar.gz # 401
curl -I https://dc.mhdf.de/releases/<produkt>/.htpasswd # 403
Nginx statt Apache
Dort greift .htaccess nicht. Der Schutz muss in die Serverkonfiguration:
location ^~ /releases/ {
# Je Produkt eine eigene Datei - sonst öffnet eine Lizenz für A auch B.
# $1 ist der Produkt-Slug aus dem Pfad.
location ~ ^/releases/([^/]+)/ {
auth_basic "Deploymentcenter Releases";
auth_basic_user_file /pfad/zum/webroot/releases/$1/.htpasswd;
}
# Die Zugangsdateien selbst nie ausliefern.
location ~ /\.ht { deny all; }
}
ReleaseGuard erzeugt die .htpasswd-Dateien unverändert weiter — nur die
.htaccess bleibt dort wirkungslos. Der Selbsttest bestätigt anschließend,
dass es trägt.
Grenzen
Das macht Pakete nicht sicher. Jeder lizenzierte Kunde kann sie weiterhin
auspacken. Ein eigener API-Schlüssel in einer mitgelieferten appsettings.json
wäre danach statt öffentlich eben für alle Kunden sichtbar — besser, aber immer
noch falsch. Was nicht ins Paket gehört, gehört auch mit Zugangsschutz nicht
hinein; siehe §3A.
Die Lese-Endpunkte der API bleiben offen. Sie verraten weiterhin, welche Produkte und Versionen es gibt — die Download-Adressen sind ohne Zugangsdaten aber wertlos.
Abschalten
Die erzeugten .htaccess-Dateien in den Produktverzeichnissen löschen. Beim
nächsten Auslöser entstehen sie neu; wer es dauerhaft will, muss den Aufruf von
ReleaseGuard entfernen.
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
openssl genrsa -out /etc/dc/release-signing.pem 2048
chmod 600 /etc/dc/release-signing.pem
// 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.
7. Release-Verwaltung (Bearbeiten & Löschen)
Veröffentlichte Releases können sowohl über das Web-Interface als auch über die API nachträglich bearbeitet oder gelöscht werden.
Web-Interface (public/index.php)
- Im Tab UpdateService → 📦 Veröffentlichte Software Releases enthält jede Zeile die Aktionen
✏️ Bearbeitenund🗑️ Löschen. - Bearbeiten: Öffnet einen Modal-Dialog, in dem Release-Notes, Download-URL, SHA256-Hash, Git Commit, Plattform, Kanal, Version und IsCritical angepasst werden können. Beim Speichern wird die kanonische Signatur serverseitig neu berechnet.
- Löschen: Entfernt nach Bestätigung den Release-Datensatz aus der Datenbank.
API-Endpunkte
Release Bearbeiten
- POST
/api/updateservice/v1/update(oderaction=update/action=edit) - Scope:
updateservice:publish - Parameter:
release_id(oderid), optional updatedproduct_slug,version,channel,platform,download_url,sha256_hash,git_commit,release_notes,size_bytes,is_critical. - Antwort (200 OK):
{ "status": "success", "release_id": 42, "updated": true, "platform": "win-x64", "signed": true, "message": "Release 1.2.0 (prod, win-x64) fuer \"myapp\" erfolgreich aktualisiert." }
Release Löschen
- POST / DELETE
/api/updateservice/v1/delete(oderaction=delete) - Scope:
updateservice:publish - Parameter:
release_id(oderid) ODER die Kombination ausproduct_slug,version,channel,platform. - Antwort (200 OK):
{ "status": "success", "release_id": 42, "deleted": true, "message": "Release v1.2.0 (prod, win-x64) fuer \"myapp\" geloescht." }