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;
public class LicenseValidationResult
{
public bool IsValid { get; set; }
public string Status { get; set; } = "unknown";
public string Message { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty;
public bool IsCached { get; set; }
/// Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches.
public long? ExpiresAt { get; set; }
///
/// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client
/// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft.
///
public long? CacheExpiresAt { get; set; }
///
/// 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.
///
public bool IsTransient { get; set; }
}
public class LicenseClient
{
///
/// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf
/// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version.
///
public static string? DefaultAppVersion { get; set; }
///
/// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie
/// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der
/// Server nicht antwortete.
///
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
private static readonly Lazy SharedHttpClient = new Lazy(
() => new HttpClient { Timeout = DefaultTimeout },
LazyThreadSafetyMode.ExecutionAndPublication);
/// Standard-Gnadenfrist, wenn der Server keine TTL mitschickt.
private const int FallbackCacheTtlHours = 168;
private readonly HttpClient _httpClient;
private readonly ILicensePrompt _prompt;
///
/// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit
/// verwendet. Ein uebergebener HttpClient
/// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer.
///
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
{
_httpClient = httpClient ?? SharedHttpClient.Value;
_prompt = prompt ?? new ConsoleLicensePrompt();
}
public Task ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
CancellationToken cancellationToken)
=> ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken);
///
/// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des
/// Deploymentcenters. Ohne Angabe wird und
/// danach die Version der Startassembly verwendet.
///
public async Task 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
{
product = productSlug,
license_key = licenseKey,
hardware_id = hwInfo.HardwareId,
legacy_hardware_id = hwInfo.LegacyHardwareId,
hwid_version = hwInfo.HwidVersion,
hwid_source = hwInfo.HwidSource,
platform = hwInfo.Platform,
hostname = Environment.MachineName,
app_version = ResolveAppVersion(appVersion),
nonce = Guid.NewGuid().ToString("N")
};
string jsonStr = JsonSerializer.Serialize(payload);
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
failureReason = ex.Message;
}
using (response)
{
if (response != null)
{
if (response.IsSuccessStatusCode)
{
string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now);
if (verdict != null)
{
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";
}
else
{
// 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();
}
}
}
return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar");
}
///
/// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares
/// Lizenzurteil enthaelt - dann greift der Offline-Zweig.
///
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 = 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
};
}
///
/// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil
/// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort.
///
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 = "server_unavailable",
Message = $"Server communication error and no valid cache available: {reason}",
HardwareId = hwInfo.HardwareId,
IsTransient = true
};
}
///
/// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist
/// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt
/// unbegrenzt zu gelten.
///
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;
}
///
/// 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.
///
public static string? TryGetCachedKey(string productSlug)
{
var cache = TryGetCachedState(productSlug);
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
}
///
/// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist,
/// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser
/// Maschine gehoert.
///
public static LocalCacheData? TryGetCachedState(string productSlug)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return StateStore.Load(productSlug, hwInfo.HardwareId);
}
///
/// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel.
/// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde.
///
public async Task 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 DeactivateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string authToken = "",
CancellationToken cancellationToken = default)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
var payload = new
{
product = productSlug,
license_key = licenseKey,
hardware_id = hwInfo.HardwareId,
nonce = Guid.NewGuid().ToString("N")
};
string jsonStr = JsonSerializer.Serialize(payload);
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content
};
if (!string.IsNullOrWhiteSpace(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
{
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()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
// Das SDK haengt bei manchen Buildeinstellungen "+" 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";
}
}