Compare commits

...
10 Commits
Author SHA1 Message Date
Deploymentcenter BotandClaude Opus 5 9d0261306c chore(cleanup): Altlasten entfernt, Webroot-Sperre und Supervisor-Route ergaenzt
Fruehjahrsputz. Alles hier ist unabhaengig von der laufenden Supervisor-
Arbeit im Arbeitsverzeichnis - die bleibt unangetastet.

Entfernt:
- Die zwoelf Symbole unter public/assets/ waren bitgleiche Dubletten der
  Dateien in public/assets/icons/. Referenziert wurde ausschliesslich der
  Pfad mit icons/. getMonitorIconUrl() setzt flach gespeicherte Altwerte
  aus der Datenbank jetzt auf icons/ um, damit bestehende Monitore ihr
  Symbol behalten.
- System.CommandLine (2.0.0-beta4) aus dem Packager. Kein using, kein
  Symbol, die Argumente werden von Hand geparst. Build bleibt fehlerfrei.

Geschlossen:
- client-php/ und mcp/ wurden weder von der .htaccess gesperrt noch vom
  Deployment ausgeschlossen. Die .mjs-Datei haette der Webserver im
  Klartext ausgeliefert. Beide gehoeren nicht ins Webroot: die PHP-Datei
  wird ins Zielprojekt kopiert, der MCP-Server laeuft lokal ueber stdio.
  Jetzt in beiden Ausschlusslisten - wie client-dotnet.
- Fuer /api/supervisor/v1/ fehlte die Rewrite-Regel. Der Datei-Fallback
  haette /api/supervisor/v1/findings auf eine nicht vorhandene Datei
  geleitet, obwohl Code und OpenAPI diese Schreibweise vorsehen.

Ergaenzt:
- __pycache__/ und *.pyc in .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:20:33 +02:00
Deploymentcenter BotandClaude Opus 5 687ee0cefc feat(installer): Laufzeitpruefung, Host-Ueberwachung, Zugang fuer Adminkonten
Drei Dinge, die beim ersten Lauf des Installers auf einer Linux-Maschine
auffielen.

1. Die Release-Ablage wies Administratorkonten ab. ReleaseGuard nahm nur
   die Rolle 'installer' in die .htpasswd auf, waehrend Installskripte und
   Agent ausdruecklich sagten, ein Administratorkonto tue es auch:
   Anmeldung und Katalog gelangen, erst der Download endete mit 401 - und
   die Meldung sprach von abgelaufenen Lizenzen, die es bei einer
   Erstinstallation gar nicht geben kann. Adminkonten zaehlen jetzt zu den
   Installationskonten. FORMAT_VERSION auf 3, damit reconcile() die
   Dateien sofort neu schreibt statt erst beim naechsten turnusmaessigen
   Lauf; ein neu angelegtes Konto landet ausserdem unabhaengig von seiner
   Rolle sofort darin. Bei einem 401 mit Benutzerzugangsdaten nennt der
   Client jetzt Konto und zugangsberechtigte Rollen, und der Agent bricht
   ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar
   Schritte spaeter ein zweites Mal zu zeigen.

2. Der Installer prueft die .NET-Laufzeit. Bisher endete eine gelungene
   Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich
   nicht starten laesst - und die Fehlersuche begann beim
   Deploymentcenter, weil das der letzte bewusste Schritt war. Gelesen
   wird die runtimeconfig.json der Anwendung und mit "dotnet
   --list-runtimes" verglichen; fehlt etwas, nennt der Installer den
   Installationsbefehl fuer diese Plattform. Eigenstaendig
   veroeffentlichte Pakete werden nicht bemaengelt, rollForward wird
   beachtet.

3. Die Ueberwachung der Maschine entsteht im Installer. Zwei Fragen -
   Name im Dashboard und ob eingeplant werden soll - statt fuenf Schritten
   in der Oberflaeche an einem anderen Rechner. Monitor, Token mit genau
   watchdog:ping, Skript, Dateirechte, ein Heartbeat zur Probe und der
   Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen
   ohne Installation: --action monitor.

Die Agent-Skripte werden jetzt in src/Modules/Watchdog/AgentScript.php
erzeugt - von Oberflaeche und Installer gemeinsam - und melden Last,
Speicher, Plattenbelegung und Laufzeit mit, statt nur "status: ok". Beim
Ausfuehren fielen zwei Fehler auf, die dort behoben sind: df -P verrutscht
bei Geraetenamen mit Leerzeichen (gezaehlt wird jetzt von hinten), und
ohne LC_ALL=C erzeugt awk auf einem deutschen System "12,5" und damit
kaputtes JSON.

Neu: POST /api/setup/v1/agent. SDK 2.6.0 mit
SetupClient.RequestWatchdogAgentAsync().

Die OpenAPI-Beschreibung des neuen Endpunkts bleibt zunaechst aussen vor:
public/api/openapi.php traegt gerade auch fremde, noch nicht committete
Aenderungen aus einer parallel laufenden Arbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:51:37 +02:00
Deploymentcenter Bot fc9b698141 feat(sdk): --require-signature ueber LaunchUpdateAgent erreichbar
Der Schalter gab es bisher nur auf der Kommandozeile. LaunchUpdateAgent,
der vom Leitfaden empfohlene Weg, hatte dafuer keinen Parameter - jede
Anwendung, die diesem Weg folgte, aktualisierte damit ungeprueft, waehrend
derselbe Vorgang von Hand geschuetzt gewesen waere.

