feat(license): implement Hardware-ID v2, multi-platform Linux support, StateStore LLS2 hardening, and AI Agent docs
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<RootNamespace>Deploymentcenter.Client</RootNamespace>
|
||||||
|
<AssemblyName>Deploymentcenter.Client</AssemblyName>
|
||||||
|
<NoWarn>$(NoWarn);CA1416</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<string>();
|
||||||
|
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/<name>/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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Deploymentcenter.Client;
|
||||||
|
|
||||||
|
public interface ILicensePrompt
|
||||||
|
{
|
||||||
|
Task<string?> RequestLicenseKeyAsync(string productSlug);
|
||||||
|
void ShowLicenseError(string title, string message);
|
||||||
|
void ShowLicenseInfo(string title, string message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConsoleLicensePrompt : ILicensePrompt
|
||||||
|
{
|
||||||
|
public Task<string?> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LicenseValidationResult> 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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocalCacheData>(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<LocalCacheData>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,8 @@
|
|||||||
<AppName>Deploymentcenter Test Suite</AppName>
|
<AppName>Deploymentcenter Test Suite</AppName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Deploymentcenter.Client;
|
||||||
|
|
||||||
namespace Deploymentcenter.TestClient;
|
namespace Deploymentcenter.TestClient;
|
||||||
|
|
||||||
@@ -17,6 +19,13 @@ class Program
|
|||||||
static async Task Main(string[] args)
|
static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
Console.OutputEncoding = Encoding.UTF8;
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
|
|
||||||
|
if (args.Length > 0)
|
||||||
|
{
|
||||||
|
await HandleCliArgsAsync(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
Console.WriteLine("=================================================");
|
Console.WriteLine("=================================================");
|
||||||
Console.WriteLine(" 🚀 Deploymentcenter Unified Client Test Suite ");
|
Console.WriteLine(" 🚀 Deploymentcenter Unified Client Test Suite ");
|
||||||
@@ -24,6 +33,8 @@ class Program
|
|||||||
Console.ResetColor();
|
Console.ResetColor();
|
||||||
Console.WriteLine($" Server Base URL: {BaseUrl}\n");
|
Console.WriteLine($" Server Base URL: {BaseUrl}\n");
|
||||||
|
|
||||||
|
await TestHardwareIdV2ModuleAsync();
|
||||||
|
await TestStateStoreHardeningAsync();
|
||||||
await TestLicenseModuleAsync();
|
await TestLicenseModuleAsync();
|
||||||
await TestWatchdogModuleAsync();
|
await TestWatchdogModuleAsync();
|
||||||
await TestUpdateServiceModuleAsync();
|
await TestUpdateServiceModuleAsync();
|
||||||
@@ -33,42 +44,176 @@ class Program
|
|||||||
Console.ResetColor();
|
Console.ResetColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task TestLicenseModuleAsync()
|
private static async Task HandleCliArgsAsync(string[] args)
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
string cmd = args[0].ToLowerInvariant();
|
||||||
Console.WriteLine("1. Teste Module: Lizenzen (License Validation)...");
|
|
||||||
Console.ResetColor();
|
|
||||||
|
|
||||||
try
|
if (cmd == "--license-status")
|
||||||
{
|
{
|
||||||
var payload = new
|
var hwInfo = HardwareId.GetHardwareId("myapp");
|
||||||
{
|
string storageDir = LicenseConfig.GetStorageDirectory("myapp");
|
||||||
product = "myapp",
|
var cache = StateStore.Load("myapp", hwInfo.HardwareId);
|
||||||
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"
|
|
||||||
};
|
|
||||||
|
|
||||||
string json = JsonSerializer.Serialize(payload);
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
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);
|
if (cmd == "--license-deactivate")
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
{
|
||||||
|
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}");
|
if (success)
|
||||||
Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}");
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode && responseBody.Contains("\"status\": \"valid\""))
|
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Green;
|
Console.ForegroundColor = ConsoleColor.Green;
|
||||||
Console.WriteLine(" ✔ Lizenzen Modul Test: GÜLTIG!");
|
Console.WriteLine("✔ Lizenz-Aktivierung erfolgreich am Server aufgehoben!");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Red;
|
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 <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:<plat>:<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)
|
catch (Exception ex)
|
||||||
@@ -83,7 +228,7 @@ class Program
|
|||||||
private static async Task TestWatchdogModuleAsync()
|
private static async Task TestWatchdogModuleAsync()
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
Console.WriteLine("2. Teste Module: Watchdog (Heartbeat Ping)...");
|
Console.WriteLine("4. Teste Modul: Watchdog (Heartbeat Ping)...");
|
||||||
Console.ResetColor();
|
Console.ResetColor();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -97,7 +242,7 @@ class Program
|
|||||||
message = "C# Client Test Suite running smoothly",
|
message = "C# Client Test Suite running smoothly",
|
||||||
interval = 60,
|
interval = 60,
|
||||||
group = "TestRunner",
|
group = "TestRunner",
|
||||||
os = Environment.OSVersion.ToString()
|
os = OperatingSystemHelpers.GetPlatformCode()
|
||||||
};
|
};
|
||||||
|
|
||||||
string json = JsonSerializer.Serialize(payload);
|
string json = JsonSerializer.Serialize(payload);
|
||||||
@@ -113,7 +258,6 @@ class Program
|
|||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
|
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
|
||||||
Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}");
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -138,7 +282,7 @@ class Program
|
|||||||
private static async Task TestUpdateServiceModuleAsync()
|
private static async Task TestUpdateServiceModuleAsync()
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
Console.WriteLine("3. Teste Module: UpdateService (Release Check)...");
|
Console.WriteLine("5. Teste Modul: UpdateService (Release Check)...");
|
||||||
Console.ResetColor();
|
Console.ResetColor();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -147,7 +291,6 @@ class Program
|
|||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
|
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
|
||||||
Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}");
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:<plattform>:<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!
|
||||||
@@ -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`<br>`/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 |
|
||||||
@@ -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: <dein_watchdog_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.
|
||||||
+42
-3
@@ -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
|
// Add Watchdog Host Machine / Application
|
||||||
if ($action === 'add_watchdog_monitor') {
|
if ($action === 'add_watchdog_monitor') {
|
||||||
$source = trim($_POST['source'] ?? '');
|
$source = trim($_POST['source'] ?? '');
|
||||||
@@ -1188,11 +1209,12 @@ $baseUrl = $protocol . '://' . $host;
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Projekt</th>
|
<th>Projekt</th>
|
||||||
<th>Lizenzschlüssel</th>
|
<th>Lizenzschlüssel</th>
|
||||||
<th>Hardware-ID</th>
|
<th>Hardware-ID / Quelle</th>
|
||||||
|
<th>Plattform / Ver.</th>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>Zuletzt gesehen</th>
|
<th>Zuletzt gesehen</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Aktion</th>
|
<th>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1200,7 +1222,17 @@ $baseUrl = $protocol . '://' . $host;
|
|||||||
<tr>
|
<tr>
|
||||||
<td><?= htmlspecialchars($a['product_name']) ?></td>
|
<td><?= htmlspecialchars($a['product_name']) ?></td>
|
||||||
<td><code><?= htmlspecialchars($a['license_key']) ?></code></td>
|
<td><code><?= htmlspecialchars($a['license_key']) ?></code></td>
|
||||||
<td><code><?= htmlspecialchars($a['hardware_id']) ?></code></td>
|
<td>
|
||||||
|
<code><?= htmlspecialchars($a['hardware_id']) ?></code>
|
||||||
|
<?php if (!empty($a['hwid_source'])): ?>
|
||||||
|
<br><small style="color:var(--text-muted);">Quelle: <?= htmlspecialchars($a['hwid_source']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" style="background:var(--bg-card); border:1px solid var(--border-color); color:var(--text-main);">
|
||||||
|
<?= htmlspecialchars(strtoupper($a['platform'] ?? 'WIN')) ?> (v<?= (int)($a['hwid_version'] ?? 1) ?>)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td><?= htmlspecialchars($a['hostname'] ?? '-') ?></td>
|
<td><?= htmlspecialchars($a['hostname'] ?? '-') ?></td>
|
||||||
<td><?= htmlspecialchars($a['last_seen']) ?></td>
|
<td><?= htmlspecialchars($a['last_seen']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -1215,6 +1247,13 @@ $baseUrl = $protocol . '://' . $host;
|
|||||||
<?= $a['is_blocked'] ? 'Entsperren' : 'Sperren' ?>
|
<?= $a['is_blocked'] ? 'Entsperren' : 'Sperren' ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="POST" action="index.php#sub-license-details" style="display:inline" onsubmit="return confirm('Möchtest du diese Aktivierung wirklich freigeben (löschen)?');">
|
||||||
|
<input type="hidden" name="action" value="release_activation">
|
||||||
|
<input type="hidden" name="activation_id" value="<?= $a['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" style="margin-left:4px;">
|
||||||
|
Freigeben
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -70,6 +70,9 @@ CREATE TABLE license_activations (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
license_id INT NOT NULL,
|
license_id INT NOT NULL,
|
||||||
hardware_id VARCHAR(128) 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,
|
hostname VARCHAR(190) NULL,
|
||||||
app_version VARCHAR(64) NULL,
|
app_version VARCHAR(64) NULL,
|
||||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ class LicenseService
|
|||||||
|
|
||||||
public function validate(array $requestData, string $clientIp): array
|
public function validate(array $requestData, string $clientIp): array
|
||||||
{
|
{
|
||||||
$productSlug = trim($requestData['product'] ?? '');
|
$productSlug = trim($requestData['product'] ?? '');
|
||||||
$licenseKey = trim($requestData['license_key'] ?? '');
|
$licenseKey = trim($requestData['license_key'] ?? '');
|
||||||
$hardwareId = trim($requestData['hardware_id'] ?? '');
|
$hardwareId = trim($requestData['hardware_id'] ?? '');
|
||||||
$nonce = trim($requestData['nonce'] ?? '');
|
$legacyHardwareId = trim($requestData['legacy_hardware_id'] ?? '');
|
||||||
$hostname = trim($requestData['hostname'] ?? '');
|
$hwidVersion = (int)($requestData['hwid_version'] ?? 1);
|
||||||
$appVersion = trim($requestData['app_version'] ?? '');
|
$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();
|
$issuedAt = time();
|
||||||
$endpoints = $this->getEndpoints();
|
$endpoints = $this->getEndpoints();
|
||||||
@@ -96,10 +100,47 @@ class LicenseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Activation management
|
// 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 = $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]);
|
$stmt->execute([':lic_id' => $license['id'], ':hw_id' => $hardwareId]);
|
||||||
$activation = $stmt->fetch();
|
$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 ($activation) {
|
||||||
if ((int)$activation['is_blocked'] === 1) {
|
if ((int)$activation['is_blocked'] === 1) {
|
||||||
return $makePayload('revoked', [
|
return $makePayload('revoked', [
|
||||||
@@ -107,8 +148,20 @@ class LicenseService
|
|||||||
'message' => 'This hardware activation is blocked'
|
'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 = $this->db->prepare('
|
||||||
$upd->execute([':host' => $hostname, ':ver' => $appVersion, ':id' => $activation['id']]);
|
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 {
|
} else {
|
||||||
$cntStmt = $this->db->prepare('SELECT COUNT(*) FROM license_activations WHERE license_id = :lic_id AND is_blocked = 0');
|
$cntStmt = $this->db->prepare('SELECT COUNT(*) FROM license_activations WHERE license_id = :lic_id AND is_blocked = 0');
|
||||||
$cntStmt->execute([':lic_id' => $license['id']]);
|
$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([
|
$ins->execute([
|
||||||
':lic_id' => $license['id'],
|
':lic_id' => $license['id'],
|
||||||
':hw_id' => $hardwareId,
|
':hw_id' => $hardwareId,
|
||||||
|
':hw_ver' => $hwidVersion,
|
||||||
|
':hw_src' => $hwidSource,
|
||||||
|
':plat' => $platform,
|
||||||
':host' => $hostname,
|
':host' => $hostname,
|
||||||
':ver' => $appVersion
|
':ver' => $appVersion
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user