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); $result = Evaluator::run($db);
$wallMs = (int)round((microtime(true) - $startedAt) * 1000); $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.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@@ -20,6 +21,14 @@ namespace Deploymentcenter.Client
public ReleaseManifest? FullManifest { get; set; } public ReleaseManifest? FullManifest { get; set; }
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
public Exception? Error { get; set; } 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 public class IntegrityCheckResult
@@ -55,6 +64,7 @@ namespace Deploymentcenter.Client
string currentVersion, string currentVersion,
string channel = "prod", string channel = "prod",
string? platform = null, string? platform = null,
ReleaseCredentials? credentials = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var result = new UpdateCheckResult(); var result = new UpdateCheckResult();
@@ -80,13 +90,26 @@ namespace Deploymentcenter.Client
try try
{ {
response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false); response = await SendWithCredentialsAsync(staticUrl, credentials, cancellationToken)
.ConfigureAwait(false);
} }
catch catch
{ {
response = null; 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) if (response == null || !response.IsSuccessStatusCode)
{ {
response?.Dispose(); response?.Dispose();
@@ -179,6 +202,21 @@ namespace Deploymentcenter.Client
return result; 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> /// <summary>
/// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette /// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette
/// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach /// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach
@@ -300,7 +338,8 @@ namespace Deploymentcenter.Client
string? restartPath = null, string? restartPath = null,
string? currentVersion = null, string? currentVersion = null,
string? platform = null, string? platform = null,
bool waitForCurrentProcess = true) bool waitForCurrentProcess = true,
string? licenseKey = null)
{ {
if (!File.Exists(agentPath)) if (!File.Exists(agentPath))
{ {
@@ -322,6 +361,13 @@ namespace Deploymentcenter.Client
AppendArg(args, "--current-version", currentVersion!); 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) if (waitForCurrentProcess)
{ {
AppendArg(args, "--wait-for-pid", AppendArg(args, "--wait-for-pid",
@@ -58,6 +58,13 @@ namespace Deploymentcenter.UpdateAgent
try try
{ {
session = await client.LoginAsync(username, password, Environment.MachineName); 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; break;
} }
catch (SetupException ex) catch (SetupException ex)
@@ -230,6 +237,7 @@ namespace Deploymentcenter.UpdateAgent
try try
{ {
await client.LoginAsync(username, password, Environment.MachineName); await client.LoginAsync(username, password, Environment.MachineName);
options.Credentials = ReleaseCredentials.FromUser(username, password);
} }
catch (SetupException ex) catch (SetupException ex)
{ {
@@ -67,7 +67,16 @@ namespace Deploymentcenter.UpdateAgent
PublicKeyPath = GetArg(args, "--pubkey"), PublicKeyPath = GetArg(args, "--pubkey"),
AgentDirectory = agentDir, AgentDirectory = agentDir,
WaitForPid = ParsePid(GetArg(args, "--wait-for-pid")), 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); PackageManifest? installedManifest = ReadInstalledManifest(targetDir);
@@ -249,7 +258,14 @@ namespace Deploymentcenter.UpdateAgent
{ {
var updateClient = new UpdateClient(HttpClient); var updateClient = new UpdateClient(HttpClient);
var res = await updateClient.CheckForUpdateAsync( 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) if (res.UpdateAvailable && res.LatestRelease != null)
{ {
@@ -350,7 +366,18 @@ namespace Deploymentcenter.UpdateAgent
.StartAsync(async ctx => .StartAsync(async ctx =>
{ {
var downloadTask = ctx.AddTask($"[green]Lade Paket v{targetRelease.Version} ...[/]"); 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(); resp.EnsureSuccessStatusCode();
long? totalBytes = resp.Content.Headers.ContentLength; long? totalBytes = resp.Content.Headers.ContentLength;
@@ -562,7 +589,22 @@ namespace Deploymentcenter.UpdateAgent
{ {
try 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) if (!resp.IsSuccessStatusCode)
continue; 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-for-pid <pid> Vor dem Anwenden auf das Ende dieses Prozesses warten");
Console.WriteLine(" --wait-timeout <sek> Geduld dabei (Vorgabe: 60)"); Console.WriteLine(" --wait-timeout <sek> Geduld dabei (Vorgabe: 60)");
Console.WriteLine(" --restart <datei> Nach dem Update zu startende Anwendung"); 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(" --pubkey <datei> Oeffentlicher Schluessel zur Signaturpruefung");
Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren"); Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren");
Console.WriteLine(); Console.WriteLine();
@@ -1092,5 +1137,11 @@ namespace Deploymentcenter.UpdateAgent
public string AgentDirectory { get; set; } = string.Empty; public string AgentDirectory { get; set; } = string.Empty;
public int WaitForPid { get; set; } public int WaitForPid { get; set; }
public int WaitTimeoutSeconds { get; set; } = 60; 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/update-agent-linux-arm64` | Linux ARM64 |
| `/installer/installer.json` | Version, Größen und Prüfsummen aller Binaries | | `/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** Zu jedem Binary liegt eine `.sha256` daneben. **Die Prüfsumme vergleichen**
„Programm herunterladen und Zugangsdaten eingeben" ist genau das Muster, das „Programm herunterladen und Zugangsdaten eingeben" ist genau das Muster, das
Phishing nachahmt; die Prüfsumme ist der Grund, warum man es hier trotzdem tun 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 ### Neu bauen und hochladen
```bash ```bash
pwsh scripts/build_installer.ps1 powershell -File scripts/build_installer.ps1
python scripts/upload_installer.py ./artifacts/installer 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 ## 6. Signatur der Releases
Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer 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. > signierte Releases, Staging-Swap mit Rollback und geschützte Konfigurationsdateien.
> **Vor dem nächsten Release einzuspielen** — > **Vor dem nächsten Release einzuspielen** —
> **[§15 Umstellung auf 2.2](#15-umstellung-auf-22)**. > **[§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 - [ ] Ein Update auf einem Testsystem: `appsettings.json` behält danach die
eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr
gibt, ist verschwunden. 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\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit; use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen; use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner; use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\Evaluator; 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 * Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
* nicht anmelden. * 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 function dc_active_admin_count(PDO $pdo): int
{ {
try { try {
@@ -477,6 +506,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db->prepare('DELETE FROM dc_projects WHERE id = :id')->execute([':id' => $id]); $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_flash(sprintf('Projekt "%s" und alle zugehoerigen Daten wurden geloescht.', $project['name']));
dc_redirect('#tab-projects'); dc_redirect('#tab-projects');
} }
@@ -507,6 +539,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]); ]);
Audit::log($pdo, $actor, 'license.create', ['key' => $licenseKey]); 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_flash('Lizenzschluessel erzeugt: <strong style="font-family:monospace;">' . e($licenseKey) . '</strong>');
dc_redirect('#tab-license'); dc_redirect('#tab-license');
} }
@@ -538,6 +574,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]); ]);
Audit::log($pdo, $actor, 'license.update', ['license_id' => $id, 'status' => $status]); 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_flash($stmt->rowCount() > 0 ? 'Lizenzdaten aktualisiert.' : 'Keine Aenderung vorgenommen.');
dc_redirect('#tab-license'); dc_redirect('#tab-license');
} }
@@ -552,6 +592,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id') $pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id')
->execute([':status' => $newStatus, ':id' => $id]); ->execute([':status' => $newStatus, ':id' => $id]);
Audit::log($pdo, $actor, 'license.status', ['license_id' => $id, 'status' => $newStatus]); 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_flash('Lizenz-Status geaendert zu: ' . strtoupper($newStatus));
} }
dc_redirect('#tab-license'); dc_redirect('#tab-license');
@@ -1219,6 +1265,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#sub-system-rocketchat'); 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 // ------------------------------------------------- Benutzerkonten
case 'add_user': { case 'add_user': {
$newUser = trim((string)($_POST['username'] ?? '')); $newUser = trim((string)($_POST['username'] ?? ''));
@@ -1261,6 +1329,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor, '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( dc_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s', 'Konto "%s" mit der Rolle %s angelegt.%s',
e($newUser), e($newUser),
@@ -1285,6 +1360,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]); $upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]); 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.', dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger'); $upd->rowCount() > 0 ? 'success' : 'danger');
dc_redirect('#sub-system-users'); dc_redirect('#sub-system-users');
@@ -1320,6 +1399,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor, 'actor' => $actor,
]); ]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']), dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
$disable ? 'deaktiviert' : 'wieder aktiviert')); $disable ? 'deaktiviert' : 'wieder aktiviert'));
dc_redirect('#sub-system-users'); dc_redirect('#sub-system-users');
@@ -1355,6 +1436,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'actor' => $actor, 'actor' => $actor,
]); ]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username']))); dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
dc_redirect('#sub-system-users'); dc_redirect('#sub-system-users');
} }
@@ -1618,6 +1701,9 @@ try {
} }
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]); $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 // Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
// werden nicht von deploy.py uebertragen, sondern von // werden nicht von deploy.py uebertragen, sondern von
// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf // scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf
@@ -3047,6 +3133,84 @@ Antwort:
</div> </div>
</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 id="sub-update-installer" class="subtab-content">
<div class="card"> <div class="card">
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div> <div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
@@ -4748,6 +4912,7 @@ SYSTEM
'updateservice': [ 'updateservice': [
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true }, { id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
{ id: 'sub-update-installer', label: '⬇️ Installer' }, { id: 'sub-update-installer', label: '⬇️ Installer' },
{ id: 'sub-update-guard', label: '🔒 Zugangsschutz' },
{ id: 'sub-update-publish', label: ' Release Veröffentlichen' } { id: 'sub-update-publish', label: ' Release Veröffentlichen' }
], ],
'bugtracker': [ 'bugtracker': [
+4
View File
@@ -25,6 +25,10 @@ IGNORE_PATTERNS = {
'log', 'log',
'__pycache__', '__pycache__',
'client-dotnet', '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(): 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;
}
}