Neuer optionaler Parameter requireSignature (Vorgabe false, keine
Verhaltensaenderung fuer bestehende Aufrufer). Der oeffentliche Schluessel
muss dafuer nicht separat verwaltet werden, der Agent holt und cached ihn
selbst. SDK auf 2.5.2, Changelog- und Leitfaden-Eintrag ergaenzt.
2026-08-15 13:38:33 +02:00
Deploymentcenter Bot e579ca1acc chore(security): Signierschluessel-Datei von git ausschliessen
config/*.pem ergaenzt, damit der neue RSA-Signierschluessel fuer
Releases (config/release-signing.pem) nie versehentlich eingecheckt wird.
2026-08-15 09:47:05 +02:00
Deploymentcenter BotandClaude Opus 5 f8771c8d1b fix(guard): Zugangsschutz je Produkt abschaltbar, Dienst-Betrieb, Buildzeiten
Sieben Rueckmeldungen aus einer laufenden Integration. Der schwerwiegendste
Punkt ist ein Fehler von mir.

D2 - Predictalytics ist ausgesperrt. Bestaetigt: /releases/predictalytics/
antwortet mit 401, waehrend die API weiter "Update verfuegbar" meldet. Jede
ausgelieferte Installation laeuft damit in die Wand. Ursache ist nicht der
Schutz an sich, sondern dass ich ihn scharfgeschaltet habe, ohne zu pruefen,
ob die Verbraucher nachgezogen sind - genau der Fall, vor dem UPGRADE §16.1
warnt.
Behoben wird die Klasse des Problems, nicht nur dieser Fall: Produkte lassen
sich unter UpdateService -> Zugangsschutz einzeln ausnehmen. Damit ist der
gestaffelte Rollout moeglich, der bisher fehlte: ausnehmen, Build mit
Schluessel ausliefern, wieder einschalten. Ausgenommene Produkte sind in der
Uebersicht deutlich als AUSGENOMMEN markiert und faerben den Selbsttest nicht
gruen.

D5 - BuildInfo.targets verhinderte inkrementelle Builds. BuildDateUtc trug die
volle Uhrzeit, aenderte sich also bei jedem Build; WriteOnlyWhenDifferent griff
nie, und jedes einbindende Projekt wurde jedes Mal neu uebersetzt. Jetzt
tagesgenau. Das Commit-Datum waere stabiler, laesst sich aber nicht
verlaesslich holen - die Formatangabe von git log ueberlebt MSBuild und cmd.exe
nicht, wie ein Fehlversuch gezeigt hat.

D4 - LicenseConfig war uneinheitlich und fuer Dienste unbrauchbar.
SetStorageDirectory benutzte den Pfad roh, waehrend der Weg ueber die
Umgebungsvariable <slug>/license anhaengte: zwei Produkte im selben Prozess
schrieben in dieselbe state.dat. Und ohne $HOME - systemd User= ohne
Heimatverzeichnis - landete der Rueckfall im Installationsverzeichnis, unter
/opt nicht beschreibbar. Neu: einheitliches Anhaengen und ein Rueckfall auf
/var/lib/<slug>, der vorher prueft, ob dort ueberhaupt geschrieben werden kann.

D1 - Woher die Anwendung den Lizenzschluessel fuer den Update-Zugang nimmt,
stand nirgends zusammenhaengend. Jetzt ein Beispiel in UPDATESERVICE §5A, das
TryGetCachedKey und CheckForUpdateAsync verbindet.

D3 - Fuer einen laufenden systemd-Dienst gab es keinen Update-Weg. Neu:
SETUP §4A mit einer oneshot-Unit, die stoppt, aktualisiert und wieder startet -
ohne --restart, weil der Agent sonst an systemd vorbei einen zweiten Prozess
startet. Inklusive EnvironmentFile fuer den Schluessel und dem Hinweis auf die
Dateirechte nach einem Lauf als root.

D6 - Die Empfehlung Environment.Exit(1) passt fuer handelnde Systeme nicht. Ein
neuer Abschnitt im Lizenz-Leitfaden beschreibt den Sperrbetrieb: abschalten,
was neue Verpflichtungen eingeht; weiterlaufen lassen, was bestehende abwickelt.

D7 - Die Drosselungsgrenzen aller Endpunkte stehen jetzt in docs/README.md.
/api/errors/v1/report erlaubt 300 pro Minute, nicht 60; die Einstellung
bugtracker.error_rate fehlte in der Beispielkonfiguration. Der zweite Teil des
Befunds war veraltet: docs/README.md fuehrt die Release-Anleitung bereits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:41:53 +02:00
Deploymentcenter BotandClaude Opus 5 a8b9f6f7c9 feat(docs): Changelog mit "was ist seit meiner Fassung neu"
Bisher musste ein Agent, der eine Anbindung aktualisiert, die gesamte Historie
lesen - oder er las gar nichts und uebersah eine brechende Aenderung. Beides
schlecht.

- public/docs/changelog.json ist die einzige Quelle. Je Fassung eine
  Zusammenfassung, je Aenderung Bereich, ein "breaking"-Kennzeichen und vor
  allem ein Feld "action" mit dem, was konkret zu tun ist. Steht dort null,
  ist nichts zu tun - das ist die haeufigste und nuetzlichste Antwort.
- GET /api/updateservice/v1/changelog?since=2.2.0 liefert nur die neueren
  Fassungen, dazu die Anzahl der Punkte mit Handlungsbedarf und der
  brechenden Aenderungen. count:0 heisst "du bist auf Stand" - dann muss gar
  nichts gelesen werden. Optional nach Bereich filterbar (?area=packager).
- /docs/changelog.php rendert dieselbe Datei fuer Menschen, mit Eingabefeld
  fuer die eigene Fassung. Bewusst dieselbe Quelle: zwei Fassungen zu pflegen
  hiesse, sie auseinanderlaufen zu lassen.
- DeploymentcenterSdk.Version im SDK ist der Bezugspunkt. Damit muss die
  Fassung nicht abgetippt werden.
- AGENT_PROMPT_TEMPLATE.md verpflichtet dazu, sie in der AGENTS.md des
  Projekts festzuhalten und vor jeder Aenderung an der Anbindung den
  Unterschied abzufragen. Auch in der Kurzfassung fuer knappe Prompt-Budgets.

Die Historie ist rueckwirkend bis 2.0.0 gefuellt: 6 Fassungen, 25 Punkte mit
Handlungsbedarf, 13 brechende Aenderungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:45:50 +02:00
Deploymentcenter BotandClaude Opus 5 1967b49ad7 fix(client): Schluessel raus aus argv, Wartezeit einstellbar, Packager sperrt
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>
2026-08-14 13:47:55 +02:00
Deploymentcenter BotandClaude Opus 5 e01a608c08 feat(release): Veroeffentlichungsvorlage fuer fremde Projekte
Die Anleitung benutzte pack-and-deploy, als laege es im PATH - beziehbar war
es nirgends. Ein Projekt, das den UpdateService einbindet, konnte also nicht
veroeffentlichen, ohne dieses Repository auszuchecken und selbst zu
uebersetzen. Das Werkzeug existierte, nur kam niemand daran.

- build_installer.ps1 baut pack-and-deploy fuer dieselben Laufzeitkennungen
  mit und fuehrt es in installer.json unter "tools". Damit steht es neben dem
  Agenten unter /installer/ bereit.
- Neue Vorlage unter public/docs/release-template/: release.ps1, release.sh
  und release.config.example.json. Kopieren, Konfiguration ausfuellen, fertig -
  die Skripte selbst bleiben unveraendert und lassen sich bei einer neuen
  Fassung einfach ersetzen.
- Sie orchestrieren nur: je Laufzeitkennung einmal dotnet publish, dann
  pack-and-deploy. Pruefsummen, Dateimanifest, latest.json und die Anmeldung
  bleiben im Werkzeug - ein zweiter Ort fuer dieselbe Logik waere ein zweiter
  Ort fuer dieselben Fehler.
- Das Werkzeug wird beim ersten Lauf selbst geholt, gegen die .sha256 geprueft
  und unter .dc-tools/ abgelegt. Die Vorlage ist damit wirklich eine Datei.
- setup.json wird ins Publish-Verzeichnis kopiert, sonst faende der Installer
  sie nicht.
- Rueckgabewert 1 (Konfigurations- oder Versionsfehler) bricht sofort ab; die
  weiteren Plattformen wuerden genauso scheitern. Bei 2 laeuft es weiter und
  meldet am Ende, welche betroffen sind.

Anleitung: public/docs/release.md, oeffentlich unter /docs/release.md - dort,
wo auch das Bugtracker-Handbuch liegt. Das Entwickler-docs/ wird nicht
ausgeliefert; ein erster Anlauf legte die Vorlage dort ab und war deshalb
nicht abrufbar.

Beim Erproben in einem leeren Projekt aufgefallen und behoben:
- Die Vorlage verlangte jq. Das ist auf den wenigsten Systemen vorinstalliert;
  sie kommt jetzt auch mit Python aus.
- Windows legt unter WindowsApps einen python3-Platzhalter ab, der gefunden
  wird, beim Aufruf aber nur auf den Store verweist. Die Erkennung erprobt den
  Interpreter deshalb, statt nur seine Existenz zu pruefen.
- Der Ternary-Operator in release.ps1 gibt es erst ab PowerShell 7; die
  Vorlage laeuft jetzt auch mit dem mitgelieferten 5.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:23:38 +02:00
Deploymentcenter BotandClaude Opus 5 56b1d2631a fix(setup): Dateirechte bei Geheimnissen, plattformabhaengige Zielnamen
Zwei Beobachtungen aus der laufenden Integration.

1. Der Installer schrieb Geheimnisse weltlesbar
   SetupWriter benutzte File.WriteAllText ohne Rechteanpassung - unter Linux
   also die Standardmaske und damit ueblicherweise 644. In genau dieser Datei
   stehen Lizenzschluessel und Anwendungstoken; jeder Benutzer des Systems
   konnte sie lesen. Der Lizenz-Cache in StateStore wird aus demselben Grund
   seit jeher auf 600 gesetzt - der Installer zog nicht nach.
   Enthaelt ein Ziel mindestens einen geheimen Wert, wird die Datei jetzt auf
   den eigenen Benutzer beschraenkt. Als geheim gilt type=secret UND
   source=provision: ein so geholtes Token traegt oft den Typ "string", ist
   aber genauso schutzbeduerftig. Unter Windows bleibt es beim Profil-ACL.

2. Zielnamen koennen plattformabhaengig unterschiedlich sein
   %APPDATA%\MeineAnwendung gegen $XDG_CONFIG_HOME/meineanwendung - eine
   setup.json kannte nur eine Schreibweise. Die kleingeschriebene Form allein
   traegt, weil NTFS die Schreibweise ignoriert, aber nur solange das
   Dateisystem tatsaechlich unempfindlich ist; auf APFS mit Beachtung der
   Schreibweise oder bei groesseren Unterschieden entstuende ein zweites,
   leeres Verzeichnis neben dem, aus dem die Anwendung liest.
   Ziele haben deshalb optional fileWindows, fileLinux und fileMacOS; ohne
   Angabe gilt weiterhin file.

Die Dokumentation haelt ausserdem fest, dass der Installer bewusst Klartext
schreibt und die Anwendung selbst entscheidet, ob und wie sie ihn danach
schuetzt - und dass eine Entschluesselung, die Klartext durchreicht, deshalb
kein Altlast-Zweig mehr ist, sondern ein aktiv genutzter Pfad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:37:01 +02:00
Deploymentcenter BotandClaude Opus 5 7a3a5dad69 fix(releases): Lizenzschluessel nicht mehr im Klartext, Selbsttest, Zielorte
Vier Befunde aus einer externen Durchsicht der 2.4-Integration.

1. Die .htpasswd war eine Klartext-Kundenliste
   Das htpasswd-Format hasht nur die Passwortspalte. Benutzername UND Passwort
   waren der Lizenzschluessel - der Schluessel stand also im Klartext direkt
   neben seinem eigenen bcrypt-Hash, und der Hash war Dekoration. Geschuetzt
   hat das Ganze nur die FilesMatch-Regel in derselben Datei.
   Der Benutzername wird jetzt abgeleitet: lic_<sha256(schluessel), 16 Hex>.
   Die Datei enthaelt damit nur noch eine Einwegableitung und einen Hash ueber
   einen hochentropen Schluessel.
   Server und SDK muessen dabei zeichengenau uebereinstimmen; ein Test prueft
   die C#-Ableitung gegen die PHP-Formel.

2. Ein Formatwechsel blieb unbemerkt liegen
   Beim Umbau auf 1. faellt auf: reconcile() sah keinen Anlass zur
   Neuerzeugung, die Dateien behielten das alte Format, waehrend die Clients
   bereits das neue schickten. Die erzeugten Dateien tragen deshalb jetzt eine
   Formatkennung; weicht sie ab, wird neu erzeugt.

3. Doku beschrieb Nginx, der Schutz ist Apache-only
   .htaccess wird von Nginx ignoriert - dort waeren die Verzeichnisse offen und
   die .htpasswd oeffentlich abrufbar. Die Statusanzeige pruefte nur, ob die
   Dateien existieren, und haette in dem Fall "GESCHUETZT" gemeldet.
   Neu: ein echter Selbsttest ruft die eigene Paket-Adresse OHNE Zugangsdaten
   ab und erwartet 401. Er laeuft beim manuellen Erzeugen und nach jeder
   automatischen Neuerzeugung; das Ergebnis steht in der Oberflaeche, ein
   Fehlschlag im Log. Er findet nebenbei auch abgeschaltetes AllowOverride und
   Tippfehler in der erzeugten Datei. Doku korrigiert, Nginx-Vorlage ergaenzt.

4. Erstinstallation schrieb an einen Ort, an dem Linux-Anwendungen nicht lesen
   setup.json-Ziele waren immer installationsrelativ. Eine Anwendung, die sich
   unter Linux richtig verhaelt, liest aus $XDG_CONFIG_HOME - /opt/<app> ist
   fuer den Dienstbenutzer meist nicht schreibbar. Der Installer legte die
   Datei also dorthin, wo nie jemand nachsieht.
   Ziele haben jetzt ein "location": install (Vorgabe), config, data, home,
   plus ${VAR}- und %VAR%-Ersetzung in "file". Unbekannte Variablen bleiben
   stehen statt leer zu werden - ein Platzhalter faellt auf, ein falscher Pfad
   nicht. Der Installer gibt den aufgeloesten Pfad aus, weil bei config das
   Konto entscheidet, unter dem er laeuft.

Ausserdem
- Doku zeigte "status": "ok" fuer update/delete; Http::ok() erzeugt
  "status": "success".
- UPGRADE §16.1 deckte Neuprodukte nicht ab: Fuer ein Produkt ohne Release
  existiert /releases/<slug>/ nicht und wird uebersprungen. Das Verzeichnis
  entsteht erst mit dem ersten Upload, der naechste Tick schuetzt es. Der erste
  ausgelieferte Build muss die Zugangsdaten also schon mitbringen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:47:26 +02:00
55 changed files with 5196 additions and 212 deletions
+4
View File
@@ -7,6 +7,9 @@ var/*.lock
artifacts/
*.bak
.DS_Store
# Bytecode-Cache der Deployment-Skripte
__pycache__/
*.pyc
bin/
obj/
*.user
@@ -14,6 +17,7 @@ obj/
# --- Secrets: NIEMALS committen ---
config/config.php
config/.htpasswd
config/*.pem
scripts/deploy_config.json
Serverdaten.txt
*.local.php
+2 -1
View File
@@ -34,6 +34,7 @@ Options -Indexes
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA]
RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA]
RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA]
# Fallback fuer direkt adressierte Dateien unterhalb von /api/
RewriteRule ^api/(.*)$ public/api/$1 [L,QSA]
@@ -57,7 +58,7 @@ Options -Indexes
# ------------------------------------------------------------------
# 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
# ------------------------------------------------------------------
RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet)(/|$) - [F,L]
RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet|client-php|mcp)(/|$) - [F,L]
RewriteRule ^Serverdaten\.txt$ - [F,L]
</IfModule>
+23
View File
@@ -159,6 +159,29 @@ foreach ($guardResult['errors'] as $guardError) {
Logger::warning('Release-Zugangsschutz', ['error' => $guardError]);
}
// Nach einer Neuerzeugung gegenpruefen, ob der Schutz wirklich greift.
// Vorhandene Dateien beweisen nichts: Unter Nginx werden sie ignoriert, und
// dann waeren die Verzeichnisse offen, ohne dass es jemandem auffiele.
if ($guardResult['ran']) {
$guardTest = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::selfTest($db);
foreach ($guardTest['failed'] as $failure) {
Logger::error('Release-Zugangsschutz greift nicht', [
'product' => $failure['slug'] ?? '?',
'status' => $failure['status'] ?? null,
'reason' => $failure['reason'] ?? '',
]);
}
if (!$options['quiet'] && $guardTest['failed'] !== []) {
printf(
"ACHTUNG: Zugangsschutz greift bei %d von %d Produkt(en) NICHT.\n",
count($guardTest['failed']),
$guardTest['checked']
);
}
}
$result = Evaluator::run($db);
$wallMs = (int)round((microtime(true) - $startedAt) * 1000);
@@ -42,7 +42,6 @@
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(MSBuildProjectName)</DeploymentcenterBuildInfoNamespace>
<BuildInfoFile>$(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs</BuildInfoFile>
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc>
<!-- Version aus <Version>, ersatzweise <AssemblyVersion>, sonst 0.0.0. -->
<BuildVersion Condition="'$(BuildVersion)' == ''">$(Version)</BuildVersion>
@@ -73,6 +72,29 @@
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitShort" />
</Exec>
<PropertyGroup>
<!--
Nur das Datum, nicht die Uhrzeit.
Zuvor stand hier DateTime.UtcNow mit voller Genauigkeit. Das aenderte
sich bei JEDEM Build, die erzeugte .g.cs unterschied sich dadurch
immer, WriteOnlyWhenDifferent griff nie - und jedes Projekt, das diese
Targets einbindet, wurde bei jedem Build neu uebersetzt, samt allem,
was davon abhaengt. Bei einer Projektmappe mit mehreren
Abhaengigkeiten kostet das jedes Mal spuerbar Zeit.
Mit Tagesgenauigkeit faellt hoechstens ein Neubau pro Tag an. Die
genaue Bauzeit war ohnehin selten die interessante Angabe - welcher
Stand gebaut wurde, sagt GitCommit praeziser, und der aendert sich
genau dann, wenn eine Neuuebersetzung faellig ist.
Das Commit-Datum waere noch stabiler, laesst sich aber nicht
verlaesslich holen: Die Formatangabe von "git log" muss durch MSBuild
und cmd.exe, und das Prozentzeichen ueberlebt diese Kette nicht.
-->
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("yyyy-MM-dd"))</BuildDateUtc>
</PropertyGroup>
<PropertyGroup>
<DcGitCommitLong Condition="'$(DcGitCommitLong)' == ''">UNKNOWN_COMMIT</DcGitCommitLong>
<DcGitCommitShort Condition="'$(DcGitCommitShort)' == ''">UNKNOWN</DcGitCommitShort>
@@ -8,8 +8,41 @@
<RootNamespace>Deploymentcenter.Client</RootNamespace>
<AssemblyName>Deploymentcenter.Client</AssemblyName>
<NoWarn>$(NoWarn);CA1416</NoWarn>
<!--
Als NuGet-Paket beziehbar machen.
Die Anleitung empfahl bisher einen Import per relativem Pfad ins
Nachbar-Repository:
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
Das setzt voraus, dass beide Repositories nebeneinander ausgecheckt sind
und in derselben Fassung stehen - genau die Kopplung, die ein Paket
vermeidet.
-->
<IsPackable>true</IsPackable>
<PackageId>Deploymentcenter.Client</PackageId>
<Version>2.6.0</Version>
<Authors>Deploymentcenter</Authors>
<Description>Client-SDK für Lizenzprüfung, Update-Verteilung und Erstinstallation über das Deploymentcenter.</Description>
<PackageTags>deploymentcenter;update;lizenz</PackageTags>
</PropertyGroup>
<!--
Das Targets-File wandert nach build/ im Paket und wird von NuGet
automatisch in jedes Projekt importiert, das auf das Paket verweist.
Ein <Import> von Hand entfaellt damit - und mit ihm der Pfad ins
Nachbar-Repository.
Der Dateiname muss dafuer der Paket-Kennung entsprechen.
-->
<ItemGroup>
<None Include="Deploymentcenter.BuildInfo.targets"
Pack="true"
PackagePath="build\Deploymentcenter.Client.targets" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup>
@@ -0,0 +1,58 @@
using System;
namespace Deploymentcenter.Client
{
/// <summary>
/// Auskunft ueber die eingebundene SDK-Fassung.
///
/// Wozu das gut ist: Wer eine Anbindung aktualisiert, will nicht die
/// gesamte Historie lesen, sondern nur wissen, was sich seit der eigenen
/// Fassung geaendert hat. Dafuer muss die Anwendung festhalten, gegen
/// welche Fassung sie gebaut wurde - und genau das ist
/// <see cref="Version"/>.
///
/// Der Wert wandert automatisch in die Update-Pruefung und den Heartbeat,
/// sodass im Deploymentcenter sichtbar wird, welche Installation auf
/// welchem Stand haengt. Ohne diese Angabe bliebe nur Nachfragen.
///
/// Abfragen laesst sich der Unterschied so:
///
/// GET /api/updateservice/v1/changelog?since=2.2.0
///
/// Die Antwort nennt nur die neueren Fassungen, dazu die Anzahl der
/// Punkte mit Handlungsbedarf und der brechenden Aenderungen.
/// </summary>
public static class DeploymentcenterSdk
{
/// <summary>
/// Fassung dieses SDK. Bei jeder Auslieferung mitzufuehren - sie ist
/// der Bezugspunkt fuer den Changelog.
/// </summary>
public const string Version = "2.5.1";
/// <summary>
/// Adresse, unter der sich der Unterschied zur eigenen Fassung
/// abfragen laesst.
/// </summary>
public static string ChangelogUrl(string baseUrl, string? since = null)
{
string clean = (baseUrl ?? string.Empty).TrimEnd('/');
string url = clean + "/api/updateservice/v1/changelog";
string from = string.IsNullOrWhiteSpace(since) ? Version : since!;
return url + "?since=" + Uri.EscapeDataString(from);
}
/// <summary>
/// Kurzer Vermerk fuer Protokolle und Fehlerberichte. Taucht ein
/// Problem auf, ist damit sofort ersichtlich, gegen welche Fassung die
/// Anbindung gebaut wurde.
/// </summary>
public static string Describe(string? appVersion = null)
{
return string.IsNullOrWhiteSpace(appVersion)
? $"Deploymentcenter-SDK {Version}"
: $"{appVersion} (Deploymentcenter-SDK {Version})";
}
}
}
@@ -378,6 +378,99 @@ public class LicenseClient
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
/// </summary>
/// <summary>
/// Stellt sicher, dass eine gueltige Lizenz vorliegt - und fragt notfalls
/// nach dem Schluessel.
///
/// Bis Version 2.5 nahm der Konstruktor ein <see cref="ILicensePrompt"/>
/// entgegen, legte es ab und benutzte es nie. Das Versprechen der
/// Schnittstelle - Schluessel abfragen, Fehler und Erfolg melden - loeste
/// nichts ein; wer darauf eine headless-Story aufbaute, baute auf Sand.
/// Diese Methode ist die Naht, die dabei fehlte.
///
/// Ablauf: zwischengespeicherten Schluessel nehmen, sonst fragen; pruefen;
/// bei Ablehnung erneut fragen, bis <paramref name="maxAttempts"/>
/// erschoepft ist.
/// </summary>
/// <param name="allowPrompt">
/// Auf false setzen, wenn kein Mensch davorsitzt - etwa in einem Dienst.
/// Dann wird ausschliesslich der zwischengespeicherte Schluessel geprueft
/// und bei dessen Fehlen abgelehnt, statt auf eine Eingabe zu warten, die
/// nie kommt.
/// </param>
public async Task<LicenseValidationResult> EnsureLicensedAsync(
string productSlug,
string serverBaseUrl,
bool allowPrompt = true,
int maxAttempts = 3,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
string? key = TryGetCachedKey(productSlug);
LicenseValidationResult? last = null;
for (int attempt = 0; attempt < Math.Max(1, maxAttempts); attempt++)
{
if (string.IsNullOrWhiteSpace(key))
{
if (!allowPrompt)
{
_prompt.ShowLicenseError(
"Keine Lizenz hinterlegt",
$"Fuer \"{productSlug}\" liegt kein Schluessel vor, und es kann nicht danach gefragt werden.");
return last ?? new LicenseValidationResult
{
IsValid = false,
Status = "no_license",
Message = "Kein Lizenzschluessel hinterlegt."
};
}
key = await _prompt.RequestLicenseKeyAsync(productSlug).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(key))
{
// Abbruch durch den Benutzer - erneutes Fragen waere
// Bevormundung.
return new LicenseValidationResult
{
IsValid = false,
Status = "no_license",
Message = "Es wurde kein Lizenzschluessel eingegeben."
};
}
}
last = await ValidateAsync(productSlug, key!, serverBaseUrl, appVersion, cancellationToken)
.ConfigureAwait(false);
if (last.IsValid)
{
_prompt.ShowLicenseInfo("Lizenz gueltig", last.Message);
return last;
}
// Ein Netzwerk- oder Serverproblem ist kein Grund, den Schluessel
// erneut abzufragen - er ist ja nicht falsch.
if (last.IsTransient)
{
_prompt.ShowLicenseError("Lizenzpruefung nicht moeglich", last.Message);
return last;
}
_prompt.ShowLicenseError("Lizenz abgelehnt", last.Message);
key = null;
}
return last ?? new LicenseValidationResult
{
IsValid = false,
Status = "invalid",
Message = "Lizenzpruefung fehlgeschlagen."
};
}
public static string? TryGetCachedKey(string productSlug)
{
var cache = TryGetCachedState(productSlug);
@@ -31,8 +31,13 @@ public static class LicenseConfig
productSlug = "default_app";
// 1. Explicitly set property
//
// Auch hier wird <slug>/license angehaengt - genau wie beim Weg ueber
// die Umgebungsvariable. Zuvor wurde der gesetzte Pfad roh benutzt:
// Zwei Produkte im selben Prozess mit gesetztem Override schrieben
// damit in dieselbe state.dat und ueberschrieben sich gegenseitig.
if (!string.IsNullOrWhiteSpace(_storageDirectoryOverride))
return ValidateNonEmpty(_storageDirectoryOverride!);
return ValidateNonEmpty(Path.Combine(_storageDirectoryOverride!, productSlug, "license"));
// 2. Environment Variable
var envDir = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_STORAGE_DIR")
@@ -50,6 +55,21 @@ public static class LicenseConfig
var home = Environment.GetEnvironmentVariable("HOME");
if (!string.IsNullOrWhiteSpace(home))
return ValidateNonEmpty(Path.Combine(home!, ".config", productSlug, "license"));
// Dienst ohne Heimatverzeichnis.
//
// Eine systemd-Unit mit User= und ohne eigenes $HOME hat weder
// XDG_CONFIG_HOME noch HOME. Der Rueckfall landete dann im
// Installationsverzeichnis - unter /opt fuer den Dienstbenutzer
// typischerweise nicht beschreibbar. Ergebnis: kein Offline-Cache,
// ausgerechnet im kopflosen Betrieb, fuer den er gedacht ist.
//
// /var/lib ist der vorgesehene Ort fuer veraenderlichen Zustand
// eines Dienstes. Genommen wird er nur, wenn er auch beschreibbar
// ist - sonst waere es derselbe stille Fehlschlag an anderer Stelle.
var varLib = Path.Combine("/var", "lib", productSlug, "license");
if (CanWriteTo(varLib))
return ValidateNonEmpty(varLib);
}
else if (OperatingSystemHelpers.IsWindows())
{
@@ -71,6 +91,29 @@ public static class LicenseConfig
_storageDirectoryOverride = path;
}
/// <summary>
/// Laesst sich dort wirklich schreiben? Ein Pfad, den man nur zurueckgibt,
/// weil er ueblich ist, hilft nicht weiter - der Fehlschlag faellt dann
/// erst beim Speichern auf.
/// </summary>
private static bool CanWriteTo(string directory)
{
try
{
Directory.CreateDirectory(directory);
string probe = Path.Combine(directory, ".dc-write-test");
File.WriteAllText(probe, string.Empty);
File.Delete(probe);
return true;
}
catch
{
return false;
}
}
private static string ValidateNonEmpty(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -43,10 +43,58 @@ namespace Deploymentcenter.Client.Models
/// <summary>Eine Datei, in die Werte geschrieben werden.</summary>
public class SetupTarget
{
/// <summary>Pfad relativ zum Installationsverzeichnis.</summary>
/// <summary>
/// Pfad, bezogen auf <see cref="Location"/>. Umgebungsvariablen sind
/// in beiden Schreibweisen erlaubt: <c>${XDG_CONFIG_HOME}</c> und
/// <c>%APPDATA%</c>. Ein absoluter Pfad wird unveraendert benutzt.
/// </summary>
[JsonPropertyName("file")]
public string File { get; set; } = string.Empty;
/// <summary>
/// Abweichender Pfad unter Windows.
///
/// Konfigurationsordner heissen plattformabhaengig unterschiedlich:
/// unter Windows ist "%APPDATA%\MeineAnwendung" ueblich, unter Linux
/// "$XDG_CONFIG_HOME/meineanwendung" klein. Meist genuegt es, die
/// kleingeschriebene Form anzugeben - NTFS unterscheidet nicht nach
/// Gross- und Kleinschreibung, umgekehrt entstuende unter Linux ein
/// zweites, leeres Verzeichnis neben dem, aus dem die Anwendung liest.
///
/// Das traegt aber nur, solange das Dateisystem tatsaechlich
/// unempfindlich ist - auf APFS mit Beachtung der Schreibweise oder
/// einem per fsutil umgestellten NTFS-Ordner nicht mehr. Und es hilft
/// gar nicht, wenn sich die Namen um mehr als die Schreibweise
/// unterscheiden. Fuer diese Faelle sind die folgenden Felder da; ohne
/// Angabe gilt <see cref="File"/>.
/// </summary>
[JsonPropertyName("fileWindows")]
public string FileWindows { get; set; } = string.Empty;
/// <summary>Abweichender Pfad unter Linux.</summary>
[JsonPropertyName("fileLinux")]
public string FileLinux { get; set; } = string.Empty;
/// <summary>Abweichender Pfad unter macOS.</summary>
[JsonPropertyName("fileMacOS")]
public string FileMacOS { get; set; } = string.Empty;
/// <summary>
/// Wo die Datei liegt:
///
/// install (Vorgabe) - im Installationsverzeichnis
/// config - %APPDATA% bzw. $XDG_CONFIG_HOME (~/.config)
/// data - %LOCALAPPDATA% bzw. $XDG_DATA_HOME
/// home - Benutzerverzeichnis
///
/// Anwendungen, die sich unter Linux richtig verhalten, legen ihre
/// Konfiguration nicht neben das Programm - /opt/&lt;app&gt; ist fuer den
/// Dienstbenutzer meist nicht schreibbar. Ohne diese Angabe schriebe
/// der Installer dorthin, wo die Anwendung nie nachsieht.
/// </summary>
[JsonPropertyName("location")]
public string Location { get; set; } = "install";
/// <summary>"json" oder "env".</summary>
[JsonPropertyName("format")]
public string Format { get; set; } = "json";
@@ -18,27 +18,63 @@ namespace Deploymentcenter.Client
/// </summary>
public sealed class ReleaseCredentials
{
private ReleaseCredentials(string user, string password)
private ReleaseCredentials(string user, string password, bool fromLicense)
{
User = user;
Password = password;
FromLicense = fromLicense;
}
public string User { get; }
public string Password { get; }
/// <summary>
/// Stammt der Zugang aus einem Lizenzschluessel oder aus einem
/// Installationskonto? Nur fuer die Fehlermeldung von Belang - ein
/// abgelehntes Konto hat andere Ursachen als eine abgelehnte Lizenz,
/// und wer die falschen genannt bekommt, sucht an der falschen Stelle.
/// </summary>
public bool FromLicense { get; }
/// <summary>
/// Zugang ueber den Lizenzschluessel.
///
/// Benutzername und Passwort sind beide der Schluessel: Basic Auth
/// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
/// Benutzernamen muessen innerhalb einer .htpasswd eindeutig sein.
/// Der Benutzername wird aus dem Schluessel abgeleitet, das Passwort
/// ist der Schluessel selbst. Der Grund liegt im htpasswd-Format:
/// gehasht wird dort nur die Passwortspalte. Stuende der Schluessel
/// auch als Benutzername in der Datei, waere sie eine vollstaendige
/// Klartext-Kundenliste und der Hash daneben blosse Dekoration.
///
/// MUSS zeichengenau mit ReleaseGuard::licenseUsername() auf dem
/// Server uebereinstimmen.
/// </summary>
public static ReleaseCredentials? FromLicenseKey(string? licenseKey)
{
string key = (licenseKey ?? string.Empty).Trim();
return key.Length == 0 ? null : new ReleaseCredentials(key, key);
if (key.Length == 0)
return null;
return new ReleaseCredentials(UsernameForLicenseKey(key), key, fromLicense: true);
}
/// <summary>
/// Ableitung des Benutzernamens: "lic_" plus die ersten 16 Hexzeichen
/// des SHA-256 ueber den Schluessel.
/// </summary>
public static string UsernameForLicenseKey(string licenseKey)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes((licenseKey ?? string.Empty).Trim()));
var builder = new StringBuilder("lic_", 20);
for (int i = 0; i < 8; i++)
{
builder.Append(hash[i].ToString("x2"));
}
return builder.ToString();
}
/// <summary>Zugang ueber ein Installationskonto.</summary>
@@ -47,7 +83,7 @@ namespace Deploymentcenter.Client
string u = (user ?? string.Empty).Trim();
string p = password ?? string.Empty;
return u.Length == 0 ? null : new ReleaseCredentials(u, p);
return u.Length == 0 ? null : new ReleaseCredentials(u, p, fromLicense: false);
}
/// <summary>Wert fuer den Authorization-Header.</summary>
@@ -86,6 +122,17 @@ namespace Deploymentcenter.Client
+ "Erwartet wird der Lizenzschluessel dieser Installation.";
}
if (!credentials.FromLicense)
{
// Bei einer Erstinstallation gibt es noch gar keine Lizenz -
// von abgelaufenen Vertraegen zu sprechen schickt den Menschen
// davor in die falsche Richtung.
return "Die Release-Ablage hat das Konto '" + credentials.User + "' abgelehnt. "
+ "Zugang haben nur Konten der Rollen installer und admin, die nicht "
+ "abgeschaltet sind. Der Zugangsschutz wird auf dem Server erzeugt und "
+ "kennt Kontoaenderungen erst nach dem naechsten Lauf.";
}
return "Die Release-Ablage hat die Zugangsdaten abgelehnt. Moegliche Gruende: die Lizenz "
+ "ist abgelaufen, wurde widerrufen oder gehoert zu einem anderen Produkt.";
}
@@ -51,6 +51,31 @@ namespace Deploymentcenter.Client
}
/// <summary>Fehler beim Ansprechen der Setup-Schnittstelle.</summary>
/// <summary>
/// Ein fertiger Watchdog-Agent fuer die Maschine, auf der installiert
/// wird: Skript, Dateiname und der Befehl, mit dem es sich einplanen
/// laesst. Das Token steckt bereits im Skript - es wird nur einmal
/// ausgeliefert und liegt danach nirgendwo sonst.
/// </summary>
public sealed class WatchdogAgent
{
public string Source { get; set; } = string.Empty;
public string Os { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Script { get; set; } = string.Empty;
/// <summary>
/// Einplanungsbefehl mit dem Platzhalter {PFAD} - der Ablageort steht
/// erst fest, wenn die Datei geschrieben ist.
/// </summary>
public string Schedule { get; set; } = string.Empty;
public int IntervalSeconds { get; set; } = 60;
/// <summary>Gab es diesen Monitor schon? Dann wurde er uebernommen.</summary>
public bool Existed { get; set; }
}
public sealed class SetupException : Exception
{
public SetupException(string message, string code = "") : base(message)
@@ -222,6 +247,63 @@ namespace Deploymentcenter.Client
return token;
}
/// <summary>
/// Laesst einen Watchdog-Agenten fuer diese Maschine ausstellen:
/// Monitor anlegen, Token erzeugen, passendes Skript bauen.
///
/// Der Name entscheidet, unter welcher Bezeichnung der Host im
/// Dashboard steht. Gibt es ihn schon, lehnt der Server mit
/// "monitor_exists" ab - eine zweite Maschine soll die Historie der
/// ersten nicht stillschweigend uebernehmen. Erst <paramref
/// name="overwrite"/> laesst das zu.
/// </summary>
public async Task<WatchdogAgent> RequestWatchdogAgentAsync(
string project,
string source,
string os,
int intervalSeconds = 60,
bool overwrite = false,
CancellationToken cancellationToken = default)
{
RequireSession();
var payload = new Dictionary<string, object?>
{
["project"] = project,
["source"] = source,
["os"] = os,
["interval"] = intervalSeconds,
["overwrite"] = overwrite
};
using var doc = await PostAsync("agent", payload, useToken: true, cancellationToken)
.ConfigureAwait(false);
var root = doc.RootElement;
string script = GetString(root, "script");
if (script.Length == 0)
{
throw new SetupException("Der Server hat kein Agent-Skript geliefert.", "no_script");
}
return new WatchdogAgent
{
Source = GetString(root, "source"),
Os = GetString(root, "os"),
FileName = GetString(root, "filename"),
Script = script,
Schedule = GetString(root, "schedule"),
IntervalSeconds = root.TryGetProperty("interval", out var interval)
&& interval.TryGetInt32(out int parsed)
? parsed
: intervalSeconds,
Existed = root.TryGetProperty("existing", out var existed)
&& existed.ValueKind == JsonValueKind.True
};
}
// ------------------------------------------------------------------
private void RequireSession()
@@ -0,0 +1,221 @@
using System;
using System.IO;
using System.Text;
using Deploymentcenter.Client.Models;
namespace Deploymentcenter.Client
{
/// <summary>
/// Loest die Zielpfade einer <c>setup.json</c> plattformrichtig auf.
///
/// Ohne das schrieb der Installer ausschliesslich ins
/// Installationsverzeichnis. Eine Anwendung, die sich unter Linux richtig
/// verhaelt, liest ihre Konfiguration aber aus $XDG_CONFIG_HOME und unter
/// Windows aus %APPDATA% - schon deshalb, weil /opt/&lt;app&gt; fuer den
/// Dienstbenutzer typischerweise nicht schreibbar ist. Der Installer legte
/// die Datei also dorthin, wo die Anwendung nie nachsieht.
/// </summary>
public static class SetupPaths
{
/// <summary>
/// Ermittelt den vollstaendigen Pfad eines Ziels.
/// </summary>
/// <param name="target">Ziel aus der setup.json.</param>
/// <param name="installDir">Installationsverzeichnis der Anwendung.</param>
public static string Resolve(SetupTarget target, string installDir)
{
string file = Expand(FileFor(target));
// Ein absoluter Pfad ist bereits die Antwort - etwa nachdem
// ${XDG_CONFIG_HOME} ersetzt wurde.
if (Path.IsPathRooted(file))
{
return Path.GetFullPath(file);
}
string root = BaseDirectory(target?.Location, installDir);
return Path.GetFullPath(Path.Combine(root, file.Replace('/', Path.DirectorySeparatorChar)));
}
/// <summary>
/// Waehlt den Pfad fuer die laufende Plattform.
///
/// Konfigurationsordner heissen plattformabhaengig unterschiedlich -
/// "ClawdDotNet" unter Windows, "clawddotnet" unter Linux. Die
/// kleingeschriebene Form allein traegt nur, solange das Dateisystem
/// die Schreibweise ignoriert; auf einem case-sensitiven Volume
/// entstuende sonst ein zweites, leeres Verzeichnis neben dem, aus dem
/// die Anwendung liest.
/// </summary>
public static string FileFor(SetupTarget? target)
{
if (target == null)
return string.Empty;
string specific = OperatingSystemHelpers.IsWindows() ? target.FileWindows
: OperatingSystemHelpers.IsMacOS() ? target.FileMacOS
: target.FileLinux;
return string.IsNullOrWhiteSpace(specific) ? (target.File ?? string.Empty) : specific;
}
/// <summary>
/// Wurzelverzeichnis fuer eine Ortsangabe.
///
/// install (Vorgabe) - das Installationsverzeichnis
/// config - %APPDATA% bzw. $XDG_CONFIG_HOME (~/.config)
/// data - %LOCALAPPDATA% bzw. $XDG_DATA_HOME (~/.local/share)
/// home - Benutzerverzeichnis
/// </summary>
public static string BaseDirectory(string? location, string installDir)
{
switch ((location ?? string.Empty).Trim().ToLowerInvariant())
{
case "config":
return ConfigDirectory();
case "data":
return DataDirectory();
case "home":
return HomeDirectory();
case "":
case "install":
return installDir;
default:
// Eine unbekannte Angabe ins Installationsverzeichnis
// umzubiegen ist die harmlosere Auslegung: dort landet die
// Datei sichtbar statt an einem geratenen Ort.
return installDir;
}
}
public static string ConfigDirectory()
{
if (OperatingSystemHelpers.IsWindows())
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return string.IsNullOrWhiteSpace(appData)
? Path.Combine(HomeDirectory(), "AppData", "Roaming")
: appData;
}
string xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") ?? string.Empty;
return string.IsNullOrWhiteSpace(xdg)
? Path.Combine(HomeDirectory(), ".config")
: xdg;
}
public static string DataDirectory()
{
if (OperatingSystemHelpers.IsWindows())
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return string.IsNullOrWhiteSpace(localAppData)
? Path.Combine(HomeDirectory(), "AppData", "Local")
: localAppData;
}
string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? string.Empty;
return string.IsNullOrWhiteSpace(xdg)
? Path.Combine(HomeDirectory(), ".local", "share")
: xdg;
}
public static string HomeDirectory()
{
string home = Environment.GetEnvironmentVariable("HOME") ?? string.Empty;
if (string.IsNullOrWhiteSpace(home))
{
home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
return string.IsNullOrWhiteSpace(home) ? Directory.GetCurrentDirectory() : home;
}
/// <summary>
/// Ersetzt Umgebungsvariablen in beiden gebraeuchlichen Schreibweisen:
/// <c>${NAME}</c> und <c>%NAME%</c>. Unbekannte Namen bleiben stehen -
/// eine leere Ersetzung wuerde stillschweigend einen falschen Pfad
/// ergeben, ein stehengebliebener Platzhalter faellt dagegen auf.
/// </summary>
public static string Expand(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
var builder = new StringBuilder(value.Length);
for (int i = 0; i < value.Length; i++)
{
// ${NAME}
if (value[i] == '$' && i + 1 < value.Length && value[i + 1] == '{')
{
int close = value.IndexOf('}', i + 2);
if (close > i + 2)
{
string name = value.Substring(i + 2, close - i - 2);
string? resolved = Lookup(name);
if (resolved != null)
{
builder.Append(resolved);
i = close;
continue;
}
}
}
// %NAME%
if (value[i] == '%')
{
int close = value.IndexOf('%', i + 1);
if (close > i + 1)
{
string name = value.Substring(i + 1, close - i - 1);
string? resolved = Lookup(name);
if (resolved != null)
{
builder.Append(resolved);
i = close;
continue;
}
}
}
builder.Append(value[i]);
}
return builder.ToString();
}
/// <summary>
/// Schlaegt einen Namen nach. Neben echten Umgebungsvariablen werden
/// die XDG-Namen auch dann beantwortet, wenn sie nicht gesetzt sind -
/// sonst muesste jede setup.json den Rueckfall auf ~/.config selbst
/// ausformulieren.
/// </summary>
private static string? Lookup(string name)
{
string value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(value))
return value;
switch (name.ToUpperInvariant())
{
case "XDG_CONFIG_HOME": return ConfigDirectory();
case "XDG_DATA_HOME": return DataDirectory();
case "HOME":
case "USERPROFILE": return HomeDirectory();
case "APPDATA": return ConfigDirectory();
case "LOCALAPPDATA": return DataDirectory();
default: return null;
}
}
}
}
@@ -12,11 +12,14 @@ namespace Deploymentcenter.Client
/// <summary>Ein zu schreibender Konfigurationswert.</summary>
public sealed class SetupValue
{
public SetupValue(string key, string value, string type = "string")
private readonly bool? _isSecret;
public SetupValue(string key, string value, string type = "string", bool? isSecret = null)
{
Key = key;
Value = value;
Type = type;
_isSecret = isSecret;
}
/// <summary>Schluessel, Ebenen durch Doppelpunkt getrennt.</summary>
@@ -26,6 +29,15 @@ namespace Deploymentcenter.Client
/// <summary>string | secret | url | int | bool | enum</summary>
public string Type { get; }
/// <summary>
/// Ist der Wert schutzbeduerftig? Ohne ausdrueckliche Angabe wird der
/// Typ herangezogen. Ein per <c>source: "provision"</c> geholtes Token
/// traegt haeufig den Typ "string" und muss deshalb gesondert
/// gekennzeichnet werden.
/// </summary>
public bool IsSecret =>
_isSecret ?? string.Equals(Type, "secret", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@@ -137,13 +149,68 @@ namespace Deploymentcenter.Client
/// <summary>Schreibt in das Format, das zum Ziel passt.</summary>
public static void Write(string path, string format, IEnumerable<SetupValue> values)
{
var list = values as IList<SetupValue> ?? new List<SetupValue>(values);
if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase))
{
WriteEnv(path, values);
return;
WriteEnv(path, list);
}
else
{
WriteJson(path, list);
}
WriteJson(path, values);
// Enthaelt die Datei Geheimnisse, gehoert sie niemandem sonst.
foreach (var value in list)
{
if (value.IsSecret)
{
RestrictPermissions(path);
break;
}
}
}
/// <summary>
/// Beschraenkt die Datei auf den eigenen Benutzer.
///
/// Ohne das schreibt .NET mit der Standardmaske - unter Linux
/// ueblicherweise 644, also weltlesbar. In dieser Datei stehen der
/// Lizenzschluessel und das Token der Anwendung; jeder Benutzer des
/// Systems koennte sie lesen. Der Lizenz-Cache in
/// <see cref="StateStore"/> wird aus demselben Grund auf 600 gesetzt.
///
/// Unter Windows erben Dateien in %APPDATA% die Zugriffsrechte des
/// Profils; dort ist nichts zu tun.
/// </summary>
public static void RestrictPermissions(string path)
{
if (OperatingSystemHelpers.IsWindows())
return;
#if NET8_0_OR_GREATER
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch
{
// Auf Dateisystemen ohne Unix-Rechte schlicht nicht anwendbar.
}
#else
try
{
var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "chmod",
Arguments = $"600 \"{path}\"",
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit();
}
catch { }
#endif
}
/// <summary>
@@ -123,6 +123,17 @@ namespace Deploymentcenter.Client
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
}
// Auch der API-Zweig kann 401 liefern - etwa wenn die
// Lese-Endpunkte spaeter ebenfalls hinter den Zugangsschutz
// wandern. Zuvor wurde das nur auf dem statischen Weg erkannt
// und hier als gewoehnlicher HTTP-Fehler gemeldet.
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
result.Unauthorized = true;
result.Message = ReleaseCredentials.DescribeUnauthorized(credentials);
return result;
}
if (!response.IsSuccessStatusCode)
{
result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}";
@@ -327,6 +338,15 @@ namespace Deploymentcenter.Client
/// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag
/// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien.
/// </param>
/// <param name="requireSignature">
/// Reicht <c>--require-signature</c> an den Agenten durch. Ohne diesen
/// Parameter gab es dafuer nur den Kommandozeilenschalter - jede
/// Anwendung, die dem Leitfaden folgt und ueber diese Methode
/// startet, aktualisierte damit ungeprueft, waehrend derselbe Aufruf
/// von Hand auf der Kommandozeile geschuetzt gewesen waere. Den
/// oeffentlichen Schluessel muss dafuer niemand selbst verwalten: der
/// Agent holt und cached ihn ohnehin von <c>/api/updateservice/v1/pubkey</c>.
/// </param>
public static bool LaunchUpdateAgent(
string agentPath,
string projectId,
@@ -339,7 +359,9 @@ namespace Deploymentcenter.Client
string? currentVersion = null,
string? platform = null,
bool waitForCurrentProcess = true,
string? licenseKey = null)
string? licenseKey = null,
int waitTimeoutSeconds = 60,
bool requireSignature = false)
{
if (!File.Exists(agentPath))
{
@@ -361,17 +383,37 @@ namespace Deploymentcenter.Client
AppendArg(args, "--current-version", currentVersion!);
}
// Ohne den Schluessel kommt der Agent nicht mehr an die Pakete -
// die Release-Ablage verlangt seit Version 2.4 Zugangsdaten.
// Der Lizenzschluessel wird BEWUSST NICHT als Argument uebergeben.
//
// 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 - fuer einen Lizenzschluessel
// gilt dasselbe.
//
// Stattdessen wird die Variable auf dem eigenen Prozess gesetzt;
// der Agent erbt den Umgebungsblock beim Start und liest sie dort.
// Das funktioniert auch mit UseShellExecute=true, wo sich
// ProcessStartInfo.Environment nicht setzen laesst.
bool licenseKeySet = false;
if (!string.IsNullOrWhiteSpace(licenseKey))
{
AppendArg(args, "--license-key", licenseKey!);
Environment.SetEnvironmentVariable("DC_LICENSE_KEY", licenseKey);
licenseKeySet = true;
}
if (waitForCurrentProcess)
{
AppendArg(args, "--wait-for-pid",
Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
// Ohne diesen Wert galten fest 60 Sekunden. Eine Anwendung,
// die allein fuer host.StopAsync 30 Sekunden braucht, kommt
// damit gefaehrlich nah an die Grenze - und laeuft sie ab,
// bricht der Agent ab, statt ueber gesperrte Dateien zu
// schreiben. Sicher, aber eben auch kein Update.
AppendArg(args, "--wait-timeout",
Math.Max(5, waitTimeoutSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture));
}
// Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei.
@@ -386,6 +428,11 @@ namespace Deploymentcenter.Client
AppendArg(args, "--restart", restart!);
}
if (requireSignature)
{
AppendFlag(args, "--require-signature");
}
var startInfo = new ProcessStartInfo
{
FileName = agentPath,
@@ -394,10 +441,31 @@ namespace Deploymentcenter.Client
UseShellExecute = true
};
Process.Start(startInfo);
try
{
Process.Start(startInfo);
}
finally
{
// Nach dem Start wieder entfernen: Der Schluessel soll nur so
// lange im eigenen Umgebungsblock stehen, wie er zum Vererben
// gebraucht wird.
if (licenseKeySet)
{
Environment.SetEnvironmentVariable("DC_LICENSE_KEY", null);
}
}
if (exitCurrentApp)
{
// Environment.Exit beendet den Prozess sofort: laufende
// finally-Bloecke anderer Threads, IHostApplicationLifetime
// und Destruktoren kommen nicht mehr zum Zug. Fuer eine
// Anwendung mit offenem Zustand - Positionen, Transaktionen,
// ungeschriebene Puffer - ist das die falsche Wahl. Dort
// gehoert exitCurrentApp:false gesetzt und danach der eigene
// geordnete Weg gegangen; der Agent wartet ohnehin auf das
// Prozessende.
Environment.Exit(0);
}
@@ -415,6 +483,12 @@ namespace Deploymentcenter.Client
builder.Append("\" ");
}
private static void AppendFlag(StringBuilder builder, string name)
{
builder.Append(name);
builder.Append(' ');
}
private static string? GetCurrentExecutablePath()
{
try
@@ -11,7 +11,6 @@
<ItemGroup>
<PackageReference Include="FluentFTP" Version="54.1.2" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
@@ -9,6 +9,7 @@ using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
@@ -266,7 +267,10 @@ namespace Deploymentcenter.Packager
}
}
WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config);
if (!CheckForSecrets(filteredFiles, publishDir, config, HasFlag(args, "--allow-secrets")))
{
return 1;
}
// ---------------------------------------------------------------
// Paket bauen
@@ -798,6 +802,197 @@ namespace Deploymentcenter.Packager
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
/// beim Update die Konfiguration jedes Zielsystems.
/// </summary>
/// <summary>
/// Prueft die zu packenden Dateien auf Zugangsdaten und bricht ab,
/// wenn welche gefunden werden.
///
/// Frueher wurde nur gewarnt. Das hat nicht gereicht: Ein echter
/// OpenRouter-Schluessel lag dadurch in einem oeffentlich abrufbaren
/// Paket. Der Zugangsschutz macht das nicht harmlos - dann ist er eben
/// fuer jeden Lizenznehmer lesbar statt fuer alle.
///
/// Zwei Stufen: der Dateiname (eine appsettings.Local.json gehoert
/// nicht ins Paket) und der Inhalt (ein gefuelltes Password= oder ein
/// Schluessel im Format sk-... verraet sich selbst). Der zweite Teil
/// findet auch Dateien, deren Name unverdaechtig ist.
///
/// Rueckgabe false bedeutet: nicht weitermachen.
/// </summary>
static bool CheckForSecrets(List<string> files, string publishDir, PackagerConfig config, bool allowSecrets)
{
var byName = new List<string>();
var byContent = new List<(string Path, string Reason)>();
string[] suspiciousNames =
{
"appsettings.Local.json", "appsettings.*.Local.json", "*.local.json",
"secrets.json", "connectionstrings.json",
"master.key", "*.pfx", "*.key", "*.pem", "*.p12",
"server_settings.xml", "*.db", "*.sqlite", "*.sqlite3"
};
foreach (var file in files)
{
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
if (GlobMatcher.IsMatch(relPath, suspiciousNames))
{
byName.Add(relPath);
continue;
}
string? reason = FindSecretInContent(file);
if (reason != null)
{
byContent.Add((relPath, reason));
}
}
if (byName.Count == 0 && byContent.Count == 0)
{
return true;
}
Console.ForegroundColor = allowSecrets ? ConsoleColor.Yellow : ConsoleColor.Red;
Console.WriteLine();
Console.WriteLine($"[{(allowSecrets ? "WARNUNG" : "ABBRUCH")}] Im Paket liegen mutmasslich Zugangsdaten:");
foreach (var name in byName)
{
Console.WriteLine($" - {name} (Dateiname)");
}
foreach (var (path, reason) in byContent)
{
Console.WriteLine($" - {path} ({reason})");
}
Console.WriteLine();
Console.WriteLine(" Release-Pakete sind fuer jeden Lizenznehmer lesbar. Was hier steht,");
Console.WriteLine(" ist damit an alle Kunden ausgeliefert.");
Console.WriteLine();
Console.WriteLine(" - Gehoert die Datei gar nicht ins Paket: in excludePatterns aufnehmen");
Console.WriteLine(" und pruefen, warum sie im Publish-Verzeichnis liegt (haeufig eine");
Console.WriteLine(" CopyToOutputDirectory-Regel in der csproj).");
Console.WriteLine(" - Ist es eine Vorlage: die echten Werte durch Platzhalter ersetzen");
Console.WriteLine(" und sie in preservePatterns aufnehmen.");
if (!allowSecrets)
{
Console.WriteLine();
Console.WriteLine(" Bewusst gewollt? --allow-secrets");
}
Console.ResetColor();
Console.WriteLine();
return allowSecrets;
}
/// <summary>
/// Sucht in einer Textdatei nach Werten, die wie Zugangsdaten
/// aussehen. Binaerdateien und grosse Dateien werden uebersprungen -
/// dort waere die Trefferquote schlecht und die Laufzeit hoch.
/// </summary>
static string? FindSecretInContent(string path)
{
try
{
var info = new FileInfo(path);
if (info.Length == 0 || info.Length > 512 * 1024)
return null;
string extension = Path.GetExtension(path).ToLowerInvariant();
string[] textLike = { ".json", ".xml", ".config", ".ini", ".yml", ".yaml", ".env", ".txt", ".toml" };
if (Array.IndexOf(textLike, extension) < 0 && extension.Length != 0)
return null;
string content = File.ReadAllText(path);
// Gefuelltes Passwortfeld - "Password=" allein ist eine leere
// Vorlage und damit in Ordnung.
var password = Regex.Match(content,
@"(?i)\b(password|passwort|pwd)\s*[=:]\s*""?([^""\s;,}]{4,})",
RegexOptions.CultureInvariant);
if (password.Success && !LooksLikePlaceholder(password.Groups[2].Value))
{
return "gefuelltes Passwortfeld";
}
// Verbreitete Schluesselformate.
//
// Auch hier wird der Treffer gegen die Platzhalterliste
// gehalten. Ohne das blockiert "sk-DEIN-SCHLUESSEL-HIER" in
// einer Vorlage jeden Build - und wer das erlebt, setzt
// --allow-secrets kuenftig gewohnheitsmaessig. Dann haette die
// Sperre genau nichts gebracht.
foreach (var (pattern, label) in new[]
{
(@"\bsk-[A-Za-z0-9_\-]{16,}", "API-Schluessel (sk-...)"),
(@"\bghp_[A-Za-z0-9]{20,}", "GitHub-Token"),
(@"\bdc_(master|sub|setup)_[0-9a-f]{16,}", "Deploymentcenter-Token"),
(@"\bAKIA[0-9A-Z]{16}\b", "AWS-Zugriffsschluessel"),
(@"-----BEGIN [A-Z ]*PRIVATE KEY-----", "privater Schluessel")
})
{
foreach (Match match in Regex.Matches(content, pattern, RegexOptions.CultureInvariant))
{
if (!LooksLikePlaceholder(match.Value))
{
return label;
}
}
}
return null;
}
catch
{
// Nicht lesbar heisst nicht verdaechtig.
return null;
}
}
/// <summary>
/// Erkennt Vorlagenwerte, damit eine ausgelieferte Musterdatei nicht
/// jeden Build blockiert.
/// </summary>
static bool LooksLikePlaceholder(string value)
{
string lower = value.Trim().ToLowerInvariant();
if (lower.Length == 0)
return true;
string[] markers =
{
"changeme", "change-me", "todo", "xxx", "platzhalter", "placeholder",
"your-", "dein-", "deine-", "<", "beispiel", "example", "secret-here",
"password", "schluessel-hier", "schlüssel-hier", "hier-einfuegen",
"replace-me", "insert-", "dummy", "sample"
};
foreach (var marker in markers)
{
if (lower.Contains(marker))
return true;
}
// Durchgehend Grossbuchstaben mit Bindestrichen und ohne Ziffern
// ist typisch fuer Platzhalter (SK-MEIN-SCHLUESSEL), aber nicht
// fuer echte Schluessel - die tragen praktisch immer Ziffern.
string body = value.Trim();
if (body.Length > 4 && body == body.ToUpperInvariant() && !body.Any(char.IsDigit))
{
return true;
}
return false;
}
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
{
string[] suspicious =
@@ -952,6 +1147,7 @@ namespace Deploymentcenter.Packager
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
Console.WriteLine(" --allow-secrets Trotz gefundener Zugangsdaten veroeffentlichen");
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
Console.WriteLine(" --token <token> Token mit updateservice:publish");
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");
@@ -192,9 +192,42 @@ namespace Deploymentcenter.UpdateAgent
}
// ----------------------------------------------------------
// 5. Einrichten
// 5. Laeuft das hier ueberhaupt?
// ----------------------------------------------------------
return await ConfigureAsync(options, client, chosen.Slug, interactive: true);
// Erst jetzt zu pruefen hat einen Grund: was die Anwendung
// braucht, steht in ihrer runtimeconfig.json - und die liegt erst
// vor, wenn die Dateien da sind. Vorher waere jede Aussage
// geraten, und ein eigenstaendig veroeffentlichtes Paket bekaeme
// eine Warnung, die auf es nicht zutrifft.
var runtime = RuntimeCheck.Inspect(targetDir);
bool runtimeOk = RuntimeCheck.Report(runtime);
// ----------------------------------------------------------
// 6. Einrichten
// ----------------------------------------------------------
int configured = await ConfigureAsync(options, client, chosen.Slug, interactive: true);
if (configured != 0)
{
return configured;
}
// ----------------------------------------------------------
// 7. Ueberwachung dieser Maschine
// ----------------------------------------------------------
await Monitoring.OfferAsync(options, client, chosen.Slug, interactive: true);
// Zum Schluss noch einmal: zwischen der Meldung oben und dem Ende
// des Ablaufs liegen inzwischen Einrichtung und Ueberwachung, und
// eine fehlende Laufzeit ist das Einzige, was den Start dieser
// Anwendung sicher verhindert.
if (!runtimeOk)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold yellow]Nicht vergessen: ohne die fehlende .NET-Laufzeit startet "
+ "die Anwendung nicht.[/]");
}
return 0;
}
/// <summary>
@@ -300,18 +333,27 @@ namespace Deploymentcenter.UpdateAgent
{
var forTarget = resolved
.Where(pair => BelongsToTarget(pair.Field, target, targets))
.Select(pair => new SetupValue(pair.Field.Key, pair.Value, pair.Field.Type))
// IsSecret ausdruecklich weiterreichen: ein per provision
// geholtes Token traegt oft den Typ "string", muss die
// Datei aber trotzdem auf den eigenen Benutzer beschraenken.
.Select(pair => new SetupValue(
pair.Field.Key, pair.Value, pair.Field.Type, pair.Field.IsSecret))
.ToList();
if (forTarget.Count == 0)
continue;
string path = Path.Combine(options.TargetDir, target.File.Replace('/', Path.DirectorySeparatorChar));
string path = SetupPaths.Resolve(target, options.TargetDir);
try
{
SetupWriter.Write(path, target.Format, forTarget);
AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(target.File)} "
// Den aufgeloesten Pfad ausgeben, nicht die Angabe aus der
// setup.json: Bei location=config entscheidet die Umgebung,
// und wer den Installer unter einem anderen Konto startet
// als den Dienst, muss das sehen koennen.
AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(path)} "
+ $"[grey]({forTarget.Count} Wert(e))[/]");
}
catch (Exception ex)
@@ -360,8 +402,9 @@ namespace Deploymentcenter.UpdateAgent
var target = FindTarget(field, targets);
if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase))
{
string path = Path.Combine(options.TargetDir,
target.File.Replace('/', Path.DirectorySeparatorChar));
// Muss denselben Pfad ergeben wie beim Schreiben - sonst
// prueft der Installer eine andere Datei, als er anlegt.
string path = SetupPaths.Resolve(target, options.TargetDir);
string? existing = SetupWriter.ReadExistingJson(path, field.Key);
@@ -0,0 +1,566 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Deploymentcenter.Client;
using Spectre.Console;
namespace Deploymentcenter.UpdateAgent
{
/// <summary>
/// Richtet die Ueberwachung der Maschine ein, auf der gerade installiert
/// wurde.
///
/// Bisher war das ein Weg ueber die Oberflaeche an einem anderen Rechner:
/// Monitor anlegen, Token erzeugen, Skript herunterladen, auf den Host
/// kopieren, einplanen. Fuenf Schritte fuer etwas, das genau dann ansteht,
/// wenn ohnehin jemand auf dem Zielsystem sitzt - entsprechend oft
/// unterblieb es, und die frisch installierte Anwendung lief auf einem
/// unbeobachteten Host.
///
/// Hier sind es zwei Fragen: Name und ob eingeplant werden soll. Alles
/// andere - Token, Monitor, Skript, Cron-Eintrag bzw. geplante Aufgabe -
/// entsteht daraus.
/// </summary>
internal static class Monitoring
{
/// <summary>
/// Bietet die Einrichtung an und fuehrt sie durch.
///
/// Rueckgabe false heisst nur "nicht eingerichtet" - nie, dass die
/// Installation gescheitert waere. Eine fehlende Ueberwachung darf
/// eine funktionierende Anwendung nicht in Frage stellen.
/// </summary>
public static async Task<bool> OfferAsync(
AgentOptions options,
SetupClient client,
string project,
bool interactive)
{
if (!interactive)
{
return false;
}
AnsiConsole.WriteLine();
AnsiConsole.Write(new Rule("[bold]Ueberwachung[/]").LeftJustified());
AnsiConsole.MarkupLine("[grey]Ein kleines Skript meldet dem Deploymentcenter minuetlich, dass diese "
+ "Maschine laeuft, und schickt Last, Speicher und Plattenbelegung mit.[/]");
if (!AnsiConsole.Confirm("Diese Maschine ueberwachen?"))
{
AnsiConsole.MarkupLine("[grey]Uebersprungen. Nachholbar mit [bold]--action monitor[/].[/]");
return false;
}
string suggested = SuggestName();
string source = AnsiConsole.Prompt(
new TextPrompt<string>("[bold]Name im Dashboard[/]:")
.DefaultValue(suggested)
.Validate(value => value.Trim().Length >= 2
? ValidationResult.Success()
: ValidationResult.Error("[red]Bitte einen Namen mit mindestens zwei Zeichen.[/]")));
string os = OperatingSystem.IsWindows() ? "windows" : "linux";
WatchdogAgent agent;
try
{
agent = await client.RequestWatchdogAgentAsync(project, source, os);
}
catch (SetupException ex) when (ex.Code == "monitor_exists")
{
// Denselben Namen zweimal zu vergeben ist bei einer
// Neuinstallation derselben Maschine der Normalfall - bei zwei
// verschiedenen Maschinen dagegen der Anfang einer Historie,
// die zwei Rechner vermischt. Das kann nur entscheiden, wer
// davorsitzt.
AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(ex.Message)}[/]");
if (!AnsiConsole.Confirm("Ist das dieselbe Maschine? Dann den bestehenden Monitor uebernehmen", false))
{
AnsiConsole.MarkupLine("[grey]Ueberwachung nicht eingerichtet.[/]");
return false;
}
try
{
agent = await client.RequestWatchdogAgentAsync(project, source, os, overwrite: true);
}
catch (SetupException retry)
{
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(retry.Message)}[/]");
return false;
}
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(ex.Message)}[/]");
return false;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(ex.Message)}[/]");
return false;
}
string? scriptPath = WriteScript(agent);
if (scriptPath == null)
{
return false;
}
AnsiConsole.MarkupLine($"[green]Abgelegt:[/] {Markup.Escape(scriptPath)}");
AnsiConsole.MarkupLine($"[grey]Monitor \"{Markup.Escape(agent.Source)}\" ist angelegt, das Token steckt "
+ "im Skript.[/]");
// Ein erster Heartbeat beantwortet sofort, ob Token, Adresse und
// Werkzeuge auf dieser Maschine zusammenpassen. Ohne ihn faellt ein
// Fehler erst auf, wenn der Monitor nach Minuten auf "down" geht -
// und dann sieht es aus wie ein Ausfall, nicht wie ein Tippfehler.
RunOnce(scriptPath);
OfferSchedule(agent, scriptPath);
return true;
}
/// <summary>
/// Eigenstaendiger Weg fuer eine Maschine, auf der nichts installiert
/// werden soll - etwa den Hypervisor unter den Anwendungen.
/// </summary>
public static async Task<int> RunStandaloneAsync(AgentOptions options, System.Net.Http.HttpClient http)
{
AnsiConsole.Write(new FigletText("Monitor").LeftJustified().Color(Color.DodgerBlue1));
string baseUrl = AnsiConsole.Prompt(
new TextPrompt<string>("[bold]Deploymentcenter[/]:")
.DefaultValue(options.BaseUrl)
.Validate(value => value.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? ValidationResult.Success()
: ValidationResult.Error("[red]Bitte eine vollstaendige Adresse angeben, mit https:// davor.[/]")));
options.BaseUrl = baseUrl.TrimEnd('/');
var client = new SetupClient(options.BaseUrl, http);
string username = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Benutzer[/]:"));
string password = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Passwort[/]:").Secret());
try
{
await client.LoginAsync(username, password, Environment.MachineName);
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]");
return 1;
}
catch (System.Net.Http.HttpRequestException ex)
{
AnsiConsole.MarkupLine($"[red]Keine Verbindung zum Deploymentcenter: {Markup.Escape(ex.Message)}[/]");
return 1;
}
// Das Token haengt am Produkt - auch ein reiner Host-Monitor
// braucht also eines. Welches, sagt entweder --project oder die
// Auswahl aus dem Katalog.
string project = options.Project;
if (string.IsNullOrWhiteSpace(project) || project == "myapp")
{
List<CatalogEntry> catalog;
try
{
catalog = await client.GetCatalogAsync(options.Platform);
}
catch (SetupException ex)
{
AnsiConsole.MarkupLine($"[red]Katalog nicht abrufbar: {Markup.Escape(ex.Message)}[/]");
return 1;
}
if (catalog.Count == 0)
{
AnsiConsole.MarkupLine("[red]Es ist kein Produkt hinterlegt, an das sich der Monitor haengen "
+ "liesse. Mit [bold]--project <slug>[/] eines angeben.[/]");
return 1;
}
var chosen = AnsiConsole.Prompt(
new SelectionPrompt<CatalogEntry>()
.Title("\n[bold]Zu welchem Produkt gehoert diese Maschine?[/]")
.PageSize(12)
.UseConverter(entry => Markup.Escape(entry.Name))
.AddChoices(catalog));
project = chosen.Slug;
}
return await OfferAsync(options, client, project, interactive: true) ? 0 : 1;
}
// ------------------------------------------------------------------
/// <summary>
/// Vorschlag fuer den Namen: der Rechnername, wie ihn auch jeder
/// andere sieht, der auf die Maschine schaut.
/// </summary>
private static string SuggestName()
{
try
{
string name = Environment.MachineName.Trim();
return name.Length > 0 ? name : "host";
}
catch
{
return "host";
}
}
/// <summary>
/// Legt das Skript ab - moeglichst systemweit, sonst beim Benutzer.
/// Rueckgabe null heisst: nirgends schreibbar.
/// </summary>
private static string? WriteScript(WatchdogAgent agent)
{
foreach (string directory in CandidateDirectories())
{
try
{
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, agent.FileName);
// Zeilenenden passend zum Ziel: ein Shell-Skript mit CRLF
// scheitert unter Linux an "\r: Datei oder Verzeichnis
// nicht gefunden" - eine Meldung, die niemand mit
// Zeilenenden in Verbindung bringt.
string script = agent.Os == "windows"
? agent.Script.Replace("\n", "\r\n")
: agent.Script.Replace("\r\n", "\n");
File.WriteAllText(path, script);
Protect(path, agent.Os);
return path;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(directory)}: {Markup.Escape(ex.Message)}[/]");
}
}
AnsiConsole.MarkupLine("[red]Das Skript liess sich nirgends ablegen.[/]");
return null;
}
/// <summary>
/// Bevorzugt ein systemweiter Ort - der Cron des Systems kommt an das
/// Heimatverzeichnis eines Benutzers nicht zwangslaeufig heran.
/// </summary>
private static IEnumerable<string> CandidateDirectories()
{
if (OperatingSystem.IsWindows())
{
string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (programData.Length > 0)
{
yield return Path.Combine(programData, "Deploymentcenter", "watchdog");
}
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (localAppData.Length > 0)
{
yield return Path.Combine(localAppData, "Deploymentcenter", "watchdog");
}
yield break;
}
yield return "/opt/deploymentcenter/watchdog";
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (home.Length > 0)
{
yield return Path.Combine(home, ".deploymentcenter", "watchdog");
}
}
/// <summary>
/// Im Skript steht ein Token. Unter Unix bleibt die Datei deshalb dem
/// Eigentuemer vorbehalten - und ausfuehrbar, sonst startet der Cron
/// sie nicht.
/// </summary>
private static void Protect(string path, string os)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
File.SetUnixFileMode(path,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]Dateirechte nicht gesetzt: {Markup.Escape(ex.Message)}[/] "
+ "[grey]Bitte selbst auf 0700 setzen - die Datei enthaelt ein Token.[/]");
}
}
/// <summary>Fuehrt das Skript einmal aus und zeigt, was es sagt.</summary>
private static void RunOnce(string scriptPath)
{
AnsiConsole.MarkupLine("[grey]Sende einen ersten Heartbeat ...[/]");
var startInfo = new ProcessStartInfo
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (OperatingSystem.IsWindows())
{
startInfo.FileName = "powershell";
startInfo.ArgumentList.Add("-NoProfile");
startInfo.ArgumentList.Add("-ExecutionPolicy");
startInfo.ArgumentList.Add("Bypass");
startInfo.ArgumentList.Add("-File");
startInfo.ArgumentList.Add(scriptPath);
}
else
{
startInfo.FileName = "/bin/bash";
startInfo.ArgumentList.Add(scriptPath);
}
try
{
using var process = Process.Start(startInfo);
if (process == null)
{
AnsiConsole.MarkupLine("[yellow]Der Testlauf liess sich nicht starten.[/]");
return;
}
string output = process.StandardOutput.ReadToEnd().Trim();
string error = process.StandardError.ReadToEnd().Trim();
if (!process.WaitForExit(30000))
{
try { process.Kill(entireProcessTree: true); } catch { }
AnsiConsole.MarkupLine("[yellow]Der Testlauf hat nicht geantwortet.[/]");
return;
}
if (process.ExitCode == 0)
{
AnsiConsole.MarkupLine($"[green]{Markup.Escape(output.Length > 0 ? output : "Heartbeat gesendet.")}[/]");
return;
}
AnsiConsole.MarkupLine("[yellow]Der erste Heartbeat kam nicht durch:[/]");
foreach (string line in new[] { output, error })
{
if (line.Length > 0)
{
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(line)}[/]");
}
}
AnsiConsole.MarkupLine("[grey]Das Skript liegt bereits am Ziel - es laesst sich von Hand "
+ "nachvollziehen.[/]");
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]Testlauf nicht moeglich: {Markup.Escape(ex.Message)}[/]");
}
}
/// <summary>
/// Traegt das Skript in Cron bzw. die Aufgabenplanung ein - auf
/// Nachfrage. Klappt das nicht, bleibt der Befehl sichtbar stehen.
/// </summary>
private static void OfferSchedule(WatchdogAgent agent, string scriptPath)
{
string command = BuildScheduleCommand(agent, scriptPath);
AnsiConsole.WriteLine();
if (!AnsiConsole.Confirm($"Minuetlich ausfuehren lassen?"))
{
AnsiConsole.MarkupLine("[grey]Nicht eingeplant. Ohne regelmaessigen Lauf faellt der Monitor "
+ "nach kurzer Zeit auf \"down\". Von Hand:[/]");
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(command)}[/]");
return;
}
if (Schedule(agent, scriptPath, out string problem))
{
AnsiConsole.MarkupLine("[green]Eingeplant.[/] [grey]Der Monitor sollte innerhalb einer Minute "
+ "gruen werden.[/]");
return;
}
AnsiConsole.MarkupLine($"[yellow]Automatisch nicht eingerichtet: {Markup.Escape(problem)}[/]");
AnsiConsole.MarkupLine("[grey]Von Hand - unter Windows in einer Eingabeaufforderung mit "
+ "Administratorrechten:[/]");
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(command)}[/]");
}
/// <summary>
/// Der Einplanungsbefehl mit eingesetztem Pfad. Die Vorlage kommt vom
/// Server, damit Oberflaeche und Installer dasselbe zeigen.
/// </summary>
private static string BuildScheduleCommand(WatchdogAgent agent, string scriptPath)
{
string quoted = scriptPath.Contains(' ') ? "\"" + scriptPath + "\"" : scriptPath;
return agent.Schedule.Length > 0
? agent.Schedule.Replace("{PFAD}", quoted)
: quoted;
}
private static bool Schedule(WatchdogAgent agent, string scriptPath, out string problem)
{
problem = string.Empty;
try
{
return OperatingSystem.IsWindows()
? ScheduleWindows(agent, scriptPath, out problem)
: ScheduleCron(agent, scriptPath, out problem);
}
catch (Exception ex)
{
problem = ex.Message;
return false;
}
}
/// <summary>
/// Traegt eine Zeile in die Crontab des aufrufenden Benutzers ein.
///
/// Bestehende Zeilen zu diesem Skript fallen dabei weg - sonst
/// sammelt jede Neuinstallation einen weiteren Eintrag an, und die
/// Maschine schickt am Ende drei Heartbeats pro Minute.
/// </summary>
private static bool ScheduleCron(WatchdogAgent agent, string scriptPath, out string problem)
{
problem = string.Empty;
string line = BuildScheduleCommand(agent, scriptPath);
// Einfache Anfuehrungszeichen im Pfad wuerden das Shell-Literal
// zerlegen. Sie kommen in Pfaden praktisch nicht vor - wenn doch,
// ist ein ehrlicher Abbruch besser als ein zerschossener Crontab.
if (scriptPath.Contains('\'') || line.Contains('\''))
{
problem = "Der Pfad enthaelt ein Anfuehrungszeichen.";
return false;
}
string script =
"(crontab -l 2>/dev/null | grep -Fv '" + scriptPath + "'; " +
"echo '" + line + "') | crontab -";
var startInfo = new ProcessStartInfo
{
FileName = "/bin/sh",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("-c");
startInfo.ArgumentList.Add(script);
using var process = Process.Start(startInfo);
if (process == null)
{
problem = "crontab liess sich nicht aufrufen.";
return false;
}
string error = process.StandardError.ReadToEnd().Trim();
process.WaitForExit(15000);
if (process.ExitCode == 0)
{
return true;
}
problem = error.Length > 0 ? error : $"crontab endete mit Code {process.ExitCode}.";
return false;
}
/// <summary>
/// Legt eine geplante Aufgabe an. /F ueberschreibt eine gleichnamige -
/// eine zweite Installation soll keine zweite Aufgabe hinterlassen.
/// </summary>
private static bool ScheduleWindows(WatchdogAgent agent, string scriptPath, out string problem)
{
problem = string.Empty;
var startInfo = new ProcessStartInfo
{
FileName = "schtasks",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("/Create");
startInfo.ArgumentList.Add("/SC");
startInfo.ArgumentList.Add("MINUTE");
startInfo.ArgumentList.Add("/MO");
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add("/TN");
startInfo.ArgumentList.Add("Deploymentcenter Watchdog " + agent.Source);
startInfo.ArgumentList.Add("/TR");
startInfo.ArgumentList.Add(
"powershell -NoProfile -ExecutionPolicy Bypass -File \"" + scriptPath + "\"");
startInfo.ArgumentList.Add("/F");
using var process = Process.Start(startInfo);
if (process == null)
{
problem = "schtasks liess sich nicht aufrufen.";
return false;
}
string output = process.StandardOutput.ReadToEnd().Trim();
string error = process.StandardError.ReadToEnd().Trim();
process.WaitForExit(15000);
if (process.ExitCode == 0)
{
return true;
}
problem = error.Length > 0
? error
: (output.Length > 0 ? output : $"schtasks endete mit Code {process.ExitCode}.");
return false;
}
}
}
@@ -73,10 +73,13 @@ namespace Deploymentcenter.UpdateAgent
// Betrieb ist das der Lizenzschluessel der Anwendung; bei
// einer Erstinstallation gibt es den noch nicht, dort treten
// die Zugangsdaten des Installationskontos an seine Stelle.
Credentials = ReleaseCredentials.FromLicenseKey(GetArg(args, "--license-key"))
?? ReleaseCredentials.FromUser(
GetArg(args, "--download-user"),
GetArg(args, "--download-password"))
//
// Umgebungsvariablen haben Vorrang vor den Argumenten: 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 - fuer einen
// Lizenzschluessel gilt nichts anderes.
Credentials = ReadCredentials(args)
};
PackageManifest? installedManifest = ReadInstalledManifest(targetDir);
@@ -129,6 +132,13 @@ namespace Deploymentcenter.UpdateAgent
case "configure":
return await Installation.ConfigureOnlyAsync(options, HttpClient);
case "monitor":
// Fuer Maschinen, auf denen nichts zu installieren ist -
// der Hypervisor unter den Anwendungen etwa - und zum
// Nachholen, wenn die Frage bei der Installation verneint
// wurde.
return await Monitoring.RunStandaloneAsync(options, HttpClient);
default:
return ShowHelp();
}
@@ -163,14 +173,23 @@ namespace Deploymentcenter.UpdateAgent
};
AnsiConsole.Write(panel);
ReleaseManifest? releaseManifest = null;
ManifestLookup lookup = new ManifestLookup();
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Lade verfuegbare Releases ...", async ctx =>
{
releaseManifest = await FetchManifestAsync(options);
lookup = await FetchManifestAsync(options);
});
// Der Grund steht schon im Bild - hier noch einmal nach fehlenden
// Releases zu suchen, waere eine zweite, falsche Faehrte.
if (lookup.Unauthorized)
{
return 1;
}
ReleaseManifest? releaseManifest = lookup.Manifest;
if (releaseManifest?.Latest == null)
{
AnsiConsole.MarkupLine("[bold red]Fehler: Es konnte kein Release ermittelt werden.[/]");
@@ -279,7 +298,18 @@ namespace Deploymentcenter.UpdateAgent
static async Task<int> DoList(AgentOptions options)
{
var manifest = await FetchManifestAsync(options);
var lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
// Derselbe Rueckgabewert wie bei DoCheck - wer den Agenten aus
// einem Skript ruft, unterscheidet damit "kein Zugang" von
// "nichts veroeffentlicht".
Console.WriteLine("UNAUTHORIZED");
return 2;
}
var manifest = lookup.Manifest;
if (manifest == null)
{
Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar.");
@@ -308,7 +338,14 @@ namespace Deploymentcenter.UpdateAgent
try
{
var releaseManifest = await FetchManifestAsync(options);
var lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
return 1;
}
var releaseManifest = lookup.Manifest;
if (releaseManifest == null)
{
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]");
@@ -558,18 +595,48 @@ namespace Deploymentcenter.UpdateAgent
/// klappte, sagte die Anwendung "Update verfuegbar" und der Agent "kein
/// Release gefunden". Der Rueckfall auf die API stand nur im SDK.
/// </summary>
static async Task<ReleaseManifest?> FetchManifestAsync(AgentOptions options)
/// <summary>
/// Ergebnis eines Manifest-Abrufs. Ein leeres Manifest wegen 401 ist
/// etwas anderes als ein leeres, weil nichts veroeffentlicht wurde -
/// die Aufrufer sollen dafuer nicht dieselbe Meldung zeigen.
/// </summary>
internal sealed class ManifestLookup
{
var fromStatic = await FetchStaticManifestAsync(options);
if (fromStatic?.Latest != null)
{
return fromStatic;
}
public ReleaseManifest? Manifest { get; init; }
return await FetchApiManifestAsync(options);
/// <summary>Die Ablage hat die Zugangsdaten abgelehnt.</summary>
public bool Unauthorized { get; init; }
}
static async Task<ReleaseManifest?> FetchStaticManifestAsync(AgentOptions options)
static async Task<ManifestLookup> FetchManifestAsync(AgentOptions options)
{
var (fromStatic, unauthorized) = await FetchStaticManifestAsync(options);
if (unauthorized)
{
// Ein 401 ist keine "Datei fehlt"-Lage: die Zugangsdaten
// tragen nicht. Die API wuerde zwar antworten, sie liegt nicht
// hinter dem Zugangsschutz - das Paket selbst aber schon. Der
// Lauf endete also ein paar Schritte spaeter mit genau
// derselben Meldung ein zweites Mal, dazwischen Ausgaben ueber
// Signaturen, die den eigentlichen Grund nach oben aus dem
// Bild schieben.
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return new ManifestLookup { Unauthorized = true };
}
if (fromStatic?.Latest != null)
{
return new ManifestLookup { Manifest = fromStatic };
}
return new ManifestLookup { Manifest = await FetchApiManifestAsync(options) };
}
static async Task<(ReleaseManifest? Manifest, bool Unauthorized)> FetchStaticManifestAsync(AgentOptions options)
{
// Plattformunabhaengige Releases liegen weiterhin im alten Pfad
// ohne Zwischenebene.
@@ -594,15 +661,12 @@ namespace Deploymentcenter.UpdateAgent
var resp = await HttpClient.SendAsync(request);
// Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt
// nicht mehr. Weiterzuprobieren wuerde die Ursache nur
// hinter einer allgemeinen Fehlermeldung verstecken.
// Ein 401 wird nach oben durchgereicht, statt hier
// gemeldet zu werden: sonst steht die Meldung schon im
// Bild, waehrend der Aufrufer noch weitersucht.
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return null;
return (null, true);
}
if (!resp.IsSuccessStatusCode)
@@ -630,7 +694,7 @@ namespace Deploymentcenter.UpdateAgent
.FirstOrDefault();
if (manifest.Latest != null)
return manifest;
return (manifest, false);
}
catch
{
@@ -638,7 +702,7 @@ namespace Deploymentcenter.UpdateAgent
}
}
return null;
return (null, false);
}
/// <summary>
@@ -1068,6 +1132,40 @@ namespace Deploymentcenter.UpdateAgent
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
/// <summary>
/// Ermittelt die Zugangsdaten fuer die Release-Ablage.
///
/// Reihenfolge: Umgebungsvariable vor Argument. Ein Geheimnis in argv
/// steht in der Prozessliste und ist damit fuer jeden Benutzer der
/// Maschine sichtbar; die Umgebung eines fremden Prozesses ist unter
/// Linux nur fuer den Eigentuemer und root lesbar.
///
/// Das SDK setzt die Variable vor dem Start auf sich selbst - der
/// Agent erbt sie und der Schluessel taucht nirgends in einer
/// Kommandozeile auf.
/// </summary>
static ReleaseCredentials? ReadCredentials(string[] args)
{
string? licenseKey = Env("DC_LICENSE_KEY") ?? GetArg(args, "--license-key");
var fromLicense = ReleaseCredentials.FromLicenseKey(licenseKey);
if (fromLicense != null)
{
return fromLicense;
}
string? user = Env("DC_DOWNLOAD_USER") ?? GetArg(args, "--download-user");
string? pass = Env("DC_DOWNLOAD_PASSWORD") ?? GetArg(args, "--download-password");
return ReleaseCredentials.FromUser(user, pass);
}
static string? Env(string name)
{
string? value = Environment.GetEnvironmentVariable(name);
return string.IsNullOrWhiteSpace(value) ? null : value;
}
static int ParsePid(string? value)
{
return int.TryParse(value, out int pid) && pid > 0 ? pid : 0;
@@ -1103,8 +1201,8 @@ namespace Deploymentcenter.UpdateAgent
Console.WriteLine();
Console.WriteLine(" --project, -p <slug> Projekt-Slug");
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | check |");
Console.WriteLine(" update | repair | list");
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | monitor |");
Console.WriteLine(" check | update | repair | list");
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
Console.WriteLine(" --platform <rid> Laufzeitkennung (Vorgabe: die des Systems)");
@@ -0,0 +1,451 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using Spectre.Console;
namespace Deploymentcenter.UpdateAgent
{
/// <summary>
/// Prueft, ob die Laufzeit vorhanden ist, die die installierte Anwendung
/// braucht.
///
/// Ohne diese Pruefung endet eine gelungene Installation mit einer
/// Anwendung, die sich nicht starten laesst - und die Fehlersuche beginnt
/// beim Deploymentcenter, weil das der letzte Schritt war, den jemand
/// bewusst getan hat. Die Meldung des Systems ("You must install .NET to
/// run this application") sieht dann jeder, der von Hand startet, aber
/// niemand, der einen Dienst einrichtet.
///
/// Gefragt wird nicht die Anwendung, sondern ihre runtimeconfig.json: dort
/// steht, was sie tatsaechlich erwartet. Das kostet die ausliefernde Seite
/// nichts - die Datei entsteht bei jedem dotnet publish von selbst - und
/// deckt auch Pakete ab, die lange vor dieser Pruefung gebaut wurden.
///
/// Ein eigenstaendig veroeffentlichtes Paket (self-contained) bringt seine
/// Laufzeit mit; dort gibt es nichts zu pruefen und entsprechend nichts zu
/// melden.
/// </summary>
internal static class RuntimeCheck
{
/// <summary>Ein Framework, das die Anwendung erwartet.</summary>
internal sealed class Requirement
{
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
/// <summary>
/// Wie weit die Laufzeit nach oben abweichen darf. Vorgabe von
/// .NET ist "Minor": eine hoehere Nebenversion derselben
/// Hauptversion wird genommen, eine hoehere Hauptversion nicht.
/// </summary>
public string RollForward { get; set; } = "Minor";
}
/// <summary>Was auf dieser Maschine installiert ist.</summary>
internal sealed class InstalledRuntime
{
public string Name { get; set; } = string.Empty;
public Version Version { get; set; } = new Version(0, 0);
}
internal sealed class Result
{
/// <summary>Liess sich ueberhaupt etwas feststellen?</summary>
public bool Inspected { get; set; }
/// <summary>Das Paket bringt seine Laufzeit selbst mit.</summary>
public bool SelfContained { get; set; }
/// <summary>Ist ueberhaupt ein "dotnet" auffindbar?</summary>
public bool DotnetFound { get; set; }
public List<Requirement> Required { get; } = new List<Requirement>();
public List<InstalledRuntime> Installed { get; } = new List<InstalledRuntime>();
/// <summary>Anforderungen, die nichts Installiertes erfuellt.</summary>
public List<Requirement> Missing { get; } = new List<Requirement>();
/// <summary>Nichts zu beanstanden - oder nichts zu pruefen.</summary>
public bool Ok => SelfContained || !Inspected || Missing.Count == 0;
}
/// <summary>
/// Untersucht das Zielverzeichnis und die Maschine.
/// </summary>
public static Result Inspect(string targetDir)
{
var result = new Result();
var requirements = ReadRequirements(targetDir, result);
if (!result.Inspected || result.SelfContained)
{
return result;
}
result.Required.AddRange(requirements);
foreach (var runtime in ListInstalledRuntimes())
{
result.Installed.Add(runtime);
}
result.DotnetFound = result.Installed.Count > 0;
foreach (var requirement in requirements)
{
if (!IsSatisfied(requirement, result.Installed))
{
result.Missing.Add(requirement);
}
}
return result;
}
/// <summary>
/// Gibt das Ergebnis aus. Rueckgabe false heisst: es fehlt etwas -
/// die Anwendung wird so nicht starten.
/// </summary>
public static bool Report(Result result)
{
if (result.SelfContained)
{
AnsiConsole.MarkupLine("[grey]Laufzeit: das Paket bringt sie selbst mit.[/]");
return true;
}
if (!result.Inspected)
{
// Kein Grund zur Beunruhigung: nicht jedes Paket ist eine
// .NET-Anwendung, und ohne runtimeconfig.json gibt es hier
// schlicht nichts zu sagen.
return true;
}
if (result.Missing.Count == 0)
{
foreach (var requirement in result.Required)
{
var best = BestMatch(requirement, result.Installed);
AnsiConsole.MarkupLine($"[green] Laufzeit vorhanden:[/] "
+ $"{Markup.Escape(requirement.Name)} {Markup.Escape(best?.Version.ToString() ?? "?")} "
+ $"[grey](verlangt {Markup.Escape(requirement.Version)})[/]");
}
return true;
}
AnsiConsole.WriteLine();
if (!result.DotnetFound)
{
AnsiConsole.MarkupLine("[bold red]Auf dieser Maschine ist keine .NET-Laufzeit installiert.[/]");
}
else
{
AnsiConsole.MarkupLine("[bold red]Die benoetigte .NET-Laufzeit fehlt.[/]");
string installed = string.Join(", ", result.Installed
.GroupBy(r => r.Name)
.Select(g => $"{g.Key} {string.Join("/", g.Select(r => r.Version.ToString()).Distinct())}"));
AnsiConsole.MarkupLine($"[grey]Vorhanden: {Markup.Escape(installed)}[/]");
}
foreach (var missing in result.Missing)
{
AnsiConsole.MarkupLine($"[red]Verlangt: {Markup.Escape(missing.Name)} {Markup.Escape(missing.Version)}[/]");
}
AnsiConsole.MarkupLine("[yellow]Die Anwendung ist vollstaendig installiert, wird sich so aber nicht "
+ "starten lassen.[/]");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Nachzuholen mit:[/]");
foreach (string hint in InstallHints(result.Missing))
{
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(hint)}[/]");
}
// Die Verwechslung ist haeufig genug, um sie hier auszuraeumen:
// zum Ausfuehren genuegt die Laufzeit. Das SDK ist zum Bauen da
// und bringt ein Vielfaches an Umfang mit.
AnsiConsole.MarkupLine("[grey]Zum Ausfuehren genuegt die Laufzeit - das SDK wird nur zum Bauen "
+ "gebraucht.[/]");
return false;
}
// ------------------------------------------------------------------
/// <summary>
/// Liest die Anforderungen aus der runtimeconfig.json im
/// Zielverzeichnis.
/// </summary>
private static List<Requirement> ReadRequirements(string targetDir, Result result)
{
var requirements = new List<Requirement>();
string[] candidates;
try
{
candidates = Directory.GetFiles(targetDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
}
catch
{
return requirements;
}
foreach (string path in candidates)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("runtimeOptions", out var options))
continue;
result.Inspected = true;
// Eigenstaendig veroeffentlicht: die Laufzeit liegt daneben
// im Verzeichnis, es gibt nichts zu installieren.
if (options.TryGetProperty("includedFrameworks", out var included)
&& included.ValueKind == JsonValueKind.Array
&& included.GetArrayLength() > 0)
{
result.SelfContained = true;
return requirements;
}
string rollForward = options.TryGetProperty("rollForward", out var roll)
&& roll.ValueKind == JsonValueKind.String
? (roll.GetString() ?? "Minor")
: "Minor";
// Einzelnes Framework oder eine Liste - beide Schreibweisen
// kommen vor, je nach Projektart und SDK-Fassung.
if (options.TryGetProperty("framework", out var single))
{
AddRequirement(requirements, single, rollForward);
}
if (options.TryGetProperty("frameworks", out var many)
&& many.ValueKind == JsonValueKind.Array)
{
foreach (var entry in many.EnumerateArray())
{
AddRequirement(requirements, entry, rollForward);
}
}
}
catch
{
// Eine unlesbare runtimeconfig.json ist kein Grund, die
// Installation zu bemaengeln - nur einer, hier nichts zu
// behaupten.
}
}
return requirements;
}
private static void AddRequirement(List<Requirement> into, JsonElement element, string rollForward)
{
if (element.ValueKind != JsonValueKind.Object)
return;
string name = element.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
? (n.GetString() ?? string.Empty)
: string.Empty;
string version = element.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String
? (v.GetString() ?? string.Empty)
: string.Empty;
if (name.Length == 0 || version.Length == 0)
return;
if (into.Any(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)))
return;
into.Add(new Requirement { Name = name, Version = version, RollForward = rollForward });
}
/// <summary>
/// Fragt "dotnet --list-runtimes" ab. Fehlt der Befehl, ist die
/// Antwort leer - genau das ist die Auskunft, um die es geht.
/// </summary>
private static List<InstalledRuntime> ListInstalledRuntimes()
{
var found = new List<InstalledRuntime>();
string output;
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "--list-runtimes",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (process == null)
return found;
output = process.StandardOutput.ReadToEnd();
// Ein haengendes dotnet darf die Installation nicht aufhalten.
if (!process.WaitForExit(15000))
{
try { process.Kill(entireProcessTree: true); } catch { }
return found;
}
}
catch
{
// Kein dotnet im Pfad.
return found;
}
// Zeilenform: "Microsoft.AspNetCore.App 8.0.14 [/usr/share/dotnet/shared/...]"
var pattern = new Regex(@"^(\S+)\s+(\d+\.\d+\.\d+\S*)\s+\[", RegexOptions.Compiled);
foreach (string line in output.Split('\n'))
{
var match = pattern.Match(line.Trim());
if (!match.Success)
continue;
if (TryParseVersion(match.Groups[2].Value, out var version))
{
found.Add(new InstalledRuntime { Name = match.Groups[1].Value, Version = version });
}
}
return found;
}
/// <summary>
/// Wird die Anforderung von irgendetwas Installiertem erfuellt?
/// </summary>
private static bool IsSatisfied(Requirement requirement, List<InstalledRuntime> installed)
{
return BestMatch(requirement, installed) != null;
}
private static InstalledRuntime? BestMatch(Requirement requirement, List<InstalledRuntime> installed)
{
if (!TryParseVersion(requirement.Version, out var wanted))
{
// Ohne verwertbare Angabe wird nichts bemaengelt: die Namen
// muessen dann genuegen.
return installed.FirstOrDefault(r =>
string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase));
}
string mode = (requirement.RollForward ?? "Minor").ToLowerInvariant();
var candidates = installed
.Where(r => string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase))
.Where(r => Matches(mode, wanted, r.Version))
.OrderByDescending(r => r.Version)
.ToList();
return candidates.FirstOrDefault();
}
private static bool Matches(string mode, Version wanted, Version have)
{
switch (mode)
{
// Ausdruecklich abgeschaltet: es muss genau diese Fassung sein.
case "disable":
return have == wanted;
// Eine hoehere Hauptversion ist erlaubt.
case "major":
case "latestmajor":
return have >= wanted;
// Vorgabe und alle uebrigen Werte (Minor, LatestMinor,
// LatestPatch): dieselbe Hauptversion, mindestens die
// verlangte Fassung.
default:
return have.Major == wanted.Major && have >= wanted;
}
}
private static bool TryParseVersion(string raw, out Version version)
{
// Vorabversionen ("8.0.0-preview.3") lassen sich nicht als Version
// lesen; der Teil vor dem Bindestrich genuegt hier.
string cleaned = raw.Split('-')[0].Trim();
return Version.TryParse(cleaned, out version!);
}
/// <summary>
/// Was der Mensch davor jetzt tun muss - als Befehl, nicht als
/// Verweis auf eine Downloadseite.
/// </summary>
private static List<string> InstallHints(List<Requirement> missing)
{
var hints = new List<string>();
bool windows = OperatingSystem.IsWindows();
foreach (var requirement in missing)
{
if (!TryParseVersion(requirement.Version, out var version))
continue;
string majorMinor = $"{version.Major}.{version.Minor}";
if (windows)
{
string package = requirement.Name switch
{
"Microsoft.AspNetCore.App" => $"Microsoft.DotNet.AspNetCore.{version.Major}",
"Microsoft.WindowsDesktop.App" => $"Microsoft.DotNet.DesktopRuntime.{version.Major}",
_ => $"Microsoft.DotNet.Runtime.{version.Major}",
};
hints.Add($"winget install --id {package} --source winget");
}
else
{
string package = requirement.Name switch
{
"Microsoft.AspNetCore.App" => $"aspnetcore-runtime-{majorMinor}",
_ => $"dotnet-runtime-{majorMinor}",
};
// Debian und Ubuntu fuehren die Pakete seit 22.04 in den
// eigenen Quellen; wo nicht, hilft der Verweis darunter.
hints.Add($"sudo apt-get install -y {package}");
}
}
if (hints.Count == 0)
{
hints.Add("https://dotnet.microsoft.com/download");
}
else
{
hints.Add("Falls das Paket dort nicht gefuehrt wird: https://dotnet.microsoft.com/download");
}
return hints;
}
}
}
+5 -1
View File
@@ -62,8 +62,12 @@ return [
'bugtracker' => [
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
'self_project' => 'deploymentcenter',
// Reports pro IP und Minute am öffentlichen Ingest-Endpunkt.
// Reports pro IP und Minute an /api/bugtracker/v1/report.
'report_rate' => 60,
// Reports pro IP und Minute an /api/errors/v1/report. Bewusst höher:
// Dort meldet ein globaler Exception-Handler, und ein Fehlerschauer
// erzeugt in Sekunden mehr Meldungen als ein Mensch am Tag.
'error_rate' => 300,
// Wie lange ein Agent ein Item exklusiv beansprucht (Minuten).
'lease_minutes' => 30,
],
+34
View File
@@ -129,6 +129,35 @@ Reagiere auf `code`, nicht auf den Text:
- `401 unauthorized` — Token prüfen, nicht wiederholen
- `409 already_claimed` — nächstes Item nehmen
- `429 rate_limited` — Intervall verdoppeln, später erneut
### Halte fest, gegen welche Fassung du integriert hast
**Pflicht bei jeder Änderung an der Deploymentcenter-Anbindung.** Trage die
Fassung in dieser Datei ein, direkt hier:
```
Deploymentcenter-Anbindung: SDK 2.5.1, integriert am 2026-08-14
```
Der Wert steht in `DeploymentcenterSdk.Version`. Ohne ihn muss beim nächsten
Mal die gesamte Historie gelesen werden.
**Bevor du die Anbindung anfasst,** frag zuerst, was sich seitdem geändert hat:
```
GET /api/updateservice/v1/changelog?since=2.5.1
```
Die Antwort nennt nur die neueren Fassungen, dazu `action_items` (Punkte mit
Handlungsbedarf) und `breaking` (brechende Änderungen). Jeder Eintrag hat ein
Feld `action` mit dem, was konkret zu tun ist — steht dort `null`, ist nichts
zu tun.
Kommt `"count": 0` zurück, bist du auf Stand und musst gar nichts lesen.
Zum Nachschlagen für Menschen: `https://dc.mhdf.de/docs/changelog.php?since=2.5.1`
**Nach getaner Arbeit die Zeile oben auf die neue Fassung setzen.**
```
---
@@ -141,5 +170,10 @@ Melde Fehler und Ideen an https://dc.mhdf.de/api/bugtracker/v1/report
description, error_message, stack_trace, severity, file_path, line_no,
client_ref). Vor der Arbeit an einem Item: POST .../manage?action=next zum
Übernehmen. Danach ?action=resolve mit resolved_in_build.
Deploymentcenter-Anbindung: SDK 2.5.1
Bevor du sie anfasst: GET /api/updateservice/v1/changelog?since=2.5.1 —
zeigt nur, was seitdem dazukam. Danach die Zeile hochsetzen.
Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json
```
+41
View File
@@ -387,6 +387,47 @@ else
}
```
### Wenn Beenden die gefährlichere Option ist
`Environment.Exit(1)` ist die richtige Antwort für ein Werkzeug, das man
einfach nicht mehr benutzen darf. Für ein System, das **offene Verpflichtungen
verwaltet**, ist es die falsche: Ein Handelssystem mit offenen Positionen, eine
Maschinensteuerung im Zyklus, ein Dienst mitten in einer Transaktion — die
dürfen bei einer abgelaufenen Lizenz nicht einfach aufhören. Der Schaden aus
dem abrupten Ende wäre größer als der aus dem Weiterlaufen.
Die brauchbare Antwort ist **Sperrbetrieb statt Abbruch**: Was neue
Verpflichtungen eingeht, wird abgeschaltet; was bestehende abwickelt, läuft
weiter.
```csharp
if (!res.IsValid && !res.IsTransient)
{
logger.Error("Lizenz ungültig: {0}. Wechsle in den Sperrbetrieb.", res.Message);
// Was neue Verpflichtungen eingeht: aus.
strategyEngine.StopOpeningPositions();
scheduler.PauseNewJobs();
// Was bestehende abwickelt: bleibt an.
// - Risikoüberwachung
// - Schließen offener Positionen
// - Ordnungsgemäßes Herunterfahren, wenn nichts mehr offen ist
riskManager.KeepRunning();
notifier.Alert("Lizenz abgelaufen - Sperrbetrieb. Keine neuen Positionen.");
}
```
Wer diesen Weg geht, sollte zwei Dinge festhalten: **wann** aus dem
Sperrbetrieb ein Ende wird (etwa sobald keine Position mehr offen ist), und
**dass der Zustand sichtbar ist** — ein Sperrbetrieb, den niemand bemerkt, ist
ein stiller Ausfall.
Der Offline-Cache federt das übrigens schon ab: Eine kurzzeitig nicht
erreichbare Prüfung führt gar nicht erst hierher, dafür ist `IsTransient` da.
Hier geht es um das echte Urteil.
| Status | `IsTransient` | Bedeutung |
|---|---|---|
| `valid` | | Vom Server bestätigt |
+19
View File
@@ -10,6 +10,8 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen.
| Dokument | Wofür |
|---|---|
| **[UPGRADE.md](./UPGRADE.md)** | **Ablaufplan für die Umstellung auf 2.0.** Enthält Pflichtschritte: Zugangsdaten wechseln, Migration, Evaluator-Cron. |
| **[Änderungen](../public/docs/changelog.json)** | Was sich je Fassung geändert hat und was zu tun ist. Öffentlich unter `/docs/changelog.php`, maschinenlesbar über `GET /api/updateservice/v1/changelog?since=X` |
| **[Release-Anleitung für Agenten](../public/docs/release.md)** | Ein Projekt veröffentlichungsfähig machen: Vorlage kopieren, konfigurieren, ausliefern |
| [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Textbaustein für `CLAUDE.md` / `AGENTS.md` eines Projekts |
| [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` |
@@ -48,6 +50,23 @@ hinterlegte Textblock entfällt damit.
---
## Drosselung
Je IP und Minute. Wird die Grenze überschritten, antwortet der Endpunkt mit
`429 rate_limited` — dann das Intervall verdoppeln und später erneut versuchen,
nicht sofort wiederholen.
| Endpunkt | Grenze | Einstellbar über |
|---|---|---|
| `/api/errors/v1/report` | **300** | `bugtracker.error_rate` |
| `/api/bugtracker/v1/report` | 60 | `bugtracker.report_rate` |
| `/api/license/v1/*` | 120 | fest |
| `/api/updateservice/v1/*` | 240 | fest |
| `/api/setup/v1/login` | 10 | fest — dort werden Passwörter geprüft |
| `/api/tokens/v1/provision` | 20 | fest |
---
## Antwortformat
Alle JSON-Endpunkte antworten einheitlich:
+143
View File
@@ -182,6 +182,91 @@ entpackt sie dann nur und fragt nichts ab.
| `validate` | Regulärer Ausdruck |
| `target` | `id` des Ziels, wenn es mehrere gibt |
### Wohin geschrieben wird
Ein Ziel hat neben `file` eine Ortsangabe:
```json
"targets": [
{ "id": "app", "file": "appsettings.json", "format": "json" },
{ "id": "user", "file": "clawddotnet/Settings.json",
"location": "config", "format": "json" }
]
```
| `location` | Windows | Linux / macOS |
|---|---|---|
| `install` (Vorgabe) | Installationsverzeichnis | Installationsverzeichnis |
| `config` | `%APPDATA%` | `$XDG_CONFIG_HOME`, sonst `~/.config` |
| `data` | `%LOCALAPPDATA%` | `$XDG_DATA_HOME`, sonst `~/.local/share` |
| `home` | Benutzerprofil | `$HOME` |
**Warum das nötig ist:** Eine Anwendung, die sich unter Linux richtig verhält,
legt ihre Konfiguration nicht neben das Programm — `/opt/<app>` ist für den
Dienstbenutzer typischerweise nicht schreibbar. Ohne Ortsangabe schriebe der
Installer die Datei dorthin, wo die Anwendung nie nachsieht.
In `file` sind Umgebungsvariablen in beiden Schreibweisen erlaubt:
`${XDG_CONFIG_HOME}/app/Settings.json` und `%APPDATA%\app\Settings.json`. Ein
absoluter Pfad wird unverändert benutzt. Unbekannte Variablen bleiben stehen,
statt zu einer leeren Zeichenkette zu werden — ein stehengebliebener
Platzhalter fällt auf, ein stillschweigend falscher Pfad nicht.
> **Wer den Installer startet, entscheidet mit.** `config`, `data` und `home`
> beziehen sich auf das Konto, unter dem der Installer läuft. Wird er als
> `root` oder Administrator gestartet, der Dienst aber unter einem eigenen
> Konto betrieben, landet die Datei im falschen Profil. Der Installer gibt
> deshalb den **aufgelösten Pfad** aus — prüfe ihn. Für Dienste ist ein
> absoluter Pfad oft die ehrlichere Angabe.
#### Wenn die Schreibweise plattformabhängig ist
Konfigurationsordner heißen nicht überall gleich: `%APPDATA%\MeineAnwendung`
ist unter Windows üblich, `$XDG_CONFIG_HOME/meineanwendung` unter Linux klein.
Meistens genügt es, die **kleingeschriebene Form** anzugeben. Sie trifft unter
Linux exakt und unter Windows ebenfalls, weil NTFS die Schreibweise ignoriert.
Andersherum funktioniert es nicht — unter Linux entstünde ein zweites, leeres
Verzeichnis neben dem, aus dem die Anwendung liest.
Dieser Kniff trägt aber nur, solange das Dateisystem wirklich unempfindlich
ist. Auf APFS mit Beachtung der Schreibweise, auf einem per `fsutil`
umgestellten NTFS-Ordner oder wenn sich die Namen um **mehr als die
Schreibweise** unterscheiden, braucht es eine eigene Angabe:
```json
{
"id": "user",
"file": "meineanwendung/Settings.json",
"fileWindows": "MeineAnwendung/Settings.json",
"location": "config"
}
```
`fileLinux` und `fileMacOS` gibt es ebenso. Ohne Angabe gilt `file`.
### Dateirechte
Enthält ein Ziel mindestens einen als geheim gekennzeichneten Wert, wird die
geschriebene Datei auf den eigenen Benutzer beschränkt (`600` unter Linux und
macOS). Ohne das schriebe .NET mit der Standardmaske, unter Linux üblicherweise
`644` — und in dieser Datei stehen Lizenzschlüssel und Anwendungstoken.
Als geheim gilt jedes Feld mit `type: "secret"` **und** jedes mit
`source: "provision"` — ein so geholtes Token trägt oft den Typ `string`, ist
aber genauso schutzbedürftig.
Unter Windows geschieht nichts: Dateien in `%APPDATA%` erben die
Zugriffsrechte des Profils.
> **Der Installer verschlüsselt nicht.** Er schreibt Klartext; ob und wie
> Geheimnisse anschließend geschützt werden, entscheidet die Anwendung — etwa
> über DPAPI beim ersten Speichern. Das setzt voraus, dass eure
> Entschlüsselung Klartext unverändert durchreicht, statt an ihm zu scheitern.
> Wer eine solche Weiche hat, sollte sie im Test als **aktiv genutzt**
> beschreiben und nicht als Altlast — sonst wird sie eines Tages als tot
> entfernt und sperrt jede frisch installierte Instanz aus.
`type` bestimmt auch, **wie** geschrieben wird: `int` und `bool` landen als
JSON-Zahl beziehungsweise -Wahrheitswert, nicht als Zeichenkette. Sonst
scheitert die Bindung in der Anwendung.
@@ -239,6 +324,64 @@ der Installer schreibt die abgefragten Werte hinein. Siehe
---
## 4A. Einen laufenden systemd-Dienst aktualisieren
Der `update-agent` schreibt nach `--target-dir` — unter `/opt/<app>` hat der
Dienstbenutzer dort keinen Schreibzugriff. Als `root` gestartet würde er zwar
schreiben können, dann aber über `--restart` **an systemd vorbei** einen
zweiten Prozess starten.
Die Abfolge lautet deshalb: anhalten, aktualisieren, starten — und der Agent
startet **nichts** selbst.
```ini
# /etc/systemd/system/myapp-update.service
[Unit]
Description=Update für myapp einspielen
After=network-online.target
[Service]
Type=oneshot
# Der Agent braucht Schreibrecht in /opt/myapp.
User=root
# Der Lizenzschlüssel gehört nicht in die Kommandozeile - "ps" zeigt sie
# jedem Benutzer der Maschine. EnvironmentFile mit 0600 und root:root.
EnvironmentFile=/etc/myapp/update.env
ExecStartPre=/usr/bin/systemctl stop myapp.service
ExecStart=/opt/myapp/update-agent \
--action update --project myapp --channel prod \
--target-dir /opt/myapp --version latest
ExecStartPost=/usr/bin/systemctl start myapp.service
```
```bash
# /etc/myapp/update.env chmod 600, chown root:root
DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
```
Auslösen von Hand oder über einen Timer:
```bash
systemctl start myapp-update.service
```
**Kein `--restart` und kein `--wait-for-pid`.** Den Neustart übernimmt
`ExecStartPost`, das Anhalten `ExecStartPre` — der Dienst ist beim Anwenden
bereits beendet, es gibt keinen Prozess, auf den zu warten wäre.
Wird der Update-Weg dagegen **aus der Anwendung heraus** angestoßen, gilt
`restartPath: ""` und `exitCurrentApp: false`: Die Anwendung fährt sich selbst
geordnet herunter, systemd startet sie über `Restart=on-success` neu.
> **Dateirechte nach dem Update.** Der Agent läuft als `root` und legt die
> Dateien entsprechend an. Gehört der Dienst einem anderen Benutzer, gehört
> ein `ExecStartPost=/usr/bin/chown -R myapp:myapp /opt/myapp` davor — sonst
> startet der Dienst danach nicht mehr.
---
## 5. Nachträglich einrichten
```bash
+241 -17
View File
@@ -26,8 +26,27 @@
> - `LaunchUpdateAgent` übergibt jetzt Neustart, Prozesskennung und Plattform,
> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**.
> **⚠️ Geändert in Version 2.5.2**
> - **`LaunchUpdateAgent` reicht `--require-signature` durch.** Zuvor gab es
> den Schalter nur auf der Kommandozeile — der empfohlene Weg über das SDK
> aktualisierte damit ungeprüft, siehe
> **[§6, "Erzwingen über LaunchUpdateAgent"](#erzwingen-über-launchupdateagent)**.
Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
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](#5a-zugangsschutz-der-release-verzeichnisse)**
> beruht auf `.htaccess` und wird **von Nginx vollständig ignoriert** — dort
> wären die Release-Verzeichnisse offen und die `.htpasswd` sogar öffentlich
> abrufbar, während die Oberfläche „geschützt" meldete.
>
> `dc.mhdf.de` läuft auf **Apache** mit aktivem `AllowOverride`, dort trägt es.
> Wer auf Nginx ausrollt, muss den Schutz in der Serverkonfiguration
> nachbilden — die Vorlage steht in [§5A](#nginx-statt-apache). Verlass dich
> nicht auf die Anzeige, sondern auf den **Selbsttest**: er ruft die eigene
> Paket-Adresse ohne Zugangsdaten ab und erwartet 401.
---
@@ -37,7 +56,8 @@ Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites,
- **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.
- **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.
---
@@ -150,16 +170,28 @@ ein Dienst-Manager den Neustart übernimmt.
### 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:
Version, UTC-Build-Datum und Git-Commit entstehen automatisch zur
Übersetzungszeit. **Seit Version 2.5 genügt dafür der Paketverweis:**
```xml
<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
<ItemGroup>
<PackageReference Include="Deploymentcenter.Client" Version="2.5.1" />
</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`:
@@ -219,6 +251,20 @@ für die API-Antwort. `UpdateCheckResult.LatestRelease` ist in beiden Fällen ei
## 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:
>
> ```bash
> wget https://dc.mhdf.de/installer/pack-and-deploy-linux-x64 -O pack-and-deploy
> chmod +x pack-and-deploy
> ```
>
> Wer nicht von Hand aufrufen will, nimmt die **Release-Vorlage**: ein Skript
> zum Kopieren ins eigene Projekt, das je Plattform `dotnet publish` und
> `pack-and-deploy` verkettet und sich das Werkzeug selbst holt. Siehe
> **[Release-Anleitung für Agenten](../public/docs/release.md)**.
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:
@@ -332,8 +378,27 @@ Die Muster sind jetzt echte Globs:
| `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.
### 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
@@ -604,12 +669,21 @@ Darin stehen:
| Eintrag | Benutzername | Passwort |
|---|---|---|
| Gültige Lizenz | der Lizenzschlüssel | derselbe Schlüssel |
| Gültige Lizenz | `lic_` + erste 16 Hexzeichen von SHA-256(Schlüssel) | der Schlüssel |
| Installationskonto | DC-Benutzername | dessen Passwort |
Beim Lizenzschlüssel sind Benutzername und Passwort identisch: Basic Auth
verlangt zwei Felder, es gibt aber nur ein Geheimnis, und Benutzernamen müssen
eindeutig sein.
**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
@@ -617,6 +691,47 @@ Hash wird unverändert aus `dc_users` übernommen: PHPs `password_hash()` erzeug
bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort
wird nirgends gebraucht.
### Woher die Anwendung den Schlüssel nimmt
Die häufigste Rückfrage bei der Integration: Der Schlüssel steht **im
Lizenz-Cache**, den das Lizenzmodul ohnehin führt. Es muss nichts zusätzlich
gespeichert werden.
```csharp
// Beim Start einmal: Lizenz prüfen (legt den Schlüssel im Cache ab)
var license = await new LicenseClient().EnsureLicensedAsync(
productSlug: "myapp",
serverBaseUrl: "https://dc.mhdf.de",
allowPrompt: false); // im Dienst: nicht nach einer Eingabe warten
if (!license.IsValid) { /* Sperrbetrieb, siehe Lizenz-Leitfaden §6 */ }
// Danach jederzeit für den Update-Weg:
string? key = LicenseClient.TryGetCachedKey("myapp");
var check = await new UpdateClient().CheckForUpdateAsync(
baseUrl: "https://dc.mhdf.de",
projectId: "myapp",
currentVersion: BuildInfo.Version,
channel: "prod",
credentials: ReleaseCredentials.FromLicenseKey(key));
if (check.Unauthorized)
{
// Kein Netzwerkfehler: Die Lizenz trägt nicht mehr.
log.Warn(check.Message);
}
```
`TryGetCachedKey` ist statisch und liest den verschlüsselten Cache aus
`LicenseConfig.GetStorageDirectory(slug)`. Liefert er `null`, wurde noch nie
erfolgreich validiert — dann gibt es auch keinen Update-Zugang.
> **Im Dienst ohne Heimatverzeichnis** (systemd `User=` ohne `$HOME`) fällt der
> Cache auf `/var/lib/<slug>/license` zurück. Ist auch das nicht beschreibbar,
> gibt es keinen Offline-Cache — dann muss `DEPLOYMENTCENTER_STORAGE_DIR` auf
> ein beschreibbares Verzeichnis zeigen.
### Was sich für Clients ändert
**Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.**
@@ -641,14 +756,51 @@ UpdateClient.LaunchUpdateAgent(
licenseKey: meineLizenz); // reicht --license-key an den Agenten durch
```
Auf der Kommandozeile:
Auf der Kommandozeile**bevorzugt über die Umgebung**:
```bash
update-agent --project myapp --action update --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX \
update-agent --project myapp --action update
```
Ersatzweise `--download-user` und `--download-password` für ein
Installationskonto — das nutzt der Installer bei der Erstinstallation.
`--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](./UPGRADE.md#5-cron-für-den-watchdog-evaluator-einrichten) 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:
```csharp
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: true` beendet den Prozess mit `Environment.Exit(0)`.**
> Laufende `finally`-Blöcke anderer Threads, `IHostApplicationLifetime` und
> Destruktoren kommen dann nicht mehr zum Zug. Für eine Anwendung mit offenem
> Zustand — Positionen, Transaktionen, ungeschriebene Puffer — ist das die
> falsche Wahl. Dort `exitCurrentApp: false` setzen 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
@@ -669,10 +821,52 @@ Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind.
| Anzeige | Bedeutung |
|---|---|
| GESCHÜTZT | alles in Ordnung |
| 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:
```bash
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:
```nginx
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
@@ -755,6 +949,36 @@ auf. War der Wechsel beabsichtigt, die Datei löschen.
Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die
Vertrauenskette, und darauf wird bei jedem Update hingewiesen.
### Erzwingen über `LaunchUpdateAgent`
**Seit 2.5.2.** `--require-signature` war vorher nur über die Kommandozeile
erreichbar — `LaunchUpdateAgent`, der empfohlene Weg aus
**[§2A](#a-referenz-auf-deploymentcenterclient)**, hatte dafür keinen
Parameter. Eine Anwendung, die dem Leitfaden folgte, aktualisierte damit
ungeprüft, während derselbe Vorgang von Hand auf der Kommandozeile geschützt
gewesen wäre — ausgerechnet bei dem Feature, das genau diesen Pfad absichern
soll.
```csharp
UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: "myapp",
licenseKey: meineLizenz,
requireSignature: true);
```
Um den öffentlichen Schlüssel muss sich niemand zusätzlich kümmern: der Agent
holt und cached ihn ohnehin selbst von `/api/updateservice/v1/pubkey` (siehe
oben, "Prüfen"). `--pubkey` bleibt nötig, wenn ein Schlüssel ohne
Netzwerkzugriff hinterlegt werden soll.
**Erst scharfschalten, wenn wirklich alles signiert ist.** Ist
`security.release_private_key` gesetzt, signiert der Server nur *neue*
Veröffentlichungen — bereits abgelegte Releases bleiben unsigniert, bis sie
erneut veröffentlicht werden. Mit `requireSignature: true` bricht der Agent
bei jedem noch unsignierten Release ab, auch bei einem sonst harmlosen
Downgrade-Schutz oder einer Reparatur auf eine ältere Version.
---
## 7. Release-Verwaltung (Bearbeiten & Löschen)
@@ -775,7 +999,7 @@ Veröffentlichte Releases können sowohl über das Web-Interface als auch über
- Antwort (200 OK):
```json
{
"status": "ok",
"status": "success",
"release_id": 42,
"updated": true,
"platform": "win-x64",
@@ -791,7 +1015,7 @@ Veröffentlichte Releases können sowohl über das Web-Interface als auch über
- Antwort (200 OK):
```json
{
"status": "ok",
"status": "success",
"release_id": 42,
"deleted": true,
"message": "Release v1.2.0 (prod, win-x64) fuer \"myapp\" geloescht."
+23 -2
View File
@@ -33,7 +33,7 @@ kompromittiert zu behandeln:
- [ ] `.htpasswd`-Passwort für `deploy` neu setzen
- [ ] Git-Token `eb429575…` widerrufen und neu ausstellen
- [ ] Admin-Passwort im WebUI ändern (die alte Fassung setzte es bei jedem
Aufruf von `install_db.php` auf `Admin1337!` zurück — jeder im Internet
Aufruf von `install_db.php` auf `Admin1337!Admin1337!` zurück — jeder im Internet
konnte das auslösen)
Wenn die Historie bereinigt werden soll, geht das mit
@@ -529,6 +529,21 @@ Läuft `cli/tick.php` als Cron, erzeugt es den Schutz beim ersten Lauf nach dem
Deployment **von selbst**. Wer die Reihenfolge einhalten will, spielt den
Serverteil also erst dann ein, wenn Schritt 3 erledigt ist.
> **Neue Produkte: die Reihenfolge oben gilt für sie nicht.**
>
> `ReleaseGuard` überspringt Verzeichnisse, die es nicht gibt — für ein Produkt
> ohne Release ist `/releases/<slug>/` schlicht nicht vorhanden und damit auch
> nicht geschützt. Das Verzeichnis entsteht erst mit dem **ersten** Upload, und
> der nächste `tick.php`-Lauf legt den Schutz darin an.
>
> Es gibt also kein Zeitfenster, in dem sich ein noch ungeschütztes Release
> herunterladen ließe, um dann das SDK nachzuziehen. **Der erste ausgelieferte
> Build muss die Zugangsdaten bereits mitbringen** — sonst fällt die Tür hinter
> ihm zu.
>
> Für Neuprodukte lautet die Reihenfolge daher schlicht: SDK 2.4 einbinden,
> `licenseKey` übergeben, *dann* das erste Release veröffentlichen.
### 16.2 Was wo eingetragen wird
Je Produktverzeichnis eine `.htpasswd` mit den aktiven, nicht abgelaufenen
@@ -552,9 +567,15 @@ wget -qO- https://dc.mhdf.de/installer/install.sh | sh
### 16.4 Prüfen
- [ ] **Selbsttest** im WebUI unter *UpdateService → 🔒 Zugangsschutz* — er ruft
die eigene Paket-Adresse ohne Zugangsdaten ab und erwartet 401. Die
Anzeige „GESCHÜTZT" allein beweist nichts: Sie prüft nur, ob die Dateien
existieren, nicht ob der Server sie auswertet.
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/prod/<rid>/<version>/package.tar.gz`
**401**
- [ ] Mit `-u "<lizenzschlüssel>:<lizenzschlüssel>"`**200**
- [ ] Mit `-u "$(printf 'lic_%s' "$(printf %s "<schlüssel>" | sha256sum | cut -c1-16)"):<schlüssel>"`**200**
(Benutzername ist die Ableitung, nicht der Schlüssel — siehe
[§5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse))
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/.htpasswd`**403**
- [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64`**200**, weiterhin offen
- [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401**
+11 -3
View File
@@ -5,10 +5,18 @@
"displayName": "Beispielanwendung",
"description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.",
"_targets_comment": "Wohin geschrieben wird. Pfade sind relativ zum Installationsverzeichnis. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
"_targets_comment": "Wohin geschrieben wird. 'location' bestimmt den Bezugspunkt: install (Vorgabe, Installationsverzeichnis), config (%APPDATA% bzw. $XDG_CONFIG_HOME), data, home. In 'file' sind ${VAR} und %VAR% erlaubt, ein absoluter Pfad wird unveraendert benutzt. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
"_platform_comment": "Heisst der Ordner je nach Plattform anders, hilft meist die kleingeschriebene Form allein: sie trifft unter Linux exakt und unter Windows auch, weil NTFS die Schreibweise ignoriert. Auf case-sensitiven Dateisystemen oder bei groesseren Unterschieden gibt es fileWindows, fileLinux und fileMacOS.",
"targets": [
{ "id": "app", "file": "appsettings.json", "format": "json" },
{ "id": "env", "file": ".env", "format": "env" }
{ "id": "app", "file": "appsettings.json", "location": "install", "format": "json" },
{
"id": "user",
"file": "beispielanwendung/Settings.json",
"fileWindows": "Beispielanwendung/Settings.json",
"location": "config",
"format": "json"
},
{ "id": "env", "file": ".env", "location": "install", "format": "env" }
],
"_fields_comment": "source steuert, ob gefragt wird: 'ask' (Vorgabe) fragt nach, 'detect:...' leitet ab, 'provision' laesst ein Token ausstellen. Ist der Wert in der Zieldatei bereits gesetzt und kein Platzhalter, wird er uebernommen und nicht erneut erfragt.",
+124 -2
View File
@@ -6,6 +6,7 @@
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
* GET /api/setup/v1/catalog Was ist hier installierbar?
* POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung
* POST /api/setup/v1/agent Watchdog-Agent fuer die Maschine selbst
*
* Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste
* vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde,
@@ -36,6 +37,8 @@ use Deploymentcenter\Core\TokenManager;
use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\Setup\SetupCatalog;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\AgentScript;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
@@ -222,9 +225,128 @@ switch (resolveSetupAction()) {
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
], 201);
case 'agent':
// Der Watchdog-Agent fuer die Maschine, auf der gerade installiert
// wird.
//
// Bisher gab es diesen Weg nur ueber die Oberflaeche: Monitor von Hand
// anlegen, Token erzeugen, Skript herunterladen, auf den Host kopieren,
// Cron-Eintrag schreiben. Fuenf Schritte an einem anderen Rechner, fuer
// etwas, das genau dann ansteht, wenn man ohnehin auf dem Zielsystem
// sitzt - entsprechend oft unterblieb es, und die frisch installierte
// Anwendung lief auf einem unbeobachteten Host.
//
// Ausgestellt wird dasselbe wie bei 'token': ein Token mit genau
// watchdog:ping. Es liegt anschliessend im Klartext im Skript auf dem
// Zielsystem - mehr Rechte duerfen daran nicht haengen.
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
}
$context = ApiAuth::requireScope($db, 'setup:install', null, false);
$project = Http::str('project') ?? Http::str('product_slug');
if ($project === null || $project === '') {
Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.');
}
ApiAuth::enforceProject($context, $project);
$exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1');
$exists->execute([':slug' => $project]);
if ($exists->fetch() === false) {
Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project));
}
// Der Name ist das, was spaeter im Dashboard steht. Gesaeubert wird in
// AgentScript - hier wird nur geprueft, ob ueberhaupt etwas uebrig
// bleibt, damit nicht stillschweigend ein Monitor "host" entsteht.
$rawSource = trim((string)(Http::str('source') ?? ''));
if ($rawSource === '') {
Http::fail(400, 'missing_source', 'Der Parameter "source" (Name des Monitors) wird benoetigt.');
}
$source = AgentScript::sanitizeSource($rawSource);
$monitorRepo = new MonitorRepo($db);
$existing = $monitorRepo->getMonitor($source);
// Ein Name, den es schon gibt, ist nicht zwangslaeufig ein Fehler -
// eine Neuinstallation derselben Maschine trifft ihren eigenen Monitor
// wieder. Nur entscheiden muss das der Aufrufer, nicht dieser
// Endpunkt: sonst uebernimmt eine zweite Maschine unbemerkt die
// Historie der ersten.
$overwrite = filter_var(Http::input('overwrite', false), FILTER_VALIDATE_BOOLEAN);
if ($existing !== null && !$overwrite) {
Http::fail(409, 'monitor_exists', sprintf(
'Ein Monitor "%s" ist bereits angelegt (Zustand: %s). Mit overwrite=true uebernehmen.',
$source,
(string)($existing['state'] ?? 'unbekannt')
), null, [
'source' => $source,
'state' => $existing['state'] ?? null,
'last_seen' => $existing['last_seen_utc'] ?? null,
]);
}
$os = AgentScript::normalizeOs(Http::str('os'));
$interval = Http::int('interval', 60);
$manager = new TokenManager($db);
$agentToken = $manager->createMasterToken(
sprintf('Watchdog %s', $source),
$project,
null,
'host',
$source,
['watchdog:ping'],
Http::str('environment') ?? 'all'
);
$monitorRepo->createMonitor($source, [
'type' => 'host',
'os' => $os,
'expected_interval_sec' => $interval,
'group_key' => Http::str('group'),
'parent_source' => Http::str('parent'),
]);
$agent = AgentScript::build(
$os,
$source,
$agentToken['raw_token'],
Http::baseUrl() . '/api/watchdog/v1/ping',
$interval
);
Logger::info('Watchdog-Agent ueber den Setup-Weg ausgestellt', [
'project' => $project,
'source' => $source,
'os' => $os,
'existing' => $existing !== null,
'actor' => $context['actor'],
'token_id' => $agentToken['token_id'],
]);
Http::ok([
'source' => $source,
'os' => $agent['os'],
'filename' => $agent['filename'],
'script' => $agent['script'],
'schedule' => $agent['schedule'],
'interval' => $agent['interval'],
'token_id' => $agentToken['token_id'],
'existing' => $existing !== null,
'message' => sprintf(
'Monitor "%s" eingerichtet. Das Skript enthaelt das Token und wird nur einmal ausgeliefert.',
$source
),
], 201);
default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['login', 'catalog', 'token'],
'available' => ['login', 'catalog', 'token', 'agent'],
]);
}
@@ -247,7 +369,7 @@ function resolveSetupAction(): string
$last = strtolower((string)end($segments));
return match ($last) {
'login', 'catalog', 'token' => $last,
'login', 'catalog', 'token', 'agent' => $last,
default => 'unknown',
};
}
+86 -1
View File
@@ -108,6 +108,90 @@ switch ($action) {
);
Http::ok(['count' => count($releases), 'releases' => $releases]);
case 'changelog':
// Was hat sich seit einer bestimmten Fassung geaendert?
//
// Damit muss ein Agent, der eine Anbindung aktualisiert, nicht die
// gesamte Historie lesen. Er merkt sich die Fassung, gegen die er
// integriert hat, und fragt spaeter nur nach dem Unterschied.
$changelogPath = dirname(__DIR__, 3) . '/docs/changelog.json';
if (!is_file($changelogPath)) {
Http::fail(404, 'no_changelog', 'Es ist kein Changelog hinterlegt.');
}
$raw = (string)file_get_contents($changelogPath);
// Windows-Werkzeuge stellen gern ein BOM voran; json_decode scheitert daran.
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
$changelog = json_decode($raw, true);
if (!is_array($changelog) || !isset($changelog['versions']) || !is_array($changelog['versions'])) {
Http::fail(500, 'invalid_changelog', 'Der hinterlegte Changelog ist nicht lesbar.');
}
$since = Http::str('since');
$area = Http::str('area');
$entries = [];
$actionItems = 0;
$breaking = 0;
foreach ($changelog['versions'] as $entry) {
if (!is_array($entry) || !isset($entry['version'])) {
continue;
}
// Nur echt neuere Fassungen. Wer auf 2.1.0 sitzt, will nicht
// wieder ueber 2.1.0 lesen.
if ($since !== null && $since !== ''
&& !Version::isNewer((string)$entry['version'], $since)) {
continue;
}
if ($area !== null && $area !== '') {
$entry['changes'] = array_values(array_filter(
$entry['changes'] ?? [],
static fn(array $c): bool => ($c['area'] ?? '') === $area
));
if ($entry['changes'] === []) {
continue;
}
}
foreach ($entry['changes'] ?? [] as $change) {
if (!empty($change['action'])) { $actionItems++; }
if (!empty($change['breaking'])) { $breaking++; }
}
$entries[] = $entry;
}
// Absteigend: das Neueste zuerst.
usort($entries, static fn(array $a, array $b): int
=> Version::compare((string)$b['version'], (string)$a['version']));
Http::ok([
'current' => $changelog['current'] ?? null,
'since' => $since,
'count' => count($entries),
'action_items' => $actionItems,
'breaking' => $breaking,
'versions' => $entries,
'message' => $entries === []
? ($since !== null && $since !== ''
? sprintf('Seit %s hat sich nichts geaendert.', $since)
: 'Es ist nichts hinterlegt.')
: sprintf(
'%d Fassung(en) neuer als %s, davon %d mit Handlungsbedarf und %d mit Bruch.',
count($entries),
$since !== null && $since !== '' ? $since : 'Anbeginn',
$actionItems,
$breaking
),
]);
case 'pubkey':
// Oeffentlicher Schluessel zum Pruefen der Release-Signaturen.
// Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn,
@@ -393,7 +477,8 @@ function resolveUpdateAction(): string
$last = strtolower((string)end($segments));
return match ($last) {
'check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last,
'check', 'latest', 'releases', 'pubkey', 'changelog', 'publish', 'update', 'edit', 'delete'
=> $last === 'edit' ? 'update' : $last,
'publish_release' => 'publish',
default => 'check',
};
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<rect width="100" height="100" rx="20" fill="#F2622E"/>
<path d="M50 20 L80 35 L80 65 L50 80 L20 65 L20 35 Z" fill="none" stroke="#FFF" stroke-width="6"/>
<circle cx="50" cy="50" r="10" fill="#FFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 299 B

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<rect width="100" height="100" rx="20" fill="#2496ED"/>
<rect x="20" y="45" width="12" height="10" rx="2" fill="#FFF"/>
<rect x="35" y="45" width="12" height="10" rx="2" fill="#FFF"/>
<rect x="50" y="45" width="12" height="10" rx="2" fill="#FFF"/>
<rect x="35" y="32" width="12" height="10" rx="2" fill="#FFF"/>
<rect x="50" y="32" width="12" height="10" rx="2" fill="#FFF"/>
<rect x="65" y="45" width="12" height="10" rx="2" fill="#FFF"/>
<path d="M15 62 Q 50 82 85 62 Q 90 70 85 75 Q 45 92 10 70 Z" fill="#FFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 624 B

