feat(releases): Zugangsschutz ueber Lizenzschluessel

/releases/ wurde bisher offen ausgeliefert, damit ausgelieferte Anwendungen
ohne Zugangsdaten nach Updates suchen koennen. Das bedeutete aber auch, dass
jeder im Internet die vollstaendigen Pakete herunterladen konnte - mitsamt
allem, was versehentlich darin liegt. Genau so lag ein echter API-Schluessel
in einer mitgelieferten appsettings.json oeffentlich abrufbar.

Zugang haengt jetzt am Lizenzschluessel: Wer eine gueltige Lizenz fuer ein
Produkt hat, kommt an dessen Updates. Die Anwendung kennt ihren Schluessel
ohnehin und versorgt sich damit selbst - es muss nichts verteilt werden.

Server
- ReleaseGuard erzeugt je Produktverzeichnis .htaccess und .htpasswd.
  Bewusst getrennt: eine gemeinsame Datei wuerde bedeuten, dass eine Lizenz
  fuer Produkt A auch Produkt B oeffnet. license_licenses.product_id bindet
  jeden Schluessel ohnehin an genau ein Projekt.
- Eingetragen werden aktive, nicht abgelaufene Lizenzen (Benutzername =
  Passwort = Schluessel; Basic Auth braucht zwei Felder, es gibt aber nur ein
  Geheimnis) sowie alle Installationskonten - bei einer Erstinstallation gibt
  es noch keinen Schluessel, mit dem sich das Paket holen liesse.
- Deren Hash wird unveraendert aus dc_users uebernommen: password_hash()
  erzeugt bcrypt im Format $2y$, genau das versteht Apache. Ein
  Klartextpasswort wird nirgends gebraucht. Argon2-Hashes werden erkannt und
  uebersprungen statt eine unbrauchbare Datei zu erzeugen.
- Lizenzschluessel werden mit Kosten 8 gehasht statt 12: 29 Zeichen
  maschineller Zufall sind kein Menschenpasswort, Apache prueft aber bei
  *jeder* Anfrage neu.
- Geschrieben wird ueber eine temporaere Datei mit rename() - ein Abbruch
  wuerde sonst eine halbe Zugangsdatei hinterlassen und in dem Moment die
  halbe Kundschaft aussperren.
- Neu erzeugt bei jeder Lizenz- und Kontoaenderung. Abgelaufene Lizenzen
  loesen anders als ein Widerruf nichts aus; dafuer gleicht cli/tick.php nach
  und erzeugt spaetestens alle sechs Stunden neu.
- Statusanzeige und Schaltflaeche im WebUI unter UpdateService.

Client
- ReleaseCredentials: Lizenzschluessel oder Installationskonto als Basic Auth.
- UpdateClient und Agent senden sie fuer latest.json und package.tar.gz.
- UpdateCheckResult.Unauthorized trennt "Lizenz traegt nicht mehr" von einem
  Netzwerkfehler. Ohne diese Unterscheidung sucht man an der falschen Stelle.
- LaunchUpdateAgent reicht licenseKey als --license-key durch.
- Der Installer benutzt die beim Anmelden eingegebenen Zugangsdaten auch fuer
  den Paketabruf; das Setup-Token taugt dafuer nicht, weil Apache prueft und
  nicht die Anwendung.

Sonstiges
- deploy.py klammert artifacts/ aus. Ohne das landeten die gebauten
  Installer-Binaries zusaetzlich unter /artifacts/ im Webroot.

