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
+165
View File
@@ -32,6 +32,7 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\Evaluator;
@@ -233,6 +234,34 @@ function dc_flash(string $message, string $type = 'success'): void
* Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
* nicht anmelden.
*/
/**
* Erzeugt den Zugangsschutz der Release-Verzeichnisse neu.
*
* Wird nach jeder Aenderung aufgerufen, die beeinflusst, wer herunterladen
* darf: Lizenzen und Installationskonten. Bewusst immer fuer alle Produkte -
* bei einer Handvoll Projekten kostet das kaum etwas und erspart die
* Fehlerquelle, den richtigen Slug zu ermitteln.
*
* Ein Fehlschlag darf die ausloesende Aktion nicht scheitern lassen: Eine
* angelegte Lizenz ist angelegt, auch wenn die Datei gerade nicht schreibbar
* war. Der naechtliche Abgleich holt es nach, und die Oberflaeche zeigt den
* Zustand unter UpdateService an.
*/
function dc_guard_releases(PDO $pdo): void
{
try {
$result = ReleaseGuard::regenerateAll($pdo);
if ($result['errors'] !== []) {
Logger::warning('Release-Zugangsschutz teilweise fehlgeschlagen', [
'errors' => $result['errors'],
]);
}
} catch (Throwable $e) {
Logger::warning('Release-Zugangsschutz nicht erzeugt', ['error' => $e->getMessage()]);
}
}
function dc_active_admin_count(PDO $pdo): int
{
try {
@@ -477,6 +506,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db->prepare('DELETE FROM dc_projects WHERE id = :id')->execute([':id' => $id]);
});
// Mit dem Projekt sind auch seine Lizenzen weg.
dc_guard_releases($pdo);
dc_flash(sprintf('Projekt "%s" und alle zugehoerigen Daten wurden geloescht.', $project['name']));
dc_redirect('#tab-projects');
}
@@ -507,6 +539,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
Audit::log($pdo, $actor, 'license.create', ['key' => $licenseKey]);
// Der neue Schluessel muss sofort Updates ziehen koennen.
dc_guard_releases($pdo);
dc_flash('Lizenzschluessel erzeugt: <strong style="font-family:monospace;">' . e($licenseKey) . '</strong>');
dc_redirect('#tab-license');
}
@@ -538,6 +574,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
Audit::log($pdo, $actor, 'license.update', ['license_id' => $id, 'status' => $status]);
// Status oder Ablaufdatum koennen sich geaendert haben.
dc_guard_releases($pdo);
dc_flash($stmt->rowCount() > 0 ? 'Lizenzdaten aktualisiert.' : 'Keine Aenderung vorgenommen.');
dc_redirect('#tab-license');
}
@@ -552,6 +592,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id')
->execute([':status' => $newStatus, ':id' => $id]);
Audit::log($pdo, $actor, 'license.status', ['license_id' => $id, 'status' => $newStatus]);
// Eine widerrufene Lizenz muss den Zugang sofort verlieren -
// sonst zieht sie weiter Updates, bis der naechtliche
// Abgleich laeuft.
dc_guard_releases($pdo);
dc_flash('Lizenz-Status geaendert zu: ' . strtoupper($newStatus));
}
dc_redirect('#tab-license');
@@ -1219,6 +1265,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#sub-system-rocketchat');
}
case 'regenerate_release_guard': {
$guard = ReleaseGuard::regenerateAll($pdo);
if ($guard['errors'] !== []) {
dc_flash(
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
'danger'
);
} else {
dc_flash(sprintf(
'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s',
$guard['projects'],
$guard['entries'],
$guard['skipped'] !== []
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
: ''
));
}
dc_redirect('#sub-update-guard');
}
// ------------------------------------------------- Benutzerkonten
case 'add_user': {
$newUser = trim((string)($_POST['username'] ?? ''));
@@ -1261,6 +1329,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor,
]);
// 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);
}
dc_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s',
e($newUser),
@@ -1285,6 +1360,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
// Der Hash steht auch in den .htpasswd-Dateien.
dc_guard_releases($pdo);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger');
dc_redirect('#sub-system-users');
@@ -1320,6 +1399,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor,
]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
$disable ? 'deaktiviert' : 'wieder aktiviert'));
dc_redirect('#sub-system-users');
@@ -1355,6 +1436,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor,
]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
dc_redirect('#sub-system-users');
}
@@ -1618,6 +1701,9 @@ try {
}
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
$releaseGuard = ReleaseGuard::status();
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
// werden nicht von deploy.py uebertragen, sondern von
// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf
@@ -3047,6 +3133,84 @@ Antwort:
</div>
</div>
<div id="sub-update-guard" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔒 Zugangsschutz der Releases</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Die Release-Verzeichnisse liegen hinter HTTP-Basic-Auth. Zugang hat, wer
einen <strong>gültigen Lizenzschlüssel</strong> für das jeweilige Produkt
besitzt Benutzername und Passwort sind beide der Schlüssel. Die Anwendung
kennt ihn ohnehin und versorgt sich damit selbst. Zusätzlich stehen die
<strong>Installationskonten</strong> in jeder Datei: bei einer
Erstinstallation gibt es noch keinen Schlüssel.
</p>
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-bottom:1.25rem;">
<strong>Je Produkt eine eigene Datei.</strong> Eine gemeinsame Liste würde
bedeuten, dass eine Lizenz für Produkt A auch Produkt B öffnet. Die Zuordnung
ergibt sich aus <code>license_licenses.product_id</code>.
</div>
<table>
<thead>
<tr>
<th>Produkt</th>
<th>Status</th>
<th>Zugänge</th>
<th>Zuletzt erzeugt</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>
<?php endif; ?>
<?php foreach ($releaseGuard as $g): ?>
<tr>
<td><strong><?= e((string)$g['slug']) ?></strong></td>
<td>
<?php if (!$g['protected']): ?>
<span class="badge badge-down">OFFEN</span>
<?php elseif ((int)$g['entries'] === 0): ?>
<span class="badge badge-warning">GESPERRT</span>
<?php else: ?>
<span class="badge badge-up">GESCHÜTZT</span>
<?php endif; ?>
<?php if (!$g['writable']): ?>
<span class="badge badge-down" title="PHP kann hier nicht schreiben">NICHT SCHREIBBAR</span>
<?php endif; ?>
</td>
<td><?= (int)$g['entries'] ?></td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<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
Lizenzen ist das der Normalfall, solange kein Installationskonto existiert.
</p>
<form method="POST" action="index.php" style="margin-top:1.25rem;">
<?= $csrfField ?>
<input type="hidden" name="action" value="regenerate_release_guard">
<input type="hidden" name="_return" value="sub-update-guard">
<button type="submit" class="btn">Zugangsschutz jetzt neu erzeugen</button>
</form>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Geschieht automatisch bei jeder Lizenz- und Kontoänderung. Abgelaufene
Lizenzen lösen von sich aus nichts aus dafür gleicht
<code>cli/tick.php</code> nach, spätestens alle sechs Stunden.
</p>
</div>
</div>
<div id="sub-update-installer" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
@@ -4748,6 +4912,7 @@ SYSTEM
'updateservice': [
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
{ id: 'sub-update-installer', label: '⬇️ Installer' },
{ id: 'sub-update-guard', label: '🔒 Zugangsschutz' },
{ id: 'sub-update-publish', label: ' Release Veröffentlichen' }
],
'bugtracker': [