-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<circle cx="50" cy="50" r="48" fill="#FCC624"/>
<ellipse cx="50" cy="42" rx="22" ry="26" fill="#000000"/>
<ellipse cx="50" cy="45" rx="16" ry="20" fill="#FFFFFF"/>
<circle cx="43" cy="35" r="4" fill="#000"/>
<circle cx="57" cy="35" r="4" fill="#000"/>
<polygon points="46,40 54,40 50,48" fill="#FFA500"/>
<ellipse cx="50" cy="72" rx="24" ry="14" fill="#000000"/>
<ellipse cx="36" cy="84" rx="12" ry="6" fill="#FFA500"/>
<ellipse cx="64" cy="84" rx="12" ry="6" fill="#FFA500"/>
</svg>

Before

Width:  |  Height:  |  Size: 588 B

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<rect width="100" height="100" rx="20" fill="#00618A"/>
<path d="M20 75 C 20 40, 45 25, 75 35 C 80 50, 70 70, 50 75 Z" fill="#E48E00"/>
<circle cx="65" cy="40" r="4" fill="#FFF"/>
<text x="50" y="88" font-size="18" font-family="sans-serif" font-weight="bold" fill="#FFF" text-anchor="middle">MySQL</text>
</svg>

Before

Width:  |  Height:  |  Size: 406 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<polygon points="50,5 92,28 92,72 50,95 8,72 8,28" fill="#009639"/>
<path d="M30 70 L30 30 L65 70 L65 30" stroke="#FFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 295 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<polygon points="50,10 90,32 90,78 50,100 10,78 10,32" fill="#5FA04E"/>
<text x="50" y="62" font-size="24" font-family="sans-serif" font-weight="bold" fill="#FFF" text-anchor="middle">node</text>
</svg>

