using ClawdDotNet.App.Settings; using Deploymentcenter.Client; using Microsoft.Extensions.Logging; namespace ClawdDotNet.App.Services; /// /// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters. /// /// Nur ein Urteil sperrt. Das SDK trennt seit 2.1 zwei Dinge, die vorher /// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz /// (revoked, expired, not_found, activation_limit, /// suspended, clock_rollback) und ein gescheiterter Versuch, überhaupt /// eine zu bekommen (). Nur das Urteil /// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig /// aussperren. /// /// Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung /// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und /// trägt damit über Ausfälle hinweg — begrenzt durch cache_ttl_hours des Projekts /// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz. /// /// Was diese Fassung nicht kann. Deaktivieren (Aktivierungsplatz freigeben) /// verlangt den shared_key des Servers. Der gehört nicht in eine ausgelieferte /// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben"). /// Eine Signaturprüfung findet nicht statt — siehe . /// public sealed class LicenseGate { private readonly SettingsManager _settings; private readonly ILogger _logger; private readonly ILicensePrompt _prompt; private readonly LicenseClient _client; private readonly string _serverUrl; private string? _hardwareId; public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt) { _settings = settings; _logger = logger; _prompt = prompt; _serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl) ? LicenseInfo.DefaultServerUrl : settings.AppSettings.DeploymentcenterUrl.Trim(); // Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das // trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist. LicenseClient.DefaultAppVersion = ReleaseInfo.Version; // Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze // von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der // Server nicht antwortete. _client = new LicenseClient(); } /// /// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung /// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas /// eintragen kann. /// public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl); /// /// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung /// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf. /// public string HardwareId => // Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst. _hardwareId ??= global::Deploymentcenter.Client.HardwareId .GetHardwareId(LicenseInfo.ProductSlug).HardwareId; /// /// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein /// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet. /// public Task RevalidateAsync(CancellationToken ct = default) => ValidateAsync(_settings.AppSettings.LicenseKey, ct); /// /// Startprüfung. Fragt über nach, bis eine nutzbare /// Lizenz vorliegt, oder gibt false zurück — dann beendet der Aufrufer die /// Anwendung. /// public async Task RunStartupCheckAsync(CancellationToken ct = default) { if (!IsEnforcementConfigured) { _logger.LogWarning( "Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) – übersprungen."); return true; } var key = _settings.AppSettings.LicenseKey; if (string.IsNullOrWhiteSpace(key)) { key = await _prompt.RequestKeyAsync(HardwareId, null, null); if (key is null) return false; } var result = await ValidateAsync(key, ct); while (!result.IsValid) { // Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer // Schlüssel würde daran nichts ändern, und danach zu fragen ließe den // Benutzer raten. Der Notausschalter greift, sobald der Server wieder // antwortet (LicenseWatch). if (result.IsTransient) { _logger.LogWarning( "Lizenz nicht prüfbar ({Status}): {Message} – Start wird fortgesetzt.", result.Status, result.Message); await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz", "Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder " + "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später " + "erneut."); return true; } if (result.Status == "clock_rollback") { // Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung. await _prompt.ShowErrorAsync("ClawdDotNet – Lizenz", "Die Lizenzprüfung konnte nicht abgeschlossen werden. " + "Bitte den Support kontaktieren."); return false; } _logger.LogWarning( "Lizenz abgelehnt: {Status} – {Message} (Produkt {Slug}, Server {Server})", result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl); var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key); if (retry is null) return false; key = retry; result = await ValidateAsync(key, ct); } _settings.AppSettings.LicenseKey = key; try { _settings.Save(); } catch (SettingsPersistenceException ex) { // Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja — // beim nächsten Start wird eben erneut gefragt. _logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden"); } if (result.IsCached) { var until = Describe(result.CacheExpiresAt); _logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until); await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz", $"Lizenzserver nicht erreichbar. Offline gültig bis {until}."); } return true; } private async Task ValidateAsync(string key, CancellationToken ct) { if (string.IsNullOrWhiteSpace(key)) { return new LicenseValidationResult { IsValid = false, Status = "not_found", Message = "Kein Lizenzschlüssel hinterlegt." }; } try { return await _client.ValidateAsync( LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct); } catch (OperationCanceledException) { throw; } catch (Exception ex) { // Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als // IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem // nicht als Urteil über die Lizenz gelten. _logger.LogError(ex, "Lizenzprüfung fehlgeschlagen."); return new LicenseValidationResult { IsValid = false, Status = "server_unavailable", Message = "Lizenz konnte nicht geprüft werden.", IsTransient = true }; } } private static string Describe(long? unixSeconds) => unixSeconds is { } seconds and > 0 ? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g") : "auf Weiteres"; private static string DescribeProblem(LicenseValidationResult result) => result.Status switch { "revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.", "suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.", "expired" => "Diese Lizenz ist abgelaufen.", "activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. " + "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste " + "über \"Freigeben\" räumen.", // Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes // Projekt und unbekannter Schlüssel. Welches davon, steht nur in message — // deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch // eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man // sucht am falschen Ende. "not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss " + $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt " + "sein und der Schlüssel dort hinterlegt." + (string.IsNullOrWhiteSpace(result.Message) ? "" : $" (Server: {result.Message})"), _ => string.IsNullOrWhiteSpace(result.Message) ? "Die Lizenz ist nicht gültig." : result.Message }; }