Files
Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs
T
Deploymentcenter BotandClaude Opus 5 1967b49ad7 fix(client): Schluessel raus aus argv, Wartezeit einstellbar, Packager sperrt
Fuenf von sechs Befunden einer externen Integration. Der sechste - unsignierte
Lizenzurteile - ist bestaetigt, aber bewusst nicht Teil dieses Commits.

1. Lizenzschluessel stand in der Kommandozeile
   Der Agent nahm --license-key nur als Argument und las keine
   Umgebungsvariablen. "ps" zeigt argv jedem Benutzer der Maschine - exakt die
   Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Damit nahm das
   SDK einen bereits geloesten Sicherheitsbefund zurueck.
   Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und
   DC_DOWNLOAD_PASSWORD, Umgebung vor Argument. LaunchUpdateAgent uebergibt
   den Schluessel nicht mehr als Argument, sondern setzt die Variable auf dem
   eigenen Prozess: das Kind erbt den Umgebungsblock, danach wird sie wieder
   entfernt. Das funktioniert auch mit UseShellExecute=true, wo sich
   ProcessStartInfo.Environment nicht setzen laesst.

2. --wait-timeout war nicht durchgereicht
   Der Agent kannte den Parameter, LaunchUpdateAgent hatte keinen dafuer - es
   galten fest 60 Sekunden. Eine Anwendung, die allein fuer host.StopAsync 30
   Sekunden braucht, kommt damit gefaehrlich nah an die Grenze. Neu:
   waitTimeoutSeconds. Ausserdem ist im Quelltext und in der Doku jetzt
   festgehalten, dass exitCurrentApp:true ueber Environment.Exit(0) laeuft und
   damit finally-Bloecke und IHostApplicationLifetime uebergeht - bei offenem
   Zustand die falsche Wahl.

3. ILicensePrompt war tot
   Der Konstruktor nahm es entgegen, legte es in _prompt ab und benutzte es
   nirgends. Wer darauf eine headless-Story aufbaute, baute auf Sand. Neu:
   EnsureLicensedAsync() - zwischengespeicherten Schluessel nehmen, sonst
   fragen, pruefen, bei Ablehnung erneut fragen. allowPrompt:false lehnt ohne
   Cache ab, statt auf eine Eingabe zu warten, die im Dienst nie kommt. Ein
   voruebergehender Netzfehler fuehrt nicht zur erneuten Abfrage - der
   Schluessel ist ja nicht falsch.

4. Der Packager warnte nur
   Er bricht jetzt ab. Anlass war ein echter API-Schluessel in einem
   oeffentlich abrufbaren Paket - und die Warnung war damals ausgerechnet
   unterdrueckt, weil die Datei auf der preserve-Liste stand.
   Zwei Stufen: Dateiname (appsettings.Local.json, master.key, *.pfx, *.db,
   server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_,
   dc_master_, AKIA, private Schluessel). Die Inhaltspruefung findet auch
   Dateien mit unverdaechtigem Namen.
   Platzhalter loesen bewusst nicht aus: "sk-DEIN-SCHLUESSEL-HIER" haette
   sonst jede ausgelieferte Vorlage blockiert, und --allow-secrets waere nach
   einer Woche Gewohnheit. Beim Erproben zuerst genau in diese Falle gelaufen.

5. BuildInfo.targets war nur per Pfad-Import zu haben
   Die Anleitung empfahl einen <Import> ins Nachbar-Repository - das setzt
   voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben
   Fassung stehen. Das Client-Projekt ist jetzt packbar und legt das Target
   unter build/ ins Paket, wo NuGet es selbst importiert.

Ausserdem: Unauthorized wurde nur im statischen Zweig erkannt, im API-Zweig
kam ein 401 als gewoehnlicher HTTP-Fehler an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:47:55 +02:00

615 lines
22 KiB
C#

