Lizenzsystem auf Deploymentcenter umgestellt

Predictalytics hing noch am abgeloesten LicenseLabrador (license.mhdf.de),
waehrend die Lizenzen inzwischen im Deploymentcenter (dc.mhdf.de) gepflegt
werden. Vor dem Umbau mit einem Wegwerf-Probelauf gegen den echten Client
abgesichert: Slug predictalytics, HWID v2 aus machine-guid, Server meldet valid.

Hosting/LicenseGuard.cs auf Deploymentcenter.Client umgeschrieben. Die
Aufteilung bleibt: plattformneutrale Pruefung im Hosting, interaktive
Aktivierung in den Hosts.

Gewinn fuer die Linux-Portierung:
- Hardware-ID v2 mit Quellen-Kaskade /etc/machine-id, dbus, DMI-UUID, MAC,
  Schluesseldatei; Container ueber DEPLOYMENTCENTER_HWID
- Der Rechnername steckt nicht mehr im Hash, Umbenennen verbraucht keinen
  Aktivierungsplatz
- Cache mit AES-256-GCM statt DPAPI, ohne stillen Klartext-Rueckfall unter
  Linux. Damit erledigt sich Punkt 3.2 Nr. 2 der Portierungsanalyse.
- XDG_CONFIG_HOME wird beruecksichtigt
- Keine Basic-Auth-Zugangsdaten mehr im Quelltext

Neue CLI-Schalter aus dem Integrationsleitfaden, ausgewertet vor jeder
Avalonia-Initialisierung: --license-status, --license-set-key,
--license-deactivate. Schluessel werden in der Ausgabe maskiert.

Die Lizenzfenster beider Hosts zeigen jetzt die Hardware-ID an, an die der
Server die Aktivierung bindet.

