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; } /// Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches. public long? ExpiresAt { get; set; } /// /// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client /// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft. /// public long? CacheExpiresAt { get; set; } /// /// 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. /// public bool IsTransient { get; set; } } public class LicenseClient { /// /// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf /// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version. /// public static string? DefaultAppVersion { get; set; } /// /// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie /// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der /// Server nicht antwortete. /// public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15); private static readonly Lazy SharedHttpClient = new Lazy( () => new HttpClient { Timeout = DefaultTimeout }, LazyThreadSafetyMode.ExecutionAndPublication); /// Standard-Gnadenfrist, wenn der Server keine TTL mitschickt. private const int FallbackCacheTtlHours = 168; private readonly HttpClient _httpClient; private readonly ILicensePrompt _prompt; /// /// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit /// verwendet. Ein uebergebener HttpClient /// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer. /// public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null) { _httpClient = httpClient ?? SharedHttpClient.Value; _prompt = prompt ?? new ConsoleLicensePrompt(); } public Task ValidateAsync( string productSlug, string licenseKey, string serverBaseUrl, CancellationToken cancellationToken) => ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken); /// /// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des /// Deploymentcenters. Ohne Angabe wird und /// danach die Version der Startassembly verwendet. /// public async Task 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"); } /// /// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares /// Lizenzurteil enthaelt - dann greift der Offline-Zweig. /// 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 }; } /// /// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil /// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort. /// 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 }; } /// /// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist /// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt /// unbegrenzt zu gelten. /// 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; } /// /// 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. /// public static string? TryGetCachedKey(string productSlug) { var cache = TryGetCachedState(productSlug); return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey; } /// /// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist, /// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser /// Maschine gehoert. /// public static LocalCacheData? TryGetCachedState(string productSlug) { var hwInfo = HardwareId.GetHardwareId(productSlug); return StateStore.Load(productSlug, hwInfo.HardwareId); } /// /// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel. /// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde. /// public async Task 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 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()?.InformationalVersion; if (!string.IsNullOrWhiteSpace(informational)) { // Das SDK haengt bei manchen Buildeinstellungen "+" 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"; } }