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>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 11:47:26 +02:00
co-authored by Claude Opus 5
parent ceb977187e
commit 7a3a5dad69
11 changed files with 684 additions and 31 deletions
+37
View File
@@ -182,6 +182,43 @@ 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.
`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.
+74 -9
View File
@@ -27,7 +27,20 @@
> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**.
Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
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 +50,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.
---
@@ -604,12 +618,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
@@ -669,10 +692,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
@@ -775,7 +840,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 +856,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."
+22 -1
View File
@@ -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**
+4 -3
View File
@@ -5,10 +5,11 @@
"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.",
"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", "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.",