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:
Deploymentcenter Bot
2026-08-13 11:21:49 +02:00
co-authored by Claude Opus 5
parent 7f41372e5f
commit ceb977187e
11 changed files with 1035 additions and 7 deletions
+22
View File
@@ -137,6 +137,28 @@ if ($options['report']) {
}
}
// Zugangsschutz der Release-Verzeichnisse abgleichen.
//
// Abgelaufene Lizenzen loesen anders als ein Widerruf nichts aus - der
// Zeitpunkt vergeht einfach. Ohne diesen Abgleich behielten sie ihren Zugang.
// reconcile() prueft selbst, ob es etwas zu tun gibt, und macht im Normalfall
// nichts; ein minuetlicher Aufruf ist deshalb unbedenklich.
$guardResult = \Deploymentcenter\Modules\UpdateService\ReleaseGuard::reconcile($db);
if ($guardResult['ran'] && !$options['quiet']) {
printf(
"Release-Zugangsschutz erneuert (%s): %d Produkt(e), %d Zugang/Zugaenge%s\n",
$guardResult['reason'],
$guardResult['projects'],
$guardResult['entries'],
$guardResult['errors'] !== [] ? ', ' . count($guardResult['errors']) . ' Fehler' : ''
);
}
foreach ($guardResult['errors'] as $guardError) {
Logger::warning('Release-Zugangsschutz', ['error' => $guardError]);
}
$result = Evaluator::run($db);
$wallMs = (int)round((microtime(true) - $startedAt) * 1000);
@@ -0,0 +1,93 @@
using System;
using System.Net.Http;
using System.Text;
namespace Deploymentcenter.Client
{
/// <summary>
/// Zugangsdaten fuer die Release-Ablage.
///
/// Die Verzeichnisse unter /releases/ liegen hinter HTTP-Basic-Auth. Wer
/// herunterladen darf, entscheidet der Lizenzschluessel: Eine Anwendung
/// mit gueltiger Lizenz versorgt sich damit selbst weiter, ohne dass
/// zusaetzliche Geheimnisse verteilt werden muessten. Zuvor war die Ablage
/// offen - und damit auch fuer jeden im Internet herunterladbar.
///
/// Bei einer Erstinstallation gibt es noch keinen Schluessel; dort treten
/// die Zugangsdaten des Installationskontos an seine Stelle.
/// </summary>
public sealed class ReleaseCredentials
{
private ReleaseCredentials(string user, string password)
{
User = user;
Password = password;
}
public string User { get; }
public string Password { get; }
/// <summary>
/// Zugang ueber den Lizenzschluessel.
///
/// Benutzername und Passwort sind beide der Schluessel: Basic Auth
/// verlangt zwei Felder, es gibt aber nur ein Geheimnis, und
/// Benutzernamen muessen innerhalb einer .htpasswd eindeutig sein.
/// </summary>
public static ReleaseCredentials? FromLicenseKey(string? licenseKey)
{
string key = (licenseKey ?? string.Empty).Trim();
return key.Length == 0 ? null : new ReleaseCredentials(key, key);
}
/// <summary>Zugang ueber ein Installationskonto.</summary>
public static ReleaseCredentials? FromUser(string? user, string? password)
{
string u = (user ?? string.Empty).Trim();
string p = password ?? string.Empty;
return u.Length == 0 ? null : new ReleaseCredentials(u, p);
}
/// <summary>Wert fuer den Authorization-Header.</summary>
public string ToHeaderValue()
{
string raw = User + ":" + Password;
return "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));
}
/// <summary>
/// Haengt den Header an eine Anfrage. Ohne Zugangsdaten passiert
/// nichts - eine noch ungeschuetzte Ablage bleibt damit erreichbar.
/// </summary>
public static void Apply(HttpRequestMessage request, ReleaseCredentials? credentials)
{
if (credentials == null || request == null)
return;
// Nicht doppelt setzen, falls der Aufrufer schon etwas mitgibt.
if (request.Headers.Contains("Authorization"))
return;
request.Headers.TryAddWithoutValidation("Authorization", credentials.ToHeaderValue());
}
/// <summary>
/// Erklaerung fuer den Menschen davor, wenn der Server 401 antwortet.
/// Ohne diesen Hinweis sieht ein abgelaufener Vertrag aus wie ein
/// Netzwerkfehler, und man sucht an der falschen Stelle.
/// </summary>
public static string DescribeUnauthorized(ReleaseCredentials? credentials)
{
if (credentials == null)
{
return "Die Release-Ablage verlangt Zugangsdaten, es wurden aber keine mitgegeben. "
+ "Erwartet wird der Lizenzschluessel dieser Installation.";
}
return "Die Release-Ablage hat die Zugangsdaten abgelehnt. Moegliche Gruende: die Lizenz "
+ "ist abgelaufen, wurde widerrufen oder gehoert zu einem anderen Produkt.";
}
}
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
@@ -20,6 +21,14 @@ namespace Deploymentcenter.Client
public ReleaseManifest? FullManifest { get; set; }
public string Message { get; set; } = string.Empty;
public Exception? Error { get; set; }
/// <summary>
/// Die Release-Ablage hat die Zugangsdaten abgelehnt - typischerweise
/// eine abgelaufene oder widerrufene Lizenz. Bewusst von einem
/// gewoehnlichen Fehler unterschieden: Sonst sieht ein abgelaufener
/// Vertrag aus wie eine Netzwerkstoerung.
/// </summary>
public bool Unauthorized { get; set; }
}
public class IntegrityCheckResult
@@ -55,6 +64,7 @@ namespace Deploymentcenter.Client
string currentVersion,
string channel = "prod",
string? platform = null,
ReleaseCredentials? credentials = null,
CancellationToken cancellationToken = default)
{
var result = new UpdateCheckResult();
@@ -80,13 +90,26 @@ namespace Deploymentcenter.Client
try
{
response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false);
response = await SendWithCredentialsAsync(staticUrl, credentials, cancellationToken)
.ConfigureAwait(false);
}
catch
{
response = null;
}
// Ein 401 auf die statische Ablage ist etwas anderes als ein
// fehlendes Release: Die Lizenz traegt nicht mehr. Das ueber
// den API-Rueckfall zu verschlucken hiesse, den Anwender im
// Unklaren zu lassen, warum keine Updates mehr kommen.
if (response != null && response.StatusCode == HttpStatusCode.Unauthorized)
{
result.Unauthorized = true;
result.Message = ReleaseCredentials.DescribeUnauthorized(credentials);
response.Dispose();
return result;
}
if (response == null || !response.IsSuccessStatusCode)
{
response?.Dispose();
@@ -179,6 +202,21 @@ namespace Deploymentcenter.Client
return result;
}
/// <summary>
/// Fuehrt eine GET-Anfrage aus und legt bei Bedarf die Zugangsdaten
/// der Release-Ablage an.
/// </summary>
private async Task<HttpResponseMessage> SendWithCredentialsAsync(
string url,
ReleaseCredentials? credentials,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
ReleaseCredentials.Apply(request, credentials);
return await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette
/// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach
@@ -300,7 +338,8 @@ namespace Deploymentcenter.Client
string? restartPath = null,
string? currentVersion = null,
string? platform = null,
bool waitForCurrentProcess = true)
bool waitForCurrentProcess = true,
string? licenseKey = null)
{
if (!File.Exists(agentPath))
{
@@ -322,6 +361,13 @@ namespace Deploymentcenter.Client
AppendArg(args, "--current-version", currentVersion!);
}
// Ohne den Schluessel kommt der Agent nicht mehr an die Pakete -
// die Release-Ablage verlangt seit Version 2.4 Zugangsdaten.
if (!string.IsNullOrWhiteSpace(licenseKey))
{
AppendArg(args, "--license-key", licenseKey!);
}
if (waitForCurrentProcess)
{
AppendArg(args, "--wait-for-pid",
@@ -58,6 +58,13 @@ namespace Deploymentcenter.UpdateAgent
try
{
session = await client.LoginAsync(username, password, Environment.MachineName);
// Dieselben Zugangsdaten oeffnen die Release-Ablage. Das
// Setup-Token taugt dafuer nicht: /releases/ liegt hinter
// HTTP-Basic-Auth und wird von Apache geprueft, nicht von
// der Anwendung. Bei einer Erstinstallation gibt es zudem
// noch keinen Lizenzschluessel.
options.Credentials = ReleaseCredentials.FromUser(username, password);
break;
}
catch (SetupException ex)
@@ -230,6 +237,7 @@ namespace Deploymentcenter.UpdateAgent
try
{
await client.LoginAsync(username, password, Environment.MachineName);
options.Credentials = ReleaseCredentials.FromUser(username, password);
}
catch (SetupException ex)
{
@@ -67,7 +67,16 @@ namespace Deploymentcenter.UpdateAgent
PublicKeyPath = GetArg(args, "--pubkey"),
AgentDirectory = agentDir,
WaitForPid = ParsePid(GetArg(args, "--wait-for-pid")),
WaitTimeoutSeconds = ParseInt(GetArg(args, "--wait-timeout"), 60)
WaitTimeoutSeconds = ParseInt(GetArg(args, "--wait-timeout"), 60),
// Die Release-Ablage verlangt Zugangsdaten. Im laufenden
// Betrieb ist das der Lizenzschluessel der Anwendung; bei
// einer Erstinstallation gibt es den noch nicht, dort treten
// die Zugangsdaten des Installationskontos an seine Stelle.
Credentials = ReleaseCredentials.FromLicenseKey(GetArg(args, "--license-key"))
?? ReleaseCredentials.FromUser(
GetArg(args, "--download-user"),
GetArg(args, "--download-password"))
};
PackageManifest? installedManifest = ReadInstalledManifest(targetDir);
@@ -249,7 +258,14 @@ namespace Deploymentcenter.UpdateAgent
{
var updateClient = new UpdateClient(HttpClient);
var res = await updateClient.CheckForUpdateAsync(
options.BaseUrl, options.Project, currentVer, options.Channel, options.Platform);
options.BaseUrl, options.Project, currentVer, options.Channel,
options.Platform, options.Credentials);
if (res.Unauthorized)
{
Console.WriteLine("UNAUTHORIZED: " + res.Message);
return 2;
}
if (res.UpdateAvailable && res.LatestRelease != null)
{
@@ -350,7 +366,18 @@ namespace Deploymentcenter.UpdateAgent
.StartAsync(async ctx =>
{
var downloadTask = ctx.AddTask($"[green]Lade Paket v{targetRelease.Version} ...[/]");
using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead);
using var pkgRequest = new HttpRequestMessage(HttpMethod.Get, pkgUrl);
ReleaseCredentials.Apply(pkgRequest, options.Credentials);
using var resp = await HttpClient.SendAsync(pkgRequest, HttpCompletionOption.ResponseHeadersRead);
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
throw new UnauthorizedAccessException(
ReleaseCredentials.DescribeUnauthorized(options.Credentials));
}
resp.EnsureSuccessStatusCode();
long? totalBytes = resp.Content.Headers.ContentLength;
@@ -562,7 +589,22 @@ namespace Deploymentcenter.UpdateAgent
{
try
{
var resp = await HttpClient.GetAsync(url);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
ReleaseCredentials.Apply(request, options.Credentials);
var resp = await HttpClient.SendAsync(request);
// Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt
// nicht mehr. Weiterzuprobieren wuerde die Ursache nur
// hinter einer allgemeinen Fehlermeldung verstecken.
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return null;
}
if (!resp.IsSuccessStatusCode)
continue;
@@ -1071,6 +1113,9 @@ namespace Deploymentcenter.UpdateAgent
Console.WriteLine(" --wait-for-pid <pid> Vor dem Anwenden auf das Ende dieses Prozesses warten");
Console.WriteLine(" --wait-timeout <sek> Geduld dabei (Vorgabe: 60)");
Console.WriteLine(" --restart <datei> Nach dem Update zu startende Anwendung");
Console.WriteLine(" --license-key <schluessel> Zugang zur Release-Ablage (Regelfall im Betrieb)");
Console.WriteLine(" --download-user <benutzer> Alternativ: Installationskonto");
Console.WriteLine(" --download-password <pw> dazugehoeriges Passwort");
Console.WriteLine(" --pubkey <datei> Oeffentlicher Schluessel zur Signaturpruefung");
Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren");
Console.WriteLine();
@@ -1092,5 +1137,11 @@ namespace Deploymentcenter.UpdateAgent
public string AgentDirectory { get; set; } = string.Empty;
public int WaitForPid { get; set; }
public int WaitTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Zugangsdaten fuer /releases/. Null bedeutet: ohne Anmeldung
/// versuchen - eine noch ungeschuetzte Ablage bleibt so erreichbar.
/// </summary>
public ReleaseCredentials? Credentials { get; set; }
}
}
+11 -1
View File
@@ -47,6 +47,16 @@ irm https://dc.mhdf.de/installer/install.ps1 | iex
| `/installer/update-agent-linux-arm64` | Linux ARM64 |
| `/installer/installer.json` | Version, Größen und Prüfsummen aller Binaries |
**Der Installer-Download selbst bleibt offen** — die Binaries enthalten keine
Zugangsdaten, und eine Anmeldung an dieser Stelle würde nur den Bootstrap
verkomplizieren (Zugangsdaten in `wget --user=…` landen in der Shell-History).
Beim Ausführen fragt der Installer als Erstes nach Benutzername und Passwort.
**Dieselben Zugangsdaten öffnen auch die Release-Ablage**, die seit Version 2.4
hinter HTTP-Basic-Auth liegt — bei einer Erstinstallation gibt es noch keinen
Lizenzschlüssel, mit dem sich das Paket holen ließe. Siehe
**[UPDATESERVICE_INTEGRATION_GUIDE §5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse)**.
Zu jedem Binary liegt eine `.sha256` daneben. **Die Prüfsumme vergleichen**
„Programm herunterladen und Zugangsdaten eingeben" ist genau das Muster, das
Phishing nachahmt; die Prüfsumme ist der Grund, warum man es hier trotzdem tun
@@ -65,7 +75,7 @@ NativeAOT und ohne Trimming — Spectre.Console löst seine Eingabeaufforderunge
### Neu bauen und hochladen
```bash
pwsh scripts/build_installer.ps1
powershell -File scripts/build_installer.ps1
python scripts/upload_installer.py ./artifacts/installer
```
+120
View File
@@ -573,6 +573,126 @@ darauf hin; wer weiter zurück muss, holt das Paket von Hand.
---
## 5A. Zugangsschutz der Release-Verzeichnisse
> **Neu in Version 2.4.** Zuvor war `/releases/` offen — damit jede
> ausgelieferte Anwendung ohne Zugangsdaten nach Updates suchen konnte. Das
> bedeutete aber auch: **jeder im Internet konnte die vollständigen Pakete
> herunterladen**, mitsamt allem, was versehentlich darin lag.
Der Zugang hängt jetzt am Lizenzschlüssel. Wer eine gültige Lizenz für ein
Produkt hat, kommt an dessen Updates; wer keine hat, braucht auch keine. Die
Anwendung kennt ihren Schlüssel ohnehin und versorgt sich damit selbst — es
muss nichts zusätzlich verteilt werden.
### Wie es aufgebaut ist
Je Produktverzeichnis eine eigene `.htpasswd`:
```text
/releases/predictalytics/.htaccess ← AuthType Basic, Require valid-user
/releases/predictalytics/.htpasswd ← nur Schlüssel für predictalytics
/releases/warenwirtschaft/.htpasswd ← nur Schlüssel für warenwirtschaft
```
**Bewusst getrennt.** Eine gemeinsame Datei würde bedeuten, dass eine Lizenz für
Produkt A auch Produkt B öffnet. Da `license_licenses.product_id` jeden
Schlüssel ohnehin an genau ein Projekt bindet, fällt die Trennung beim Erzeugen
von selbst ab.
Darin stehen:
| Eintrag | Benutzername | Passwort |
|---|---|---|
| Gültige Lizenz | der Lizenzschlüssel | derselbe Schlüssel |
| Installationskonto | DC-Benutzername | dessen Passwort |
Beim Lizenzschlüssel sind Benutzername und Passwort identisch: Basic Auth
verlangt zwei Felder, es gibt aber nur ein Geheimnis, und Benutzernamen müssen
eindeutig sein.
Die Installationskonten stehen in **jeder** Datei — bei einer Erstinstallation
gibt es noch keinen Lizenzschlüssel, mit dem sich das Paket holen ließe. Ihr
Hash wird unverändert aus `dc_users` übernommen: PHPs `password_hash()` erzeugt
bcrypt im Format `$2y$`, und genau das versteht Apache. Ein Klartextpasswort
wird nirgends gebraucht.
### Was sich für Clients ändert
**Ohne Nachziehen bekommt keine bestehende Installation mehr Updates.**
```csharp
var checkResult = await updateClient.CheckForUpdateAsync(
baseUrl: "https://dc.mhdf.de",
projectId: "myapp",
currentVersion: BuildInfo.Version,
channel: "prod",
credentials: ReleaseCredentials.FromLicenseKey(meineLizenz));
if (checkResult.Unauthorized)
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr.
Console.WriteLine(checkResult.Message);
}
UpdateClient.LaunchUpdateAgent(
agentPath: UpdateClient.ResolveAgentPath()!,
projectId: "myapp",
licenseKey: meineLizenz); // reicht --license-key an den Agenten durch
```
Auf der Kommandozeile:
```bash
update-agent --project myapp --action update --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
```
Ersatzweise `--download-user` und `--download-password` für ein
Installationskonto — das nutzt der Installer bei der Erstinstallation.
`UpdateCheckResult.Unauthorized` ist bewusst von einem gewöhnlichen Fehler
getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine
Netzwerkstörung, und man sucht an der falschen Stelle.
### Wann neu erzeugt wird
Automatisch beim Anlegen, Bearbeiten, Widerrufen und Sperren einer Lizenz, beim
Löschen eines Projekts und bei jeder Änderung an einem Installationskonto.
**Abgelaufene Lizenzen lösen von sich aus nichts aus** — der Zeitpunkt vergeht
einfach. Dafür gleicht `cli/tick.php` nach: Es prüft, ob seit dem letzten Lauf
etwas abgelaufen ist, und erzeugt spätestens alle sechs Stunden ohnehin neu.
Ein minütlicher Aufruf ist unbedenklich, im Normalfall passiert nichts.
Von Hand: WebUI → **UpdateService → 🔒 Zugangsschutz**. Dort steht auch, welche
Produkte geschützt sind und wie viele Zugänge jeweils eingetragen sind.
| Anzeige | Bedeutung |
|---|---|
| GESCHÜTZT | alles in Ordnung |
| OFFEN | keine `.htaccess` — jeder im Internet kann laden |
| GESPERRT | Datei vorhanden, aber leer: weder gültige Lizenzen noch Installationskonten |
### Grenzen
**Das macht Pakete nicht sicher.** Jeder lizenzierte Kunde kann sie weiterhin
auspacken. Ein eigener API-Schlüssel in einer mitgelieferten `appsettings.json`
wäre danach statt öffentlich eben für alle Kunden sichtbar — besser, aber immer
noch falsch. Was nicht ins Paket gehört, gehört auch mit Zugangsschutz nicht
hinein; siehe **[§3A](#3a-ausschließen-oder-schützen)**.
Die Lese-Endpunkte der API bleiben offen. Sie verraten weiterhin, welche
Produkte und Versionen es gibt — die Download-Adressen sind ohne Zugangsdaten
aber wertlos.
### Abschalten
Die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen löschen. Beim
nächsten Auslöser entstehen sie neu; wer es dauerhaft will, muss den Aufruf von
`ReleaseGuard` entfernen.
---
## 6. Signatur der Releases
Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer
+71
View File
@@ -12,6 +12,11 @@ Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen.
> signierte Releases, Staging-Swap mit Rollback und geschützte Konfigurationsdateien.
> **Vor dem nächsten Release einzuspielen** —
> **[§15 Umstellung auf 2.2](#15-umstellung-auf-22)**.
>
> **Version 2.4** stellt die Release-Ablage hinter einen Zugangsschutz.
> **Reihenfolge beachten:** erst das SDK ausliefern, dann scharfschalten —
> sonst bekommen bestehende Installationen keine Updates mehr.
> **[§16 Umstellung auf 2.4](#16-umstellung-auf-24)**.
---
@@ -496,3 +501,69 @@ entscheiden:
- [ ] Ein Update auf einem Testsystem: `appsettings.json` behält danach die
eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr
gibt, ist verschwunden.
---
## 16. Umstellung auf 2.4
Die Release-Ablage liegt jetzt hinter HTTP-Basic-Auth. Zugang hat, wer einen
gültigen Lizenzschlüssel für das Produkt besitzt — oder ein Installationskonto.
Ausführlich: **[UPDATESERVICE_INTEGRATION_GUIDE §5A](./UPDATESERVICE_INTEGRATION_GUIDE.md#5a-zugangsschutz-der-release-verzeichnisse)**
### 16.1 Reihenfolge — das ist der kritische Teil
> **Erst ausliefern, dann scharfschalten.** Der Zugangsschutz sperrt jede
> Anwendung aus, die noch mit dem alten SDK gebaut ist: Sie schickt keine
> Zugangsdaten und bekommt ab dem Moment nur noch 401. Andersherum sperrst du
> deine eigene Installationsbasis aus.
1. [ ] SDK auf 2.4 heben und `licenseKey` an `CheckForUpdateAsync` und
`LaunchUpdateAgent` übergeben.
2. [ ] Ein Release mit dem neuen SDK bauen und veröffentlichen.
3. [ ] Warten, bis die Installationen dieses Release gezogen haben.
4. [ ] **Erst dann** den Schutz erzeugen — WebUI → *UpdateService →
🔒 Zugangsschutz → Zugangsschutz jetzt neu erzeugen*.
Läuft `cli/tick.php` als Cron, erzeugt es den Schutz beim ersten Lauf nach dem
Deployment **von selbst**. Wer die Reihenfolge einhalten will, spielt den
Serverteil also erst dann ein, wenn Schritt 3 erledigt ist.
### 16.2 Was wo eingetragen wird
Je Produktverzeichnis eine `.htpasswd` mit den aktiven, nicht abgelaufenen
Lizenzen dieses Produkts (Benutzername = Passwort = Schlüssel) und allen
Installationskonten. Bestehende Lizenzen werden dabei automatisch übernommen —
es ist nichts von Hand nachzutragen.
### 16.3 Neue Aufrufe
```bash
update-agent --project myapp --action update \
--license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
```
```bash
# Erstinstallation: der Installer fragt die Zugangsdaten ab und benutzt sie
# auch für den Paketabruf. Der Installer-Download selbst bleibt offen.
wget -qO- https://dc.mhdf.de/installer/install.sh | sh
./update-agent --action install
```
### 16.4 Prüfen
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/prod/<rid>/<version>/package.tar.gz`
**401**
- [ ] Mit `-u "<lizenzschlüssel>:<lizenzschlüssel>"`**200**
- [ ] `curl -I https://dc.mhdf.de/releases/<produkt>/.htpasswd`**403**
- [ ] `https://dc.mhdf.de/installer/update-agent-linux-x64`**200**, weiterhin offen
- [ ] Eine Lizenz widerrufen und erneut mit ihr laden → **401**
### 16.5 Wenn etwas klemmt
Ein 401 im Client heißt **Lizenz**, nicht Netzwerk. `UpdateCheckResult.Unauthorized`
unterscheidet beides; der Agent gibt `UNAUTHORIZED: …` aus und liefert
Rückgabewert 2.
Zum Abschalten die erzeugten `.htaccess`-Dateien in den Produktverzeichnissen
löschen. Sie entstehen beim nächsten Auslöser neu.
+165
View File
@@ -32,6 +32,7 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\Evaluator;
@@ -233,6 +234,34 @@ function dc_flash(string $message, string $type = 'success'): void
* Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
* nicht anmelden.
*/
/**
* Erzeugt den Zugangsschutz der Release-Verzeichnisse neu.
*
* Wird nach jeder Aenderung aufgerufen, die beeinflusst, wer herunterladen
* darf: Lizenzen und Installationskonten. Bewusst immer fuer alle Produkte -
* bei einer Handvoll Projekten kostet das kaum etwas und erspart die
* Fehlerquelle, den richtigen Slug zu ermitteln.
*
* Ein Fehlschlag darf die ausloesende Aktion nicht scheitern lassen: Eine
* angelegte Lizenz ist angelegt, auch wenn die Datei gerade nicht schreibbar
* war. Der naechtliche Abgleich holt es nach, und die Oberflaeche zeigt den
* Zustand unter UpdateService an.
*/
function dc_guard_releases(PDO $pdo): void
{
try {
$result = ReleaseGuard::regenerateAll($pdo);
if ($result['errors'] !== []) {
Logger::warning('Release-Zugangsschutz teilweise fehlgeschlagen', [
'errors' => $result['errors'],
]);
}
} catch (Throwable $e) {
Logger::warning('Release-Zugangsschutz nicht erzeugt', ['error' => $e->getMessage()]);
}
}
function dc_active_admin_count(PDO $pdo): int
{
try {
@@ -477,6 +506,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db->prepare('DELETE FROM dc_projects WHERE id = :id')->execute([':id' => $id]);
});
// Mit dem Projekt sind auch seine Lizenzen weg.
dc_guard_releases($pdo);
dc_flash(sprintf('Projekt "%s" und alle zugehoerigen Daten wurden geloescht.', $project['name']));
dc_redirect('#tab-projects');
}
@@ -507,6 +539,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
Audit::log($pdo, $actor, 'license.create', ['key' => $licenseKey]);
// Der neue Schluessel muss sofort Updates ziehen koennen.
dc_guard_releases($pdo);
dc_flash('Lizenzschluessel erzeugt: <strong style="font-family:monospace;">' . e($licenseKey) . '</strong>');
dc_redirect('#tab-license');
}
@@ -538,6 +574,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]);
Audit::log($pdo, $actor, 'license.update', ['license_id' => $id, 'status' => $status]);
// Status oder Ablaufdatum koennen sich geaendert haben.
dc_guard_releases($pdo);
dc_flash($stmt->rowCount() > 0 ? 'Lizenzdaten aktualisiert.' : 'Keine Aenderung vorgenommen.');
dc_redirect('#tab-license');
}
@@ -552,6 +592,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id')
->execute([':status' => $newStatus, ':id' => $id]);
Audit::log($pdo, $actor, 'license.status', ['license_id' => $id, 'status' => $newStatus]);
// Eine widerrufene Lizenz muss den Zugang sofort verlieren -
// sonst zieht sie weiter Updates, bis der naechtliche
// Abgleich laeuft.
dc_guard_releases($pdo);
dc_flash('Lizenz-Status geaendert zu: ' . strtoupper($newStatus));
}
dc_redirect('#tab-license');
@@ -1219,6 +1265,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#sub-system-rocketchat');
}
case 'regenerate_release_guard': {
$guard = ReleaseGuard::regenerateAll($pdo);
if ($guard['errors'] !== []) {
dc_flash(
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
'danger'
);
} else {
dc_flash(sprintf(
'Zugangsschutz erneuert: %d Produkt(e), %d Zugang/Zugaenge.%s',
$guard['projects'],
$guard['entries'],
$guard['skipped'] !== []
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
: ''
));
}
dc_redirect('#sub-update-guard');
}
// ------------------------------------------------- Benutzerkonten
case 'add_user': {
$newUser = trim((string)($_POST['username'] ?? ''));
@@ -1261,6 +1329,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor,
]);
// Installationskonten stehen in jeder .htpasswd - ohne sie
// waere eine Erstinstallation nicht moeglich, weil es dabei
// noch keinen Lizenzschluessel gibt.
if ($newRole === 'installer') {
dc_guard_releases($pdo);
}
dc_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s',
e($newUser),
@@ -1285,6 +1360,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
// Der Hash steht auch in den .htpasswd-Dateien.
dc_guard_releases($pdo);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger');
dc_redirect('#sub-system-users');
@@ -1320,6 +1399,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor,
]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
$disable ? 'deaktiviert' : 'wieder aktiviert'));
dc_redirect('#sub-system-users');
@@ -1355,6 +1436,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor,
]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
dc_redirect('#sub-system-users');
}
@@ -1618,6 +1701,9 @@ try {
}
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
$releaseGuard = ReleaseGuard::status();
// 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
@@ -3047,6 +3133,84 @@ Antwort:
</div>
</div>
<div id="sub-update-guard" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔒 Zugangsschutz der Releases</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Die Release-Verzeichnisse liegen hinter HTTP-Basic-Auth. Zugang hat, wer
einen <strong>gültigen Lizenzschlüssel</strong> 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
<strong>Installationskonten</strong> in jeder Datei: bei einer
Erstinstallation gibt es noch keinen Schlüssel.
</p>
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-bottom:1.25rem;">
<strong>Je Produkt eine eigene Datei.</strong> Eine gemeinsame Liste würde
bedeuten, dass eine Lizenz für Produkt A auch Produkt B öffnet. Die Zuordnung
ergibt sich aus <code>license_licenses.product_id</code>.
</div>
<table>
<thead>
<tr>
<th>Produkt</th>
<th>Status</th>
<th>Zugänge</th>
<th>Zuletzt erzeugt</th>
</tr>
</thead>
<tbody>
<?php if ($releaseGuard === []): ?>
<tr><td colspan="4" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
<?php endif; ?>
<?php foreach ($releaseGuard as $g): ?>
<tr>
<td><strong><?= e((string)$g['slug']) ?></strong></td>
<td>
<?php if (!$g['protected']): ?>
<span class="badge badge-down">OFFEN</span>
<?php elseif ((int)$g['entries'] === 0): ?>
<span class="badge badge-warning">GESPERRT</span>
<?php else: ?>
<span class="badge badge-up">GESCHÜTZT</span>
<?php endif; ?>
<?php if (!$g['writable']): ?>
<span class="badge badge-down" title="PHP kann hier nicht schreiben">NICHT SCHREIBBAR</span>
<?php endif; ?>
</td>
<td><?= (int)$g['entries'] ?></td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
<strong>GESPERRT</strong> 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.
</p>
<form method="POST" action="index.php" style="margin-top:1.25rem;">
<?= $csrfField ?>
<input type="hidden" name="action" value="regenerate_release_guard">
<input type="hidden" name="_return" value="sub-update-guard">
<button type="submit" class="btn">Zugangsschutz jetzt neu erzeugen</button>
</form>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Geschieht automatisch bei jeder Lizenz- und Kontoänderung. Abgelaufene
Lizenzen lösen von sich aus nichts aus dafür gleicht
<code>cli/tick.php</code> nach, spätestens alle sechs Stunden.
</p>
</div>
</div>
<div id="sub-update-installer" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
@@ -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': [
+4
View File
@@ -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():
+438
View File
@@ -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;
}
}