diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj new file mode 100644 index 0000000..044fad6 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0;net8.0 + enable + enable + latest + Deploymentcenter.Client + Deploymentcenter.Client + $(NoWarn);CA1416 + + + + + + + + + + + diff --git a/client-dotnet/Deploymentcenter.Client/HardwareId.cs b/client-dotnet/Deploymentcenter.Client/HardwareId.cs new file mode 100644 index 0000000..53138f9 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/HardwareId.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Win32; + +namespace Deploymentcenter.Client; + +public class HardwareIdResult +{ + public string HardwareId { get; set; } = string.Empty; + public int HwidVersion { get; set; } = 2; + public string HwidSource { get; set; } = string.Empty; + public string Platform { get; set; } = string.Empty; + public string LegacyHardwareId { get; set; } = string.Empty; +} + +public static class HardwareId +{ + private static readonly string[] MacStopWords = new[] + { + "docker", "veth", "br-", "virbr", "cni", "flannel", "cali", "weave", + "zt", "tailscale", "ipsec", "sit", "gre", "dummy", "bond", "macvlan", "ovs" + }; + + public static HardwareIdResult GetHardwareId(string productSlug = "default_app") + { + string platform = OperatingSystemHelpers.GetPlatformCode(); + string rawValue = string.Empty; + string source = string.Empty; + + // 3.1 Override (all platforms, highest priority) + var overrideValue = LicenseConfig.HardwareIdOverride; + if (!string.IsNullOrWhiteSpace(overrideValue)) + { + rawValue = overrideValue!.Trim(); + source = "override"; + } + else if (OperatingSystemHelpers.IsWindows()) + { + // 3.2 Windows Sources + string? machineGuid = GetWindowsMachineGuid(); + if (IsPlausibleMachineId(machineGuid)) + { + rawValue = machineGuid!; + source = "machine-guid"; + } + else + { + string? macs = GetStablePhysicalMacs(); + if (!string.IsNullOrWhiteSpace(macs)) + { + rawValue = macs!; + source = "mac"; + } + else + { + rawValue = GetOrCreateKeyfile(productSlug); + source = "keyfile"; + } + } + } + else if (OperatingSystemHelpers.IsLinux()) + { + // 3.3 Linux Sources + string? systemdId = ReadTextFileFile("/etc/machine-id"); + if (IsPlausibleMachineId(systemdId)) + { + rawValue = systemdId!; + source = "machine-id"; + } + else + { + string? dbusId = ReadTextFileFile("/var/lib/dbus/machine-id"); + if (IsPlausibleMachineId(dbusId)) + { + rawValue = dbusId!; + source = "dbus-machine-id"; + } + else + { + string? dmiUuid = ReadTextFileFile("/sys/class/dmi/id/product_uuid"); + if (IsPlausibleMachineId(dmiUuid)) + { + rawValue = dmiUuid!; + source = "dmi-uuid"; + } + else + { + string? macs = GetStablePhysicalMacs(); + if (!string.IsNullOrWhiteSpace(macs)) + { + rawValue = macs!; + source = "mac"; + } + else + { + rawValue = GetOrCreateKeyfile(productSlug); + source = "keyfile"; + } + } + } + } + } + else + { + // Generic fallback + string? macs = GetStablePhysicalMacs(); + if (!string.IsNullOrWhiteSpace(macs)) + { + rawValue = macs!; + source = "mac"; + } + else + { + rawValue = GetOrCreateKeyfile(productSlug); + source = "keyfile"; + } + } + + string v2Hash = ComputeV2Hash(platform, source, rawValue); + string v2HardwareId = $"2:{platform}:{v2Hash}"; + string legacyHardwareId = GetLegacyHardwareId(); + + return new HardwareIdResult + { + HardwareId = v2HardwareId, + HwidVersion = 2, + HwidSource = source, + Platform = platform, + LegacyHardwareId = legacyHardwareId + }; + } + + public static string ComputeV2Hash(string platform, string source, string rawValue) + { + string domainString = "LicenseLabrador-HWID-v2"; + string payload = $"{domainString}\n{platform}\n{source}\n{rawValue}"; + + using var sha256 = SHA256.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(payload); + byte[] hash = sha256.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + public static string GetLegacyHardwareId() + { + // Legacy v1 algorithm: SHA256 of MachineName + First MAC + string firstMac = GetFirstPhysicalMacLegacy(); + string raw = $"{Environment.MachineName}:{firstMac}"; + + using var sha256 = SHA256.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(raw); + byte[] hash = sha256.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + public static bool IsPlausibleMachineId(string? v) + { + if (string.IsNullOrWhiteSpace(v)) + return false; + + string trimmed = v!.Trim(); + if (trimmed.Length < 16) + return false; + + if (trimmed.Equals("uninitialized", StringComparison.OrdinalIgnoreCase)) + return false; + + if (trimmed.Trim('0').Length == 0) + return false; + + return true; + } + + private static string? GetWindowsMachineGuid() + { + if (!OperatingSystemHelpers.IsWindows()) + return null; + + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var subKey = baseKey.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + return subKey?.GetValue("MachineGuid")?.ToString(); + } + catch + { + return null; + } + } + + private static string? GetStablePhysicalMacs() + { + try + { + var validMacs = new List(); + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + + foreach (var ni in interfaces) + { + if (ni.OperationalStatus != OperationalStatus.Up && ni.OperationalStatus != OperationalStatus.Unknown) + continue; + + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback || + ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + continue; + + string name = ni.Name.ToLowerInvariant(); + string desc = ni.Description.ToLowerInvariant(); + + if (MacStopWords.Any(sw => name.Contains(sw) || desc.Contains(sw))) + continue; + + var physAddr = ni.GetPhysicalAddress(); + var bytes = physAddr.GetAddressBytes(); + if (bytes.Length == 0) + continue; + + // Check locally administered bit (mac[0] & 0x02) != 0 + if ((bytes[0] & 0x02) != 0) + continue; + + // On Linux, check /sys/class/net//device + if (OperatingSystemHelpers.IsLinux()) + { + string sysDevicePath = $"/sys/class/net/{ni.Name}/device"; + if (!Directory.Exists(sysDevicePath) && !File.Exists(sysDevicePath)) + continue; + } + + string macHex = BitConverter.ToString(bytes).Replace("-", "").ToUpperInvariant(); + if (!string.IsNullOrWhiteSpace(macHex) && macHex.Trim('0').Length > 0) + { + validMacs.Add(macHex); + } + } + + if (validMacs.Count == 0) + return null; + + validMacs.Sort(); + return string.Join(":", validMacs); + } + catch + { + return null; + } + } + + private static string GetFirstPhysicalMacLegacy() + { + try + { + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var ni in interfaces) + { + if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) + continue; + + var bytes = ni.GetPhysicalAddress().GetAddressBytes(); + if (bytes.Length > 0) + { + return BitConverter.ToString(bytes).Replace("-", "").ToUpperInvariant(); + } + } + } + catch { } + + return "000000000000"; + } + + private static string? ReadTextFileFile(string path) + { + try + { + if (!File.Exists(path)) + return null; + return File.ReadAllText(path).Trim(); + } + catch + { + return null; + } + } + + private static string GetOrCreateKeyfile(string productSlug) + { + string dir = LicenseConfig.GetStorageDirectory(productSlug); + Directory.CreateDirectory(dir); + string keyfilePath = Path.Combine(dir, "machine.key"); + + if (File.Exists(keyfilePath)) + { + try + { + string existing = File.ReadAllText(keyfilePath).Trim(); + if (!string.IsNullOrWhiteSpace(existing)) + return existing; + } + catch { } + } + + byte[] randomBytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomBytes); + } + string keyBase64 = Convert.ToBase64String(randomBytes); + + File.WriteAllText(keyfilePath, keyBase64, Encoding.UTF8); + + // Set 0600 permissions on Linux/macOS + SetUnixPermissions(keyfilePath, "600"); + + return keyBase64; + } + + private static void SetUnixPermissions(string filePath, string mode) + { + if (OperatingSystemHelpers.IsWindows()) + return; + +#if NET8_0_OR_GREATER + try + { + File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch { } +#else + try + { + var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "chmod", + Arguments = $"{mode} \"{filePath}\"", + UseShellExecute = false, + CreateNoWindow = true + }); + proc?.WaitForExit(); + } + catch { } +#endif + } +} diff --git a/client-dotnet/Deploymentcenter.Client/ILicensePrompt.cs b/client-dotnet/Deploymentcenter.Client/ILicensePrompt.cs new file mode 100644 index 0000000..690bb33 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/ILicensePrompt.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; + +namespace Deploymentcenter.Client; + +public interface ILicensePrompt +{ + Task RequestLicenseKeyAsync(string productSlug); + void ShowLicenseError(string title, string message); + void ShowLicenseInfo(string title, string message); +} + +public class ConsoleLicensePrompt : ILicensePrompt +{ + public Task RequestLicenseKeyAsync(string productSlug) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"\n[🔑 Lizenzschlüssel erforderlich für '{productSlug}']"); + Console.ResetColor(); + Console.Write("Bitte Lizenzschlüssel eingeben: "); + string? input = Console.ReadLine(); + return Task.FromResult(input?.Trim()); + } + + public void ShowLicenseError(string title, string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n[✖ {title}] {message}"); + Console.ResetColor(); + } + + public void ShowLicenseInfo(string title, string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"\n[✔ {title}] {message}"); + Console.ResetColor(); + } +} diff --git a/client-dotnet/Deploymentcenter.Client/LicenseClient.cs b/client-dotnet/Deploymentcenter.Client/LicenseClient.cs new file mode 100644 index 0000000..9f5f3b1 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/LicenseClient.cs @@ -0,0 +1,206 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +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; } + public long? ExpiresAt { get; set; } +} + +public class LicenseClient +{ + private readonly HttpClient _httpClient; + private readonly ILicensePrompt _prompt; + + public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null) + { + _httpClient = httpClient ?? new HttpClient(); + _prompt = prompt ?? new ConsoleLicensePrompt(); + } + + public async Task ValidateAsync(string productSlug, string licenseKey, string serverBaseUrl) + { + var hwInfo = HardwareId.GetHardwareId(productSlug); + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + 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 = "1.0.0", + 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"; + + 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 + }; + } + } + } + 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") + { + // Check time-rollback protection + if (now < cache.MaxSeenTime) + { + return new LicenseValidationResult + { + IsValid = false, + Status = "clock_rollback", + Message = "System clock rollback detected! Online verification required.", + HardwareId = hwInfo.HardwareId + }; + } + + // Check cache expiry + if (cache.ExpiresAt > 0 && now > cache.ExpiresAt) + { + return new LicenseValidationResult + { + IsValid = false, + Status = "cache_expired", + Message = "Cached license has expired.", + HardwareId = hwInfo.HardwareId + }; + } + + // Update max_seen_time + cache.MaxSeenTime = now; + StateStore.Save(productSlug, hwInfo.HardwareId, cache); + + return new LicenseValidationResult + { + IsValid = true, + Status = "valid_offline", + Message = "License validated via secure offline cache", + HardwareId = hwInfo.HardwareId, + IsCached = true, + ExpiresAt = cache.ExpiresAt + }; + } + + return new LicenseValidationResult + { + IsValid = false, + Status = "network_error", + Message = $"Server communication error and no valid cache available: {ex.Message}", + HardwareId = hwInfo.HardwareId + }; + } + + return new LicenseValidationResult + { + IsValid = false, + Status = "unknown_error", + Message = "Validation failed.", + HardwareId = hwInfo.HardwareId + }; + } + + public async Task DeactivateAsync(string productSlug, string licenseKey, string serverBaseUrl, string authToken = "") + { + 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"; + + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = content + }; + + if (!string.IsNullOrWhiteSpace(authToken)) + { + request.Headers.Add("X-Watchdog-Key", authToken); + request.Headers.Add("Authorization", $"Bearer {authToken}"); + } + + try + { + HttpResponseMessage response = await _httpClient.SendAsync(request); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs b/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs new file mode 100644 index 0000000..b65a230 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; + +namespace Deploymentcenter.Client; + +public static class LicenseConfig +{ + private static string? _storageDirectoryOverride; + private static string? _hardwareIdOverride; + + public static string? HardwareIdOverride + { + get + { + if (!string.IsNullOrWhiteSpace(_hardwareIdOverride)) + return _hardwareIdOverride; + + var envHwid = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_HWID") + ?? Environment.GetEnvironmentVariable("LICENSELABRADOR_HWID"); + if (!string.IsNullOrWhiteSpace(envHwid)) + return envHwid; + + return null; + } + set => _hardwareIdOverride = value; + } + + public static string GetStorageDirectory(string productSlug) + { + if (string.IsNullOrWhiteSpace(productSlug)) + productSlug = "default_app"; + + // 1. Explicitly set property + if (!string.IsNullOrWhiteSpace(_storageDirectoryOverride)) + return ValidateNonEmpty(_storageDirectoryOverride!); + + // 2. Environment Variable + var envDir = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_STORAGE_DIR") + ?? Environment.GetEnvironmentVariable("LICENSELABRADOR_STORAGE_DIR"); + if (!string.IsNullOrWhiteSpace(envDir)) + return ValidateNonEmpty(Path.Combine(envDir!, productSlug, "license")); + + // 3. Platform specific resolution + if (OperatingSystemHelpers.IsLinux() || OperatingSystemHelpers.IsMacOS()) + { + var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (!string.IsNullOrWhiteSpace(xdg)) + return ValidateNonEmpty(Path.Combine(xdg!, productSlug, "license")); + + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrWhiteSpace(home)) + return ValidateNonEmpty(Path.Combine(home!, ".config", productSlug, "license")); + } + else if (OperatingSystemHelpers.IsWindows()) + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + return ValidateNonEmpty(Path.Combine(appData, productSlug, "license")); + } + + // 4. Fallback to AppContext.BaseDirectory + var baseDir = AppContext.BaseDirectory; + if (!string.IsNullOrWhiteSpace(baseDir)) + return ValidateNonEmpty(Path.Combine(baseDir, "license")); + + throw new InvalidOperationException("Could not resolve valid non-empty StorageDirectory for licensing."); + } + + public static void SetStorageDirectory(string path) + { + _storageDirectoryOverride = path; + } + + private static string ValidateNonEmpty(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new InvalidOperationException("StorageDirectory resolved to an empty or invalid path."); + return path; + } +} diff --git a/client-dotnet/Deploymentcenter.Client/OperatingSystemHelpers.cs b/client-dotnet/Deploymentcenter.Client/OperatingSystemHelpers.cs new file mode 100644 index 0000000..7a99fbe --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/OperatingSystemHelpers.cs @@ -0,0 +1,29 @@ +using System.Runtime.InteropServices; + +namespace Deploymentcenter.Client; + +public static class OperatingSystemHelpers +{ + public static bool IsWindows() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + } + + public static bool IsLinux() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + } + + public static bool IsMacOS() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } + + public static string GetPlatformCode() + { + if (IsWindows()) return "win"; + if (IsLinux()) return "lin"; + if (IsMacOS()) return "mac"; + return "unk"; + } +} diff --git a/client-dotnet/Deploymentcenter.Client/StateStore.cs b/client-dotnet/Deploymentcenter.Client/StateStore.cs new file mode 100644 index 0000000..c03c856 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/StateStore.cs @@ -0,0 +1,288 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +#if NETSTANDARD2_0 +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; +#endif + +namespace Deploymentcenter.Client; + +public class LocalCacheData +{ + public int SchemaVersion { get; set; } = 2; + public string ProductSlug { get; set; } = string.Empty; + public string LicenseKey { get; set; } = string.Empty; + public string HardwareId { get; set; } = string.Empty; + public string Status { get; set; } = "invalid"; + public long IssuedAt { get; set; } + public long ExpiresAt { get; set; } + public long MaxSeenTime { get; set; } + public string Checksum { get; set; } = string.Empty; +} + +public static class StateStore +{ + private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32 + + public static LocalCacheData? Load(string productSlug, string hardwareId) + { + try + { + string dir = LicenseConfig.GetStorageDirectory(productSlug); + string statePath = Path.Combine(dir, "state.dat"); + + if (!File.Exists(statePath)) + return null; + + byte[] rawFileContent = File.ReadAllBytes(statePath); + if (rawFileContent.Length == 0) + return null; + + byte[] payloadBytes = rawFileContent; + + // Try DPAPI unwrap on Windows + if (OperatingSystemHelpers.IsWindows()) + { + try + { + payloadBytes = ProtectedData.Unprotect(rawFileContent, null, DataProtectionScope.CurrentUser); + } + catch + { + payloadBytes = rawFileContent; + } + } + + // Check LLS2 Magic Header + if (payloadBytes.Length >= 4 + 12 + 16 && StartsWithMagic(payloadBytes, Magic)) + { + byte[] key = DeriveKey(hardwareId, productSlug); + byte[]? jsonBytes = DecryptAesGcmEnvelope(payloadBytes, key); + if (jsonBytes == null) + return null; // Decryption/Auth failed -> Treat strictly as Cache Miss + + string jsonStr = Encoding.UTF8.GetString(jsonBytes); + var data = JsonSerializer.Deserialize(jsonStr); + + if (data == null || data.SchemaVersion != 2) + return null; // Incompatible schema -> Treat as Cache Miss + + return data; + } + + // Legacy Migration Check (non-LLS2 file) + try + { + string legacyJson = Encoding.UTF8.GetString(payloadBytes); + var legacyData = JsonSerializer.Deserialize(legacyJson); + if (legacyData != null) + { + legacyData.SchemaVersion = 2; + Save(productSlug, hardwareId, legacyData); + return legacyData; + } + } + catch { } + + return null; + } + catch + { + return null; + } + } + + public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData) + { + try + { + cacheData.SchemaVersion = 2; + string dir = LicenseConfig.GetStorageDirectory(productSlug); + Directory.CreateDirectory(dir); + string statePath = Path.Combine(dir, "state.dat"); + + string jsonStr = JsonSerializer.Serialize(cacheData); + byte[] jsonBytes = Encoding.UTF8.GetBytes(jsonStr); + + byte[] key = DeriveKey(hardwareId, productSlug); + byte[] envelopeBytes = EncryptAesGcmEnvelope(jsonBytes, key); + + byte[] finalFileBytes = envelopeBytes; + + // Wrap with DPAPI on Windows + if (OperatingSystemHelpers.IsWindows()) + { + try + { + finalFileBytes = ProtectedData.Protect(envelopeBytes, null, DataProtectionScope.CurrentUser); + } + catch + { + finalFileBytes = envelopeBytes; + } + } + + File.WriteAllBytes(statePath, finalFileBytes); + SetUnixPermissions(statePath, "600"); + return true; + } + catch + { + return false; + } + } + + private static bool StartsWithMagic(byte[] data, byte[] magic) + { + if (data.Length < magic.Length) return false; + for (int i = 0; i < magic.Length; i++) + { + if (data[i] != magic[i]) return false; + } + return true; + } + + private static byte[] DeriveKey(string hardwareId, string productSlug) + { + string ikmStr = $"{hardwareId}:{productSlug}"; + byte[] ikm = Encoding.UTF8.GetBytes(ikmStr); + byte[] salt = Encoding.UTF8.GetBytes("LicenseLabradorHKDFSaltV2"); + byte[] info = Encoding.UTF8.GetBytes("StateStoreEncryptionKey"); + +#if NET8_0_OR_GREATER + return HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, info); +#else + var hkdf = new HkdfBytesGenerator(new Org.BouncyCastle.Crypto.Digests.Sha256Digest()); + hkdf.Init(new HkdfParameters(ikm, salt, info)); + byte[] key = new byte[32]; + hkdf.GenerateBytes(key, 0, 32); + return key; +#endif + } + + private static byte[] EncryptAesGcmEnvelope(byte[] plaintext, byte[] key) + { + byte[] nonce = new byte[12]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(nonce); + } + +#if NET8_0_OR_GREATER + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag = new byte[16]; + using (var aes = new AesGcm(key, 16)) + { + aes.Encrypt(nonce, plaintext, ciphertext, tag); + } + + byte[] envelope = new byte[4 + 12 + ciphertext.Length + 16]; + Buffer.BlockCopy(Magic, 0, envelope, 0, 4); + Buffer.BlockCopy(nonce, 0, envelope, 4, 12); + Buffer.BlockCopy(ciphertext, 0, envelope, 16, ciphertext.Length); + Buffer.BlockCopy(tag, 0, envelope, 16 + ciphertext.Length, 16); + return envelope; +#else + var cipher = new GcmBlockCipher(new AesEngine()); + var parameters = new AeadParameters(new KeyParameter(key), 128, nonce); + cipher.Init(true, parameters); + + byte[] output = new byte[cipher.GetOutputSize(plaintext.Length)]; + int len = cipher.ProcessBytes(plaintext, 0, plaintext.Length, output, 0); + cipher.DoFinal(output, len); + + // output in BouncyCastle contains Ciphertext + 16-byte Tag + int cipherLen = output.Length - 16; + byte[] ciphertext = new byte[cipherLen]; + byte[] tag = new byte[16]; + Buffer.BlockCopy(output, 0, ciphertext, 0, cipherLen); + Buffer.BlockCopy(output, cipherLen, tag, 0, 16); + + byte[] envelope = new byte[4 + 12 + ciphertext.Length + 16]; + Buffer.BlockCopy(Magic, 0, envelope, 0, 4); + Buffer.BlockCopy(nonce, 0, envelope, 4, 12); + Buffer.BlockCopy(ciphertext, 0, envelope, 16, ciphertext.Length); + Buffer.BlockCopy(tag, 0, envelope, 16 + ciphertext.Length, 16); + return envelope; +#endif + } + + private static byte[]? DecryptAesGcmEnvelope(byte[] envelope, byte[] key) + { + try + { + if (envelope.Length < 4 + 12 + 16) + return null; + + byte[] nonce = new byte[12]; + Buffer.BlockCopy(envelope, 4, nonce, 0, 12); + + int cipherLen = envelope.Length - 4 - 12 - 16; + byte[] ciphertext = new byte[cipherLen]; + Buffer.BlockCopy(envelope, 16, ciphertext, 0, cipherLen); + + byte[] tag = new byte[16]; + Buffer.BlockCopy(envelope, 16 + cipherLen, tag, 0, 16); + +#if NET8_0_OR_GREATER + byte[] plaintext = new byte[cipherLen]; + using (var aes = new AesGcm(key, 16)) + { + aes.Decrypt(nonce, ciphertext, tag, plaintext); + } + return plaintext; +#else + var cipher = new GcmBlockCipher(new AesEngine()); + var parameters = new AeadParameters(new KeyParameter(key), 128, nonce); + cipher.Init(false, parameters); + + byte[] input = new byte[ciphertext.Length + 16]; + Buffer.BlockCopy(ciphertext, 0, input, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, input, ciphertext.Length, 16); + + byte[] plaintext = new byte[cipher.GetOutputSize(input.Length)]; + int len = cipher.ProcessBytes(input, 0, input.Length, plaintext, 0); + cipher.DoFinal(plaintext, len); + + return plaintext; +#endif + } + catch + { + return null; // Auth/Decryption failed + } + } + + private static void SetUnixPermissions(string filePath, string mode) + { + if (OperatingSystemHelpers.IsWindows()) + return; + +#if NET8_0_OR_GREATER + try + { + File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch { } +#else + try + { + var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "chmod", + Arguments = $"{mode} \"{filePath}\"", + UseShellExecute = false, + CreateNoWindow = true + }); + proc?.WaitForExit(); + } + catch { } +#endif + } +} diff --git a/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj b/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj index 936787b..c20b1f7 100644 --- a/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj +++ b/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj @@ -8,4 +8,8 @@ Deploymentcenter Test Suite + + + + diff --git a/client-dotnet/Deploymentcenter.TestClient/Program.cs b/client-dotnet/Deploymentcenter.TestClient/Program.cs index 231d75d..f5d4ecc 100644 --- a/client-dotnet/Deploymentcenter.TestClient/Program.cs +++ b/client-dotnet/Deploymentcenter.TestClient/Program.cs @@ -1,8 +1,10 @@ using System; +using System.IO; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Deploymentcenter.Client; namespace Deploymentcenter.TestClient; @@ -17,6 +19,13 @@ class Program static async Task Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; + + if (args.Length > 0) + { + await HandleCliArgsAsync(args); + return; + } + Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine("================================================="); Console.WriteLine(" 🚀 Deploymentcenter Unified Client Test Suite "); @@ -24,6 +33,8 @@ class Program Console.ResetColor(); Console.WriteLine($" Server Base URL: {BaseUrl}\n"); + await TestHardwareIdV2ModuleAsync(); + await TestStateStoreHardeningAsync(); await TestLicenseModuleAsync(); await TestWatchdogModuleAsync(); await TestUpdateServiceModuleAsync(); @@ -33,42 +44,176 @@ class Program Console.ResetColor(); } - private static async Task TestLicenseModuleAsync() + private static async Task HandleCliArgsAsync(string[] args) { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("1. Teste Module: Lizenzen (License Validation)..."); - Console.ResetColor(); + string cmd = args[0].ToLowerInvariant(); - try + if (cmd == "--license-status") { - var payload = new - { - product = "myapp", - license_key = "LLAB1-98A72-B3C4D-5E6F7-89012", - hardware_id = "HWID-TEST-CLI-CSHARP-01", - nonce = Guid.NewGuid().ToString("N"), - hostname = Environment.MachineName, - app_version = "1.0.0" - }; + var hwInfo = HardwareId.GetHardwareId("myapp"); + string storageDir = LicenseConfig.GetStorageDirectory("myapp"); + var cache = StateStore.Load("myapp", hwInfo.HardwareId); - string json = JsonSerializer.Serialize(payload); - var content = new StringContent(json, Encoding.UTF8, "application/json"); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("================================================="); + Console.WriteLine(" 🔑 Deploymentcenter Lizenz & HWID Status "); + Console.WriteLine("================================================="); + Console.ResetColor(); + Console.WriteLine($" Platform: {hwInfo.Platform.ToUpperInvariant()}"); + Console.WriteLine($" HWID v2: {hwInfo.HardwareId}"); + Console.WriteLine($" HWID Source: {hwInfo.HwidSource}"); + Console.WriteLine($" Legacy HWID (v1): {hwInfo.LegacyHardwareId}"); + Console.WriteLine($" Storage Directory:{storageDir}"); + Console.WriteLine($" Local Cache: {(cache != null ? $"VALID (Status: {cache.Status}, Schema: {cache.SchemaVersion})" : "NONE / MISSING")}"); + return; + } - HttpResponseMessage response = await Client.PostAsync($"{BaseUrl}/api/license/v1/validate", content); - string responseBody = await response.Content.ReadAsStringAsync(); + if (cmd == "--license-deactivate") + { + string key = args.Length > 1 ? args[1] : "LLAB1-98A72-B3C4D-5E6F7-89012"; + Console.WriteLine($"Deaktiviere Lizenz '{key}' für diesen Host..."); + var licClient = new LicenseClient(Client); + bool success = await licClient.DeactivateAsync("myapp", key, BaseUrl, "dc_shared_master_secret"); - Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}"); - Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}"); - - if (response.IsSuccessStatusCode && responseBody.Contains("\"status\": \"valid\"")) + if (success) { Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine(" ✔ Lizenzen Modul Test: GÜLTIG!"); + Console.WriteLine("✔ Lizenz-Aktivierung erfolgreich am Server aufgehoben!"); } else { Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(" ✖ Lizenzen Modul Test: Fehler oder Ungültig!"); + Console.WriteLine("✖ Fehler bei der Lizenz-Deaktivierung."); + } + Console.ResetColor(); + return; + } + + if (cmd == "--license-set-key") + { + if (args.Length < 2) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Fehler: Bitte Lizenzschlüssel angeben. Beispiel: --license-set-key LLAB1-XXXXX"); + Console.ResetColor(); + return; + } + string key = args[1].Trim(); + Console.WriteLine($"Prüfe neuen Lizenzschlüssel '{key}'..."); + var licClient = new LicenseClient(Client); + var res = await licClient.ValidateAsync("myapp", key, BaseUrl); + if (res.IsValid) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✔ Lizenz '{key}' erfolgreich aktiviert! Status: {res.Status}"); + } + else + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✖ Lizenzprüfung fehlgeschlagen: {res.Message} (Status: {res.Status})"); + } + Console.ResetColor(); + return; + } + + Console.WriteLine($"Unbekanntes Argument: {args[0]}"); + Console.WriteLine("Verfügbare Befehle:"); + Console.WriteLine(" --license-status"); + Console.WriteLine(" --license-deactivate [KEY]"); + Console.WriteLine(" --license-set-key "); + } + + private static Task TestHardwareIdV2ModuleAsync() + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("1. Teste Modul: HardwareId v2 Berechnungen & Quellenerkennung..."); + Console.ResetColor(); + + var hwInfo = HardwareId.GetHardwareId("myapp"); + Console.WriteLine($" Platform: {hwInfo.Platform}"); + Console.WriteLine($" Hardware ID v2: {hwInfo.HardwareId}"); + Console.WriteLine($" HWID Source: {hwInfo.HwidSource}"); + Console.WriteLine($" Legacy HWID v1: {hwInfo.LegacyHardwareId}"); + + if (hwInfo.HardwareId.StartsWith("2:") && hwInfo.HardwareId.Length == 70) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(" ✔ Hardware-ID v2 Format validiert! (2::<64 hex>)"); + } + else + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(" ✖ Fehlerhaftes Hardware-ID v2 Format!"); + } + Console.ResetColor(); + Console.WriteLine(); + return Task.CompletedTask; + } + + private static Task TestStateStoreHardeningAsync() + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("2. Teste Modul: StateStore LLS2 AES-GCM Verschlüsselung & Integrität..."); + Console.ResetColor(); + + string slug = "test_app_hardening"; + var hwInfo = HardwareId.GetHardwareId(slug); + + var testCache = new LocalCacheData + { + SchemaVersion = 2, + ProductSlug = slug, + LicenseKey = "LLAB-TEST-12345", + HardwareId = hwInfo.HardwareId, + Status = "valid", + IssuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds(), + MaxSeenTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Checksum = hwInfo.HardwareId + }; + + bool saved = StateStore.Save(slug, hwInfo.HardwareId, testCache); + var loaded = StateStore.Load(slug, hwInfo.HardwareId); + + if (saved && loaded != null && loaded.Status == "valid" && loaded.SchemaVersion == 2) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(" ✔ StateStore LLS2 AES-GCM Speicher & Entschlüsselung OK!"); + } + else + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(" ✖ Fehler beim StateStore Test!"); + } + Console.ResetColor(); + Console.WriteLine(); + return Task.CompletedTask; + } + + private static async Task TestLicenseModuleAsync() + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("3. Teste Modul: Lizenzen (Online Validation & v1 Migration)..."); + Console.ResetColor(); + + try + { + var licClient = new LicenseClient(Client); + var result = await licClient.ValidateAsync("myapp", "LLAB1-98A72-B3C4D-5E6F7-89012", BaseUrl); + + Console.WriteLine($" Status: {result.Status}"); + Console.WriteLine($" Hardware ID: {result.HardwareId}"); + Console.WriteLine($" Message: {result.Message}"); + + if (result.IsValid) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(" ✔ License Validation Server Test: ERFOLGREICH!"); + } + else + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" ✖ License Validation Server Test: {result.Message}"); } } catch (Exception ex) @@ -83,7 +228,7 @@ class Program private static async Task TestWatchdogModuleAsync() { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("2. Teste Module: Watchdog (Heartbeat Ping)..."); + Console.WriteLine("4. Teste Modul: Watchdog (Heartbeat Ping)..."); Console.ResetColor(); try @@ -97,7 +242,7 @@ class Program message = "C# Client Test Suite running smoothly", interval = 60, group = "TestRunner", - os = Environment.OSVersion.ToString() + os = OperatingSystemHelpers.GetPlatformCode() }; string json = JsonSerializer.Serialize(payload); @@ -113,7 +258,6 @@ class Program string responseBody = await response.Content.ReadAsStringAsync(); Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}"); - Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}"); if (response.IsSuccessStatusCode) { @@ -138,7 +282,7 @@ class Program private static async Task TestUpdateServiceModuleAsync() { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("3. Teste Module: UpdateService (Release Check)..."); + Console.WriteLine("5. Teste Modul: UpdateService (Release Check)..."); Console.ResetColor(); try @@ -147,7 +291,6 @@ class Program string responseBody = await response.Content.ReadAsStringAsync(); Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}"); - Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}"); if (response.IsSuccessStatusCode) { diff --git a/docs/LICENSE_INTEGRATION_GUIDE.md b/docs/LICENSE_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..2540c40 --- /dev/null +++ b/docs/LICENSE_INTEGRATION_GUIDE.md @@ -0,0 +1,125 @@ +# Deploymentcenter — Lizenzsystem Integration für KI-Agenten + +> **Zielgruppe**: KI-Agenten & Softwareentwickler +> **Gültig ab**: Hardware-ID v2 Specification (August 2026) +> **Plattformen**: Windows, Linux (inkl. systemd Services & Docker-Container), macOS + +--- + +## 1. Architektur & Konzepte + +Das Lizenzsystem von Deploymentcenter schützt Anwendungen über eine Kombination aus serverseitiger Validierung, plattformunabhängiger Hardware-ID v2 und einem gehärteten lokalen Cache (`LLS2` format with AES-GCM encryption). + +### 1.1 Hardware-ID v2 Format +Format: `2::<64-Hex-Zeichen>` + +Beispiele: +- `2:win:9f3ab7c1...` (Windows, `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`) +- `2:lin:41e0d5aa...` (Linux, `/etc/machine-id`) +- `2:lin:7c9182ff...` (Linux/Container via Umgebungsvariable `DEPLOYMENTCENTER_HWID`) + +### 1.2 Hash-Berechnung (KEIN MachineName im Hash) +`sha256("LicenseLabrador-HWID-v2" + "\n" + plattform + "\n" + quelle + "\n" + rohwert)` + +> **WICHTIG**: Der Rechnername steckt **nicht** im Hash. Ein Umbenennen der Maschine verändert die Hardware-ID nicht und verbraucht keine zusätzlichen Aktivierungsplätze. + +### 1.3 Quellen-Priorisierung je Plattform + +#### Windows: +1. `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (`machine-guid`) +2. Verkettete physische MAC-Adressen (`mac`) +3. Erzeugte Schlüsseldatei `machine.key` im StorageDirectory (`keyfile`) + +#### Linux: +1. `/etc/machine-id` (`machine-id`) — muss plausibel sein (Länge >= 16, nicht `uninitialized`, nicht nur Nullen). +2. `/var/lib/dbus/machine-id` (`dbus-machine-id`) +3. `/sys/class/dmi/id/product_uuid` (`dmi-uuid`) +4. Verkettete physische MAC-Adressen (`mac`) +5. Erzeugte Schlüsseldatei `machine.key` (`keyfile`) + +#### Container / Headless Overrides: +Wenn `DEPLOYMENTCENTER_HWID` oder `LICENSELABRADOR_HWID` gesetzt ist, gewinnt diese Variable Plattform-weit (`override`). + +--- + +## 2. Einbindung in C# (.NET Core / .NET 8+) + +Verwende das NuGet-Paket/Projekt `Deploymentcenter.Client` (Multi-Targeting `netstandard2.0;net8.0`). + +### 2.1 Initialisierung und Standard-Validierung + +```csharp +using System; +using System.Threading.Tasks; +using Deploymentcenter.Client; + +public class Program +{ + private static readonly string ServerUrl = "https://dc.mhdf.de"; + private static readonly string ProductSlug = "myapp"; // In dc_projects hinterlegter Slug + + public static async Task Main(string[] args) + { + // 1. CLI Schalter für Headless/Admin-Operationen abfangen + if (args.Length > 0 && args[0] == "--license-status") + { + var hwInfo = HardwareId.GetHardwareId(ProductSlug); + Console.WriteLine($"HWID v2: {hwInfo.HardwareId} ({hwInfo.HwidSource})"); + return; + } + + // 2. LicenseClient instanziieren + var client = new LicenseClient(); + string licenseKey = "LLAB1-98A72-B3C4D-5E6F7-89012"; + + // 3. Online-Validierung durchführen + LicenseValidationResult res = await client.ValidateAsync(ProductSlug, licenseKey, ServerUrl); + + if (res.IsValid) + { + Console.WriteLine($"[✔] Lizenz gültig! (Status: {res.Status}, Cached: {res.IsCached})"); + } + else + { + Console.WriteLine($"[✖] Lizenz ungültig: {res.Message}"); + Environment.Exit(1); + } + } +} +``` + +--- + +## 3. Zustandsspeicher (`StateStore.cs`) & Cache-Härtung + +- **Format**: File Envelope mit Magic `"LLS2"`, 12-Byte Nonce, AES-256-GCM Ciphertext und 16-Byte GCM Tag. +- **Schlüsselableitung**: HKDF-SHA256 aus `HardwareId` + `ProductSlug`. +- **Windows**: DPAPI-Zusatzhülle um AES-GCM Payload. +- **Linux**: Dateirechte `0600` (`chmod 600 state.dat`). +- **Sicherheitsvorgabe**: Kein Klartext-Rückfall! Beschädigte oder manipulierte Cache-Dateien werden strikt als Cache-Fehltreffer behandelt. + +--- + +## 4. Kopfloser Betrieb (Headless Services / systemd) + +Für Hintergrunddienste (ohne GUI) stehen folgende CLI-Schalter am Anwendungshost zur Verfügung: + +```bash +# Status der Hardware-ID und des lokalen Caches ausgeben +my-service --license-status + +# Lizenzschlüssel festlegen & aktivieren +my-service --license-set-key LLAB1-98A72-B3C4D-5E6F7-89012 + +# Aktivierung für diesen Host aufheben (Freigabe am Server) +my-service --license-deactivate +``` + +--- + +## 5. Migration v1 → v2 ohne Platzverlust + +Wenn ein bestehender Windows-Client auf Hardware-ID v2 aktualisiert wird: +- Der Client schickt `hardware_id` (v2) **und** `legacy_hardware_id` (v1) mit. +- Der Server findet die alte Aktivierung unter `legacy_hardware_id` und zieht den Datenbank-Eintrag lautlos auf v2 um. +- Es wird kein zusätzlicher Aktivierungsplatz verbraucht! diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d679611 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# Deploymentcenter — AI Agent Integration Guides + +Willkommen in der Entwickler- und Agenten-Dokumentation von **Deploymentcenter**. + +Diese Anleitungen sind speziell dafür strukturiert, KI-Agenten und Entwicklern klare, praxiserprobte Vorgaben zur Integration unserer zentralen Dienste bereitzustellen: + +- **[Lizenzsystem-Integration (Hardware-ID v2)](./LICENSE_INTEGRATION_GUIDE.md)**: Hardware-Anbindung, Lizenzschlüssel-Validierung, verschlüsselter Offline-Cache (`LLS2`), CLI-Befehle und Multi-Plattform-Betrieb (Windows & Linux / Docker). +- **[Watchdog-Integration (Heartbeat & Telemetrie)](./WATCHDOG_INTEGRATION_GUIDE.md)**: Überwachung von Anwendungen, Diensten und Infrastruktur-Knoten via Ping-API, Agent-Tokens und automatisiertem Heartbeat. + +--- + +## Modulübersicht + +| Modul | Hauptaufgabe | Endpunkte | .NET Client SDK | +| :--- | :--- | :--- | :--- | +| **Lizenzen** | Lizenzprüfung, Hardware-ID v2, Offline-Cache | `/api/license/v1/validate`
`/api/license/v1/deactivate` | `Deploymentcenter.Client` (`LicenseClient`, `HardwareId`) | +| **Watchdog** | Heartbeat-Monitoring, Status & Alerting | `/api/watchdog/v1/ping` | `HttpClient` + Header `X-Agent-Token` | +| **UpdateService** | Automatic Software Release Checks | `/api/updateservice/v1/check` | `HttpClient` GET Request | diff --git a/docs/WATCHDOG_INTEGRATION_GUIDE.md b/docs/WATCHDOG_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..8e58b14 --- /dev/null +++ b/docs/WATCHDOG_INTEGRATION_GUIDE.md @@ -0,0 +1,153 @@ +# Deploymentcenter — Watchdog Integration für KI-Agenten + +> **Zielgruppe**: KI-Agenten & Softwareentwickler +> **Zweck**: Einbindung von Heartbeat-Monitoring, Statusmeldungen und Telemetrie in Anwendungen & Serverdienste. + +--- + +## 1. Übersicht + +Der **Watchdog** in Deploymentcenter überwacht kontinuierlich den Zustand von Hosts, Diensten, Cronjobs und Proxmox-Hypervisoren. + +Anwendungen senden in regelmäßigen Abständen (standardmäßig alle 60 Sekunden) einen HTTP POST Ping an die Watchdog API. Ausbleibende Pings oder gemeldete Fehler erzeugen automatisch Warnungen im Admin-Dashboard. + +--- + +## 2. API Endpunkt & Authentifizierung + +- **URL**: `POST https://dc.mhdf.de/api/watchdog/v1/ping` +- **Content-Type**: `application/json` +- **Header**: `X-Agent-Token: ` + +### Request Body Schema (JSON) +```json +{ + "source": "srv-db-01", + "instance": "default", + "type": "heartbeat", + "status": "ok", + "message": "Service running smoothly", + "interval": 60, + "group": "Infrastructure", + "os": "Ubuntu 24.04 LTS" +} +``` + +#### Felder: +- `source` *(string, erforderlich)*: Eindeutiger Name des Dienstes oder Hostnames (z.B. `srv-db-01` oder `PolyTrader Worker`). +- `instance` *(string, optional)*: Instanzbezeichner (Standard: `default`). +- `type` *(string)*: `heartbeat`, `host`, `hypervisor_node` oder `guest`. +- `status` *(string)*: `ok`, `warning` oder `error`. +- `message` *(string, optional)*: Status- oder Fehlermeldung. +- `interval` *(int)*: Erwarteter Abstand in Sekunden zwischen zwei Pings (Standard: `60`). +- `group` *(string, optional)*: Gruppierung im Dashboard (z.B. `Applications`, `Infrastructure`). +- `os` *(string, optional)*: Betriebssystem-Name (z.B. `.NET 8 Service`, `Debian 12`). + +--- + +## 3. Implementierungsbeispiele + +### 3.1 C# (.NET Core / .NET 8+) + +```csharp +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +public class WatchdogHeartbeatService +{ + private static readonly HttpClient Client = new HttpClient(); + private static readonly string PingUrl = "https://dc.mhdf.de/api/watchdog/v1/ping"; + private static readonly string Token = "wd_live_token_infra_01_secure"; + + public static async Task SendPingAsync(string sourceName, string status = "ok", string message = "Service active") + { + var payload = new + { + source = sourceName, + instance = "default", + type = "heartbeat", + status = status, + message = message, + interval = 60, + group = "Services", + os = Environment.OSVersion.ToString() + }; + + string json = JsonSerializer.Serialize(payload); + var request = new HttpRequestMessage(HttpMethod.Post, PingUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + request.Headers.Add("X-Agent-Token", Token); + + try + { + HttpResponseMessage response = await Client.SendAsync(request); + if (response.IsSuccessStatusCode) + { + Console.WriteLine("[✔] Watchdog Heartbeat erfolgreich gesendet."); + } + } + catch (Exception ex) + { + Console.WriteLine($"[✖] Watchdog Ping Fehlgeschlagen: {ex.Message}"); + } + } +} +``` + +### 3.2 Python 3 + +```python +import requests + +WATCHDOG_URL = "https://dc.mhdf.de/api/watchdog/v1/ping" +AGENT_TOKEN = "wd_live_token_infra_01_secure" + +def send_heartbeat(source_name, status="ok", message="Python Background Task running"): + headers = { + "Content-Type": "application/json", + "X-Agent-Token": AGENT_TOKEN + } + payload = { + "source": source_name, + "instance": "default", + "type": "heartbeat", + "status": status, + "message": message, + "interval": 60, + "group": "Python Services" + } + try: + response = requests.post(WATCHDOG_URL, json=payload, headers=headers, timeout=10) + if response.status_code == 200: + print("[✔] Watchdog Ping OK") + except Exception as e: + print(f"[✖] Watchdog Ping Error: {e}") +``` + +### 3.3 Bash / Cronjob (Linux) + +```bash +#!/usr/bin/env bash + +WATCHDOG_URL="https://dc.mhdf.de/api/watchdog/v1/ping" +TOKEN="wd_live_token_infra_01_secure" +SOURCE="$(hostname)" + +curl -s -X POST "$WATCHDOG_URL" \ + -H "Content-Type: application/json" \ + -H "X-Agent-Token: $TOKEN" \ + -d '{"source": "'"$SOURCE"'", "status": "ok", "message": "Hourly Backup Task Completed", "interval": 3600}' +``` + +--- + +## 4. Best Practices für KI-Agenten + +1. **Heartbeat-Schleife**: Lasse in eigenständigen Hoster-Diensten einen periodischen Timer (z.B. `System.Threading.Timer` oder `BackgroundService`) alle 60s `SendPingAsync` aufrufen. +2. **Graceful Shutdown**: Sende beim Beenden des Dienstes einen Ping mit Status `stopped` oder `maintenance`. +3. **Fehlerbehandlung**: Fange Netzwerkfehler bei Watchdog-Pings stets stumm/abgefangen ab, damit der Ausfall des Monitoring-Servers niemals den Hauptanwendungsfluss unterbricht. diff --git a/public/index.php b/public/index.php index 105fb54..9f4de0c 100644 --- a/public/index.php +++ b/public/index.php @@ -262,6 +262,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } + // Release (Delete) Hardware Activation + if ($action === 'release_activation') { + $actId = (int)($_POST['activation_id'] ?? 0); + if ($actId > 0) { + $actStmt = $pdo->prepare('SELECT a.*, l.license_key FROM license_activations a JOIN license_licenses l ON a.license_id = l.id WHERE a.id = :id'); + $actStmt->execute([':id' => $actId]); + $actRow = $actStmt->fetch(); + if ($actRow) { + $delStmt = $pdo->prepare('DELETE FROM license_activations WHERE id = :id'); + $delStmt->execute([':id' => $actId]); + Audit::log($pdo, $_SESSION['username'] ?? 'admin', 'activation_released', [ + 'license_id' => $actRow['license_id'], + 'license_key' => $actRow['license_key'], + 'hardware_id' => $actRow['hardware_id'], + 'hostname' => $actRow['hostname'] + ]); + $msg = "Hardware-Aktivierung wurde freigegeben (gelöscht)."; + } + } + } + // Add Watchdog Host Machine / Application if ($action === 'add_watchdog_monitor') { $source = trim($_POST['source'] ?? ''); @@ -1188,11 +1209,12 @@ $baseUrl = $protocol . '://' . $host; Projekt Lizenzschlüssel - Hardware-ID + Hardware-ID / Quelle + Plattform / Ver. Hostname Zuletzt gesehen Status - Aktion + Aktionen @@ -1200,7 +1222,17 @@ $baseUrl = $protocol . '://' . $host; - + + + +
Quelle: + + + + + (v) + + @@ -1215,6 +1247,13 @@ $baseUrl = $protocol . '://' . $host; +
+ + + +
diff --git a/sql/migrations/v2_hardware_id.sql b/sql/migrations/v2_hardware_id.sql new file mode 100644 index 0000000..df09e1c --- /dev/null +++ b/sql/migrations/v2_hardware_id.sql @@ -0,0 +1,7 @@ +-- Migration: Hardware-ID v2 support for license_activations +-- Date: 2026-08-06 + +ALTER TABLE license_activations + ADD COLUMN hwid_version TINYINT NOT NULL DEFAULT 1 AFTER hardware_id, + ADD COLUMN hwid_source VARCHAR(32) NULL AFTER hwid_version, + ADD COLUMN platform VARCHAR(8) NULL AFTER hwid_source; diff --git a/sql/schema.sql b/sql/schema.sql index 3214ce6..4abd8de 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -70,6 +70,9 @@ CREATE TABLE license_activations ( id INT AUTO_INCREMENT PRIMARY KEY, license_id INT NOT NULL, hardware_id VARCHAR(128) NOT NULL, + hwid_version TINYINT NOT NULL DEFAULT 1, + hwid_source VARCHAR(32) NULL, + platform VARCHAR(8) NULL, hostname VARCHAR(190) NULL, app_version VARCHAR(64) NULL, first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/Modules/License/LicenseService.php b/src/Modules/License/LicenseService.php index 9b76402..cd5e1b9 100644 --- a/src/Modules/License/LicenseService.php +++ b/src/Modules/License/LicenseService.php @@ -15,12 +15,16 @@ class LicenseService public function validate(array $requestData, string $clientIp): array { - $productSlug = trim($requestData['product'] ?? ''); - $licenseKey = trim($requestData['license_key'] ?? ''); - $hardwareId = trim($requestData['hardware_id'] ?? ''); - $nonce = trim($requestData['nonce'] ?? ''); - $hostname = trim($requestData['hostname'] ?? ''); - $appVersion = trim($requestData['app_version'] ?? ''); + $productSlug = trim($requestData['product'] ?? ''); + $licenseKey = trim($requestData['license_key'] ?? ''); + $hardwareId = trim($requestData['hardware_id'] ?? ''); + $legacyHardwareId = trim($requestData['legacy_hardware_id'] ?? ''); + $hwidVersion = (int)($requestData['hwid_version'] ?? 1); + $hwidSource = trim($requestData['hwid_source'] ?? '') ?: null; + $platform = trim($requestData['platform'] ?? '') ?: null; + $nonce = trim($requestData['nonce'] ?? ''); + $hostname = trim($requestData['hostname'] ?? ''); + $appVersion = trim($requestData['app_version'] ?? ''); $issuedAt = time(); $endpoints = $this->getEndpoints(); @@ -96,10 +100,47 @@ class LicenseService } // 4. Activation management + // 4.1 Try match by hardware_id (v2) $stmt = $this->db->prepare('SELECT * FROM license_activations WHERE license_id = :lic_id AND hardware_id = :hw_id'); $stmt->execute([':lic_id' => $license['id'], ':hw_id' => $hardwareId]); $activation = $stmt->fetch(); + // 4.2 If not found by v2 hardware_id, try migration matching by legacy_hardware_id (v1) + if (!$activation && !empty($legacyHardwareId)) { + $legStmt = $this->db->prepare('SELECT * FROM license_activations WHERE license_id = :lic_id AND hardware_id = :legacy_hw_id'); + $legStmt->execute([':lic_id' => $license['id'], ':legacy_hw_id' => $legacyHardwareId]); + $legacyActivation = $legStmt->fetch(); + + if ($legacyActivation) { + // Migrate activation to v2 format + $migUpd = $this->db->prepare(' + UPDATE license_activations + SET hardware_id = :hw_id, hwid_version = :ver, hwid_source = :src, platform = :plat, last_seen = NOW(), hostname = :host, app_version = :app_ver + WHERE id = :id + '); + $migUpd->execute([ + ':hw_id' => $hardwareId, + ':ver' => $hwidVersion, + ':src' => $hwidSource, + ':plat' => $platform, + ':host' => $hostname, + ':app_ver' => $appVersion, + ':id' => $legacyActivation['id'] + ]); + + Audit::log($this->db, 'api', 'hwid_migrated', [ + 'license_id' => $license['id'], + 'old_hardware_id' => $legacyHardwareId, + 'new_hardware_id' => $hardwareId, + 'hwid_source' => $hwidSource, + 'platform' => $platform, + 'ip' => $clientIp + ]); + + $activation = $legacyActivation; + } + } + if ($activation) { if ((int)$activation['is_blocked'] === 1) { return $makePayload('revoked', [ @@ -107,8 +148,20 @@ class LicenseService 'message' => 'This hardware activation is blocked' ]); } - $upd = $this->db->prepare('UPDATE license_activations SET last_seen = NOW(), hostname = :host, app_version = :ver WHERE id = :id'); - $upd->execute([':host' => $hostname, ':ver' => $appVersion, ':id' => $activation['id']]); + $upd = $this->db->prepare(' + UPDATE license_activations + SET last_seen = NOW(), hostname = :host, app_version = :ver, hwid_version = :hw_ver, + hwid_source = COALESCE(:hw_src, hwid_source), platform = COALESCE(:plat, platform) + WHERE id = :id + '); + $upd->execute([ + ':host' => $hostname, + ':ver' => $appVersion, + ':hw_ver' => $hwidVersion, + ':hw_src' => $hwidSource, + ':plat' => $platform, + ':id' => $activation['id'] + ]); } else { $cntStmt = $this->db->prepare('SELECT COUNT(*) FROM license_activations WHERE license_id = :lic_id AND is_blocked = 0'); $cntStmt->execute([':lic_id' => $license['id']]); @@ -121,10 +174,16 @@ class LicenseService ]); } - $ins = $this->db->prepare('INSERT INTO license_activations (license_id, hardware_id, hostname, app_version) VALUES (:lic_id, :hw_id, :host, :ver)'); + $ins = $this->db->prepare(' + INSERT INTO license_activations (license_id, hardware_id, hwid_version, hwid_source, platform, hostname, app_version) + VALUES (:lic_id, :hw_id, :hw_ver, :hw_src, :plat, :host, :ver) + '); $ins->execute([ ':lic_id' => $license['id'], ':hw_id' => $hardwareId, + ':hw_ver' => $hwidVersion, + ':hw_src' => $hwidSource, + ':plat' => $platform, ':host' => $hostname, ':ver' => $appVersion ]);