Instanzmanager: Lizenz und Sub-Token wandern in den Manager (#528, E-M3)
Neues Projekt ClawdDotNet.Licensing. LicenseGate, LicenseWatch, LicenseInfo und ILicensePrompt sind aus ClawdDotNet.App dorthin gezogen, weil zwei Prozesse dieselbe Lizenzpruefung brauchen und keiner von beiden den anderen kennen darf. In Core konnte es nicht landen - dann zoege jedes der sechzehn Werkzeugprojekte das Deploymentcenter-SDK mit. Dabei ist die Kopplung an SettingsManager verschwunden. LicenseGate kennt jetzt nur ILicenseStore: Serveradresse lesen, Schluessel lesen und schreiben, speichern. Die Anwendung fuehrt ihn in Settings.json, der Manager in manager.json. Der Torwaechter prueft Schluessel; Dateien verwaltet er nicht. Auch die Anwendungsversion kommt als Parameter statt aus einem globalen Zugriff - diese Schicht weiss nicht, welche Anwendung sie benutzt. ManagerCore ist die Klammer: eigene Einstellungen, Lizenz, Register, Meldeweg. Drei Regeln: - Ohne gueltige Lizenz startet er keine Instanz. Laufende werden trotzdem in die Liste genommen - man soll sie sehen und anhalten koennen. - Ein Widerruf haelt alle an. Der einzige Fall, in dem der Manager das von sich aus tut. Ein Serverausfall gehoert ausdruecklich nicht dazu; das SDK trennt Urteil und gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst naehme ein Netzproblem saemtliche Installationen mit. - Ohne hinterlegte Lizenzpruefung sperrt er nichts, sonst gaebe es eine Henne-Ei-Sperre, bevor jemand einen Schluessel eintragen kann. Sub-Token je Instanz: Der Manager haelt das Master-Token und tauscht es beim ANLEGEN einer Instanz gegen ein eigenes (watchdog:ping, bugtracker:report, einzeln widerrufbar). Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann zweifelsfrei niemandem gehoert - sie spaeter zu schreiben, waehrend der Instanzprozess laeuft, hiesse zwei Schreiber auf einer Datei. Fuer Instanzen, die es vor dem Manager schon gab, wird es vor dem Start nachgeholt. Danach fragt er ueber whoami nach, ob das Token an ein Projekt gebunden ist (SDK 2.8.1). Ein fehlgeschlagener Tokenbezug haelt die Instanz nicht auf. NOCH NICHT VOLLSTAENDIG: Die Instanzen pruefen die Lizenz weiterhin selbst. Das laesst sich erst abschalten, wenn eine Instanz feststellen kann, ob ein Manager da ist - vorher entstuende eine Fassung ganz ohne Durchsetzung. Und ein Testfehler, der zweimal derselbe war: Die Socket-Antwort war hinterlegt, BEVOR der Manager hochfuhr. Damit sagt man "diese Instanz laeuft bereits", der Manager uebernimmt sie, der Starter wird nie angefasst - und der Test prueft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die das ausschliesst. Build ohne Warnungen, 774 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ClawdDotNet.Licensing;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wie der <see cref="LicenseGate"/> mit dem Benutzer spricht.
|
||||
///
|
||||
/// Vorher rief er unmittelbar <c>MessageBox.Show</c> und einen WinForms-Dialog.
|
||||
/// Das band die Lizenzprüfung an WinForms — und wäre im kopflosen Betrieb fatal
|
||||
/// gewesen: Ein Dienst, der beim Start ein Fenster öffnet und auf eine Eingabe wartet,
|
||||
/// hängt für immer, ohne dass jemand die Meldung je zu sehen bekäme.
|
||||
///
|
||||
/// Es gibt drei Umsetzungen:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AvaloniaLicensePrompt</c> — Dialoge im Fenster (Desktop).</item>
|
||||
/// <item><see cref="ConsoleLicensePrompt"/> — Eingabe über die Konsole (Kommandozeile).</item>
|
||||
/// <item><see cref="NonInteractiveLicensePrompt"/> — antwortet nie (systemd-Dienst).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface ILicensePrompt
|
||||
{
|
||||
/// <summary>
|
||||
/// Fragt einen Lizenzschlüssel ab. <c>null</c> heißt Abbruch — der Aufrufer beendet
|
||||
/// die Anwendung.
|
||||
/// </summary>
|
||||
/// <param name="hardwareId">Wird angezeigt, damit der Nutzer ihn an den Support geben kann.</param>
|
||||
/// <param name="problem">Warum der bisherige Schlüssel nicht taugt; <c>null</c> beim ersten Fragen.</param>
|
||||
/// <param name="currentKey">Vorbelegung des Eingabefelds.</param>
|
||||
Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey);
|
||||
|
||||
/// <summary>Eine Meldung, die keine Antwort braucht (etwa „offline gültig bis …").</summary>
|
||||
Task ShowInfoAsync(string title, string message);
|
||||
|
||||
/// <summary>Ein Fehler, nach dem die Anwendung nicht weiterläuft.</summary>
|
||||
Task ShowErrorAsync(string title, string message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab.
|
||||
///
|
||||
/// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren
|
||||
/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile
|
||||
/// hinterlegt (<c>--license-set-key</c>).
|
||||
/// </summary>
|
||||
public sealed class NonInteractiveLicensePrompt(Action<string> log) : ILicensePrompt
|
||||
{
|
||||
public Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
|
||||
{
|
||||
log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. "
|
||||
+ (problem is null ? "" : $"Grund: {problem}. ")
|
||||
+ "Schlüssel mit '--license-set-key <SCHLÜSSEL>' hinterlegen.");
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
public Task ShowInfoAsync(string title, string message)
|
||||
{
|
||||
log($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ShowErrorAsync(string title, string message)
|
||||
{
|
||||
log($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Eingabe über die Konsole — für Kommandozeilenaufrufe.</summary>
|
||||
public sealed class ConsoleLicensePrompt : ILicensePrompt
|
||||
{
|
||||
public Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
|
||||
{
|
||||
if (problem is not null)
|
||||
Console.Error.WriteLine($"Lizenz: {problem}");
|
||||
|
||||
Console.WriteLine($"Hardware-ID: {hardwareId}");
|
||||
Console.Write("Lizenzschlüssel: ");
|
||||
|
||||
var input = Console.ReadLine()?.Trim();
|
||||
return Task.FromResult(string.IsNullOrWhiteSpace(input) ? null : input);
|
||||
}
|
||||
|
||||
public Task ShowInfoAsync(string title, string message)
|
||||
{
|
||||
Console.WriteLine($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ShowErrorAsync(string title, string message)
|
||||
{
|
||||
Console.Error.WriteLine($"{title}: {message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fest hinterlegte Lizenz-Eckdaten.
|
||||
///
|
||||
/// <para><b>Kein Public-Key mehr.</b> Die frühere Fassung führte einen
|
||||
/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine
|
||||
/// Gegenseite: Der Client liest ausschließlich das Feld <c>status</c> aus der Antwort,
|
||||
/// eine Signaturprüfung findet nicht statt (siehe
|
||||
/// <c>docs/Deploymentcenter-Anbindung-Review.md</c>, Abschnitt 2.1). Ein Schlüssel, der
|
||||
/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist.
|
||||
/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück.</para>
|
||||
///
|
||||
/// <para>Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann
|
||||
/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz.</para>
|
||||
/// </summary>
|
||||
public static class LicenseInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Projekt-Slug, wie er in <c>dc_projects</c> angelegt ist. Gilt für alle Module:
|
||||
/// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe
|
||||
/// Projekttabelle zu.
|
||||
///
|
||||
/// <para>Hier stand bis zur Umstellung <c>clawd</c> — der Name aus dem
|
||||
/// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt
|
||||
/// <c>clawddotnet</c>. Der Server beantwortet einen unbekannten Slug mit demselben
|
||||
/// <c>not_found</c> wie einen unbekannten Schlüssel, weshalb das wie ein falsch
|
||||
/// eingegebener Lizenzschlüssel aussah.</para>
|
||||
/// </summary>
|
||||
public const string ProductSlug = "clawddotnet";
|
||||
|
||||
/// <summary>
|
||||
/// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine
|
||||
/// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung
|
||||
/// und teilen sich diese Adresse.
|
||||
/// </summary>
|
||||
public const string DefaultServerUrl = "https://dc.mhdf.de";
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die Lizenz im laufenden Betrieb nach.
|
||||
///
|
||||
/// <para>Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die
|
||||
/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle
|
||||
/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro
|
||||
/// Klick.</para>
|
||||
///
|
||||
/// <para><b>Nur ein Urteil zählt.</b> Ein Netzproblem, eine Drosselung oder eine
|
||||
/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als
|
||||
/// <c>IsTransient</c>, und ein Serverausfall darf nicht alle laufenden Instanzen
|
||||
/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseWatch : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12);
|
||||
|
||||
private readonly LicenseGate _gate;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _interval;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>
|
||||
/// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber
|
||||
/// ohne Rückfrage; der übergebene Text erklärt den Grund.
|
||||
/// </summary>
|
||||
public event Func<string, Task>? Revoked;
|
||||
|
||||
public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null)
|
||||
{
|
||||
_gate = gate;
|
||||
_logger = logger;
|
||||
_interval = interval ?? DefaultInterval;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop is { IsCompleted: false })
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = RunAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(_interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (result.IsValid)
|
||||
continue;
|
||||
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Lizenz-Nachprüfung ohne Ergebnis ({Status}) – Betrieb läuft weiter.",
|
||||
result.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Lizenz gilt nicht mehr ({Status}) – Instanz wird beendet.",
|
||||
result.Status);
|
||||
|
||||
if (Revoked is { } handler)
|
||||
{
|
||||
await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). "
|
||||
+ "ClawdDotNet wird beendet.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Regulärer Stopp.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Die Nachprüfung darf den Betrieb nicht mitnehmen.
|
||||
_logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts is null)
|
||||
return;
|
||||
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user