+
+
+
+
+ Die Release-Verzeichnisse liegen hinter HTTP-Basic-Auth. Zugang hat, wer
+ einen gültigen Lizenzschlüssel 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
+ Installationskonten in jeder Datei: bei einer
+ Erstinstallation gibt es noch keinen Schlüssel.
+
+
+
+ Je Produkt eine eigene Datei. Eine gemeinsame Liste würde
+ bedeuten, dass eine Lizenz für Produkt A auch Produkt B öffnet. Die Zuordnung
+ ergibt sich aus license_licenses.product_id.
+
+
+
+
+
+ | Produkt |
+ Status |
+ Zugänge |
+ Zuletzt erzeugt |
+
+
+
+
+ | Es liegen noch keine Release-Verzeichnisse vor. |
+
+
+
+ | = e((string)$g['slug']) ?> |
+
+
+ OFFEN
+
+ GESPERRT
+
+ GESCHÜTZT
+
+
+ NICHT SCHREIBBAR
+
+ |
+ = (int)$g['entries'] ?> |
+
+ = $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
+ |
+
+
+
+
+
+
+ OFFEN heißt: jeder im Internet kann die Pakete laden.
+ GESPERRT 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.
+
+
+
+
+
+ Geschieht automatisch bei jeder Lizenz- und Kontoänderung. Abgelaufene
+ Lizenzen lösen von sich aus nichts aus — dafür gleicht
+ cli/tick.php nach, spätestens alle sechs Stunden.
+
+
+
+
@@ -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': [
diff --git a/scripts/deploy.py b/scripts/deploy.py
index e9200e3..d3df8b8 100644
--- a/scripts/deploy.py
+++ b/scripts/deploy.py
@@ -25,6 +25,10 @@ IGNORE_PATTERNS = {
'log',
'__pycache__',
'client-dotnet',
+ # Gebaute Installer-Binaries. Sie gehoeren nach /installer/ und werden
+ # von upload_installer.py dorthin gebracht - ueber diesen Weg landeten
+ # sonst ~100 MB zusaetzlich unter /artifacts/ im Webroot.
+ 'artifacts',
}
def load_config():
diff --git a/src/Modules/UpdateService/ReleaseGuard.php b/src/Modules/UpdateService/ReleaseGuard.php
new file mode 100644
index 0000000..c0d5fb5
--- /dev/null
+++ b/src/Modules/UpdateService/ReleaseGuard.php
@@ -0,0 +1,438 @@
+,errors:list
}
+ */
+ 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 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 <<
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
+
+
+ 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}
+ */
+ 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>
+ */
+ 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;
+ }
+}