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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user