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);
|
$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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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': [
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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