feat(releases): Zugangsschutz ueber Lizenzschluessel

/releases/ wurde bisher offen ausgeliefert, damit ausgelieferte Anwendungen
ohne Zugangsdaten nach Updates suchen koennen. Das bedeutete aber auch, dass
jeder im Internet die vollstaendigen Pakete herunterladen konnte - mitsamt
allem, was versehentlich darin liegt. Genau so lag ein echter API-Schluessel
in einer mitgelieferten appsettings.json oeffentlich abrufbar.

Zugang haengt jetzt am Lizenzschluessel: Wer eine gueltige Lizenz fuer ein
Produkt hat, kommt an dessen Updates. Die Anwendung kennt ihren Schluessel
ohnehin und versorgt sich damit selbst - es muss nichts verteilt werden.

Server
- ReleaseGuard erzeugt je Produktverzeichnis .htaccess und .htpasswd.
  Bewusst getrennt: eine gemeinsame Datei wuerde bedeuten, dass eine Lizenz
  fuer Produkt A auch Produkt B oeffnet. license_licenses.product_id bindet
  jeden Schluessel ohnehin an genau ein Projekt.
- Eingetragen werden aktive, nicht abgelaufene Lizenzen (Benutzername =
  Passwort = Schluessel; Basic Auth braucht zwei Felder, es gibt aber nur ein
  Geheimnis) sowie alle Installationskonten - bei einer Erstinstallation gibt
  es noch keinen Schluessel, mit dem sich das Paket holen liesse.
- Deren Hash wird unveraendert aus dc_users uebernommen: password_hash()
  erzeugt bcrypt im Format $2y$, genau das versteht Apache. Ein
  Klartextpasswort wird nirgends gebraucht. Argon2-Hashes werden erkannt und
  uebersprungen statt eine unbrauchbare Datei zu erzeugen.
- Lizenzschluessel werden mit Kosten 8 gehasht statt 12: 29 Zeichen
  maschineller Zufall sind kein Menschenpasswort, Apache prueft aber bei
  *jeder* Anfrage neu.
- Geschrieben wird ueber eine temporaere Datei mit rename() - ein Abbruch
  wuerde sonst eine halbe Zugangsdatei hinterlassen und in dem Moment die
  halbe Kundschaft aussperren.
- Neu erzeugt bei jeder Lizenz- und Kontoaenderung. Abgelaufene Lizenzen
  loesen anders als ein Widerruf nichts aus; dafuer gleicht cli/tick.php nach
  und erzeugt spaetestens alle sechs Stunden neu.
- Statusanzeige und Schaltflaeche im WebUI unter UpdateService.

Client
- ReleaseCredentials: Lizenzschluessel oder Installationskonto als Basic Auth.
- UpdateClient und Agent senden sie fuer latest.json und package.tar.gz.
- UpdateCheckResult.Unauthorized trennt "Lizenz traegt nicht mehr" von einem
  Netzwerkfehler. Ohne diese Unterscheidung sucht man an der falschen Stelle.
- LaunchUpdateAgent reicht licenseKey als --license-key durch.
- Der Installer benutzt die beim Anmelden eingegebenen Zugangsdaten auch fuer
  den Paketabruf; das Setup-Token taugt dafuer nicht, weil Apache prueft und
  nicht die Anwendung.

Sonstiges
- deploy.py klammert artifacts/ aus. Ohne das landeten die gebauten
  Installer-Binaries zusaetzlich unter /artifacts/ im Webroot.

ACHTUNG Reihenfolge: Der Schutz sperrt jede Anwendung aus, die noch mit dem
alten SDK gebaut ist. Erst ausliefern, dann scharfschalten - siehe
UPGRADE.md §16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 11:21:49 +02:00
co-authored by Claude Opus 5
parent 7f41372e5f
commit ceb977187e
11 changed files with 1035 additions and 7 deletions
@@ -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; }
}
}