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:
@@ -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 (1–1,5 PT)
|
||||
|
||||
### AP 4.1 — Kestrel-Binding und Absicherung ⚠️
|
||||
|
||||
@@ -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:<plattform>:<hex></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)
|
||||
{
|
||||
if (result.IsCached)
|
||||
{
|
||||
Log.Warning("Lizenzserver nicht erreichbar — Offline-Gnadenfrist läuft bis {GraceUntil}.",
|
||||
result.GraceUntil);
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user