using System;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Deploymentcenter.Client;
public class LicenseValidationResult
{
public bool IsValid { get; set; }
public string Status { get; set; } = "unknown";
public string Message { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty;
public bool IsCached { get; set; }
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches.</summary>
public long? ExpiresAt { get; set; }
/// <summary>
/// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client
/// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft.
/// </summary>
public long? CacheExpiresAt { get; set; }
/// <summary>
/// Wahr, wenn kein Lizenzurteil vorliegt, sondern nur die Verbindung zum
/// Server gescheitert ist (Netzfehler, HTTP 429/5xx, unlesbare Antwort).
///
/// Ein solcher Zustand darf eine Anwendung nicht beenden. Nur ein vom
/// Server geliefertes Urteil (revoked, expired, not_found,
/// activation_limit, suspended) ist endgueltig.
/// </summary>
public bool IsTransient { get; set; }
}
public class LicenseClient
{
/// <summary>
/// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf
/// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version.
/// </summary>
public static string? DefaultAppVersion { get; set; }
/// <summary>
/// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie
/// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der
/// Server nicht antwortete.
/// </summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
private static readonly Lazy<HttpClient> SharedHttpClient = new Lazy<HttpClient>(
() => new HttpClient { Timeout = DefaultTimeout },
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Standard-Gnadenfrist, wenn der Server keine TTL mitschickt.</summary>
private const int FallbackCacheTtlHours = 168;
private readonly HttpClient _httpClient;
private readonly ILicensePrompt _prompt;
/// <summary>
/// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit
/// <see cref="DefaultTimeout"/> verwendet. Ein uebergebener HttpClient
/// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer.
/// </summary>
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
{
_httpClient = httpClient ?? SharedHttpClient.Value;
_prompt = prompt ?? new ConsoleLicensePrompt();
}
public Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
CancellationToken cancellationToken)
=> ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken);
/// <param name="appVersion">
/// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des
/// Deploymentcenters. Ohne Angabe wird <see cref="DefaultAppVersion"/> und
/// danach die Version der Startassembly verwendet.
/// </param>
public async Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string? failureReason = null;
HttpResponseMessage? response = null;
try
{
var payload = new
{
product = productSlug,
license_key = licenseKey,
hardware_id = hwInfo.HardwareId,
legacy_hardware_id = hwInfo.LegacyHardwareId,
hwid_version = hwInfo.HwidVersion,
hwid_source = hwInfo.HwidSource,
platform = hwInfo.Platform,
hostname = Environment.MachineName,
app_version = ResolveAppVersion(appVersion),
nonce = Guid.NewGuid().ToString("N")
};
string jsonStr = JsonSerializer.Serialize(payload);
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
failureReason = ex.Message;
}
using (response)
{
if (response != null)
{
if (response.IsSuccessStatusCode)
{
string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now);
if (verdict != null)
{
return verdict;
}
// Erfolgreiche Antwort, aber kein lesbares Urteil - etwa die
// Fehlerseite eines Proxys mit Statuscode 200. Das ist keine
// Aussage ueber die Lizenz, also wird sie auch nicht als
// solche behandelt.
failureReason = "Antwort des Servers war nicht auswertbar";
}
else
{
// WICHTIG: Ein HTTP-Fehler ist kein Lizenzurteil.
//
// Frueher lag der Cache-Zweig ausschliesslich im catch.
// Ein 429 (Drosselung) oder 500 warf keine Exception,
// sondern fiel aus dem Erfolgszweig heraus und endete als
// "unknown_error" - ohne den Cache auch nur zu befragen.
// Ein Serverfehler entzog damit die Lizenz, ein gezogenes
// Netzkabel nicht. Jetzt fuehrt jeder Nicht-Erfolg in
// denselben Offline-Zweig.
failureReason = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
}
}
}
return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar");
}
/// <summary>
/// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares
/// Lizenzurteil enthaelt - dann greift der Offline-Zweig.
/// </summary>
private static LicenseValidationResult? TryReadVerdict(
string resBody,
string productSlug,
string licenseKey,
HardwareIdResult hwInfo,
long now)
{
string status;
string message;
long? expiresAt;
int cacheTtlHours;
try
{
using var doc = JsonDocument.Parse(resBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out var sProp))
{
return null;
}
status = sProp.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(status))
{
return null;
}
message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number
? eProp.GetInt64()
: (long?)null;
// Der Server bestimmt die Laenge der Offline-Gnadenfrist je Projekt.
// Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum
// der Lizenz eingetragen - bei einer Lizenz bis 2040 war die Frist
// praktisch unbegrenzt.
cacheTtlHours = root.TryGetProperty("cache_ttl_hours", out var tProp) && tProp.ValueKind == JsonValueKind.Number
? tProp.GetInt32()
: FallbackCacheTtlHours;
}
catch (JsonException)
{
return null;
}
if (cacheTtlHours <= 0)
{
cacheTtlHours = FallbackCacheTtlHours;
}
if (!status.Equals("valid", StringComparison.OrdinalIgnoreCase))
{
return new LicenseValidationResult
{
IsValid = false,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt,
IsTransient = false
};
}
// Die Gnadenfrist endet mit der TTL - spaetestens aber mit der Lizenz.
long cacheExpiresAt = now + (long)cacheTtlHours * 3600L;
if (expiresAt.HasValue && expiresAt.Value > 0 && expiresAt.Value < cacheExpiresAt)
{
cacheExpiresAt = expiresAt.Value;
}
var cache = new LocalCacheData
{
SchemaVersion = StateStore.CurrentSchemaVersion,
ProductSlug = productSlug,
LicenseKey = licenseKey,
HardwareId = hwInfo.HardwareId,
Status = "valid",
IssuedAt = now,
ExpiresAt = expiresAt ?? 0,
CacheExpiresAt = cacheExpiresAt,
CacheTtlHours = cacheTtlHours,
MaxSeenTime = now,
Checksum = hwInfo.HardwareId
};
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt,
CacheExpiresAt = cacheExpiresAt,
IsTransient = false
};
}
/// <summary>
/// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil
/// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort.
/// </summary>
private static LicenseValidationResult OfflineFallback(
string productSlug,
HardwareIdResult hwInfo,
long now,
string reason)
{
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
if (cache != null && cache.Status == "valid")
{
// Rueckdrehen der Systemuhr erkennen
if (now < cache.MaxSeenTime)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "clock_rollback",
Message = "System clock rollback detected! Online verification required.",
HardwareId = hwInfo.HardwareId,
IsTransient = false
};
}
long cacheExpiresAt = ResolveCacheExpiry(cache);
if (cacheExpiresAt > 0 && now > cacheExpiresAt)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "cache_expired",
Message = $"Offline-Gnadenfrist abgelaufen, Server nicht erreichbar ({reason}).",
HardwareId = hwInfo.HardwareId,
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
CacheExpiresAt = cacheExpiresAt,
IsTransient = true
};
}
// Auch offline darf eine abgelaufene Lizenz nicht weiterlaufen.
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "expired",
Message = "Cached license has expired.",
HardwareId = hwInfo.HardwareId,
ExpiresAt = cache.ExpiresAt,
IsTransient = false
};
}
cache.MaxSeenTime = now;
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = "valid_offline",
Message = $"License validated via secure offline cache ({reason}).",
HardwareId = hwInfo.HardwareId,
IsCached = true,
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
CacheExpiresAt = cacheExpiresAt,
IsTransient = false
};
}
return new LicenseValidationResult
{
IsValid = false,
Status = "server_unavailable",
Message = $"Server communication error and no valid cache available: {reason}",
HardwareId = hwInfo.HardwareId,
IsTransient = true
};
}
/// <summary>
/// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist
/// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt
/// unbegrenzt zu gelten.
/// </summary>
private static long ResolveCacheExpiry(LocalCacheData cache)
{
if (cache.CacheExpiresAt > 0)
{
return cache.CacheExpiresAt;
}
int ttlHours = cache.CacheTtlHours > 0 ? cache.CacheTtlHours : FallbackCacheTtlHours;
return cache.IssuedAt > 0 ? cache.IssuedAt + (long)ttlHours * 3600L : 0;
}
/// <summary>
/// Liest den zuletzt erfolgreich geprueften Schluessel aus dem lokalen
/// Cache. Damit laesst sich ein Schalter wie --license-status oder ein
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
/// </summary>
/// <summary>
/// Stellt sicher, dass eine gueltige Lizenz vorliegt - und fragt notfalls
/// nach dem Schluessel.
///
/// Bis Version 2.5 nahm der Konstruktor ein <see cref="ILicensePrompt"/>
/// entgegen, legte es ab und benutzte es nie. Das Versprechen der
/// Schnittstelle - Schluessel abfragen, Fehler und Erfolg melden - loeste
/// nichts ein; wer darauf eine headless-Story aufbaute, baute auf Sand.
/// Diese Methode ist die Naht, die dabei fehlte.
///
/// Ablauf: zwischengespeicherten Schluessel nehmen, sonst fragen; pruefen;
/// bei Ablehnung erneut fragen, bis <paramref name="maxAttempts"/>
/// erschoepft ist.
/// </summary>
/// <param name="allowPrompt">
/// Auf false setzen, wenn kein Mensch davorsitzt - etwa in einem Dienst.
/// Dann wird ausschliesslich der zwischengespeicherte Schluessel geprueft
/// und bei dessen Fehlen abgelehnt, statt auf eine Eingabe zu warten, die
/// nie kommt.
/// </param>
public async Task<LicenseValidationResult> EnsureLicensedAsync(
string productSlug,
string serverBaseUrl,
bool allowPrompt = true,
int maxAttempts = 3,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
string? key = TryGetCachedKey(productSlug);
LicenseValidationResult? last = null;
for (int attempt = 0; attempt < Math.Max(1, maxAttempts); attempt++)
{
if (string.IsNullOrWhiteSpace(key))
{
if (!allowPrompt)
{
_prompt.ShowLicenseError(
"Keine Lizenz hinterlegt",
$"Fuer \"{productSlug}\" liegt kein Schluessel vor, und es kann nicht danach gefragt werden.");
return last ?? new LicenseValidationResult
{
IsValid = false,
Status = "no_license",
Message = "Kein Lizenzschluessel hinterlegt."
};
}
key = await _prompt.RequestLicenseKeyAsync(productSlug).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(key))
{
// Abbruch durch den Benutzer - erneutes Fragen waere
// Bevormundung.
return new LicenseValidationResult
{
IsValid = false,
Status = "no_license",
Message = "Es wurde kein Lizenzschluessel eingegeben."
};
}
}
last = await ValidateAsync(productSlug, key!, serverBaseUrl, appVersion, cancellationToken)
.ConfigureAwait(false);
if (last.IsValid)
{
_prompt.ShowLicenseInfo("Lizenz gueltig", last.Message);
return last;
}
// Ein Netzwerk- oder Serverproblem ist kein Grund, den Schluessel
// erneut abzufragen - er ist ja nicht falsch.
if (last.IsTransient)
{
_prompt.ShowLicenseError("Lizenzpruefung nicht moeglich", last.Message);
return last;
}
_prompt.ShowLicenseError("Lizenz abgelehnt", last.Message);
key = null;
}
return last ?? new LicenseValidationResult
{
IsValid = false,
Status = "invalid",
Message = "Lizenzpruefung fehlgeschlagen."
};
}
public static string? TryGetCachedKey(string productSlug)
{
var cache = TryGetCachedState(productSlug);
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
}
/// <summary>
/// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist,
/// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser
/// Maschine gehoert.
/// </summary>
public static LocalCacheData? TryGetCachedState(string productSlug)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return StateStore.Load(productSlug, hwInfo.HardwareId);
}
/// <summary>
/// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel.
/// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde.
/// </summary>
public async Task<LicenseValidationResult> RevalidateAsync(
string productSlug,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
string? key = TryGetCachedKey(productSlug);
if (key == null)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return new LicenseValidationResult
{
IsValid = false,
Status = "not_found",
Message = "Kein zwischengespeicherter Lizenzschluessel vorhanden.",
HardwareId = hwInfo.HardwareId,
IsTransient = false
};
}
return await ValidateAsync(productSlug, key, serverBaseUrl, appVersion, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeactivateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string authToken = "",
CancellationToken cancellationToken = default)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
var payload = new
{
product = productSlug,
license_key = licenseKey,
hardware_id = hwInfo.HardwareId,
nonce = Guid.NewGuid().ToString("N")
};
string jsonStr = JsonSerializer.Serialize(payload);
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content
};
if (!string.IsNullOrWhiteSpace(authToken))
{
// Nur noch der Standardweg. Der zusaetzliche X-Watchdog-Key war ein
// Ueberbleibsel des alten Servers und irrefuehrend benannt: hier
// gehoert der shared_key hin, kein Watchdog-Token. Erschwerend
// prueft der Server X-Watchdog-Key VOR Authorization - ein dort
// versehentlich eingetragenes Watchdog-Token haette das richtige
// Bearer-Token stillschweigend verdraengt.
request.Headers.Add("Authorization", $"Bearer {authToken}");
}
try
{
using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return false;
}
}
private static string ResolveAppVersion(string? explicitVersion)
{
if (!string.IsNullOrWhiteSpace(explicitVersion))
{
return explicitVersion!.Trim();
}
if (!string.IsNullOrWhiteSpace(DefaultAppVersion))
{
return DefaultAppVersion!.Trim();
}
// Vorher stand hier fest "1.0.0". In der Aktivierungsliste des
// Deploymentcenters trug damit jede Installation dieselbe Version,
// obwohl die Spalte app_version dafuer vorgesehen ist.
try
{
var assembly = Assembly.GetEntryAssembly();
if (assembly != null)
{
string? informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
// Das SDK haengt bei manchen Buildeinstellungen "+<commit>" an.
int plus = informational!.IndexOf('+');
return plus > 0 ? informational.Substring(0, plus) : informational;
}
var version = assembly.GetName().Version;
if (version != null)
{
return version.ToString();
}
}
}
catch
{
// Version zu ermitteln darf eine Lizenzpruefung nie scheitern lassen.
}
return "unknown";
}
}