feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback
Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im Fehlerfall halb aktualisierte Installationen hinterliessen. Server - Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version, weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System zog sich damit das Windows-Paket. - Aufloesungsregel: je Version das plattformgenaue Paket, sonst das plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich 'any' - lieber kein Update als das falsche. - manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer, wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war. - Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf fremden Systemen und darf den Signierschluessel nicht besitzen. Packager - Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der bestehenden latest.json fehl, ersetzte ein leeres catch die komplette Historie durch einen einzigen Eintrag - ohne jede Meldung. - Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der mitgelieferten Beispielkonfiguration nie zu. - preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems. - Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen. - Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer Endlosschleife: Clients aktualisieren, melden weiter die alte Version, halten das Release erneut fuer neu. - --platform mit Ableitung aus dem Publish-Pfad. Agent - Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als "Atomic Replace with Backup" kommentiert und war eine Kopierschleife. - Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der Vorversion. Was nicht aus einem Release stammt, bleibt liegen. - Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben. - API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte. - Installierte Version aus --current-version oder manifest.json statt des Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen liess. Reparatur funktioniert damit auch ohne manifest.json. - Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden. SDK - ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes "update-agent.exe" wird unter Linux nie gefunden. - LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit dem Herunterfahren) und --platform. Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz- Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
@@ -13,25 +15,88 @@ public class LicenseValidationResult
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string HardwareId { get; set; } = string.Empty;
|
||||
public bool IsCached { get; set; }
|
||||
|
||||
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches.</summary>
|
||||
public long? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client
|
||||
/// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft.
|
||||
/// </summary>
|
||||
public long? CacheExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wahr, wenn kein Lizenzurteil vorliegt, sondern nur die Verbindung zum
|
||||
/// Server gescheitert ist (Netzfehler, HTTP 429/5xx, unlesbare Antwort).
|
||||
///
|
||||
/// Ein solcher Zustand darf eine Anwendung nicht beenden. Nur ein vom
|
||||
/// Server geliefertes Urteil (revoked, expired, not_found,
|
||||
/// activation_limit, suspended) ist endgueltig.
|
||||
/// </summary>
|
||||
public bool IsTransient { get; set; }
|
||||
}
|
||||
|
||||
public class LicenseClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf
|
||||
/// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version.
|
||||
/// </summary>
|
||||
public static string? DefaultAppVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie
|
||||
/// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der
|
||||
/// Server nicht antwortete.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static readonly Lazy<HttpClient> SharedHttpClient = new Lazy<HttpClient>(
|
||||
() => new HttpClient { Timeout = DefaultTimeout },
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
/// <summary>Standard-Gnadenfrist, wenn der Server keine TTL mitschickt.</summary>
|
||||
private const int FallbackCacheTtlHours = 168;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILicensePrompt _prompt;
|
||||
|
||||
/// <summary>
|
||||
/// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit
|
||||
/// <see cref="DefaultTimeout"/> verwendet. Ein uebergebener HttpClient
|
||||
/// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer.
|
||||
/// </summary>
|
||||
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_httpClient = httpClient ?? SharedHttpClient.Value;
|
||||
_prompt = prompt ?? new ConsoleLicensePrompt();
|
||||
}
|
||||
|
||||
public async Task<LicenseValidationResult> ValidateAsync(string productSlug, string licenseKey, string serverBaseUrl)
|
||||
public Task<LicenseValidationResult> ValidateAsync(
|
||||
string productSlug,
|
||||
string licenseKey,
|
||||
string serverBaseUrl,
|
||||
CancellationToken cancellationToken)
|
||||
=> ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken);
|
||||
|
||||
/// <param name="appVersion">
|
||||
/// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des
|
||||
/// Deploymentcenters. Ohne Angabe wird <see cref="DefaultAppVersion"/> und
|
||||
/// danach die Version der Startassembly verwendet.
|
||||
/// </param>
|
||||
public async Task<LicenseValidationResult> ValidateAsync(
|
||||
string productSlug,
|
||||
string licenseKey,
|
||||
string serverBaseUrl,
|
||||
string? appVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
string? failureReason = null;
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
@@ -44,7 +109,7 @@ public class LicenseClient
|
||||
hwid_source = hwInfo.HwidSource,
|
||||
platform = hwInfo.Platform,
|
||||
hostname = Environment.MachineName,
|
||||
app_version = "1.0.0",
|
||||
app_version = ResolveAppVersion(appVersion),
|
||||
nonce = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
@@ -52,122 +117,318 @@ public class LicenseClient
|
||||
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
|
||||
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
|
||||
|
||||
HttpResponseMessage response = await _httpClient.PostAsync(endpoint, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string resBody = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(resBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
|
||||
string message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
|
||||
long? expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number ? eProp.GetInt64() : null;
|
||||
|
||||
if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Save encrypted local cache
|
||||
var cache = new LocalCacheData
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
ProductSlug = productSlug,
|
||||
LicenseKey = licenseKey,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
Status = "valid",
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt ?? (now + 7 * 86400),
|
||||
MaxSeenTime = now,
|
||||
Checksum = hwInfo.HardwareId
|
||||
};
|
||||
|
||||
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = false,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = false
|
||||
};
|
||||
}
|
||||
}
|
||||
response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Server request failed -> Fall back to encrypted offline cache
|
||||
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
|
||||
if (cache != null && cache.Status == "valid")
|
||||
failureReason = ex.Message;
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response != null)
|
||||
{
|
||||
// Check time-rollback protection
|
||||
if (now < cache.MaxSeenTime)
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now);
|
||||
|
||||
if (verdict != null)
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "clock_rollback",
|
||||
Message = "System clock rollback detected! Online verification required.",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
};
|
||||
return verdict;
|
||||
}
|
||||
|
||||
// Erfolgreiche Antwort, aber kein lesbares Urteil - etwa die
|
||||
// Fehlerseite eines Proxys mit Statuscode 200. Das ist keine
|
||||
// Aussage ueber die Lizenz, also wird sie auch nicht als
|
||||
// solche behandelt.
|
||||
failureReason = "Antwort des Servers war nicht auswertbar";
|
||||
}
|
||||
|
||||
// Check cache expiry
|
||||
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
|
||||
else
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "cache_expired",
|
||||
Message = "Cached license has expired.",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
};
|
||||
// WICHTIG: Ein HTTP-Fehler ist kein Lizenzurteil.
|
||||
//
|
||||
// Frueher lag der Cache-Zweig ausschliesslich im catch.
|
||||
// Ein 429 (Drosselung) oder 500 warf keine Exception,
|
||||
// sondern fiel aus dem Erfolgszweig heraus und endete als
|
||||
// "unknown_error" - ohne den Cache auch nur zu befragen.
|
||||
// Ein Serverfehler entzog damit die Lizenz, ein gezogenes
|
||||
// Netzkabel nicht. Jetzt fuehrt jeder Nicht-Erfolg in
|
||||
// denselben Offline-Zweig.
|
||||
failureReason = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update max_seen_time
|
||||
cache.MaxSeenTime = now;
|
||||
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
||||
return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar");
|
||||
}
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = "valid_offline",
|
||||
Message = "License validated via secure offline cache",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = true,
|
||||
ExpiresAt = cache.ExpiresAt
|
||||
};
|
||||
/// <summary>
|
||||
/// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares
|
||||
/// Lizenzurteil enthaelt - dann greift der Offline-Zweig.
|
||||
/// </summary>
|
||||
private static LicenseValidationResult? TryReadVerdict(
|
||||
string resBody,
|
||||
string productSlug,
|
||||
string licenseKey,
|
||||
HardwareIdResult hwInfo,
|
||||
long now)
|
||||
{
|
||||
string status;
|
||||
string message;
|
||||
long? expiresAt;
|
||||
int cacheTtlHours;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(resBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out var sProp))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
status = sProp.GetString() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
|
||||
expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number
|
||||
? eProp.GetInt64()
|
||||
: (long?)null;
|
||||
|
||||
// Der Server bestimmt die Laenge der Offline-Gnadenfrist je Projekt.
|
||||
// Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum
|
||||
// der Lizenz eingetragen - bei einer Lizenz bis 2040 war die Frist
|
||||
// praktisch unbegrenzt.
|
||||
cacheTtlHours = root.TryGetProperty("cache_ttl_hours", out var tProp) && tProp.ValueKind == JsonValueKind.Number
|
||||
? tProp.GetInt32()
|
||||
: FallbackCacheTtlHours;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cacheTtlHours <= 0)
|
||||
{
|
||||
cacheTtlHours = FallbackCacheTtlHours;
|
||||
}
|
||||
|
||||
if (!status.Equals("valid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "network_error",
|
||||
Message = $"Server communication error and no valid cache available: {ex.Message}",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
Status = status,
|
||||
Message = message,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = false,
|
||||
ExpiresAt = expiresAt,
|
||||
IsTransient = false
|
||||
};
|
||||
}
|
||||
|
||||
// Die Gnadenfrist endet mit der TTL - spaetestens aber mit der Lizenz.
|
||||
long cacheExpiresAt = now + (long)cacheTtlHours * 3600L;
|
||||
if (expiresAt.HasValue && expiresAt.Value > 0 && expiresAt.Value < cacheExpiresAt)
|
||||
{
|
||||
cacheExpiresAt = expiresAt.Value;
|
||||
}
|
||||
|
||||
var cache = new LocalCacheData
|
||||
{
|
||||
SchemaVersion = StateStore.CurrentSchemaVersion,
|
||||
ProductSlug = productSlug,
|
||||
LicenseKey = licenseKey,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
Status = "valid",
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt ?? 0,
|
||||
CacheExpiresAt = cacheExpiresAt,
|
||||
CacheTtlHours = cacheTtlHours,
|
||||
MaxSeenTime = now,
|
||||
Checksum = hwInfo.HardwareId
|
||||
};
|
||||
|
||||
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = false,
|
||||
ExpiresAt = expiresAt,
|
||||
CacheExpiresAt = cacheExpiresAt,
|
||||
IsTransient = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil
|
||||
/// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort.
|
||||
/// </summary>
|
||||
private static LicenseValidationResult OfflineFallback(
|
||||
string productSlug,
|
||||
HardwareIdResult hwInfo,
|
||||
long now,
|
||||
string reason)
|
||||
{
|
||||
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
|
||||
|
||||
if (cache != null && cache.Status == "valid")
|
||||
{
|
||||
// Rueckdrehen der Systemuhr erkennen
|
||||
if (now < cache.MaxSeenTime)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "clock_rollback",
|
||||
Message = "System clock rollback detected! Online verification required.",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsTransient = false
|
||||
};
|
||||
}
|
||||
|
||||
long cacheExpiresAt = ResolveCacheExpiry(cache);
|
||||
|
||||
if (cacheExpiresAt > 0 && now > cacheExpiresAt)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "cache_expired",
|
||||
Message = $"Offline-Gnadenfrist abgelaufen, Server nicht erreichbar ({reason}).",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
|
||||
CacheExpiresAt = cacheExpiresAt,
|
||||
IsTransient = true
|
||||
};
|
||||
}
|
||||
|
||||
// Auch offline darf eine abgelaufene Lizenz nicht weiterlaufen.
|
||||
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "expired",
|
||||
Message = "Cached license has expired.",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
ExpiresAt = cache.ExpiresAt,
|
||||
IsTransient = false
|
||||
};
|
||||
}
|
||||
|
||||
cache.MaxSeenTime = now;
|
||||
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = "valid_offline",
|
||||
Message = $"License validated via secure offline cache ({reason}).",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = true,
|
||||
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
|
||||
CacheExpiresAt = cacheExpiresAt,
|
||||
IsTransient = false
|
||||
};
|
||||
}
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "unknown_error",
|
||||
Message = "Validation failed.",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
Status = "server_unavailable",
|
||||
Message = $"Server communication error and no valid cache available: {reason}",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsTransient = true
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateAsync(string productSlug, string licenseKey, string serverBaseUrl, string authToken = "")
|
||||
/// <summary>
|
||||
/// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist
|
||||
/// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt
|
||||
/// unbegrenzt zu gelten.
|
||||
/// </summary>
|
||||
private static long ResolveCacheExpiry(LocalCacheData cache)
|
||||
{
|
||||
if (cache.CacheExpiresAt > 0)
|
||||
{
|
||||
return cache.CacheExpiresAt;
|
||||
}
|
||||
|
||||
int ttlHours = cache.CacheTtlHours > 0 ? cache.CacheTtlHours : FallbackCacheTtlHours;
|
||||
return cache.IssuedAt > 0 ? cache.IssuedAt + (long)ttlHours * 3600L : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den zuletzt erfolgreich geprueften Schluessel aus dem lokalen
|
||||
/// Cache. Damit laesst sich ein Schalter wie --license-status oder ein
|
||||
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
|
||||
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
|
||||
/// </summary>
|
||||
public static string? TryGetCachedKey(string productSlug)
|
||||
{
|
||||
var cache = TryGetCachedState(productSlug);
|
||||
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist,
|
||||
/// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser
|
||||
/// Maschine gehoert.
|
||||
/// </summary>
|
||||
public static LocalCacheData? TryGetCachedState(string productSlug)
|
||||
{
|
||||
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
||||
return StateStore.Load(productSlug, hwInfo.HardwareId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel.
|
||||
/// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde.
|
||||
/// </summary>
|
||||
public async Task<LicenseValidationResult> RevalidateAsync(
|
||||
string productSlug,
|
||||
string serverBaseUrl,
|
||||
string? appVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? key = TryGetCachedKey(productSlug);
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "not_found",
|
||||
Message = "Kein zwischengespeicherter Lizenzschluessel vorhanden.",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsTransient = false
|
||||
};
|
||||
}
|
||||
|
||||
return await ValidateAsync(productSlug, key, serverBaseUrl, appVersion, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateAsync(
|
||||
string productSlug,
|
||||
string licenseKey,
|
||||
string serverBaseUrl,
|
||||
string authToken = "",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
||||
var payload = new
|
||||
@@ -182,25 +443,79 @@ public class LicenseClient
|
||||
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
|
||||
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authToken))
|
||||
{
|
||||
request.Headers.Add("X-Watchdog-Key", authToken);
|
||||
// Nur noch der Standardweg. Der zusaetzliche X-Watchdog-Key war ein
|
||||
// Ueberbleibsel des alten Servers und irrefuehrend benannt: hier
|
||||
// gehoert der shared_key hin, kein Watchdog-Token. Erschwerend
|
||||
// prueft der Server X-Watchdog-Key VOR Authorization - ein dort
|
||||
// versehentlich eingetragenes Watchdog-Token haette das richtige
|
||||
// Bearer-Token stillschweigend verdraengt.
|
||||
request.Headers.Add("Authorization", $"Bearer {authToken}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveAppVersion(string? explicitVersion)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitVersion))
|
||||
{
|
||||
return explicitVersion!.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(DefaultAppVersion))
|
||||
{
|
||||
return DefaultAppVersion!.Trim();
|
||||
}
|
||||
|
||||
// Vorher stand hier fest "1.0.0". In der Aktivierungsliste des
|
||||
// Deploymentcenters trug damit jede Installation dieselbe Version,
|
||||
// obwohl die Spalte app_version dafuer vorgesehen ist.
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly();
|
||||
if (assembly != null)
|
||||
{
|
||||
string? informational = assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(informational))
|
||||
{
|
||||
// Das SDK haengt bei manchen Buildeinstellungen "+<commit>" an.
|
||||
int plus = informational!.IndexOf('+');
|
||||
return plus > 0 ? informational.Substring(0, plus) : informational;
|
||||
}
|
||||
|
||||
var version = assembly.GetName().Version;
|
||||
if (version != null)
|
||||
{
|
||||
return version.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Version zu ermitteln darf eine Lizenzpruefung nie scheitern lassen.
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user