/.htpasswd` → **403**
- [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64` → **200**, weiterhin offen
- [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401**
diff --git a/docs/setup.example.json b/docs/setup.example.json
index 11fad65..d80abff 100644
--- a/docs/setup.example.json
+++ b/docs/setup.example.json
@@ -5,10 +5,11 @@
"displayName": "Beispielanwendung",
"description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.",
- "_targets_comment": "Wohin geschrieben wird. Pfade sind relativ zum Installationsverzeichnis. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
+ "_targets_comment": "Wohin geschrieben wird. 'location' bestimmt den Bezugspunkt: install (Vorgabe, Installationsverzeichnis), config (%APPDATA% bzw. $XDG_CONFIG_HOME), data, home. In 'file' sind ${VAR} und %VAR% erlaubt, ein absoluter Pfad wird unveraendert benutzt. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
"targets": [
- { "id": "app", "file": "appsettings.json", "format": "json" },
- { "id": "env", "file": ".env", "format": "env" }
+ { "id": "app", "file": "appsettings.json", "location": "install", "format": "json" },
+ { "id": "user", "file": "beispielanwendung/Settings.json", "location": "config", "format": "json" },
+ { "id": "env", "file": ".env", "location": "install", "format": "env" }
],
"_fields_comment": "source steuert, ob gefragt wird: 'ask' (Vorgabe) fragt nach, 'detect:...' leitet ab, 'provision' laesst ein Token ausstellen. Ist der Wert in der Zieldatei bereits gesetzt und kein Platzhalter, wird er uebernommen und nicht erneut erfragt.",
diff --git a/public/index.php b/public/index.php
index a31b74d..b815db5 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1268,6 +1268,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
case 'regenerate_release_guard': {
$guard = ReleaseGuard::regenerateAll($pdo);
+ // Direkt gegenpruefen, ob der Schutz tatsaechlich greift.
+ // Vorhandene Dateien beweisen nichts - unter Nginx werden sie
+ // ignoriert, und die Oberflaeche meldete trotzdem Erfolg.
+ $test = ReleaseGuard::selfTest($pdo);
+
+ if ($test['failed'] !== []) {
+ $reasons = array_map(
+ static fn(array $f): string => ($f['slug'] ?? '?') . ': ' . ($f['reason'] ?? ''),
+ $test['failed']
+ );
+ dc_flash(
+ 'Zugangsschutz erzeugt, aber der Selbsttest schlaegt fehl! ' . e(implode(' | ', $reasons)),
+ 'danger'
+ );
+ dc_redirect('#sub-update-guard');
+ }
+
if ($guard['errors'] !== []) {
dc_flash(
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
@@ -1275,9 +1292,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
);
} else {
dc_flash(sprintf(
- 'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s',
+ 'Zugangsschutz erneuert und geprueft: %d Produkt(e), %d Zugang/Zugaenge, '
+ . '%d Adresse(n) antworten ohne Zugangsdaten korrekt mit 401.%s',
$guard['projects'],
$guard['entries'],
+ $test['ok'],
$guard['skipped'] !== []
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
: ''
@@ -1704,6 +1723,10 @@ $usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
$releaseGuard = ReleaseGuard::status();
+// Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je
+// Produkt bei jedem Seitenaufbau waere Verschwendung.
+$releaseGuardTest = ReleaseGuard::lastSelfTest($pdo);
+
// 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
@@ -3189,6 +3212,34 @@ Antwort:
+
+
+
+
Selbsttest vom = e((string)($releaseGuardTest['ran_at'] ?? '?')) ?> UTC:
+ = (int)($releaseGuardTest['ok'] ?? 0) ?> von
+ = (int)($releaseGuardTest['checked'] ?? 0) ?> Adresse(n) antworten ohne
+ Zugangsdaten korrekt mit 401.
+
+
+
+ - = e((string)($f['slug'] ?? '?')) ?>: = e((string)($f['reason'] ?? '')) ?>
+
+
+
+
+
+
+
+ Der Selbsttest ist die einzige belastbare Aussage. Dass die
+ Dateien vorhanden sind, beweist nichts: Unter Nginx werden
+ .htaccess-Dateien vollständig ignoriert — die Verzeichnisse wären
+ dann offen und die .htpasswd öffentlich abrufbar, während hier
+ „GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten
+ ab und erwartet 401.
+
+
OFFEN heißt: jeder im Internet kann die Pakete laden.
GESPERRT heißt: niemand kommt heran, weil es weder gültige
diff --git a/src/Modules/UpdateService/ReleaseGuard.php b/src/Modules/UpdateService/ReleaseGuard.php
index c0d5fb5..2132b49 100644
--- a/src/Modules/UpdateService/ReleaseGuard.php
+++ b/src/Modules/UpdateService/ReleaseGuard.php
@@ -47,6 +47,18 @@ final class ReleaseGuard
/** Von Apache verstandene Hash-Praefixe. */
private const APACHE_BCRYPT_PREFIXES = ['$2y$', '$2a$', '$2b$'];
+ /**
+ * Leitet den Basic-Auth-Benutzernamen aus einem Lizenzschluessel ab.
+ *
+ * MUSS zeichengenau mit ReleaseCredentials.FromLicenseKey() im .NET-SDK
+ * uebereinstimmen - weichen die beiden voneinander ab, kommt niemand mehr
+ * an seine Updates.
+ */
+ public static function licenseUsername(string $licenseKey): string
+ {
+ return 'lic_' . substr(hash('sha256', trim($licenseKey)), 0, 16);
+ }
+
/** Wurzelverzeichnis der Releases im Dateisystem. */
public static function releasesRoot(): string
{
@@ -103,10 +115,15 @@ final class ReleaseGuard
}
}
+ // Auch der Weg ueber die Oberflaeche vermerkt das Format - sonst
+ // sieht der naechste Tick eine Formataenderung, die es nicht gibt.
+ self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
+
Logger::info('Release-Zugangsschutz neu erzeugt', [
'projects' => $result['projects'],
'entries' => $result['entries'],
'errors' => count($result['errors']),
+ 'format' => self::FORMAT_VERSION,
]);
return $result;
@@ -165,9 +182,16 @@ final class ReleaseGuard
// 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.
+ // Der Benutzername wird aus dem Schluessel ABGELEITET, er ist nicht der
+ // Schluessel selbst. Das htpasswd-Format hasht nur die Passwortspalte;
+ // stuende der Schluessel als Benutzername daneben, waere die Datei eine
+ // vollstaendige Klartext-Kundenliste und der bcrypt-Hash daneben blosse
+ // Dekoration. So enthaelt sie nur eine Einwegableitung und einen Hash
+ // ueber einen hochentropen Schluessel - selbst offengelegt ist damit
+ // nichts anzufangen.
+ //
+ // Die Ableitung muss zeichengenau der clientseitigen in
+ // ReleaseCredentials.FromLicenseKey() entsprechen.
try {
$stmt = $db->prepare("
SELECT l.license_key
@@ -185,7 +209,8 @@ final class ReleaseGuard
continue;
}
- $entries[$key] = password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
+ $entries[self::licenseUsername($key)] =
+ password_hash($key, PASSWORD_BCRYPT, ['cost' => self::LICENSE_HASH_COST]);
}
} catch (\Throwable $e) {
Logger::warning('Lizenzen fuer den Release-Schutz nicht lesbar', [
@@ -304,6 +329,171 @@ final class ReleaseGuard
/** Schluessel in dc_settings, unter dem der letzte Lauf vermerkt wird. */
private const LAST_RUN_KEY = 'release_guard_last_run';
+ /** Schluessel in dc_settings fuer das Ergebnis des letzten Selbsttests. */
+ private const SELFTEST_KEY = 'release_guard_selftest';
+
+ /** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */
+ private const FORMAT_KEY = 'release_guard_format';
+
+ /**
+ * Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien
+ * neu geschrieben werden, sonst passen Server und Client nicht mehr
+ * zusammen.
+ *
+ * 1 - Benutzername = Lizenzschluessel im Klartext
+ * 2 - Benutzername = lic_
+ *
+ * Ohne diese Kennung waere der Umstieg auf 2 unbemerkt liegengeblieben:
+ * reconcile() sah keinen Anlass, und die Dateien behielten bis zum
+ * naechsten turnusmaessigen Lauf das alte Format - waehrend die Clients
+ * bereits das neue schicken.
+ */
+ private const FORMAT_VERSION = 2;
+
+ /**
+ * Prueft, ob der Schutz tatsaechlich greift - durch einen echten Abruf.
+ *
+ * Dass .htaccess und .htpasswd vorhanden sind, beweist nichts. Unter Nginx
+ * werden .htaccess-Dateien vollstaendig ignoriert; die Verzeichnisse
+ * waeren dann offen und die .htpasswd oeffentlich abrufbar, waehrend die
+ * Oberflaeche "geschuetzt" meldet. Dasselbe gilt bei abgeschaltetem
+ * AllowOverride oder einem Tippfehler in der erzeugten Datei.
+ *
+ * Nur ein Abruf der eigenen Paket-Adresse ohne Zugangsdaten beantwortet
+ * die Frage, und der findet alle drei Faelle auf einmal.
+ *
+ * @return array{checked:int,ok:int,failed:list>,ran_at:string}
+ */
+ public static function selfTest(PDO $db, ?string $slug = null): array
+ {
+ $baseUrl = rtrim((string)\Deploymentcenter\Core\Config::get('app.url', ''), '/');
+
+ $result = ['checked' => 0, 'ok' => 0, 'failed' => [], 'ran_at' => gmdate('Y-m-d H:i:s')];
+
+ if ($baseUrl === '') {
+ $result['failed'][] = ['slug' => '-', 'reason' => 'app.url ist nicht konfiguriert.'];
+ return $result;
+ }
+
+ $sql = '
+ SELECT product_slug, download_url
+ FROM updateservice_releases
+ WHERE download_url <> ""
+ ';
+ $params = [];
+
+ if ($slug !== null && $slug !== '') {
+ $sql .= ' AND product_slug = :slug';
+ $params[':slug'] = $slug;
+ }
+
+ $sql .= ' ORDER BY product_slug ASC, created_at DESC';
+
+ try {
+ $stmt = $db->prepare($sql);
+ $stmt->execute($params);
+ $rows = $stmt->fetchAll() ?: [];
+ } catch (\Throwable $e) {
+ $result['failed'][] = ['slug' => '-', 'reason' => 'Releases nicht lesbar: ' . $e->getMessage()];
+ return $result;
+ }
+
+ // Je Produkt genuegt eine Adresse.
+ $seen = [];
+
+ foreach ($rows as $row) {
+ $product = (string)$row['product_slug'];
+ if (isset($seen[$product])) {
+ continue;
+ }
+ $seen[$product] = true;
+
+ $url = (string)$row['download_url'];
+ $result['checked']++;
+
+ $status = self::probe($url);
+
+ if ($status === 401) {
+ $result['ok']++;
+ continue;
+ }
+
+ $result['failed'][] = [
+ 'slug' => $product,
+ 'status' => $status,
+ 'url' => $url,
+ 'reason' => $status === 200
+ ? 'Das Paket ist OHNE Zugangsdaten abrufbar. Wird .htaccess ausgewertet? '
+ . 'Unter Nginx wirkungslos, unter Apache AllowOverride pruefen.'
+ : sprintf('Unerwarteter Status %s - erwartet wurde 401.', $status === null ? 'keiner' : (string)$status),
+ ];
+ }
+
+ self::writeSetting($db, self::SELFTEST_KEY, json_encode($result) ?: '');
+
+ Logger::info('Release-Selbsttest', [
+ 'checked' => $result['checked'],
+ 'ok' => $result['ok'],
+ 'failed' => count($result['failed']),
+ ]);
+
+ return $result;
+ }
+
+ /** Ergebnis des letzten Selbsttests, oder null. */
+ public static function lastSelfTest(PDO $db): ?array
+ {
+ $raw = self::readSetting($db, self::SELFTEST_KEY);
+ if ($raw === null) {
+ return null;
+ }
+
+ $decoded = json_decode($raw, true);
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ /**
+ * Ruft eine Adresse ohne Zugangsdaten ab und liefert den Statuscode.
+ * Bewusst nur der Kopf der Antwort - es geht um den Status, nicht um
+ * mehrere Megabyte Paket.
+ */
+ private static function probe(string $url): ?int
+ {
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_NOBODY => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_FOLLOWLOCATION => false,
+ // Bewusst kein Zugangsdaten-Header: geprueft wird ja gerade,
+ // ob der Abruf ohne scheitert.
+ ]);
+ curl_exec($ch);
+ $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+
+ return $status > 0 ? $status : null;
+ }
+
+ $context = stream_context_create(['http' => [
+ 'method' => 'HEAD',
+ 'timeout' => 10,
+ 'ignore_errors' => true,
+ ]]);
+
+ @file_get_contents($url, false, $context);
+
+ foreach ($http_response_header ?? [] as $header) {
+ if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $m) === 1) {
+ return (int)$m[1];
+ }
+ }
+
+ return null;
+ }
+
/**
* Fuer den Cron gedacht: erzeugt nur dann neu, wenn es noetig ist.
*
@@ -325,6 +515,8 @@ final class ReleaseGuard
if ($lastRun === null) {
$reason = 'noch nie gelaufen';
+ } elseif ((int)(self::readSetting($db, self::FORMAT_KEY) ?? '1') !== self::FORMAT_VERSION) {
+ $reason = 'Dateiformat geaendert';
} elseif (self::hasExpiredSince($db, $lastRun)) {
$reason = 'Lizenz abgelaufen';
} elseif ((time() - strtotime($lastRun . ' UTC')) >= $maxAge) {
@@ -366,10 +558,21 @@ final class ReleaseGuard
}
private static function readLastRun(PDO $db): ?string
+ {
+ return self::readSetting($db, self::LAST_RUN_KEY);
+ }
+
+ private static function writeLastRun(PDO $db): void
+ {
+ self::writeSetting($db, self::LAST_RUN_KEY, gmdate('Y-m-d H:i:s'));
+ self::writeSetting($db, self::FORMAT_KEY, (string)self::FORMAT_VERSION);
+ }
+
+ private static function readSetting(PDO $db, string $key): ?string
{
try {
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
- $stmt->execute([':k' => self::LAST_RUN_KEY]);
+ $stmt->execute([':k' => $key]);
$value = $stmt->fetchColumn();
return is_string($value) && $value !== '' ? $value : null;
} catch (\Throwable $e) {
@@ -377,16 +580,16 @@ final class ReleaseGuard
}
}
- private static function writeLastRun(PDO $db): void
+ private static function writeSetting(PDO $db, string $key, string $value): 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')]);
+ $stmt->execute([':k' => $key, ':v' => $value]);
} catch (\Throwable $e) {
- Logger::warning('Zeitpunkt des Release-Schutzes nicht vermerkt', ['error' => $e->getMessage()]);
+ Logger::warning('Einstellung nicht gespeichert', ['key' => $key, 'error' => $e->getMessage()]);
}
}