238 lines
9.9 KiB
C#
238 lines
9.9 KiB
C#
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
|
||
};
|
||
}
|