ACHTUNG Reihenfolge: Der Schutz sperrt jede Anwendung aus, die noch mit dem
alten SDK gebaut ist. Erst ausliefern, dann scharfschalten - siehe
UPGRADE.md §16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 11:21:49 +02:00
co-authored by Claude Opus 5
parent 7f41372e5f
commit ceb977187e
11 changed files with 1035 additions and 7 deletions
+120
View File
@@ -573,6 +573,126 @@ darauf hin; wer weiter zurück muss, holt das Paket von Hand.
---
## 5A. Zugangsschutz der Release-Verzeichnisse
> **Neu in Version 2.4.** Zuvor war `/releases/` offen — damit jede
> ausgelieferte Anwendung ohne Zugangsdaten nach Updates suchen konnte. Das
> bedeutete aber auch: **jeder im Internet konnte die vollständigen Pakete
> herunterladen**, mitsamt allem, was versehentlich darin lag.
Der Zugang hängt jetzt am Lizenzschlüssel. Wer eine gültige Lizenz für ein
Produkt hat, kommt an dessen Updates; wer keine hat, braucht auch keine. Die
Anwendung kennt ihren Schlüssel ohnehin und versorgt sich damit selbst — es
muss nichts zusätzlich verteilt werden.
### Wie es aufgebaut ist
Je Produktverzeichnis eine eigene `.htpasswd`:
```text
/releases/predictalytics/.htaccess ← AuthType Basic, Require valid-user
/releases/predictalytics/.htpasswd ← nur Schlüssel für predictalytics
/releases/warenwirtschaft/.htpasswd ← nur Schlüssel für warenwirtschaft
```
**Bewusst getrennt.** Eine gemeinsame Datei würde bedeuten, dass eine Lizenz für
Produkt A auch Produkt B öffnet. Da `license_licenses.product_id` jeden
Schlüssel ohnehin an genau ein Projekt bindet, fällt die Trennung beim Erzeugen
von selbst ab.
Darin stehen:
| Eintrag | Benutzername | Passwort |
|---|---|---|
| Gültige Lizenz | der Lizenzschlüssel | derselbe 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.
Die Installationskonten stehen in **jeder** Datei — bei einer Erstinstallation
gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr
Hash wird unverändert aus `dc_users` übernommen: PHPs `password_hash()` erzeugt
bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort
wird nirgends gebraucht.
### Was sich für Clients ändert
**Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.**
```csharp
var checkResult = await updateClient.CheckForUpdateAsync(
baseUrl: "https://dc.mhdf.de",
projectId: "myapp",
currentVersion: BuildInfo.Version,
channel: "prod",
credentials: ReleaseCredentials.FromLicenseKey(meineLizenz));
if (checkResult.Unauthorized)
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr.
Console.WriteLine(checkResult.Message);
}
UpdateClient.LaunchUpdateAgent(
agentPath: UpdateClient.ResolveAgentPath()!,
projectId: "myapp",
licenseKey: meineLizenz); // reicht --license-key an den Agenten durch
```
Auf der Kommandozeile:
```bash
update-agent --project myapp --action update --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
```
Ersatzweise `--download-user` und `--download-password` für ein
Installationskonto — das nutzt der Installer bei der Erstinstallation.
`UpdateCheckResult.Unauthorized` ist bewusst von einem gewöhnlichen Fehler
getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine
Netzwerkstörung, und man sucht an der falschen Stelle.
### Wann neu erzeugt wird
Automatisch beim Anlegen, Bearbeiten, Widerrufen und Sperren einer Lizenz, beim
Löschen eines Projekts und bei jeder Änderung an einem Installationskonto.
**Abgelaufene Lizenzen lösen von sich aus nichts aus** — der Zeitpunkt vergeht
einfach. Dafür gleicht `cli/tick.php` nach: Es prüft, ob seit dem letzten Lauf
etwas abgelaufen ist, und erzeugt spätestens alle sechs Stunden ohnehin neu.
Ein minütlicher Aufruf ist unbedenklich, im Normalfall passiert nichts.
Von Hand: WebUI → **UpdateService → 🔒 Zugangsschutz**. Dort steht auch, welche
Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind.
| Anzeige | Bedeutung |
|---|---|
| GESCHÜTZT | alles in Ordnung |
| OFFEN | keine `.htaccess` — jeder im Internet kann laden |
| GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten |
### Grenzen
**Das macht Pakete nicht sicher.** Jeder lizenzierte Kunde kann sie weiterhin
auspacken. Ein eigener API-Schlüssel in einer mitgelieferten `appsettings.json`
wäre danach statt öffentlich eben für alle Kunden sichtbar — besser, aber immer
noch falsch. Was nicht ins Paket gehört, gehört auch mit Zugangsschutz nicht
hinein; siehe **[§3A](#3a-ausschließen-oder-schützen)**.
Die Lese-Endpunkte der API bleiben offen. Sie verraten weiterhin, welche
Produkte und Versionen es gibt — die Download-Adressen sind ohne Zugangsdaten
aber wertlos.
### Abschalten
Die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen löschen. Beim
nächsten Auslöser entstehen sie neu; wer es dauerhaft will, muss den Aufruf von
`ReleaseGuard` entfernen.
---
## 6. Signatur der Releases
Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer