Files
ClawdDotNet/src/ClawdDotNet.App/Services/LicenseGate.cs
T

238 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClawdDotNet.App.Settings;
using Deploymentcenter.Client;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
///
/// <para><b>Nur ein Urteil sperrt.</b> Das SDK trennt seit 2.1 zwei Dinge, die vorher
/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
/// (<c>revoked</c>, <c>expired</c>, <c>not_found</c>, <c>activation_limit</c>,
/// <c>suspended</c>, <c>clock_rollback</c>) und ein gescheiterter Versuch, überhaupt
/// eine zu bekommen (<see cref="LicenseValidationResult.IsTransient"/>). Nur das Urteil
/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
/// aussperren.</para>
///
/// <para>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 <c>cache_ttl_hours</c> des Projekts
/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.</para>
///
/// <para><b>Was diese Fassung nicht kann.</b> Deaktivieren (Aktivierungsplatz freigeben)
/// verlangt den <c>shared_key</c> 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 <see cref="LicenseInfo"/>.</para>
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
/// <summary>
/// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
/// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
/// </summary>
public string HardwareId =>
// Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
_hardwareId ??= global::Deploymentcenter.Client.HardwareId
.GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
/// <summary>
/// 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.
/// </summary>
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
/// <summary>
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> nach, bis eine nutzbare
/// Lizenz vorliegt, oder gibt <c>false</c> zurück — dann beendet der Aufrufer die
/// Anwendung.
/// </summary>
public async Task<bool> 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<LicenseValidationResult> 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
};
}