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:
co-authored by
Claude Opus 5
parent
7f41372e5f
commit
ceb977187e
@@ -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': [
|
||||
|
||||
Reference in New Issue
Block a user