diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets index 7d52d7c..4cdbe97 100644 --- a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.BuildInfo.targets @@ -42,7 +42,6 @@ $(MSBuildProjectName) $(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs - $([System.DateTime]::UtcNow.ToString("o")) $(Version) @@ -73,6 +72,29 @@ + + + $([System.DateTime]::UtcNow.ToString("yyyy-MM-dd")) + + UNKNOWN_COMMIT UNKNOWN diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj index 87885ad..acb653f 100644 --- a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj @@ -23,7 +23,7 @@ --> true Deploymentcenter.Client - 2.5.0 + 2.5.1 Deploymentcenter Client-SDK für Lizenzprüfung, Update-Verteilung und Erstinstallation über das Deploymentcenter. deploymentcenter;update;lizenz diff --git a/client-dotnet/Deploymentcenter.Client/DeploymentcenterSdk.cs b/client-dotnet/Deploymentcenter.Client/DeploymentcenterSdk.cs index 4af6dcc..a6ad667 100644 --- a/client-dotnet/Deploymentcenter.Client/DeploymentcenterSdk.cs +++ b/client-dotnet/Deploymentcenter.Client/DeploymentcenterSdk.cs @@ -28,7 +28,7 @@ namespace Deploymentcenter.Client /// Fassung dieses SDK. Bei jeder Auslieferung mitzufuehren - sie ist /// der Bezugspunkt fuer den Changelog. /// - public const string Version = "2.5.0"; + public const string Version = "2.5.1"; /// /// Adresse, unter der sich der Unterschied zur eigenen Fassung diff --git a/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs b/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs index b65a230..2b42bb4 100644 --- a/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs +++ b/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs @@ -31,8 +31,13 @@ public static class LicenseConfig productSlug = "default_app"; // 1. Explicitly set property + // + // Auch hier wird /license angehaengt - genau wie beim Weg ueber + // die Umgebungsvariable. Zuvor wurde der gesetzte Pfad roh benutzt: + // Zwei Produkte im selben Prozess mit gesetztem Override schrieben + // damit in dieselbe state.dat und ueberschrieben sich gegenseitig. if (!string.IsNullOrWhiteSpace(_storageDirectoryOverride)) - return ValidateNonEmpty(_storageDirectoryOverride!); + return ValidateNonEmpty(Path.Combine(_storageDirectoryOverride!, productSlug, "license")); // 2. Environment Variable var envDir = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_STORAGE_DIR") @@ -50,6 +55,21 @@ public static class LicenseConfig var home = Environment.GetEnvironmentVariable("HOME"); if (!string.IsNullOrWhiteSpace(home)) return ValidateNonEmpty(Path.Combine(home!, ".config", productSlug, "license")); + + // Dienst ohne Heimatverzeichnis. + // + // Eine systemd-Unit mit User= und ohne eigenes $HOME hat weder + // XDG_CONFIG_HOME noch HOME. Der Rueckfall landete dann im + // Installationsverzeichnis - unter /opt fuer den Dienstbenutzer + // typischerweise nicht beschreibbar. Ergebnis: kein Offline-Cache, + // ausgerechnet im kopflosen Betrieb, fuer den er gedacht ist. + // + // /var/lib ist der vorgesehene Ort fuer veraenderlichen Zustand + // eines Dienstes. Genommen wird er nur, wenn er auch beschreibbar + // ist - sonst waere es derselbe stille Fehlschlag an anderer Stelle. + var varLib = Path.Combine("/var", "lib", productSlug, "license"); + if (CanWriteTo(varLib)) + return ValidateNonEmpty(varLib); } else if (OperatingSystemHelpers.IsWindows()) { @@ -71,6 +91,29 @@ public static class LicenseConfig _storageDirectoryOverride = path; } + /// + /// Laesst sich dort wirklich schreiben? Ein Pfad, den man nur zurueckgibt, + /// weil er ueblich ist, hilft nicht weiter - der Fehlschlag faellt dann + /// erst beim Speichern auf. + /// + private static bool CanWriteTo(string directory) + { + try + { + Directory.CreateDirectory(directory); + + string probe = Path.Combine(directory, ".dc-write-test"); + File.WriteAllText(probe, string.Empty); + File.Delete(probe); + + return true; + } + catch + { + return false; + } + } + private static string ValidateNonEmpty(string path) { if (string.IsNullOrWhiteSpace(path)) diff --git a/config/config.example.php b/config/config.example.php index 4015c93..792a47f 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -62,8 +62,12 @@ return [ 'bugtracker' => [ // Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet. 'self_project' => 'deploymentcenter', - // Reports pro IP und Minute am öffentlichen Ingest-Endpunkt. + // Reports pro IP und Minute an /api/bugtracker/v1/report. 'report_rate' => 60, + // Reports pro IP und Minute an /api/errors/v1/report. Bewusst höher: + // Dort meldet ein globaler Exception-Handler, und ein Fehlerschauer + // erzeugt in Sekunden mehr Meldungen als ein Mensch am Tag. + 'error_rate' => 300, // Wie lange ein Agent ein Item exklusiv beansprucht (Minuten). 'lease_minutes' => 30, ], diff --git a/docs/AGENT_PROMPT_TEMPLATE.md b/docs/AGENT_PROMPT_TEMPLATE.md index 7d735a4..6a18468 100644 --- a/docs/AGENT_PROMPT_TEMPLATE.md +++ b/docs/AGENT_PROMPT_TEMPLATE.md @@ -136,7 +136,7 @@ Reagiere auf `code`, nicht auf den Text: Fassung in dieser Datei ein, direkt hier: ``` -Deploymentcenter-Anbindung: SDK 2.5.0, integriert am 2026-08-14 +Deploymentcenter-Anbindung: SDK 2.5.1, integriert am 2026-08-14 ``` Der Wert steht in `DeploymentcenterSdk.Version`. Ohne ihn muss beim nächsten @@ -145,7 +145,7 @@ Mal die gesamte Historie gelesen werden. **Bevor du die Anbindung anfasst,** frag zuerst, was sich seitdem geändert hat: ``` -GET /api/updateservice/v1/changelog?since=2.5.0 +GET /api/updateservice/v1/changelog?since=2.5.1 ``` Die Antwort nennt nur die neueren Fassungen, dazu `action_items` (Punkte mit @@ -155,7 +155,7 @@ zu tun. Kommt `"count": 0` zurück, bist du auf Stand und musst gar nichts lesen. -Zum Nachschlagen für Menschen: `https://dc.mhdf.de/docs/changelog.php?since=2.5.0` +Zum Nachschlagen für Menschen: `https://dc.mhdf.de/docs/changelog.php?since=2.5.1` **Nach getaner Arbeit die Zeile oben auf die neue Fassung setzen.** ``` @@ -171,8 +171,8 @@ description, error_message, stack_trace, severity, file_path, line_no, client_ref). Vor der Arbeit an einem Item: POST .../manage?action=next zum Übernehmen. Danach ?action=resolve mit resolved_in_build. -Deploymentcenter-Anbindung: SDK 2.5.0 -Bevor du sie anfasst: GET /api/updateservice/v1/changelog?since=2.5.0 — +Deploymentcenter-Anbindung: SDK 2.5.1 +Bevor du sie anfasst: GET /api/updateservice/v1/changelog?since=2.5.1 — zeigt nur, was seitdem dazukam. Danach die Zeile hochsetzen. Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json diff --git a/docs/LICENSE_INTEGRATION_GUIDE.md b/docs/LICENSE_INTEGRATION_GUIDE.md index 66df5d6..9e266ee 100644 --- a/docs/LICENSE_INTEGRATION_GUIDE.md +++ b/docs/LICENSE_INTEGRATION_GUIDE.md @@ -387,6 +387,47 @@ else } ``` +### Wenn Beenden die gefährlichere Option ist + +`Environment.Exit(1)` ist die richtige Antwort für ein Werkzeug, das man +einfach nicht mehr benutzen darf. Für ein System, das **offene Verpflichtungen +verwaltet**, ist es die falsche: Ein Handelssystem mit offenen Positionen, eine +Maschinensteuerung im Zyklus, ein Dienst mitten in einer Transaktion — die +dürfen bei einer abgelaufenen Lizenz nicht einfach aufhören. Der Schaden aus +dem abrupten Ende wäre größer als der aus dem Weiterlaufen. + +Die brauchbare Antwort ist **Sperrbetrieb statt Abbruch**: Was neue +Verpflichtungen eingeht, wird abgeschaltet; was bestehende abwickelt, läuft +weiter. + +```csharp +if (!res.IsValid && !res.IsTransient) +{ + logger.Error("Lizenz ungültig: {0}. Wechsle in den Sperrbetrieb.", res.Message); + + // Was neue Verpflichtungen eingeht: aus. + strategyEngine.StopOpeningPositions(); + scheduler.PauseNewJobs(); + + // Was bestehende abwickelt: bleibt an. + // - Risikoüberwachung + // - Schließen offener Positionen + // - Ordnungsgemäßes Herunterfahren, wenn nichts mehr offen ist + riskManager.KeepRunning(); + + notifier.Alert("Lizenz abgelaufen - Sperrbetrieb. Keine neuen Positionen."); +} +``` + +Wer diesen Weg geht, sollte zwei Dinge festhalten: **wann** aus dem +Sperrbetrieb ein Ende wird (etwa sobald keine Position mehr offen ist), und +**dass der Zustand sichtbar ist** — ein Sperrbetrieb, den niemand bemerkt, ist +ein stiller Ausfall. + +Der Offline-Cache federt das übrigens schon ab: Eine kurzzeitig nicht +erreichbare Prüfung führt gar nicht erst hierher, dafür ist `IsTransient` da. +Hier geht es um das echte Urteil. + | Status | `IsTransient` | Bedeutung | |---|---|---| | `valid` | – | Vom Server bestätigt | diff --git a/docs/README.md b/docs/README.md index a3228b5..44d218d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,23 @@ hinterlegte Textblock entfällt damit. --- +## Drosselung + +Je IP und Minute. Wird die Grenze überschritten, antwortet der Endpunkt mit +`429 rate_limited` — dann das Intervall verdoppeln und später erneut versuchen, +nicht sofort wiederholen. + +| Endpunkt | Grenze | Einstellbar über | +|---|---|---| +| `/api/errors/v1/report` | **300** | `bugtracker.error_rate` | +| `/api/bugtracker/v1/report` | 60 | `bugtracker.report_rate` | +| `/api/license/v1/*` | 120 | fest | +| `/api/updateservice/v1/*` | 240 | fest | +| `/api/setup/v1/login` | 10 | fest — dort werden Passwörter geprüft | +| `/api/tokens/v1/provision` | 20 | fest | + +--- + ## Antwortformat Alle JSON-Endpunkte antworten einheitlich: diff --git a/docs/SETUP_INTEGRATION_GUIDE.md b/docs/SETUP_INTEGRATION_GUIDE.md index de0578f..9ac018d 100644 --- a/docs/SETUP_INTEGRATION_GUIDE.md +++ b/docs/SETUP_INTEGRATION_GUIDE.md @@ -324,6 +324,64 @@ der Installer schreibt die abgefragten Werte hinein. Siehe --- +## 4A. Einen laufenden systemd-Dienst aktualisieren + +Der `update-agent` schreibt nach `--target-dir` — unter `/opt/` hat der +Dienstbenutzer dort keinen Schreibzugriff. Als `root` gestartet würde er zwar +schreiben können, dann aber über `--restart` **an systemd vorbei** einen +zweiten Prozess starten. + +Die Abfolge lautet deshalb: anhalten, aktualisieren, starten — und der Agent +startet **nichts** selbst. + +```ini +# /etc/systemd/system/myapp-update.service +[Unit] +Description=Update für myapp einspielen +After=network-online.target + +[Service] +Type=oneshot +# Der Agent braucht Schreibrecht in /opt/myapp. +User=root + +# Der Lizenzschlüssel gehört nicht in die Kommandozeile - "ps" zeigt sie +# jedem Benutzer der Maschine. EnvironmentFile mit 0600 und root:root. +EnvironmentFile=/etc/myapp/update.env + +ExecStartPre=/usr/bin/systemctl stop myapp.service +ExecStart=/opt/myapp/update-agent \ + --action update --project myapp --channel prod \ + --target-dir /opt/myapp --version latest +ExecStartPost=/usr/bin/systemctl start myapp.service +``` + +```bash +# /etc/myapp/update.env chmod 600, chown root:root +DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX +``` + +Auslösen von Hand oder über einen Timer: + +```bash +systemctl start myapp-update.service +``` + +**Kein `--restart` und kein `--wait-for-pid`.** Den Neustart übernimmt +`ExecStartPost`, das Anhalten `ExecStartPre` — der Dienst ist beim Anwenden +bereits beendet, es gibt keinen Prozess, auf den zu warten wäre. + +Wird der Update-Weg dagegen **aus der Anwendung heraus** angestoßen, gilt +`restartPath: ""` und `exitCurrentApp: false`: Die Anwendung fährt sich selbst +geordnet herunter, systemd startet sie über `Restart=on-success` neu. + +> **Dateirechte nach dem Update.** Der Agent läuft als `root` und legt die +> Dateien entsprechend an. Gehört der Dienst einem anderen Benutzer, gehört +> ein `ExecStartPost=/usr/bin/chown -R myapp:myapp /opt/myapp` davor — sonst +> startet der Dienst danach nicht mehr. + +--- + ## 5. Nachträglich einrichten ```bash diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md index 68ff89b..b0781a0 100644 --- a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -173,7 +173,7 @@ Version, UTC-Build-Datum und Git-Commit entstehen automatisch zur - + ``` @@ -685,6 +685,47 @@ Hash wird unverändert aus `dc_users` übernommen: PHPs `password_hash()` erzeug bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort wird nirgends gebraucht. +### Woher die Anwendung den Schlüssel nimmt + +Die häufigste Rückfrage bei der Integration: Der Schlüssel steht **im +Lizenz-Cache**, den das Lizenzmodul ohnehin führt. Es muss nichts zusätzlich +gespeichert werden. + +```csharp +// Beim Start einmal: Lizenz prüfen (legt den Schlüssel im Cache ab) +var license = await new LicenseClient().EnsureLicensedAsync( + productSlug: "myapp", + serverBaseUrl: "https://dc.mhdf.de", + allowPrompt: false); // im Dienst: nicht nach einer Eingabe warten + +if (!license.IsValid) { /* Sperrbetrieb, siehe Lizenz-Leitfaden §6 */ } + +// Danach jederzeit für den Update-Weg: +string? key = LicenseClient.TryGetCachedKey("myapp"); + +var check = await new UpdateClient().CheckForUpdateAsync( + baseUrl: "https://dc.mhdf.de", + projectId: "myapp", + currentVersion: BuildInfo.Version, + channel: "prod", + credentials: ReleaseCredentials.FromLicenseKey(key)); + +if (check.Unauthorized) +{ + // Kein Netzwerkfehler: Die Lizenz trägt nicht mehr. + log.Warn(check.Message); +} +``` + +`TryGetCachedKey` ist statisch und liest den verschlüsselten Cache aus +`LicenseConfig.GetStorageDirectory(slug)`. Liefert er `null`, wurde noch nie +erfolgreich validiert — dann gibt es auch keinen Update-Zugang. + +> **Im Dienst ohne Heimatverzeichnis** (systemd `User=` ohne `$HOME`) fällt der +> Cache auf `/var/lib//license` zurück. Ist auch das nicht beschreibbar, +> gibt es keinen Offline-Cache — dann muss `DEPLOYMENTCENTER_STORAGE_DIR` auf +> ein beschreibbares Verzeichnis zeigen. + ### Was sich für Clients ändert **Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.** diff --git a/public/docs/changelog.json b/public/docs/changelog.json index ff56d30..d6d07b8 100644 --- a/public/docs/changelog.json +++ b/public/docs/changelog.json @@ -2,9 +2,52 @@ "_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.", "schema": 1, - "current": "2.5.0", + "current": "2.5.1", "versions": [ + { + "version": "2.5.1", + "date": "2026-08-14", + "summary": "Zugangsschutz je Produkt abschaltbar, Dienst-Betrieb dokumentiert, inkrementelle Builds repariert.", + "actionRequired": true, + "changes": [ + { + "area": "server", + "breaking": false, + "title": "Produkte lassen sich vom Zugangsschutz ausnehmen", + "text": "Der Schutz entstand beim ersten Lauf nach dem Deployment von selbst - auch fuer Produkte, deren ausgelieferte Installationen noch ohne Lizenzschluessel bauen. Die bekamen ab dem Moment 401 statt Updates; bei Predictalytics ist genau das passiert. Unter UpdateService -> Zugangsschutz laesst sich je Produkt umschalten.", + "action": "Wer eine Installationsbasis ohne SDK 2.4 hat: Produkt ausnehmen, Build mit Schluessel ausliefern, danach wieder einschalten." + }, + { + "area": "sdk", + "breaking": false, + "title": "BuildInfo.targets verhindert keine inkrementellen Builds mehr", + "text": "BuildDateUtc trug die volle Uhrzeit und aenderte sich bei jedem Build; WriteOnlyWhenDifferent griff nie, und jedes einbindende Projekt wurde jedes Mal neu uebersetzt. Jetzt tagesgenau - hoechstens ein Neubau pro Tag.", + "action": "Keine. BuildInfo.BuildDateUtc enthaelt nur noch das Datum, nicht die Uhrzeit." + }, + { + "area": "sdk", + "breaking": true, + "title": "LicenseConfig.SetStorageDirectory haengt jetzt /license an", + "text": "Zuvor wurde der gesetzte Pfad roh benutzt, waehrend der Weg ueber DEPLOYMENTCENTER_STORAGE_DIR /license anhaengte. Zwei Produkte im selben Prozess mit gesetztem Override schrieben damit in dieselbe state.dat.", + "action": "Wer SetStorageDirectory benutzt: Der Cache liegt jetzt eine Ebene tiefer. Ein vorhandener Cache wird einmalig neu aufgebaut." + }, + { + "area": "sdk", + "breaking": false, + "title": "Lizenz-Cache faellt auf /var/lib/ zurueck", + "text": "Eine systemd-Unit mit User= und ohne eigenes Heimatverzeichnis hat weder XDG_CONFIG_HOME noch HOME. Der Rueckfall landete im Installationsverzeichnis, unter /opt fuer den Dienstbenutzer nicht beschreibbar - also kein Offline-Cache, ausgerechnet im kopflosen Betrieb.", + "action": "Wenn /var/lib nicht beschreibbar ist: DEPLOYMENTCENTER_STORAGE_DIR setzen." + }, + { + "area": "docs", + "breaking": false, + "title": "Lueckenschluss in den Leitfaeden", + "text": "Woher die Anwendung den Lizenzschluessel fuer den Update-Zugang nimmt (TryGetCachedKey), wie ein laufender systemd-Dienst aktualisiert wird, was statt Environment.Exit(1) zu tun ist, wenn Beenden die gefaehrlichere Option waere, und die Drosselungsgrenzen aller Endpunkte.", + "action": null + } + ] + }, { "version": "2.5.0", "date": "2026-08-14", diff --git a/public/index.php b/public/index.php index b815db5..0efcf7c 100644 --- a/public/index.php +++ b/public/index.php @@ -1265,6 +1265,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { dc_redirect('#sub-system-rocketchat'); } + case 'toggle_release_guard_exempt': { + $slug = trim((string)($_POST['slug'] ?? '')); + $makeExempt = ($_POST['exempt'] ?? '') === '1'; + + if ($slug === '') { + dc_flash('Kein Produkt angegeben.', 'danger'); + dc_redirect('#sub-update-guard'); + } + + ReleaseGuard::setExempt($pdo, $slug, $makeExempt); + + if ($makeExempt) { + dc_flash(sprintf( + 'Achtung: "%s" ist jetzt OHNE Zugangsschutz - jeder im Internet kann die Pakete ' + . 'herunterladen. Nur so lange lassen, bis die ausgelieferten Installationen ihren ' + . 'Lizenzschluessel mitschicken.', + e($slug) + ), 'danger'); + } else { + dc_flash(sprintf( + 'Zugangsschutz fuer "%s" ist wieder aktiv. Installationen ohne Lizenzschluessel ' + . 'bekommen ab jetzt 401.', + e($slug) + )); + } + + dc_redirect('#sub-update-guard'); + } + case 'regenerate_release_guard': { $guard = ReleaseGuard::regenerateAll($pdo); @@ -1721,7 +1750,7 @@ try { $usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); // Zustand des Zugangsschutzes der Release-Verzeichnisse. -$releaseGuard = ReleaseGuard::status(); +$releaseGuard = ReleaseGuard::status($pdo); // Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je // Produkt bei jedem Seitenaufbau waere Verschwendung. @@ -3182,17 +3211,21 @@ Antwort: Status Zugänge Zuletzt erzeugt + Aktion - Es liegen noch keine Release-Verzeichnisse vor. + Es liegen noch keine Release-Verzeichnisse vor. + - + + AUSGENOMMEN + OFFEN GESPERRT @@ -3207,6 +3240,19 @@ Antwort: + +
> + + + + + + +
+ @@ -3241,6 +3287,11 @@ Antwort:

+ AUSGENOMMEN heißt: bewusst offen gelassen — für Produkte, + deren ausgelieferte Installationen noch mit einem SDK vor 2.4 gebaut sind und + deshalb keinen Lizenzschlüssel mitschicken. Sie bekämen sonst 401 statt + Updates. Sobald ein Build mit Schlüssel draußen ist, wieder + einschalten.
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 diff --git a/src/Modules/UpdateService/ReleaseGuard.php b/src/Modules/UpdateService/ReleaseGuard.php index 2132b49..7575fe1 100644 --- a/src/Modules/UpdateService/ReleaseGuard.php +++ b/src/Modules/UpdateService/ReleaseGuard.php @@ -147,6 +147,15 @@ final class ReleaseGuard return ['entries' => 0, 'error' => sprintf('Kein Schreibrecht in %s', $dir)]; } + // Ausgenommene Produkte bleiben offen. Eine bestehende .htaccess muss + // dabei weg - sonst bliebe der Schutz stehen und die Ausnahme waere + // wirkungslos. + if (self::isExempt($db, $slug)) { + @unlink($dir . '/.htaccess'); + @unlink($dir . '/.htpasswd'); + return ['entries' => 0, 'error' => null]; + } + $entries = self::collectEntries($db, $slug); $lines = []; @@ -335,6 +344,69 @@ final class ReleaseGuard /** Schluessel in dc_settings fuer das Format der erzeugten Dateien. */ private const FORMAT_KEY = 'release_guard_format'; + /** Schluessel in dc_settings fuer die ausgenommenen Produkte. */ + private const EXEMPT_KEY = 'release_guard_exempt'; + + /** + * Produkte, die bewusst ohne Zugangsschutz ausgeliefert werden. + * + * Wozu das noetig ist: Der Schutz entsteht beim ersten Lauf nach dem + * Deployment von selbst - auch fuer Produkte, deren ausgelieferte + * Installationen noch mit einem SDK vor 2.4 gebaut sind. Die schicken + * keine Zugangsdaten und bekommen ab dem Moment 401 statt Updates. + * Genau das ist bei Predictalytics passiert. + * + * Ohne diese Liste bliebe nur, den Schutz global abzuschalten oder die + * Installationsbasis auszusperren. Mit ihr laesst sich je Produkt + * umschalten, sobald dessen Clients nachgezogen sind. + * + * @return list + */ + public static function exemptProjects(PDO $db): array + { + $raw = self::readSetting($db, self::EXEMPT_KEY); + + if ($raw === null || trim($raw) === '') { + return []; + } + + return array_values(array_filter(array_map( + static fn(string $s): string => strtolower(trim($s)), + explode(',', $raw) + ), static fn(string $s): bool => $s !== '')); + } + + public static function isExempt(PDO $db, string $slug): bool + { + return in_array(strtolower(trim($slug)), self::exemptProjects($db), true); + } + + /** + * Nimmt ein Produkt vom Schutz aus oder hebt die Ausnahme wieder auf. + * Die Dateien werden anschliessend neu erzeugt bzw. entfernt. + */ + public static function setExempt(PDO $db, string $slug, bool $exempt): void + { + $slug = strtolower(trim($slug)); + $list = self::exemptProjects($db); + + if ($exempt) { + if (!in_array($slug, $list, true)) { + $list[] = $slug; + } + } else { + $list = array_values(array_filter($list, static fn(string $s): bool => $s !== $slug)); + } + + self::writeSetting($db, self::EXEMPT_KEY, implode(',', $list)); + + Logger::warning($exempt + ? 'Produkt vom Release-Zugangsschutz ausgenommen' + : 'Ausnahme vom Release-Zugangsschutz aufgehoben', ['product' => $slug]); + + self::regenerateForProject($db, $slug); + } + /** * Format der erzeugten .htpasswd. Bei einer Aenderung muessen alle Dateien * neu geschrieben werden, sonst passen Server und Client nicht mehr @@ -408,6 +480,12 @@ final class ReleaseGuard } $seen[$product] = true; + // Ein bewusst ausgenommenes Produkt darf den Selbsttest nicht + // rot faerben - sonst gewoehnt man sich an einen roten Test. + if (self::isExempt($db, $product)) { + continue; + } + $url = (string)$row['download_url']; $result['checked']++; @@ -599,7 +677,7 @@ final class ReleaseGuard * * @return list> */ - public static function status(): array + public static function status(?PDO $db = null): array { $root = self::releasesRoot(); @@ -607,6 +685,7 @@ final class ReleaseGuard return []; } + $exempt = $db !== null ? self::exemptProjects($db) : []; $report = []; foreach (scandir($root) ?: [] as $entry) { @@ -628,6 +707,7 @@ final class ReleaseGuard $report[] = [ 'slug' => $entry, 'protected' => $protected, + 'exempt' => in_array(strtolower((string)$entry), $exempt, true), 'entries' => $count, 'updated_at' => $protected ? gmdate('Y-m-d H:i:s', (int)filemtime($htpasswd)) : null, 'writable' => is_writable($dir),