Build: 0 Fehler. Tests: 100 bestanden.
Verifiziert: --license-status meldet gueltig; die GUI startet vollstaendig
durch bis MainWindowViewModel.Start(), belegt durch die Logdateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-07 10:41:04 +02:00
co-authored by Claude Opus 5
parent 1efd23abe4
commit 65b44e984e
14 changed files with 384 additions and 140 deletions
+64 -5
View File
@@ -309,12 +309,22 @@ Beide Betriebsarten laufen an:
| `Predictalytics.Shell.exe` (GUI) | startet, Lizenzfenster erscheint, kein Absturz |
| Konsolen-Kodierung | UTF-8 explizit gesetzt — ohne das zeigt die Windows-Konsole nur Fragezeichen statt Rahmen und Emoji |
### ⚠️ Nicht verifiziert: das Hauptfenster
### Nachtrag 2026-08-07: Hauptfenster verifiziert ✅
Die hinterlegte Lizenz ist **abgelaufen** (`Expired: License has expired`). Beide
Betriebsarten bleiben deshalb an der Lizenzschranke stehen — `MainWindow`, Terminal,
Einstellungsansicht und die Wartungsaktionen sind compilergeprüft, aber **noch nie
angezeigt worden**. Nach Erneuerung der Lizenz nachzuholen.
Zunächst blockierte eine abgelaufene Lizenz die Prüfung. Nach der Umstellung auf das
Deploymentcenter (Abschnitt 5a) startet die GUI vollständig durch — belegt durch die
Logdateien des Laufs:
```
[INF] Shell initialisiert. Bereit.
[INF] 'Start Webserver' startet die WebUI auf http://localhost:5000
[INF] Einstellungen: C:\Users\bergm\AppData\Roaming\Predictalytics\settings.json
```
Damit sind Lizenzschranke, Serilog-Aufbau mit Terminal-Sink, `MainWindowViewModel.Start()`
und der neue Einstellungspfad nachgewiesen. **Noch nicht durchgeklickt**: die einzelnen
Wartungsaktionen und die Einstellungsansicht — dafür fehlen die in Phase 1 verlorenen
DB-Zugangsdaten.
### Umsetzung
@@ -433,6 +443,55 @@ Lizenzdialog.
---
## 5a. Zusatz — Umstellung auf das Deploymentcenter-Lizenzsystem ✅ (2026-08-07)
**Nicht ursprünglich geplant.** Beim Aktivieren der neuen Lizenz stellte sich heraus, dass
Predictalytics noch am alten Lizenzsystem hing (LicenseLabrador, `license.mhdf.de`),
während die Lizenzen inzwischen im Deploymentcenter (`dc.mhdf.de`) gepflegt werden.
Vor dem Umbau mit einem Wegwerf-Probelauf gegen den echten Client abgesichert:
Slug `predictalytics`, HWID v2 aus `machine-guid`, Server meldet `valid`.
### Was sich geändert hat
| | vorher (LicenseLabrador) | jetzt (Deploymentcenter) |
|---|---|---|
| Endpunkt | `license.mhdf.de/public/api/v1` | `dc.mhdf.de/api/license/v1` |
| Client | `LicenseLabrador.Client` (netstandard2.0) | `Deploymentcenter.Client` (netstandard2.0;net8.0) |
| Authentifizierung | Ed25519-Signaturprüfung + Basic Auth im Quelltext | serverseitig, kein Geheimnis im Client |
| Hardware-ID | SHA-256 aus MachineGuid **+ Rechnername** | **v2**: `2:<plattform>:<hex>`, ohne Rechnername |
| Linux-HWID | `/etc/machine-id`, sonst MAC | `/etc/machine-id` → dbus → DMI-UUID → MAC → Schlüsseldatei |
| Container | nicht vorgesehen | `DEPLOYMENTCENTER_HWID` überschreibt |
| Cache | DPAPI, **stiller Klartext-Rückfall unter Linux** | AES-256-GCM, HKDF aus HWID+Slug, **kein Klartext-Rückfall**, `0600` unter Linux |
| Ablageort | `%APPDATA%` | zusätzlich `XDG_CONFIG_HOME` unter Linux |
**Damit erledigt sich Analyse-Punkt 3.2 Nr. 2 von selbst:** der stille Klartext-Rückfall
des Lizenz-Caches unter Linux existiert im neuen Client nicht mehr. Der Rechnername steckt
außerdem nicht mehr im Hash — Umbenennen verbraucht keinen Aktivierungsplatz.
### Neue CLI-Schalter (`Services/LicenseCli.cs`)
Aus dem Deploymentcenter-Integrationsleitfaden übernommen, für Server ohne Oberfläche:
```bash
Predictalytics.Shell --license-status # HWID, Cache-Ablage, Serverprüfung
Predictalytics.Shell --license-set-key <SCHLÜSSEL> # einmalig aktivieren
Predictalytics.Shell --license-deactivate # Aktivierungsplatz freigeben
```
Die Schalter werden **vor** jeder Avalonia-Initialisierung ausgewertet, wie `--headless`.
Der Schlüssel wird in der Ausgabe maskiert (`LLAB3-*****-*****-*****-AABB2`).
### ⚠️ Offen: Watchdog läuft noch auf dem alten System
Das Deploymentcenter bündelt License, Watchdog, UpdateService und Bugtracker.
`WatchdogHeartbeatService` zeigt weiterhin auf `watchdog.mhdf.de` mit dem Header
`X-Watchdog-Key`. Das funktioniert, ist aber dieselbe Altlast wie eben beim Lizenzsystem.
Migration nach `dc.mhdf.de/api/watchdog/v1` als eigener Schritt einplanen —
Leitfaden liegt unter `Deploymentcenter/docs/WATCHDOG_INTEGRATION_GUIDE.md`.
---
## 6. Phase 4 — Aufräumen und Absichern (11,5 PT)
### AP 4.1 — Kestrel-Binding und Absicherung ⚠️
+87 -49
View File
@@ -1,71 +1,96 @@
using LicenseLabrador.Client;
using Deploymentcenter.Client;
using Serilog;
namespace Predictalytics.Hosting;
/// <summary>
/// Lizenzpruefung gegen den LicenseLabrador-Server.
/// Lizenzpruefung gegen das Deploymentcenter (dc.mhdf.de).
/// <para>
/// Endpunkt, Produkt-Slug und der Ed25519-Public-Key sind bewusst einkompiliert
/// (keine Benutzerkonfiguration): ein konfigurierbarer Endpunkt bzw. Key wuerde es
/// erlauben, die Anwendung auf einen gefaelschten Lizenzserver zu zeigen.
/// Endpunkt und Produkt-Slug sind bewusst einkompiliert (keine Benutzer-
/// konfiguration): ein konfigurierbarer Endpunkt wuerde es erlauben, die
/// Anwendung auf einen gefaelschten Lizenzserver zu zeigen.
/// </para>
/// <para>
/// Enthaelt keinen Dialog: die interaktive Aktivierung liegt beim Host (WinForms-
/// bzw. Avalonia-Dialog), der Headless-Modus bezieht den Schluessel aus der
/// Konfiguration.
/// Enthaelt keinen Dialog: die interaktive Aktivierung liegt beim Host
/// (Avalonia- bzw. WinForms-Fenster), der Headless-Modus bezieht den
/// Schluessel aus der Umgebungsvariable oder dem verschluesselten Cache.
/// </para>
/// </summary>
public static class LicenseGuard
{
private const string ProductSlug = "predictalytics";
private const string PublicKeyBase64 = "L7YR1wMKk8+lNefatzL+DMvAtHFVkZWYXAxXGrro+/U=";
private const string BasicAuthUser = "Labrador";
private const string BasicAuthPassword = "Labrador02763!";
// HTTPS ist Pflicht, nicht Kosmetik: license.mhdf.de leitet http→https um, und .NET
// macht bei einem Redirect aus dem POST ein GET. Der Server antwortet darauf mit 405,
// das SDK wertet das als "unerreichbar" und meldet irrefuehrend NoLicense.
// Ausserdem gingen die BasicAuth-Credentials sonst im Klartext ueber die Leitung.
private static readonly string[] Endpoints = { "https://license.mhdf.de/public/api/v1" };
/// <summary>In dc_projects hinterlegter Slug.</summary>
public const string ProductSlug = "predictalytics";
private const string ServerUrl = "https://dc.mhdf.de";
/// <summary>Re-check interval while the app is running (12 h).</summary>
public static readonly TimeSpan RevalidationInterval = TimeSpan.FromHours(12);
public static LicenseClient CreateClient()
private static readonly LicenseClient Client = new();
/// <summary>Hardware-ID v2 dieser Maschine (Format <c>2:&lt;plattform&gt;:&lt;hex&gt;</c>).</summary>
public static HardwareIdResult GetHardwareInfo() => HardwareId.GetHardwareId(ProductSlug);
/// <summary>Ablageort des verschluesselten Lizenz-Caches.</summary>
public static string StorageDirectory => LicenseConfig.GetStorageDirectory(ProductSlug);
/// <summary>
/// Zwischengespeicherter Lizenzschluessel, oder null wenn noch nie aktiviert wurde.
/// Der Cache ist AES-GCM-verschluesselt und an Hardware-ID und Produkt gebunden.
/// </summary>
public static string? GetCachedLicenseKey()
{
var config = new LicenseConfig
try
{
ProductSlug = ProductSlug,
PublicKeyBase64 = PublicKeyBase64,
Endpoints = Endpoints,
HttpBasicAuthUser = BasicAuthUser,
HttpBasicAuthPassword = BasicAuthPassword,
OfflineGraceHoursFallback = 168 // 7 Tage offline nutzbar, danach Serverkontakt nötig
};
return new LicenseClient(config);
var cache = StateStore.Load(ProductSlug, GetHardwareInfo().HardwareId);
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
}
catch (Exception ex)
{
Log.Debug(ex, "Lizenz-Cache konnte nicht gelesen werden");
return null;
}
}
/// <summary>
/// Prueft die zwischengespeicherte Lizenz. Liefert das Ergebnis; ob es nutzbar ist,
/// beantwortet <see cref="IsUsable"/>.
/// Validiert einen Lizenzschluessel am Server und legt ihn bei Erfolg
/// verschluesselt ab. Bei Netzwerkfehlern faellt der Client auf den
/// Cache zurueck (Status <c>valid_offline</c>).
/// </summary>
public static Task<LicenseResult> RevalidateAsync(LicenseClient client) => client.RevalidateAsync();
/// <summary>Nutzbar heisst: gueltig <em>und</em> mit passender Pruefsumme.</summary>
public static bool IsUsable(LicenseClient client, LicenseResult? result) =>
result != null && result.IsUsable && client.VerifyChecksum(result);
public static Task<LicenseValidationResult> ValidateAsync(string licenseKey) =>
Client.ValidateAsync(ProductSlug, Normalize(licenseKey), ServerUrl);
/// <summary>
/// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf/Ablauf
/// waehrend des Betriebs.
/// Validiert mit dem zwischengespeicherten Schluessel.
/// Liefert null, wenn noch keiner hinterlegt ist.
/// </summary>
public static async Task<LicenseValidationResult?> ValidateCachedAsync()
{
var key = GetCachedLicenseKey();
return key == null ? null : await ValidateAsync(key);
}
/// <summary>Gibt den Aktivierungsplatz dieser Maschine am Server wieder frei.</summary>
public static async Task<bool> DeactivateAsync()
{
var key = GetCachedLicenseKey();
if (key == null) return false;
return await Client.DeactivateAsync(ProductSlug, key, ServerUrl);
}
/// <summary>Schreibweise vereinheitlichen — die Schluessel sind durchgaengig gross.</summary>
public static string Normalize(string licenseKey) => licenseKey.Trim().ToUpperInvariant();
/// <summary>
/// Startet die periodische Revalidierung im Hintergrund. Erkennt Widerruf und
/// Ablauf waehrend des Betriebs.
/// </summary>
/// <param name="onUnusable">
/// Wird genau einmal aufgerufen, wenn die Lizenz endgueltig nicht mehr nutzbar ist.
/// Der Host entscheidet, was dann passiert (Meldung anzeigen, Anwendung beenden).
/// Transiente Fehler (Netz) loesen den Rueckruf nicht aus — dafuer gibt es die
/// Offline-Gnadenfrist des SDK.
/// Der Host entscheidet, was dann passiert. Netzwerkfehler loesen den Rueckruf
/// nicht aus — dafuer gibt es den verschluesselten Offline-Cache des Clients.
/// </param>
public static IDisposable StartPeriodicRevalidation(LicenseClient client, Action<LicenseResult> onUnusable)
public static IDisposable StartPeriodicRevalidation(Action<LicenseValidationResult> onUnusable)
{
var cts = new CancellationTokenSource();
@@ -78,26 +103,39 @@ public static class LicenseGuard
{
try
{
var result = await client.RevalidateAsync();
if (IsUsable(client, result))
var result = await ValidateCachedAsync();
if (result == null)
{
if (result.State == LicenseState.ValidOffline)
Log.Warning("Lizenz-Revalidierung: kein zwischengespeicherter Schlüssel vorhanden.");
continue;
}
if (result.IsValid)
{
Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.",
result.GraceUntil);
if (result.IsCached)
{
Log.Warning("Lizenzserver nicht erreichbar — Lizenz gilt über den lokalen Cache weiter ({Status}).",
result.Status);
}
continue;
}
Log.Fatal("Lizenzprüfung fehlgeschlagen ({State}): {Message} — Anwendung wird beendet.",
result.State, result.Message);
// network_error heisst: Server weg UND kein brauchbarer Cache.
// Das ist kein Widerruf — beim naechsten Durchlauf erneut versuchen.
if (result.Status.Equals("network_error", StringComparison.OrdinalIgnoreCase))
{
Log.Warning("Lizenz-Revalidierung fehlgeschlagen (Netzwerk): {Message}", result.Message);
continue;
}
Log.Fatal("Lizenzprüfung fehlgeschlagen ({Status}): {Message} — Anwendung wird beendet.",
result.Status, result.Message);
onUnusable(result);
return;
}
catch (Exception ex)
{
// Transiente Fehler (Netz etc.) faengt die Offline-Gnadenfrist des SDK ab —
// eine unerwartete Ausnahme darf die Anwendung niemals beenden.
// Eine unerwartete Ausnahme darf die Anwendung niemals beenden.
Log.Warning(ex, "Periodische Lizenz-Revalidierung fehlgeschlagen (wird erneut versucht).");
}
}
@@ -28,8 +28,9 @@
<ProjectReference Include="..\Predictalytics.Api\Predictalytics.Api.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\LicenseLabrador muss neben dem Predictalytics-Checkout liegen. -->
<ProjectReference Include="..\..\..\..\LicenseLabrador\client-dotnet\LicenseLabrador.Client\LicenseLabrador.Client.csproj" />
<!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem Predictalytics-Checkout liegen.
Loest LicenseLabrador ab (license.mhdf.de -> dc.mhdf.de). -->
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
</Project>
+5 -6
View File
@@ -43,12 +43,11 @@ public partial class App : global::Avalonia.Application
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
var client = LicenseGuard.CreateClient();
var result = await LicenseGuard.RevalidateAsync(client);
var result = await LicenseGuard.ValidateCachedAsync();
if (!LicenseGuard.IsUsable(client, result))
if (result?.IsValid != true)
{
var licenseWindow = new LicenseWindow(client, result);
var licenseWindow = new LicenseWindow(result);
desktop.MainWindow = licenseWindow;
licenseWindow.Show();
@@ -75,11 +74,11 @@ public partial class App : global::Avalonia.Application
viewModel.Start();
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, unusable =>
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(unusable =>
Dispatcher.UIThread.Post(async () =>
{
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
$"Die Lizenz ist nicht mehr gültig ({unusable.State}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
$"Die Lizenz ist nicht mehr gültig ({unusable.Status}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
desktop.Shutdown();
}));
@@ -21,7 +21,7 @@
</ItemGroup>
<ItemGroup>
<!-- Api, Worker, Infrastructure und LicenseLabrador.Client kommen transitiv ueber Hosting. -->
<!-- Api, Worker, Infrastructure und Deploymentcenter.Client kommen transitiv ueber Hosting. -->
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup>
+7 -1
View File
@@ -10,7 +10,13 @@ internal static class Program
{
// Die Argumentauswertung steht bewusst vor jeder Avalonia-Initialisierung:
// auf einem Server ohne X11 wuerde der Prozess sonst beim Aufbau des
// Fenstersystems sterben, bevor --headless ueberhaupt greift.
// Fenstersystems sterben, bevor die Schalter ueberhaupt greifen.
if (LicenseCli.IsLicenseCommand(args))
{
ConsoleAttach.TryAttachToParent();
return LicenseCli.RunAsync(args).GetAwaiter().GetResult();
}
if (args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase)))
{
ConsoleAttach.TryAttachToParent();
@@ -29,17 +29,16 @@ internal static class HeadlessRunner
try
{
var client = await EnsureLicensedAsync();
if (client == null) return ExitNoLicense;
if (!await EnsureLicensedAsync()) return ExitNoLicense;
var host = new PredictalyticsHost(options);
using var cts = new CancellationTokenSource();
using var shutdownSignals = RegisterShutdown(cts);
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, result =>
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(result =>
{
Log.Fatal("Lizenz nicht mehr gültig ({State}): {Message} — Dienst wird beendet.",
result.State, result.Message);
Log.Fatal("Lizenz nicht mehr gültig ({Status}): {Message} — Dienst wird beendet.",
result.Status, result.Message);
cts.Cancel();
});
@@ -76,33 +75,36 @@ internal static class HeadlessRunner
/// Lizenzpruefung ohne Dialog: zuerst der zwischengespeicherte Schluessel, sonst
/// einmalige Aktivierung mit dem Schluessel aus der Umgebungsvariable.
/// </summary>
private static async Task<LicenseLabrador.Client.LicenseClient?> EnsureLicensedAsync()
private static async Task<bool> EnsureLicensedAsync()
{
var client = LicenseGuard.CreateClient();
var result = await LicenseGuard.RevalidateAsync(client);
if (LicenseGuard.IsUsable(client, result)) return client;
var result = await LicenseGuard.ValidateCachedAsync();
if (result?.IsValid == true)
{
Log.Information("Lizenz gültig ({Status}{Cached}).",
result.Status, result.IsCached ? ", aus lokalem Cache" : "");
return true;
}
var key = Environment.GetEnvironmentVariable(LicenseKeyVariable);
if (string.IsNullOrWhiteSpace(key))
{
Log.Fatal("Keine nutzbare Lizenz ({State}: {Message}) und {Variable} ist nicht gesetzt. " +
Log.Fatal("Keine nutzbare Lizenz ({Status}) und {Variable} ist nicht gesetzt. " +
"Im Headless-Betrieb gibt es keinen Aktivierungsdialog — bitte den Lizenzschlüssel " +
"über die Umgebungsvariable bereitstellen.",
result.State, result.Message, LicenseKeyVariable);
return null;
"über die Umgebungsvariable bereitstellen oder einmalig mit --license-set-key aktivieren.",
result?.Status ?? "kein Schlüssel hinterlegt", LicenseKeyVariable);
return false;
}
Log.Information("Aktiviere Lizenz mit dem Schlüssel aus {Variable}…", LicenseKeyVariable);
result = await client.ValidateAsync(key.Trim().ToUpperInvariant());
if (LicenseGuard.IsUsable(client, result))
result = await LicenseGuard.ValidateAsync(key);
if (result.IsValid)
{
Log.Information("Lizenz aktiviert ({State}).", result.State);
return client;
Log.Information("Lizenz aktiviert ({Status}).", result.Status);
return true;
}
Log.Fatal("Aktivierung fehlgeschlagen ({State}): {Message}", result.State, result.Message);
return null;
Log.Fatal("Aktivierung fehlgeschlagen ({Status}): {Message}", result.Status, result.Message);
return false;
}
private static WatchdogHeartbeatService? StartWatchdog(PredictalyticsOptions options, PredictalyticsHost host)
@@ -0,0 +1,104 @@
using Predictalytics.Hosting;
namespace Predictalytics.Shell.Services;
/// <summary>
/// Lizenzverwaltung von der Kommandozeile — die im Deploymentcenter-Integrations-
/// leitfaden vorgesehenen Schalter. Gedacht fuer Server ohne Oberflaeche, wo es
/// keinen Aktivierungsdialog geben kann.
/// </summary>
internal static class LicenseCli
{
public const string StatusSwitch = "--license-status";
public const string SetKeySwitch = "--license-set-key";
public const string DeactivateSwitch = "--license-deactivate";
public static bool IsLicenseCommand(string[] args) =>
args.Length > 0 && args[0] is StatusSwitch or SetKeySwitch or DeactivateSwitch;
public static async Task<int> RunAsync(string[] args)
{
switch (args[0])
{
case StatusSwitch: return await ShowStatusAsync();
case SetKeySwitch: return await SetKeyAsync(args.Length > 1 ? args[1] : null);
case DeactivateSwitch: return await DeactivateAsync();
default: return 1;
}
}
private static async Task<int> ShowStatusAsync()
{
var hw = LicenseGuard.GetHardwareInfo();
Console.WriteLine("Predictalytics — Lizenzstatus");
Console.WriteLine($" Produkt : {LicenseGuard.ProductSlug}");
Console.WriteLine($" Hardware-ID : {hw.HardwareId}");
Console.WriteLine($" HWID-Quelle : {hw.HwidSource} (v{hw.HwidVersion}, {hw.Platform})");
Console.WriteLine($" Cache-Ablage : {LicenseGuard.StorageDirectory}");
var cachedKey = LicenseGuard.GetCachedLicenseKey();
if (cachedKey == null)
{
Console.WriteLine(" Schlüssel : keiner hinterlegt");
Console.WriteLine();
Console.WriteLine($"Aktivierung: {SetKeySwitch} <SCHLÜSSEL>");
return HeadlessRunner.ExitNoLicense;
}
Console.WriteLine($" Schlüssel : {Mask(cachedKey)}");
Console.WriteLine();
Console.Write("Prüfe am Server… ");
var result = await LicenseGuard.ValidateCachedAsync();
Console.WriteLine(result is { IsValid: true }
? $"gültig ({result.Status}{(result.IsCached ? ", aus lokalem Cache" : "")})"
: $"NICHT gültig ({result?.Status}): {result?.Message}");
return result is { IsValid: true } ? HeadlessRunner.ExitOk : HeadlessRunner.ExitNoLicense;
}
private static async Task<int> SetKeyAsync(string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
Console.Error.WriteLine($"Aufruf: {SetKeySwitch} <SCHLÜSSEL>");
return HeadlessRunner.ExitFailed;
}
Console.Write($"Aktiviere {Mask(LicenseGuard.Normalize(key))} … ");
var result = await LicenseGuard.ValidateAsync(key);
if (result.IsValid)
{
Console.WriteLine($"erfolgreich ({result.Status})");
Console.WriteLine($"Hardware-ID: {result.HardwareId}");
return HeadlessRunner.ExitOk;
}
Console.Error.WriteLine($"fehlgeschlagen ({result.Status}): {result.Message}");
return HeadlessRunner.ExitNoLicense;
}
private static async Task<int> DeactivateAsync()
{
if (LicenseGuard.GetCachedLicenseKey() == null)
{
Console.Error.WriteLine("Kein hinterlegter Schlüssel — nichts zu deaktivieren.");
return HeadlessRunner.ExitFailed;
}
Console.Write("Gebe Aktivierungsplatz am Server frei… ");
var ok = await LicenseGuard.DeactivateAsync();
Console.WriteLine(ok ? "erfolgreich" : "fehlgeschlagen");
return ok ? HeadlessRunner.ExitOk : HeadlessRunner.ExitFailed;
}
/// <summary>Nur die Segmentstruktur zeigen, nicht den vollstaendigen Schluessel.</summary>
private static string Mask(string key)
{
var parts = key.Split('-');
if (parts.Length < 2) return "*****";
for (var i = 1; i < parts.Length - 1; i++) parts[i] = new string('*', parts[i].Length);
return string.Join('-', parts);
}
}
@@ -2,14 +2,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Predictalytics.Shell.Views.LicenseWindow"
Title="Predictalytics — Lizenzaktivierung"
Width="560" SizeToContent="Height"
Width="580" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
<StackPanel Margin="24" Spacing="16">
<TextBlock TextWrapping="Wrap"
Text="Diese Installation benötigt eine gültige Lizenz." FontSize="15" FontWeight="SemiBold" />
<TextBlock TextWrapping="Wrap" FontSize="15" FontWeight="SemiBold"
Text="Diese Installation benötigt eine gültige Lizenz." />
<TextBlock TextWrapping="Wrap" Opacity="0.8"
Text="Bitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):" />
@@ -29,5 +29,16 @@
<Button Content="Beenden" MinWidth="100" IsCancel="True" Click="OnExitClick" />
</StackPanel>
<!-- Die Aktivierung wird am Server an diese Hardware-ID gebunden. -->
<Border BorderThickness="0,1,0,0" Padding="0,12,0,0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="4">
<TextBlock Text="Hardware-ID dieser Maschine" FontSize="11" Opacity="0.6" />
<SelectableTextBlock Name="HardwareIdText"
FontFamily="Cascadia Code,DejaVu Sans Mono,Consolas,monospace"
FontSize="11" Opacity="0.8" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</Window>
@@ -1,39 +1,49 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using LicenseLabrador.Client;
using Deploymentcenter.Client;
using Predictalytics.Hosting;
namespace Predictalytics.Shell.Views;
/// <summary>
/// Aktivierungsfenster, wenn keine nutzbare Lizenz vorliegt.
/// <see cref="Completion"/> liefert true, sobald eine gueltige, pruefsummen-
/// verifizierte Lizenz vorliegt; false, wenn der Benutzer abbricht.
/// <see cref="Completion"/> liefert true, sobald eine gueltige Lizenz vorliegt;
/// false, wenn der Benutzer abbricht.
/// </summary>
public partial class LicenseWindow : Window
{
private readonly LicenseClient _client = null!;
private readonly TaskCompletionSource<bool> _completion = new();
/// <summary>Wird abgeschlossen, wenn aktiviert oder abgebrochen wurde.</summary>
public Task<bool> Completion => _completion.Task;
public LicenseWindow() => InitializeComponent();
public LicenseWindow(LicenseClient client, LicenseResult? lastResult) : this()
public LicenseWindow()
{
_client = client;
InitializeComponent();
if (lastResult != null && lastResult.State != LicenseState.NoLicense)
try
{
SetStatus($"Letzte Prüfung: {lastResult.State} — {lastResult.Message}", isError: true);
var hw = LicenseGuard.GetHardwareInfo();
HardwareIdText.Text = $"{hw.HardwareId}\nQuelle: {hw.HwidSource}";
}
catch (Exception ex)
{
HardwareIdText.Text = $"nicht ermittelbar: {ex.Message}";
}
}
public LicenseWindow(LicenseValidationResult? lastResult) : this()
{
if (lastResult != null && !string.IsNullOrWhiteSpace(lastResult.Message))
{
SetStatus($"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}", isError: true);
}
}
private async void OnActivateClick(object? sender, RoutedEventArgs e)
{
var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? "";
var key = KeyBox.Text ?? "";
if (string.IsNullOrWhiteSpace(key))
{
SetStatus("Bitte einen Lizenzschlüssel eingeben.", isError: true);
@@ -46,15 +56,15 @@ public partial class LicenseWindow : Window
try
{
var result = await _client.ValidateAsync(key);
if (LicenseGuard.IsUsable(_client, result))
var result = await LicenseGuard.ValidateAsync(key);
if (result.IsValid)
{
_completion.TrySetResult(true);
Close();
return;
}
SetStatus($"Lizenz nicht nutzbar ({result.State}):\n{result.Message}", isError: true);
SetStatus($"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}", isError: true);
}
catch (Exception ex)
{
@@ -23,7 +23,7 @@
</ItemGroup>
<ItemGroup>
<!-- Api, Worker, Infrastructure und LicenseLabrador.Client kommen transitiv ueber Hosting. -->
<!-- Api, Worker, Infrastructure und Deploymentcenter.Client kommen transitiv ueber Hosting. -->
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup>
+3 -4
View File
@@ -12,8 +12,7 @@ internal static class Program
ApplicationConfiguration.Initialize();
// ─── License gate: no usable license, no app ───
var licenseClient = LicenseGate.EnsureLicensed();
if (licenseClient == null)
if (!LicenseGate.EnsureLicensed())
{
return;
}
@@ -28,13 +27,13 @@ internal static class Program
mainForm.Initialize();
// While running: re-check the license every 12 h (revocation/expiry/offline grace).
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(licenseClient, result =>
using var licenseWatch = LicenseGuard.StartPeriodicRevalidation(result =>
{
if (mainForm.IsDisposed) return;
mainForm.BeginInvoke(() =>
{
MessageBox.Show(
$"Die Lizenz ist nicht mehr gültig ({result.State}):\n{result.Message}\n\nPredictalytics wird beendet.",
$"Die Lizenz ist nicht mehr gültig ({result.Status}):\n{result.Message}\n\nPredictalytics wird beendet.",
"Lizenzfehler", MessageBoxButtons.OK, MessageBoxIcon.Stop);
System.Windows.Forms.Application.Exit();
});
@@ -1,44 +1,40 @@
using LicenseLabrador.Client;
using Deploymentcenter.Client;
using Predictalytics.Hosting;
namespace Predictalytics.WinFormsHost.Services;
/// <summary>
/// Modal dialog shown at startup when no usable license is present.
/// Lets the user enter/activate a license key; closes with OK only after a
/// successful, checksum-verified validation.
/// successful validation against the Deploymentcenter.
/// </summary>
public sealed class LicenseDialog : Form
{
private readonly LicenseClient _client;
private readonly Label _lblStatus;
private readonly TextBox _txtKey;
private readonly Button _btnActivate;
private readonly Button _btnExit;
public LicenseResult? Result { get; private set; }
public LicenseDialog(LicenseClient client, LicenseResult? lastResult)
public LicenseDialog(LicenseValidationResult? lastResult)
{
_client = client;
Text = "Predictalytics — Lizenzaktivierung";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
ClientSize = new Size(460, 190);
ClientSize = new Size(480, 250);
var lblInfo = new Label
{
Text = "Diese Installation benötigt eine gültige Lizenz.\nBitte Lizenzschlüssel eingeben (Format: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX):",
Location = new Point(12, 12),
Size = new Size(436, 34)
Size = new Size(456, 34)
};
_txtKey = new TextBox
{
Location = new Point(12, 52),
Size = new Size(436, 26),
Size = new Size(456, 26),
Font = new Font("Consolas", 11f),
CharacterCasing = CharacterCasing.Upper
};
@@ -46,36 +42,59 @@ public sealed class LicenseDialog : Form
_lblStatus = new Label
{
Location = new Point(12, 84),
Size = new Size(436, 50),
Size = new Size(456, 50),
ForeColor = Color.Firebrick,
Text = FormatInitialStatus(lastResult)
};
// Die Aktivierung wird am Server an diese Hardware-ID gebunden.
var lblHardwareId = new Label
{
Location = new Point(12, 140),
Size = new Size(456, 60),
ForeColor = Color.Gray,
Font = new Font("Consolas", 7.5f),
Text = FormatHardwareId()
};
_btnActivate = new Button
{
Text = "Aktivieren",
Location = new Point(252, 146),
Size = new Size(96, 30)
Location = new Point(262, 206),
Size = new Size(100, 30)
};
_btnActivate.Click += async (_, _) => await ActivateAsync();
_btnExit = new Button
{
Text = "Beenden",
Location = new Point(354, 146),
Size = new Size(94, 30),
Location = new Point(368, 206),
Size = new Size(100, 30),
DialogResult = DialogResult.Cancel
};
AcceptButton = _btnActivate;
CancelButton = _btnExit;
Controls.AddRange(new Control[] { lblInfo, _txtKey, _lblStatus, _btnActivate, _btnExit });
Controls.AddRange([lblInfo, _txtKey, _lblStatus, lblHardwareId, _btnActivate, _btnExit]);
}
private static string FormatInitialStatus(LicenseResult? lastResult)
private static string FormatInitialStatus(LicenseValidationResult? lastResult)
{
if (lastResult == null || lastResult.State == LicenseState.NoLicense) return "";
return $"Letzte Prüfung: {lastResult.State} — {lastResult.Message}";
if (lastResult == null || string.IsNullOrWhiteSpace(lastResult.Message)) return "";
return $"Letzte Prüfung: {lastResult.Status} — {lastResult.Message}";
}
private static string FormatHardwareId()
{
try
{
var hw = LicenseGuard.GetHardwareInfo();
return $"Hardware-ID: {hw.HardwareId}\nQuelle: {hw.HwidSource}";
}
catch (Exception ex)
{
return $"Hardware-ID nicht ermittelbar: {ex.Message}";
}
}
private async Task ActivateAsync()
@@ -93,17 +112,16 @@ public sealed class LicenseDialog : Form
try
{
var result = await _client.ValidateAsync(key);
if (result.IsUsable && _client.VerifyChecksum(result))
var result = await LicenseGuard.ValidateAsync(key);
if (result.IsValid)
{
Result = result;
DialogResult = DialogResult.OK;
Close();
return;
}
_lblStatus.ForeColor = Color.Firebrick;
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.State}):\n{result.Message}";
_lblStatus.Text = $"Lizenz nicht nutzbar ({result.Status}):\n{result.Message}";
}
catch (Exception ex)
{
@@ -1,4 +1,3 @@
using LicenseLabrador.Client;
using Predictalytics.Hosting;
namespace Predictalytics.WinFormsHost.Services;
@@ -12,19 +11,17 @@ internal static class LicenseGate
/// <summary>
/// Blockiert, bis eine nutzbare Lizenz vorliegt. Zuerst wird der zwischengespeicherte
/// Schluessel geprueft; erst wenn der nicht (mehr) nutzbar ist, erscheint der Dialog.
/// Liefert null, wenn der Benutzer abbricht — die Anwendung muss dann beendet werden.
/// Liefert false, wenn der Benutzer abbricht — die Anwendung muss dann beendet werden.
/// </summary>
public static LicenseClient? EnsureLicensed()
public static bool EnsureLicensed()
{
var client = LicenseGuard.CreateClient();
var result = LicenseGuard.RevalidateAsync(client).GetAwaiter().GetResult();
if (LicenseGuard.IsUsable(client, result))
var result = LicenseGuard.ValidateCachedAsync().GetAwaiter().GetResult();
if (result?.IsValid == true)
{
return client;
return true;
}
using var dialog = new LicenseDialog(client, result);
return dialog.ShowDialog() == DialogResult.OK ? client : null;
using var dialog = new LicenseDialog(result);
return dialog.ShowDialog() == DialogResult.OK;
}
}