UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4

Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-23 12:16:05 +02:00
co-authored by Claude Opus 5
parent b5bf97ae74
commit 33d95a6c3f
88 changed files with 11665 additions and 1029 deletions
@@ -0,0 +1,299 @@
# Deploymentcenter 2.2 2.4: Was noch zu tun ist
Stand: 2026-08-13. Ergänzt [Deploymentcenter-Integration](Deploymentcenter-Integration.md)
(dort steht der Stand nach 2.1) um die drei neuen Ausbaustufen.
| Fassung | Was dazukam | Betrifft uns |
|---|---|---|
| **2.2** | Plattform-Dimension, signierte Releases, Anwenden mit Rollback, `preservePatterns` | Release-Strecke, Update-Anwendung |
| **2.3** | Erstinstallation über `update-agent --action install`, `setup.json`, Installationskonto | Neu, siehe §4 |
| **2.4** | Release-Ablage hinter HTTP-Basic-Auth, Zugang über den Lizenzschlüssel | **Erledigt**, siehe §1 |
---
## 1. Zugangsschutz (2.4) — erledigt
`CheckForUpdateAsync` übergibt jetzt `ReleaseCredentials.FromLicenseKey(...)`, und
`UpdateCheckResult.Unauthorized` wird getrennt von einem Netzfehler behandelt.
**Warum das nicht warten konnte:** UPGRADE §16.1 empfiehlt „erst ausliefern, dann
scharfschalten". Für ein Produkt, das noch nie veröffentlicht hat, geht diese Reihenfolge
nicht auf. `ReleaseGuard::regenerateForProject` überspringt Verzeichnisse, die es nicht
gibt — `/releases/clawddotnet/` liefert derzeit 404, es ist also nichts geschützt. Sobald
wir das **erste** Release hochladen, entsteht das Verzeichnis, und der nächste
`tick.php`-Lauf legt den Schutz an. Der erste ausgelieferte Build muss die Zugangsdaten
also bereits mitbringen, sonst schließt sich die Tür hinter dem ersten Release.
---
## 2. Plattform (2.2) — Release-Strecke steht
Erstes Paket veröffentlicht: **0.1.0, Kanal `dev`, Plattform `win-x64`**, 116 Dateien,
25 MB, Rückgabewert 0. Die Update-Prüfung antwortet korrekt (`0.0.9` → Update, `0.1.0`
keins).
```bash
pack-and-deploy --config deploy/packager.config.json \
--project clawddotnet --version 0.1.0 \
--channel dev --platform win-x64 \
--publish-dir <dotnet-publish-Ausgabe>
```
- Zugangsdaten in `deploy/packager.config.json` (per `.gitignore` ausgeschlossen),
Vorlage ohne Werte in [`packager.config.example.json`](../deploy/packager.config.example.json).
- **`ftpRemoteBaseDir` ist `/releases`**, nicht `/public_html/releases` wie in der
Packager-Vorlage: Auf diesem Server liegt die Release-Ablage auf der FTP-Wurzel.
- Der Packager veröffentlicht mit einem **Sub-Token**, das nur `updateservice:publish`
trägt — gezogen über `/api/tokens/v1/provision`. Das Master-Token gehört nicht in eine
Konfigurationsdatei.
- **`deploy.py` ist dafür das falsche Werkzeug.** Es spiegelt den
Deploymentcenter-Projektbaum in die FTP-Wurzel und hat mit dem Veröffentlichen eines
Anwendungspakets nichts zu tun.
- Die Versionsgegenprobe des Packagers greift und passt: `<Version>` aus
[Directory.Build.props](../Directory.Build.props) stimmt mit `clawddotnet.dll` überein.
Offen: `linux-x64` (erst nach der Avalonia-Portierung) und `prod`.
Clientseitig ist nichts zu tun: Das SDK schickt die Kennung des laufenden Systems von
selbst.
### `preservePatterns` betrifft uns kaum
Unsere Konfiguration liegt seit der Linux-Portierung in `AppPaths.ConfigDirectory`
(`%APPDATA%` bzw. XDG), **nicht** neben der Programmdatei. Ein Update kann sie also gar
nicht überschreiben. Zu prüfen bleibt nur, dass keine leeren Arbeitsordner ins Paket
wandern — die `CreateWorkingDirectories`-Targets in
[ClawdDotNet.csproj](../ClawdDotNet.csproj) legen `tools/`, `Logs/` und `Instances/`
unter `OutputPath` an, und die sind mit AppPaths ohnehin überholt.
---
## 3. Update anwenden — erledigt
Aus dem Hinweis ist eine Rückfrage geworden („Jetzt installieren" / „Später"), die den
`update-agent` startet. Umgesetzt in
[`DeploymentcenterService.StartUpdate`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs)
und `App.StartUpdateAsync`.
### Der Agent wird mitgeliefert — er muss es
Die Erstinstallation legt den Agenten **nicht** ins Zielverzeichnis: Sie läuft von dort,
wo der Benutzer sie hingelegt hat. `ResolveAgentPath()` sucht ihn aber neben der
Anwendung. Ohne Mitliefern fände die Anwendung nie einen Agenten und könnte sich nicht
aktualisieren.
[`deploy/publish.py`](../deploy/publish.py) holt das ausgelieferte Binary von
`/installer/`, **prüft die SHA256 gegen `installer.json`** und legt es plattformrichtig
ab (`update-agent.exe` bzw. `update-agent`). Bewusst das offizielle statt eines selbst
gebauten: Es ist dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt.
Ein ungeprüfter Download wäre ausgerechnet auf dem Pfad, der später fremden Code
ausführt, die falsche Sparsamkeit.
Kosten: rund 28 MB im gepackten Paket (25 → 53 MB).
### Die Reihenfolge ist der eigentliche Inhalt
```
1. AnnounceUpdate(version) → Watchdog meldet beim Beenden "maintenance"
2. AppHost.DisposeAsync() → Datenbank, Scanner, Telegram, Abmeldung
3. StartUpdate(...) → Agent starten, exitCurrentApp: false
4. desktop.Shutdown() → wir beenden uns selbst
```
Die Verlockung wäre, `LaunchUpdateAgent` das Beenden zu überlassen. Das tut es aber über
`Environment.Exit` und übergeht damit Schritt 2 vollständig: keine Abmeldung, keine
geschlossene Instanzdatenbank. Deshalb `exitCurrentApp: false` und
`waitForCurrentProcess: true` — der Agent bekommt unsere Prozesskennung und wartet, bis
wir wirklich weg sind, statt über gesperrte Dateien zu kopieren.
`maintenance` statt `stopped` ist kein Schönheitsfehler: `stopped` heißt „bewusst
beendet" und lässt den Monitor liegen, bis jemand ihn anfasst. Beim Update kommt die
Instanz aber wieder.
**Doppeltes Aufräumen** war die Falle dabei: Nach Schritt 2 ruft `desktop.Shutdown()` die
Behandlung, die erneut aufräumt — und dabei den gerade gesetzten Wartungszustand mit
einer zweiten Abmeldung überschrieben hätte. `AppHost.DisposeAsync` sperrt sich jetzt
selbst gegen den zweiten Durchlauf.
---
## 4. Erstinstallation (2.3) — `setup.json` steht
Der Konfigurationsort war der Blocker: `setup.json`-Ziele waren „relativ zum
Installationsverzeichnis", unsere Konfiguration liegt aber in `%APPDATA%` bzw.
`$XDG_CONFIG_HOME` — weil `/opt/clawddotnet` unter Linux für den Dienstbenutzer nicht
schreibbar ist ([Linux-Analyse](Linux-Portierung-Analyse.md)).
Das Deploymentcenter hat daraufhin `location` am Ziel ergänzt (`install`, `config`,
`data`, `home`) samt Variablenersetzung in `file`. Damit ist der Weg frei;
[`setup.json`](../src/ClawdDotNet.Desktop/setup.json) liegt im Projekt und wird ins
Ausgabeverzeichnis kopiert, landet also im Paket neben der `manifest.json`.
### Der Ordnername ist bewusst kleingeschrieben
`AppPaths` legt das Verzeichnis plattformabhängig unterschiedlich an:
| Plattform | Pfad |
|---|---|
| Windows | `%APPDATA%\ClawdDotNet` |
| Linux | `$XDG_CONFIG_HOME/clawddotnet` (klein, Konvention) |
Eine `setup.json` kennt nur **eine** Schreibweise. `clawddotnet/Settings.json` trifft
unter Linux exakt und unter Windows ebenfalls, weil NTFS Groß- und Kleinschreibung nicht
unterscheidet. Andersherum ginge es nicht: `ClawdDotNet` wäre unter Linux ein zweites,
leeres Verzeichnis neben dem, aus dem die Anwendung liest.
### Was dabei abfällt
Das Token stellt der Server aus (`source: "provision"`), die Server-Adresse kommt aus dem
Installer (`detect:baseurl`). Damit entfällt der Absatz „bis die Avalonia-Einstellungs-
ansicht steht, von Hand in `Settings.json`" aus der
[Integrationsbeschreibung](Deploymentcenter-Integration.md) — jedenfalls für frisch
installierte Systeme.
Der Installer schreibt Lizenzschlüssel und Token **im Klartext**; er kennt unsere
DPAPI-Hülle nicht. Das ist in Ordnung und abgesichert: `SecretProtector.Unprotect` gibt
Klartext unverändert zurück, beim ersten Speichern wird verschlüsselt. Der Test dazu
steht in `SecretProtectorTests` und nennt jetzt beide Gründe, damit ihn niemand als
Altlast entfernt.
### Zwei Grenzen bleiben
- **`CLAWD_CONFIG_DIR` kennt der Installer nicht.** Wer den Ort per Umgebungsvariable
verlegt, muss die Datei selbst verschieben.
- **Wer installiert, entscheidet mit** (SETUP warnt selbst davor): `config` bezieht sich
auf das Konto, unter dem der Installer läuft. Für einen systemd-Dienst mit eigenem
Benutzer heißt das: als dieser Benutzer installieren, sonst landet die Konfiguration
im falschen Profil.
### Durchgespielt (2026-08-15)
Anmeldung mit dem Installationskonto und der gesamte Ablauf gegen den echten Server:
| Schritt | Ergebnis |
|---|---|
| `POST /api/setup/v1/login` | 201, Rolle `installer`, Recht `setup:install`, Token 30 min gültig |
| `GET /api/setup/v1/catalog?platform=win-x64` | `clawddotnet` (dev=0.1.2) erscheint. **Ohne `platform` leer** — wie die Update-Prüfung, der Agent schickt `PlatformId.Current` |
| `POST /api/setup/v1/token` | Anwendungstoken mit genau `watchdog:ping` + `bugtracker:report` |
| Rechteschranke | Das ausgestellte Token kann **kein** `updateservice:publish` nachziehen (403 `provision_denied`) |
| `SetupPaths.Resolve` gegen unsere `setup.json` | löst unter Windows auf `%APPDATA%\ClawdDotNet\Settings.json` auf (`fileWindows` greift) |
| **Round-Trip** SDK schreibt → `SettingsManager` liest | trägt: camelCase-Keys treffen, der Klartext-Lizenzschlüssel geht durch den Entschlüsselungspfad (der Klartext unverändert durchreicht) |
Damit ist der Weg vollständig: Ein frisch aufgesetztes System bekommt über den Installer
Server-Adresse, Lizenzschlüssel und ein vom Server ausgestelltes Instanz-Token in die
`Settings.json` geschrieben, die ClawdDotNet dann ohne Zutun lädt.
### Befund am Rande: alte Felder bleiben stehen
Der `SetupWriter` merged in eine vorhandene `Settings.json`, statt sie zu ersetzen —
richtig so, sonst gingen Logging-Einstellungen und Ähnliches verloren. Auf einem System
mit einer **alten** Datei bleiben dabei Felder stehen, die es in der aktuellen
`AppSettings` nicht mehr gibt (`watchdogServerUrl`, `licensePublicKeyBase64` aus der
LicenseLabrador-Zeit). Harmlos — `SettingsManager` ignoriert unbekannte Felder beim
Laden —, aber tote Einträge in der Datei. Kein Handlungsbedarf; beim ersten `Save` der
laufenden App verschwinden sie.
**Nicht enthalten** (SETUP §7): systemd-Unit und Windows-Dienst legt der Installer nicht
an. Für den kopflosen Betrieb bleibt das unsere Aufgabe.
---
## 4a. Der Update-Weg ist durchgespielt
Am 2026-08-14 gegen den echten Server geprüft, nicht nur gebaut. Ausgangslage: das
0.1.0-Paket mit Lizenzschlüssel geladen und entpackt — also eine Installation, wie sie
beim Kunden aussieht — plus eine selbst angelegte Datei, die in keinem Manifest steht.
| Fall | Ergebnis |
|---|---|
| **0.1.0 → 0.1.1** | RC 0. SHA256 des Pakets und 117 Manifest-Hashes geprüft. Fremde Datei unangetastet, `setup.json` da, `update-agent.exe` neu im Ziel |
| **Rücksprung 0.1.1 → 0.1.0** | RC 0. `update-agent.exe` als nicht mehr zum Release gehörig **entfernt** — und nur die, die fremde Datei blieb liegen |
| **Ohne Lizenzschlüssel** | `UNAUTHORIZED: … Erwartet wird der Lizenzschluessel dieser Installation`, RC 2. Sauber von einem Netzfehler unterschieden |
| **Abbruch mitten im Schreiben** (Datei exklusiv gesperrt) | RC 1, „Vorheriger Stand wurde wiederhergestellt". Version, Dateizahl und Inhalt unverändert — die Installation blieb lauffähig |
Damit trägt die Zusage aus §4B des UpdateService-Handbuchs: Ein Abbruch hinterlässt keine
halbe Installation, und verwaiste Dateien werden aufgeräumt, ohne fremde anzufassen.
Zwei Kleinigkeiten am Rand:
- Nach dem gescheiterten Lauf blieb ein **leeres** `.dc-update-backup/` zurück. Kein
Speicherverlust — der Rollback hatte alles zurückgeholt —, und der nächste erfolgreiche
Lauf hat es entfernt. Ein leeres Verzeichnis dieses Namens sieht für einen Betreiber
aber nach „Update hängt" aus.
- Der Agent weist bei **jedem** Lauf auf das unsignierte Release hin. Das ist richtig so
und wird erst still, wenn §5 erledigt ist.
---
## 5. Signierte Releases (2.2) — Schlüssel steht, Prüfung getestet
Der Signierschlüssel ist seit dem 2026-08-14 serverseitig hinterlegt (RSA-SHA256,
`canonical-line-v1`). **0.1.2 ist das erste signierte Release**; 0.1.0 und 0.1.1 bleiben
unsigniert, weil serverseitig beim Veröffentlichen signiert wird.
Am Testsystem durchgespielt:
| Fall | Ergebnis |
|---|---|
| Signiertes 0.1.2 mit `--require-signature` | RC 0, kein Unsigniert-Hinweis mehr |
| Unsigniertes 0.1.1 mit `--require-signature` | **RC 1, Abbruch vor dem Herunterladen** |
| Unsigniertes 0.1.1 ohne die Pflicht | RC 0 mit Hinweis — wie dokumentiert |
Der öffentliche Schlüssel wird beim ersten Lauf geholt und als
`dc-release-pubkey.pem` neben dem Agenten festgehalten. Ein später abweichender Schlüssel
fällt damit auf.
### Offen: die Pflicht ist aus der Anwendung heraus nicht erreichbar
`--require-signature` gibt es **nur als Kommandozeilenschalter**.
`UpdateClient.LaunchUpdateAgent` — der vom Handbuch empfohlene Weg, den auch wir
benutzen — hat dafür keinen Parameter, und der Agent liest keine Umgebungsvariable dafür
(`Program.cs:66` liest ausschließlich `HasFlag(args, "--require-signature")`).
Damit läuft jede Anwendung, die den empfohlenen Weg geht, ohne Signaturprüfung, während
derselbe Vorgang von Hand auf der Kommandozeile geschützt wäre. Wir könnten den Start
selbst nachbauen — dann verlieren wir aber `--restart`, `--wait-for-pid` und
`--wait-timeout`, also genau die Handgriffe, für die es die Hilfsmethode gibt.
**Gemeldet.** Sobald `LaunchUpdateAgent` einen Parameter dafür hat, setzen wir ihn:
Alle unsere Releases ab 0.1.2 sind signiert, ein Rückschritt auf unsignierte Stände wäre
danach kein Verlust.
---
## 6. Reihenfolge
1. **Zugangsschutz** (§1) — erledigt, muss im ersten Release drin sein.
2. **`setup.json`** (§4) — erledigt, wird mit dem ersten Paket ausgeliefert.
3. **Release-Strecke** (§2): `packager.config.json`, erster Testlauf nach `dev`.
4. **Update anwenden** (§3): Agent mitliefern, `maintenance` beim Update melden.
5. **Signatur scharf** (§5), sobald der Serverschlüssel steht.
Schritt 3 ist die Voraussetzung für alles Weitere: Solange kein Release veröffentlicht
ist, lässt sich weder Update noch Erstinstallation erproben — und die `setup.json` wirkt
erst, wenn sie in einem Paket steckt.
---
## 7. Befunde vom 2026-08-13 — alle behoben
Zur Nachvollziehbarkeit, weil einige unsere Umsetzung geformt haben:
| Befund | Behoben durch |
|---|---|
| `.htpasswd` enthielt alle Lizenzschlüssel im Klartext (Benutzernamenspalte wird nicht gehasht) | `ReleaseGuard::licenseUsername()` leitet `lic_<sha256[0..16]>` ab; `ReleaseCredentials.UsernameForLicenseKey` bildet dieselbe Ableitung nach. Die Datei enthält jetzt nur noch bcrypt über einen hochentropen Schlüssel |
| Doku beschrieb Nginx, der Schutz greift nur unter Apache; WebUI meldete „GESCHÜTZT" allein anhand vorhandener Dateien | Echter HTTP-Selbsttest (`ReleaseGuard::selfTest`, erwartet 401), Warnhinweis und eigener Nginx-Abschnitt in der Doku |
| UPDATESERVICE §7 dokumentierte `{"status":"ok"}`, der Code liefert `"success"` | Doku berichtigt |
| `setup.json` schrieb nur ins Installationsverzeichnis | `location`-Angabe am Ziel plus Variablenersetzung in `file` |
### Offen aus dem ersten Release (2026-08-13)
| Befund | Wirkung |
|---|---|
| **Veröffentlichen löst `ReleaseGuard` nicht aus.** `regenerateForProject` läuft nur bei Lizenzänderungen, Projektlöschung, Kontoänderungen und im Sechs-Stunden-Turnus von `cli/tick.php`. Ein Produktverzeichnis entsteht aber erst beim ersten Upload | `/releases/clawddotnet/` war nach dem Upload **ohne `.htaccess`** — das frische Paket bis zum nächsten Turnuslauf für jeden ladbar. Der Turnus hat es inzwischen geschlossen (401 bestätigt). Ein Aufruf am Ende von `/api/updateservice/v1/publish` würde das Fenster ganz vermeiden; das Verzeichnis existiert dort bereits |
| **Die Prüfvorschrift aus UPGRADE §16.4 meldet falsch grün.** `curl -I …/.htpasswd → 403` trifft auch dann zu, wenn die Datei gar nicht existiert: Apache sperrt `.ht*` global | Wir hatten 403 auf `.htpasswd` **und** 200 auf `package.tar.gz`. Aussagekräftig ist nur der Paket-Abruf ohne Zugangsdaten |
| **Kein Signierschlüssel auf dem Server.** `security.release_private_key` ist nicht gesetzt (UPGRADE §15.2) | Releases sind unsigniert, der Agent kann die Herkunft nicht prüfen. `--require-signature` ist damit unbenutzbar |
Die Ableitung des Benutzernamens muss auf beiden Seiten zeichengenau übereinstimmen —
`lic_` plus die ersten 16 Hexzeichen des SHA-256 über den getrimmten Schlüssel. Wer eine
Seite ändert, sperrt die gesamte Installationsbasis aus.