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
@@ -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",