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. |
+
| = e((string)$g['slug']) ?> |
-
+
+ AUSGENOMMEN
+
OFFEN
GESPERRT
@@ -3207,6 +3240,19 @@ Antwort:
|
= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
|
+
+
+ |
@@ -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),