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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user