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:
co-authored by
Claude Opus 5
parent
7f41372e5f
commit
ceb977187e
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user