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
+438
View File
@@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\UpdateService;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Schuetzt die Release-Verzeichnisse mit HTTP-Basic-Auth.
*
* Ausgangslage: /releases/ wurde statisch und ohne Authentifizierung
* 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.
*
* Der Zugang haengt jetzt am Lizenzschluessel: Wer eine gueltige Lizenz hat,
* kommt an die Updates seines Produkts; wer keine hat, braucht auch keine.
* Die Anwendung kennt ihren Schluessel ohnehin und kann sich damit selbst
* versorgen - es muss nichts zusaetzlich verteilt werden.
*
* JE PRODUKT EINE DATEI. Eine gemeinsame .htpasswd wuerde bedeuten, dass eine
* Lizenz fuer Produkt A auch Produkt B oeffnet. Da license_licenses ohnehin
* ueber product_id an genau ein Projekt gebunden ist, faellt die Trennung beim
* Erzeugen von selbst ab.
*
* Zusaetzlich stehen die Installationskonten in jeder Datei: Bei einer
* Erstinstallation gibt es noch keinen Lizenzschluessel, mit dem sich das
* Paket holen liesse.
*/
final class ReleaseGuard
{
/**
* Kosten fuer das Hashen der Lizenzschluessel.
*
* Bewusst niedriger als bei einem Benutzerpasswort: Ein Lizenzschluessel
* ist 29 Zeichen maschinell erzeugter Zufall, kein von Menschen gewaehltes
* Wort - Woerterbuchangriffe greifen nicht, und Durchprobieren ist auch
* bei geringen Kosten aussichtslos. Dafuer prueft Apache bei *jeder*
* Anfrage neu, auch bei jeder Update-Abfrage; Kosten 12 wuerden dort
* spuerbar.
*/
private const LICENSE_HASH_COST = 8;
/** Von Apache verstandene Hash-Praefixe. */
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
/** Wurzelverzeichnis der Releases im Dateisystem. */
public static function releasesRoot(): string
{
$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if (is_string($docRoot) && $docRoot !== '' && is_dir($docRoot . '/releases')) {
return rtrim($docRoot, '/') . '/releases';
}
// Aufruf ueber die Kommandozeile (Cron): dort gibt es kein
// DOCUMENT_ROOT. Die Ablage liegt neben dem Anwendungsverzeichnis.
$fallback = dirname(__DIR__, 3) . '/releases';
return $fallback;
}
/**
* Erzeugt den Schutz fuer alle Produkte neu, die ein Release-Verzeichnis
* haben.
*
* @return array{projects:int,entries:int,skipped:list<string>,errors:list<string>}
*/
public static function regenerateAll(PDO $db): array
{
$root = self::releasesRoot();
$result = ['projects' => 0, 'entries' => 0, 'skipped' => [], 'errors' => []];
if (!is_dir($root)) {
$result['errors'][] = sprintf('Release-Verzeichnis nicht gefunden: %s', $root);
return $result;
}
// Massgeblich ist, was tatsaechlich auf der Platte liegt - ein Projekt
// ohne Release-Verzeichnis braucht keinen Schutz, und ein Verzeichnis
// ohne Projekt darf nicht offen bleiben.
$directories = array_filter(
scandir($root) ?: [],
static fn(string $entry): bool => $entry !== '.' && $entry !== '..' && is_dir($root . '/' . $entry)
);
foreach ($directories as $slug) {
$single = self::regenerateForProject($db, (string)$slug, $root);
if ($single['error'] !== null) {
$result['errors'][] = $single['error'];
continue;
}
$result['projects']++;
$result['entries'] += $single['entries'];
if ($single['entries'] === 0) {
$result['skipped'][] = (string)$slug;
}
}
Logger::info('Release-Zugangsschutz neu erzeugt', [
'projects' => $result['projects'],
'entries' => $result['entries'],
'errors' => count($result['errors']),
]);
return $result;
}
/**
* Erzeugt .htaccess und .htpasswd fuer ein einzelnes Produkt.
*
* @return array{entries:int,error:?string}
*/
public static function regenerateForProject(PDO $db, string $slug, ?string $root = null): array
{
$root ??= self::releasesRoot();
$dir = $root . '/' . $slug;
if (!is_dir($dir)) {
return ['entries' => 0, 'error' => null];
}
if (!is_writable($dir)) {
return ['entries' => 0, 'error' => sprintf('Kein Schreibrecht in %s', $dir)];
}
$entries = self::collectEntries($db, $slug);
$lines = [];
foreach ($entries as $username => $hash) {
$lines[] = $username . ':' . $hash;
}
$htpasswd = implode("\n", $lines) . "\n";
$htaccess = self::buildHtaccess($dir, $slug);
// Erst schreiben, dann umbenennen: ein abgebrochener Schreibvorgang
// wuerde sonst eine halbe Datei hinterlassen, und Apache liesse in dem
// Moment die halbe Kundschaft nicht mehr herein.
if (!self::writeAtomic($dir . '/.htpasswd', $htpasswd)) {
return ['entries' => 0, 'error' => sprintf('.htpasswd in %s nicht schreibbar', $dir)];
}
if (!self::writeAtomic($dir . '/.htaccess', $htaccess)) {
return ['entries' => 0, 'error' => sprintf('.htaccess in %s nicht schreibbar', $dir)];
}
return ['entries' => count($entries), 'error' => null];
}
/**
* Stellt zusammen, wer dieses Produkt herunterladen darf.
*
* @return array<string,string> Benutzername => Hash
*/
private static function collectEntries(PDO $db, string $slug): array
{
$entries = [];
// 1. Gueltige Lizenzen dieses Produkts.
//
// Benutzername und Passwort sind beide der Lizenzschluessel: Basic Auth
// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
// Benutzernamen muessen eindeutig sein.
try {
$stmt = $db->prepare("
SELECT l.license_key
FROM license_licenses l
JOIN dc_projects p ON p.id = l.product_id
WHERE p.slug = :slug
AND l.status = 'active'
AND (l.expires_at IS NULL OR l.expires_at > UTC_TIMESTAMP())
");
$stmt->execute([':slug' => $slug]);
foreach ($stmt->fetchAll() ?: [] as $row) {
$key = trim((string)$row['license_key']);
if ($key === '') {
continue;
}
$entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
}
} catch (\Throwable $e) {
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
'slug' => $slug,
'error' => $e->getMessage(),
]);
}
// 2. Installationskonten.
//
// Ohne sie waere eine Erstinstallation unmoeglich: Zu diesem Zeitpunkt
// ist die Anwendung noch nicht eingerichtet und hat keinen
// Lizenzschluessel, mit dem sie das Paket holen koennte.
//
// Der in dc_users abgelegte Hash wird unveraendert uebernommen - PHPs
// password_hash() erzeugt bcrypt im Format $2y$, genau das versteht
// Apache. Ein Klartextpasswort wird dafuer nirgends gebraucht.
try {
$stmt = $db->query("
SELECT username, password_hash
FROM dc_users
WHERE role = 'installer' AND disabled = 0
");
foreach ($stmt->fetchAll() ?: [] as $row) {
$username = trim((string)$row['username']);
$hash = (string)$row['password_hash'];
if ($username === '' || $hash === '') {
continue;
}
$usable = false;
foreach (self::APACHE_BCRYPT_PREFIXES as $prefix) {
if (str_starts_with($hash, $prefix)) {
$usable = true;
break;
}
}
if (!$usable) {
// Etwa wenn PHPs Standardverfahren einmal auf Argon2
// wechselt - das versteht Apache nicht.
Logger::warning('Installationskonto fuer den Release-Schutz uebersprungen', [
'username' => $username,
'reason' => 'Hash-Verfahren wird von Apache nicht unterstuetzt',
]);
continue;
}
$entries[$username] = $hash;
}
} catch (\Throwable $e) {
Logger::warning('Installationskonten nicht lesbar', ['error' => $e->getMessage()]);
}
return $entries;
}
private static function buildHtaccess(string $dir, string $slug): string
{
$realm = 'Deploymentcenter Releases - ' . $slug;
return <<<HTACCESS
# Automatisch erzeugt von ReleaseGuard - Aenderungen gehen beim
# naechsten Lauf verloren. Erzeugt wird bei jeder Lizenzaenderung und
# einmal taeglich ueber cli/tick.php.
#
# Zugang haben: gueltige Lizenzschluessel dieses Produkts
# (Benutzername = Passwort = Schluessel) sowie die Installationskonten.
AuthType Basic
AuthName "{$realm}"
AuthUserFile {$dir}/.htpasswd
Require valid-user
# Die Zugangsdatei selbst darf nicht ausgeliefert werden. Die meisten
# Apache-Installationen sperren .ht* global - verlassen sollte man sich
# darauf nicht.
<FilesMatch "^\\.ht">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
HTACCESS;
}
/**
* Schreibt ueber eine temporaere Datei und benennt sie um. Unter Linux ist
* rename() innerhalb desselben Dateisystems atomar - es gibt also keinen
* Moment, in dem Apache eine halb geschriebene Zugangsdatei liest.
*/
private static function writeAtomic(string $path, string $content): bool
{
$temp = $path . '.tmp-' . bin2hex(random_bytes(4));
if (@file_put_contents($temp, $content) === false) {
return false;
}
@chmod($temp, 0644);
if (!@rename($temp, $path)) {
@unlink($temp);
return false;
}
return true;
}
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
private const LAST_RUN_KEY = 'release_guard_last_run';
/**
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
*
* Der Tick laeuft minuetlich; jedes Mal alle Lizenzschluessel neu zu
* hashen waere Verschwendung. Zwei Anlaesse gibt es aber:
*
* - Eine Lizenz ist seit dem letzten Lauf abgelaufen. Anders als ein
* Widerruf loest das von sich aus nichts aus - der Zeitpunkt vergeht
* einfach, und ohne diese Pruefung behielte die Lizenz ihren Zugang.
* - Der letzte Lauf ist laenger her als $maxAge. Sicherheitsnetz gegen
* Aenderungen, die ausserhalb der Oberflaeche passiert sind.
*
* @return array{ran:bool,reason:string,projects:int,entries:int,errors:list<string>}
*/
public static function reconcile(PDO $db, int $maxAge = 21600): array
{
$lastRun = self::readLastRun($db);
$reason = '';
if ($lastRun === null) {
$reason = 'noch nie gelaufen';
} elseif (self::hasExpiredSince($db, $lastRun)) {
$reason = 'Lizenz abgelaufen';
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
$reason = 'turnusmaessig';
}
if ($reason === '') {
return ['ran' => false, 'reason' => 'nichts zu tun', 'projects' => 0, 'entries' => 0, 'errors' => []];
}
$result = self::regenerateAll($db);
self::writeLastRun($db);
return [
'ran' => true,
'reason' => $reason,
'projects' => $result['projects'],
'entries' => $result['entries'],
'errors' => $result['errors'],
];
}
private static function hasExpiredSince(PDO $db, string $since): bool
{
try {
$stmt = $db->prepare("
SELECT COUNT(*) FROM license_licenses
WHERE expires_at IS NOT NULL
AND expires_at <= UTC_TIMESTAMP()
AND expires_at > :since
");
$stmt->execute([':since' => $since]);
return (int)$stmt->fetchColumn() > 0;
} catch (\Throwable $e) {
// Im Zweifel neu erzeugen - lieber einmal zu viel als eine
// abgelaufene Lizenz, die weiter herunterlaedt.
return true;
}
}
private static function readLastRun(PDO $db): ?string
{
try {
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
$stmt->execute([':k' => self::LAST_RUN_KEY]);
$value = $stmt->fetchColumn();
return is_string($value) && $value !== '' ? $value : null;
} catch (\Throwable $e) {
return null;
}
}
private static function writeLastRun(PDO $db): void
{
try {
$stmt = $db->prepare('
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
');
$stmt->execute([':k' => self::LAST_RUN_KEY, ':v' => gmdate('Y-m-d H:i:s')]);
} catch (\Throwable $e) {
Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]);
}
}
/**
* Kurzbericht fuer die Oberflaeche: Welche Produkte sind geschuetzt, wie
* viele Zugaenge stehen darin, wann wurde zuletzt erzeugt?
*
* @return list<array<string,mixed>>
*/
public static function status(): array
{
$root = self::releasesRoot();
if (!is_dir($root)) {
return [];
}
$report = [];
foreach (scandir($root) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || !is_dir($root . '/' . $entry)) {
continue;
}
$dir = $root . '/' . $entry;
$htpasswd = $dir . '/.htpasswd';
$htaccess = $dir . '/.htaccess';
$protected = is_file($htpasswd) && is_file($htaccess);
$count = 0;
if ($protected) {
$lines = file($htpasswd, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$count = count($lines);
}
$report[] = [
'slug' => $entry,
'protected' => $protected,
'entries' => $count,
'updated_at' => $protected ? gmdate('Y-m-d H:i:s', (int)filemtime($htpasswd)) : null,
'writable' => is_writable($dir),
];
}
usort($report, static fn(array $a, array $b): int => strcmp((string)$a['slug'], (string)$b['slug']));
return $report;
}
}