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
+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);