Before

Width:  |  Height:  |  Size: 293 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<ellipse cx="50" cy="50" rx="46" ry="28" fill="#777BB4"/>
<text x="50" y="58" font-size="28" font-family="sans-serif" font-weight="bold" fill="#FFF" text-anchor="middle">php</text>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<rect width="100" height="100" rx="20" fill="#E57000"/>
<path d="M25 25 L75 25 L75 45 L45 45 L45 55 L75 55 L75 75 L25 75 Z" fill="#FFFFFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 238 B

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<path d="M48 10 C 25 10, 25 22, 25 22 L 25 34 L 50 34 L 50 38 L 20 38 C 20 38, 8 38, 8 60 C 8 82, 20 82, 20 82 L 32 82 L 32 70 C 32 70, 32 56, 48 56 L 62 56 C 62 56, 75 56, 75 42 L 75 22 C 75 22, 75 10, 48 10 Z" fill="#3776AB"/>
<circle cx="36" cy="22" r="4" fill="#FFF"/>
<path d="M52 90 C 75 90, 75 78, 75 78 L 75 66 L 50 66 L 50 62 L 80 62 C 80 62, 92 62, 92 40 C 92 18, 80 18, 80 18 L 68 18 L 68 30 C 68 30, 68 44, 52 44 L 38 44 C 38 44, 25 44, 25 58 L 25 78 C 25 78, 25 90, 52 90 Z" fill="#FFD43B"/>
<circle cx="64" cy="78" r="4" fill="#000"/>
</svg>

Before

Width:  |  Height:  |  Size: 650 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<rect width="100" height="100" rx="20" fill="#D82C20"/>
<polygon points="50,20 80,35 50,50 20,35" fill="#FFF"/>
<polygon points="50,45 80,60 50,75 20,60" fill="#FFF" opacity="0.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 281 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="24" height="24">
<rect width="100" height="100" rx="16" fill="#1E232B"/>
<rect x="15" y="15" width="70" height="30" rx="6" fill="#242A33" stroke="#F2622E" stroke-width="3"/>
<circle cx="30" cy="30" r="4" fill="#37C26B"/>
<circle cx="42" cy="30" r="4" fill="#37C26B"/>
<rect x="15" y="55" width="70" height="30" rx="6" fill="#242A33" stroke="#F2622E" stroke-width="3"/>
<circle cx="30" cy="70" r="4" fill="#37C26B"/>
<circle cx="42" cy="70" r="4" fill="#37C26B"/>
</svg>

Before

Width:  |  Height:  |  Size: 553 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88" width="24" height="24">
<path fill="#00ADEF" d="M0 12.5L35.7 7.6v33.4H0V12.5zm0 32.5h35.7v33.5L0 73.8V45zm39.5-38L88 0v41H39.5V7zm0 38H88v41l-48.5-6.8V45z"/>
</svg>

Before

Width:  |  Height:  |  Size: 227 B

+369
View File
@@ -0,0 +1,369 @@
{
"_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.",
"schema": 1,
"current": "2.6.0",
"versions": [
{
"version": "2.6.0",
"date": "2026-08-21",
"summary": "Der Installer prueft die Laufzeit und richtet die Ueberwachung des Hosts ein.",
"actionRequired": false,
"changes": [
{
"area": "client",
"breaking": false,
"title": "Installer prueft die .NET-Laufzeit",
"text": "Bisher endete eine gelungene Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich nicht starten laesst - und die Fehlersuche begann beim Deploymentcenter, weil das der letzte bewusste Schritt war. Nach dem Uebertragen der Dateien liest der Installer jetzt die runtimeconfig.json der Anwendung, vergleicht sie mit \"dotnet --list-runtimes\" und nennt bei Bedarf den Installationsbefehl fuer diese Plattform. Eigenstaendig veroeffentlichte Pakete bringen ihre Laufzeit mit und werden nicht bemaengelt; rollForward wird beachtet.",
"action": ""
},
{
"area": "client",
"breaking": false,
"title": "Ueberwachung der Maschine direkt aus dem Installer",
"text": "Am Ende der Installation genuegen zwei Fragen - Name im Dashboard und ob eingeplant werden soll. Monitor, Token, Agent-Skript, Dateirechte, ein erster Heartbeat zur Probe und der Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen ohne Installation, etwa den Hypervisor darunter, gibt es denselben Weg als \"--action monitor\".",
"action": "Keine. Wer die Frage verneint, holt es spaeter mit --action monitor nach."
},
{
"area": "server",
"breaking": false,
"title": "Agent-Skripte melden jetzt Kennzahlen",
"text": "Das aus der Oberflaeche heruntergeladene Skript schickte nur \"status: ok\" - damit beantwortete ein Host-Monitor genau eine Frage und keine der Fragen, wegen derer man auf einen Host schaut. Die Skripte melden jetzt Last, Speicherbelegung, Plattenbelegung und Laufzeit mit und stufen sich ab 90 Prozent Plattenbelegung selbst auf \"warning\". Erzeugt werden sie an einer Stelle (Modules/Watchdog/AgentScript.php), die Oberflaeche und Installer gemeinsam nutzen.",
"action": ""
},
{
"area": "api",
"breaking": false,
"title": "POST /api/setup/v1/agent",
"text": "Legt den Monitor an, stellt ein Token mit genau watchdog:ping aus und liefert das fertige Skript fuer den Host. Ein bereits vorhandener Monitor gleichen Namens wird mit 409 abgelehnt, damit nicht eine zweite Maschine stillschweigend die Historie der ersten uebernimmt; overwrite=true erlaubt es ausdruecklich. Im SDK: SetupClient.RequestWatchdogAgentAsync(...).",
"action": ""
}
]
},
{
"version": "2.5.3",
"date": "2026-08-21",
"summary": "Administratorkonten kommen an die Release-Ablage.",
"actionRequired": false,
"changes": [
{
"area": "server",
"breaking": false,
"title": "Administratorkonten im Release-Zugangsschutz",
"text": "Der Zugangsschutz nahm bisher nur Konten der Rolle installer auf, waehrend die Installationsskripte und der Installer ausdruecklich sagten, ein Administratorkonto tue es auch. Eine Erstinstallation mit einem Administratorkonto meldete sich erfolgreich an, zeigte den Katalog und scheiterte erst beim Herunterladen mit einer Meldung ueber abgelaufene Lizenzen. Administratoren zaehlen jetzt zu den Installationskonten; die .htpasswd-Dateien werden beim naechsten Tick neu erzeugt. Neu angelegte Konten landen ausserdem sofort darin - zuvor galt das nur fuer die Rolle installer, ein frisches Administratorkonto wartete bis zum naechsten turnusmaessigen Lauf.",
"action": ""
},
{
"area": "client",
"breaking": false,
"title": "Ehrliche Meldung bei abgelehntem Zugang",
"text": "Ein 401 der Release-Ablage nannte immer Lizenzgruende - abgelaufen, widerrufen, falsches Produkt. Bei einer Erstinstallation gibt es noch gar keine Lizenz; die Meldung schickte damit in die falsche Richtung. Wurde mit Benutzername und Passwort zugegriffen, nennt sie jetzt das Konto und die Rollen, die Zugang haben. Ausserdem bricht der Agent bei 401 sofort ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar Schritte spaeter ein zweites Mal zu zeigen.",
"action": ""
}
]
},
{
"version": "2.5.2",
"date": "2026-08-15",
"summary": "LaunchUpdateAgent reicht --require-signature durch.",
"actionRequired": true,
"changes": [
{
"area": "sdk",
"breaking": false,
"title": "requireSignature-Parameter auf LaunchUpdateAgent",
"text": "--require-signature gab es bisher nur als Kommandozeilenschalter. Jede Anwendung, die dem Leitfaden folgt und ueber LaunchUpdateAgent startet - der empfohlene Weg - aktualisierte damit ungeprueft, waehrend derselbe Vorgang von Hand auf der Kommandozeile geschuetzt gewesen waere. Der oeffentliche Schluessel muss dafuer nicht separat verwaltet werden, der Agent holt und cached ihn ohnehin selbst.",
"action": "LaunchUpdateAgent(..., requireSignature: true) setzen, sobald server.security.release_private_key hinterlegt ist und alle ausgelieferten Releases signiert wurden."
}
]
},
{
"version": "2.5.1",
"date": "2026-08-14",
"summary": "Zugangsschutz je Produkt abschaltbar, Dienst-Betrieb dokumentiert, inkrementelle Builds repariert.",
"actionRequired": true,
"changes": [
{
"area": "server",
"breaking": false,
"title": "Produkte lassen sich vom Zugangsschutz ausnehmen",
"text": "Der Schutz entstand beim ersten Lauf nach dem Deployment von selbst - auch fuer Produkte, deren ausgelieferte Installationen noch ohne Lizenzschluessel bauen. Die bekamen ab dem Moment 401 statt Updates; bei Predictalytics ist genau das passiert. Unter UpdateService -> Zugangsschutz laesst sich je Produkt umschalten.",
"action": "Wer eine Installationsbasis ohne SDK 2.4 hat: Produkt ausnehmen, Build mit Schluessel ausliefern, danach wieder einschalten."
},
{
"area": "sdk",
"breaking": false,
"title": "BuildInfo.targets verhindert keine inkrementellen Builds mehr",
"text": "BuildDateUtc trug die volle Uhrzeit und aenderte sich bei jedem Build; WriteOnlyWhenDifferent griff nie, und jedes einbindende Projekt wurde jedes Mal neu uebersetzt. Jetzt tagesgenau - hoechstens ein Neubau pro Tag.",
"action": "Keine. BuildInfo.BuildDateUtc enthaelt nur noch das Datum, nicht die Uhrzeit."
},
{
"area": "sdk",
"breaking": true,
"title": "LicenseConfig.SetStorageDirectory haengt jetzt <slug>/license an",
"text": "Zuvor wurde der gesetzte Pfad roh benutzt, waehrend der Weg ueber DEPLOYMENTCENTER_STORAGE_DIR <slug>/license anhaengte. Zwei Produkte im selben Prozess mit gesetztem Override schrieben damit in dieselbe state.dat.",
"action": "Wer SetStorageDirectory benutzt: Der Cache liegt jetzt eine Ebene tiefer. Ein vorhandener Cache wird einmalig neu aufgebaut."
},
{
"area": "sdk",
"breaking": false,
"title": "Lizenz-Cache faellt auf /var/lib/<slug> zurueck",
"text": "Eine systemd-Unit mit User= und ohne eigenes Heimatverzeichnis hat weder XDG_CONFIG_HOME noch HOME. Der Rueckfall landete im Installationsverzeichnis, unter /opt fuer den Dienstbenutzer nicht beschreibbar - also kein Offline-Cache, ausgerechnet im kopflosen Betrieb.",
"action": "Wenn /var/lib nicht beschreibbar ist: DEPLOYMENTCENTER_STORAGE_DIR setzen."
},
{
"area": "docs",
"breaking": false,
"title": "Lueckenschluss in den Leitfaeden",
"text": "Woher die Anwendung den Lizenzschluessel fuer den Update-Zugang nimmt (TryGetCachedKey), wie ein laufender systemd-Dienst aktualisiert wird, was statt Environment.Exit(1) zu tun ist, wenn Beenden die gefaehrlichere Option waere, und die Drosselungsgrenzen aller Endpunkte.",
"action": null
}
]
},
{
"version": "2.5.0",
"date": "2026-08-14",
"summary": "Geheimnisse raus aus der Kommandozeile, Packager sperrt statt zu warnen, ILicensePrompt lebt.",
"actionRequired": true,
"changes": [
{
"area": "sdk",
"breaking": false,
"title": "Lizenzschluessel wird nicht mehr als Argument uebergeben",
"text": "Der Agent nahm --license-key nur aus argv. 'ps' zeigt das jedem Benutzer der Maschine - exakt die Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und DC_DOWNLOAD_PASSWORD; Umgebung vor Argument. LaunchUpdateAgent setzt die Variable auf dem eigenen Prozess, das Kind erbt sie.",
"action": "Keine Aenderung noetig, wenn ihr licenseKey an LaunchUpdateAgent uebergebt. Wer den Agenten selbst aufruft, sollte von --license-key auf DC_LICENSE_KEY umstellen."
},
{
"area": "sdk",
"breaking": false,
"title": "waitTimeoutSeconds an LaunchUpdateAgent",
"text": "Der Agent kannte --wait-timeout, das SDK reichte es nicht durch - es galten fest 60 Sekunden. Laeuft die Zeit ab, bricht der Agent ab, ohne etwas zu veraendern.",
"action": "Wenn euer Herunterfahren laenger als etwa 40 Sekunden dauert: waitTimeoutSeconds heraufsetzen."
},
{
"area": "sdk",
"breaking": false,
"title": "Environment.Exit(0) ist dokumentiert, nicht geaendert",
"text": "exitCurrentApp:true beendet den Prozess hart - laufende finally-Bloecke, IHostApplicationLifetime und Destruktoren kommen nicht mehr zum Zug.",
"action": "Bei offenem Zustand (Positionen, Transaktionen, ungeschriebene Puffer) exitCurrentApp:false setzen und selbst geordnet herunterfahren. Der Agent wartet ohnehin auf das Prozessende."
},
{
"area": "sdk",
"breaking": false,
"title": "EnsureLicensedAsync - ILicensePrompt wird endlich benutzt",
"text": "Der Konstruktor nahm ILicensePrompt entgegen, legte es ab und rief es nie auf. Neu: EnsureLicensedAsync() nimmt den zwischengespeicherten Schluessel, fragt sonst nach, prueft, und fragt bei Ablehnung erneut. allowPrompt:false lehnt ohne Cache ab, statt im Dienst auf eine Eingabe zu warten, die nie kommt.",
"action": "Optional. Wer eine eigene Abfrage gebaut hat, kann sie behalten."
},
{
"area": "packager",
"breaking": true,
"title": "Bricht bei Zugangsdaten im Paket ab",
"text": "Zuvor nur eine Warnung - und die war ausgerechnet unterdrueckt, wenn die Datei auf der preserve-Liste stand. So geriet ein echter API-Schluessel in ein oeffentlich abrufbares Paket. Geprueft werden Dateiname (appsettings.Local.json, master.key, *.pfx, *.db, server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_, dc_master_, AKIA, private Schluessel). Platzhalter loesen nicht aus.",
"action": "Pruefen, was im Publish-Verzeichnis landet - haeufig eine CopyToOutputDirectory-Regel in der csproj. Notausgang: --allow-secrets."
},
{
"area": "sdk",
"breaking": false,
"title": "BuildInfo.targets liegt im NuGet-Paket",
"text": "Die Anleitung empfahl einen <Import> per relativem Pfad ins Nachbar-Repository - das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben Fassung stehen. Das Target liegt jetzt unter build/ im Paket, NuGet importiert es selbst.",
"action": "Den <Import Project=\"..\\Deploymentcenter.Client\\Deploymentcenter.BuildInfo.targets\" /> aus der csproj entfernen und stattdessen PackageReference auf Deploymentcenter.Client 2.5.0 setzen."
},
{
"area": "sdk",
"breaking": false,
"title": "Unauthorized auch im API-Zweig",
"text": "Ein 401 wurde nur auf dem statischen Weg als Lizenzproblem erkannt; ueber die API kam er als gewoehnlicher HTTP-Fehler an.",
"action": null
},
{
"area": "tooling",
"breaking": false,
"title": "pack-and-deploy ist beziehbar, Release-Vorlage vorhanden",
"text": "Das Werkzeug wurde in der Anleitung benutzt, als laege es im PATH - beziehbar war es nirgends. Es steht jetzt unter /installer/ neben dem Agenten. Dazu eine Vorlage zum Kopieren ins eigene Projekt, die je Plattform dotnet publish und pack-and-deploy verkettet und sich das Werkzeug selbst holt.",
"action": "Empfohlen: Vorlage von /docs/release-template/ holen. Anleitung unter /docs/release.md."
}
]
},
{
"version": "2.4.0",
"date": "2026-08-13",
"summary": "Release-Ablage liegt hinter Zugangsschutz. Ohne Lizenzschluessel keine Updates mehr.",
"actionRequired": true,
"changes": [
{
"area": "server",
"breaking": true,
"title": "/releases/ verlangt Zugangsdaten",
"text": "Zuvor konnte jeder im Internet die vollstaendigen Pakete herunterladen. Zugang haben jetzt gueltige Lizenzschluessel des jeweiligen Produkts sowie die Installationskonten. Je Produkt eine eigene .htpasswd - eine gemeinsame wuerde bedeuten, dass eine Lizenz fuer Produkt A auch Produkt B oeffnet.",
"action": "PFLICHT: credentials: ReleaseCredentials.FromLicenseKey(schluessel) an CheckForUpdateAsync, und licenseKey an LaunchUpdateAgent. Ohne das bekommt die Anwendung 401 und keine Updates mehr."
},
{
"area": "server",
"breaking": true,
"title": "Benutzername ist eine Ableitung, nicht der Schluessel",
"text": "Das htpasswd-Format hasht nur die Passwortspalte. Stuende der Lizenzschluessel auch als Benutzername darin, waere die Datei eine Klartext-Kundenliste. Der Benutzername ist deshalb lic_<sha256(schluessel), 16 Hexzeichen>.",
"action": "Nur relevant, wenn ihr Basic Auth selbst baut statt ReleaseCredentials zu benutzen."
},
{
"area": "sdk",
"breaking": false,
"title": "UpdateCheckResult.Unauthorized",
"text": "Trennt 'Lizenz traegt nicht mehr' von einem Netzwerkfehler. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine Stoerung, und man sucht an der falschen Stelle.",
"action": "Empfohlen: Unauthorized abfragen und dem Benutzer als Lizenzhinweis zeigen."
},
{
"area": "server",
"breaking": false,
"title": "Reihenfolge bei Neuprodukten",
"text": "Fuer ein Produkt ohne Release existiert /releases/<slug>/ nicht und wird uebersprungen. Das Verzeichnis entsteht mit dem ersten Upload, der naechste Abgleich schuetzt es. Es gibt also kein Fenster, um ein ungeschuetztes Release zu ziehen und danach das SDK nachzuruesten.",
"action": "Der erste ausgelieferte Build eines neuen Produkts muss die Zugangsdaten schon mitbringen."
}
]
},
{
"version": "2.3.0",
"date": "2026-08-13",
"summary": "Erstinstallation ueber den Update-Agent, setup.json, Installationskonto.",
"actionRequired": false,
"changes": [
{
"area": "agent",
"breaking": false,
"title": "update-agent --action install",
"text": "Fuehrt durch Anmeldung, Auswahl aus dem Katalog, Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und Rollback.",
"action": "Optional: setup.json ins Publish-Verzeichnis legen, damit die Erstinstallation die noetigen Werte abfragen kann."
},
{
"area": "agent",
"breaking": false,
"title": "setup.json beschreibt die einzurichtenden Werte",
"text": "Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... -> provision -> fragen. Ziele haben ein 'location' (install, config, data, home) und optional fileWindows/fileLinux/fileMacOS. Dateien mit geheimen Werten werden auf den eigenen Benutzer beschraenkt.",
"action": "Optional. Ohne setup.json laesst sich die Anwendung installieren, aber nicht einrichten."
},
{
"area": "server",
"breaking": false,
"title": "Rolle 'installer' und Benutzerverwaltung",
"text": "Konten dieser Rolle koennen sich ausschliesslich ueber /api/setup/v1/login anmelden und Anwendungen einrichten - nicht am WebUI. Die Zugangsdaten werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto verteilte man den Zugang zur gesamten Verwaltung.",
"action": "Migration 012 einspielen. Fuer Erstinstallationen ein installer-Konto anlegen."
}
]
},
{
"version": "2.2.0",
"date": "2026-08-09",
"summary": "Plattform-Dimension, signierte Releases, Update mit Rollback, geschuetzte Konfigurationsdateien.",
"actionRequired": true,
"changes": [
{
"area": "server",
"breaking": true,
"title": "Releases tragen eine Plattform",
"text": "Zuvor gab es die Dimension nicht: win-x64 und linux-x64 landeten unter derselben Version im selben Kanal und ueberschrieben sich: ein Linux-System zog sich das Windows-Paket. Ein Client, der keine Plattform mitschickt, sieht ausschliesslich Releases mit platform=any.",
"action": "PFLICHT beim Veroeffentlichen: --platform win-x64 (o. ae.) an pack-and-deploy. Clientseitig passiert es von selbst - CheckForUpdateAsync schickt ohne Angabe die Kennung des laufenden Systems."
},
{
"area": "packager",
"breaking": true,
"title": "Version wird gegen die Hauptassembly geprueft",
"text": "Weicht --version von der einkompilierten ab, bricht der Vorgang ab. Wird 1.0.1 als 1.0.2 veroeffentlicht, aktualisieren alle Clients, melden danach weiter 1.0.1, halten das Release erneut fuer neu - eine Endlosschleife ueber die gesamte Installationsbasis.",
"action": "<Version> in die Directory.Build.props, nicht in einzelne csproj-Dateien. Notausgang: --ignore-version-mismatch."
},
{
"area": "packager",
"breaking": true,
"title": "preservePatterns schuetzt Konfigurationsdateien",
"text": "Zuvor ueberschrieb jedes Update die eingerichteten Werte des Zielsystems. Ausschluss und Schutz sind zwei verschiedene Dinge: excludePatterns haelt eine Datei aus dem Paket, preservePatterns liefert sie aus, laesst am Ziel aber die vorhandene Fassung in Ruhe. Die Muster sind jetzt echte Globs - 'logs/**' traf zuvor nie zu.",
"action": "preservePatterns in die packager.config.json aufnehmen. Pruefen, ob eure Konfigurationsdateien bisher ueberschrieben wurden."
},
{
"area": "agent",
"breaking": false,
"title": "Anwenden mit Backup und Rollback",
"text": "Die Stelle war als 'Atomic Replace with Backup' kommentiert und war eine Kopierschleife. Bricht sie ab, blieb eine halb aktualisierte Installation zurueck. Jetzt: Plan, Backup, Anwenden, bei Fehler vollstaendiger Rollback. Verwaiste Dateien werden entfernt - aber nur solche aus dem Manifest der Vorversion.",
"action": null
},
{
"area": "sdk",
"breaking": true,
"title": "LaunchUpdateAgent uebergibt Neustart und Prozesskennung",
"text": "--restart wurde nie uebergeben - die Anwendung schloss sich nach 'Jetzt installieren' und blieb zu. --wait-for-pid gab es nicht: der Agent kopierte bei langsamem Herunterfahren ueber gesperrte Dateien. ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes update-agent.exe wird unter Linux nie gefunden.",
"action": "UpdateClient.ResolveAgentPath() statt eines festen Pfads benutzen. currentVersion: BuildInfo.Version mitgeben - fuer Installationen ohne manifest.json."
},
{
"area": "server",
"breaking": false,
"title": "Releases werden signiert",
"text": "Der SHA256 stammt aus derselben Quelle wie das Paket. Wer den Webroot kontrolliert, tauscht beide gemeinsam aus. Der Server signiert jetzt mit RSA-SHA256, der Agent prueft gegen /api/updateservice/v1/pubkey. Bewusst asymmetrisch: bei HMAC braeuchte der pruefende Agent denselben geheimen Schluessel.",
"action": "Serverseitig security.release_private_key hinterlegen. Ohne Schluessel bleiben Releases unsigniert und installierbar."
},
{
"area": "sdk",
"breaking": true,
"title": "CheckForUpdateAsync hat einen Parameter mehr",
"text": "platform steht vor dem CancellationToken. Wer den Token positionell uebergeben hat, bekommt einen Uebersetzungsfehler - kein stilles Fehlverhalten.",
"action": "Benannte Argumente benutzen."
}
]
},
{
"version": "2.1.0",
"date": "2026-08-08",
"summary": "Korrekturen im .NET-SDK, app_version am Heartbeat.",
"actionRequired": true,
"changes": [
{
"area": "sdk",
"breaking": true,
"title": "BuildInfo.targets erzeugt im Namensraum des Projekts",
"text": "Die vorherige Fassung erzeugte fest in Deploymentcenter.Client.Models und war damit nicht einbindbar (CS0433).",
"action": "Ein etwaiges 'using Deploymentcenter.Client.Models' fuer BuildInfo entfernen."
},
{
"area": "sdk",
"breaking": false,
"title": "API-Rueckfall der Update-Pruefung liefert vollstaendige Daten",
"text": "Fehlte die latest.json, kamen ueber die API weder Download-Adresse noch Pruefsumme, Changelog oder Kritikalitaet an - nur 'version' stimmte in beiden Formaten ueberein.",
"action": "Eigene Umgehungen koennen entfallen."
},
{
"area": "sdk",
"breaking": true,
"title": "unknown_error entfaellt, cache_ttl_hours wird ausgewertet",
"text": "Der Cache-Rueckfall greift jetzt bei jedem HTTP-Fehler, nicht nur bei bestimmten. app_version ist Parameter statt fest.",
"action": "Wer unknown_error abfaengt, prueft stattdessen IsTransient. LicenseClient.DefaultAppVersion beim Start setzen."
}
]
},
{
"version": "2.0.0",
"date": "2026-08-07",
"summary": "Sicherheitsumstellung: Veroeffentlichen braucht ein Token, Versionsvergleich nach Semver.",
"actionRequired": true,
"changes": [
{
"area": "server",
"breaking": true,
"title": "Veroeffentlichen verlangt updateservice:publish",
"text": "Zuvor voellig ungeschuetzt - jeder konnte download_url und sha256_hash eines bestehenden Releases ueberschreiben und allen Clients ein beliebiges Paket unterschieben.",
"action": "Token mit dem Recht updateservice:publish erzeugen und als DC_TOKEN hinterlegen."
},
{
"area": "server",
"breaking": true,
"title": "Versionsvergleich folgt der semantischen Ordnung",
"text": "Zuvor verglich SQL lexikografisch: 1.9.0 galt als neuer als 1.10.0, und Clients bekamen ein Downgrade als Update angeboten.",
"action": null
},
{
"area": "server",
"breaking": true,
"title": "Einheitliches Antwortformat",
"text": "Alle JSON-Endpunkte antworten mit {\"status\":\"success\",...} bzw. {\"status\":\"error\",\"error\":{\"code\":...}}. Der code ist stabil und fuer Programme gedacht, die message richtet sich an Menschen.",
"action": "Auswertung der Antworten auf das neue Format umstellen."
}
]
}
]
}
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
/**
* Lesbare Ansicht des Changelogs.
*
* Quelle ist changelog.json - dieselbe Datei, die
* GET /api/updateservice/v1/changelog ausliefert. Zwei Fassungen zu pflegen
* hiesse, sie frueher oder spaeter auseinanderlaufen zu lassen.
*
* /docs/changelog.php alles
* /docs/changelog.php?since=2.2.0 nur was seitdem kam
* /docs/changelog.php?raw die rohe JSON-Datei
*/
$path = __DIR__ . '/changelog.json';
$raw = is_file($path) ? (string)file_get_contents($path) : '';
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
if (isset($_GET['raw'])) {
header('Content-Type: application/json; charset=utf-8');
echo $raw;
exit;
}
$decoded = json_decode($raw, true);
// Auch bei kaputter Datei ein Array - sonst greift die Ausgabe unten auf null zu.
$data = is_array($decoded) ? $decoded : [];
$versions = isset($data['versions']) && is_array($data['versions'])
? $data['versions']
: [];
$since = isset($_GET['since']) ? trim((string)$_GET['since']) : '';
/** Semantischer Vergleich - dieselbe Ordnung wie serverseitig. */
function cl_core(string $v): array
{
$v = ltrim(trim($v), 'vV');
$plus = strpos($v, '+');
if ($plus !== false) { $v = substr($v, 0, $plus); }
$dash = strpos($v, '-');
if ($dash !== false) { $v = substr($v, 0, $dash); }
$out = [];
foreach (explode('.', $v) as $part) {
$out[] = (int)preg_replace('/\D/', '', $part);
}
return $out;
}
function cl_newer(string $candidate, string $than): bool
{
$a = cl_core($candidate);
$b = cl_core($than);
for ($i = 0; $i < max(count($a), count($b)); $i++) {
$x = $a[$i] ?? 0;
$y = $b[$i] ?? 0;
if ($x !== $y) { return $x > $y; }
}
return false;
}
if ($since !== '') {
$versions = array_values(array_filter(
$versions,
static fn(array $v): bool => cl_newer((string)($v['version'] ?? '0'), $since)
));
}
$actionItems = 0;
$breaking = 0;
foreach ($versions as $v) {
foreach ($v['changes'] ?? [] as $c) {
if (!empty($c['action'])) { $actionItems++; }
if (!empty($c['breaking'])) { $breaking++; }
}
}
function e(?string $s): string
{
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
}
$areaLabels = [
'server' => 'Server',
'sdk' => 'SDK',
'agent' => 'Update-Agent',
'packager' => 'Packager',
'tooling' => 'Werkzeuge',
'docs' => 'Dokumentation',
];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deploymentcenter — Änderungen</title>
<style>
:root {
--bg: #0b0f19; --card: #141b2d; --accent: #5b9dff;
--text: #e2e8f0; --muted: #94a3b8; --border: #1f2937;
--warn: #fbbf24; --break: #f87171; --ok: #4ade80;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; line-height: 1.6; }
.wrap { max-width: 60rem; margin: 0 auto; }
h1 { margin: 0 0 .25rem; font-size: 1.75rem; }
.lead { color: var(--muted); margin: 0 0 1.5rem; }
code { font-family: "Fira Code", ui-monospace, monospace; font-size: .85em;
background: rgba(91,157,255,.12); padding: .1rem .35rem; border-radius: 3px; }
pre code { display: block; padding: .75rem; overflow-x: auto; }
.box { background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.25rem; }
.filter { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: .4rem .6rem; border-radius: 5px; font-family: inherit; }
button { background: var(--accent); border: 0; color: #041225; font-weight: 600;
padding: .45rem .9rem; border-radius: 5px; cursor: pointer; font-family: inherit; }
.ver { border-left: 3px solid var(--accent); padding-left: 1rem; margin: 2rem 0; }
.ver h2 { margin: 0; font-size: 1.3rem; }
.date { color: var(--muted); font-size: .85rem; }
.summary { margin: .4rem 0 1rem; }
.chg { border-top: 1px solid var(--border); padding: .9rem 0; }
.chg:first-of-type { border-top: 0; }
.chg h3 { margin: 0 0 .35rem; font-size: 1rem; }
.tag { display: inline-block; font-size: .7rem; font-weight: 700; letter-spacing: .03em;
padding: .1rem .45rem; border-radius: 4px; margin-right: .4rem; vertical-align: middle; }
.t-area { background: rgba(148,163,184,.18); color: var(--muted); }
.t-break { background: rgba(248,113,113,.16); color: var(--break); }
.t-act { background: rgba(251,191,36,.16); color: var(--warn); }
.action { background: rgba(251,191,36,.08); border-left: 2px solid var(--warn);
padding: .5rem .75rem; margin-top: .5rem; font-size: .92rem; }
.action strong { color: var(--warn); }
.none { color: var(--muted); font-style: italic; }
a { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<h1>Änderungen am Deploymentcenter</h1>
<p class="lead">
Aktuelle Fassung: <code><?= e($data['current'] ?? '?') ?></code>.
Trage ein, gegen welche Fassung deine Anbindung gebaut wurde — dann steht hier
nur, was seitdem dazugekommen ist.
</p>
<div class="box">
<form method="get" class="filter">
<label for="since">Meine Fassung:</label>
<input type="text" id="since" name="since" value="<?= e($since) ?>" placeholder="2.2.0" size="10">
<button type="submit">Unterschied zeigen</button>
<?php if ($since !== ''): ?>
<a href="changelog.php" style="margin-left:.5rem;">alles zeigen</a>
<?php endif; ?>
</form>
<p style="margin:.75rem 0 0; color:var(--muted); font-size:.9rem;">
Maschinenlesbar: <code>GET /api/updateservice/v1/changelog?since=<?= e($since !== '' ? $since : '2.2.0') ?></code>
· <a href="changelog.php?raw">rohes JSON</a>
</p>
</div>
<?php if ($versions === []): ?>
<p class="none">
<?= $since !== ''
? 'Seit ' . e($since) . ' hat sich nichts geändert.'
: 'Es ist nichts hinterlegt.' ?>
</p>
<?php else: ?>
<div class="box">
<strong><?= count($versions) ?></strong> Fassung(en)<?= $since !== '' ? ' neuer als ' . e($since) : '' ?>,
davon <strong style="color:var(--warn);"><?= $actionItems ?></strong> Punkt(e) mit Handlungsbedarf
und <strong style="color:var(--break);"><?= $breaking ?></strong> mit Bruch.
</div>
<?php foreach ($versions as $v): ?>
<div class="ver">
<h2><?= e($v['version'] ?? '?') ?>
<span class="date">— <?= e($v['date'] ?? '') ?></span>
</h2>
<p class="summary"><?= e($v['summary'] ?? '') ?></p>
<?php foreach ($v['changes'] ?? [] as $c): ?>
<div class="chg">
<h3>
<span class="tag t-area"><?= e($areaLabels[$c['area'] ?? ''] ?? ($c['area'] ?? '?')) ?></span>
<?php if (!empty($c['breaking'])): ?><span class="tag t-break">BRUCH</span><?php endif; ?>
<?php if (!empty($c['action'])): ?><span class="tag t-act">TUN</span><?php endif; ?>
<?= e($c['title'] ?? '') ?>
</h3>
<div><?= e($c['text'] ?? '') ?></div>
<?php if (!empty($c['action'])): ?>
<div class="action"><strong>Zu tun:</strong> <?= e($c['action']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<p style="margin-top:2.5rem; color:var(--muted); font-size:.9rem;">
<a href="/docs/">Agenten-Handbuch</a> ·
<a href="/docs/release.md">Veröffentlichen</a> ·
<a href="/api/openapi.json">OpenAPI</a>
</p>
</div>
</body>
</html>
@@ -0,0 +1,27 @@
{
"_comment": "Kopie als scripts/release.config.json anlegen und ausfuellen. Diese Datei enthaelt KEINE Zugangsdaten - die kommen aus Umgebungsvariablen (DC_FTP_HOST, DC_FTP_USER, DC_FTP_PASS, DC_TOKEN) oder aus einer packager.config.json neben dem Werkzeug. release.config.json darf deshalb versioniert werden.",
"_project_comment": "Projekt-Slug im Deploymentcenter. Muss dort unter Projekte angelegt sein, sonst schlaegt die Registrierung fehl.",
"project": "myapp",
"_csproj_comment": "Pfad zur Startprojektdatei, relativ zur Repository-Wurzel.",
"csproj": "src/MyApp/MyApp.csproj",
"_runtimes_comment": "Fuer welche Laufzeitkennungen gebaut wird. Je Eintrag entsteht ein eigenes Release - ohne Plattformangabe wuerden sie sich gegenseitig ueberschreiben.",
"runtimes": ["win-x64", "linux-x64"],
"_selfContained_comment": "true nimmt die .NET-Laufzeit ins Paket. Fuer Zielsysteme ohne vorinstalliertes .NET die richtige Wahl - das Paket wird dadurch deutlich groesser.",
"selfContained": true,
"_publishSingleFile_comment": "Alles in eine ausfuehrbare Datei. Bequem, erschwert aber das gezielte Ersetzen einzelner Dateien beim Update.",
"publishSingleFile": false,
"_setupJson_comment": "Beschreibung der einzurichtenden Werte, relativ zur Repository-Wurzel. Wird ins Paket kopiert, damit der Installer sie findet. Fehlt die Datei, wird ohne Einrichtungsschritt ausgeliefert.",
"setupJson": "setup.json",
"_mainAssembly_comment": "Optional. Datei, gegen die pack-and-deploy die Version gegenprueft. Ohne Angabe wird sie aus dem Projekt-Slug bzw. der runtimeconfig.json abgeleitet.",
"mainAssembly": "",
"_baseUrl_comment": "Adresse des Deploymentcenters. Von hier wird auch pack-and-deploy geholt.",
"baseUrl": "https://dc.mhdf.de"
}
+233
View File
@@ -0,0 +1,233 @@
<#
.SYNOPSIS
Veroeffentlicht dieses Projekt im Deploymentcenter.
.DESCRIPTION
Vorlage zum Kopieren nach scripts/release.ps1 des eigenen Projekts.
Anzupassen ist nur der Kopf von release.config.json - dieses Skript
selbst bleibt unveraendert.
Der Ablauf je Zielplattform:
dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
pack-and-deploy uebernimmt dabei Pruefsummen, Dateimanifest, das
Fortschreiben der latest.json und die Anmeldung beim Deploymentcenter.
Das hier nachzubauen waere ein zweiter Ort fuer dieselben Fehler; das
Skript orchestriert nur.
Fehlt das Werkzeug, wird es geholt und die Pruefsumme geprueft.
.EXAMPLE
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
.EXAMPLE
.\scripts\release.ps1 -Version 1.5.0 -Channel beta -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
# Ohne Angabe wird die Version aus Directory.Build.props bzw. der csproj gelesen.
[string] $Version,
[ValidateSet('prod', 'beta', 'dev')]
[string] $Channel = 'prod',
[string] $Changelog,
# Als kritisches Update kennzeichnen (Rollout priorisieren).
[switch] $Critical,
[string] $ConfigFile = (Join-Path $PSScriptRoot 'release.config.json')
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- Konfiguration
if (-not (Test-Path $ConfigFile)) {
throw "Konfiguration fehlt: $ConfigFile`nVorlage kopieren: release.config.example.json -> release.config.json"
}
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
foreach ($required in @('project', 'csproj', 'runtimes')) {
if (-not $config.$required) {
throw "In $ConfigFile fehlt der Eintrag '$required'."
}
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$csprojRel = $config.csproj
$csproj = Join-Path $repoRoot $csprojRel
if (-not (Test-Path $csproj)) {
throw "Projektdatei nicht gefunden: $csproj"
}
$baseUrl = if ($config.baseUrl) { $config.baseUrl.TrimEnd('/') } else { 'https://dc.mhdf.de' }
$toolDir = Join-Path $repoRoot '.dc-tools'
# ---------------------------------------------------------------------- Version
function Get-ProjectVersion {
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben frueher oder spaeter auseinander - und
# pack-and-deploy bricht dann zu Recht mit einem Versionskonflikt ab.
foreach ($candidate in @(
(Join-Path $repoRoot 'Directory.Build.props'),
$csproj
)) {
if (-not (Test-Path $candidate)) { continue }
$match = [regex]::Match((Get-Content $candidate -Raw), '<Version>\s*([^<]+?)\s*</Version>')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
}
return $null
}
if (-not $Version) {
$Version = Get-ProjectVersion
if (-not $Version) {
throw "Keine <Version> in Directory.Build.props oder $csprojRel gefunden. Bitte -Version angeben."
}
Write-Host "Version aus dem Projekt gelesen: $Version" -ForegroundColor DarkGray
}
if (-not $Changelog) {
$Changelog = "Release v$Version"
}
# ------------------------------------------------------------------- Werkzeug
function Get-PackAndDeploy {
$exe = Join-Path $toolDir 'pack-and-deploy.exe'
if (Test-Path $exe) { return $exe }
Write-Host "pack-and-deploy wird geholt ..." -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
$name = 'pack-and-deploy-win-x64.exe'
$temp = Join-Path $toolDir 'download.tmp'
Invoke-WebRequest -Uri "$baseUrl/installer/$name" -OutFile $temp -UseBasicParsing
$expectedRaw = (Invoke-WebRequest -Uri "$baseUrl/installer/$name.sha256" -UseBasicParsing).Content
$expected = if ($expectedRaw -is [byte[]]) {
[System.Text.Encoding]::ASCII.GetString($expectedRaw)
} else { [string]$expectedRaw }
$expected = $expected.Trim().ToLower()
$actual = (Get-FileHash $temp -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Remove-Item $temp -Force
throw "Pruefsumme von $name stimmt nicht.`n erwartet: $expected`n erhalten: $actual"
}
Move-Item $temp $exe -Force
try { Unblock-File $exe -ErrorAction SilentlyContinue } catch { }
Write-Host " Pruefsumme in Ordnung." -ForegroundColor DarkGray
return $exe
}
$packAndDeploy = Get-PackAndDeploy
# --------------------------------------------------------------------- Ablauf
Write-Host ''
Write-Host "Projekt : $($config.project)" -ForegroundColor White
Write-Host "Version : $Version"
Write-Host "Kanal : $Channel"
Write-Host "Plattform : $($config.runtimes -join ', ')"
Write-Host ''
$results = @()
foreach ($rid in $config.runtimes) {
Write-Host "=== $rid ===" -ForegroundColor Cyan
$publishDir = Join-Path $repoRoot "artifacts/publish/$rid"
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'dotnet publish')) {
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
$publishArgs = @(
'publish', $csproj,
'-c', 'Release',
'-r', $rid,
'-o', $publishDir,
'--nologo'
)
if ($config.selfContained) { $publishArgs += '--self-contained', 'true' }
else { $publishArgs += '--self-contained', 'false' }
if ($config.publishSingleFile) { $publishArgs += '-p:PublishSingleFile=true' }
& dotnet @publishArgs
if ($LASTEXITCODE -ne 0) { throw "dotnet publish fuer $rid ist fehlgeschlagen." }
}
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
# Bewusst kein Ternary-Operator: den gibt es erst ab PowerShell 7, und
# diese Vorlage soll auch mit dem mitgelieferten 5.1 laufen.
$setupRel = if ($config.setupJson) { $config.setupJson } else { 'setup.json' }
$setupJson = Join-Path $repoRoot $setupRel
if (Test-Path $setupJson) {
Copy-Item $setupJson (Join-Path $publishDir 'setup.json') -Force
Write-Host " setup.json mitgenommen" -ForegroundColor DarkGray
}
if ($PSCmdlet.ShouldProcess("$($config.project) $Version ($rid)", 'pack-and-deploy')) {
$packArgs = @(
'--project', $config.project,
'--version', $Version,
'--channel', $Channel,
'--platform', $rid,
'--publish-dir', $publishDir,
'--changelog', $Changelog
)
if ($Critical) { $packArgs += '--critical' }
if ($config.mainAssembly) { $packArgs += '--main-assembly', $config.mainAssembly }
& $packAndDeploy @packArgs
$code = $LASTEXITCODE
$results += [pscustomobject]@{ Runtime = $rid; ExitCode = $code }
# 1 = Konfigurationsfehler oder Versionskonflikt: dann stimmt etwas
# Grundsaetzliches, und die weiteren Plattformen wuerden genauso
# scheitern.
if ($code -eq 1) { throw "pack-and-deploy meldet einen Konfigurations- oder Versionsfehler." }
}
Write-Host ''
}
# ------------------------------------------------------------------ Ergebnis
Write-Host '=== Ergebnis ===' -ForegroundColor White
foreach ($r in $results) {
$text = switch ($r.ExitCode) {
0 { 'vollstaendig veroeffentlicht' }
2 { 'TEILWEISE - Upload oder Registrierung fehlgeschlagen' }
default { "unerwarteter Rueckgabewert $($r.ExitCode)" }
}
$color = if ($r.ExitCode -eq 0) { 'Green' } else { 'Yellow' }
Write-Host (" {0,-16} {1}" -f $r.Runtime, $text) -ForegroundColor $color
}
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
if ($failed.Count -gt 0) {
Write-Host ''
Write-Host 'Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen,' -ForegroundColor Yellow
Write-Host 'ob die bereits hochgeladenen Dateien konsistent sind.' -ForegroundColor Yellow
exit 2
}
Write-Host ''
Write-Host "Fertig. $($config.project) $Version ist im Kanal $Channel verfuegbar." -ForegroundColor Green
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env bash
#
# Veroeffentlicht dieses Projekt im Deploymentcenter.
#
# Vorlage zum Kopieren nach scripts/release.sh des eigenen Projekts.
# Anzupassen ist nur scripts/release.config.json - dieses Skript selbst
# bleibt unveraendert.
#
# Der Ablauf je Zielplattform:
# dotnet publish -r <rid> -> pack-and-deploy --platform <rid>
#
# pack-and-deploy uebernimmt Pruefsummen, Dateimanifest, das Fortschreiben der
# latest.json und die Anmeldung beim Deploymentcenter. Das hier nachzubauen
# waere ein zweiter Ort fuer dieselben Fehler; das Skript orchestriert nur.
#
# ./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
# ./scripts/release.sh --version 1.5.0 --channel beta --dry-run
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_FILE="${DC_RELEASE_CONFIG:-$SCRIPT_DIR/release.config.json}"
VERSION=""
CHANNEL="prod"
CHANGELOG=""
CRITICAL=0
DRY_RUN=0
usage() {
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--changelog) CHANGELOG="$2"; shift 2 ;;
--critical) CRITICAL=1; shift ;;
--dry-run|-n) DRY_RUN=1; shift ;;
--help|-h) usage ;;
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
esac
done
# ------------------------------------------------------------- Voraussetzungen
command -v dotnet >/dev/null 2>&1 || {
echo "FEHLER: dotnet wird gebraucht, ist aber nicht installiert." >&2
exit 1
}
[ -f "$CONFIG_FILE" ] || {
echo "FEHLER: Konfiguration fehlt: $CONFIG_FILE" >&2
echo " Vorlage kopieren: release.config.example.json -> release.config.json" >&2
exit 1
}
# JSON lesen - mit jq, sonst mit Python.
#
# Bewusst nicht nur jq: Es ist auf den wenigsten Systemen vorinstalliert, und
# an einer fehlenden Abhaengigkeit soll die Vorlage nicht scheitern. Python
# liegt auf den meisten Entwickler- und CI-Systemen ohnehin bereit.
JSON_READER=""
if command -v jq >/dev/null 2>&1; then
JSON_READER="jq"
else
# Nicht nur pruefen, ob der Befehl existiert, sondern ob er laeuft:
# Windows legt unter WindowsApps einen python3-Platzhalter ab, der
# gefunden wird, beim Aufruf aber nur auf den Store verweist.
for candidate in python3 python; do
if command -v "$candidate" >/dev/null 2>&1 \
&& "$candidate" -c "import json" >/dev/null 2>&1; then
JSON_READER="$candidate"
break
fi
done
fi
if [ -z "$JSON_READER" ]; then
echo "FEHLER: Zum Lesen von $CONFIG_FILE wird jq oder ein lauffaehiges Python gebraucht." >&2
exit 1
fi
# Liest einen Skalar. Pfad in jq-Schreibweise, z. B. .project
cfg() {
if [ "$JSON_READER" = "jq" ]; then
jq -r "$1 // empty" "$CONFIG_FILE"
else
"$JSON_READER" -c "
import json,sys
d=json.load(open(sys.argv[1], encoding='utf-8'))
for part in sys.argv[2].lstrip('.').split('.'):
if not isinstance(d, dict): d=None; break
d=d.get(part)
if d is None: print('')
elif isinstance(d, bool): print('true' if d else 'false')
else: print(d)
" "$CONFIG_FILE" "$1"
fi
}
# Liest ein Feld mit Zeichenketten, eine je Zeile.
cfg_list() {
if [ "$JSON_READER" = "jq" ]; then
jq -r "$1[]?" "$CONFIG_FILE"
else
"$JSON_READER" -c "
import json,sys
d=json.load(open(sys.argv[1], encoding='utf-8'))
for part in sys.argv[2].lstrip('.').split('.'):
d = d.get(part) if isinstance(d, dict) else None
for item in (d or []): print(item)
" "$CONFIG_FILE" "$1"
fi
}
PROJECT="$(cfg .project)"
CSPROJ_REL="$(cfg .csproj)"
BASE_URL="$(cfg .baseUrl)"
BASE_URL="${BASE_URL:-https://dc.mhdf.de}"
BASE_URL="${BASE_URL%/}"
SETUP_REL="$(cfg .setupJson)"
SETUP_REL="${SETUP_REL:-setup.json}"
MAIN_ASSEMBLY="$(cfg .mainAssembly)"
[ -n "$PROJECT" ] || { echo "FEHLER: 'project' fehlt in $CONFIG_FILE" >&2; exit 1; }
[ -n "$CSPROJ_REL" ] || { echo "FEHLER: 'csproj' fehlt in $CONFIG_FILE" >&2; exit 1; }
CSPROJ="$REPO_ROOT/$CSPROJ_REL"
[ -f "$CSPROJ" ] || { echo "FEHLER: Projektdatei nicht gefunden: $CSPROJ" >&2; exit 1; }
mapfile -t RUNTIMES < <(cfg_list .runtimes)
[ "${#RUNTIMES[@]}" -gt 0 ] || { echo "FEHLER: 'runtimes' ist leer." >&2; exit 1; }
SELF_CONTAINED="$(cfg .selfContained)"
SINGLE_FILE="$(cfg .publishSingleFile)"
# -------------------------------------------------------------------- Version
if [ -z "$VERSION" ]; then
# Directory.Build.props zuerst: Steht <Version> nur in einem von mehreren
# Projekten, laufen die Angaben auseinander - und pack-and-deploy bricht
# dann zu Recht mit einem Versionskonflikt ab.
for candidate in "$REPO_ROOT/Directory.Build.props" "$CSPROJ"; do
[ -f "$candidate" ] || continue
VERSION="$(sed -n 's:.*<Version>\s*\([^<]*\)\s*</Version>.*:\1:p' "$candidate" | head -1 | tr -d '[:space:]')"
[ -n "$VERSION" ] && break
done
[ -n "$VERSION" ] || {
echo "FEHLER: Keine <Version> gefunden. Bitte --version angeben." >&2
exit 1
}
echo "Version aus dem Projekt gelesen: $VERSION"
fi
CHANGELOG="${CHANGELOG:-Release v$VERSION}"
# ------------------------------------------------------------------- Werkzeug
TOOL_DIR="$REPO_ROOT/.dc-tools"
PACK="$TOOL_DIR/pack-and-deploy"
fetch_tool() {
[ -x "$PACK" ] && return 0
case "$(uname -m)" in
x86_64|amd64) rid="linux-x64" ;;
aarch64|arm64) rid="linux-arm64" ;;
*) echo "FEHLER: Nicht unterstuetzte Architektur $(uname -m)" >&2; exit 1 ;;
esac
echo "pack-and-deploy wird geholt ($rid) ..."
mkdir -p "$TOOL_DIR"
tmp="$(mktemp)"
curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid" -o "$tmp"
expected="$(curl -fsSL "$BASE_URL/installer/pack-and-deploy-$rid.sha256" | tr -d ' \t\r\n')"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$tmp" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$tmp" | cut -d' ' -f1)"
fi
if [ "$actual" != "$expected" ]; then
rm -f "$tmp"
echo "FEHLER: Pruefsumme stimmt nicht." >&2
echo " erwartet: $expected" >&2
echo " erhalten: $actual" >&2
exit 1
fi
chmod +x "$tmp"
mv "$tmp" "$PACK"
echo " Pruefsumme in Ordnung."
}
fetch_tool
# --------------------------------------------------------------------- Ablauf
echo
echo "Projekt : $PROJECT"
echo "Version : $VERSION"
echo "Kanal : $CHANNEL"
echo "Plattform : ${RUNTIMES[*]}"
[ "$DRY_RUN" -eq 1 ] && echo "(Probelauf - es wird nichts hochgeladen)"
echo
FAILED=0
for rid in "${RUNTIMES[@]}"; do
echo "=== $rid ==="
PUBLISH_DIR="$REPO_ROOT/artifacts/publish/$rid"
if [ "$DRY_RUN" -eq 0 ]; then
# Sauber neu bauen: Reste einer vorherigen Laufzeitkennung wuerden
# sonst mit ins Paket wandern.
rm -rf "$PUBLISH_DIR"
publish_args=(publish "$CSPROJ" -c Release -r "$rid" -o "$PUBLISH_DIR" --nologo)
if [ "$SELF_CONTAINED" = "true" ]; then
publish_args+=(--self-contained true)
else
publish_args+=(--self-contained false)
fi
[ "$SINGLE_FILE" = "true" ] && publish_args+=(-p:PublishSingleFile=true)
dotnet "${publish_args[@]}"
# setup.json mitliefern, damit der Installer weiss, was einzurichten ist.
if [ -f "$REPO_ROOT/$SETUP_REL" ]; then
cp "$REPO_ROOT/$SETUP_REL" "$PUBLISH_DIR/setup.json"
echo " setup.json mitgenommen"
fi
pack_args=(
--project "$PROJECT"
--version "$VERSION"
--channel "$CHANNEL"
--platform "$rid"
--publish-dir "$PUBLISH_DIR"
--changelog "$CHANGELOG"
)
[ "$CRITICAL" -eq 1 ] && pack_args+=(--critical)
[ -n "$MAIN_ASSEMBLY" ] && pack_args+=(--main-assembly "$MAIN_ASSEMBLY")
set +e
"$PACK" "${pack_args[@]}"
code=$?
set -e
case "$code" in
0) echo " -> vollstaendig veroeffentlicht" ;;
1)
# Konfigurationsfehler oder Versionskonflikt: die weiteren
# Plattformen wuerden genauso scheitern.
echo "FEHLER: Konfigurations- oder Versionsfehler - Abbruch." >&2
exit 1
;;
2) echo " -> TEILWEISE: Upload oder Registrierung fehlgeschlagen"; FAILED=1 ;;
*) echo " -> unerwarteter Rueckgabewert $code"; FAILED=1 ;;
esac
fi
echo
done
if [ "$FAILED" -ne 0 ]; then
echo "Nicht alle Plattformen sind durchgelaufen. Vor einem erneuten Versuch pruefen," >&2
echo "ob die bereits hochgeladenen Dateien konsistent sind." >&2
exit 2
fi
echo "Fertig. $PROJECT $VERSION ist im Kanal $CHANNEL verfuegbar."
+257
View File
@@ -0,0 +1,257 @@
# Ein Projekt veröffentlichungsfähig machen
> Für Coding-Agenten, die den UpdateService in ein Projekt integrieren.
> Ergebnis: `./scripts/release.ps1 -Version 1.4.3` baut, packt, lädt hoch und
> meldet das Release beim Deploymentcenter an — für alle Zielplattformen.
> **Aktualisierst du eine bestehende Anbindung?** Dann lies nicht alles neu.
> Frag zuerst, was seit deiner Fassung dazugekommen ist:
>
> ```
> GET /api/updateservice/v1/changelog?since=2.2.0
> ```
>
> `count: 0` heißt: du bist auf Stand. Sonst nennt jeder Eintrag unter
> `action`, was konkret zu tun ist. Für Menschen: [/docs/changelog.php](./changelog.php).
> Die eigene Fassung steht in `DeploymentcenterSdk.Version` — **und gehört in
> die `AGENTS.md` deines Projekts**, sonst fängst du beim nächsten Mal wieder
> von vorn an.
Es gibt bereits ein Werkzeug, das den schwierigen Teil erledigt:
**`pack-and-deploy`**. Es berechnet Prüfsummen, erzeugt das Dateimanifest,
schreibt die `latest.json` fort und meldet das Release über die API an. **Baue
das nicht nach.** Ein zweiter Ort für dieselbe Logik ist ein zweiter Ort, an
dem dieselben Fehler wieder entstehen — und dieses Werkzeug hat sie bereits
hinter sich.
Was fehlt, ist nur die Orchestrierung: pro Zielplattform einmal
`dotnet publish`, dann `pack-and-deploy`. Genau das ist die Vorlage.
---
## 1. Einrichten
Drei Dateien, einmalig:
```bash
mkdir -p scripts .dc-tools
# Windows
curl -fsSL https://dc.mhdf.de/docs/release-template/release.ps1 -o scripts/release.ps1
# Linux / CI
curl -fsSL https://dc.mhdf.de/docs/release-template/release.sh -o scripts/release.sh
chmod +x scripts/release.sh
# in beiden Fällen
curl -fsSL https://dc.mhdf.de/docs/release-template/release.config.example.json \
-o scripts/release.config.json
echo '.dc-tools/' >> .gitignore
echo 'artifacts/' >> .gitignore
```
**Nur `release.config.json` wird angepasst.** Die Skripte selbst bleiben
unverändert — dann lassen sie sich bei einer neuen Fassung einfach ersetzen.
```json
{
"project": "myapp",
"csproj": "src/MyApp/MyApp.csproj",
"runtimes": ["win-x64", "linux-x64"],
"selfContained": true,
"setupJson": "setup.json"
}
```
`release.config.json` enthält **keine Zugangsdaten** und darf versioniert
werden.
### Zugangsdaten
Die kommen aus Umgebungsvariablen:
```bash
export DC_FTP_HOST=ftp.example.com
export DC_FTP_USER=...
export DC_FTP_PASS=...
export DC_TOKEN=dc_master_... # braucht das Recht updateservice:publish
```
Alternativ eine `packager.config.json` neben dem Werkzeug — die steht dann in
`.gitignore`. Ohne `DC_TOKEN` wird das Paket zwar gebaut und hochgeladen, aber
**nicht angemeldet und nicht signiert**; der Rückgabewert ist dann 2.
### Voraussetzungen
| | Windows | Linux |
|---|---|---|
| Skript | `release.ps1` (PowerShell 5.1 genügt) | `release.sh` |
| Nötig | .NET SDK | .NET SDK, `curl`, dazu `jq` **oder** Python |
`pack-and-deploy` holt sich das Skript beim ersten Lauf selbst von
`/installer/`, prüft die Prüfsumme und legt es unter `.dc-tools/` ab. Das
Verzeichnis gehört in die `.gitignore`.
---
## 2. Veröffentlichen
```powershell
.\scripts\release.ps1 -Version 1.4.3 -Changelog "Behebt den Login-Fehler."
```
```bash
./scripts/release.sh --version 1.4.3 --changelog "Behebt den Login-Fehler."
```
Ohne `-Version` wird sie aus `Directory.Build.props` oder der `.csproj`
gelesen. Weitere Schalter: `-Channel beta`, `-Critical`, `-WhatIf`
beziehungsweise `--channel`, `--critical`, `--dry-run`.
Je Laufzeitkennung entsteht ein eigenes Release. **Ohne Plattformangabe würden
sie sich gegenseitig überschreiben** — bis Version 2.2 war genau das der Fall,
und ein Linux-System zog sich das Windows-Paket.
---
## 3. Was du im Projekt vorbereiten musst
### `<Version>` in die `Directory.Build.props`
```xml
<Project>
<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>
</Project>
```
**Nicht in einzelne `.csproj`-Dateien.** `pack-and-deploy` liest die Version
aus der Hauptassembly und **bricht bei einer Abweichung ab**. Das ist Absicht:
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.
### Konfigurationsdateien schützen
Zwei verschiedene Dinge, und die Verwechslung hat schon einen echten
API-Schlüssel öffentlich gemacht:
| | `excludePatterns` | `preservePatterns` |
|---|---|---|
| Im Paket? | nein | ja |
| Erstinstallation | fehlt | wird geschrieben |
| Update | — | vorhandene Datei bleibt unangetastet |
Eine `appsettings.json` mit echten Zugangsdaten gehört in **keine** von beiden
Listen — sie gehört gar nicht erst ins Publish-Verzeichnis. Was ausgeliefert
wird, muss eine **Vorlage mit Platzhaltern** sein.
**`pack-and-deploy` bricht ab, wenn es Zugangsdaten findet** — geprüft werden
Dateinamen (`appsettings.Local.json`, `master.key`, `*.pfx`, `*.db`, …) und der
Inhalt (gefüllte `Password=`-Felder, `sk-…`, `dc_master_…`, private Schlüssel).
Platzhalter wie `sk-DEIN-SCHLUESSEL-HIER` lösen nicht aus.
Häufigste Ursache: eine `CopyToOutputDirectory`-Regel in der `.csproj`, die
eine lokale Konfigurationsdatei ins Ausgabeverzeichnis kopiert. Dann gehört sie
in `excludePatterns` — und die Regel auf den Prüfstand.
Notausgang: `--allow-secrets`. **Nur benutzen, wenn du weißt, warum.**
Release-Pakete liegen hinter einem Zugangsschutz, aber jeder lizenzierte Kunde
kann sie auspacken.
### `setup.json` anlegen
Beschreibt, was die Erstinstallation abfragen muss. Das Skript kopiert sie ins
Publish-Verzeichnis, sodass sie im Paket landet.
```json
{
"schema": 1,
"targets": [
{ "id": "app", "file": "myapp/Settings.json", "location": "config" }
],
"fields": [
{ "key": "ConnectionStrings:Main", "label": "Datenbank", "type": "secret" },
{ "key": "Deploymentcenter:Token", "source": "provision",
"scopes": ["watchdog:ping"] }
]
}
```
Vollständig beschrieben in `docs/SETUP_INTEGRATION_GUIDE.md` im
Deploymentcenter-Repository.
Ohne `setup.json` lässt sich die Anwendung installieren, aber nicht einrichten.
### Das SDK einbinden
```csharp
var check = await new UpdateClient().CheckForUpdateAsync(
baseUrl: "https://dc.mhdf.de", projectId: "myapp",
currentVersion: BuildInfo.Version, channel: "prod",
credentials: ReleaseCredentials.FromLicenseKey(meineLizenz));
```
**Der Lizenzschlüssel ist Pflicht.** Die Release-Ablage liegt hinter
HTTP-Basic-Auth; ohne ihn bekommt die Anwendung 401 und keine Updates mehr.
Details in `docs/UPDATESERVICE_INTEGRATION_GUIDE.md` §5A
(Zugangsschutz der Release-Verzeichnisse).
> **Für ein Produkt, das noch nie veröffentlicht hat, gilt eine besondere
> Reihenfolge.** `/releases/<slug>/` existiert noch nicht und ist deshalb auch
> nicht geschützt. Das Verzeichnis entsteht mit dem ersten Upload, und der
> nächste Abgleich schützt es. Es gibt also kein Zeitfenster, um ein
> ungeschütztes Release zu ziehen und danach das SDK nachzurüsten: **der erste
> ausgelieferte Build muss die Zugangsdaten schon mitbringen.**
---
## 4. Rückgabewerte
| Wert | Bedeutung |
|---|---|
| `0` | vollständig veröffentlicht |
| `1` | Konfigurationsfehler oder Versionskonflikt — **nichts wurde ausgeführt** |
| `2` | teilweise: Upload oder Registrierung fehlgeschlagen |
Bei `1` bricht das Skript sofort ab, statt die übrigen Plattformen ins Leere
laufen zu lassen. Bei `2` läuft es weiter und meldet am Ende, welche
Plattformen betroffen sind — dort ist zu prüfen, ob die bereits hochgeladenen
Dateien zusammenpassen.
---
## 5. Prüfen, ob es getragen hat
```bash
curl "https://dc.mhdf.de/api/updateservice/v1/check?product=myapp&version=0.0.0&channel=prod&platform=win-x64"
```
Erwartet: `update_available: true` mit der neuen Version und `"signed": true`
in der Antwort des Publish-Aufrufs. Im WebUI erscheint das Release unter
*UpdateService → Releases* mit Plattform-Spalte; steht dort **UNSIGNIERT**,
fehlt auf dem Server der Signierschlüssel.
Und der Zugangsschutz:
```bash
curl -I https://dc.mhdf.de/releases/myapp/prod/win-x64/1.4.3/package.tar.gz # 401 erwartet
```
Antwortet das mit **200**, ist das Paket öffentlich abrufbar — dann im WebUI
unter *UpdateService → 🔒 Zugangsschutz* den Selbsttest laufen lassen.
---
## 6. Häufige Stolperstellen
| Symptom | Ursache |
|---|---|
| `Versionskonflikt` beim Packen | `<Version>` steht nur in einem von mehreren Projekten — gehört in die `Directory.Build.props` |
| Rückgabewert 2, „Registrierung fehlgeschlagen" | `DC_TOKEN` fehlt oder hat nicht `updateservice:publish` |
| `unknown_project` | Der Slug ist im Deploymentcenter nicht angelegt |
| Client bekommt 401 statt Updates | Kein `licenseKey` übergeben, oder die Lizenz ist abgelaufen |
| Linux-Paket startet nicht | Unter Windows gebaut — der Agent setzt das Ausführungsbit beim Anwenden, ein von Hand entpacktes Archiv nicht |
| Update lädt endlos erneut | Veröffentlichte Version weicht von der einkompilierten ab |
+142 -47
View File
@@ -35,6 +35,7 @@ use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\AgentScript;
use Deploymentcenter\Modules\Watchdog\Evaluator;
use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
@@ -365,46 +366,18 @@ if (($_GET['action'] ?? '') === 'download_agent') {
$os = trim((string)($_GET['os'] ?? 'windows'));
$token = trim((string)($_GET['token'] ?? ''));
// Nur unkritische Zeichen, damit nichts in das erzeugte Skript
// hineingeschmuggelt werden kann.
$source = preg_replace('/[^A-Za-z0-9._ -]/', '', $source) ?? 'server-node';
$token = preg_replace('/[^A-Za-z0-9._-]/', '', $token) ?? '';
$endpoint = Http::baseUrl() . '/api/watchdog/v1/ping';
if ($os === 'windows') {
$script = "# Deploymentcenter Watchdog Agent (Windows PowerShell)\n"
. "# Als geplante Aufgabe minuetlich ausfuehren.\n\n"
. '$WatchdogUrl = "' . $endpoint . "\"\n"
. '$Token = "' . $token . "\"\n"
. '$Source = "' . $source . "\"\n\n"
. '$body = @{ source = $Source; status = "ok"; message = "Heartbeat via PowerShell Task"; interval = 60 } | ConvertTo-Json' . "\n"
. 'try {' . "\n"
. ' Invoke-RestMethod -Uri $WatchdogUrl -Method Post -Body $body -ContentType "application/json" -Headers @{ "X-Agent-Token" = $Token } | Out-Null' . "\n"
. ' Write-Host "[OK] Heartbeat gesendet."' . "\n"
. '} catch {' . "\n"
. ' Write-Error "Heartbeat fehlgeschlagen: $_"' . "\n"
. ' exit 1' . "\n"
. '}' . "\n";
$filename = 'watchdog-agent-' . $source . '.ps1';
} else {
$script = "#!/usr/bin/env bash\n"
. "# Deploymentcenter Watchdog Agent (Linux)\n"
. "# Per Cron minuetlich ausfuehren.\n\n"
. 'set -euo pipefail' . "\n\n"
. 'WATCHDOG_URL="' . $endpoint . "\"\n"
. 'TOKEN="' . $token . "\"\n"
. 'SOURCE="' . $source . "\"\n\n"
. 'curl -fsS -X POST "$WATCHDOG_URL" \\' . "\n"
. ' -H "Content-Type: application/json" \\' . "\n"
. ' -H "X-Agent-Token: $TOKEN" \\' . "\n"
. ' -d "{\"source\": \"$SOURCE\", \"status\": \"ok\", \"message\": \"Heartbeat via Cron\", \"interval\": 60}" \\' . "\n"
. ' > /dev/null && echo "[OK] Heartbeat gesendet."' . "\n";
$filename = 'watchdog-agent-' . $source . '.sh';
}
// Erzeugt wird in AgentScript - dieselbe Fassung, die der Installer ueber
// den Setup-Weg bekommt. Das Saeubern der Eingaben passiert dort mit.
$agent = AgentScript::build(
$os,
$source,
$token,
Http::baseUrl() . '/api/watchdog/v1/ping'
);
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9._-]/', '', $filename) . '"');
echo $script;
header('Content-Disposition: attachment; filename="' . $agent['filename'] . '"');
echo $agent['script'];
exit;
}
@@ -1265,9 +1238,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#sub-system-rocketchat');
}
case 'toggle_release_guard_exempt': {
$slug = trim((string)($_POST['slug'] ?? ''));
$makeExempt = ($_POST['exempt'] ?? '') === '1';
if ($slug === '') {
dc_flash('Kein Produkt angegeben.', 'danger');
dc_redirect('#sub-update-guard');
}
ReleaseGuard::setExempt($pdo, $slug, $makeExempt);
if ($makeExempt) {
dc_flash(sprintf(
'Achtung: "%s" ist jetzt OHNE Zugangsschutz - jeder im Internet kann die Pakete '
. 'herunterladen. Nur so lange lassen, bis die ausgelieferten Installationen ihren '
. 'Lizenzschluessel mitschicken.',
e($slug)
), 'danger');
} else {
dc_flash(sprintf(
'Zugangsschutz fuer "%s" ist wieder aktiv. Installationen ohne Lizenzschluessel '
. 'bekommen ab jetzt 401.',
e($slug)
));
}
dc_redirect('#sub-update-guard');
}
case 'regenerate_release_guard': {
$guard = ReleaseGuard::regenerateAll($pdo);
// Direkt gegenpruefen, ob der Schutz tatsaechlich greift.
// Vorhandene Dateien beweisen nichts - unter Nginx werden sie
// ignoriert, und die Oberflaeche meldete trotzdem Erfolg.
$test = ReleaseGuard::selfTest($pdo);
if ($test['failed'] !== []) {
$reasons = array_map(
static fn(array $f): string => ($f['slug'] ?? '?') . ': ' . ($f['reason'] ?? ''),
$test['failed']
);
dc_flash(
'Zugangsschutz erzeugt, aber der Selbsttest schlaegt fehl! ' . e(implode(' | ', $reasons)),
'danger'
);
dc_redirect('#sub-update-guard');
}
if ($guard['errors'] !== []) {
dc_flash(
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
@@ -1275,9 +1294,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
);
} else {
dc_flash(sprintf(
'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s',
'Zugangsschutz erneuert und geprueft: %d Produkt(e), %d Zugang/Zugaenge, '
. '%d Adresse(n) antworten ohne Zugangsdaten korrekt mit 401.%s',
$guard['projects'],
$guard['entries'],
$test['ok'],
$guard['skipped'] !== []
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
: ''
@@ -1331,10 +1352,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Installationskonten stehen in jeder .htpasswd - ohne sie
// waere eine Erstinstallation nicht moeglich, weil es dabei
// noch keinen Lizenzschluessel gibt.
if ($newRole === 'installer') {
dc_guard_releases($pdo);
}
// noch keinen Lizenzschluessel gibt. Administratoren zaehlen
// dazu; die Bedingung stand hier noch aus der Zeit, als nur
// die Rolle 'installer' Zugang hatte, und liess ein frisches
// Administratorkonto bis zum naechsten turnusmaessigen Lauf
// vor verschlossener Ablage stehen.
dc_guard_releases($pdo);
dc_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s',
@@ -1650,9 +1673,15 @@ function getMonitorIconUrl(?string $icon, ?string $source, ?string $os, ?string
if (str_starts_with($icon, 'custom/')) {
return 'assets/icons/' . $icon;
}
if (str_starts_with($icon, 'assets/')) {
if (str_starts_with($icon, 'assets/icons/')) {
return $icon;
}
// Fruehere Fassungen legten die Symbole flach unter assets/ ab und
// schrieben diesen Pfad in die Datenbank. Die Dateien liegen jetzt nur
// noch unter assets/icons/ - Altwerte werden hier umgesetzt.
if (str_starts_with($icon, 'assets/')) {
return 'assets/icons/' . substr($icon, strlen('assets/'));
}
return 'assets/icons/' . $icon;
}
@@ -1702,7 +1731,11 @@ try {
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
$releaseGuard = ReleaseGuard::status();
$releaseGuard = ReleaseGuard::status($pdo);
// Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je
// Produkt bei jedem Seitenaufbau waere Verschwendung.
$releaseGuardTest = ReleaseGuard::lastSelfTest($pdo);
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
// werden nicht von deploy.py uebertragen, sondern von
@@ -3159,17 +3192,21 @@ Antwort:
<th>Status</th>
<th>Zugänge</th>
<th>Zuletzt erzeugt</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php if ($releaseGuard === []): ?>
<tr><td colspan="4" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
<tr><td colspan="5" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
<?php endif; ?>
<?php foreach ($releaseGuard as $g): ?>
<?php $isExempt = !empty($g['exempt']); ?>
<tr>
<td><strong><?= e((string)$g['slug']) ?></strong></td>
<td>
<?php if (!$g['protected']): ?>
<?php if ($isExempt): ?>
<span class="badge badge-down">AUSGENOMMEN</span>
<?php elseif (!$g['protected']): ?>
<span class="badge badge-down">OFFEN</span>
<?php elseif ((int)$g['entries'] === 0): ?>
<span class="badge badge-warning">GESPERRT</span>
@@ -3184,12 +3221,58 @@ Antwort:
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
</td>
<td>
<form method="POST" action="index.php" style="display:inline;"
<?= $isExempt ? '' : 'onsubmit="return confirm(\'Damit kann jeder im Internet die Pakete von ' . e((string)$g['slug']) . ' herunterladen. Wirklich?\');"' ?>>
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_release_guard_exempt">
<input type="hidden" name="_return" value="sub-update-guard">
<input type="hidden" name="slug" value="<?= e((string)$g['slug']) ?>">
<input type="hidden" name="exempt" value="<?= $isExempt ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm <?= $isExempt ? '' : 'btn-secondary' ?>">
<?= $isExempt ? 'Schutz aktivieren' : 'Ausnehmen' ?>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($releaseGuardTest !== null): ?>
<?php $testFailed = !empty($releaseGuardTest['failed']); ?>
<div class="alert" style="margin-top:1rem; <?= $testFailed
? 'background:rgba(255,70,70,0.12); border:1px solid rgba(255,70,70,0.35); color:#fecaca;'
: 'background:rgba(60,200,120,0.12); border:1px solid rgba(60,200,120,0.3); color:#bbf7d0;' ?>">
<strong>Selbsttest vom <?= e((string)($releaseGuardTest['ran_at'] ?? '?')) ?> UTC:</strong>
<?= (int)($releaseGuardTest['ok'] ?? 0) ?> von
<?= (int)($releaseGuardTest['checked'] ?? 0) ?> Adresse(n) antworten ohne
Zugangsdaten korrekt mit 401.
<?php if ($testFailed): ?>
<ul style="margin:0.5rem 0 0 1rem;">
<?php foreach ($releaseGuardTest['failed'] as $f): ?>
<li><strong><?= e((string)($f['slug'] ?? '?')) ?>:</strong> <?= e((string)($f['reason'] ?? '')) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>Der Selbsttest ist die einzige belastbare Aussage.</strong> Dass die
Dateien vorhanden sind, beweist nichts: Unter Nginx werden
<code>.htaccess</code>-Dateien vollständig ignoriert die Verzeichnisse wären
dann offen und die <code>.htpasswd</code> öffentlich abrufbar, während hier
„GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten
ab und erwartet 401.
</p>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>AUSGENOMMEN</strong> heißt: bewusst offen gelassen für Produkte,
deren ausgelieferte Installationen noch mit einem SDK vor 2.4 gebaut sind und
deshalb keinen Lizenzschlüssel mitschicken. Sie bekämen sonst 401 statt
Updates. <strong>Sobald ein Build mit Schlüssel draußen ist, wieder
einschalten.</strong><br>
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
<strong>GESPERRT</strong> heißt: niemand kommt heran, weil es weder gültige
Lizenzen noch Installationskonten gibt bei internen Anwendungen ohne
@@ -3305,6 +3388,12 @@ chmod +x update-agent
man Nutzer sonst warnt.
</p>
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
Nur die <strong>Maschine überwachen</strong>, ohne etwas zu installieren
legt Monitor und Token an, schreibt das Agent-Skript und plant es ein:
</p>
<div class="prompt-box">./update-agent --action monitor</div>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
gebraucht anzulegen unter <em>System 👤 Benutzer</em>. Ein
@@ -3611,6 +3700,10 @@ chmod +x update-agent
'resolved' => 'Gelöst',
'closed' => 'Geschlossen',
'rejected' => 'Abgelehnt',
// Meldungen aus einem anderen Projekt. Ohne eigenen
// Filtereintrag wären sie nur unter "Alle Status" zu
// finden - ein Eingang, den niemand sieht, ist keiner.
'triage_external' => '📥 Fremdmeldungen',
];
foreach ($statusOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
@@ -3681,6 +3774,7 @@ chmod +x update-agent
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
'resolved' => '<span class="badge badge-up">GELÖST</span>',
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
'triage_external' => '<span class="badge" style="background:rgba(120,160,255,0.2); color:#7aa2ff; border:1px solid #7aa2ff;">📥 FREMDMELDUNG</span>',
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
};
@@ -5739,6 +5833,7 @@ SYSTEM
<option value="resolved">Gelöst / Umgesetzt</option>
<option value="closed">Geschlossen</option>
<option value="rejected">Abgelehnt</option>
<option value="triage_external">📥 Fremdmeldung (ungesichtet)</option>
</select>
</div>
<div class="form-group">
+53
View File
@@ -24,6 +24,7 @@ $ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$project = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.UpdateAgent\Deploymentcenter.UpdateAgent.csproj'
$packager = Join-Path $repoRoot 'client-dotnet\Deploymentcenter.Packager\Deploymentcenter.Packager.csproj'
$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("dc-installer-build-" + [guid]::NewGuid().ToString('N'))
if (-not (Test-Path $project)) {
@@ -83,12 +84,64 @@ try {
Write-Host (" {0,-22} {1,6:N1} MB {2}" -f $targetName, ($size / 1MB), $hash.Substring(0, 16))
}
# ------------------------------------------------------------------
# pack-and-deploy
# ------------------------------------------------------------------
# Das Veroeffentlichungswerkzeug wurde in der Anleitung benutzt, als laege
# es im PATH - beziehbar war es nirgends. Fremde Projekte konnten also
# nicht veroeffentlichen, ohne dieses Repository auszuchecken und selbst zu
# uebersetzen.
$tools = @()
if (Test-Path $packager) {
foreach ($rid in $Runtimes) {
Write-Host "Baue pack-and-deploy fuer $rid ..." -ForegroundColor Cyan
$ridOut = Join-Path $staging "packager-$rid"
dotnet publish $packager `
-c Release -r $rid `
--self-contained true `
-p:PublishSingleFile=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=None `
-o $ridOut `
-v q --nologo
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish (pack-and-deploy) fuer $rid ist fehlgeschlagen."
}
$isWindows = $rid.StartsWith('win')
$sourceName = if ($isWindows) { 'pack-and-deploy.exe' } else { 'pack-and-deploy' }
$targetName = if ($isWindows) { "pack-and-deploy-$rid.exe" } else { "pack-and-deploy-$rid" }
$target = Join-Path $OutputDir $targetName
Copy-Item (Join-Path $ridOut $sourceName) $target -Force
$hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLower()
[System.IO.File]::WriteAllText("$target.sha256", $hash)
$tools += [ordered]@{
platform = $rid
file = $targetName
sha256 = $hash
sizeBytes = (Get-Item $target).Length
}
Write-Host (" {0,-26} {1,6:N1} MB {2}" -f $targetName, ((Get-Item $target).Length / 1MB), $hash.Substring(0, 16))
}
} else {
Write-Warning "Packager-Projekt nicht gefunden - pack-and-deploy wird nicht mit ausgeliefert."
}
$manifest = [ordered]@{
tool = 'update-agent'
version = $Version
gitCommit = $gitCommit
buildDateUtc = (Get-Date).ToUniversalTime().ToString('o')
binaries = $binaries
tools = $tools
}
# Bewusst ueber WriteAllText mit einer BOM-freien Kodierung: Out-File
+4
View File
@@ -25,6 +25,10 @@ IGNORE_PATTERNS = {
'log',
'__pycache__',
'client-dotnet',
# Bibliothek zum Mitnehmen ins Zielprojekt bzw. lokal ueber stdio
# betriebener MCP-Server - beide gehoeren nicht ins Webroot.
'client-php',
'mcp',
# Gebaute Installer-Binaries. Sie gehoeren nach /installer/ und werden
# von upload_installer.py dorthin gebracht - ueber diesen Weg landeten
# sonst ~100 MB zusaetzlich unter /artifacts/ im Webroot.
+3
View File
@@ -63,6 +63,9 @@ try {
Write-Host 'Weiter mit:'
Write-Host " & '$target' --action install"
Write-Host ''
Write-Host 'Nur diese Maschine ueberwachen, ohne etwas zu installieren:'
Write-Host " & '$target' --action monitor"
Write-Host ''
Write-Host "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
Write-Host 'gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein'
Write-Host 'Zielsystem.'
+3
View File
@@ -104,6 +104,9 @@ echo ""
echo "Weiter mit:"
echo " $target --action install"
echo ""
echo "Nur diese Maschine ueberwachen, ohne etwas zu installieren:"
echo " $target --action monitor"
echo ""
echo "Dafuer werden Benutzername und Passwort eines Kontos der Rolle 'installer'"
echo "gebraucht. Ein Administratorkonto tut es auch, gehoert aber nicht auf ein"
echo "Zielsystem."
+306 -13
View File
@@ -26,9 +26,9 @@ use PDO;
* ueber product_id an genau ein Projekt gebunden ist, faellt die Trennung beim
* Erzeugen von selbst ab.
*
* Zusaetzlich stehen die Installationskonten in jeder Datei: Bei einer
* Erstinstallation gibt es noch keinen Lizenzschluessel, mit dem sich das
* Paket holen liesse.
* Zusaetzlich stehen die Installationskonten in jeder Datei - die der Rolle
* 'installer' wie die der Rolle 'admin': Bei einer Erstinstallation gibt es
* noch keinen Lizenzschluessel, mit dem sich das Paket holen liesse.
*/
final class ReleaseGuard
{
@@ -47,6 +47,18 @@ final class ReleaseGuard
/** Von Apache verstandene Hash-Praefixe. */
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
/**
* Leitet den Basic-Auth-Benutzernamen aus einem Lizenzschluessel ab.
*
* MUSS zeichengenau mit ReleaseCredentials.FromLicenseKey() im .NET-SDK
* uebereinstimmen - weichen die beiden voneinander ab, kommt niemand mehr
* an seine Updates.
*/
public static function licenseUsername(string $licenseKey): string
{
return 'lic_' . substr(hash('sha256', trim($licenseKey)), 0, 16);
}
/** Wurzelverzeichnis der Releases im Dateisystem. */
public static function releasesRoot(): string
{
@@ -103,10 +115,15 @@ final class ReleaseGuard
}
}
// Auch der Weg ueber die Oberflaeche vermerkt das Format - sonst
// sieht der naechste Tick eine Formataenderung, die es nicht gibt.
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
Logger::info('Release-Zugangsschutz neu erzeugt', [
'projects' => $result['projects'],
'entries' => $result['entries'],
'errors' => count($result['errors']),
'format' => self::FORMAT_VERSION,
]);
return $result;
@@ -130,6 +147,15 @@ final class ReleaseGuard
return ['entries' => 0, 'error' => sprintf('Kein Schreibrecht in %s', $dir)];
}
// Ausgenommene Produkte bleiben offen. Eine bestehende .htaccess muss
// dabei weg - sonst bliebe der Schutz stehen und die Ausnahme waere
// wirkungslos.
if (self::isExempt($db, $slug)) {
@unlink($dir . '/.htaccess');
@unlink($dir . '/.htpasswd');
return ['entries' => 0, 'error' => null];
}
$entries = self::collectEntries($db, $slug);
$lines = [];
@@ -165,9 +191,16 @@ final class ReleaseGuard
// 1. Gueltige Lizenzen dieses Produkts.
//
// Benutzername und Passwort sind beide der Lizenzschluessel: Basic Auth
// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
// Benutzernamen muessen eindeutig sein.
// Der Benutzername wird aus dem Schluessel ABGELEITET, er ist nicht der
// Schluessel selbst. Das htpasswd-Format hasht nur die Passwortspalte;
// stuende der Schluessel als Benutzername daneben, waere die Datei eine
// vollstaendige Klartext-Kundenliste und der bcrypt-Hash daneben blosse
// Dekoration. So enthaelt sie nur eine Einwegableitung und einen Hash
// ueber einen hochentropen Schluessel - selbst offengelegt ist damit
// nichts anzufangen.
//
// Die Ableitung muss zeichengenau der clientseitigen in
// ReleaseCredentials.FromLicenseKey() entsprechen.
try {
$stmt = $db->prepare("
SELECT l.license_key
@@ -185,7 +218,8 @@ final class ReleaseGuard
continue;
}
$entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
$entries[self::licenseUsername($key)] =
password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
}
} catch (\Throwable $e) {
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
@@ -200,6 +234,14 @@ final class ReleaseGuard
// ist die Anwendung noch nicht eingerichtet und hat keinen
// Lizenzschluessel, mit dem sie das Paket holen koennte.
//
// Administratoren zaehlen dazu. Zuvor stand hier nur die Rolle
// 'installer' - waehrend die Installationsskripte und der Agent
// ausdruecklich sagten, ein Administratorkonto tue es auch. Es tat es
// nicht: der Login gelang, der Katalog kam, und erst der Download
// endete mit 401 und einer Meldung ueber abgelaufene Lizenzen. Ein
// Administrator darf ohnehin alles, was hier zu holen waere - die
// Zeile in der .htpasswd eroeffnet ihm nichts Neues.
//
// Der in dc_users abgelegte Hash wird unveraendert uebernommen - PHPs
// password_hash() erzeugt bcrypt im Format $2y$, genau das versteht
// Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht.
@@ -207,7 +249,7 @@ final class ReleaseGuard
$stmt = $db->query("
SELECT username, password_hash
FROM dc_users
WHERE role = 'installer' AND disabled = 0
WHERE role IN ('installer', 'admin') AND disabled = 0
");
foreach ($stmt->fetchAll() ?: [] as $row) {
@@ -304,6 +346,242 @@ final class ReleaseGuard
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
private const LAST_RUN_KEY = 'release_guard_last_run';
/** Schluessel in dc_settings fuer das Ergebnis des letzten Selbsttests. */
private const SELFTEST_KEY = 'release_guard_selftest';
/** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */
private const FORMAT_KEY = 'release_guard_format';
/** Schluessel in dc_settings fuer die ausgenommenen Produkte. */
private const EXEMPT_KEY = 'release_guard_exempt';
/**
* Produkte, die bewusst ohne Zugangsschutz ausgeliefert werden.
*
* Wozu das noetig ist: Der Schutz entsteht beim ersten Lauf nach dem
* Deployment von selbst - auch fuer Produkte, deren ausgelieferte
* Installationen noch mit einem SDK vor 2.4 gebaut sind. Die schicken
* keine Zugangsdaten und bekommen ab dem Moment 401 statt Updates.
* Genau das ist bei Predictalytics passiert.
*
* Ohne diese Liste bliebe nur, den Schutz global abzuschalten oder die
* Installationsbasis auszusperren. Mit ihr laesst sich je Produkt
* umschalten, sobald dessen Clients nachgezogen sind.
*
* @return list<string>
*/
public static function exemptProjects(PDO $db): array
{
$raw = self::readSetting($db, self::EXEMPT_KEY);
if ($raw === null || trim($raw) === '') {
return [];
}
return array_values(array_filter(array_map(
static fn(string $s): string => strtolower(trim($s)),
explode(',', $raw)
), static fn(string $s): bool => $s !== ''));
}
public static function isExempt(PDO $db, string $slug): bool
{
return in_array(strtolower(trim($slug)), self::exemptProjects($db), true);
}
/**
* Nimmt ein Produkt vom Schutz aus oder hebt die Ausnahme wieder auf.
* Die Dateien werden anschliessend neu erzeugt bzw. entfernt.
*/
public static function setExempt(PDO $db, string $slug, bool $exempt): void
{
$slug = strtolower(trim($slug));
$list = self::exemptProjects($db);
if ($exempt) {
if (!in_array($slug, $list, true)) {
$list[] = $slug;
}
} else {
$list = array_values(array_filter($list, static fn(string $s): bool => $s !== $slug));
}
self::writeSetting($db, self::EXEMPT_KEY, implode(',', $list));
Logger::warning($exempt
? 'Produkt vom Release-Zugangsschutz ausgenommen'
: 'Ausnahme vom Release-Zugangsschutz aufgehoben', ['product' => $slug]);
self::regenerateForProject($db, $slug);
}
/**
* Aufbau der erzeugten .htpasswd. Aendert sich hier etwas, muessen alle
* Dateien neu geschrieben werden, sonst passen Server und Client nicht
* mehr zusammen.
*
* 1 - Benutzername = Lizenzschluessel im Klartext
* 2 - Benutzername = lic_<sha256(schluessel), 16 Hexzeichen>
* 3 - Administratorkonten zaehlen zu den Installationskonten
*
* Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben:
* reconcile() sah keinen Anlass, und die Dateien behielten bis zum
* naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients
* bereits das neue schicken. Fuer 3 gilt dasselbe: die Administratoren
* kaemen sonst bis zu sechs Stunden lang weiter nicht an ihr Paket.
*/
private const FORMAT_VERSION = 3;
/**
* Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
*
* Dass .htaccess und .htpasswd vorhanden sind, beweist nichts. Unter Nginx
* werden .htaccess-Dateien vollstaendig ignoriert; die Verzeichnisse
* waeren dann offen und die .htpasswd oeffentlich abrufbar, waehrend die
* Oberflaeche "geschuetzt" meldet. Dasselbe gilt bei abgeschaltetem
* AllowOverride oder einem Tippfehler in der erzeugten Datei.
*
* Nur ein Abruf der eigenen Paket-Adresse ohne Zugangsdaten beantwortet
* die Frage, und der findet alle drei Faelle auf einmal.
*
* @return array{checked:int,ok:int,failed:list<array<string,mixed>>,ran_at:string}
*/
public static function selfTest(PDO $db, ?string $slug = null): array
{
$baseUrl = rtrim((string)\Deploymentcenter\Core\Config::get('app.url', ''), '/');
$result = ['checked' => 0, 'ok' => 0, 'failed' => [], 'ran_at' => gmdate('Y-m-d H:i:s')];
if ($baseUrl === '') {
$result['failed'][] = ['slug' => '-', 'reason' => 'app.url ist nicht konfiguriert.'];
return $result;
}
$sql = '
SELECT product_slug, download_url
FROM updateservice_releases
WHERE download_url <> ""
';
$params = [];
if ($slug !== null && $slug !== '') {
$sql .= ' AND product_slug = :slug';
$params[':slug'] = $slug;
}
$sql .= ' ORDER BY product_slug ASC, created_at DESC';
try {
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll() ?: [];
} catch (\Throwable $e) {
$result['failed'][] = ['slug' => '-', 'reason' => 'Releases nicht lesbar: ' . $e->getMessage()];
return $result;
}
// Je Produkt genuegt eine Adresse.
$seen = [];
foreach ($rows as $row) {
$product = (string)$row['product_slug'];
if (isset($seen[$product])) {
continue;
}
$seen[$product] = true;
// Ein bewusst ausgenommenes Produkt darf den Selbsttest nicht
// rot faerben - sonst gewoehnt man sich an einen roten Test.
if (self::isExempt($db, $product)) {
continue;
}
$url = (string)$row['download_url'];
$result['checked']++;
$status = self::probe($url);
if ($status === 401) {
$result['ok']++;
continue;
}
$result['failed'][] = [
'slug' => $product,
'status' => $status,
'url' => $url,
'reason' => $status === 200
? 'Das Paket ist OHNE Zugangsdaten abrufbar. Wird .htaccess ausgewertet? '
. 'Unter Nginx wirkungslos, unter Apache AllowOverride pruefen.'
: sprintf('Unerwarteter Status %s - erwartet wurde 401.', $status === null ? 'keiner' : (string)$status),
];
}
self::writeSetting($db, self::SELFTEST_KEY, json_encode($result) ?: '');
Logger::info('Release-Selbsttest', [
'checked' => $result['checked'],
'ok' => $result['ok'],
'failed' => count($result['failed']),
]);
return $result;
}
/** Ergebnis des letzten Selbsttests, oder null. */
public static function lastSelfTest(PDO $db): ?array
{
$raw = self::readSetting($db, self::SELFTEST_KEY);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* Ruft eine Adresse ohne Zugangsdaten ab und liefert den Statuscode.
* Bewusst nur der Kopf der Antwort - es geht um den Status, nicht um
* mehrere Megabyte Paket.
*/
private static function probe(string $url): ?int
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
// Bewusst kein Zugangsdaten-Header: geprueft wird ja gerade,
// ob der Abruf ohne scheitert.
]);
curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return $status > 0 ? $status : null;
}
$context = stream_context_create(['http' => [
'method' => 'HEAD',
'timeout' => 10,
'ignore_errors' => true,
]]);
@file_get_contents($url, false, $context);
foreach ($http_response_header ?? [] as $header) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $m) === 1) {
return (int)$m[1];
}
}
return null;
}
/**
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
*
@@ -325,6 +603,8 @@ final class ReleaseGuard
if ($lastRun === null) {
$reason = 'noch nie gelaufen';
} elseif ((int)(self::readSetting($db, self::FORMAT_KEY) ?? '1') !== self::FORMAT_VERSION) {
$reason = 'Dateiformat geaendert';
} elseif (self::hasExpiredSince($db, $lastRun)) {
$reason = 'Lizenz abgelaufen';
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
@@ -366,10 +646,21 @@ final class ReleaseGuard
}
private static function readLastRun(PDO $db): ?string
{
return self::readSetting($db, self::LAST_RUN_KEY);
}
private static function writeLastRun(PDO $db): void
{
self::writeSetting($db, self::LAST_RUN_KEY, gmdate('Y-m-d H:i:s'));
self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
}
private static function readSetting(PDO $db, string $key): ?string
{
try {
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
$stmt->execute([':k' => self::LAST_RUN_KEY]);
$stmt->execute([':k' => $key]);
$value = $stmt->fetchColumn();
return is_string($value) && $value !== '' ? $value : null;
} catch (\Throwable $e) {
@@ -377,16 +668,16 @@ final class ReleaseGuard
}
}
private static function writeLastRun(PDO $db): void
private static function writeSetting(PDO $db, string $key, string $value): void
{
try {
$stmt = $db->prepare('
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
');
$stmt->execute([':k' => self::LAST_RUN_KEY, ':v' => gmdate('Y-m-d H:i:s')]);
$stmt->execute([':k' => $key, ':v' => $value]);
} catch (\Throwable $e) {
Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]);
Logger::warning('Einstellung nicht gespeichert', ['key' => $key, 'error' => $e->getMessage()]);
}
}
@@ -396,7 +687,7 @@ final class ReleaseGuard
*
* @return list<array<string,mixed>>
*/
public static function status(): array
public static function status(?PDO $db = null): array
{
$root = self::releasesRoot();
@@ -404,6 +695,7 @@ final class ReleaseGuard
return [];
}
$exempt = $db !== null ? self::exemptProjects($db) : [];
$report = [];
foreach (scandir($root) ?: [] as $entry) {
@@ -425,6 +717,7 @@ final class ReleaseGuard
$report[] = [
'slug' => $entry,
'protected' => $protected,
'exempt' => in_array(strtolower((string)$entry), $exempt, true),
'entries' => $count,
'updated_at' => $protected ? gmdate('Y-m-d H:i:s', (int)filemtime($htpasswd)) : null,
'writable' => is_writable($dir),
+356
View File
@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog;
/**
* Erzeugt die Agent-Skripte, die auf einer Host-Maschine minuetlich laufen
* und einen Heartbeat samt Kennzahlen schicken.
*
* Warum hier und nicht dort, wo sie gebraucht werden: Die Skripte werden an
* zwei Stellen ausgeliefert - als Download aus der Oberflaeche und ueber den
* Setup-Weg an den Installer. Zwei Fassungen desselben Skripts laufen
* unweigerlich auseinander; auffallen wuerde das erst, wenn ein Host andere
* Werte liefert als der daneben.
*
* Die Erstfassung (inline in index.php) schickte nur "status: ok" ohne eine
* einzige Kennzahl. Damit beantwortete der Monitor genau eine Frage - laeuft
* die Kiste noch - und keine der Fragen, wegen derer man auf einen Host
* schaut: Ist die Platte voll? Frisst etwas den Speicher? Die Skripte hier
* melden Last, Speicher, Belegung und Laufzeit mit; der MetricStore nimmt
* jede numerische Angabe ohne weiteres Zutun auf.
*
* Bewusst ohne Fremdwerkzeuge: bash, awk und curl bzw. Windows PowerShell 5.1
* sind ueberall da. Ein Agent, der erst installiert werden muss, wird nicht
* installiert.
*/
final class AgentScript
{
/** Ab dieser Belegung meldet der Host "warning" statt "ok". */
private const DISK_WARN_PERCENT = 90;
/** Dasselbe fuer den Arbeitsspeicher. */
private const MEM_WARN_PERCENT = 95;
/**
* Erlaubte Zeichen in einem Monitor-Namen.
*
* Der Name landet in einem Shell- bzw. PowerShell-Literal. Alles, was
* dort ausbrechen koennte - Anfuehrungszeichen, Dollar, Backticks,
* Zeilenumbrueche - hat darin nichts zu suchen.
*/
public static function sanitizeSource(string $source): string
{
$clean = trim(preg_replace('/[^A-Za-z0-9._ -]/', '', trim($source)) ?? '');
return $clean !== '' ? mb_substr($clean, 0, 100) : 'host';
}
/** Dasselbe fuer ein Token - die Zeichen, die dc_tokens vergibt. */
public static function sanitizeToken(string $token): string
{
return preg_replace('/[^A-Za-z0-9._-]/', '', trim($token)) ?? '';
}
/** Normalisiert die Betriebssystemangabe auf "windows" oder "linux". */
public static function normalizeOs(?string $os): string
{
$value = strtolower(trim((string)$os));
return in_array($value, ['windows', 'win', 'win32', 'win-x64', 'win-arm64'], true)
? 'windows'
: 'linux';
}
/**
* Baut das Skript.
*
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
* schedule ist der Befehl, mit dem sich das Skript einplanen
* laesst - der Installer setzt ihn ein, die Oberflaeche zeigt ihn
* an. {PFAD} steht darin fuer den Ablageort, den erst der kennt,
* der die Datei hinlegt.
*/
public static function build(
string $os,
string $source,
string $token,
string $endpoint,
int $intervalSec = 60
): array {
$os = self::normalizeOs($os);
$source = self::sanitizeSource($source);
$token = self::sanitizeToken($token);
$interval = max(30, min($intervalSec, 3600));
// Der Endpunkt stammt aus der eigenen Konfiguration, nicht aus einer
// Anfrage - trotzdem nicht ungeprueft in ein Skript schreiben.
$endpoint = filter_var($endpoint, FILTER_VALIDATE_URL) !== false ? $endpoint : '';
return $os === 'windows'
? self::buildWindows($source, $token, $endpoint, $interval)
: self::buildLinux($source, $token, $endpoint, $interval);
}
/**
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
*/
private static function buildLinux(string $source, string $token, string $endpoint, int $interval): array
{
$diskWarn = self::DISK_WARN_PERCENT;
$memWarn = self::MEM_WARN_PERCENT;
$filename = 'watchdog-agent-' . self::fileSlug($source) . '.sh';
// Die Werte stammen aus /proc und df - kein Paket, das erst
// nachinstalliert werden muesste. awk rechnet, damit auch ein System
// ohne bc auskommt.
$script = <<<BASH
#!/usr/bin/env bash
#
# Deploymentcenter Watchdog Agent
#
# Meldet Erreichbarkeit und Kennzahlen dieser Maschine. Erzeugt vom
# Deploymentcenter fuer den Monitor "{$source}" - nicht von Hand
# aendern, ein neu erzeugtes Skript ueberschreibt diese Datei.
#
# Einplanen (minuetlich):
# crontab -e
# * * * * * /pfad/zu/{$filename} >/dev/null 2>&1
#
# Die Datei enthaelt ein Token. Rechte 0600 belassen.
set -uo pipefail
# Ohne feste Locale gibt awk auf einem deutschen System "12,5" statt
# "12.5" aus - das erzeugte JSON waere kaputt, und der Server lehnte
# jeden Heartbeat mit einem Parserfehler ab.
export LC_ALL=C
WATCHDOG_URL="{$endpoint}"
TOKEN="{$token}"
SOURCE="{$source}"
INTERVAL={$interval}
# Ab diesen Werten wird "warning" gemeldet statt "ok".
DISK_WARN={$diskWarn}
MEM_WARN={$memWarn}
# --- Kennzahlen ---------------------------------------------------
# Last als Anteil der verfuegbaren Kerne: 1.0 bedeutet auf einer
# Maschine mit vier Kernen etwas anderes als auf einer mit einem.
cores=\$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
load1=\$(awk '{print \$1}' /proc/loadavg 2>/dev/null || echo 0)
load_percent=\$(awk -v l="\$load1" -v c="\$cores" 'BEGIN { if (c <= 0) c = 1; printf "%.1f", (l / c) * 100 }')
mem_total=\$(awk '/^MemTotal:/ {print \$2}' /proc/meminfo 2>/dev/null || echo 0)
# MemAvailable gibt es erst ab Kernel 3.14. Fehlt es, ist MemFree die
# naechstbeste Auskunft - ohne diesen Rueckfall stuende dort 0, und
# der Host meldete auf ewig 100 Prozent Speicherbelegung.
mem_avail=\$(awk '/^MemAvailable:/ {print \$2}' /proc/meminfo 2>/dev/null)
if [ -z "\${mem_avail:-}" ]; then
mem_avail=\$(awk '/^MemFree:/ {print \$2}' /proc/meminfo 2>/dev/null || echo 0)
fi
mem_percent=\$(awk -v t="\$mem_total" -v a="\${mem_avail:-0}" 'BEGIN { if (t <= 0) { print 0 } else { printf "%.1f", ((t - a) / t) * 100 } }')
mem_total_mb=\$(awk -v t="\$mem_total" 'BEGIN { printf "%.0f", t / 1024 }')
# Von hinten gezaehlt, nicht von vorn: -P erzwingt zwar eine Zeile je
# Dateisystem, aber ein Geraetename mit Leerzeichen verschiebt trotzdem
# jede Spalte. Belegung und freier Platz stehen verlaesslich an
# vorletzter und drittletzter Stelle, weil der Mountpunkt hier "/" ist.
disk_percent=\$(df -P / 2>/dev/null | awk 'NR==2 {gsub("%","",\$(NF-1)); print \$(NF-1)}')
disk_free_gb=\$(df -P / 2>/dev/null | awk 'NR==2 {printf "%.1f", \$(NF-2) / 1048576}')
uptime_sec=\$(awk '{printf "%.0f", \$1}' /proc/uptime 2>/dev/null || echo 0)
: "\${disk_percent:=0}"
: "\${disk_free_gb:=0}"
# Sicherheitsnetz gegen ein df, das anders zaehlt als erwartet: ein
# Prozentwert jenseits von 0 bis 100 ist keine Belegung, sondern ein
# verrutschtes Feld - und wuerde als Dauerwarnung durchschlagen.
case "\$disk_percent" in
''|*[!0-9]*) disk_percent=0 ;;
*) [ "\$disk_percent" -gt 100 ] && disk_percent=0 ;;
esac
# --- Bewertung ----------------------------------------------------
status="ok"
message="Heartbeat"
if awk -v v="\$disk_percent" -v w="\$DISK_WARN" 'BEGIN { exit !(v >= w) }'; then
status="warning"
message="Platte zu \${disk_percent}% belegt"
elif awk -v v="\$mem_percent" -v w="\$MEM_WARN" 'BEGIN { exit !(v >= w) }'; then
status="warning"
message="Arbeitsspeicher zu \${mem_percent}% belegt"
fi
payload=\$(cat <<JSON
{
"source": "\$SOURCE",
"type": "host",
"os": "linux",
"status": "\$status",
"message": "\$message",
"interval": \$INTERVAL,
"metrics": {
"cpu_load_percent": \$load_percent,
"load_1min": \$load1,
"cpu_cores": \$cores,
"memory_used_percent": \$mem_percent,
"memory_total_mb": \$mem_total_mb,
"disk_used_percent": \$disk_percent,
"disk_free_gb": \$disk_free_gb,
"uptime_sec": \$uptime_sec
}
}
JSON
)
# --fail laesst curl bei 4xx/5xx einen Fehlercode liefern; ohne das
# galte auch eine abgelehnte Anmeldung als erfolgreich gesendet.
if curl -fsS --max-time 20 -X POST "\$WATCHDOG_URL" \\
-H "Content-Type: application/json" \\
-H "X-Agent-Token: \$TOKEN" \\
-d "\$payload" > /dev/null; then
echo "[OK] Heartbeat gesendet (\$status)."
else
echo "[FEHLER] Heartbeat nicht angenommen." >&2
exit 1
fi
BASH;
return [
'filename' => $filename,
'script' => $script,
'os' => 'linux',
'interval' => $interval,
'schedule' => '* * * * * {PFAD} >/dev/null 2>&1',
];
}
/**
* @return array{filename:string,script:string,os:string,interval:int,schedule:string}
*/
private static function buildWindows(string $source, string $token, string $endpoint, int $interval): array
{
$diskWarn = self::DISK_WARN_PERCENT;
$memWarn = self::MEM_WARN_PERCENT;
$filename = 'watchdog-agent-' . self::fileSlug($source) . '.ps1';
// Get-CimInstance statt Get-WmiObject: letzteres gibt es in
// PowerShell 7 nicht mehr, und dort landet man inzwischen schneller,
// als einem lieb ist.
$script = <<<POWERSHELL
# Deploymentcenter Watchdog Agent
#
# Meldet Erreichbarkeit und Kennzahlen dieser Maschine. Erzeugt vom
# Deploymentcenter fuer den Monitor "{$source}" - nicht von Hand
# aendern, ein neu erzeugtes Skript ueberschreibt diese Datei.
#
# Einplanen (minuetlich):
# schtasks /Create /SC MINUTE /MO 1 /TN "Deploymentcenter Watchdog {$source}"
# /TR "powershell -NoProfile -ExecutionPolicy Bypass -File <Pfad>\\{$filename}"
#
# Die Datei enthaelt ein Token.
\$ErrorActionPreference = "Stop"
\$WatchdogUrl = "{$endpoint}"
\$Token = "{$token}"
\$Source = "{$source}"
\$Interval = {$interval}
# Ab diesen Werten wird "warning" gemeldet statt "ok".
\$DiskWarn = {$diskWarn}
\$MemWarn = {$memWarn}
function Get-HostMetrics {
\$os = Get-CimInstance Win32_OperatingSystem
\$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
\$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='\$(\$env:SystemDrive)'"
\$memTotalMb = [math]::Round(\$os.TotalVisibleMemorySize / 1KB, 0)
\$memUsedPct = if (\$os.TotalVisibleMemorySize -gt 0) {
[math]::Round(((\$os.TotalVisibleMemorySize - \$os.FreePhysicalMemory) / \$os.TotalVisibleMemorySize) * 100, 1)
} else { 0 }
\$diskUsedPct = if (\$disk -and \$disk.Size -gt 0) {
[math]::Round(((\$disk.Size - \$disk.FreeSpace) / \$disk.Size) * 100, 1)
} else { 0 }
\$diskFreeGb = if (\$disk) { [math]::Round(\$disk.FreeSpace / 1GB, 1) } else { 0 }
# LastBootUpTime kommt als DateTime - die Differenz ist die Laufzeit.
\$uptimeSec = [math]::Round(((Get-Date) - \$os.LastBootUpTime).TotalSeconds, 0)
return [ordered]@{
cpu_load_percent = \$(if (\$null -ne \$cpu) { [double]\$cpu } else { 0 })
cpu_cores = [int]\$env:NUMBER_OF_PROCESSORS
memory_used_percent = \$memUsedPct
memory_total_mb = \$memTotalMb
disk_used_percent = \$diskUsedPct
disk_free_gb = \$diskFreeGb
uptime_sec = \$uptimeSec
}
}
try {
\$metrics = Get-HostMetrics
\$status = "ok"
\$message = "Heartbeat"
if (\$metrics.disk_used_percent -ge \$DiskWarn) {
\$status = "warning"
\$message = "Platte zu \$(\$metrics.disk_used_percent)% belegt"
} elseif (\$metrics.memory_used_percent -ge \$MemWarn) {
\$status = "warning"
\$message = "Arbeitsspeicher zu \$(\$metrics.memory_used_percent)% belegt"
}
\$body = [ordered]@{
source = \$Source
type = "host"
os = "windows"
status = \$status
message = \$message
interval = \$Interval
metrics = \$metrics
} | ConvertTo-Json -Depth 4 -Compress
# Ohne diese Zeile scheitert der Aufruf auf Maschinen, die noch
# mit TLS 1.0 als Vorgabe laufen - der Fehler sieht dort aus wie
# ein Netzwerkproblem.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-RestMethod -Uri \$WatchdogUrl -Method Post -Body \$body -ContentType "application/json" -Headers @{ "X-Agent-Token" = \$Token } | Out-Null
Write-Host "[OK] Heartbeat gesendet (\$status)."
} catch {
Write-Error "[FEHLER] Heartbeat nicht angenommen: \$_"
exit 1
}
POWERSHELL;
return [
'filename' => $filename,
'script' => $script,
'os' => 'windows',
'interval' => $interval,
'schedule' => 'schtasks /Create /SC MINUTE /MO 1 /TN "Deploymentcenter Watchdog ' . $source . '" '
. '/TR "powershell -NoProfile -ExecutionPolicy Bypass -File {PFAD}" /F',
];
}
/** Dateinamensteil aus dem Monitor-Namen: nur Unverfaengliches. */
private static function fileSlug(string $source): string
{
$slug = trim(strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $source) ?? ''), '-');
return $slug !== '' ? $slug : 'host';
}
}