diff --git a/.gitignore b/.gitignore index edc16d3..ed038a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .deploy_cache.json scratch/ +# Laufzeitsperren der CLI-Skripte +var/*.lock *.bak .DS_Store bin/ diff --git a/.htaccess b/.htaccess index 08f0e9a..ec5f0e1 100644 --- a/.htaccess +++ b/.htaccess @@ -56,7 +56,7 @@ Options -Indexes # ------------------------------------------------------------------ # 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte # ------------------------------------------------------------------ - RewriteRule ^(config|src|sql|scripts|var|client-dotnet)(/|$) - [F,L] + RewriteRule ^(config|src|sql|scripts|cli|var|client-dotnet)(/|$) - [F,L] RewriteRule ^Serverdaten\.txt$ - [F,L] diff --git a/cli/.htaccess b/cli/.htaccess new file mode 100644 index 0000000..d349c3f --- /dev/null +++ b/cli/.htaccess @@ -0,0 +1,14 @@ +# Kommandozeilenskripte - kein Webzugriff. +# +# Zweite Verteidigungslinie. Die erste ist die SAPI-Pruefung in den Skripten +# selbst: sie wirkt auch dann, wenn mod_rewrite, AllowOverride oder diese +# Datei nicht greifen. + + + Require all denied + + + + Order allow,deny + Deny from all + diff --git a/cli/tick.php b/cli/tick.php new file mode 100644 index 0000000..83a3c32 --- /dev/null +++ b/cli/tick.php @@ -0,0 +1,261 @@ +#!/usr/bin/env php + $e->getMessage(), + 'file' => $e->getFile() . ':' . $e->getLine(), + ]); + + fwrite(STDERR, sprintf( + "[FEHLER] %s: %s\n %s:%d\n", + get_class($e), + $e->getMessage(), + $e->getFile(), + $e->getLine() + )); + + exit(1); +}); + +$options = parseArguments($argv); + +if ($options['help']) { + printUsage(); + exit(0); +} + +// --------------------------------------------------------------------------- +// Ueberlappende Laeufe verhindern. +// +// Bei minuetlichem Cron und einem Lauf, der laenger als eine Minute braucht - +// etwa weil die Datenbank klemmt oder viele Webhooks zugestellt werden - +// starten sonst mehrere Evaluatoren gleichzeitig. Die wuerden dieselben +// Zustandswechsel doppelt protokollieren und doppelt alarmieren. +// --------------------------------------------------------------------------- +$lockHandle = null; + +if (!$options['no-lock']) { + $lockFile = DC_VAR . '/watchdog-tick.lock'; + + if (!is_dir(DC_VAR)) { + @mkdir(DC_VAR, 0775, true); + } + + $lockHandle = @fopen($lockFile, 'c'); + + if ($lockHandle === false) { + fwrite(STDERR, "[FEHLER] Sperrdatei nicht beschreibbar: {$lockFile}\n"); + exit(1); + } + + if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { + // Kein Fehler, sondern der Normalfall bei einem langsamen Vorlauf. + if (!$options['quiet']) { + fwrite(STDERR, "[UEBERSPRUNGEN] Es laeuft bereits ein Tick.\n"); + } + exit(2); + } + + // Der Rueckgabewert von ftruncate ist hier ohne Belang: die Datei traegt + // nur die PID zur Fehlersuche, ihr Inhalt steuert nichts. + ftruncate($lockHandle, 0); + fwrite($lockHandle, (string)getmypid()); + fflush($lockHandle); +} + +// Kein try/finally um den Lauf: exit() fuehrt in PHP keine finally-Bloecke aus, +// die Freigabe waere dort also nur zum Schein notiert. Der Handler oben beendet +// den Prozess bei einer Exception - dabei gibt das Betriebssystem die Sperre +// ohnehin frei. Fuer den regulaeren Weg steht die Freigabe am Ende. +$startedAt = microtime(true); +$db = Db::init(); +$result = Evaluator::run($db); +$wallMs = (int)round((microtime(true) - $startedAt) * 1000); + +if ($options['json']) { + echo json_encode( + $result + ['wall_ms' => $wallMs], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ), PHP_EOL; +} elseif (!($options['quiet'] && $result['changed'] === 0)) { + // Im Cron soll nur etwas ankommen, wenn es etwas zu berichten gibt - + // sonst erzeugt ein minuetlicher Job 1440 Mails am Tag. + printSummary($result, $wallMs); +} + +releaseLock($lockHandle); +exit(0); + +// =========================================================================== + +/** + * @param list $argv + * @return array{help:bool,json:bool,quiet:bool,no-lock:bool} + */ +function parseArguments(array $argv): array +{ + $options = ['help' => false, 'json' => false, 'quiet' => false, 'no-lock' => false]; + + foreach (array_slice($argv, 1) as $arg) { + switch ($arg) { + case '-h': + case '--help': + $options['help'] = true; + break; + case '-j': + case '--json': + $options['json'] = true; + break; + case '-q': + case '--quiet': + $options['quiet'] = true; + break; + case '--no-lock': + $options['no-lock'] = true; + break; + default: + fwrite(STDERR, "Unbekannte Option: {$arg}\n\n"); + printUsage(); + exit(1); + } + } + + return $options; +} + +/** + * Gibt die Sperre frei. Ohne aktive Sperre ein No-Op. + * + * @param resource|null $handle + */ +function releaseLock($handle): void +{ + if ($handle === null) { + return; + } + + flock($handle, LOCK_UN); + fclose($handle); +} + +/** @param array $result */ +function printSummary(array $result, int $wallMs): void +{ + printf( + "Watchdog-Tick: %d Monitor(e) geprueft, %d Zustandswechsel in %d ms (gesamt %d ms).\n", + $result['checked'], + $result['changed'], + $result['duration_ms'], + $wallMs + ); + + foreach ($result['changes'] as $change) { + printf( + " %-28s %s -> %-8s %s%s\n", + $change['source'], + $change['from'], + $change['to'], + $change['reason'], + $change['suppressed'] !== null + ? sprintf(' [Alarm unterdrueckt, %s ist selbst unten]', $change['suppressed']) + : '' + ); + } + + if (($result['released_leases'] ?? 0) > 0) { + printf(" %d abgelaufene Bugtracker-Lease(s) freigegeben.\n", $result['released_leases']); + } + + if (($result['purged_metrics'] ?? 0) > 0) { + printf(" %d alte Metrikwerte abgeraeumt.\n", $result['purged_metrics']); + } +} + +function printUsage(): void +{ + echo << - - + + + + true + BuildInfo + + + + - $(IntermediateOutputPath)BuildInfo.g.cs + + $(RootNamespace) + $(MSBuildProjectName) + + $(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs $([System.DateTime]::UtcNow.ToString("o")) - $(Version) - 1.0.0 + + + $(Version) + $(AssemblyVersion) + 0.0.0 + prod - - + + + - - + + + - UNKNOWN_COMMIT - UNKNOWN + UNKNOWN_COMMIT + UNKNOWN + + $(DcGitCommitLong.Trim()) + $(DcGitCommitShort.Trim()) - - + + - + + - + + + + + + + + - - - - - + - + + diff --git a/client-dotnet/Deploymentcenter.Client/GlobMatcher.cs b/client-dotnet/Deploymentcenter.Client/GlobMatcher.cs new file mode 100644 index 0000000..28a5d21 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/GlobMatcher.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace Deploymentcenter.Client +{ + /// + /// Abgleich von Pfaden gegen Glob-Muster. + /// + /// Der Packager verstand zuvor nur "*.endung" und exakte Namen. Muster wie + /// "logs/**" oder "wwwroot/cache/*" trafen deshalb nie zu - sie standen in + /// der mitgelieferten Beispielkonfiguration und weckten den Eindruck, die + /// betreffenden Dateien seien ausgeschlossen. Ein Konfigurationsfile, das + /// so unbemerkt ins Paket rutscht, ueberschreibt beim naechsten Update die + /// Einstellungen jedes Zielsystems. + /// + /// Regeln, angelehnt an .gitignore: + /// * trifft beliebig viele Zeichen ausser dem Trenner / + /// ** trifft beliebig viele Zeichen einschliesslich / + /// ? trifft genau ein Zeichen ausser / + /// Muster ohne / werden gegen den Dateinamen geprueft, + /// Muster mit / gegen den vollstaendigen relativen Pfad. + /// Ein Muster, das auf / endet, trifft alles unterhalb dieses Ordners. + /// + /// Verglichen wird ohne Beachtung der Gross-/Kleinschreibung: Ziel sind + /// Windows- und Linux-Systeme gleichermassen, und ein Muster, das nur auf + /// einer der beiden Plattformen greift, ist gefaehrlicher als ein Muster, + /// das etwas zu viel trifft. + /// + public static class GlobMatcher + { + private static readonly Dictionary Cache = new Dictionary(StringComparer.Ordinal); + private static readonly object CacheLock = new object(); + + /// + /// Prueft einen relativen Pfad gegen eine Liste von Mustern. + /// Leere Listen treffen nie. + /// + public static bool IsMatch(string relativePath, IEnumerable? patterns) + { + if (patterns == null || string.IsNullOrEmpty(relativePath)) + return false; + + string normalized = Normalize(relativePath); + + foreach (var pattern in patterns) + { + if (IsMatch(normalized, pattern, alreadyNormalized: true)) + return true; + } + + return false; + } + + /// Prueft einen relativen Pfad gegen ein einzelnes Muster. + public static bool IsMatch(string relativePath, string? pattern, bool alreadyNormalized = false) + { + if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrEmpty(relativePath)) + return false; + + string path = alreadyNormalized ? relativePath : Normalize(relativePath); + string trimmed = pattern!.Trim(); + + if (trimmed.Length == 0) + return false; + + // Ein fuehrendes ./ oder / bedeutet "ab Wurzel" und ist fuer den + // Vergleich mit einem ohnehin relativen Pfad ohne Bedeutung. + if (trimmed.StartsWith("./", StringComparison.Ordinal)) + trimmed = trimmed.Substring(2); + else if (trimmed.StartsWith("/", StringComparison.Ordinal)) + trimmed = trimmed.Substring(1); + + // "logs/" meint alles unterhalb von logs. + if (trimmed.EndsWith("/", StringComparison.Ordinal)) + trimmed += "**"; + + var regex = GetRegex(trimmed); + + // Muster ohne Trenner gelten fuer den Dateinamen an beliebiger + // Stelle im Baum - "*.pdb" soll auch runtimes/x/y.pdb treffen. + if (trimmed.IndexOf('/') < 0) + { + int slash = path.LastIndexOf('/'); + string fileName = slash >= 0 ? path.Substring(slash + 1) : path; + return regex.IsMatch(fileName); + } + + return regex.IsMatch(path); + } + + /// Vereinheitlicht Trenner und entfernt ein fuehrendes ./ + public static string Normalize(string path) + { + string value = (path ?? string.Empty).Replace('\\', '/').TrimStart(); + + if (value.StartsWith("./", StringComparison.Ordinal)) + value = value.Substring(2); + + return value; + } + + private static Regex GetRegex(string pattern) + { + lock (CacheLock) + { + if (Cache.TryGetValue(pattern, out var cached)) + return cached; + + var regex = new Regex( + "^" + Translate(pattern) + "$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Die Musterlisten stammen aus Konfigurationsdateien und sind + // klein; die Obergrenze verhindert nur unbegrenztes Wachsen, + // falls doch einmal dynamisch erzeugte Muster hereinkommen. + if (Cache.Count < 512) + Cache[pattern] = regex; + + return regex; + } + } + + /// Uebersetzt ein Glob-Muster in einen regulaeren Ausdruck. + private static string Translate(string pattern) + { + var builder = new StringBuilder(); + + for (int i = 0; i < pattern.Length; i++) + { + char c = pattern[i]; + + switch (c) + { + case '*': + bool doubleStar = i + 1 < pattern.Length && pattern[i + 1] == '*'; + if (doubleStar) + { + i++; + + // "a/**/b" muss auch "a/b" treffen, sonst waere ein + // Muster wie "logs/**" auf den Ordner selbst blind. + if (i + 1 < pattern.Length && pattern[i + 1] == '/') + { + i++; + builder.Append("(?:.*/)?"); + } + else + { + builder.Append(".*"); + } + } + else + { + builder.Append("[^/]*"); + } + break; + + case '?': + builder.Append("[^/]"); + break; + + default: + builder.Append(Regex.Escape(c.ToString())); + break; + } + } + + return builder.ToString(); + } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/LicenseClient.cs b/client-dotnet/Deploymentcenter.Client/LicenseClient.cs index 9f5f3b1..6c7f137 100644 --- a/client-dotnet/Deploymentcenter.Client/LicenseClient.cs +++ b/client-dotnet/Deploymentcenter.Client/LicenseClient.cs @@ -1,7 +1,9 @@ using System; using System.Net.Http; +using System.Reflection; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; namespace Deploymentcenter.Client; @@ -13,25 +15,88 @@ public class LicenseValidationResult public string Message { get; set; } = string.Empty; public string HardwareId { get; set; } = string.Empty; public bool IsCached { get; set; } + + /// Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches. public long? ExpiresAt { get; set; } + + /// + /// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client + /// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft. + /// + public long? CacheExpiresAt { get; set; } + + /// + /// Wahr, wenn kein Lizenzurteil vorliegt, sondern nur die Verbindung zum + /// Server gescheitert ist (Netzfehler, HTTP 429/5xx, unlesbare Antwort). + /// + /// Ein solcher Zustand darf eine Anwendung nicht beenden. Nur ein vom + /// Server geliefertes Urteil (revoked, expired, not_found, + /// activation_limit, suspended) ist endgueltig. + /// + public bool IsTransient { get; set; } } public class LicenseClient { + /// + /// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf + /// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version. + /// + public static string? DefaultAppVersion { get; set; } + + /// + /// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie + /// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der + /// Server nicht antwortete. + /// + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15); + + private static readonly Lazy SharedHttpClient = new Lazy( + () => new HttpClient { Timeout = DefaultTimeout }, + LazyThreadSafetyMode.ExecutionAndPublication); + + /// Standard-Gnadenfrist, wenn der Server keine TTL mitschickt. + private const int FallbackCacheTtlHours = 168; + private readonly HttpClient _httpClient; private readonly ILicensePrompt _prompt; + /// + /// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit + /// verwendet. Ein uebergebener HttpClient + /// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer. + /// public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null) { - _httpClient = httpClient ?? new HttpClient(); + _httpClient = httpClient ?? SharedHttpClient.Value; _prompt = prompt ?? new ConsoleLicensePrompt(); } - public async Task ValidateAsync(string productSlug, string licenseKey, string serverBaseUrl) + public Task ValidateAsync( + string productSlug, + string licenseKey, + string serverBaseUrl, + CancellationToken cancellationToken) + => ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken); + + /// + /// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des + /// Deploymentcenters. Ohne Angabe wird und + /// danach die Version der Startassembly verwendet. + /// + public async Task ValidateAsync( + string productSlug, + string licenseKey, + string serverBaseUrl, + string? appVersion = null, + CancellationToken cancellationToken = default) { var hwInfo = HardwareId.GetHardwareId(productSlug); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string? failureReason = null; + HttpResponseMessage? response = null; + try { var payload = new @@ -44,7 +109,7 @@ public class LicenseClient hwid_source = hwInfo.HwidSource, platform = hwInfo.Platform, hostname = Environment.MachineName, - app_version = "1.0.0", + app_version = ResolveAppVersion(appVersion), nonce = Guid.NewGuid().ToString("N") }; @@ -52,122 +117,318 @@ public class LicenseClient var content = new StringContent(jsonStr, Encoding.UTF8, "application/json"); string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate"; - HttpResponseMessage response = await _httpClient.PostAsync(endpoint, content); - if (response.IsSuccessStatusCode) - { - string resBody = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(resBody); - var root = doc.RootElement; - - string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown"; - string message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : ""; - long? expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number ? eProp.GetInt64() : null; - - if (status.Equals("valid", StringComparison.OrdinalIgnoreCase)) - { - // Save encrypted local cache - var cache = new LocalCacheData - { - SchemaVersion = 2, - ProductSlug = productSlug, - LicenseKey = licenseKey, - HardwareId = hwInfo.HardwareId, - Status = "valid", - IssuedAt = now, - ExpiresAt = expiresAt ?? (now + 7 * 86400), - MaxSeenTime = now, - Checksum = hwInfo.HardwareId - }; - - StateStore.Save(productSlug, hwInfo.HardwareId, cache); - - return new LicenseValidationResult - { - IsValid = true, - Status = status, - Message = message, - HardwareId = hwInfo.HardwareId, - IsCached = false, - ExpiresAt = expiresAt - }; - } - else - { - return new LicenseValidationResult - { - IsValid = false, - Status = status, - Message = message, - HardwareId = hwInfo.HardwareId, - IsCached = false - }; - } - } + response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { - // Server request failed -> Fall back to encrypted offline cache - var cache = StateStore.Load(productSlug, hwInfo.HardwareId); - if (cache != null && cache.Status == "valid") + failureReason = ex.Message; + } + + using (response) + { + if (response != null) { - // Check time-rollback protection - if (now < cache.MaxSeenTime) + if (response.IsSuccessStatusCode) { - return new LicenseValidationResult + string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now); + + if (verdict != null) { - IsValid = false, - Status = "clock_rollback", - Message = "System clock rollback detected! Online verification required.", - HardwareId = hwInfo.HardwareId - }; + return verdict; + } + + // Erfolgreiche Antwort, aber kein lesbares Urteil - etwa die + // Fehlerseite eines Proxys mit Statuscode 200. Das ist keine + // Aussage ueber die Lizenz, also wird sie auch nicht als + // solche behandelt. + failureReason = "Antwort des Servers war nicht auswertbar"; } - - // Check cache expiry - if (cache.ExpiresAt > 0 && now > cache.ExpiresAt) + else { - return new LicenseValidationResult - { - IsValid = false, - Status = "cache_expired", - Message = "Cached license has expired.", - HardwareId = hwInfo.HardwareId - }; + // WICHTIG: Ein HTTP-Fehler ist kein Lizenzurteil. + // + // Frueher lag der Cache-Zweig ausschliesslich im catch. + // Ein 429 (Drosselung) oder 500 warf keine Exception, + // sondern fiel aus dem Erfolgszweig heraus und endete als + // "unknown_error" - ohne den Cache auch nur zu befragen. + // Ein Serverfehler entzog damit die Lizenz, ein gezogenes + // Netzkabel nicht. Jetzt fuehrt jeder Nicht-Erfolg in + // denselben Offline-Zweig. + failureReason = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim(); } + } + } - // Update max_seen_time - cache.MaxSeenTime = now; - StateStore.Save(productSlug, hwInfo.HardwareId, cache); + return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar"); + } - return new LicenseValidationResult - { - IsValid = true, - Status = "valid_offline", - Message = "License validated via secure offline cache", - HardwareId = hwInfo.HardwareId, - IsCached = true, - ExpiresAt = cache.ExpiresAt - }; + /// + /// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares + /// Lizenzurteil enthaelt - dann greift der Offline-Zweig. + /// + private static LicenseValidationResult? TryReadVerdict( + string resBody, + string productSlug, + string licenseKey, + HardwareIdResult hwInfo, + long now) + { + string status; + string message; + long? expiresAt; + int cacheTtlHours; + + try + { + using var doc = JsonDocument.Parse(resBody); + var root = doc.RootElement; + + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out var sProp)) + { + return null; } + status = sProp.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(status)) + { + return null; + } + + message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : ""; + expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number + ? eProp.GetInt64() + : (long?)null; + + // Der Server bestimmt die Laenge der Offline-Gnadenfrist je Projekt. + // Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum + // der Lizenz eingetragen - bei einer Lizenz bis 2040 war die Frist + // praktisch unbegrenzt. + cacheTtlHours = root.TryGetProperty("cache_ttl_hours", out var tProp) && tProp.ValueKind == JsonValueKind.Number + ? tProp.GetInt32() + : FallbackCacheTtlHours; + } + catch (JsonException) + { + return null; + } + + if (cacheTtlHours <= 0) + { + cacheTtlHours = FallbackCacheTtlHours; + } + + if (!status.Equals("valid", StringComparison.OrdinalIgnoreCase)) + { return new LicenseValidationResult { IsValid = false, - Status = "network_error", - Message = $"Server communication error and no valid cache available: {ex.Message}", - HardwareId = hwInfo.HardwareId + Status = status, + Message = message, + HardwareId = hwInfo.HardwareId, + IsCached = false, + ExpiresAt = expiresAt, + IsTransient = false + }; + } + + // Die Gnadenfrist endet mit der TTL - spaetestens aber mit der Lizenz. + long cacheExpiresAt = now + (long)cacheTtlHours * 3600L; + if (expiresAt.HasValue && expiresAt.Value > 0 && expiresAt.Value < cacheExpiresAt) + { + cacheExpiresAt = expiresAt.Value; + } + + var cache = new LocalCacheData + { + SchemaVersion = StateStore.CurrentSchemaVersion, + ProductSlug = productSlug, + LicenseKey = licenseKey, + HardwareId = hwInfo.HardwareId, + Status = "valid", + IssuedAt = now, + ExpiresAt = expiresAt ?? 0, + CacheExpiresAt = cacheExpiresAt, + CacheTtlHours = cacheTtlHours, + MaxSeenTime = now, + Checksum = hwInfo.HardwareId + }; + + StateStore.Save(productSlug, hwInfo.HardwareId, cache); + + return new LicenseValidationResult + { + IsValid = true, + Status = status, + Message = message, + HardwareId = hwInfo.HardwareId, + IsCached = false, + ExpiresAt = expiresAt, + CacheExpiresAt = cacheExpiresAt, + IsTransient = false + }; + } + + /// + /// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil + /// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort. + /// + private static LicenseValidationResult OfflineFallback( + string productSlug, + HardwareIdResult hwInfo, + long now, + string reason) + { + var cache = StateStore.Load(productSlug, hwInfo.HardwareId); + + if (cache != null && cache.Status == "valid") + { + // Rueckdrehen der Systemuhr erkennen + if (now < cache.MaxSeenTime) + { + return new LicenseValidationResult + { + IsValid = false, + Status = "clock_rollback", + Message = "System clock rollback detected! Online verification required.", + HardwareId = hwInfo.HardwareId, + IsTransient = false + }; + } + + long cacheExpiresAt = ResolveCacheExpiry(cache); + + if (cacheExpiresAt > 0 && now > cacheExpiresAt) + { + return new LicenseValidationResult + { + IsValid = false, + Status = "cache_expired", + Message = $"Offline-Gnadenfrist abgelaufen, Server nicht erreichbar ({reason}).", + HardwareId = hwInfo.HardwareId, + ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null, + CacheExpiresAt = cacheExpiresAt, + IsTransient = true + }; + } + + // Auch offline darf eine abgelaufene Lizenz nicht weiterlaufen. + if (cache.ExpiresAt > 0 && now > cache.ExpiresAt) + { + return new LicenseValidationResult + { + IsValid = false, + Status = "expired", + Message = "Cached license has expired.", + HardwareId = hwInfo.HardwareId, + ExpiresAt = cache.ExpiresAt, + IsTransient = false + }; + } + + cache.MaxSeenTime = now; + StateStore.Save(productSlug, hwInfo.HardwareId, cache); + + return new LicenseValidationResult + { + IsValid = true, + Status = "valid_offline", + Message = $"License validated via secure offline cache ({reason}).", + HardwareId = hwInfo.HardwareId, + IsCached = true, + ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null, + CacheExpiresAt = cacheExpiresAt, + IsTransient = false }; } return new LicenseValidationResult { IsValid = false, - Status = "unknown_error", - Message = "Validation failed.", - HardwareId = hwInfo.HardwareId + Status = "server_unavailable", + Message = $"Server communication error and no valid cache available: {reason}", + HardwareId = hwInfo.HardwareId, + IsTransient = true }; } - public async Task DeactivateAsync(string productSlug, string licenseKey, string serverBaseUrl, string authToken = "") + /// + /// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist + /// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt + /// unbegrenzt zu gelten. + /// + private static long ResolveCacheExpiry(LocalCacheData cache) + { + if (cache.CacheExpiresAt > 0) + { + return cache.CacheExpiresAt; + } + + int ttlHours = cache.CacheTtlHours > 0 ? cache.CacheTtlHours : FallbackCacheTtlHours; + return cache.IssuedAt > 0 ? cache.IssuedAt + (long)ttlHours * 3600L : 0; + } + + /// + /// Liest den zuletzt erfolgreich geprueften Schluessel aus dem lokalen + /// Cache. Damit laesst sich ein Schalter wie --license-status oder ein + /// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen. + /// Liefert null, wenn kein brauchbarer Cache vorliegt. + /// + public static string? TryGetCachedKey(string productSlug) + { + var cache = TryGetCachedState(productSlug); + return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey; + } + + /// + /// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist, + /// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser + /// Maschine gehoert. + /// + public static LocalCacheData? TryGetCachedState(string productSlug) + { + var hwInfo = HardwareId.GetHardwareId(productSlug); + return StateStore.Load(productSlug, hwInfo.HardwareId); + } + + /// + /// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel. + /// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde. + /// + public async Task RevalidateAsync( + string productSlug, + string serverBaseUrl, + string? appVersion = null, + CancellationToken cancellationToken = default) + { + string? key = TryGetCachedKey(productSlug); + + if (key == null) + { + var hwInfo = HardwareId.GetHardwareId(productSlug); + return new LicenseValidationResult + { + IsValid = false, + Status = "not_found", + Message = "Kein zwischengespeicherter Lizenzschluessel vorhanden.", + HardwareId = hwInfo.HardwareId, + IsTransient = false + }; + } + + return await ValidateAsync(productSlug, key, serverBaseUrl, appVersion, cancellationToken).ConfigureAwait(false); + } + + public async Task DeactivateAsync( + string productSlug, + string licenseKey, + string serverBaseUrl, + string authToken = "", + CancellationToken cancellationToken = default) { var hwInfo = HardwareId.GetHardwareId(productSlug); var payload = new @@ -182,25 +443,79 @@ public class LicenseClient var content = new StringContent(jsonStr, Encoding.UTF8, "application/json"); string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate"; - var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { Content = content }; if (!string.IsNullOrWhiteSpace(authToken)) { - request.Headers.Add("X-Watchdog-Key", authToken); + // Nur noch der Standardweg. Der zusaetzliche X-Watchdog-Key war ein + // Ueberbleibsel des alten Servers und irrefuehrend benannt: hier + // gehoert der shared_key hin, kein Watchdog-Token. Erschwerend + // prueft der Server X-Watchdog-Key VOR Authorization - ein dort + // versehentlich eingetragenes Watchdog-Token haette das richtige + // Bearer-Token stillschweigend verdraengt. request.Headers.Add("Authorization", $"Bearer {authToken}"); } try { - HttpResponseMessage response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch { return false; } } + + private static string ResolveAppVersion(string? explicitVersion) + { + if (!string.IsNullOrWhiteSpace(explicitVersion)) + { + return explicitVersion!.Trim(); + } + + if (!string.IsNullOrWhiteSpace(DefaultAppVersion)) + { + return DefaultAppVersion!.Trim(); + } + + // Vorher stand hier fest "1.0.0". In der Aktivierungsliste des + // Deploymentcenters trug damit jede Installation dieselbe Version, + // obwohl die Spalte app_version dafuer vorgesehen ist. + try + { + var assembly = Assembly.GetEntryAssembly(); + if (assembly != null) + { + string? informational = assembly + .GetCustomAttribute()?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informational)) + { + // Das SDK haengt bei manchen Buildeinstellungen "+" an. + int plus = informational!.IndexOf('+'); + return plus > 0 ? informational.Substring(0, plus) : informational; + } + + var version = assembly.GetName().Version; + if (version != null) + { + return version.ToString(); + } + } + } + catch + { + // Version zu ermitteln darf eine Lizenzpruefung nie scheitern lassen. + } + + return "unknown"; + } } diff --git a/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs b/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs index a1a778f..aec4859 100644 --- a/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs +++ b/client-dotnet/Deploymentcenter.Client/Models/BuildInfo.cs @@ -3,7 +3,18 @@ using System; namespace Deploymentcenter.Client.Models { /// - /// Runtime accessibility for build metadata embedded at compile-time. + /// Zur Laufzeit setzbare Build-Daten des SDK selbst. + /// + /// NICHT die Klasse, die Deploymentcenter.BuildInfo.targets erzeugt: die + /// entsteht im Namensraum des einbindenden Projekts und traegt dessen + /// Version. Diese hier ist nur ein Ablageort fuer Anwendungen, die ihre + /// Version zur Laufzeit von Hand setzen wollen. + /// + /// Der Zielnamensraum des Targets laesst sich ueber + /// DeploymentcenterBuildInfoNamespace umstellen - er darf nur nicht auf + /// diesen hier zeigen: partial verbindet Teilklassen nur innerhalb + /// derselben Assembly, ueber Assemblygrenzen hinweg entstuenden zwei Typen + /// mit demselben vollen Namen (CS0433). /// public static class BuildInfo { diff --git a/client-dotnet/Deploymentcenter.Client/Models/FlexibleBoolConverter.cs b/client-dotnet/Deploymentcenter.Client/Models/FlexibleBoolConverter.cs new file mode 100644 index 0000000..887e6ec --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/Models/FlexibleBoolConverter.cs @@ -0,0 +1,58 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Deploymentcenter.Client.Models +{ + /// + /// Liest Wahrheitswerte, die nicht als JSON-Boolean ankommen. + /// + /// Die Deploymentcenter-API reicht Release-Zeilen unveraendert aus MySQL + /// durch. is_critical ist dort TINYINT(1) und erscheint je nach + /// PDO-Einstellung als 1, "1" oder true. Der Standardkonverter von + /// System.Text.Json wirft bei allem ausser true/false - und der Fehler + /// haette den gesamten Release-Datensatz unbrauchbar gemacht. + /// + public class FlexibleBoolConverter : JsonConverter + { + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + + case JsonTokenType.False: + case JsonTokenType.Null: + return false; + + case JsonTokenType.Number: + return reader.TryGetInt64(out long number) ? number != 0 : reader.GetDouble() != 0d; + + case JsonTokenType.String: + string? value = reader.GetString(); + if (string.IsNullOrWhiteSpace(value)) + return false; + + value = value!.Trim(); + + if (bool.TryParse(value, out bool parsed)) + return parsed; + + if (long.TryParse(value, out long numeric)) + return numeric != 0; + + return value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("on", StringComparison.OrdinalIgnoreCase); + + default: + return false; + } + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs b/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs index 8206326..99b362a 100644 --- a/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs +++ b/client-dotnet/Deploymentcenter.Client/Models/PackageManifest.cs @@ -19,6 +19,14 @@ namespace Deploymentcenter.Client.Models [JsonPropertyName("channel")] public string Channel { get; set; } = "prod"; + /// + /// Laufzeitkennung des Pakets ("win-x64", "linux-x64", ...) oder + /// "any". Fehlt das Feld, stammt das Manifest aus der Zeit vor der + /// Plattform-Dimension und gilt als plattformunabhaengig. + /// + [JsonPropertyName("platform")] + public string Platform { get; set; } = PlatformId.Any; + [JsonPropertyName("buildDate")] public string BuildDate { get; set; } = string.Empty; @@ -31,6 +39,20 @@ namespace Deploymentcenter.Client.Models [JsonPropertyName("changelog")] public string Changelog { get; set; } = string.Empty; + /// + /// Muster fuer Dateien, die zwar im Paket liegen, eine vorhandene + /// Datei am Ziel aber nicht ersetzen duerfen - typischerweise + /// Konfigurationsvorlagen wie appsettings.json. + /// + /// Ausschluss beim Packen und Schutz beim Anwenden sind zwei + /// verschiedene Dinge. Wer eine Konfigurationsvorlage ausliefern will, + /// kann sie nicht einfach vom Paket ausnehmen; sie darf nur beim + /// Update nicht ueber die eingerichtete Fassung des Zielsystems + /// geschrieben werden. + /// + [JsonPropertyName("preserve")] + public List Preserve { get; set; } = new List(); + [JsonPropertyName("files")] public List Files { get; set; } = new List(); } diff --git a/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs b/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs index aa52a4d..a0f8f04 100644 --- a/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs +++ b/client-dotnet/Deploymentcenter.Client/Models/ReleaseManifest.cs @@ -15,6 +15,13 @@ namespace Deploymentcenter.Client.Models [JsonPropertyName("channel")] public string Channel { get; set; } = "prod"; + /// + /// Laufzeitkennung des Kanals. Fehlt sie, stammt die Datei aus der + /// Zeit vor der Plattform-Dimension und gilt als "any". + /// + [JsonPropertyName("platform")] + public string Platform { get; set; } = PlatformId.Any; + [JsonPropertyName("latest")] public VersionInfo? Latest { get; set; } @@ -22,6 +29,80 @@ namespace Deploymentcenter.Client.Models public List Versions { get; set; } = new List(); } + /// + /// Release-Daten so, wie sie /api/updateservice/v1/check unter + /// latest_release liefert: die Zeile aus updateservice_releases + /// mit ihren Spaltennamen. + /// + /// Bewusst ein eigenes Modell. Zuvor wurde die API-Antwort in + /// deserialisiert, das die camelCase-Namen der + /// statischen latest.json traegt. Von beiden Formaten stimmt nur "version" + /// ueberein - ueber die API kamen also weder Download-Adresse noch Pruefsumme, + /// Changelog oder Kritikalitaet an. Der API-Zweig ist genau der Rueckfall, + /// wenn die latest.json fehlt; er degradierte damit still. + /// + public class ApiReleaseInfo + { + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("channel")] + public string Channel { get; set; } = "prod"; + + [JsonPropertyName("platform")] + public string Platform { get; set; } = PlatformId.Any; + + [JsonPropertyName("download_url")] + public string DownloadUrl { get; set; } = string.Empty; + + /// + /// RSA-SHA256 ueber die kanonische Release-Zeile, base64. Der Server + /// bildet sie beim Veroeffentlichen; leer, wenn dort kein + /// Signierschluessel hinterlegt ist. + /// + [JsonPropertyName("manifest_signature")] + public string ManifestSignature { get; set; } = string.Empty; + + [JsonPropertyName("sha256_hash")] + public string Sha256Hash { get; set; } = string.Empty; + + [JsonPropertyName("release_notes")] + public string ReleaseNotes { get; set; } = string.Empty; + + [JsonPropertyName("git_commit")] + public string GitCommit { get; set; } = string.Empty; + + [JsonPropertyName("size_bytes")] + public long SizeBytes { get; set; } + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = string.Empty; + + /// + /// MySQL liefert TINYINT(1) als 0/1, PHP je nach Treiber auch als + /// Zeichenkette. nimmt beides. + /// + [JsonPropertyName("is_critical")] + [JsonConverter(typeof(FlexibleBoolConverter))] + public bool IsCritical { get; set; } + + /// Uebersetzt in das Modell, das Aufrufer bereits kennen. + public VersionInfo ToVersionInfo() => new VersionInfo + { + Version = Version, + BuildDate = CreatedAt, + GitCommit = GitCommit, + GitCommitShort = GitCommit.Length >= 7 ? GitCommit.Substring(0, 7) : GitCommit, + PackageUrl = DownloadUrl, + Sha256 = Sha256Hash, + SizeBytes = SizeBytes, + Changelog = ReleaseNotes, + IsCritical = IsCritical, + Platform = Platform, + Signature = ManifestSignature + }; + } + /// /// Individual release version details. /// @@ -53,5 +134,21 @@ namespace Deploymentcenter.Client.Models [JsonPropertyName("isCritical")] public bool IsCritical { get; set; } + + /// + /// Laufzeitkennung des Pakets. Aeltere latest.json-Dateien kennen das + /// Feld nicht; sie gelten dann als plattformunabhaengig. + /// + [JsonPropertyName("platform")] + public string Platform { get; set; } = PlatformId.Any; + + /// + /// Signatur des Servers, sofern der Agent das Release ueber die API + /// bezogen hat. Die statische latest.json fuehrt sie nicht: sie wird + /// vom Packager geschrieben, und der besitzt den Signierschluessel + /// bewusst nicht. + /// + [JsonPropertyName("signature")] + public string Signature { get; set; } = string.Empty; } } diff --git a/client-dotnet/Deploymentcenter.Client/PlatformId.cs b/client-dotnet/Deploymentcenter.Client/PlatformId.cs new file mode 100644 index 0000000..7d9de78 --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/PlatformId.cs @@ -0,0 +1,203 @@ +using System; +using System.Runtime.InteropServices; + +namespace Deploymentcenter.Client +{ + /// + /// Laufzeitkennung (.NET-RID) eines Releases. + /// + /// Der UpdateService kannte lange nur Projekt, Kanal und Version. Sobald + /// fuer mehrere Plattformen gebaut wurde, landeten alle Pakete unter + /// derselben Version im selben Kanal und ueberschrieben sich - ein + /// Linux-System zog sich das Windows-Paket. Diese Klasse liefert die + /// gemeinsame Sprache dafuer: Packager, Agent und Server benutzen + /// dieselbe Schreibweise und dieselben Ablagepfade. + /// + public static class PlatformId + { + /// Kennung fuer plattformunabhaengige Releases. + public const string Any = "any"; + + /// + /// Kennung des laufenden Systems, z. B. "win-x64" oder "linux-arm64". + /// + public static string Current + { + get + { +#if NET8_0_OR_GREATER + string rid = RuntimeInformation.RuntimeIdentifier; + if (!string.IsNullOrWhiteSpace(rid)) + { + return Normalize(rid); + } +#endif + return Normalize(ComposeFallback()); + } + } + + /// + /// Bringt eine Kennung auf die Form, die auch der Server verwendet. + /// Unbrauchbare Angaben werden zu "any" - ein verunglueckter Parameter + /// soll nicht dazu fuehren, dass gar kein Release mehr gefunden wird. + /// + public static string Normalize(string? platform) + { + string value = (platform ?? string.Empty).Trim().ToLowerInvariant(); + + if (value.Length == 0) + return Any; + + // Portable RIDs tragen manchmal eine Betriebssystemversion + // ("win10-x64", "ubuntu.22.04-x64"). Fuer die Auswahl eines Pakets + // ist nur die Familie samt Architektur interessant. + value = CollapseVersioned(value); + + foreach (char c in value) + { + bool allowed = (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' || c == '.' || c == '_'; + if (!allowed) + return Any; + } + + return value.Length > 32 ? Any : value; + } + + /// Ist das die plattformunabhaengige Kennung? + public static bool IsAny(string? platform) + { + return Normalize(platform) == Any; + } + + /// + /// Pfadsegment fuer die Release-Ablage. + /// + /// Plattformunabhaengige Releases behalten bewusst den alten Pfad + /// ohne Zwischenebene. Andernfalls waeren alle bereits ausgelieferten + /// Anwendungen von einem Tag auf den anderen von ihren Updates + /// abgeschnitten. + /// + public static string PathSegment(string? platform) + { + string normalized = Normalize(platform); + return normalized == Any ? string.Empty : "/" + normalized; + } + + /// + /// Versucht, die Kennung aus einem Publish-Pfad zu lesen, etwa + /// "bin/Release/net8.0/linux-x64/publish". Liefert null, wenn der Pfad + /// keine erkennbare Kennung enthaelt. + /// + public static string? InferFromPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + string[] segments = path!.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + // Von hinten, weil die Kennung in der Konvention unmittelbar vor + // "publish" steht und weiter vorne zufaellig gleichnamige Ordner + // liegen koennen. + for (int i = segments.Length - 1; i >= 0; i--) + { + string candidate = segments[i].ToLowerInvariant(); + if (LooksLikeRid(candidate)) + { + return Normalize(candidate); + } + } + + return null; + } + + private static bool LooksLikeRid(string value) + { + int dash = value.IndexOf('-'); + if (dash <= 0 || dash == value.Length - 1) + return false; + + string os = value.Substring(0, dash); + string rest = value.Substring(dash + 1); + + bool knownOs = os == "win" || os == "linux" || os == "osx" + || os.StartsWith("win", StringComparison.Ordinal) + || os.StartsWith("linux", StringComparison.Ordinal) + || os.StartsWith("osx", StringComparison.Ordinal); + + if (!knownOs) + return false; + + return rest == "x64" || rest == "x86" || rest == "arm64" || rest == "arm" + || rest.EndsWith("-x64", StringComparison.Ordinal) + || rest.EndsWith("-arm64", StringComparison.Ordinal); + } + + /// + /// "win10-x64" und "ubuntu.22.04-x64" auf "win-x64" bzw. "linux-x64" + /// zurueckfuehren. Ohne das entstuenden fuer dasselbe Paket mehrere + /// Kennungen, je nachdem, auf welchem System gebaut wurde. + /// + private static string CollapseVersioned(string value) + { + int dash = value.LastIndexOf('-'); + if (dash <= 0) + return value; + + string os = value.Substring(0, dash); + string arch = value.Substring(dash + 1); + + if (os.StartsWith("win", StringComparison.Ordinal)) + return "win-" + arch; + + if (os.StartsWith("osx", StringComparison.Ordinal) || os.StartsWith("macos", StringComparison.Ordinal)) + return "osx-" + arch; + + // musl ist eine eigene Zielplattform - ein glibc-Paket laeuft dort + // nicht, die Unterscheidung muss also erhalten bleiben. + if (os.Contains("musl")) + return "linux-musl-" + arch; + + if (os.StartsWith("linux", StringComparison.Ordinal) + || os.StartsWith("ubuntu", StringComparison.Ordinal) + || os.StartsWith("debian", StringComparison.Ordinal) + || os.StartsWith("alpine", StringComparison.Ordinal) + || os.StartsWith("rhel", StringComparison.Ordinal) + || os.StartsWith("centos", StringComparison.Ordinal) + || os.StartsWith("fedora", StringComparison.Ordinal)) + { + return os.StartsWith("alpine", StringComparison.Ordinal) + ? "linux-musl-" + arch + : "linux-" + arch; + } + + return value; + } + + private static string ComposeFallback() + { + string os; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + os = "win"; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + os = "osx"; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + os = "linux"; + else + return Any; + + string arch; + switch (RuntimeInformation.ProcessArchitecture) + { + case Architecture.X64: arch = "x64"; break; + case Architecture.X86: arch = "x86"; break; + case Architecture.Arm64: arch = "arm64"; break; + case Architecture.Arm: arch = "arm"; break; + default: return Any; + } + + return os + "-" + arch; + } + } +} diff --git a/client-dotnet/Deploymentcenter.Client/ReleaseVerifier.cs b/client-dotnet/Deploymentcenter.Client/ReleaseVerifier.cs new file mode 100644 index 0000000..879951b --- /dev/null +++ b/client-dotnet/Deploymentcenter.Client/ReleaseVerifier.cs @@ -0,0 +1,134 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +#if NETSTANDARD2_0 +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; +#endif + +namespace Deploymentcenter.Client +{ + /// + /// Prueft die Signatur eines Releases. + /// + /// Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket + /// selbst. Wer den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht + /// beide gemeinsam aus - der Hash schuetzt dann gegen Uebertragungsfehler, + /// nicht gegen Manipulation. Ausgerechnet auf dem Pfad, der fremden Code + /// ausfuehrt. + /// + /// Bewusst asymmetrisch: bei einem HMAC braeuchte der pruefende Agent + /// denselben geheimen Schluessel wie der signierende Server. Der Agent + /// laeuft auf Kundensystemen; ein dort ausgelesener Schluessel liesse sich + /// zum Signieren beliebiger Pakete verwenden. Der Server signiert daher + /// mit einem privaten RSA-Schluessel, der Agent prueft mit dem + /// oeffentlichen aus /api/updateservice/v1/pubkey. + /// + public static class ReleaseVerifier + { + /// + /// Kanonische Darstellung eines Releases - muss zeichengenau der + /// serverseitigen Fassung in ReleaseSigner::canonical() entsprechen. + /// Signiert wird bewusst diese Zeile und nicht das Manifest-JSON: + /// JSON-Ausgabe ist nicht bytestabil (Schluesselreihenfolge, Escaping, + /// Zahlenformat), eine Signatur darueber waere unzuverlaessig pruefbar. + /// + public static string BuildCanonical( + string productSlug, + string version, + string channel, + string platform, + string? sha256Hash, + string downloadUrl, + long sizeBytes) + { + return string.Join("\n", new[] + { + "dc-release-v1", + productSlug ?? string.Empty, + version ?? string.Empty, + channel ?? string.Empty, + platform ?? string.Empty, + (sha256Hash ?? string.Empty).ToLowerInvariant(), + downloadUrl ?? string.Empty, + sizeBytes.ToString(System.Globalization.CultureInfo.InvariantCulture) + }); + } + + /// + /// Prueft eine base64-kodierte RSA-SHA256-Signatur gegen einen + /// oeffentlichen Schluessel im PEM-Format. + /// + public static bool Verify(string canonical, string? signatureBase64, string? publicKeyPem) + { + if (string.IsNullOrWhiteSpace(signatureBase64) || string.IsNullOrWhiteSpace(publicKeyPem)) + return false; + + byte[] signature; + try + { + signature = Convert.FromBase64String(signatureBase64!.Trim()); + } + catch (FormatException) + { + return false; + } + + byte[] data = Encoding.UTF8.GetBytes(canonical); + + try + { +#if NET8_0_OR_GREATER + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem!.ToCharArray()); + return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); +#else + AsymmetricKeyParameter key = PublicKeyFactory.CreateKey(DecodePem(publicKeyPem!)); + ISigner signer = SignerUtilities.GetSigner("SHA256withRSA"); + signer.Init(false, key); + signer.BlockUpdate(data, 0, data.Length); + return signer.VerifySignature(signature); +#endif + } + catch + { + // Ein nicht lesbarer Schluessel oder eine unpassende Signatur + // sind kein Sonderfall, sondern schlicht "nicht geprueft". + return false; + } + } + + /// + /// Fingerabdruck eines PEM-Schluessels. Damit erkennt der Agent den + /// einmal geholten Schluessel wieder, statt ihm bei jedem Aufruf neu + /// zu vertrauen - ein spaeter ausgetauschter Schluessel faellt so auf. + /// + public static string Fingerprint(string publicKeyPem) + { + using var sha256 = SHA256.Create(); + byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(publicKeyPem ?? string.Empty)); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + +#if NETSTANDARD2_0 + /// Entfernt Kopf- und Fusszeile eines PEM und dekodiert base64. + private static byte[] DecodePem(string pem) + { + var builder = new StringBuilder(); + + foreach (string line in pem.Split('\n')) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith("-----", StringComparison.Ordinal)) + continue; + + builder.Append(trimmed); + } + + return Convert.FromBase64String(builder.ToString()); + } +#endif + } +} diff --git a/client-dotnet/Deploymentcenter.Client/StateStore.cs b/client-dotnet/Deploymentcenter.Client/StateStore.cs index c03c856..d0a3815 100644 --- a/client-dotnet/Deploymentcenter.Client/StateStore.cs +++ b/client-dotnet/Deploymentcenter.Client/StateStore.cs @@ -15,13 +15,28 @@ namespace Deploymentcenter.Client; public class LocalCacheData { - public int SchemaVersion { get; set; } = 2; + public int SchemaVersion { get; set; } = StateStore.CurrentSchemaVersion; 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; } + + /// Ablauf der Lizenz selbst (Unix-Zeit), 0 wenn unbefristet. public long ExpiresAt { get; set; } + + /// + /// Ende der Offline-Gnadenfrist (Unix-Zeit). Getrennt von + /// , weil eine Lizenz bis 2040 laufen kann, die + /// Frist ohne Serverkontakt aber nur ueber die vom Server gemeldeten + /// cache_ttl_hours. Schema 2 kannte das Feld nicht; dort wird die Frist + /// aus IssuedAt abgeleitet. + /// + public long CacheExpiresAt { get; set; } + + /// Vom Server gemeldete Gnadenfrist in Stunden. + public int CacheTtlHours { get; set; } + public long MaxSeenTime { get; set; } public string Checksum { get; set; } = string.Empty; } @@ -30,6 +45,16 @@ public static class StateStore { private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32 + /// Schema, das dieser Client schreibt. + public const int CurrentSchemaVersion = 3; + + /// + /// Schemata, die noch gelesen werden. Schema 2 hat keine getrennte + /// Cache-Frist; ein Aufsteigen darf keinen Zwang zur Online-Pruefung + /// ausloesen, nur weil das SDK aktualisiert wurde. + /// + private static readonly int[] SupportedSchemaVersions = { 2, 3 }; + public static LocalCacheData? Load(string productSlug, string hardwareId) { try @@ -70,25 +95,43 @@ public static class StateStore string jsonStr = Encoding.UTF8.GetString(jsonBytes); var data = JsonSerializer.Deserialize(jsonStr); - if (data == null || data.SchemaVersion != 2) + if (data == null || Array.IndexOf(SupportedSchemaVersions, data.SchemaVersion) < 0) return null; // Incompatible schema -> Treat as Cache Miss + if (!BelongsHere(data, productSlug, hardwareId)) + return null; + return data; } // Legacy Migration Check (non-LLS2 file) + // + // Vorher wurde hier beliebiges JSON nach LocalCacheData + // deserialisiert und sofort im LLS2-Format zurueckgeschrieben. + // Passte kein einziges Feld, entstand ein Standardobjekt, das die + // urspruengliche Datei ueberschrieb. Da LicenseLabrador denselben + // Pfad und Dateinamen verwendet - GetStorageDirectory beruecksichtigt + // dafuer eigens LICENSELABRADOR_STORAGE_DIR - zerstoerte das den + // fremden Cache still. Uebernommen wird jetzt nur, was sich als + // Cache genau dieses Produkts auf genau dieser Maschine ausweist. try { string legacyJson = Encoding.UTF8.GetString(payloadBytes); - var legacyData = JsonSerializer.Deserialize(legacyJson); - if (legacyData != null) + + // Ein Ueberbleibsel im Binaerformat ist kein JSON-Objekt. + if (legacyJson.TrimStart().StartsWith("{", StringComparison.Ordinal)) { - legacyData.SchemaVersion = 2; - Save(productSlug, hardwareId, legacyData); - return legacyData; + var legacyData = JsonSerializer.Deserialize(legacyJson); + + if (legacyData != null && BelongsHere(legacyData, productSlug, hardwareId)) + { + legacyData.SchemaVersion = CurrentSchemaVersion; + Save(productSlug, hardwareId, legacyData); + return legacyData; + } } } - catch { } + catch (JsonException) { } return null; } @@ -98,11 +141,35 @@ public static class StateStore } } + /// + /// Prueft, ob ein gelesener Cache tatsaechlich zu diesem Produkt gehoert. + /// + /// Fuer LLS2-Dateien ist die Hardware-Bindung bereits durch die + /// Schluesselableitung gegeben - dort faellt die Entschluesselung sonst aus. + /// Entscheidend ist der Produktbezug: ohne ihn wuerde eine fremde + /// state.dat im selben Verzeichnis uebernommen und ueberschrieben. + /// + private static bool BelongsHere(LocalCacheData data, string productSlug, string hardwareId) + { + if (string.IsNullOrWhiteSpace(data.ProductSlug)) + return false; + + if (!string.Equals(data.ProductSlug, productSlug, StringComparison.OrdinalIgnoreCase)) + return false; + + // Ein Cache ohne Schluessel taugt zu nichts und ist meist ein + // Standardobjekt aus einer Datei, die gar keine unsrige war. + if (string.IsNullOrWhiteSpace(data.LicenseKey)) + return false; + + return true; + } + public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData) { try { - cacheData.SchemaVersion = 2; + cacheData.SchemaVersion = CurrentSchemaVersion; string dir = LicenseConfig.GetStorageDirectory(productSlug); Directory.CreateDirectory(dir); string statePath = Path.Combine(dir, "state.dat"); diff --git a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs index a224d51..ff24ab8 100644 --- a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs +++ b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs @@ -43,21 +43,31 @@ namespace Deploymentcenter.Client /// /// Checks for update availability against LEMP static latest.json or Deploymentcenter API. /// + /// + /// Laufzeitkennung des Systems (z. B. "win-x64"). Ohne Angabe wird die + /// des laufenden Prozesses verwendet. Wird bewusst mitgeschickt: ohne + /// sie liefert der Server nur plattformunabhaengige Releases, damit ein + /// Client nie das Paket einer fremden Plattform angeboten bekommt. + /// public async Task CheckForUpdateAsync( string baseUrl, string projectId, string currentVersion, string channel = "prod", + string? platform = null, CancellationToken cancellationToken = default) { var result = new UpdateCheckResult(); try { string cleanBaseUrl = baseUrl.TrimEnd('/'); - + string rid = PlatformId.Normalize(platform ?? PlatformId.Current); + // Primary check: LEMP static channel latest.json - // Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json - string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json"; + // Plattformunabhaengige Releases liegen weiterhin unter dem + // alten Pfad ohne Zwischenebene, damit bereits ausgelieferte + // Anwendungen ihre Updates finden. + string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}{PlatformId.PathSegment(rid)}/latest.json"; // Zuerst die statische latest.json, danach die API. // @@ -84,7 +94,8 @@ namespace Deploymentcenter.Client string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check" + $"?product={Uri.EscapeDataString(projectId)}" + $"&version={Uri.EscapeDataString(currentVersion)}" - + $"&channel={Uri.EscapeDataString(channel)}"; + + $"&channel={Uri.EscapeDataString(channel)}" + + $"&platform={Uri.EscapeDataString(rid)}"; response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); } @@ -121,16 +132,41 @@ namespace Deploymentcenter.Client } } // Handle API response format + // + // Die API antwortet in snake_case und reicht die Release-Zeile + // aus updateservice_releases durch. Sie wird deshalb ueber + // ApiReleaseInfo gelesen und erst danach in das Modell + // uebersetzt, das Aufrufer kennen. Zuvor wurde direkt nach + // VersionInfo deserialisiert - dessen camelCase-Namen gehoeren + // aber zur statischen latest.json, sodass ausser "version" + // nichts ankam. else if (root.TryGetProperty("update_available", out var availProp)) { - bool available = availProp.GetBoolean(); + bool available = availProp.ValueKind == JsonValueKind.True + || (availProp.ValueKind == JsonValueKind.Number && availProp.GetInt32() != 0); + result.UpdateAvailable = available; - if (root.TryGetProperty("latest_release", out var relProp)) + + if (root.TryGetProperty("latest_release", out var relProp) + && relProp.ValueKind == JsonValueKind.Object) { - var info = JsonSerializer.Deserialize(relProp.GetRawText()); - result.LatestRelease = info; - result.IsCritical = info?.IsCritical ?? false; + var info = JsonSerializer.Deserialize(relProp.GetRawText()); + result.LatestRelease = info?.ToVersionInfo(); } + + // is_critical steht auf oberster Ebene der Antwort, nicht im + // Release-Objekt. Vorher wurde es aus dem deserialisierten + // Objekt gelesen und war damit immer false - ein kritisches + // Release wurde ueber diesen Weg nie als kritisch gemeldet. + if (root.TryGetProperty("is_critical", out var critProp)) + { + result.IsCritical = ReadFlexibleBool(critProp); + } + else + { + result.IsCritical = result.LatestRelease?.IsCritical ?? false; + } + result.Message = available ? "Update available." : "Application is up to date."; } } @@ -143,6 +179,28 @@ namespace Deploymentcenter.Client return result; } + /// + /// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette + /// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach + /// PDO-Einstellung als 1 oder "1" weiter. + /// + private static bool ReadFlexibleBool(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.True: + return true; + case JsonValueKind.Number: + return element.TryGetInt64(out long number) && number != 0; + case JsonValueKind.String: + string value = (element.GetString() ?? string.Empty).Trim(); + if (bool.TryParse(value, out bool parsed)) return parsed; + return long.TryParse(value, out long numeric) && numeric != 0; + default: + return false; + } + } + /// /// Validates local application integrity against manifest.json. /// @@ -181,8 +239,56 @@ namespace Deploymentcenter.Client } /// - /// Launches UpdateAgent process with appropriate parameters and optionally exits current application. + /// Dateiname des Agenten auf dieser Plattform. + /// + /// Unter Linux und macOS traegt das Binary keine Endung. Ein fest auf + /// "update-agent.exe" verdrahteter Name wird dort nie gefunden, und die + /// Anwendung meldet stumm "kein Agent vorhanden". /// + public static string AgentFileName => + OperatingSystemHelpers.IsWindows() ? "update-agent.exe" : "update-agent"; + + /// + /// Sucht den Agenten. Ohne Verzeichnisangabe wird neben der laufenden + /// Anwendung gesucht. + /// + public static string? ResolveAgentPath(string? directory = null) + { + string dir = string.IsNullOrWhiteSpace(directory) + ? AppDomain.CurrentDomain.BaseDirectory + : directory!; + + string candidate = Path.Combine(dir, AgentFileName); + if (File.Exists(candidate)) + return candidate; + + // Ein Paket, das fuer die jeweils andere Plattform gebaut wurde, + // bringt den Agenten unter dem dortigen Namen mit. Lieber finden + // als daran scheitern. + foreach (string alternative in new[] { "update-agent", "update-agent.exe" }) + { + string path = Path.Combine(dir, alternative); + if (File.Exists(path)) + return path; + } + + return null; + } + + /// + /// Startet den UpdateAgent und beendet auf Wunsch die laufende Anwendung. + /// + /// + /// Anwendung, die der Agent nach dem Update wieder starten soll. + /// Wurde dieser Wert zuvor nie uebergeben - der Agent unterstuetzte + /// --restart, bekam es aber nie zu sehen, sodass die Anwendung + /// nach "Jetzt installieren" schlicht geschlossen blieb. + /// + /// + /// Uebergibt die eigene Prozesskennung, damit der Agent das Ende der + /// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag + /// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien. + /// public static bool LaunchUpdateAgent( string agentPath, string projectId, @@ -190,7 +296,11 @@ namespace Deploymentcenter.Client string action = "update", string version = "latest", string? targetDir = null, - bool exitCurrentApp = true) + bool exitCurrentApp = true, + string? restartPath = null, + string? currentVersion = null, + string? platform = null, + bool waitForCurrentProcess = true) { if (!File.Exists(agentPath)) { @@ -200,16 +310,41 @@ namespace Deploymentcenter.Client targetDir ??= AppDomain.CurrentDomain.BaseDirectory; var args = new StringBuilder(); - args.Append($"--project \"{projectId}\" "); - args.Append($"--channel \"{channel}\" "); - args.Append($"--action \"{action}\" "); - args.Append($"--version \"{version}\" "); - args.Append($"--target-dir \"{targetDir}\""); + AppendArg(args, "--project", projectId); + AppendArg(args, "--channel", channel); + AppendArg(args, "--action", action); + AppendArg(args, "--version", version); + AppendArg(args, "--target-dir", targetDir); + AppendArg(args, "--platform", PlatformId.Normalize(platform ?? PlatformId.Current)); + + if (!string.IsNullOrWhiteSpace(currentVersion)) + { + AppendArg(args, "--current-version", currentVersion!); + } + + if (waitForCurrentProcess) + { + AppendArg(args, "--wait-for-pid", + Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + // Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei. + string? restart = restartPath; + if (string.IsNullOrWhiteSpace(restart)) + { + restart = GetCurrentExecutablePath(); + } + + if (!string.IsNullOrWhiteSpace(restart)) + { + AppendArg(args, "--restart", restart!); + } var startInfo = new ProcessStartInfo { FileName = agentPath, - Arguments = args.ToString(), + Arguments = args.ToString().TrimEnd(), + WorkingDirectory = Path.GetDirectoryName(agentPath) ?? targetDir, UseShellExecute = true }; @@ -223,6 +358,34 @@ namespace Deploymentcenter.Client return true; } + private static void AppendArg(StringBuilder builder, string name, string value) + { + builder.Append(name); + builder.Append(" \""); + // Ein abschliessender Backslash wuerde sonst das Anfuehrungszeichen + // maskieren und alle folgenden Argumente verschlucken - bei + // Windows-Pfaden wie C:\App\ ein realer Fall. + builder.Append(value.Replace("\"", "\\\"").TrimEnd('\\')); + builder.Append("\" "); + } + + private static string? GetCurrentExecutablePath() + { + try + { +#if NET8_0_OR_GREATER + string? path = Environment.ProcessPath; + if (!string.IsNullOrWhiteSpace(path)) + return path; +#endif + return Process.GetCurrentProcess().MainModule?.FileName; + } + catch + { + return null; + } + } + public static string ComputeSha256(string filePath) { using var sha256 = SHA256.Create(); diff --git a/client-dotnet/Deploymentcenter.Packager/Program.cs b/client-dotnet/Deploymentcenter.Packager/Program.cs index 3af3a51..dc78078 100644 --- a/client-dotnet/Deploymentcenter.Packager/Program.cs +++ b/client-dotnet/Deploymentcenter.Packager/Program.cs @@ -10,11 +10,21 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Deploymentcenter.Client; using Deploymentcenter.Client.Models; using FluentFTP; namespace Deploymentcenter.Packager { + /// + /// Wird geworfen, wenn die vorhandene Versionshistorie nicht sicher + /// gelesen werden konnte. Dann darf latest.json nicht geschrieben werden. + /// + internal sealed class ReleaseHistoryException : Exception + { + public ReleaseHistoryException(string message) : base(message) { } + } + /// /// Konfiguration des Packagers. /// @@ -39,9 +49,34 @@ namespace Deploymentcenter.Packager /// public string ApiToken { get; set; } = ""; + /// + /// Dateien, die gar nicht erst ins Paket kommen. + /// + /// Die Muster werden seit dieser Fassung als echte Globs ausgewertet. + /// Zuvor verstand der Abgleich nur "*.endung" und exakte Namen, sodass + /// Eintraege wie "logs/**" nie zutrafen - sie standen in der + /// Beispielkonfiguration und erweckten den Eindruck, es sei etwas + /// ausgeschlossen. + /// public List ExcludePatterns { get; set; } = new List { - "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*" + "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", + "*.log", "logs/**", "*.tmp" + }; + + /// + /// Dateien, die ins Paket gehoeren, am Ziel aber eine vorhandene + /// Fassung nicht ersetzen duerfen. + /// + /// Ausschluss und Schutz sind zwei verschiedene Dinge: eine + /// Konfigurationsvorlage soll ausgeliefert werden, damit eine + /// Erstinstallation vollstaendig ist - beim Update darf sie die + /// eingerichteten Werte des Zielsystems aber nicht ueberschreiben. + /// + public List PreservePatterns { get; set; } = new List + { + "appsettings.json", "appsettings.Production.json", "settings.json", + "config.json", ".env" }; /// Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei. @@ -72,9 +107,15 @@ namespace Deploymentcenter.Packager static async Task Main(string[] args) { Console.WriteLine("================================================="); - Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 "); + Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v2.0 "); Console.WriteLine("================================================="); + if (HasFlag(args, "--help") || HasFlag(args, "-h")) + { + ShowHelp(); + return 0; + } + string project = GetArg(args, "--project", "-p") ?? "myapp"; string version = GetArg(args, "--version", "-v") ?? "1.0.0"; string channel = GetArg(args, "--channel", "-c") ?? "prod"; @@ -119,6 +160,7 @@ namespace Deploymentcenter.Packager Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter"); Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0"); Console.WriteLine(" ein Token mit dem Recht updateservice:publish."); + Console.WriteLine(" Ohne Registrierung entsteht ausserdem keine Signatur."); Console.ResetColor(); } @@ -126,34 +168,109 @@ namespace Deploymentcenter.Packager if (!Directory.Exists(publishDir)) { Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}"); + Console.WriteLine($"[FEHLER] Publish-Verzeichnis existiert nicht: {publishDir}"); Console.ResetColor(); return 1; } - Console.WriteLine($"[INFO] Packaging Project : {project}"); - Console.WriteLine($"[INFO] Version : {version}"); - Console.WriteLine($"[INFO] Channel : {channel}"); - Console.WriteLine($"[INFO] Publish Directory : {publishDir}"); + // --------------------------------------------------------------- + // Plattform bestimmen + // --------------------------------------------------------------- + // Ohne Plattform landeten Pakete verschiedener Laufzeitkennungen + // unter derselben Version im selben Kanal und ueberschrieben sich. + string platform; + string platformSource; - // 1. Gather files and filter exclusions + string? explicitPlatform = GetArg(args, "--platform") ?? GetArg(args, "--rid"); + if (!string.IsNullOrWhiteSpace(explicitPlatform)) + { + platform = PlatformId.Normalize(explicitPlatform); + platformSource = "Argument"; + } + else + { + string? inferred = PlatformId.InferFromPath(publishDir); + if (inferred != null) + { + platform = inferred; + platformSource = "aus dem Publish-Pfad abgeleitet"; + } + else + { + platform = PlatformId.Any; + platformSource = "Standard"; + } + } + + Console.WriteLine($"[INFO] Projekt : {project}"); + Console.WriteLine($"[INFO] Version : {version}"); + Console.WriteLine($"[INFO] Kanal : {channel}"); + Console.WriteLine($"[INFO] Plattform : {platform} ({platformSource})"); + Console.WriteLine($"[INFO] Publish-Verzeichnis: {publishDir}"); + + if (platform == PlatformId.Any && !HasFlag(args, "--allow-any-platform")) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("[WARNUNG] Keine Plattform angegeben - das Release gilt als plattformunabhaengig."); + Console.WriteLine(" Wird fuer mehrere Laufzeitkennungen gebaut, ueberschreiben sich die"); + Console.WriteLine(" Pakete gegenseitig. Mit --platform win-x64 (o. ae.) trennen."); + Console.ResetColor(); + } + + // --------------------------------------------------------------- + // Version gegen die Hauptassembly pruefen + // --------------------------------------------------------------- + // Weicht die veroeffentlichte Version von der einkompilierten ab, + // meldet die Anwendung nach dem Update weiterhin die alte Version, + // haelt das Release fuer neu und aktualisiert bei jedem Start + // erneut - eine Endlosschleife ueber die gesamte Installationsbasis. + if (!VerifyVersionAgainstAssembly(publishDir, project, version, GetArg(args, "--main-assembly"), + HasFlag(args, "--ignore-version-mismatch"))) + { + return 1; + } + + // --------------------------------------------------------------- + // Dateien einsammeln + // --------------------------------------------------------------- var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories); var filteredFiles = new List(); + var preservedRelPaths = new List(); foreach (var file in allFiles) { string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); - if (IsExcluded(relPath, config.ExcludePatterns)) + + if (GlobMatcher.IsMatch(relPath, config.ExcludePatterns)) { - Console.WriteLine($" [EXCLUDED] {relPath}"); + Console.WriteLine($" [AUSGESCHLOSSEN] {relPath}"); continue; } + filteredFiles.Add(file); + + if (GlobMatcher.IsMatch(relPath, config.PreservePatterns)) + { + preservedRelPaths.Add(relPath); + } } - Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}"); + Console.WriteLine($"[INFO] Dateien im Paket : {filteredFiles.Count}"); - // 2. Prepare staging directory + if (preservedRelPaths.Count > 0) + { + Console.WriteLine($"[INFO] Davon geschuetzt : {preservedRelPaths.Count} (ersetzen am Ziel keine vorhandene Datei)"); + foreach (var p in preservedRelPaths) + { + Console.WriteLine($" [GESCHUETZT] {p}"); + } + } + + WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config); + + // --------------------------------------------------------------- + // Paket bauen + // --------------------------------------------------------------- string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(outputTempDir); @@ -165,16 +282,17 @@ namespace Deploymentcenter.Packager string gitCommitShort = GetGitCommitShort(); string buildDateUtc = DateTime.UtcNow.ToString("o"); - // Build Manifest var packageManifest = new PackageManifest { ProjectId = project, Version = version, Channel = channel, + Platform = platform, BuildDate = buildDateUtc, GitCommit = gitCommit, GitCommitShort = gitCommitShort, Changelog = changelog, + Preserve = new List(config.PreservePatterns), Files = new List() }; @@ -191,12 +309,11 @@ namespace Deploymentcenter.Packager }); } - // Write manifest.json - string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true }); + var manifestOptions = new JsonSerializerOptions { WriteIndented = true }; + string manifestJson = JsonSerializer.Serialize(packageManifest, manifestOptions); await File.WriteAllTextAsync(manifestJsonPath, manifestJson); - // 3. Create package.tar.gz - Console.WriteLine("[INFO] Creating package.tar.gz archive..."); + Console.WriteLine("[INFO] Erzeuge package.tar.gz ..."); string archiveStaging = Path.Combine(outputTempDir, "archive_root"); Directory.CreateDirectory(archiveStaging); @@ -208,10 +325,8 @@ namespace Deploymentcenter.Packager File.Copy(file, targetFile, true); } - // Also place manifest.json inside archive root File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true); - // Compress to tar.gz using System.Formats.Tar + GZipStream using (var fs = File.Create(packageTarGzPath)) using (var gz = new GZipStream(fs, CompressionLevel.Optimal)) { @@ -222,16 +337,23 @@ namespace Deploymentcenter.Packager string packageSha256 = ComputeSha256(packageTarGzPath); await File.WriteAllTextAsync(sha256FilePath, packageSha256); - Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)"); - Console.WriteLine($"[INFO] Package SHA256: {packageSha256}"); + Console.WriteLine($"[OK] Paket erstellt ({packageSizeBytes} Bytes)"); + Console.WriteLine($"[INFO] SHA256: {packageSha256}"); - // 4. FTP Upload to LEMP Release Server - string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}"; + // --------------------------------------------------------------- + // Hochladen + // --------------------------------------------------------------- + // Plattformunabhaengige Releases behalten den alten Pfad ohne + // Zwischenebene, damit bereits ausgelieferte Anwendungen ihre + // Updates weiterhin finden. + string platformSegment = PlatformId.PathSegment(platform); + string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}{platformSegment}"; string remoteVersionPath = $"{remoteChannelPath}/{version}"; - Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})..."); + Console.WriteLine($"[INFO] Upload nach {ftpHost}:{config.FtpPort} ({remoteVersionPath}) ..."); bool ftpSucceeded = false; + bool historyPreserved = true; try { @@ -240,44 +362,76 @@ namespace Deploymentcenter.Packager await ftp.CreateDirectory(remoteVersionPath); - // Upload package.tar.gz, package.tar.gz.sha256, and manifest.json await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite); await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite); await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite); - Console.WriteLine("[SUCCESS] Files uploaded to version directory!"); + Console.WriteLine("[OK] Paketdateien hochgeladen."); - // 5. Update remote channel latest.json + // ----------------------------------------------------------- + // latest.json fortschreiben + // ----------------------------------------------------------- string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json"; - ReleaseManifest channelManifest = new ReleaseManifest + + var channelManifest = new ReleaseManifest { ProjectId = project, Channel = channel, + Platform = platform, Versions = new List() }; - // Read existing latest.json if present on FTP + // Die vorherige Fassung startete mit leerer Versionsliste und + // verschluckte jeden Fehler beim Lesen der bestehenden Datei + // in einem leeren catch. Schlug Download oder Parsen fehl, + // wurde die gesamte Historie durch einen einzigen Eintrag + // ersetzt - ohne jede Meldung. Jetzt bricht der Vorgang ab, + // bevor latest.json geschrieben wird. if (await ftp.FileExists(remoteLatestJsonPath)) { string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json"); - var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite); - if (status == FtpStatus.Success && File.Exists(tempLatestLocal)) + FtpStatus status; + + try { - try - { - string existingJson = await File.ReadAllTextAsync(tempLatestLocal); - var existingManifest = JsonSerializer.Deserialize(existingJson); - if (existingManifest != null && existingManifest.Versions != null) - { - channelManifest.Versions = existingManifest.Versions; - } - } - catch { } + status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite); } + catch (Exception ex) + { + throw new ReleaseHistoryException( + $"Die vorhandene latest.json konnte nicht geladen werden: {ex.Message}"); + } + + if (status != FtpStatus.Success || !File.Exists(tempLatestLocal)) + { + throw new ReleaseHistoryException( + "Die vorhandene latest.json konnte nicht geladen werden (Download nicht erfolgreich)."); + } + + ReleaseManifest? existingManifest; + try + { + string existingJson = await File.ReadAllTextAsync(tempLatestLocal); + existingManifest = JsonSerializer.Deserialize(existingJson); + } + catch (Exception ex) + { + throw new ReleaseHistoryException( + $"Die vorhandene latest.json ist nicht lesbar: {ex.Message}"); + } + + if (existingManifest?.Versions == null) + { + throw new ReleaseHistoryException( + "Die vorhandene latest.json enthaelt keine auswertbare Versionsliste."); + } + + channelManifest.Versions = existingManifest.Versions; + Console.WriteLine($"[INFO] Bestehende Historie gelesen: {channelManifest.Versions.Count} Eintraege."); } - // Construct new version info - string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz"; + string packagePublicUrl = + $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz"; var newVersionInfo = new VersionInfo { @@ -289,65 +443,99 @@ namespace Deploymentcenter.Packager Sha256 = packageSha256, SizeBytes = packageSizeBytes, Changelog = changelog, - IsCritical = isCritical + IsCritical = isCritical, + Platform = platform }; - // Remove duplicate version entry if re-publishing same version channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase)); channelManifest.Versions.Insert(0, newVersionInfo); - // Keep last 15 releases - if (channelManifest.Versions.Count > 15) + // Nach Versionsordnung sortieren, damit "latest" auch dann + // stimmt, wenn nachtraeglich eine aeltere Version gebaut wird. + channelManifest.Versions.Sort((a, b) => UpdateClient.CompareVersions(b.Version, a.Version)); + + // Aeltere Eintraege werden nur aus der Liste genommen, die + // Dateien bleiben auf dem Server liegen. Ein Rollback auf eine + // herausgefallene Version ist ueber die Liste nicht mehr + // erreichbar - deshalb der Hinweis statt stiller Kuerzung. + const int keep = 15; + if (channelManifest.Versions.Count > keep) { - channelManifest.Versions = channelManifest.Versions.Take(15).ToList(); + var dropped = channelManifest.Versions.Skip(keep).Select(v => v.Version).ToList(); + channelManifest.Versions = channelManifest.Versions.Take(keep).ToList(); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[HINWEIS] latest.json fuehrt {keep} Versionen. Nicht mehr gelistet: " + + string.Join(", ", dropped)); + Console.WriteLine(" Die Dateien liegen weiterhin auf dem Server, sind ueber den Agenten"); + Console.WriteLine(" aber nicht mehr auswaehlbar."); + Console.ResetColor(); } channelManifest.Latest = channelManifest.Versions.FirstOrDefault(); - string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true }); + string updatedLatestJson = JsonSerializer.Serialize(channelManifest, manifestOptions); string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json"); await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson); await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite); - Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!"); + Console.WriteLine("[OK] latest.json fortgeschrieben."); await ftp.Disconnect(); ftpSucceeded = true; } + catch (ReleaseHistoryException ex) + { + historyPreserved = false; + + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[FEHLER] {ex.Message}"); + Console.WriteLine(" latest.json wurde NICHT geschrieben - die bestehende Historie ist"); + Console.WriteLine(" unveraendert. Die Paketdateien dieser Version liegen bereits auf dem"); + Console.WriteLine(" Server; nach Behebung der Ursache genuegt ein erneuter Aufruf."); + Console.ResetColor(); + } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}"); - Console.WriteLine(" Das Paket wurde NICHT ausgeliefert."); + Console.WriteLine(" Das Paket wurde NICHT vollstaendig ausgeliefert."); Console.ResetColor(); } - // 6. Deploymentcenter benachrichtigen - // - // Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar - // nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit - // unsichtbar, waehrend das Programm am Ende Erfolg meldete. + // --------------------------------------------------------------- + // Deploymentcenter benachrichtigen + // --------------------------------------------------------------- bool apiNotified = false; + bool signed = false; string apiMessage = "uebersprungen (kein Token gesetzt)"; if (!string.IsNullOrWhiteSpace(apiToken)) { try { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish"; - var payload = new + // Das Dateimanifest wandert mit. Damit kann die API als + // vollwertiger Rueckfall dienen, wenn die statische + // latest.json fehlt oder der FTP-Upload scheiterte. + using var manifestDoc = JsonDocument.Parse(manifestJson); + + var payload = new Dictionary { - product_slug = project, - version = version, - channel = channel, - release_notes = changelog, - download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz", - sha256_hash = packageSha256, - git_commit = gitCommitShort, - size_bytes = packageSizeBytes, - is_critical = isCritical + ["product_slug"] = project, + ["version"] = version, + ["channel"] = channel, + ["platform"] = platform, + ["release_notes"] = changelog, + ["download_url"] = + $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz", + ["sha256_hash"] = packageSha256, + ["git_commit"] = gitCommitShort, + ["size_bytes"] = packageSizeBytes, + ["is_critical"] = isCritical, + ["manifest_json"] = manifestDoc.RootElement.Clone() }; var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl) @@ -363,6 +551,8 @@ namespace Deploymentcenter.Packager { apiNotified = true; apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen."; + signed = string.Equals(ExtractJsonString(body, "signed"), "True", StringComparison.OrdinalIgnoreCase) + || ExtractJsonString(body, "signed") == "true"; string? autoResolved = ExtractJsonString(body, "auto_resolved"); if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0") @@ -385,35 +575,267 @@ namespace Deploymentcenter.Packager if (apiNotified) { Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine($"[SUCCESS] {apiMessage}"); + Console.WriteLine($"[OK] {apiMessage}"); + Console.ResetColor(); + + if (!signed) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("[HINWEIS] Das Release ist unsigniert - auf dem Server ist kein"); + Console.WriteLine(" Signierschluessel hinterlegt (security.release_private_key)."); + Console.WriteLine(" Der Agent kann die Herkunft des Pakets dann nicht pruefen."); + Console.ResetColor(); + } } else { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}"); + Console.ResetColor(); } - Console.ResetColor(); - // Cleanup temp try { Directory.Delete(outputTempDir, true); } catch { } - // Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist. - // Zuvor wurde immer 0 und "successfully published" gemeldet, selbst - // wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren. bool fullySucceeded = ftpSucceeded && apiNotified; Console.WriteLine(); Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow; Console.WriteLine(fullySucceeded - ? $"[FERTIG] Release {version} fuer {project} ({channel}) vollstaendig veroeffentlicht." - : $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}): " + ? $"[FERTIG] Release {version} fuer {project} ({channel}, {platform}) vollstaendig veroeffentlicht." + : $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}, {platform}): " + $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, " - + $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}."); + + $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}" + + (historyPreserved ? "." : ", Historie unveraendert.")); Console.ResetColor(); return fullySucceeded ? 0 : 2; } + /// + /// Vergleicht die angegebene Version mit der, die tatsaechlich in der + /// Hauptassembly steht. + /// + /// Rueckgabe false bedeutet: abbrechen. Laesst sich die Assembly nicht + /// bestimmen, wird nur gewarnt - ein nicht pruefbarer Fall ist kein + /// Fehler, ein nachgewiesener Widerspruch schon. + /// + static bool VerifyVersionAgainstAssembly( + string publishDir, + string project, + string declaredVersion, + string? mainAssemblyOverride, + bool ignoreMismatch) + { + string? assemblyPath = ResolveMainAssembly(publishDir, project, mainAssemblyOverride); + + if (assemblyPath == null) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("[WARNUNG] Hauptassembly nicht gefunden - die Version konnte nicht gegengeprueft"); + Console.WriteLine(" werden. Mit --main-assembly gezielt angeben."); + Console.ResetColor(); + return true; + } + + string? actual = ReadAssemblyVersion(assemblyPath); + + if (actual == null) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[WARNUNG] Aus {Path.GetFileName(assemblyPath)} liess sich keine Version lesen."); + Console.ResetColor(); + return true; + } + + if (VersionCoresMatch(actual, declaredVersion)) + { + Console.WriteLine($"[OK] Version stimmt mit {Path.GetFileName(assemblyPath)} ueberein ({actual})."); + return true; + } + + Console.ForegroundColor = ignoreMismatch ? ConsoleColor.Yellow : ConsoleColor.Red; + Console.WriteLine($"[{(ignoreMismatch ? "WARNUNG" : "FEHLER")}] Versionskonflikt:"); + Console.WriteLine($" --version sagt : {declaredVersion}"); + Console.WriteLine($" {Path.GetFileName(assemblyPath)} sagt : {actual}"); + Console.WriteLine(); + Console.WriteLine(" Wird so veroeffentlicht, meldet die Anwendung nach dem Update weiterhin"); + Console.WriteLine(" ihre einkompilierte Version, haelt das Release fuer neu und aktualisiert"); + Console.WriteLine(" bei jedem Start erneut - auf allen Installationen."); + Console.WriteLine(); + Console.WriteLine(" Ueblicher Grund: steht nur in einem der beteiligten Projekte."); + Console.WriteLine(" Gehoert in die Directory.Build.props, damit alle denselben Wert tragen."); + + if (!ignoreMismatch) + { + Console.WriteLine(" Bewusst gewollt? --ignore-version-mismatch"); + } + + Console.ResetColor(); + + return ignoreMismatch; + } + + /// + /// Sucht die Assembly, deren Version fuer das Release massgeblich ist. + /// + static string? ResolveMainAssembly(string publishDir, string project, string? overrideName) + { + if (!string.IsNullOrWhiteSpace(overrideName)) + { + string candidate = Path.IsPathRooted(overrideName!) + ? overrideName! + : Path.Combine(publishDir, overrideName!); + + return File.Exists(candidate) ? candidate : null; + } + + // 1. Gleichnamig zum Projekt-Slug. + foreach (string extension in new[] { ".dll", ".exe" }) + { + string candidate = Path.Combine(publishDir, project + extension); + if (File.Exists(candidate)) + return candidate; + } + + // 2. Ueber die runtimeconfig.json: sie traegt den Namen der + // Startassembly und existiert genau einmal je Anwendung. + var runtimeConfigs = Directory.GetFiles(publishDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly); + if (runtimeConfigs.Length == 1) + { + string baseName = Path.GetFileName(runtimeConfigs[0]); + baseName = baseName.Substring(0, baseName.Length - ".runtimeconfig.json".Length); + + foreach (string extension in new[] { ".dll", ".exe" }) + { + string candidate = Path.Combine(publishDir, baseName + extension); + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } + + /// + /// Liest die Version einer Assembly, ohne sie zu laden. + /// ProductVersion entspricht InformationalVersion und damit dem, was + /// in der csproj unter <Version> steht. + /// + static string? ReadAssemblyVersion(string path) + { + try + { + var info = FileVersionInfo.GetVersionInfo(path); + + if (!string.IsNullOrWhiteSpace(info.ProductVersion)) + return info.ProductVersion!.Trim(); + + if (!string.IsNullOrWhiteSpace(info.FileVersion)) + return info.FileVersion!.Trim(); + } + catch { } + + try + { + var name = System.Reflection.AssemblyName.GetAssemblyName(path); + return name.Version?.ToString(); + } + catch { } + + return null; + } + + /// + /// Vergleicht nur den numerischen Kern. "1.4.3" und "1.4.3.0" sind + /// dieselbe Version; "1.4.3+abc123" ebenso - Build-Metadaten und + /// Vorabkennungen sind fuer diese Pruefung ohne Bedeutung. + /// + static bool VersionCoresMatch(string a, string b) + { + var coreA = VersionCore(a); + var coreB = VersionCore(b); + + int length = Math.Max(coreA.Count, coreB.Count); + for (int i = 0; i < length; i++) + { + int partA = i < coreA.Count ? coreA[i] : 0; + int partB = i < coreB.Count ? coreB[i] : 0; + if (partA != partB) + return false; + } + + return true; + } + + static List VersionCore(string version) + { + string value = (version ?? string.Empty).Trim().TrimStart('v', 'V'); + + int cut = value.IndexOfAny(new[] { '-', '+', ' ' }); + if (cut >= 0) + value = value.Substring(0, cut); + + var core = new List(); + foreach (string part in value.Split('.')) + { + string digits = new string(part.Where(char.IsDigit).ToArray()); + core.Add(digits.Length > 0 ? int.Parse(digits) : 0); + } + + if (core.Count == 0) + core.Add(0); + + return core; + } + + /// + /// Warnt vor Dateien, die nach Zugangsdaten aussehen und weder + /// ausgeschlossen noch geschuetzt sind. + /// + /// Eine settings.json mit Datenbankpasswort und DC-Token, die im + /// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt + /// beim Update die Konfiguration jedes Zielsystems. + /// + static void WarnAboutUnprotectedSecrets(List files, string publishDir, PackagerConfig config) + { + string[] suspicious = + { + "appsettings*.json", "settings.json", "*.config.json", ".env*", + "secrets.json", "connectionstrings.json", "*.pfx", "*.key", "*.pem" + }; + + var hits = new List(); + + foreach (var file in files) + { + string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); + + if (GlobMatcher.IsMatch(relPath, config.PreservePatterns)) + continue; + + if (GlobMatcher.IsMatch(relPath, suspicious)) + hits.Add(relPath); + } + + if (hits.Count == 0) + return; + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(); + Console.WriteLine("[WARNUNG] Diese Dateien sehen nach Konfiguration oder Zugangsdaten aus, stehen"); + Console.WriteLine(" aber weder unter excludePatterns noch unter preservePatterns:"); + foreach (var hit in hits) + { + Console.WriteLine($" - {hit}"); + } + Console.WriteLine(); + Console.WriteLine(" Sie werden mit ausgeliefert UND ueberschreiben beim Update die Fassung"); + Console.WriteLine(" auf dem Zielsystem. Entweder in excludePatterns (gar nicht ausliefern)"); + Console.WriteLine(" oder in preservePatterns (ausliefern, aber nie ersetzen) aufnehmen."); + Console.ResetColor(); + Console.WriteLine(); + } + /// /// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein /// vollstaendiges Modell dafuer zu benoetigen. @@ -468,32 +890,24 @@ namespace Deploymentcenter.Packager try { string json = File.ReadAllText(path); - var cfg = JsonSerializer.Deserialize(json); + var cfg = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (cfg != null) return cfg; } - catch { } + catch (Exception ex) + { + // Eine unlesbare Konfiguration still zu ignorieren hiesse, + // mit leeren Zugangsdaten weiterzumachen und den Nutzer + // ueber die Ursache im Unklaren zu lassen. + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[WARNUNG] {Path.GetFileName(path)} ist nicht lesbar: {ex.Message}"); + Console.WriteLine(" Es gelten Umgebungsvariablen und CLI-Argumente."); + Console.ResetColor(); + } } return new PackagerConfig(); } - static bool IsExcluded(string relPath, List patterns) - { - string fileName = Path.GetFileName(relPath); - foreach (var pattern in patterns) - { - if (pattern.StartsWith("*.")) - { - string ext = pattern.Substring(1); - if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true; - } - else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - static string ComputeSha256(string file) { using var sha256 = SHA256.Create(); @@ -520,6 +934,30 @@ namespace Deploymentcenter.Packager return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); } + static void ShowHelp() + { + Console.WriteLine(); + Console.WriteLine("Aufruf: pack-and-deploy [Optionen]"); + Console.WriteLine(); + Console.WriteLine(" --project, -p Projekt-Slug im Deploymentcenter"); + Console.WriteLine(" --version, -v Zu veroeffentlichende Version"); + Console.WriteLine(" --channel, -c prod | beta | dev (Vorgabe: prod)"); + Console.WriteLine(" --platform win-x64, linux-x64, ... (Vorgabe: aus dem"); + Console.WriteLine(" Publish-Pfad abgeleitet, sonst 'any')"); + Console.WriteLine(" --publish-dir, -d Ausgabe von dotnet publish"); + Console.WriteLine(" --changelog Aenderungshinweise"); + Console.WriteLine(" --critical Als kritisches Update kennzeichnen"); + Console.WriteLine(" --main-assembly Assembly fuer die Versionsgegenprobe"); + Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln"); + Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken"); + Console.WriteLine(" --config Abweichende packager.config.json"); + Console.WriteLine(" --token Token mit updateservice:publish"); + Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir"); + Console.WriteLine(); + Console.WriteLine("Rueckgabewerte: 0 vollstaendig, 1 Konfigurationsfehler, 2 teilweise fehlgeschlagen."); + Console.WriteLine(); + } + static string GetGitCommitLong() { try diff --git a/client-dotnet/Deploymentcenter.Packager/packager.config.example.json b/client-dotnet/Deploymentcenter.Packager/packager.config.example.json index 3245a8d..6d5e73a 100644 --- a/client-dotnet/Deploymentcenter.Packager/packager.config.example.json +++ b/client-dotnet/Deploymentcenter.Packager/packager.config.example.json @@ -11,6 +11,7 @@ "_apiToken_comment": "Token mit dem Recht updateservice:publish. Im WebUI unter Token-Verwaltung erzeugen.", "apiToken": "", + "_excludePatterns_comment": "Kommt gar nicht erst ins Paket. Echte Glob-Muster: * trifft innerhalb eines Ordners, ** ueber Ordnergrenzen, ? genau ein Zeichen. Muster ohne / gelten fuer den Dateinamen an beliebiger Stelle.", "excludePatterns": [ "*.pdb", "*.xml", @@ -20,5 +21,14 @@ "logs/**", "scratch/**", "*.tmp" + ], + + "_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber niemals eine vorhandene Datei. Fuer Konfigurationsvorlagen: die Erstinstallation bekommt sie, ein Update laesst die eingerichteten Werte in Ruhe. Ausschluss und Schutz sind zwei verschiedene Dinge - was hier fehlt und Zugangsdaten enthaelt, ueberschreibt beim Update die Konfiguration jedes Zielsystems.", + "preservePatterns": [ + "appsettings.json", + "appsettings.Production.json", + "settings.json", + "config.json", + ".env" ] } diff --git a/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj b/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj index c20b1f7..248c9af 100644 --- a/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj +++ b/client-dotnet/Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj @@ -6,10 +6,20 @@ enable enable Deploymentcenter Test Suite + Deploymentcenter.TestClient + 1.4.3 + + + diff --git a/client-dotnet/Deploymentcenter.TestClient/Program.cs b/client-dotnet/Deploymentcenter.TestClient/Program.cs index 43e12f0..467ca2e 100644 --- a/client-dotnet/Deploymentcenter.TestClient/Program.cs +++ b/client-dotnet/Deploymentcenter.TestClient/Program.cs @@ -282,7 +282,9 @@ class Program private static async Task TestUpdateServiceModuleAsync() { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]..."); + // Die vom Target erzeugte Klasse im eigenen Namensraum, nicht die des + // SDK: sie traegt die Version dieses Projekts ( in der csproj). + Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {BuildInfo.Summary}]..."); Console.ResetColor(); try diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Installer.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Installer.cs new file mode 100644 index 0000000..574d94c --- /dev/null +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Installer.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Deploymentcenter.Client; +using Deploymentcenter.Client.Models; + +namespace Deploymentcenter.UpdateAgent +{ + /// Was ein Anwenden tun wuerde beziehungsweise getan hat. + internal sealed class ApplyPlan + { + /// Relative Pfade, die geschrieben werden. + public List Write { get; } = new List(); + + /// Geschuetzte Dateien, die am Ziel bereits vorhanden sind. + public List Preserved { get; } = new List(); + + /// Dateien der Vorversion, die es im neuen Release nicht mehr gibt. + public List Orphans { get; } = new List(); + + /// Relativer Pfad des laufenden Agenten, falls er im Paket liegt. + public string? SelfPath { get; set; } + + /// Konnten verwaiste Dateien ueberhaupt bestimmt werden? + public bool OrphanDetectionPossible { get; set; } + } + + /// + /// Schreibt ein entpacktes Release ins Zielverzeichnis. + /// + /// Die Vorgaengerfassung war als "Atomic Replace with Backup" kommentiert, + /// tatsaechlich aber eine Kopierschleife: kein Backup, kein Rollback, kein + /// Aufraeumen. Brach sie in der Mitte ab - gesperrte Datei, volle Platte - + /// blieb eine halb aktualisierte Installation zurueck, aus der kein Weg + /// zurueckfuehrte. Dateien, die es im neuen Release nicht mehr gab, blieben + /// ausserdem fuer immer liegen; bei .NET ein realer Weg in kaputte + /// Assembly-Aufloesung. + /// + internal static class Installer + { + private const string BackupDirectoryName = ".dc-update-backup"; + + /// + /// Endung, unter der eine laufende ausfuehrbare Datei zur Seite gelegt + /// wird. Unter Windows laesst sich eine laufende Datei umbenennen, aber + /// nicht ueberschreiben oder loeschen - genau darauf baut der + /// Selbstaustausch auf. + /// + private const string ReplacedSuffix = ".dc-old"; + + /// + /// Bestimmt, was zu tun ist, ohne etwas zu veraendern. + /// + public static ApplyPlan BuildPlan( + string stagingDir, + string targetDir, + PackageManifest newManifest, + PackageManifest? installedManifest, + string? runningAgentPath) + { + var plan = new ApplyPlan(); + var preservePatterns = newManifest.Preserve ?? new List(); + + var staged = Directory + .GetFiles(stagingDir, "*", SearchOption.AllDirectories) + .Select(f => GlobMatcher.Normalize(Path.GetRelativePath(stagingDir, f))) + .ToList(); + + var stagedSet = new HashSet(staged, StringComparer.OrdinalIgnoreCase); + + foreach (string rel in staged) + { + string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); + + // Geschuetzte Dateien werden nur bei der Erstinstallation + // geschrieben. Ein Update darf die eingerichtete Konfiguration + // des Zielsystems nicht ersetzen. + if (File.Exists(targetPath) && GlobMatcher.IsMatch(rel, preservePatterns)) + { + plan.Preserved.Add(rel); + continue; + } + + if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath)) + { + plan.SelfPath = rel; + } + + plan.Write.Add(rel); + } + + // Verwaiste Dateien lassen sich nur bestimmen, wenn bekannt ist, + // was die Vorversion mitgebracht hat. Ohne dieses Wissen wird + // nichts geloescht - alles andere hiesse, fremde Dateien im + // Zielverzeichnis zu entfernen. + if (installedManifest?.Files != null && installedManifest.Files.Count > 0) + { + plan.OrphanDetectionPossible = true; + + foreach (var entry in installedManifest.Files) + { + string rel = GlobMatcher.Normalize(entry.Path); + + if (rel.Length == 0 || stagedSet.Contains(rel)) + continue; + + if (GlobMatcher.IsMatch(rel, preservePatterns)) + continue; + + string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(targetPath)) + continue; + + if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath)) + continue; + + plan.Orphans.Add(rel); + } + } + + return plan; + } + + /// + /// Fuehrt den Plan aus. Bei einem Fehler wird der Ausgangszustand + /// wiederhergestellt und die Ausnahme weitergereicht. + /// + public static void Apply( + string stagingDir, + string targetDir, + ApplyPlan plan, + Action? log = null) + { + Directory.CreateDirectory(targetDir); + + string backupDir = Path.Combine(targetDir, BackupDirectoryName); + + // Ein Rest aus einem frueheren Abbruch wuerde die Wiederherstellung + // mit fremden Daten fuettern. + SafeDeleteDirectory(backupDir); + Directory.CreateDirectory(backupDir); + + // Merkt sich je Datei, was zurueckzunehmen waere. + var backedUp = new List(); + var created = new List(); + string? renamedSelf = null; + + try + { + foreach (string rel in plan.Write) + { + string sourcePath = Path.Combine(stagingDir, rel.Replace('/', Path.DirectorySeparatorChar)); + string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + + bool isSelf = plan.SelfPath != null + && string.Equals(rel, plan.SelfPath, StringComparison.OrdinalIgnoreCase); + + if (File.Exists(targetPath)) + { + if (isSelf) + { + // Die laufende Datei kann nicht ueberschrieben, + // wohl aber umbenannt werden. Ohne diesen Schritt + // bricht das Update unter Windows mitten im + // Kopieren mit einer Zugriffsverletzung ab - und + // hinterliess bisher eine halbe Installation. + renamedSelf = targetPath + ReplacedSuffix; + SafeDelete(renamedSelf); + File.Move(targetPath, renamedSelf); + log?.Invoke($"Laufendes Agent-Binary zur Seite gelegt: {rel}"); + } + else + { + MoveToBackup(targetPath, targetDir, backupDir, rel); + backedUp.Add(rel); + } + } + else + { + created.Add(rel); + } + + File.Copy(sourcePath, targetPath, overwrite: true); + CopyExecutableBit(sourcePath, targetPath, rel); + } + + foreach (string rel in plan.Orphans) + { + string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(targetPath)) + continue; + + MoveToBackup(targetPath, targetDir, backupDir, rel); + backedUp.Add(rel); + log?.Invoke($"Entfernt (nicht mehr Teil des Releases): {rel}"); + } + } + catch + { + Rollback(targetDir, backupDir, backedUp, created, plan.SelfPath, renamedSelf, log); + throw; + } + + // Erst jetzt ist der alte Stand entbehrlich. + SafeDeleteDirectory(backupDir); + + // Die zur Seite gelegte eigene Datei laesst sich waehrend des + // Laufens nicht loeschen; das erledigt der naechste Start. + if (renamedSelf != null) + { + log?.Invoke("Der Agent hat sich selbst erneuert. Die alte Fassung wird beim naechsten Start entfernt."); + } + + RemoveEmptyDirectories(targetDir); + } + + /// + /// Raeumt Reste eines Selbstaustauschs weg. Wird beim Start aufgerufen, + /// weil die Datei zu diesem Zeitpunkt nicht mehr in Benutzung ist. + /// + public static void CleanupPreviousSelfUpdate(string directory) + { + try + { + if (!Directory.Exists(directory)) + return; + + foreach (string leftover in Directory.GetFiles(directory, "*" + ReplacedSuffix, SearchOption.TopDirectoryOnly)) + { + SafeDelete(leftover); + } + } + catch + { + // Ein liegengebliebener Rest ist unschoen, aber kein Grund, + // den Start zu verweigern. + } + } + + private static void Rollback( + string targetDir, + string backupDir, + List backedUp, + List created, + string? selfRel, + string? renamedSelf, + Action? log) + { + log?.Invoke("Update abgebrochen - stelle den vorherigen Stand wieder her ..."); + + // Neu angelegte Dateien wieder entfernen. + foreach (string rel in created) + { + try + { + SafeDelete(Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar))); + } + catch { } + } + + // Gesicherte Dateien zuruecklegen. + foreach (string rel in backedUp) + { + try + { + string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar)); + string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(backupPath)) + continue; + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + SafeDelete(targetPath); + File.Move(backupPath, targetPath); + } + catch (Exception ex) + { + log?.Invoke($"Wiederherstellung von {rel} fehlgeschlagen: {ex.Message}"); + } + } + + // Den eigenen Namen zuruecknehmen, falls schon umbenannt wurde. + if (renamedSelf != null && selfRel != null) + { + try + { + string targetPath = Path.Combine(targetDir, selfRel.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(targetPath) && File.Exists(renamedSelf)) + { + File.Move(renamedSelf, targetPath); + } + } + catch { } + } + + log?.Invoke("Vorheriger Stand wiederhergestellt."); + } + + private static void MoveToBackup(string targetPath, string targetDir, string backupDir, string rel) + { + string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + SafeDelete(backupPath); + File.Move(targetPath, backupPath); + } + + /// + /// Uebertraegt das Ausfuehrbar-Bit. + /// + /// Wird unter Windows fuer linux-x64 gebaut, kennt das tar-Archiv keine + /// Unix-Rechte und alles landet als 644 - die Anwendung liesse sich auf + /// dem Zielsystem nicht starten. Betroffen sind der Apphost (traegt + /// unter Linux keine Endung) und Shell-Skripte. + /// + private static void CopyExecutableBit(string sourcePath, string targetPath, string rel) + { + // Bewusst OperatingSystem.IsWindows() statt des eigenen Helfers: + // nur diese Form erkennt die Plattformanalyse als Absicherung der + // unter Windows nicht unterstuetzten Unix-Rechte-Aufrufe. + if (OperatingSystem.IsWindows()) + return; + + try + { + var mode = File.GetUnixFileMode(sourcePath); + + bool alreadyExecutable = + (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + + if (!alreadyExecutable && LooksExecutable(rel)) + { + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + } + + File.SetUnixFileMode(targetPath, mode); + } + catch + { + // Auf Dateisystemen ohne Unix-Rechte (etwa eine gemountete + // Windows-Freigabe) ist das schlicht nicht anwendbar. + } + } + + private static bool LooksExecutable(string rel) + { + string name = rel; + int slash = name.LastIndexOf('/'); + if (slash >= 0) + name = name.Substring(slash + 1); + + if (name.EndsWith(".sh", StringComparison.OrdinalIgnoreCase)) + return true; + + // Der Apphost einer .NET-Anwendung traegt unter Linux keine + // Endung. Dateien ohne Punkt sind hier die einzigen Kandidaten. + return name.IndexOf('.') < 0; + } + + /// + /// Entfernt Verzeichnisse, die durch das Aufraeumen leer geworden sind. + /// Das Zielverzeichnis selbst bleibt bestehen. + /// + private static void RemoveEmptyDirectories(string targetDir) + { + try + { + foreach (string dir in Directory + .GetDirectories(targetDir, "*", SearchOption.AllDirectories) + .OrderByDescending(d => d.Length)) + { + try + { + if (Directory.GetFileSystemEntries(dir).Length == 0) + Directory.Delete(dir); + } + catch { } + } + } + catch { } + } + + private static bool IsSameFile(string a, string b) + { + try + { + string fullA = Path.GetFullPath(a); + string fullB = Path.GetFullPath(b); + + var comparison = OperatingSystemHelpers.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + return string.Equals(fullA, fullB, comparison); + } + catch + { + return false; + } + } + + private static void SafeDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch { } + } + + private static void SafeDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch { } + } + } +} diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs index b4e4f81..fe22469 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Deploymentcenter.Client; using Deploymentcenter.Client.Models; @@ -19,89 +20,155 @@ namespace Deploymentcenter.UpdateAgent { private static readonly HttpClient HttpClient = new HttpClient(); + /// + /// Version, mit der gerechnet wird, wenn die installierte unbekannt + /// ist. Zuvor stand hier der Text "Unbekannt", der beim + /// Versionsvergleich als 0 gelesen wurde - der Agent hielt damit jede + /// Version fuer neuer, und die Reparatur suchte auf dem Server nach + /// einer Version namens "Unbekannt" und fand sie nie. + /// + private const string UnknownVersion = "0.0.0"; + + private const string PublicKeyFileName = "dc-release-pubkey.pem"; + static async Task Main(string[] args) { + string agentDir = AppContext.BaseDirectory; + + // Reste eines vorangegangenen Selbstaustauschs entfernen. Waehrend + // der Agent lief, war die alte Datei nicht loeschbar. + Installer.CleanupPreviousSelfUpdate(agentDir); + + if (HasFlag(args, "--help") || HasFlag(args, "-h")) + { + return ShowHelp(); + } + string project = GetArg(args, "--project", "-p") ?? "myapp"; string channel = GetArg(args, "--channel", "-c") ?? "prod"; string action = GetArg(args, "--action", "-a") ?? "interactive"; string version = GetArg(args, "--version", "-v") ?? "latest"; - string targetDir = GetArg(args, "--target-dir", "-t") ?? AppDomain.CurrentDomain.BaseDirectory; + string targetDir = GetArg(args, "--target-dir", "-t") ?? agentDir; string baseUrl = GetArg(args, "--base-url") ?? "https://dc.mhdf.de"; string restartApp = GetArg(args, "--restart") ?? ""; + string platform = PlatformId.Normalize(GetArg(args, "--platform") ?? GetArg(args, "--rid") ?? PlatformId.Current); targetDir = Path.GetFullPath(targetDir); - // Read current local manifest if present - string localManifestPath = Path.Combine(targetDir, "manifest.json"); - PackageManifest? currentManifest = null; - if (File.Exists(localManifestPath)) + var options = new AgentOptions { - try - { - string json = File.ReadAllText(localManifestPath); - currentManifest = JsonSerializer.Deserialize(json); - } - catch { } - } + BaseUrl = baseUrl, + Project = project, + Channel = channel, + Platform = platform, + TargetDir = targetDir, + RestartApp = restartApp, + RequireSignature = HasFlag(args, "--require-signature"), + PublicKeyPath = GetArg(args, "--pubkey"), + AgentDirectory = agentDir, + WaitForPid = ParsePid(GetArg(args, "--wait-for-pid")), + WaitTimeoutSeconds = ParseInt(GetArg(args, "--wait-timeout"), 60) + }; - string currentVersion = currentManifest?.Version ?? "Unbekannt"; - string currentBuildDate = currentManifest?.BuildDate ?? "Unbekannt"; - string currentGit = currentManifest?.GitCommitShort ?? "Unbekannt"; + PackageManifest? installedManifest = ReadInstalledManifest(targetDir); + + // Die installierte Version hat zwei moegliche Quellen: die + // manifest.json im Zielverzeichnis und die Angabe der Anwendung, + // die ihre einkompilierte Version kennt. Bei einer von Hand + // aufgesetzten Installation gibt es keine manifest.json - ohne + // --current-version blieb der Agent dort blind. + string? declaredCurrent = GetArg(args, "--current-version"); + string currentVersion = declaredCurrent + ?? installedManifest?.Version + ?? UnknownVersion; + + bool versionKnown = declaredCurrent != null || installedManifest?.Version != null; + + string currentBuildDate = installedManifest?.BuildDate ?? "unbekannt"; + string currentGit = installedManifest?.GitCommitShort ?? "unbekannt"; if (action.Equals("interactive", StringComparison.OrdinalIgnoreCase)) { - return await RunInteractiveMode(baseUrl, project, channel, currentVersion, currentBuildDate, currentGit, targetDir, restartApp); + return await RunInteractiveMode(options, currentVersion, versionKnown, + currentBuildDate, currentGit, installedManifest); } - // CLI Mode - return action.ToLowerInvariant() switch + switch (action.ToLowerInvariant()) { - "check" => await DoCheck(baseUrl, project, channel, currentVersion), - "update" => await DoUpdateOrRepair(baseUrl, project, channel, version, targetDir, currentManifest, restartApp), - "repair" => await DoUpdateOrRepair(baseUrl, project, channel, currentVersion, targetDir, currentManifest, restartApp), - "list" => await DoList(baseUrl, project, channel), - _ => ShowHelp() - }; + case "check": + return await DoCheck(options, currentVersion); + + case "update": + return await DoUpdateOrRepair(options, version, installedManifest); + + case "repair": + // Reparatur auf die tatsaechlich installierte Version. + // Ist sie unbekannt, kann nur die neueste gemeint sein - + // zuvor wurde nach der Version "Unbekannt" gesucht und die + // Reparatur brach genau dann ab, wenn man sie braucht. + return await DoUpdateOrRepair( + options, + versionKnown ? currentVersion : "latest", + installedManifest); + + case "list": + return await DoList(options); + + default: + return ShowHelp(); + } } + // ------------------------------------------------------------------ + // Modi + // ------------------------------------------------------------------ + static async Task RunInteractiveMode( - string baseUrl, - string project, - string channel, + AgentOptions options, string currentVer, + bool versionKnown, string currentBuildDate, string currentGit, - string targetDir, - string restartApp) + PackageManifest? installedManifest) { AnsiConsole.Write( new FigletText("UpdateAgent") .LeftJustified() .Color(Color.DodgerBlue1)); + string versionLabel = versionKnown ? currentVer : "unbekannt"; + var panel = new Panel( - $"[bold white]Projekt:[/] [cyan]{project}[/] [bold white]Kanal:[/] [yellow]{channel}[/]\n" + - $"[bold white]Installierte Version:[/] [green]{currentVer}[/] ({currentBuildDate}) [bold grey][Git: {currentGit}][/]\n" + - $"[bold white]Zielpfad:[/] [grey]{targetDir}[/]") + $"[bold white]Projekt:[/] [cyan]{options.Project}[/] [bold white]Kanal:[/] [yellow]{options.Channel}[/] [bold white]Plattform:[/] [yellow]{options.Platform}[/]\n" + + $"[bold white]Installierte Version:[/] [green]{versionLabel}[/] ({currentBuildDate}) [bold grey][Git: {currentGit}][/]\n" + + $"[bold white]Zielpfad:[/] [grey]{options.TargetDir}[/]") { Header = new PanelHeader("[bold blue] Deploymentcenter Update Agent [/]"), Border = BoxBorder.Rounded }; AnsiConsole.Write(panel); - AnsiConsole.MarkupLine("\n[grey]Lade verfügbare Releases...[/]"); - ReleaseManifest? releaseManifest = null; await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) - .StartAsync("Verbinde mit LEMP Server...", async ctx => + .StartAsync("Lade verfuegbare Releases ...", async ctx => { - releaseManifest = await FetchManifestAsync(baseUrl, project, channel); + releaseManifest = await FetchManifestAsync(options); }); - if (releaseManifest == null || releaseManifest.Latest == null) + if (releaseManifest?.Latest == null) { - AnsiConsole.MarkupLine("[bold red]Fehler: Koppelung zum LEMP Release Server fehlgeschlagen oder kein Release gefunden.[/]"); + AnsiConsole.MarkupLine("[bold red]Fehler: Es konnte kein Release ermittelt werden.[/]"); + AnsiConsole.MarkupLine($"[grey]Geprueft wurden die statische latest.json und die API unter {Markup.Escape(options.BaseUrl)}.[/]"); + + if (options.Platform != PlatformId.Any) + { + AnsiConsole.MarkupLine( + $"[grey]Gesucht wurde fuer die Plattform [yellow]{options.Platform}[/]. " + + "Existiert dort nur ein Paket fuer eine andere Plattform, ist das Absicht " + + "und kein Fehler.[/]"); + } + return 1; } @@ -109,20 +176,20 @@ namespace Deploymentcenter.UpdateAgent bool isUpdateAvailable = UpdateClient.IsVersionNewer(currentVer, latest.Version); var choices = new List(); - string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + (isUpdateAvailable ? "[bold green]← empfohlen[/]" : "[grey](aktuell)[/]"); + string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + + (isUpdateAvailable ? "[bold green]<- empfohlen[/]" : "[grey](aktuell)[/]"); choices.Add(latestLabel); int idx = 1; - var verMap = new Dictionary(); - verMap["latest"] = latest; + var verMap = new Dictionary { ["latest"] = latest }; foreach (var ver in releaseManifest.Versions) { string key = $"{idx++}"; string label = $"[{key}] Version {ver.Version} - {ver.BuildDate} [grey]({ver.GitCommitShort})[/]"; - if (ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase)) + if (versionKnown && ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase)) { - label += " [cyan]← aktuell installiert[/]"; + label += " [cyan]<- aktuell installiert[/]"; } choices.Add(label); verMap[key] = ver; @@ -133,8 +200,8 @@ namespace Deploymentcenter.UpdateAgent var selected = AnsiConsole.Prompt( new SelectionPrompt() - .Title("\n[bold white]Bitte wählen Sie eine Aktion:[/]") - .PageSize(10) + .Title("\n[bold white]Bitte waehlen Sie eine Aktion:[/]") + .PageSize(12) .AddChoices(choices)); if (selected.StartsWith("[Q]")) @@ -143,20 +210,21 @@ namespace Deploymentcenter.UpdateAgent return 0; } - string targetVersion = "latest"; + string targetVersion; + if (selected.StartsWith("[R]")) { - targetVersion = currentVer; - AnsiConsole.MarkupLine($"\n[bold yellow]Starte Reparatur der Version v{targetVersion}...[/]"); + targetVersion = versionKnown ? currentVer : "latest"; + AnsiConsole.MarkupLine($"\n[bold yellow]Starte Reparatur (v{targetVersion}) ...[/]"); } else if (selected.StartsWith("[L]")) { targetVersion = "latest"; - AnsiConsole.MarkupLine($"\n[bold green]Starte Update auf Version v{latest.Version}...[/]"); + AnsiConsole.MarkupLine($"\n[bold green]Starte Update auf v{latest.Version} ...[/]"); } else { - // Match index + targetVersion = "latest"; foreach (var entry in verMap) { if (entry.Key != "latest" && selected.StartsWith($"[{entry.Key}]")) @@ -165,88 +233,105 @@ namespace Deploymentcenter.UpdateAgent break; } } - AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von Version v{targetVersion}...[/]"); + AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von v{targetVersion} ...[/]"); } - return await DoUpdateOrRepair(baseUrl, project, channel, targetVersion, targetDir, null, restartApp); + return await DoUpdateOrRepair(options, targetVersion, installedManifest); } - static async Task DoCheck(string baseUrl, string project, string channel, string currentVer) + static async Task DoCheck(AgentOptions options, string currentVer) { var updateClient = new UpdateClient(HttpClient); - var res = await updateClient.CheckForUpdateAsync(baseUrl, project, currentVer, channel); + var res = await updateClient.CheckForUpdateAsync( + options.BaseUrl, options.Project, currentVer, options.Channel, options.Platform); + if (res.UpdateAvailable && res.LatestRelease != null) { Console.WriteLine($"UPDATE_AVAILABLE: {res.LatestRelease.Version} (Current: {currentVer})"); return 0; } + Console.WriteLine("UP_TO_DATE"); return 0; } - static async Task DoList(string baseUrl, string project, string channel) + static async Task DoList(AgentOptions options) { - var manifest = await FetchManifestAsync(baseUrl, project, channel); + var manifest = await FetchManifestAsync(options); if (manifest == null) { - Console.WriteLine("ERROR: Could not fetch manifest."); + Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar."); return 1; } - Console.WriteLine($"Releases for {project} [{channel}]:"); + Console.WriteLine($"Releases fuer {options.Project} [{options.Channel}/{options.Platform}]:"); foreach (var v in manifest.Versions) { - Console.WriteLine($" - v{v.Version} ({v.BuildDate}) [Git: {v.GitCommitShort}] SHA: {v.Sha256}"); + Console.WriteLine($" - v{v.Version} ({v.BuildDate}) [Git: {v.GitCommitShort}] [{v.Platform}] SHA: {v.Sha256}"); } return 0; } + // ------------------------------------------------------------------ + // Update und Reparatur + // ------------------------------------------------------------------ + static async Task DoUpdateOrRepair( - string baseUrl, - string project, - string channel, + AgentOptions options, string targetVersion, - string targetDir, - PackageManifest? currentManifest, - string restartApp) + PackageManifest? installedManifest) { string tempDir = Path.Combine(Path.GetTempPath(), "dc_update_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); try { - var releaseManifest = await FetchManifestAsync(baseUrl, project, channel); + var releaseManifest = await FetchManifestAsync(options); if (releaseManifest == null) { - AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte nicht abgerufen werden.[/]"); + AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]"); return 1; } - VersionInfo? targetRelease = null; - if (targetVersion.Equals("latest", StringComparison.OrdinalIgnoreCase)) - { - targetRelease = releaseManifest.Latest; - } - else - { - targetRelease = releaseManifest.Versions.FirstOrDefault(v => v.Version.Equals(targetVersion, StringComparison.OrdinalIgnoreCase)); - } + VersionInfo? targetRelease = targetVersion.Equals("latest", StringComparison.OrdinalIgnoreCase) + ? releaseManifest.Latest + : releaseManifest.Versions.FirstOrDefault(v => + v.Version.Equals(targetVersion, StringComparison.OrdinalIgnoreCase)); if (targetRelease == null) { - AnsiConsole.MarkupLine($"[bold red]Fehler: Version '{targetVersion}' wurde auf dem Server nicht gefunden.[/]"); + AnsiConsole.MarkupLine($"[bold red]Fehler: Version '{Markup.Escape(targetVersion)}' wurde nicht gefunden.[/]"); + + if (releaseManifest.Versions.Count > 0) + { + AnsiConsole.MarkupLine("[grey]Verfuegbar: " + + Markup.Escape(string.Join(", ", releaseManifest.Versions.Select(v => v.Version))) + + "[/]"); + AnsiConsole.MarkupLine("[grey]Aeltere Versionen werden aus der Liste genommen, sobald sie " + + "ueber die letzten 15 hinausgehen - die Dateien liegen dann noch auf dem Server, " + + "sind hier aber nicht mehr auswaehlbar.[/]"); + } + return 1; } - string pkgUrl = targetRelease.PackageUrl; - if (string.IsNullOrEmpty(pkgUrl)) + // ---------------------------------------------------------- + // Signatur pruefen + // ---------------------------------------------------------- + if (!await VerifyReleaseSignature(options, targetRelease)) { - pkgUrl = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/{targetRelease.Version}/package.tar.gz"; + return 1; } + string pkgUrl = string.IsNullOrEmpty(targetRelease.PackageUrl) + ? BuildPackageUrl(options, targetRelease.Version) + : targetRelease.PackageUrl; + string localPkgPath = Path.Combine(tempDir, "package.tar.gz"); - // 1. Download Package + // ---------------------------------------------------------- + // 1. Herunterladen + // ---------------------------------------------------------- await AnsiConsole.Progress() .Columns(new ProgressColumn[] { @@ -258,7 +343,7 @@ namespace Deploymentcenter.UpdateAgent }) .StartAsync(async ctx => { - var downloadTask = ctx.AddTask($"[green]Lade Package v{targetRelease.Version} herunter...[/]"); + var downloadTask = ctx.AddTask($"[green]Lade Paket v{targetRelease.Version} ...[/]"); using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead); resp.EnsureSuccessStatusCode(); @@ -282,67 +367,144 @@ namespace Deploymentcenter.UpdateAgent downloadTask.Value = 100; }); - // 2. Verify SHA256 - AnsiConsole.MarkupLine("[grey]Prüfe SHA256-Integrität des Pakets...[/]"); + // ---------------------------------------------------------- + // 2. Pruefsumme + // ---------------------------------------------------------- + AnsiConsole.MarkupLine("[grey]Pruefe SHA256 des Pakets ...[/]"); string computedSha = ComputeSha256(localPkgPath); if (!string.Equals(computedSha, targetRelease.Sha256, StringComparison.OrdinalIgnoreCase)) { - AnsiConsole.MarkupLine($"[bold red]Sicherheitsfehler: SHA256-Hash stimmt nicht überein![/]"); + AnsiConsole.MarkupLine("[bold red]Sicherheitsfehler: SHA256 stimmt nicht ueberein![/]"); AnsiConsole.MarkupLine($"Erwartet: {targetRelease.Sha256}"); AnsiConsole.MarkupLine($"Erhalten: {computedSha}"); return 1; } - AnsiConsole.MarkupLine("[bold green][✔] SHA256 Hash erfolgreich verifiziert.[/]"); + AnsiConsole.MarkupLine("[bold green] SHA256 verifiziert.[/]"); - // 3. Extract to temp staging directory + // ---------------------------------------------------------- + // 3. Entpacken + // ---------------------------------------------------------- string extractDir = Path.Combine(tempDir, "extracted"); Directory.CreateDirectory(extractDir); - AnsiConsole.MarkupLine("[grey]Entpacke Archiv (tar.gz)...[/]"); + AnsiConsole.MarkupLine("[grey]Entpacke Archiv ...[/]"); using (var fs = File.OpenRead(localPkgPath)) using (var gz = new GZipStream(fs, CompressionMode.Decompress)) { TarFile.ExtractToDirectory(gz, extractDir, overwriteFiles: true); } - // 4. Verify extracted files against manifest.json + // ---------------------------------------------------------- + // 4. Dateien gegen das Paketmanifest pruefen + // ---------------------------------------------------------- + PackageManifest? packageManifest = null; string extractedManifestPath = Path.Combine(extractDir, "manifest.json"); + if (File.Exists(extractedManifestPath)) { string mJson = await File.ReadAllTextAsync(extractedManifestPath); - var pkgManifest = JsonSerializer.Deserialize(mJson); - if (pkgManifest != null) + packageManifest = JsonSerializer.Deserialize(mJson); + + if (packageManifest != null) { - var integrity = UpdateClient.VerifyIntegrity(extractDir, pkgManifest); + var integrity = UpdateClient.VerifyIntegrity(extractDir, packageManifest); if (!integrity.IsValid) { - AnsiConsole.MarkupLine("[bold red]Fehler bei Dateivalidierung nach Entpacken![/]"); - foreach (var missing in integrity.MissingFiles) AnsiConsole.MarkupLine($" - Fehlt: {missing}"); - foreach (var corrupt in integrity.CorruptedFiles) AnsiConsole.MarkupLine($" - Beschädigt: {corrupt}"); + AnsiConsole.MarkupLine("[bold red]Fehler bei der Dateipruefung nach dem Entpacken![/]"); + foreach (var missing in integrity.MissingFiles) AnsiConsole.MarkupLine($" - Fehlt: {Markup.Escape(missing)}"); + foreach (var corrupt in integrity.CorruptedFiles) AnsiConsole.MarkupLine($" - Beschaedigt: {Markup.Escape(corrupt)}"); return 1; } - AnsiConsole.MarkupLine($"[bold green][✔] {integrity.TotalCheckedFiles} Dateien gegen Manifest-Hashes verifiziert.[/]"); + AnsiConsole.MarkupLine($"[bold green] {integrity.TotalCheckedFiles} Dateien gegen Manifest-Hashes verifiziert.[/]"); + + // Ein Paket der falschen Plattform waere hier noch + // aufzuhalten - danach liegt es im Zielverzeichnis. + if (!PlatformId.IsAny(packageManifest.Platform) + && !PlatformId.IsAny(options.Platform) + && !string.Equals(PlatformId.Normalize(packageManifest.Platform), options.Platform, + StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine( + $"[bold red]Abbruch: Das Paket ist fuer [yellow]{Markup.Escape(packageManifest.Platform)}[/] gebaut, " + + $"dieses System ist [yellow]{Markup.Escape(options.Platform)}[/].[/]"); + return 1; + } + } + } + else + { + AnsiConsole.MarkupLine("[yellow]Hinweis: Das Paket enthaelt keine manifest.json. " + + "Verwaiste Dateien der Vorversion koennen nicht bestimmt werden.[/]"); + } + + // ---------------------------------------------------------- + // 5. Auf das Ende der Anwendung warten + // ---------------------------------------------------------- + if (!WaitForProcessExit(options)) + { + return 1; + } + + // ---------------------------------------------------------- + // 6. Anwenden + // ---------------------------------------------------------- + packageManifest ??= new PackageManifest + { + ProjectId = options.Project, + Version = targetRelease.Version, + Channel = options.Channel, + Platform = options.Platform + }; + + string? runningAgent = GetRunningAgentPath(); + + var plan = Installer.BuildPlan(extractDir, options.TargetDir, packageManifest, + installedManifest, runningAgent); + + AnsiConsole.MarkupLine($"[grey]Uebertrage {plan.Write.Count} Datei(en) ...[/]"); + + if (plan.Preserved.Count > 0) + { + AnsiConsole.MarkupLine($"[grey]{plan.Preserved.Count} geschuetzte Datei(en) bleiben unveraendert:[/]"); + foreach (var p in plan.Preserved) + { + AnsiConsole.MarkupLine($"[grey] - {Markup.Escape(p)}[/]"); } } - // 5. Apply Update (Atomic Replace with Backup) - AnsiConsole.MarkupLine("[grey]Übertrage neue Dateien in Zielverzeichnis...[/]"); - ApplyFiles(extractDir, targetDir); - - AnsiConsole.MarkupLine($"\n[bold green]🚀 Update auf Version v{targetRelease.Version} erfolgreich abgeschlossen![/]"); - - // 6. Restart App if requested - if (!string.IsNullOrEmpty(restartApp) && File.Exists(restartApp)) + if (plan.Orphans.Count > 0) { - AnsiConsole.MarkupLine($"[grey]Starte Hauptanwendung neu ({Path.GetFileName(restartApp)})...[/]"); - Process.Start(new ProcessStartInfo { FileName = restartApp, UseShellExecute = true }); + AnsiConsole.MarkupLine($"[grey]{plan.Orphans.Count} nicht mehr benoetigte Datei(en) werden entfernt.[/]"); } + else if (!plan.OrphanDetectionPossible) + { + AnsiConsole.MarkupLine("[grey]Ohne manifest.json der Vorversion wird nichts geloescht.[/]"); + } + + try + { + Installer.Apply(extractDir, options.TargetDir, plan, + msg => AnsiConsole.MarkupLine($"[grey]{Markup.Escape(msg)}[/]")); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[bold red]Update fehlgeschlagen: {Markup.Escape(ex.Message)}[/]"); + AnsiConsole.MarkupLine("[bold green]Der vorherige Stand wurde wiederhergestellt.[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"\n[bold green]Update auf v{targetRelease.Version} abgeschlossen.[/]"); + + // ---------------------------------------------------------- + // 7. Anwendung wieder starten + // ---------------------------------------------------------- + RestartApplication(options); return 0; } catch (Exception ex) { - AnsiConsole.MarkupLine($"[bold red]Fehler während des Update-Vorgangs: {ex.Message}[/]"); + AnsiConsole.MarkupLine($"[bold red]Fehler waehrend des Updates: {Markup.Escape(ex.Message)}[/]"); return 1; } finally @@ -351,39 +513,505 @@ namespace Deploymentcenter.UpdateAgent } } - static void ApplyFiles(string sourceDir, string targetDir) + // ------------------------------------------------------------------ + // Release-Quellen + // ------------------------------------------------------------------ + + /// + /// Holt die Release-Liste - erst statisch, dann ueber die API. + /// + /// Zuvor las der Agent ausschliesslich die latest.json. Ging der + /// FTP-Upload schief, waehrend die Registrierung im Deploymentcenter + /// klappte, sagte die Anwendung "Update verfuegbar" und der Agent "kein + /// Release gefunden". Der Rueckfall auf die API stand nur im SDK. + /// + static async Task FetchManifestAsync(AgentOptions options) { - Directory.CreateDirectory(targetDir); - - // Copy recursively, overwriting files - foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) + var fromStatic = await FetchStaticManifestAsync(options); + if (fromStatic?.Latest != null) { - Directory.CreateDirectory(dirPath.Replace(sourceDir, targetDir)); + return fromStatic; } - foreach (string newPath in Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories)) - { - string targetPath = newPath.Replace(sourceDir, targetDir); - File.Copy(newPath, targetPath, true); - } + return await FetchApiManifestAsync(options); } - static async Task FetchManifestAsync(string baseUrl, string project, string channel) + static async Task FetchStaticManifestAsync(AgentOptions options) + { + // Plattformunabhaengige Releases liegen weiterhin im alten Pfad + // ohne Zwischenebene. + string segment = PlatformId.PathSegment(options.Platform); + + var candidates = new List + { + $"{options.BaseUrl.TrimEnd('/')}/releases/{options.Project}/{options.Channel}{segment}/latest.json" + }; + + if (segment.Length > 0) + { + candidates.Add($"{options.BaseUrl.TrimEnd('/')}/releases/{options.Project}/{options.Channel}/latest.json"); + } + + foreach (string url in candidates) + { + try + { + var resp = await HttpClient.GetAsync(url); + if (!resp.IsSuccessStatusCode) + continue; + + string json = await resp.Content.ReadAsStringAsync(); + var manifest = JsonSerializer.Deserialize(json); + + if (manifest == null) + continue; + + // Der Rueckfall auf den plattformlosen Pfad darf kein + // Paket einer fremden Plattform liefern. + manifest.Versions = manifest.Versions + .Where(v => IsAcceptablePlatform(v.Platform, options.Platform)) + .ToList(); + + if (manifest.Latest != null && !IsAcceptablePlatform(manifest.Latest.Platform, options.Platform)) + { + manifest.Latest = null; + } + + manifest.Latest ??= manifest.Versions + .OrderByDescending(v => v.Version, Comparer.Create(UpdateClient.CompareVersions)) + .FirstOrDefault(); + + if (manifest.Latest != null) + return manifest; + } + catch + { + // Naechster Kandidat. + } + } + + return null; + } + + /// + /// Baut die Release-Liste aus der API. Die Plattformaufloesung + /// geschieht hier, damit auch plattformunabhaengige Releases + /// beruecksichtigt werden. + /// + static async Task FetchApiManifestAsync(AgentOptions options) { try { - string url = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/latest.json"; + string url = $"{options.BaseUrl.TrimEnd('/')}/api/updateservice/v1/releases" + + $"?product={Uri.EscapeDataString(options.Project)}" + + $"&channel={Uri.EscapeDataString(options.Channel)}"; + var resp = await HttpClient.GetAsync(url); - if (resp.IsSuccessStatusCode) + if (!resp.IsSuccessStatusCode) + return null; + + string json = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("releases", out var releasesProp) + || releasesProp.ValueKind != JsonValueKind.Array) { - string json = await resp.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json); + return null; + } + + var manifest = new ReleaseManifest + { + ProjectId = options.Project, + Channel = options.Channel, + Platform = options.Platform + }; + + // Je Version das plattformgenaue Paket bevorzugen, sonst das + // plattformunabhaengige - dieselbe Regel wie auf dem Server. + var byVersion = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var element in releasesProp.EnumerateArray()) + { + var info = JsonSerializer.Deserialize(element.GetRawText()); + if (info == null || string.IsNullOrWhiteSpace(info.Version)) + continue; + + if (!IsAcceptablePlatform(info.Platform, options.Platform)) + continue; + + var candidate = info.ToVersionInfo(); + + if (byVersion.TryGetValue(info.Version, out var existing)) + { + bool existingIsAny = PlatformId.IsAny(existing.Platform); + bool candidateIsAny = PlatformId.IsAny(candidate.Platform); + + if (existingIsAny && !candidateIsAny) + { + byVersion[info.Version] = candidate; + } + } + else + { + byVersion[info.Version] = candidate; + } + } + + manifest.Versions = byVersion.Values + .OrderByDescending(v => v.Version, Comparer.Create(UpdateClient.CompareVersions)) + .ToList(); + + manifest.Latest = manifest.Versions.FirstOrDefault(); + + return manifest.Latest == null ? null : manifest; + } + catch + { + return null; + } + } + + /// + /// Darf ein Paket dieser Plattform auf diesem System installiert + /// werden? "any" passt immer; sonst muss die Kennung uebereinstimmen. + /// + static bool IsAcceptablePlatform(string? releasePlatform, string systemPlatform) + { + string release = PlatformId.Normalize(releasePlatform); + + if (release == PlatformId.Any) + return true; + + // Ein System ohne bestimmbare Kennung bekommt nur 'any'. + if (PlatformId.IsAny(systemPlatform)) + return false; + + return string.Equals(release, systemPlatform, StringComparison.OrdinalIgnoreCase); + } + + static string BuildPackageUrl(AgentOptions options, string version) + { + string segment = PlatformId.PathSegment(options.Platform); + return $"{options.BaseUrl.TrimEnd('/')}/releases/{options.Project}/{options.Channel}{segment}/{version}/package.tar.gz"; + } + + // ------------------------------------------------------------------ + // Signatur + // ------------------------------------------------------------------ + + /// + /// Prueft die Serversignatur des Releases, sofern eine vorliegt. + /// Rueckgabe false bedeutet: nicht weitermachen. + /// + static async Task VerifyReleaseSignature(AgentOptions options, VersionInfo release) + { + string? publicKey = await LoadPublicKeyAsync(options); + + if (string.IsNullOrWhiteSpace(release.Signature)) + { + // Die statische latest.json fuehrt keine Signatur - sie wird + // vom Packager geschrieben, der den Schluessel bewusst nicht + // besitzt. Bei Bedarf wird sie hier ueber die API nachgeholt. + string? fetched = await FetchSignatureFromApiAsync(options, release); + if (fetched != null) + { + release.Signature = fetched; + } + } + + if (string.IsNullOrWhiteSpace(release.Signature)) + { + if (options.RequireSignature) + { + AnsiConsole.MarkupLine("[bold red]Abbruch: Das Release ist nicht signiert, --require-signature ist gesetzt.[/]"); + return false; + } + + AnsiConsole.MarkupLine("[yellow]Hinweis: Das Release ist nicht signiert - die Herkunft laesst sich " + + "nicht pruefen. Auf dem Server security.release_private_key hinterlegen.[/]"); + return true; + } + + if (string.IsNullOrWhiteSpace(publicKey)) + { + if (options.RequireSignature) + { + AnsiConsole.MarkupLine("[bold red]Abbruch: Kein oeffentlicher Schluessel verfuegbar, --require-signature ist gesetzt.[/]"); + return false; + } + + AnsiConsole.MarkupLine("[yellow]Hinweis: Signatur vorhanden, aber kein oeffentlicher Schluessel - nicht geprueft.[/]"); + return true; + } + + string canonical = ReleaseVerifier.BuildCanonical( + options.Project, + release.Version, + options.Channel, + PlatformId.Normalize(release.Platform), + release.Sha256, + release.PackageUrl, + release.SizeBytes); + + if (ReleaseVerifier.Verify(canonical, release.Signature, publicKey)) + { + AnsiConsole.MarkupLine("[bold green] Signatur des Deploymentcenters verifiziert.[/]"); + return true; + } + + AnsiConsole.MarkupLine("[bold red]Sicherheitsfehler: Die Signatur des Releases ist ungueltig.[/]"); + AnsiConsole.MarkupLine("[red]Entweder wurden die Release-Daten veraendert, oder der hinterlegte " + + "oeffentliche Schluessel passt nicht zum Server.[/]"); + return false; + } + + /// + /// Laedt den oeffentlichen Schluessel: erst der ausdruecklich + /// angegebene, dann der zwischengespeicherte, zuletzt der vom Server. + /// + /// Ein einmal gespeicherter Schluessel wird nicht stillschweigend + /// ersetzt: taeuscht jemand den Server vor, faellt der Wechsel auf. + /// + static async Task LoadPublicKeyAsync(AgentOptions options) + { + if (!string.IsNullOrWhiteSpace(options.PublicKeyPath)) + { + try + { + return await File.ReadAllTextAsync(options.PublicKeyPath!); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[yellow]Oeffentlicher Schluessel nicht lesbar: {Markup.Escape(ex.Message)}[/]"); + return null; + } + } + + string cachePath = Path.Combine(options.AgentDirectory, PublicKeyFileName); + string? cached = null; + + if (File.Exists(cachePath)) + { + try { cached = await File.ReadAllTextAsync(cachePath); } catch { } + } + + string? fromServer = await FetchPublicKeyAsync(options); + + if (cached != null && fromServer != null) + { + string cachedPrint = ReleaseVerifier.Fingerprint(cached.Trim()); + string serverPrint = ReleaseVerifier.Fingerprint(fromServer.Trim()); + + if (!string.Equals(cachedPrint, serverPrint, StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine("[bold red]Warnung: Der Server meldet einen anderen Signierschluessel als den " + + "hier hinterlegten.[/]"); + AnsiConsole.MarkupLine($"[red]Hinterlegt: {cachedPrint.Substring(0, 16)}...[/]"); + AnsiConsole.MarkupLine($"[red]Server: {serverPrint.Substring(0, 16)}...[/]"); + AnsiConsole.MarkupLine("[red]Es wird weiter der hinterlegte Schluessel benutzt. War der Wechsel " + + $"beabsichtigt, die Datei {PublicKeyFileName} loeschen.[/]"); + } + + return cached; + } + + if (cached != null) + return cached; + + if (fromServer != null) + { + try + { + await File.WriteAllTextAsync(cachePath, fromServer); + AnsiConsole.MarkupLine($"[grey]Signierschluessel des Servers uebernommen " + + $"({ReleaseVerifier.Fingerprint(fromServer.Trim()).Substring(0, 16)}...).[/]"); + } + catch { } + + return fromServer; + } + + return null; + } + + static async Task FetchPublicKeyAsync(AgentOptions options) + { + try + { + var resp = await HttpClient.GetAsync($"{options.BaseUrl.TrimEnd('/')}/api/updateservice/v1/pubkey"); + if (!resp.IsSuccessStatusCode) + return null; + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + + if (doc.RootElement.TryGetProperty("public_key", out var keyProp) + && keyProp.ValueKind == JsonValueKind.String) + { + string? pem = keyProp.GetString(); + return string.IsNullOrWhiteSpace(pem) ? null : pem; } } catch { } + return null; } + static async Task FetchSignatureFromApiAsync(AgentOptions options, VersionInfo release) + { + try + { + string url = $"{options.BaseUrl.TrimEnd('/')}/api/updateservice/v1/releases" + + $"?product={Uri.EscapeDataString(options.Project)}" + + $"&channel={Uri.EscapeDataString(options.Channel)}"; + + var resp = await HttpClient.GetAsync(url); + if (!resp.IsSuccessStatusCode) + return null; + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + + if (!doc.RootElement.TryGetProperty("releases", out var releasesProp) + || releasesProp.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var element in releasesProp.EnumerateArray()) + { + var info = JsonSerializer.Deserialize(element.GetRawText()); + + if (info != null + && string.Equals(info.Version, release.Version, StringComparison.OrdinalIgnoreCase) + && string.Equals(PlatformId.Normalize(info.Platform), PlatformId.Normalize(release.Platform), + StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(info.ManifestSignature)) + { + return info.ManifestSignature; + } + } + } + catch { } + + return null; + } + + // ------------------------------------------------------------------ + // Prozesssteuerung + // ------------------------------------------------------------------ + + /// + /// Wartet, bis die uebergebene Prozesskennung beendet ist. + /// + /// Ohne diesen Handschlag startete die Anwendung den Agenten und rief + /// unmittelbar Environment.Exit auf. Bei einem langsamen Herunterfahren + /// - Kestrel, EF, Log-Flush - kopierte der Agent ueber noch gesperrte + /// Dateien. + /// + static bool WaitForProcessExit(AgentOptions options) + { + if (options.WaitForPid <= 0) + return true; + + Process process; + try + { + process = Process.GetProcessById(options.WaitForPid); + } + catch (ArgumentException) + { + // Schon beendet - der Normalfall bei zuegigem Herunterfahren. + return true; + } + + AnsiConsole.MarkupLine($"[grey]Warte auf das Ende der Anwendung (PID {options.WaitForPid}) ...[/]"); + + using (process) + { + if (process.WaitForExit(options.WaitTimeoutSeconds * 1000)) + { + // Windows gibt Dateihandles minimal verzoegert frei. + Thread.Sleep(250); + return true; + } + } + + AnsiConsole.MarkupLine( + $"[bold red]Abbruch: Die Anwendung (PID {options.WaitForPid}) laeuft nach " + + $"{options.WaitTimeoutSeconds} Sekunden noch.[/]"); + AnsiConsole.MarkupLine("[red]Es wurde nichts veraendert. Anwendung beenden und erneut versuchen.[/]"); + return false; + } + + static void RestartApplication(AgentOptions options) + { + if (string.IsNullOrEmpty(options.RestartApp)) + return; + + string path = options.RestartApp; + + // Ein relativer Pfad ist auf das Zielverzeichnis bezogen. + if (!Path.IsPathRooted(path)) + { + path = Path.Combine(options.TargetDir, path); + } + + if (!File.Exists(path)) + { + AnsiConsole.MarkupLine($"[yellow]Neustart uebersprungen: {Markup.Escape(path)} existiert nicht.[/]"); + return; + } + + try + { + AnsiConsole.MarkupLine($"[grey]Starte {Markup.Escape(Path.GetFileName(path))} neu ...[/]"); + Process.Start(new ProcessStartInfo + { + FileName = path, + WorkingDirectory = options.TargetDir, + UseShellExecute = true + }); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[yellow]Neustart fehlgeschlagen: {Markup.Escape(ex.Message)}[/]"); + } + } + + /// Vollstaendiger Pfad der laufenden Agent-Datei. + static string? GetRunningAgentPath() + { + try + { + string? path = Environment.ProcessPath; + return string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path); + } + catch + { + return null; + } + } + + // ------------------------------------------------------------------ + // Hilfsfunktionen + // ------------------------------------------------------------------ + + static PackageManifest? ReadInstalledManifest(string targetDir) + { + string path = Path.Combine(targetDir, "manifest.json"); + + if (!File.Exists(path)) + return null; + + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch + { + return null; + } + } + static string ComputeSha256(string file) { using var sha256 = SHA256.Create(); @@ -392,6 +1020,16 @@ namespace Deploymentcenter.UpdateAgent return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } + static int ParsePid(string? value) + { + return int.TryParse(value, out int pid) && pid > 0 ? pid : 0; + } + + static int ParseInt(string? value, int fallback) + { + return int.TryParse(value, out int parsed) && parsed > 0 ? parsed : fallback; + } + static string? GetArg(string[] args, string flagLong, string? flagShort = null) { for (int i = 0; i < args.Length - 1; i++) @@ -405,18 +1043,47 @@ namespace Deploymentcenter.UpdateAgent return null; } + static bool HasFlag(string[] args, string flag) + { + return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); + } + static int ShowHelp() { Console.WriteLine("Deploymentcenter Update Agent"); - Console.WriteLine("Usage: update-agent [options]"); - Console.WriteLine("Options:"); - Console.WriteLine(" --project, -p Project slug"); - Console.WriteLine(" --channel, -c Channel (prod, beta, dev)"); - Console.WriteLine(" --action, -a interactive | check | update | repair | list"); - Console.WriteLine(" --version, -v Target version or 'latest'"); - Console.WriteLine(" --target-dir, -t Directory to update"); - Console.WriteLine(" --restart Executable to restart upon completion"); + Console.WriteLine("Aufruf: update-agent [Optionen]"); + Console.WriteLine(); + Console.WriteLine(" --project, -p Projekt-Slug"); + Console.WriteLine(" --channel, -c prod | beta | dev"); + Console.WriteLine(" --action, -a interactive | check | update | repair | list"); + Console.WriteLine(" --version, -v Zielversion oder 'latest'"); + Console.WriteLine(" --target-dir, -t Zu aktualisierendes Verzeichnis"); + Console.WriteLine(" --platform Laufzeitkennung (Vorgabe: die des Systems)"); + Console.WriteLine(" --base-url Adresse des Deploymentcenters"); + Console.WriteLine(" --current-version Installierte Version, wenn keine manifest.json vorliegt"); + Console.WriteLine(" --wait-for-pid Vor dem Anwenden auf das Ende dieses Prozesses warten"); + Console.WriteLine(" --wait-timeout Geduld dabei (Vorgabe: 60)"); + Console.WriteLine(" --restart Nach dem Update zu startende Anwendung"); + Console.WriteLine(" --pubkey Oeffentlicher Schluessel zur Signaturpruefung"); + Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren"); + Console.WriteLine(); return 0; } } + + /// Gebuendelte Aufrufparameter. + internal sealed class AgentOptions + { + public string BaseUrl { get; set; } = "https://dc.mhdf.de"; + public string Project { get; set; } = "myapp"; + public string Channel { get; set; } = "prod"; + public string Platform { get; set; } = PlatformId.Any; + public string TargetDir { get; set; } = string.Empty; + public string RestartApp { get; set; } = string.Empty; + public bool RequireSignature { get; set; } + public string? PublicKeyPath { get; set; } + public string AgentDirectory { get; set; } = string.Empty; + public int WaitForPid { get; set; } + public int WaitTimeoutSeconds { get; set; } = 60; + } } diff --git a/config/config.example.php b/config/config.example.php index 734366e..901e8a5 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -49,6 +49,15 @@ return [ 'webhook_key' => dc_env('DC_WEBHOOK_KEY', ''), // Signaturschlüssel für Offline-Lizenzdateien (.lic). 'license_key' => dc_env('DC_LICENSE_SIGNING_KEY', ''), + // Privater RSA-Schlüssel, mit dem Releases signiert werden. Entweder + // das PEM selbst oder ein Pfad zur Schlüsseldatei. + // openssl genrsa -out /etc/dc/release-signing.pem 2048 + // chmod 600 /etc/dc/release-signing.pem + // Leer lassen schaltet das Signieren ab; Releases bleiben dann + // installierbar, der Agent weist aber bei jedem Update darauf hin. + // Bewusst asymmetrisch: geprüft wird auf fremden Systemen, dort darf + // der Signierschlüssel nicht liegen. + 'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', ''), ], 'bugtracker' => [ // Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet. diff --git a/docs/LICENSE_INTEGRATION_GUIDE.md b/docs/LICENSE_INTEGRATION_GUIDE.md index be7e0a3..66df5d6 100644 --- a/docs/LICENSE_INTEGRATION_GUIDE.md +++ b/docs/LICENSE_INTEGRATION_GUIDE.md @@ -16,6 +16,18 @@ > > Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)** +> **⚠️ Änderungen in Version 2.1 — betreffen jede bestehende Integration** +> - **Ein HTTP-Fehler ist kein Lizenzurteil mehr.** `ValidateAsync` fällt jetzt +> bei jedem erfolglosen Aufruf auf den Offline-Cache zurück, nicht nur bei +> einer Exception. Der Status `unknown_error` entfällt, siehe **[§6](#6-fehlerfälle-richtig-behandeln)**. +> - **`cache_ttl_hours` wird ausgewertet.** Die Offline-Gnadenfrist ist damit +> tatsächlich begrenzt, siehe **[§3](#3-zustandsspeicher-statestorecs--cache-härtung)**. +> - `ValidateAsync` nimmt `appVersion` und einen `CancellationToken` entgegen. +> - Neu: `LicenseClient.TryGetCachedKey()` und `RevalidateAsync()`. +> - `DeactivateAsync` sendet den Schlüssel nur noch als `Authorization: Bearer`. +> Der zusätzliche `X-Watchdog-Key` ist entfallen. +> - Standard-Zeitgrenze von 15 s statt 100 s. + --- ## 0. Antwortformat — bitte beachten @@ -123,12 +135,18 @@ public class Program string licenseKey = "LLAB1-98A72-B3C4D-5E6F7-89012"; // 3. Online-Validierung durchführen - LicenseValidationResult res = await client.ValidateAsync(ProductSlug, licenseKey, ServerUrl); + LicenseValidationResult res = await client.ValidateAsync( + ProductSlug, licenseKey, ServerUrl, appVersion: BuildInfo.Version); if (res.IsValid) { Console.WriteLine($"[✔] Lizenz gültig! (Status: {res.Status}, Cached: {res.IsCached})"); } + else if (res.IsTransient) + { + // Kein Urteil, nur keine Verbindung. Weiterlaufen lassen. + Console.WriteLine($"[!] Lizenz vorerst nicht prüfbar: {res.Message}"); + } else { Console.WriteLine($"[✖] Lizenz ungültig: {res.Message}"); @@ -138,6 +156,53 @@ public class Program } ``` +### 2.2 Signatur + +```csharp +Task ValidateAsync( + string productSlug, + string licenseKey, + string serverBaseUrl, + string? appVersion = null, + CancellationToken cancellationToken = default); +``` + +`appVersion` landet in der Aktivierungsliste des WebUI. Ohne Angabe greift +`LicenseClient.DefaultAppVersion` und danach die Version der Startassembly. +Einmal beim Start setzen genügt: + +```csharp +LicenseClient.DefaultAppVersion = BuildInfo.Version; +``` + +> Vorher stand hier fest `"1.0.0"`. In der Verwaltungsansicht trug dadurch jede +> Installation dieselbe Version, obwohl die Spalte `app_version` dafür da ist. + +### 2.3 Ergebnisfelder + +| Feld | Bedeutung | +|---|---| +| `IsValid` | Darf die Anwendung laufen? | +| `Status` | `valid`, `valid_offline`, `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `cache_expired`, `clock_rollback`, `server_unavailable` | +| `IsTransient` | **Neu.** Wahr, wenn kein Urteil vorliegt, sondern nur die Verbindung scheiterte | +| `IsCached` | Ergebnis stammt aus dem Offline-Cache | +| `ExpiresAt` | Ablauf der Lizenz (Unix-Zeit) | +| `CacheExpiresAt` | **Neu.** Ende der Offline-Gnadenfrist (Unix-Zeit) | + +### 2.4 Zwischengespeicherten Schlüssel lesen + +```csharp +// Für --license-status: was liegt lokal vor? +string? key = LicenseClient.TryGetCachedKey(ProductSlug); +LocalCacheData? state = LicenseClient.TryGetCachedState(ProductSlug); + +// Erneut prüfen, ohne den Schlüssel selbst vorhalten zu müssen +var res = await client.RevalidateAsync(ProductSlug, ServerUrl); +``` + +`ValidateAsync` legt den Schlüssel bei erfolgreicher Prüfung selbst im +verschlüsselten Cache ab — eine eigene Ablage braucht es dafür nicht. + --- ## 3. Zustandsspeicher (`StateStore.cs`) & Cache-Härtung @@ -148,11 +213,46 @@ public class Program - **Linux**: Dateirechte `0600` (`chmod 600 state.dat`). - **Sicherheitsvorgabe**: Kein Klartext-Rückfall! Beschädigte oder manipulierte Cache-Dateien werden strikt als Cache-Fehltreffer behandelt. +### 3.1 Offline-Gnadenfrist + +Der Server meldet in jeder Antwort `cache_ttl_hours` — den projektspezifischen +Wert aus `dc_projects.default_cache_ttl_hours`, den du im WebUI unter +**Projekte** einstellst. Der Client trägt daraus `CacheExpiresAt` in den Cache +ein: **frühestens** die TTL, spätestens der Ablauf der Lizenz selbst. + +> Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum der Lizenz als +> Cache-Ablauf eingetragen. Bei einer Lizenz bis 2040 war die Frist ohne +> Serverkontakt damit praktisch unbegrenzt — die Einstellung im WebUI blieb +> wirkungslos. + +Wer eine eigene Anbindung schreibt: `cache_ttl_hours` auswerten, nicht durch +`expires_at` ersetzen. + +### 3.2 Schema-Stand des Caches + +`state.dat` trägt jetzt Schema 3 (zusätzlich `CacheExpiresAt`, `CacheTtlHours`). +Schema 2 wird weiterhin gelesen; die Frist wird dort aus `IssuedAt` plus der +Standard-TTL von 168 h abgeleitet. Ein SDK-Wechsel erzwingt also **keine** +Online-Prüfung. + +### 3.3 Migration fremder Cache-Dateien + +Eine Datei ohne `LLS2`-Kopf wird nur übernommen, wenn ihr `ProductSlug` zu +diesem Produkt passt und sie einen Lizenzschlüssel enthält. + +> Vorher wurde beliebiges JSON nach `LocalCacheData` deserialisiert und sofort +> zurückgeschrieben. Passte kein einziges Feld, entstand ein Standardobjekt, das +> die ursprüngliche Datei überschrieb. Da LicenseLabrador denselben Pfad und +> Dateinamen verwendet — `GetStorageDirectory` berücksichtigt eigens +> `LICENSELABRADOR_STORAGE_DIR` —, zerstörte das dessen Cache still. + --- ## 4. Kopfloser Betrieb (Headless Services / systemd) -Für Hintergrunddienste (ohne GUI) stehen folgende CLI-Schalter am Anwendungshost zur Verfügung: +> **Diese Schalter baut deine Anwendung selbst** — das SDK bringt keine +> CLI mit. Es liefert die Bausteine dafür; die folgende Empfehlung ist eine +> Konvention, damit alle Dienste gleich zu bedienen sind. ```bash # Status der Hardware-ID und des lokalen Caches ausgeben @@ -165,6 +265,52 @@ my-service --license-set-key LLAB1-98A72-B3C4D-5E6F7-89012 my-service --license-deactivate ``` +Umsetzung mit den Bausteinen des SDK: + +```csharp +var client = new LicenseClient(); + +switch (args[0]) +{ + case "--license-status": + { + var hw = HardwareId.GetHardwareId(ProductSlug); + var state = LicenseClient.TryGetCachedState(ProductSlug); + + Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})"); + Console.WriteLine($"Schlüssel: {LicenseClient.TryGetCachedKey(ProductSlug) ?? "keiner"}"); + Console.WriteLine($"Cache: {(state == null ? "leer" : state.Status)}"); + + if (state != null && state.CacheExpiresAt > 0) + { + var until = DateTimeOffset.FromUnixTimeSeconds(state.CacheExpiresAt); + Console.WriteLine($"Offline noch bis: {until:u}"); + } + return 0; + } + + case "--license-set-key": + // ValidateAsync legt den Schlüssel bei Erfolg selbst im Cache ab. + var res = await client.ValidateAsync(ProductSlug, args[1], ServerUrl); + Console.WriteLine(res.Message); + return res.IsValid ? 0 : 1; + + case "--license-deactivate": + string? key = LicenseClient.TryGetCachedKey(ProductSlug); + if (key == null) { Console.Error.WriteLine("Kein Schlüssel hinterlegt."); return 1; } + + bool ok = await client.DeactivateAsync( + ProductSlug, key, ServerUrl, + Environment.GetEnvironmentVariable("DC_SHARED_KEY") ?? ""); + return ok ? 0 : 1; +} +``` + +> `TryGetCachedKey` und `TryGetCachedState` sind neu in 2.1. Vorher gab es +> keinen Weg, den Schlüssel aus dem eigenen Cache zu lesen — jede Anwendung +> musste ihn ein zweites Mal selbst ablegen oder direkt `StateStore.Load()` +> aufrufen. + ### Deaktivierung braucht Authentifizierung `/api/license/v1/deactivate` gibt einen Aktivierungsplatz frei und ist deshalb @@ -192,13 +338,12 @@ curl -X POST https://dc.mhdf.de/api/license/v1/deactivate \ Alternativ genügt für Einzelfälle der Knopf **Freigeben** in der Hardware-Liste des WebUI — das ist der übliche Weg und braucht keinen Schlüssel im Feld. -### Was passiert bei Ratenbegrenzung - -`/validate` ist auf 120 Anfragen pro Minute und IP begrenzt. Darüber kommt -`429` mit `{"status":"error","error":{"code":"rate_limited"}}` — hier greift -ausnahmsweise das Umschlagformat, weil die Drosselung vor der Lizenzlogik -zuschlägt. Ein Client sollte in dem Fall den lokalen Cache verwenden und es -später erneut versuchen, statt die Anwendung zu blockieren. +> **Nur noch `Authorization: Bearer`.** Frühere SDK-Fassungen schickten denselben +> Wert zusätzlich als `X-Watchdog-Key`. Der Name führte in die Irre — hier gehört +> der `shared_key` hin, kein Watchdog-Token — und der Server prüft +> `X-Watchdog-Key` **vor** `Authorization`. Ein dort versehentlich eingetragenes +> Watchdog-Token hätte das richtige Bearer-Token stillschweigend verdrängt und +> zu einem unerklärlichen 401 geführt. --- @@ -208,3 +353,66 @@ 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! + +--- + +## 6. Fehlerfälle richtig behandeln + +**Nur ein gelesenes Urteil ist ein Urteil.** Ein HTTP-Fehler sagt nichts über +die Lizenz aus — er sagt, dass die Frage nicht beantwortet wurde. + +`ValidateAsync` fällt deshalb bei **jedem** erfolglosen Aufruf auf den +Offline-Cache zurück: Netzfehler, Zeitüberschreitung, `429`, `5xx`, und auch bei +einer erfolgreichen Antwort, die sich nicht auswerten lässt (etwa der +Fehlerseite eines Proxys mit Statuscode 200). + +```csharp +var res = await client.ValidateAsync(ProductSlug, key, ServerUrl); + +if (res.IsValid) +{ + // valid oder valid_offline — weiterarbeiten +} +else if (res.IsTransient) +{ + // server_unavailable, cache_expired: kein Urteil, nur keine Auskunft. + // Warnen, weiterlaufen lassen, später erneut prüfen. + logger.Warn(res.Message); +} +else +{ + // revoked, expired, not_found, activation_limit, suspended, clock_rollback + // Das ist ein Urteil. Jetzt darf beendet werden. + Environment.Exit(1); +} +``` + +| Status | `IsTransient` | Bedeutung | +|---|---|---| +| `valid` | – | Vom Server bestätigt | +| `valid_offline` | – | Aus dem Cache, Gnadenfrist läuft noch | +| `server_unavailable` | **ja** | Keine Verbindung **und** kein brauchbarer Cache | +| `cache_expired` | **ja** | Gnadenfrist abgelaufen, Server weiterhin stumm | +| `revoked`, `expired`, `not_found`, `activation_limit`, `suspended` | nein | Endgültig | +| `clock_rollback` | nein | Systemuhr zurückgedreht, Online-Prüfung nötig | + +> **Was sich geändert hat:** Der Cache-Zweig lag zuvor ausschließlich im +> `catch`. Ein `429` oder `500` wirft aber keine Exception — die Prüfung fiel aus +> dem Erfolgszweig heraus und endete als `unknown_error`, **ohne den Cache auch +> nur zu befragen**. Ein Server-Schluckauf entzog damit die Lizenz, ein gezogenes +> Netzkabel nicht. Der Status `unknown_error` existiert nicht mehr. + +### Ratenbegrenzung + +`/validate` ist auf 120 Anfragen pro Minute und IP begrenzt. Darüber kommt +`429` mit `{"status":"error","error":{"code":"rate_limited"}}` — hier greift +ausnahmsweise das Umschlagformat, weil die Drosselung vor der Lizenzlogik +zuschlägt. Hinter einem NAT mit vielen Installationen ist das erreichbar. Der +Client verwendet in diesem Fall den lokalen Cache und versucht es später erneut. + +### Zeitgrenze + +Der intern erzeugte `HttpClient` hat jetzt 15 s statt der 100 s des .NET-Standards +— sonst stand eine Anwendung beim Start bis zu anderthalb Minuten still, wenn +der Server nicht antwortete. Ein selbst übergebener `HttpClient` wird nicht +verändert; dessen Zeitgrenze verantwortet der Aufrufer. diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md index f06c49d..e1999ec 100644 --- a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -6,6 +6,26 @@ > jetzt der semantischen Versionsordnung, `1.10.0` gilt also korrekt als neuer > als `1.9.0`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**. +> **⚠️ Geändert in Version 2.1** +> - `Deploymentcenter.BuildInfo.targets` erzeugt die Klasse jetzt im Namensraum +> des einbindenden Projekts. Die vorherige Fassung war nicht einbindbar +> (CS0433), siehe **[§2B](#b-msbuild-buildinfo-generierung)**. +> - Der API-Rückfall in `CheckForUpdateAsync` liest die Antwort jetzt korrekt. +> Fehlte die `latest.json`, kamen zuvor weder Download-Adresse noch Prüfsumme, +> Changelog oder Kritikalität an, siehe **[§2C](#c-zwei-quellen-zwei-formate)**. + +> **⚠️ Geändert in Version 2.2 — bitte vollständig lesen, bevor das nächste +> Release gebaut wird.** Umstellungsschritte: **[UPGRADE.md §15](./UPGRADE.md#15-umstellung-auf-22)**. +> - **Releases tragen eine Plattform.** Ohne sie überschrieben sich `win-x64` +> und `linux-x64` gegenseitig, siehe **[§1A](#1a-plattformen)**. +> - **Konfigurationsdateien überleben ein Update.** Bisher überschrieb jedes +> Update die eingerichteten Werte des Zielsystems, siehe **[§3A](#3a-ausschließen-oder-schützen)**. +> - **Anwenden mit Rollback.** Ein Abbruch hinterlässt keine halbe Installation +> mehr, und entfernte Dateien werden aufgeräumt, siehe **[§4B](#4b-wie-ein-update-angewendet-wird)**. +> - **Releases sind signiert**, siehe **[§6](#6-signatur-der-releases)**. +> - `LaunchUpdateAgent` übergibt jetzt Neustart, Prozesskennung und Plattform, +> siehe **[§2A](#a-referenz-auf-deploymentcenterclient)**. + Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API). @@ -16,10 +36,56 @@ Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, - **Kein dauerhafter Background-Dienst**: Hauptanwendungen prüfen beim Start einmalig schnell und netzwerktolerant auf verfügbare Updates und Dateiintegrität. - **Entkoppelte Ausführung**: Bei Handlungsbedarf beendet sich die Hauptanwendung sauber und übergibt die Kontrolle an den eigenständigen Console Agent (`update-agent.exe` / `update-agent`). - **3-Kanal-System**: Kanäle `prod` (Produktiv), `beta` (Vorab-Test), `dev` (Entwicklung). +- **Plattform-Dimension**: je Kanal getrennte Pakete für `win-x64`, `linux-x64` usw. - **Statische LEMP-Verteilung**: Downloads und Versionen-Manifeste (`latest.json`, `manifest.json`, `package.tar.gz`) werden über Nginx extrem performant bereitgestellt. --- +## 1A. Plattformen + +Ein Release wird durch **vier** Angaben bestimmt: Projekt, Kanal, Version und +Plattform. Die Plattform ist eine .NET-Laufzeitkennung (`win-x64`, +`linux-x64`, `linux-musl-arm64`, `osx-arm64`, …) oder `any` für Pakete, die +überall laufen. + +Zuvor gab es diese Dimension nicht. Wer für zwei Plattformen baute, veröffentlichte +beide Pakete unter derselben Version im selben Kanal — das zweite überschrieb +das erste, und ein Linux-System zog sich das Windows-Paket. Behelfe waren +getrennte Projekt-Slugs (`myapp-win`, `myapp-linux`) oder zweckentfremdete +Kanäle; beides trug nicht weit. + +### Auswahlregel + +| Client schickt | bekommt | +|---|---| +| `platform=win-x64` | Pakete mit `win-x64`, ersatzweise `any` | +| `platform=linux-x64` | Pakete mit `linux-x64`, ersatzweise `any` | +| nichts | **ausschließlich** `any` | + +Der letzte Fall ist Absicht. Ein Client, der seine Kennung nicht nennt, soll +lieber kein Update bekommen als das Paket einer fremden Plattform. Alle vor +Version 2.2 veröffentlichten Releases stehen auf `any` und bleiben damit für +bereits ausgelieferte Anwendungen erreichbar. + +Je Version gewinnt das plattformgenaue Paket; gibt es keines, wird das +plattformunabhängige genommen. + +### Ablage + +Plattformunabhängige Releases behalten den bisherigen Pfad, alle anderen +bekommen eine Zwischenebene: + +```text +/releases/myapp/prod/1.4.3/package.tar.gz ← platform = any +/releases/myapp/prod/win-x64/1.4.3/package.tar.gz ← platform = win-x64 +/releases/myapp/prod/linux-x64/1.4.3/package.tar.gz ← platform = linux-x64 +``` + +Ohne diese Rücksicht wären alle bestehenden Installationen von einem Tag auf +den anderen von ihren Updates abgeschnitten gewesen. + +--- + ## 2. Integration in .NET Client-Anwendungen ### A. Referenz auf `Deploymentcenter.Client` @@ -37,32 +103,118 @@ var checkResult = await updateClient.CheckForUpdateAsync( projectId: "myapp", currentVersion: BuildInfo.Version, channel: "prod" + // platform: entfällt - ohne Angabe die Kennung des laufenden Systems ); if (checkResult.UpdateAvailable) { Console.WriteLine($"[UPDATE] Neues Release v{checkResult.LatestRelease.Version} verfügbar!"); - // UpdateAgent starten und Hauptanwendung beenden + var agentPath = UpdateClient.ResolveAgentPath(); + if (agentPath == null) + { + Console.WriteLine("[UPDATE] Kein update-agent gefunden."); + return; + } + UpdateClient.LaunchUpdateAgent( - agentPath: "update-agent.exe", - projectId: "myapp", - channel: "prod", - action: "update", - version: "latest", + agentPath: agentPath, + projectId: "myapp", + channel: "prod", + action: "update", + version: "latest", + currentVersion: BuildInfo.Version, exitCurrentApp: true ); } ``` +**Nicht mehr `"update-agent.exe"` fest verdrahten.** `ResolveAgentPath()` +liefert den plattformrichtigen Namen — unter Linux und macOS trägt das Binary +keine Endung, ein fester `.exe`-Name wird dort nie gefunden, und die Anwendung +meldet stumm „kein Agent vorhanden". + +Drei Dinge erledigt `LaunchUpdateAgent` seit 2.2 von selbst: + +| Was | Warum | +|---|---| +| `--restart` mit dem Pfad der eigenen Anwendung | Zuvor wurde der Parameter nie übergeben. Der Agent unterstützte ihn, bekam ihn aber nie zu sehen — die Anwendung schloss sich und blieb zu | +| `--wait-for-pid` mit der eigenen Prozesskennung | Zuvor wurde der Agent gestartet und sofort `Environment.Exit(0)` gerufen. Bei langsamem Herunterfahren (Kestrel, EF, Log-Flush) kopierte er über noch gesperrte Dateien | +| `--platform` mit der Kennung des Systems | Verhindert, dass ein Paket der falschen Plattform gezogen wird | + +Abschaltbar über `restartPath: ""` bzw. `waitForCurrentProcess: false`, wenn +ein Dienst-Manager den Neustart übernimmt. + +`currentVersion` ist wichtig für Installationen ohne `manifest.json` — siehe +**[§4A](#4a-welche-version-ist-installiert)**. + ### B. MSBuild BuildInfo Generierung Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden: ```xml + + 1.4.3 + + ``` +Die Klasse entsteht im Namensraum deines Projekts (`$(RootNamespace)`), nicht im +SDK. Du erreichst sie also ohne `using`: + +```csharp +Console.WriteLine(BuildInfo.Version); // "1.4.3" aus +Console.WriteLine(BuildInfo.Summary); // v1.4.3 (5f9b0c5) built on ... [prod] +``` + +Verfügbare Werte: `Version`, `GitCommit`, `GitCommitShort`, `BuildDateUtc`, +`Channel`, `Summary`. Ohne Git-Arbeitskopie stehen die Commit-Felder auf +`UNKNOWN`, der Build läuft trotzdem durch. + +Überschreibbare MSBuild-Eigenschaften: + +| Eigenschaft | Wirkung | +|---|---| +| `DeploymentcenterBuildInfoNamespace` | Zielnamensraum (Vorgabe: `$(RootNamespace)`) | +| `DeploymentcenterBuildInfoClass` | Klassenname (Vorgabe: `BuildInfo`) | +| `BuildChannel` | `prod`, `beta`, `dev` (Vorgabe: `prod`) | +| `GenerateDeploymentcenterBuildInfo` | auf `false` setzen, um die Erzeugung abzuschalten | + +> **Nicht auf `Deploymentcenter.Client.Models` zeigen lassen.** Dort liefert das +> SDK bereits eine gleichnamige Klasse aus; `partial` verbindet Teilklassen nur +> innerhalb derselben Assembly. Genau daran scheiterte die vorherige Fassung des +> Targets: sie erzeugte fest in diesen Namensraum, wodurch im Consumer ein +> zweiter Typ mit demselben vollen Namen entstand (CS0433) und der generierte +> statische Konstruktor Eigenschaften setzte, die es dort nicht gab (CS0103). +> Ein Import war damit unmöglich. + +### C. Zwei Quellen, zwei Formate + +`CheckForUpdateAsync` fragt zuerst die statische +`/releases/{projectId}/{channel}/latest.json` ab und fällt auf +`/api/updateservice/v1/check` zurück. **Die beiden liefern unterschiedliche +Feldnamen:** + +| Bedeutung | `latest.json` (Packager) | API-Antwort (Datenbank) | +|---|---|---| +| Download-Adresse | `packageUrl` | `download_url` | +| Prüfsumme | `sha256` | `sha256_hash` | +| Änderungshinweise | `changelog` | `release_notes` | +| Größe | `sizeBytes` | `size_bytes` | +| Kritisch | `isCritical` | `is_critical` **auf oberster Ebene** | + +Das SDK bildet beide ab: `VersionInfo` für die `latest.json`, `ApiReleaseInfo` +für die API-Antwort. `UpdateCheckResult.LatestRelease` ist in beiden Fällen ein +`VersionInfo` — für die API wird übersetzt. + +> Wer die API selbst anspricht, muss das berücksichtigen. Die vorherige SDK-Fassung +> deserialisierte die API-Antwort direkt nach `VersionInfo`; von beiden Formaten +> stimmt nur `version` überein. Über den API-Weg kam deshalb nichts weiter an — +> und `IsCritical` wurde aus dem Release-Objekt statt vom Wurzelfeld gelesen und +> war damit immer `false`. Da dieser Zweig genau der Rückfall ist, wenn die +> `latest.json` fehlt, degradierte die Update-Prüfung still. + --- ## 3. Packaging & Deployment CLI (`pack-and-deploy`) @@ -72,10 +224,16 @@ Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeu ### Aufruf-Beispiel: ```bash -# Automatisierter Release-Publish via CLI -pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung" +pack-and-deploy --project myapp --version 1.4.0 --channel prod \ + --platform win-x64 \ + --publish-dir ./bin/Release/net8.0/win-x64/publish \ + --changelog "Fehlerbehebungen und Performance-Optimierung" ``` +Ohne `--platform` versucht der Packager, die Kennung aus dem Publish-Pfad zu +lesen (`.../net8.0/linux-x64/publish` → `linux-x64`). Gelingt das nicht, gilt +das Release als plattformunabhängig und es erscheint eine Warnung. + ### Konfiguration (`packager.config.json`) > Diese Datei enthält Zugangsdaten und ist per `.gitignore` von der @@ -98,13 +256,19 @@ pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./b "appsettings.Development.json", "*.log", "logs/**" + ], + "preservePatterns": [ + "appsettings.json", + "settings.json", + ".env" ] } ``` `apiToken` braucht das Recht `updateservice:publish`. Ohne Token baut und lädt der Packager das Paket zwar hoch, meldet es aber nicht beim Deploymentcenter an -und beendet sich mit Rückgabewert 2. +und beendet sich mit Rückgabewert 2. Ohne Registrierung entsteht auch **keine +Signatur**. ### Alternative: Umgebungsvariablen @@ -118,7 +282,8 @@ export DC_FTP_PASS='...' export DC_TOKEN='dc_sub_...' pack-and-deploy --project myapp --version 1.4.0 --channel prod \ - --publish-dir ./bin/Release/net8.0/publish + --platform linux-x64 \ + --publish-dir ./bin/Release/net8.0/linux-x64/publish ``` ### Rückgabewerte @@ -126,7 +291,7 @@ pack-and-deploy --project myapp --version 1.4.0 --channel prod \ | Wert | Bedeutung | |---|---| | `0` | Paket gebaut, hochgeladen und im Deploymentcenter registriert | -| `1` | Konfiguration unvollständig oder Publish-Verzeichnis fehlt — nichts wurde ausgeführt | +| `1` | Konfiguration unvollständig, Publish-Verzeichnis fehlt oder Versionskonflikt — nichts wurde ausgeführt | | `2` | Teilweise fehlgeschlagen: FTP-Upload oder Registrierung ging schief | Zuvor lieferte das Werkzeug in allen Fällen `0` und meldete „successfully @@ -134,6 +299,65 @@ published", selbst wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren. --- +## 3A. Ausschließen oder schützen + +Das sind zwei verschiedene Dinge, und die Unterscheidung ist der Grund, warum +Updates bisher Konfigurationen zerstört haben. + +| | `excludePatterns` | `preservePatterns` | +|---|---|---| +| Im Paket? | nein | ja | +| Bei der Erstinstallation? | fehlt | wird geschrieben | +| Beim Update? | — | vorhandene Datei bleibt unangetastet | +| Wofür | Build-Artefakte, Logs, Entwicklungs-Einstellungen | Konfigurationsvorlagen | + +Eine `appsettings.json` gehört ins Paket — sonst ist eine Erstinstallation +unvollständig. Sie darf beim Update nur nicht über die eingerichteten Werte des +Zielsystems geschrieben werden. Genau dafür ist `preservePatterns` da; die +Liste wandert ins `manifest.json` und wird vom Agenten ausgewertet. + +> **Vorher:** `IsExcluded` verstand ausschließlich `*.endung` und exakte +> Namen. Die mitgelieferte Beispielkonfiguration enthielt `logs/**` und +> `scratch/**` — beides traf **nie** zu. Und eine `settings.json` mit +> Datenbankpasswort und DC-Token stand auf keiner der beiden Listen: sie wurde +> mitgeliefert und überschrieb beim Update die Konfiguration jedes Zielsystems. + +Die Muster sind jetzt echte Globs: + +| Muster | trifft | +|---|---| +| `*.pdb` | jede `.pdb` in jedem Unterverzeichnis | +| `logs/**` | alles unterhalb von `logs/` | +| `wwwroot/*.css` | nur direkt in `wwwroot/`, nicht darunter | +| `wwwroot/**/*.css` | auch in Unterverzeichnissen | +| `appsettings*.json` | `appsettings.json`, `appsettings.Production.json`, … | + +Der Packager warnt zusätzlich von sich aus, wenn eine Datei nach Zugangsdaten +aussieht und auf keiner der beiden Listen steht. + +### Versionsgegenprobe + +Der Packager liest die Version aus der Hauptassembly und bricht bei einer +Abweichung zu `--version` ab: + +``` +[FEHLER] Versionskonflikt: + --version sagt : 1.0.2 + MyApp.dll sagt : 1.0.1 +``` + +Der Grund dafür ist unangenehm genug, um dafür abzubrechen: Wird `1.0.1` als +`1.0.2` veröffentlicht, aktualisieren alle Clients, melden danach weiterhin +`1.0.1`, halten das Release erneut für neu — und aktualisieren bei jedem Start +wieder. Eine Endlosschleife über die gesamte Installationsbasis. + +Üblicher Auslöser: `` steht nur in einem der beteiligten Projekte. Der +Wert gehört in die `Directory.Build.props`. Notausgang für bewusste +Abweichungen: `--ignore-version-mismatch`. Lässt sich die Assembly nicht +bestimmen, wird nur gewarnt — `--main-assembly` gibt sie gezielt an. + +--- + ## 4. Standalone UpdateAgent (`update-agent`) Der `update-agent` kann sowohl interaktiv (Spectre.Console Terminal UI) als auch im Headless CLI-Modus betrieben werden. @@ -157,16 +381,92 @@ update-agent --project myapp --channel prod --action repair update-agent --project myapp --channel prod --action list ``` +Zusätzliche Parameter seit 2.2: + +| Parameter | Wirkung | +|---|---| +| `--platform ` | Laufzeitkennung; Vorgabe ist die des laufenden Systems | +| `--current-version ` | Installierte Version, wenn keine `manifest.json` vorliegt | +| `--wait-for-pid ` | Vor dem Anwenden auf das Ende dieses Prozesses warten | +| `--wait-timeout ` | Geduld dabei (Vorgabe 60). Läuft der Prozess danach noch, wird **nichts** verändert | +| `--pubkey ` | Öffentlicher Schlüssel zur Signaturprüfung | +| `--require-signature` | Ohne gültige Signatur nicht installieren | + --- -## 4a. Prüf-Endpunkte (für eigene Anbindungen) +## 4A. Welche Version ist installiert? + +Es gab zwei Antworten darauf, und sie widersprachen sich: Die Anwendung +verglich `BuildInfo.Version` (einkompiliert), der Agent las `manifest.json` im +Zielverzeichnis. Fehlte diese Datei — etwa bei einer von Hand aufgesetzten +Installation — meldete der Agent „Unbekannt" und hielt **jede** Version für +neuer. Die Reparatur suchte dann auf dem Server nach einer Version namens +„Unbekannt" und brach genau dann ab, wenn man sie braucht. + +Die Reihenfolge ist jetzt: + +1. `--current-version`, falls übergeben — die Anwendung kennt ihre eigene Version am sichersten +2. `manifest.json` im Zielverzeichnis +3. sonst `0.0.0`, und die Reparatur greift auf `latest` zurück + +Deshalb sollte `LaunchUpdateAgent` immer `currentVersion: BuildInfo.Version` +mitgeben. + +--- + +## 4B. Wie ein Update angewendet wird + +Die Stelle war als „Atomic Replace with Backup" kommentiert und war +tatsächlich eine Kopierschleife: kein Backup, kein Rollback, kein Aufräumen. +Brach sie in der Mitte ab — gesperrte Datei, volle Platte —, blieb eine halb +aktualisierte Installation zurück, aus der kein Weg zurückführte. + +Der Ablauf ist jetzt: + +1. **Plan bilden.** Welche Dateien werden geschrieben, welche sind geschützt, + welche gehören nicht mehr zum Release? +2. **Sichern.** Jede Datei, die überschrieben oder entfernt wird, wandert + vorher nach `.dc-update-backup/`. +3. **Anwenden.** Schreiben, dann verwaiste Dateien entfernen. +4. **Bei einem Fehler:** vollständiger Rollback aus dem Backup, danach wird die + Ursache gemeldet. Die Installation bleibt auf dem alten Stand lauffähig. +5. **Bei Erfolg:** Backup löschen, leer gewordene Verzeichnisse entfernen. + +### Verwaiste Dateien + +Eine DLL, die es im neuen Release nicht mehr gibt, blieb bisher für immer im +Verzeichnis liegen — bei .NET ein realer Weg in kaputte Assembly-Auflösung. +Sie wird jetzt entfernt, aber **nur**, wenn sie in der `manifest.json` der +Vorversion stand. Ohne dieses Wissen wird nichts gelöscht; Dateien, die nicht +aus einem Release stammen, bleiben in jedem Fall unangetastet. + +### Der Agent im Paket + +`--target-dir` zeigt in der Vorgabe auf das Verzeichnis des Agenten selbst. +Liegt der Agent im Paket, kopierte er sich also unter laufendem Betrieb über +sich selbst — unter Windows eine Zugriffsverletzung mitten im Update. + +Eine laufende ausführbare Datei lässt sich unter Windows nicht überschreiben, +aber umbenennen. Der Agent legt sich deshalb als `update-agent.exe.dc-old` zur +Seite, schreibt die neue Fassung und entfernt den Rest beim nächsten Start. + +### Ausführungsrechte + +Wird unter Windows für `linux-x64` gebaut, kennt das tar-Archiv keine +Unix-Rechte und alles landet als `644` — die Anwendung ließe sich auf dem +Zielsystem nicht starten. Der Agent setzt das Ausführungsbit beim Anwenden für +Dateien ohne Endung (der .NET-Apphost) und für `*.sh`. + +--- + +## 4C. Prüf-Endpunkte (für eigene Anbindungen) Die Lese-Endpunkte sind bewusst **ohne Token** erreichbar, damit ausgelieferte Anwendungen ohne Anpassung weiter nach Updates suchen können. Sie liefern nur Release-Metadaten, die über die Download-URL ohnehin öffentlich sind. ```bash -GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod +GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod&platform=win-x64 ``` ```json @@ -175,25 +475,33 @@ GET /api/updateservice/v1/check?product=myapp&version=1.4.2&channel=prod "update_available": true, "current_version": "1.4.2", "latest_version": "1.4.3", + "platform": "win-x64", "is_critical": false, "latest_release": { "version": "1.4.3", - "download_url": "https://dc.mhdf.de/releases/myapp/prod/1.4.3/package.tar.gz", + "platform": "win-x64", + "download_url": "https://dc.mhdf.de/releases/myapp/prod/win-x64/1.4.3/package.tar.gz", "sha256_hash": "e3b0c442...", "git_commit": "a21536f", "size_bytes": 8412160, "release_notes": "Behebt den Login-Fehler.", + "manifest_signature": "hsuQVhef...", "is_critical": 0 } } ``` +> **Ohne `platform` werden ausschließlich Releases mit `platform=any` +> berücksichtigt.** Wer die Endpunkte selbst anspricht und für mehrere +> Plattformen ausliefert, muss den Parameter mitschicken. + Weitere Endpunkte: | Aufruf | Zweck | |---|---| -| `GET .../latest?product=myapp&channel=prod` | Höchstes Release, unabhängig von der Client-Version | -| `GET .../releases?product=myapp` | Alle Releases, nach Versionsordnung sortiert | +| `GET .../latest?product=myapp&channel=prod&platform=win-x64` | Höchstes Release, unabhängig von der Client-Version | +| `GET .../releases?product=myapp` | Alle Releases, nach Versionsordnung sortiert. `platform` filtert hier **exakt** — der Endpunkt listet den Bestand, er wählt kein Paket aus | +| `GET .../pubkey` | Öffentlicher Schlüssel zur Signaturprüfung | ### Versionsvergleich @@ -232,14 +540,97 @@ v1.4.3", der Packager veröffentlicht v1.4.3, und das Item schließt sich selbst ```text /var/www/releases/ (oder /public_html/releases/) -└── {ProjectId}/ # z.B. myapp, polytrader +└── {ProjectId}/ # z.B. myapp, polytrader ├── prod/ - │ ├── latest.json # Kanal-Übersicht & neueste Version - │ ├── 1.4.0/ - │ │ ├── package.tar.gz # Das gezippte Release + │ ├── latest.json # nur platform = any + │ ├── 1.4.0/ # nur platform = any + │ │ ├── package.tar.gz │ │ ├── package.tar.gz.sha256 - │ │ └── manifest.json # Einzeldateien + Hashes - │ └── 1.3.9/ + │ │ └── manifest.json # Einzeldateien, Hashes, preserve-Liste + │ ├── win-x64/ + │ │ ├── latest.json # eigene Historie je Plattform + │ │ └── 1.4.0/ + │ │ ├── package.tar.gz + │ │ ├── package.tar.gz.sha256 + │ │ └── manifest.json + │ └── linux-x64/ + │ ├── latest.json + │ └── 1.4.0/ ├── beta/ └── dev/ ``` + +Jede Plattform führt ihre eigene `latest.json`. Der Agent fragt zuerst den +plattformspezifischen Pfad ab und fällt auf den plattformlosen zurück — Pakete +einer fremden Plattform werden dabei verworfen. + +### Aufbewahrung + +`latest.json` führt die letzten 15 Versionen. Ältere Versionsverzeichnisse +bleiben auf dem Server liegen, sind über den Agenten aber nicht mehr +auswählbar. Der Packager weist beim Herausfallen einer Version ausdrücklich +darauf hin; wer weiter zurück muss, holt das Paket von Hand. + +--- + +## 6. Signatur der Releases + +Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. Wer +den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht beide gemeinsam +aus — der Hash schützt dann gegen Übertragungsfehler, nicht gegen Manipulation. +Ausgerechnet auf dem Pfad, der fremden Code ausführt. + +### Warum kein HMAC + +Beim Lizenzmodul wird mit HMAC signiert, und das geht dort auf, weil der +**Server** prüft. Ein Update wird auf dem Zielsystem geprüft. Ein HMAC bräuchte +dort denselben geheimen Schlüssel wie auf dem Server; wer ihn ausliest, kann +beliebige Pakete signieren — die Signatur verlöre genau die Eigenschaft, wegen +der es sie gibt. + +Deshalb asymmetrisch: der Server signiert mit einem privaten RSA-Schlüssel, der +Agent prüft mit dem öffentlichen. + +### Einrichten + +```bash +openssl genrsa -out /etc/dc/release-signing.pem 2048 +chmod 600 /etc/dc/release-signing.pem +``` + +```php +// config/config.php +'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', '/etc/dc/release-signing.pem'), +``` + +Signiert wird beim Veröffentlichen, serverseitig. **Der Packager bekommt den +Schlüssel nicht zu sehen** — er läuft auf Entwicklerrechnern, und der Schlüssel +wäre so gut geschützt wie das schwächste dieser Systeme. + +Signiert wird eine kanonische Zeile, nicht das Manifest-JSON: JSON-Ausgabe ist +nicht bytestabil (Schlüsselreihenfolge, Escaping, Zahlenformat), eine Signatur +darüber wäre unzuverlässig prüfbar. + +``` +dc-release-v1\n{product_slug}\n{version}\n{channel}\n{platform}\n +{sha256_hash klein}\n{download_url}\n{size_bytes} +``` + +### Prüfen + +Der Agent holt den öffentlichen Schlüssel einmalig von +`/api/updateservice/v1/pubkey` und legt ihn als `dc-release-pubkey.pem` neben +sich ab. Meldet der Server später einen **anderen** Schlüssel, wird gewarnt und +weiterhin der hinterlegte benutzt — ein untergeschobener Server fällt damit +auf. War der Wechsel beabsichtigt, die Datei löschen. + +| Lage | Verhalten | +|---|---| +| Signatur gültig | Installation läuft | +| Signatur ungültig | **Abbruch**, immer | +| Release unsigniert | Hinweis, Installation läuft | +| Kein öffentlicher Schlüssel | Hinweis, Installation läuft | +| `--require-signature` gesetzt | Die letzten beiden Fälle brechen ebenfalls ab | + +Ohne hinterlegten Schlüssel bleibt also alles funktionsfähig — es fehlt nur die +Vertrauenskette, und darauf wird bei jedem Update hingewiesen. diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index acad4ad..22a17a0 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -3,6 +3,16 @@ Diese Fassung enthält Sicherheitskorrekturen, die das Verhalten der Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen. +> **Version 2.1** ergänzt Korrekturen im .NET-SDK und ein neues Feld am +> Heartbeat. Sie erfordert keine Konfigurationsänderung, aber eine +> Datenbankmigration und ein Nachziehen der Clients: +> **[§14 Umstellung auf 2.1](#14-umstellung-auf-21)**. +> +> **Version 2.2** überarbeitet den UpdateService grundlegend: Plattform-Dimension, +> signierte Releases, Staging-Swap mit Rollback und geschützte Konfigurationsdateien. +> **Vor dem nächsten Release einzuspielen** — +> **[§15 Umstellung auf 2.2](#15-umstellung-auf-22)**. + --- ## 1. Vor dem Deployment: Zugangsdaten wechseln @@ -102,10 +112,38 @@ Die Migration ist additiv und legt an bzw. korrigiert: bisher vollständig — der Zustand änderte sich nur beim Eintreffen eines Heartbeats, ein ausgefallener Server blieb dauerhaft grün. +### Empfohlen: interner Aufruf (seit 2.1) + +```bash +* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet +``` + +Das entspricht dem früheren `watchdog/cli/tick.php`. Gegenüber dem HTTP-Weg: + +- **Kein Schlüssel im Crontab.** `ps` zeigt die Argumente laufender Prozesse + jedem Nutzer der Maschine — bei einem `curl -H "Authorization: Bearer …"` + also auch den Shared Key. +- **Keine Abhängigkeit von Webserver, TLS oder DNS.** Der Evaluator läuft auch + dann, wenn der Webserver klemmt — also gerade dann, wenn das Erkennen von + Ausfällen wichtig ist. +- **Keine überlappenden Läufe.** Das Skript sperrt sich per `flock` selbst; ein + langsamer Vorlauf führt nicht zu doppelten Alarmen. + +`--quiet` gibt nur bei Zustandswechseln und Fehlern etwas aus — sonst erzeugt +ein minütlicher Job 1440 Cron-Mails am Tag. Rückgabewerte: `0` erfolgreich, +`1` Fehler, `2` übersprungen (es lief bereits ein Tick). + +### Alternativ: über die Schnittstelle + +Sinnvoll, wenn der Cron auf einer anderen Maschine läuft als die Anwendung: + ```bash * * * * * curl -fsS -H "Authorization: Bearer " https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null ``` +Beide Wege rufen denselben Code auf und schreiben denselben Lauf-Vermerk in +`watchdog_cron_jobs` — nur einen von beiden einrichten. + Solange der Job fehlt, zeigt das WebUI oben einen Warnhinweis mit einer Schaltfläche für einen einmaligen Lauf. @@ -279,3 +317,182 @@ X-DC-Signature: sha256= ``` Nach 20 Fehlversuchen in Folge deaktiviert sich ein Webhook selbst. + +--- + +## 14. Umstellung auf 2.1 + +Diese Fassung korrigiert Fehler im .NET-SDK, ergänzt ein Feld am Heartbeat und +vervollständigt die OpenAPI-Beschreibung. **Keine Konfigurationsänderung nötig.** + +### 14.1 Serverseitig + +- [ ] Migration **008** ausführen (Spalte `app_version` in `watchdog_monitors`). + Läuft wie die übrigen über den Migrator, siehe [§4](#4-migration-ausführen). +- [ ] `/api/openapi.json` abrufen und prüfen, dass die Lizenz-Endpunkte und + `/api/watchdog/v1/event`, `/events`, `/status` jetzt enthalten sind. + +Bestehende Agenten laufen unverändert weiter; alle neuen Felder sind optional. + +### 14.2 Clientseitig (`Deploymentcenter.Client`) + +Die folgenden Änderungen betreffen jede bestehende Integration: + +| Änderung | Was zu tun ist | +|---|---| +| `unknown_error` entfällt | Wer diesen Status abfängt, prüft stattdessen `IsTransient` | +| Cache-Rückfall bei jedem HTTP-Fehler | Eigene Behelfslösungen für 429/5xx können entfallen | +| `cache_ttl_hours` wird ausgewertet | Offline-Gnadenfrist prüfen: Vorgabe je Projekt im WebUI | +| `app_version` ist Parameter | `LicenseClient.DefaultAppVersion` beim Start setzen | +| `X-Watchdog-Key` entfällt bei `DeactivateAsync` | Nur noch `Authorization: Bearer` | +| `BuildInfo.targets` erzeugt in `$(RootNamespace)` | Import ist jetzt möglich; ggf. `using` entfernen | +| API-Rückfall der Update-Prüfung liefert vollständige Daten | Eigene Umgehungen können entfallen | + +Ausführlich: **[LICENSE_INTEGRATION_GUIDE §6](./LICENSE_INTEGRATION_GUIDE.md)** +und **[UPDATESERVICE_INTEGRATION_GUIDE §2B/§2C](./UPDATESERVICE_INTEGRATION_GUIDE.md)**. + +### 14.3 Cache-Schema + +`state.dat` trägt jetzt Schema 3. Schema 2 wird weiterhin gelesen — ein +SDK-Wechsel erzwingt **keine** erneute Online-Prüfung. Ein Rückschritt auf ein +älteres SDK dagegen schon: das kennt Schema 3 nicht und behandelt den Cache als +Fehltreffer. + +--- + +## 15. Umstellung auf 2.2 + +Diese Fassung behebt eine Reihe von Fehlern im Update-Weg, die zusammengenommen +verhindert haben, dass für mehr als eine Plattform ausgeliefert werden konnte — +und die im Fehlerfall halb aktualisierte Installationen hinterließen. + +**Diese Umstellung gehört vor das nächste Release**, nicht danach. + +### 15.1 Was sich ändert + +| Bereich | Vorher | Jetzt | +|---|---|---| +| Plattformen | nicht vorhanden — `win-x64` und `linux-x64` überschrieben sich | eigene Dimension `platform`, getrennte Ablage | +| Konfigurationsdateien | wurden beim Update überschrieben | `preservePatterns` schützt sie | +| Ausschlussmuster | nur `*.endung` und exakte Namen | echte Globs (`**`, `*`, `?`) | +| Abbruch beim Anwenden | halb aktualisierte Installation | Backup, automatischer Rollback | +| Entfernte Dateien | blieben für immer liegen | werden aufgeräumt | +| Agent im Paket | überschrieb sich selbst | wird zur Seite gelegt und ersetzt | +| Übergabe an den Agenten | Wettlauf, kein Neustart | PID-Handschlag und `--restart` | +| Versionsangabe | ungeprüft | wird gegen die Assembly gehalten | +| Paketherkunft | nur SHA256 aus derselben Quelle | RSA-Signatur des Servers | +| `manifest_json` | Spalte blieb immer leer | wird befüllt, API ist vollwertiger Rückfall | + +### 15.2 Serverseitig + +- [ ] Migration **009** ausführen (`platform`, `manifest_signature`, neuer + Unique-Key). Additiv; bestehende Releases erhalten `platform = 'any'` und + bleiben für alle Clients erreichbar. +- [ ] Signierschlüssel erzeugen und in `config/config.php` eintragen: + +```bash +openssl genrsa -out /etc/dc/release-signing.pem 2048 +chmod 600 /etc/dc/release-signing.pem +chown www-data:www-data /etc/dc/release-signing.pem +``` + +```php +'release_private_key' => dc_env('DC_RELEASE_SIGNING_KEY', '/etc/dc/release-signing.pem'), +``` + +- [ ] Prüfen, dass der öffentliche Schlüssel ausgeliefert wird: + +```bash +curl https://dc.mhdf.de/api/updateservice/v1/pubkey +``` + +> **Bewusst RSA und kein HMAC.** Bei einem HMAC braucht der Prüfende denselben +> geheimen Schlüssel wie der Signierende. Der Agent läuft auf fremden Systemen; +> ein dort hinterlegter Schlüssel ließe sich auslesen und zum Signieren +> beliebiger Pakete verwenden — die Signatur wäre wertlos. Beim Lizenzmodul geht +> HMAC auf, weil dort der Server prüft. + +Ohne Schlüssel bleibt alles funktionsfähig; Releases sind dann unsigniert, und +der Agent weist bei jedem Update darauf hin. + +### 15.3 Packager + +- [ ] `packager.config.json` um `preservePatterns` ergänzen (Vorlage in + `packager.config.example.json`). +- [ ] **Prüfen, welche Konfigurationsdateien im Publish-Verzeichnis liegen.** + Der Packager warnt jetzt von sich aus vor Dateien, die nach Zugangsdaten + aussehen und weder ausgeschlossen noch geschützt sind. Diese Warnung ernst + nehmen: eine `settings.json` mit Datenbankpasswort, die bisher mitgeliefert + wurde, hat beim Update die Konfiguration jedes Zielsystems überschrieben. +- [ ] Aufrufe um `--platform` ergänzen: + +```bash +pack-and-deploy --project myapp --version 1.4.3 --channel prod \ + --platform win-x64 \ + --publish-dir ./bin/Release/net8.0/win-x64/publish +``` + +Ohne `--platform` versucht der Packager, die Kennung aus dem Publish-Pfad +abzuleiten. Gelingt das nicht, gilt das Release als plattformunabhängig und es +erscheint eine Warnung. + +- [ ] Die Version wird jetzt gegen die Hauptassembly geprüft. Weicht sie ab, + bricht der Vorgang ab. Das ist der Fall, in dem `` nur in einem + der beteiligten Projekte steht — der Wert gehört in die + `Directory.Build.props`, damit alle denselben tragen. Notausgang für + bewusste Abweichungen: `--ignore-version-mismatch`. + +### 15.4 Clientseitig + +`UpdateClient.CheckForUpdateAsync` hat einen zusätzlichen Parameter `platform` +**vor** dem `CancellationToken`. Wer den Token bisher positionell übergeben hat, +bekommt einen Übersetzungsfehler — kein stilles Fehlverhalten. + +Der Aufruf des Agenten sollte umgestellt werden: + +```csharp +var agentPath = UpdateClient.ResolveAgentPath(); // plattformrichtiger Name + +if (agentPath != null && checkResult.UpdateAvailable) +{ + UpdateClient.LaunchUpdateAgent( + agentPath: agentPath, + projectId: "myapp", + channel: "prod", + currentVersion: BuildInfo.Version, // für Ziele ohne manifest.json + exitCurrentApp: true); // Neustart und PID-Handschlag + // sind jetzt Vorgabe +} +``` + +| Änderung | Wirkung | +|---|---| +| `ResolveAgentPath()` | Findet `update-agent` bzw. `update-agent.exe` — ein fest verdrahteter `.exe`-Name wird unter Linux nie gefunden | +| `--restart` wird übergeben | Die Anwendung startet nach dem Update wieder. Bisher blieb sie einfach zu | +| `--wait-for-pid` wird übergeben | Der Agent wartet auf das Ende der Anwendung, statt über gesperrte Dateien zu kopieren | +| `--platform` wird übergeben | Es kann kein Paket einer fremden Plattform mehr gezogen werden | + +### 15.5 Den Agenten aus dem Paket nehmen + +Liegt `update-agent` im Publish-Verzeichnis, wandert er ins Paket und wird beim +Update mit ausgeliefert. Das ist zulässig — der Agent legt seine laufende Datei +inzwischen zur Seite und ersetzt sie —, aber es lohnt sich, bewusst zu +entscheiden: + +- **Mitliefern:** Der Agent aktualisiert sich selbst mit. Die alte Fassung bleibt + als `update-agent.exe.dc-old` liegen und wird beim nächsten Start entfernt. +- **Ausschließen:** `"update-agent*"` in `excludePatterns`. Dann wird der Agent + getrennt gepflegt. + +### 15.6 Prüfen + +- [ ] `curl "https://dc.mhdf.de/api/updateservice/v1/check?product=myapp&version=0.0.0&channel=prod&platform=win-x64"` + liefert das Windows-Paket, mit `platform=linux-x64` das Linux-Paket. +- [ ] Ohne `platform` kommen ausschließlich Releases mit `platform=any`. Das ist + Absicht: ein Client, der seine Kennung nicht nennt, soll lieber kein Update + bekommen als das falsche. +- [ ] Im WebUI zeigt die Release-Übersicht eine Spalte **Plattform** und + kennzeichnet unsignierte Releases. +- [ ] Ein Update auf einem Testsystem: `appsettings.json` behält danach die + eingerichteten Werte, und eine Datei, die es im neuen Release nicht mehr + gibt, ist verschwunden. diff --git a/docs/WATCHDOG_INTEGRATION_GUIDE.md b/docs/WATCHDOG_INTEGRATION_GUIDE.md index 837fb03..d031cec 100644 --- a/docs/WATCHDOG_INTEGRATION_GUIDE.md +++ b/docs/WATCHDOG_INTEGRATION_GUIDE.md @@ -3,7 +3,7 @@ Überwachung von Anwendungen, Diensten und Servern über Heartbeats. > **Stand:** Version 2.0 — vollständig überarbeitet. Wer eine ältere Integration -> betreibt, findet die Änderungen in Abschnitt 8. +> betreibt, findet die Änderungen in Abschnitt 9. --- @@ -26,10 +26,11 @@ ausgefallen. > Dienst bliebe dauerhaft grün. > > ```bash -> * * * * * curl -fsS -H "Authorization: Bearer " https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null +> * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet > ``` > -> Fehlt der Job, zeigt das WebUI oben einen Warnhinweis. +> Fehlt der Job, zeigt das WebUI oben einen Warnhinweis. Details und die +> HTTP-Variante: **[§11](#11-der-evaluator-tick)**. --- @@ -72,6 +73,7 @@ Content-Type: application/json "message": "Verarbeite Warteschlange", "group": "Applications", "os": ".NET 8 Service", + "version": "1.4.3", "checks": { "db": { "ok": true }, "market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" } }, @@ -89,9 +91,31 @@ Content-Type: application/json | `message` | nein | Kurztext, erscheint im Dashboard | | `group` | nein | Gruppierung im Dashboard | | `os` | nein | Plattform, steuert auch die Icon-Erkennung | +| `version` | nein | Version der laufenden Anwendung (Alias: `app_version`) | | `checks` | nein | Selbst ermittelter Gesundheitszustand, siehe 4. | | `metrics` | nein | Numerische Werte, siehe 5. | +### Welche Version läuft dort? + +`version` erscheint als eigene Spalte in der Monitorliste und kommt im +Heartbeat-Antwortobjekt als `app_version` zurück. Damit ist auf einen Blick +sichtbar, ob ein Ausfall zeitlich zu einem Rollout passt — „Monitor X ist seit +dem Rollout von 1.4.3 unten". + +Das Feld ist optional und **überschreibt einen früheren Wert nicht mit `null`**: +ein Agent, der es nicht mitschickt, löscht die zuletzt gemeldete Version nicht. +Bestehende Agenten laufen also unverändert weiter. + +```csharp +// Am einfachsten aus der vom Build erzeugten Klasse, siehe +// UPDATESERVICE_INTEGRATION_GUIDE §2B +version = BuildInfo.Version +``` + +> Der Fehler-Stream führt `build`, der Bugtracker `build_version`, die +> Aktivierungsliste des Lizenzmoduls `app_version` — nur der Watchdog konnte +> bis 2.1 nicht sagen, welche Version tatsächlich läuft. + ### Zulässige Werte für `status` | Wert | Zustand | Wirkung | @@ -217,6 +241,7 @@ public sealed class WatchdogReporter interval = 60, message, os = Environment.OSVersion.ToString(), + version = BuildInfo.Version, // erscheint in der Monitorliste checks, metrics }; @@ -372,6 +397,8 @@ Zulässige `kind`-Werte: `started`, `stopped_graceful`, `crash_suspected`, | Geplantes Beenden | nicht möglich | `status: "stopped"` bzw. `"maintenance"` | | Alarme bei Ausfall eines Hosts | eine Meldung je Kind | Kinder werden unterdrückt | | Antwortformat | uneinheitlich | `{"status":"success",…}` bzw. `{"status":"error","error":{"code":…}}` | +| Laufende Version | nicht übermittelbar | Feld `version` (seit 2.1) | +| OpenAPI-Beschreibung | `/event`, `/events`, `/status` fehlten, ebenso `group` und `type` am Ping | vollständig | Bestehende Agenten mit `wd_live_`-Token und einfachem `ok`-Ping laufen unverändert weiter — die neuen Felder sind alle optional. @@ -389,3 +416,75 @@ unverändert weiter — die neuen Felder sind alle optional. 4. **`checks` nutzen.** Ein Heartbeat sagt nur, dass ein Thread läuft. 5. **Hierarchie pflegen**, wenn Dienste auf gemeinsamen Hosts laufen — sonst bringt ein Hostausfall eine Alarmlawine. +6. **`version` mitschicken.** Ohne sie lässt sich ein Ausfall nicht mit einem + Rollout in Verbindung bringen. + +--- + +## 11. Der Evaluator-Tick + +Der Evaluator ist die Komponente, die Monitore anhand ihres erwarteten +Intervalls herabstuft. Er läuft **nicht** von selbst — ohne einen Cron-Eintrag +sind die Zustände im Dashboard wertlos. + +Er erledigt in einem Durchlauf: + +- Monitore anhand `expected_interval_sec` auf `warning` bzw. `down` stufen +- Zustandswechsel im Ereignisprotokoll festhalten und Webhooks auslösen +- Alarme für Kinder eines ausgefallenen Hosts unterdrücken +- abgelaufene Bugtracker-Leases freigeben +- stündlich den Metrik-Verlauf abräumen + +### Interner Aufruf (empfohlen) + +```bash +* * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet +``` + +Entspricht dem früheren `watchdog/cli/tick.php`. Kein Schlüssel im Crontab, +keine Abhängigkeit von Webserver, TLS oder DNS — und damit auch dann +lauffähig, wenn der Webserver gerade das Problem ist. + +| Option | Wirkung | +|---|---| +| `-q`, `--quiet` | Ausgabe nur bei Zustandswechseln und Fehlern (für Cron) | +| `-j`, `--json` | Ergebnis maschinenlesbar | +| `--no-lock` | Sperre gegen überlappende Läufe übergehen (nur zur Fehlersuche) | +| `-h`, `--help` | Hilfe | + +| Rückgabewert | Bedeutung | +|---|---| +| `0` | Lauf erfolgreich | +| `1` | Fehler — Meldung auf `stderr`, Einzelheiten im Log unter `var/log/` | +| `2` | Übersprungen, es lief bereits ein Tick | + +Das Skript sperrt sich über `var/watchdog-tick.lock` selbst. Braucht ein Lauf +länger als eine Minute, überspringt der nächste Cron-Aufruf — sonst würden +zwei Evaluatoren dieselben Zustandswechsel doppelt melden. + +> Der Aufruf ist auf `PHP_SAPI === 'cli'` beschränkt und `cli/` ist zusätzlich +> per `.htaccess` gesperrt. Über den Webserver ist das Skript also nicht +> erreichbar — sonst ließe sich ein Evaluationslauf ohne jede Authentifizierung +> auslösen, während der HTTP-Endpunkt dafür bewusst den Shared Key verlangt. + +### Über die Schnittstelle + +Sinnvoll, wenn der Cron auf einer anderen Maschine läuft: + +```bash +* * * * * curl -fsS -H "Authorization: Bearer " https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null +``` + +Beide Wege rufen denselben Code auf und schreiben denselben Lauf-Vermerk in +`watchdog_cron_jobs`. **Nur einen von beiden einrichten** — zwei parallele +Zeitpläne bringen keinen Gewinn, nur die Gefahr überlappender Läufe. + +### Prüfen, ob er läuft + +```bash +php cli/tick.php --json +curl https://dc.mhdf.de/api/health -H "Authorization: Bearer " +``` + +`/api/health` meldet unter `checks.evaluator` den letzten Lauf. Im WebUI zeigt +das Watchdog-Modul denselben Zustand als Abzeichen **AKTIV** / **INAKTIV**. diff --git a/public/api/openapi.php b/public/api/openapi.php index afe5eac..69261fa 100644 --- a/public/api/openapi.php +++ b/public/api/openapi.php @@ -15,6 +15,7 @@ require_once __DIR__ . '/../../src/bootstrap.php'; use Deploymentcenter\Core\Config; use Deploymentcenter\Core\Http; use Deploymentcenter\Modules\Bugtracker\BugRepo; +use Deploymentcenter\Modules\Watchdog\EventLog; use Deploymentcenter\Core\TokenManager; Http::beginJson(['GET', 'OPTIONS'], true); @@ -32,7 +33,11 @@ $spec = [ 'title' => 'Deploymentcenter API', 'version' => (string)Config::get('app.version', '2.0.0'), 'description' => - "Zentrale Schnittstelle fuer Bugtracker, UpdateService, Watchdog und Token-Provisionierung.\n\n" + "Zentrale Schnittstelle fuer Bugtracker, UpdateService, Watchdog, Lizenzen und Token-Provisionierung.\n\n" + . "**Zwei Antwortformate.** Der Regelfall ist der Umschlag `{\"status\":\"success\",...}` bzw.\n" + . "`{\"status\":\"error\",\"error\":{\"code\":...}}`. Die Endpunkte unter `/api/license/v1/`\n" + . "antworten davon abweichend ohne Umschlag - dort ist `status` der Lizenzzustand.\n" + . "Siehe das Schema `LicenseResult`.\n\n" . "Authentifizierung ueber `Authorization: Bearer ` oder `X-Agent-Token`.\n" . "Tokens werden im WebUI erzeugt (Master-Token) und koennen sich per\n" . "`/api/tokens/v1/provision` selbst in Sub-Tokens aufteilen.\n\n" @@ -115,6 +120,94 @@ $spec = [ 'context' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext'], ], ], + 'Release' => [ + 'type' => 'object', + 'description' => + 'Release-Zeile aus updateservice_releases. Die Feldnamen sind die der Datenbank ' + . '(snake_case) und unterscheiden sich von der statischen latest.json, die der ' + . 'Packager in camelCase erzeugt.', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'product_slug' => ['type' => 'string'], + 'version' => ['type' => 'string', 'example' => '1.4.3'], + 'channel' => ['type' => 'string', 'default' => 'prod'], + 'platform' => [ + 'type' => 'string', + 'default' => 'any', + 'example' => 'win-x64', + 'description' => + 'Laufzeitkennung (.NET-RID) oder "any" fuer plattformunabhaengige Releases. ' + . 'Ein Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform ' + . 'oder "any"; ohne Angabe ausschliesslich "any".', + ], + 'release_notes' => ['type' => 'string', 'nullable' => true, 'description' => 'Entspricht "changelog" in der latest.json.'], + 'download_url' => ['type' => 'string', 'description' => 'Entspricht "packageUrl" in der latest.json.'], + 'sha256_hash' => ['type' => 'string', 'nullable' => true, 'description' => 'Entspricht "sha256" in der latest.json.'], + 'git_commit' => ['type' => 'string', 'nullable' => true], + 'size_bytes' => ['type' => 'integer'], + 'manifest_signature' => [ + 'type' => 'string', + 'nullable' => true, + 'description' => + 'RSA-SHA256 ueber die kanonische Release-Zeile, base64. Pruefbar mit dem ' + . 'Schluessel aus /api/updateservice/v1/pubkey. Null, wenn auf dem Server ' + . 'kein Signierschluessel hinterlegt ist.', + ], + 'is_critical' => ['type' => 'boolean', 'description' => 'Kommt als TINYINT(1), also 0 oder 1.'], + 'created_at' => ['type' => 'string', 'format' => 'date-time'], + ], + ], + 'Monitor' => [ + 'type' => 'object', + 'properties' => [ + 'source' => ['type' => 'string'], + 'instance' => ['type' => 'string'], + 'type' => ['type' => 'string', 'enum' => ['heartbeat', 'host', 'hypervisor_node', 'guest']], + 'state' => ['type' => 'string', 'enum' => ['up', 'warning', 'down', 'error', 'stopped', 'maintenance']], + 'expected_interval_sec' => ['type' => 'integer'], + 'last_seen_utc' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], + 'last_status' => ['type' => 'string', 'enum' => ['ok', 'warning', 'error'], 'nullable' => true], + 'last_message' => ['type' => 'string', 'nullable' => true], + 'app_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Vom Heartbeat gemeldete Anwendungsversion.'], + 'group_key' => ['type' => 'string', 'nullable' => true], + 'parent_source' => ['type' => 'string', 'nullable' => true], + 'os' => ['type' => 'string', 'nullable' => true], + 'metrics_json' => ['type' => 'object', 'nullable' => true], + 'failing_checks' => ['type' => 'string', 'nullable' => true], + 'is_muted' => ['type' => 'boolean'], + ], + ], + 'LicenseResult' => [ + 'type' => 'object', + 'description' => + 'ACHTUNG: ohne den status/error-Umschlag der uebrigen API. Das Feld "status" ' + . 'traegt hier den Lizenzzustand, nicht "success".', + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['validation_result']], + 'status' => [ + 'type' => 'string', + 'enum' => ['valid', 'not_found', 'revoked', 'suspended', 'expired', 'activation_limit'], + 'description' => + 'valid = gueltig und aktiviert; not_found = Projekt oder Schluessel unbekannt; ' + . 'revoked = widerrufen oder diese Hardware gesperrt; suspended = voruebergehend ' + . 'ausgesetzt; expired = Ablaufdatum ueberschritten; activation_limit = maximale ' + . 'Anzahl Aktivierungen erreicht.', + ], + 'issued_at' => ['type' => 'integer', 'description' => 'Unix-Zeit der Ausstellung.'], + 'expires_at' => ['type' => 'integer', 'nullable' => true, 'description' => 'Unix-Zeit des Lizenzablaufs, null bei unbefristet.'], + 'cache_ttl_hours' => [ + 'type' => 'integer', + 'example' => 168, + 'description' => 'Laenge der Offline-Gnadenfrist in Stunden. Auswerten - nicht durch expires_at ersetzen.', + ], + 'product' => ['type' => 'string'], + 'license_key' => ['type' => 'string'], + 'hardware_id' => ['type' => 'string'], + 'nonce' => ['type' => 'string', 'description' => 'Der uebergebene Wert, unveraendert.'], + 'endpoints' => ['type' => 'object'], + 'message' => ['type' => 'string', 'nullable' => true], + ], + ], ], ], 'security' => [['bearerAuth' => []], ['agentToken' => []]], @@ -261,13 +354,125 @@ $spec = [ 'get' => [ 'tags' => ['UpdateService'], 'summary' => 'Auf Update pruefen', + 'description' => + "Ohne Token erreichbar.\n\n" + . "**Achtung, zwei verschiedene Formate:** diese Antwort ist in snake_case und " + . "reicht unter `latest_release` die Datenbankzeile durch (`download_url`, " + . "`sha256_hash`, `release_notes`, `size_bytes`). Die statische " + . "`/releases/{product}/{channel}/latest.json`, die der Packager erzeugt, " + . "verwendet dagegen camelCase (`packageUrl`, `sha256`, `changelog`, `isCritical`). " + . "Wer beide Quellen unterstuetzt, braucht zwei Modelle.\n\n" + . "`is_critical` steht auf oberster Ebene, nicht im Release-Objekt.\n\n" + . "**Plattform:** ohne `platform` werden ausschliesslich Releases mit `platform=any` " + . "beruecksichtigt. Das ist Absicht - ein Client, der seine Laufzeitkennung nicht " + . "nennt, soll lieber kein Update bekommen als das Paket einer fremden Plattform.", 'security' => [], 'parameters' => [ ['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']], ['name' => 'version', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']], ['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']], + ['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'], + 'description' => '.NET-RID des anfragenden Systems. Alias: rid.'], + ], + 'responses' => [ + '200' => [ + 'description' => 'Vergleich nach semantischer Versionsordnung', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'update_available' => ['type' => 'boolean'], + 'current_version' => ['type' => 'string'], + 'latest_version' => ['type' => 'string'], + 'is_critical' => ['type' => 'boolean', 'description' => 'Nur vorhanden, wenn ein Update ansteht.'], + 'latest_release' => ['$ref' => '#/components/schemas/Release'], + 'message' => ['type' => 'string'], + ], + ]]], + ], + ], + ], + ], + + '/api/updateservice/v1/latest' => [ + 'get' => [ + 'tags' => ['UpdateService'], + 'summary' => 'Hoechstes Release eines Kanals', + 'description' => 'Ohne Token erreichbar. Unabhaengig von der Version des Aufrufers.', + 'security' => [], + 'parameters' => [ + ['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']], + ['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']], + ['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64']], + ], + 'responses' => [ + '200' => [ + 'description' => 'Release', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => ['release' => ['$ref' => '#/components/schemas/Release']], + ]]], + ], + '404' => $errorResponse, + ], + ], + ], + + '/api/updateservice/v1/releases' => [ + 'get' => [ + 'tags' => ['UpdateService'], + 'summary' => 'Releases auflisten', + 'description' => 'Ohne Token erreichbar. Ohne "product" alle Produkte.', + 'security' => [], + 'parameters' => [ + ['name' => 'product', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string'], + 'description' => 'Filtert exakt auf diese Plattform - dieser Endpunkt listet den Bestand, er waehlt kein Paket aus.'], + ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 200]], + ], + 'responses' => [ + '200' => [ + 'description' => 'Liste', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'count' => ['type' => 'integer'], + 'releases' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Release']], + ], + ]]], + ], + ], + ], + ], + + '/api/updateservice/v1/pubkey' => [ + 'get' => [ + 'tags' => ['UpdateService'], + 'summary' => 'Oeffentlichen Schluessel fuer Release-Signaturen holen', + 'description' => + "Ohne Token erreichbar - der Schluessel ist oeffentlich, und ein Agent braucht ihn, " + . "bevor er irgendetwas anderes pruefen kann.\n\n" + . "Signiert wird die kanonische Release-Zeile (`canonical-line-v1`): die Felder " + . "`dc-release-v1`, product_slug, version, channel, platform, sha256_hash (klein " + . "geschrieben), download_url und size_bytes, verbunden mit \\n. Geprueft wird " + . "`manifest_signature` (base64) mit RSA-SHA256.\n\n" + . "404 bedeutet, dass auf diesem Server kein Signierschluessel hinterlegt ist.", + 'security' => [], + 'responses' => [ + '200' => [ + 'description' => 'Oeffentlicher Schluessel im PEM-Format', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'algorithm' => ['type' => 'string', 'example' => 'RSA-SHA256'], + 'format' => ['type' => 'string', 'example' => 'canonical-line-v1'], + 'public_key' => ['type' => 'string', 'description' => 'PEM'], + 'fingerprint' => ['type' => 'string', 'description' => 'SHA256 ueber das PEM, zum Wiedererkennen.'], + ], + ]]], + ], + '404' => $errorResponse, ], - 'responses' => ['200' => ['description' => 'Vergleich nach semantischer Versionsordnung']], ], ], @@ -275,7 +480,11 @@ $spec = [ 'post' => [ 'tags' => ['UpdateService'], 'summary' => 'Release veroeffentlichen', - 'description' => 'Scope updateservice:publish. Schliesst automatisch alle Bugtracker-Items, deren resolved_in_build dieser Version entspricht.', + 'description' => + "Scope updateservice:publish. Schliesst automatisch alle Bugtracker-Items, deren " + . "resolved_in_build dieser Version entspricht.\n\n" + . "Die Signatur wird serverseitig gebildet und ist nicht Teil des Aufrufs - der " + . "Packager laeuft auf Entwicklerrechnern und darf den Signierschluessel nicht besitzen.", 'requestBody' => [ 'required' => true, 'content' => ['application/json' => ['schema' => [ @@ -285,12 +494,20 @@ $spec = [ 'product_slug' => ['type' => 'string'], 'version' => ['type' => 'string', 'example' => '1.4.3'], 'channel' => ['type' => 'string', 'default' => 'prod'], + 'platform' => ['type' => 'string', 'default' => 'any', 'example' => 'win-x64'], 'download_url' => ['type' => 'string'], 'sha256_hash' => ['type' => 'string', 'pattern' => '^[0-9a-fA-F]{64}$'], 'git_commit' => ['type' => 'string'], 'size_bytes' => ['type' => 'integer'], 'release_notes' => ['type' => 'string'], 'is_critical' => ['type' => 'boolean'], + 'manifest_json' => [ + 'type' => 'object', + 'description' => + 'Dateimanifest des Pakets (Pfade, Groessen, SHA256, preserve-Liste). ' + . 'Damit kann die API als vollwertiger Rueckfall dienen, wenn die ' + . 'statische latest.json fehlt. Maximal 4 MB.', + ], ], ]]], ], @@ -309,8 +526,23 @@ $spec = [ 'type' => 'object', 'required' => ['source'], 'properties' => [ - 'source' => ['type' => 'string'], + 'source' => ['type' => 'string', 'description' => 'Eindeutiger Name des Dienstes oder Hosts.'], 'instance' => ['type' => 'string', 'default' => 'default'], + 'type' => [ + 'type' => 'string', + 'enum' => ['heartbeat', 'host', 'hypervisor_node', 'guest'], + 'default' => 'heartbeat', + 'description' => 'Bestimmt die Einordnung in der Hierarchie.', + ], + 'group' => [ + 'type' => 'string', + 'description' => 'Gruppierung im Dashboard, z. B. "Applications". Alias: group_key.', + ], + 'version' => [ + 'type' => 'string', + 'description' => 'Version der laufenden Anwendung, erscheint in der Monitorliste. Alias: app_version. Optional - bestehende Agenten senden es nicht.', + 'example' => '1.4.3', + ], 'status' => [ 'type' => 'string', 'enum' => ['ok', 'warning', 'error', 'stopped', 'maintenance'], @@ -331,6 +563,73 @@ $spec = [ ], ], + '/api/watchdog/v1/event' => [ + 'post' => [ + 'tags' => ['Watchdog'], + 'summary' => 'Einzelnes Ereignis protokollieren', + 'description' => + "Scope watchdog:ping, alternativ ein Agent-Token. Fuer einmalige Vorkommnisse " + . "statt zyklischer Meldungen - etwa ein angekuendigtes Herunterfahren oder eine " + . "wiederhergestellte Verbindung.\n\n" + . "Zustandswechsel durch Heartbeats werden ohnehin selbst protokolliert; dieser " + . "Endpunkt ist fuer alles, was dazwischen passiert.", + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['source'], + 'properties' => [ + 'source' => ['type' => 'string'], + 'instance' => ['type' => 'string', 'default' => 'default'], + 'kind' => ['type' => 'string', 'enum' => EventLog::KINDS, 'default' => 'started'], + 'severity' => ['type' => 'string', 'enum' => EventLog::SEVERITIES, 'default' => 'info'], + 'message' => ['type' => 'string'], + 'from_state' => ['type' => 'string'], + 'to_state' => ['type' => 'string'], + 'meta' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext.'], + ], + ]]], + ], + 'responses' => ['201' => ['description' => 'Protokolliert'], '401' => $errorResponse], + ], + ], + + '/api/watchdog/v1/status' => [ + 'get' => [ + 'tags' => ['Watchdog'], + 'summary' => 'Alle Monitore abrufen', + 'description' => 'Scope watchdog:read. Liefert jede Zeile aus watchdog_monitors, einschliesslich app_version und group_key.', + 'responses' => [ + '200' => [ + 'description' => 'Monitorliste', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'count' => ['type' => 'integer'], + 'monitors' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Monitor']], + ], + ]]], + ], + '401' => $errorResponse, + ], + ], + ], + + '/api/watchdog/v1/events' => [ + 'get' => [ + 'tags' => ['Watchdog'], + 'summary' => 'Ereignisprotokoll lesen', + 'description' => 'Scope watchdog:read.', + 'parameters' => [ + ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50]], + ['name' => 'source', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'instance', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => EventLog::SEVERITIES]], + ], + 'responses' => ['200' => ['description' => 'Ereignisliste'], '401' => $errorResponse], + ], + ], + '/api/watchdog/v1/evaluate' => [ 'get' => [ 'tags' => ['Watchdog'], @@ -340,6 +639,102 @@ $spec = [ ], ], + // ------------------------------------------------------------------ + // Lizenzmodul + // + // Fehlte hier bisher vollstaendig, obwohl README und Prompt-Vorlage + // Agenten ausdruecklich auf diese Beschreibung verweisen. Wer sich + // daran orientierte, konnte das Lizenzsystem nicht anbinden - und + // kannte insbesondere dessen abweichendes Antwortformat nicht. + // ------------------------------------------------------------------ + + '/api/license/v1/validate' => [ + 'post' => [ + 'tags' => ['Lizenz'], + 'summary' => 'Lizenz und Hardware pruefen', + 'description' => + "Bewusst ohne Token erreichbar: ausgelieferte Anwendungen pruefen hier ihre Lizenz.\n\n" + . "**Abweichendes Antwortformat.** Diese Antwort traegt NICHT den " + . "`status`/`error`-Umschlag der uebrigen API. `status` auf oberster Ebene ist der " + . "Lizenzzustand (`valid`, `not_found`, `revoked`, `suspended`, `expired`, " + . "`activation_limit`), nicht `success`. Das ist ein bereits ausgerollter Vertrag - " + . "ein Umschlag mit `status: \"success\"` wuerde von jedem bestehenden Client als " + . "\"nicht valid\" gelesen.\n\n" + . "**Nur ein gelesenes Urteil ist ein Urteil.** Ein HTTP-Fehler (429 durch " + . "Drosselung, 5xx) ist keine Aussage ueber die Lizenz. Clients muessen in diesem " + . "Fall ihren lokalen Cache verwenden und es spaeter erneut versuchen, statt die " + . "Anwendung zu blockieren.\n\n" + . "`cache_ttl_hours` bestimmt, wie lange ohne Serverkontakt weitergearbeitet " + . "werden darf. Der Wert stammt aus `dc_projects.default_cache_ttl_hours` und ist " + . "auszuwerten - nicht das Ablaufdatum der Lizenz dafuer heranzuziehen.\n\n" + . "Begrenzt auf 120 Anfragen pro Minute und IP.", + 'security' => [], + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['product', 'license_key', 'hardware_id'], + 'properties' => [ + 'product' => ['type' => 'string', 'description' => 'Slug aus dc_projects.'], + 'license_key' => ['type' => 'string', 'example' => 'XXXXX-XXXXX-XXXXX-XXXXX-XXXXX'], + 'hardware_id' => ['type' => 'string', 'example' => '2:win:a765bd47...', 'description' => 'Hardware-ID v2, Format 2::<64 Hex>.'], + 'legacy_hardware_id' => ['type' => 'string', 'description' => 'Hardware-ID v1. Mitschicken, damit eine bestehende Aktivierung ohne zusaetzlichen Platz auf v2 umzieht.'], + 'hwid_version' => ['type' => 'integer', 'default' => 2], + 'hwid_source' => ['type' => 'string', 'example' => 'machine-guid'], + 'platform' => ['type' => 'string', 'example' => 'win'], + 'hostname' => ['type' => 'string'], + 'app_version' => ['type' => 'string', 'example' => '1.4.3', 'description' => 'Version der Anwendung; erscheint in der Aktivierungsliste.'], + 'nonce' => ['type' => 'string', 'description' => 'Zufallswert, wird unveraendert zurueckgegeben.'], + ], + ]]], + ], + 'responses' => [ + '200' => [ + 'description' => 'Lizenzurteil (auch bei ungueltiger Lizenz)', + 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/LicenseResult']]], + ], + '429' => $errorResponse, + ], + ], + ], + + '/api/license/v1/deactivate' => [ + 'post' => [ + 'tags' => ['Lizenz'], + 'summary' => 'Aktivierung freigeben', + 'description' => + "Scope license:deactivate, alternativ der `shared_key` aus config/config.php als " + . "`Authorization: Bearer`. Geschuetzt, weil sonst jeder fremde Installationen " + . "abmelden koennte.\n\n" + . "Der Schluessel gehoert auf den Administrationsrechner, nicht in die " + . "ausgelieferte Anwendung. Fuer Einzelfaelle genuegt der Knopf **Freigeben** in " + . "der Hardware-Liste des WebUI.", + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['product', 'license_key', 'hardware_id'], + 'properties' => [ + 'product' => ['type' => 'string'], + 'license_key' => ['type' => 'string'], + 'hardware_id' => ['type' => 'string'], + 'nonce' => ['type' => 'string'], + ], + ]]], + ], + 'responses' => ['200' => ['description' => 'Freigegeben'], '401' => $errorResponse], + ], + ], + + '/api/license/v1/status' => [ + 'get' => [ + 'tags' => ['Lizenz'], + 'summary' => 'Verfuegbarkeitspruefung des Moduls', + 'security' => [], + 'responses' => ['200' => ['description' => 'Modulzustand']], + ], + ], + '/api/tokens/v1/provision' => [ 'post' => [ 'tags' => ['Tokens'], diff --git a/public/api/updateservice/v1/index.php b/public/api/updateservice/v1/index.php index 2ca8167..0e00255 100644 --- a/public/api/updateservice/v1/index.php +++ b/public/api/updateservice/v1/index.php @@ -3,9 +3,10 @@ /** * UpdateService API * - * GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod - * GET /api/updateservice/v1/latest?product=myapp&channel=prod + * GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod&platform=win-x64 + * GET /api/updateservice/v1/latest?product=myapp&channel=prod&platform=win-x64 * GET /api/updateservice/v1/releases?product=myapp + * GET /api/updateservice/v1/pubkey * POST /api/updateservice/v1/publish (Scope updateservice:publish) * * SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig @@ -13,6 +14,11 @@ * Releases ueberschreiben und damit allen Clients ein beliebiges Paket * unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish". * + * PLATTFORM: Releases tragen seit Migration 009 eine Laufzeitkennung. Ein + * Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform oder + * plattformunabhaengige ('any'). Ein Client ohne Angabe sieht ausschliesslich + * 'any' - lieber kein Update als das Paket einer fremden Plattform. + * * Die Lese-Endpunkte bleiben ohne Token erreichbar, damit bereits ausgerollte * Client-Anwendungen weiter nach Updates suchen koennen. Sie liefern nur * Release-Metadaten, die ueber die Download-URL ohnehin oeffentlich sind. @@ -26,6 +32,7 @@ use Deploymentcenter\Core\ApiAuth; use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Http; use Deploymentcenter\Modules\License\RateLimiter; +use Deploymentcenter\Modules\UpdateService\ReleaseSigner; use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\UpdateService\Version; @@ -49,17 +56,19 @@ switch ($action) { Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.'); } - $current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0'; - $channel = Http::str('channel') ?? 'prod'; + $current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0'; + $channel = Http::str('channel') ?? 'prod'; + $platform = Http::str('platform') ?? Http::str('rid'); - $latest = $manager->checkUpdate($product, $current, $channel); + $latest = $manager->checkUpdate($product, $current, $channel, $platform); if ($latest === null) { - $installed = $manager->latestRelease($product, $channel); + $installed = $manager->latestRelease($product, $channel, $platform); Http::ok([ 'update_available' => false, 'current_version' => $current, 'latest_version' => $installed !== null ? $installed['version'] : $current, + 'platform' => UpdateManager::normalizePlatform($platform), 'message' => 'Anwendung ist aktuell.', ]); } @@ -68,6 +77,7 @@ switch ($action) { 'update_available' => true, 'current_version' => $current, 'latest_version' => $latest['version'], + 'platform' => $latest['platform'] ?? UpdateManager::PLATFORM_ANY, 'is_critical' => (bool)$latest['is_critical'], 'latest_release' => $latest, ]); @@ -78,7 +88,11 @@ switch ($action) { Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.'); } - $release = $manager->latestRelease($product, Http::str('channel') ?? 'prod'); + $release = $manager->latestRelease( + $product, + Http::str('channel') ?? 'prod', + Http::str('platform') ?? Http::str('rid') + ); if ($release === null) { Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product)); } @@ -89,10 +103,33 @@ switch ($action) { $releases = $manager->getReleases( Http::str('product') ?? Http::str('product_slug'), Http::str('channel'), - Http::int('limit', 200) + Http::int('limit', 200), + Http::str('platform') ?? Http::str('rid') ); Http::ok(['count' => count($releases), 'releases' => $releases]); + case 'pubkey': + // Oeffentlicher Schluessel zum Pruefen der Release-Signaturen. + // Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn, + // bevor er irgendetwas anderes vertrauen kann. + $pub = ReleaseSigner::publicKeyPem(); + + if ($pub === null) { + Http::fail( + 404, + 'signing_disabled', + 'Auf diesem Deploymentcenter ist kein Signierschluessel hinterlegt ' + . '(security.release_private_key). Releases werden unsigniert ausgeliefert.' + ); + } + + Http::ok([ + 'algorithm' => 'RSA-SHA256', + 'format' => 'canonical-line-v1', + 'public_key' => $pub, + 'fingerprint' => ReleaseSigner::publicKeyFingerprint(), + ]); + case 'publish': if (Http::method() !== 'POST') { Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.'); @@ -131,30 +168,76 @@ switch ($action) { Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.'); } + $channel = Http::str('channel') ?? 'prod'; + $platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid')); + $size = Http::int('size_bytes', 0); + + // Das Dateimanifest wandert mit in die Datenbank. Bisher blieb die + // Spalte manifest_json immer leer - damit konnte die API kein + // vollwertiger Rueckfall fuer den Agenten sein, wenn die statische + // latest.json fehlt. + $manifestJson = null; + $manifestRaw = Http::input('manifest_json', null); + + if (is_array($manifestRaw)) { + $encoded = json_encode($manifestRaw, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $manifestJson = $encoded === false ? null : $encoded; + } elseif (is_string($manifestRaw) && trim($manifestRaw) !== '') { + if (json_decode($manifestRaw) === null && json_last_error() !== JSON_ERROR_NONE) { + Http::fail(400, 'invalid_manifest', 'manifest_json ist kein gueltiges JSON.'); + } + $manifestJson = $manifestRaw; + } + + if ($manifestJson !== null && strlen($manifestJson) > 4 * 1024 * 1024) { + Http::fail(400, 'manifest_too_large', 'manifest_json ueberschreitet 4 MB.'); + } + + // Signiert wird serverseitig. Der Packager laeuft auf Entwickler- + // rechnern; ein dort hinterlegter Signierschluessel waere so gut + // geschuetzt wie das schwaechste dieser Systeme. + $signature = ReleaseSigner::sign(ReleaseSigner::canonical( + $product, + $version, + $channel, + $platform, + $hash, + $url, + $size + )); + $result = $manager->addRelease( $product, $version, - Http::str('channel') ?? 'prod', + $channel, Http::str('release_notes'), $url, $hash, Http::str('git_commit'), - Http::int('size_bytes', 0), - null, + $size, + $manifestJson, filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN), - $context['actor'] + $context['actor'], + $platform, + $signature ); Http::ok([ 'release_id' => $result['id'], 'created' => $result['created'], + 'platform' => $platform, + 'signed' => $signature !== null, 'auto_resolved' => $result['auto_resolved'], 'message' => sprintf( - 'Release %s (%s) fuer "%s" %s.%s', + 'Release %s (%s, %s) fuer "%s" %s.%s%s', $version, - Http::str('channel') ?? 'prod', + $channel, + $platform, $product, $result['created'] ? 'veroeffentlicht' : 'aktualisiert', + $signature === null + ? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.' + : '', $result['auto_resolved'] > 0 ? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved']) : '' @@ -163,7 +246,7 @@ switch ($action) { default: Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ - 'available' => ['check', 'latest', 'releases', 'publish'], + 'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'], ]); } @@ -187,7 +270,7 @@ function resolveUpdateAction(): string $last = strtolower((string)end($segments)); return match ($last) { - 'check', 'latest', 'releases', 'publish' => $last, + 'check', 'latest', 'releases', 'pubkey', 'publish' => $last, 'publish_release' => 'publish', default => 'check', }; diff --git a/public/api/watchdog/v1/index.php b/public/api/watchdog/v1/index.php index 469e85c..03cbf63 100644 --- a/public/api/watchdog/v1/index.php +++ b/public/api/watchdog/v1/index.php @@ -14,7 +14,11 @@ * blieb ein ausgefallener Server dauerhaft gruen, weil der Zustand sich nur * beim Eintreffen eines Heartbeats aenderte. * - * Cron-Eintrag (minuetlich): + * Cron-Eintrag (minuetlich). Bevorzugt intern, ohne HTTP und ohne Schluessel + * im Crontab: + * * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet + * + * Ueber die Schnittstelle, wenn der Cron woanders laeuft: * * * * * * curl -fsS -H "Authorization: Bearer " \ * https://dc.example.com/api/watchdog/v1/evaluate > /dev/null * @@ -71,7 +75,10 @@ switch ($action) { Http::str('group') ?? Http::str('group_key'), Http::str('os'), // Gesundheitszustand, den die Anwendung selbst ermittelt hat. - Http::input('checks') + Http::input('checks'), + // Welche Version laeuft hier? Optional - bestehende Agenten + // schicken das Feld nicht und laufen unveraendert weiter. + Http::str('version') ?? Http::str('app_version') ); // Numerische Werte in den Verlauf uebernehmen, damit sich Trends @@ -102,6 +109,7 @@ switch ($action) { 'state' => $monitor['state'], 'last_status' => $monitor['last_status'], 'last_seen_utc' => $monitor['last_seen_utc'], + 'app_version' => $monitor['app_version'] ?? null, 'state_changed' => (bool)($monitor['_state_changed'] ?? false), 'failing_checks' => $monitor['_failing_checks'] ?? [], ], diff --git a/public/index.php b/public/index.php index 9f6b481..69d7456 100644 --- a/public/index.php +++ b/public/index.php @@ -32,6 +32,7 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\IgnoreRules; use Deploymentcenter\Modules\License\Audit; use Deploymentcenter\Modules\License\KeyGen; +use Deploymentcenter\Modules\UpdateService\ReleaseSigner; use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\Watchdog\Evaluator; use Deploymentcenter\Modules\Watchdog\EventLog; @@ -716,25 +717,49 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $updateManager = new UpdateManager($pdo); + + $channel = trim((string)($_POST['channel'] ?? 'prod')); + $platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? '')); + $size = (int)($_POST['size_bytes'] ?? 0); + + // Auch der Weg ueber die Oberflaeche wird signiert, sonst + // haetten von Hand eingetragene Releases keine Vertrauenskette. + $signature = ReleaseSigner::sign(ReleaseSigner::canonical( + $productSlug, + $version, + $channel, + $platform, + $hash !== '' ? $hash : null, + $url, + $size + )); + $result = $updateManager->addRelease( $productSlug, $version, - trim((string)($_POST['channel'] ?? 'prod')), + $channel, trim((string)($_POST['release_notes'] ?? '')), $url, $hash !== '' ? $hash : null, trim((string)($_POST['git_commit'] ?? '')), - (int)($_POST['size_bytes'] ?? 0), + $size, null, isset($_POST['is_critical']), - $actor + $actor, + $platform, + $signature ); dc_flash(sprintf( - 'Release %s fuer "%s" %s.%s', + 'Release %s (%s, %s) fuer "%s" %s.%s%s', e($version), + e($channel), + e($platform), e($productSlug), $result['created'] ? 'veroeffentlicht' : 'aktualisiert', + $signature === null + ? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.' + : '', $result['auto_resolved'] > 0 ? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved']) : '' @@ -1654,7 +1679,18 @@ $csrfField = Csrf::field(); Der Evaluator lief noch nie.
- Cron einrichten: * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" /api/watchdog/v1/evaluate > /dev/null + + Empfohlen (intern):
+ * * * * * /usr/bin/php /cli/tick.php --quiet +
+ Alternativ über HTTP:
+ * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" /api/watchdog/v1/evaluate > /dev/null +
@@ -1698,6 +1734,7 @@ $csrfField = Csrf::field(); Hierarchie / Entity Source Typ Status + Version Letzte Meldung Zuletzt Gesehen @@ -1727,6 +1764,13 @@ $csrfField = Csrf::field(); ● + + + + + + + @@ -2358,6 +2402,7 @@ Antwort: Name Typ Status + Version Zuletzt gesehen Kurz-Info / Message Aktionen @@ -2383,6 +2428,13 @@ Antwort: + + + + + + + @@ -2566,6 +2618,7 @@ Antwort: Projekt Kanal + Plattform Version Git Commit Größe @@ -2588,7 +2641,15 @@ Antwort: - v KRITISCH + + + + + + v + KRITISCH + UNSIGNIERT + @@ -2633,6 +2694,22 @@ Antwort: +
+ + +
+ Clients ohne Plattformangabe sehen ausschließlich any. +
+
diff --git a/sql/migrations/008_watchdog_app_version.sql b/sql/migrations/008_watchdog_app_version.sql new file mode 100644 index 0000000..afe4e9a --- /dev/null +++ b/sql/migrations/008_watchdog_app_version.sql @@ -0,0 +1,22 @@ +-- Migration 008: Anwendungsversion am Heartbeat +-- +-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062. + +-- --------------------------------------------------------------------------- +-- Welche Version laeuft auf diesem Monitor? +-- --------------------------------------------------------------------------- +-- Der Fehler-Stream fuehrt build_version, der Bugtracker ebenso, die +-- Aktivierungsliste des Lizenzmoduls app_version - nur der Watchdog konnte +-- nicht sagen, welche Version eines Dienstes tatsaechlich laeuft. Damit war +-- eine Aussage wie "Monitor X ist seit dem Rollout von 1.4.3 unten" nur ueber +-- Umwege zu bekommen. +-- +-- Das Feld ist optional; bestehende Agenten senden es nicht und bleiben +-- unveraendert lauffaehig. +ALTER TABLE watchdog_monitors + ADD COLUMN app_version VARCHAR(64) NULL AFTER os; + +-- Rollouts lassen sich damit gruppieren: "zeige alle Monitore, die noch auf +-- der Vorversion stehen". +ALTER TABLE watchdog_monitors + ADD KEY ix_wd_app_version (app_version); diff --git a/sql/migrations/009_updateservice_platform.sql b/sql/migrations/009_updateservice_platform.sql new file mode 100644 index 0000000..64ab99a --- /dev/null +++ b/sql/migrations/009_updateservice_platform.sql @@ -0,0 +1,55 @@ +-- Migration 009: Plattform-Dimension im UpdateService +-- +-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062/1091. + +-- --------------------------------------------------------------------------- +-- Fuer welche Plattform gilt dieses Release? +-- --------------------------------------------------------------------------- +-- Der UpdateService kannte Produkt, Kanal und Version - aber keine Plattform. +-- Seit fuer mehrere Laufzeitkennungen gebaut wird (win-x64, linux-x64, ...) +-- landeten beide Pakete im selben Kanal unter derselben Version und +-- ueberschrieben sich gegenseitig; ein Linux-System zog sich das +-- Windows-Paket. Behelfe waren getrennte Produkt-Slugs oder zweckentfremdete +-- Kanaele - beides trug nicht weit. +-- +-- 'any' ist der Wert fuer plattformunabhaengige Releases und zugleich der +-- Bestandsschutz: alles, was vor dieser Migration veroeffentlicht wurde, gilt +-- weiterhin fuer jeden Client, der keine Plattform mitschickt. +ALTER TABLE updateservice_releases + ADD COLUMN platform VARCHAR(32) NOT NULL DEFAULT 'any' AFTER channel; + +-- Die Eindeutigkeit muss die Plattform einschliessen, sonst verdraengt das +-- zuletzt veroeffentlichte Paket einer Version alle anderen Plattformen +-- derselben Version (ON DUPLICATE KEY UPDATE greift auf dem alten Schluessel). +ALTER TABLE updateservice_releases + DROP INDEX uq_prod_ver_chan; + +ALTER TABLE updateservice_releases + ADD UNIQUE KEY uq_prod_ver_chan_plat (product_slug, version, channel, platform); + +-- Die Abfrage lautet immer "Produkt + Kanal + passende Plattform". +ALTER TABLE updateservice_releases + ADD KEY ix_us_lookup (product_slug, channel, platform); + +-- --------------------------------------------------------------------------- +-- Signatur des Releases +-- --------------------------------------------------------------------------- +-- Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket selbst. +-- Das schuetzt gegen Uebertragungsfehler, nicht gegen einen manipulierten +-- Webroot oder gestohlene FTP-Zugangsdaten - ausgerechnet auf dem Pfad, der +-- fremden Code ausfuehrt. +-- +-- Bewusst KEIN HMAC: Bei einem HMAC braucht der Pruefende denselben +-- geheimen Schluessel wie der Signierende. Der Agent laeuft auf fremden +-- Systemen; ein dort hinterlegter Schluessel koennte gestohlen und zum +-- Signieren beliebiger Pakete benutzt werden - die Signatur waere wertlos. +-- Beim Lizenzmodul geht HMAC auf, weil dort der Server prueft. +-- +-- Stattdessen RSA-SHA256: der Server signiert mit dem privaten Schluessel +-- (security.release_private_key), der Agent prueft mit dem oeffentlichen aus +-- /api/updateservice/v1/pubkey. Base64 einer 2048-bit-Signatur sind 344 +-- Zeichen, daher TEXT und nicht VARCHAR(64). +-- +-- Optional: Releases ohne Signatur bleiben installierbar, der Agent warnt. +ALTER TABLE updateservice_releases + ADD COLUMN manifest_signature TEXT NULL AFTER manifest_json; diff --git a/sql/schema.sql b/sql/schema.sql index af4fc1d..375bf1e 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -94,6 +94,8 @@ CREATE TABLE IF NOT EXISTS watchdog_monitors ( is_muted TINYINT(1) NOT NULL DEFAULT 0, expect_running TINYINT(1) NOT NULL DEFAULT 1, os VARCHAR(50) NULL, + -- Version der laufenden Anwendung, vom Heartbeat mitgeliefert (optional). + app_version VARCHAR(64) NULL, first_contact_deadline_utc DATETIME NULL, ack_until_utc DATETIME NULL, acknowledged_by VARCHAR(100) NULL, @@ -168,15 +170,25 @@ CREATE TABLE IF NOT EXISTS updateservice_releases ( product_slug VARCHAR(64) NOT NULL, version VARCHAR(32) NOT NULL, channel VARCHAR(32) NOT NULL DEFAULT 'prod', + -- Laufzeitkennung (win-x64, linux-x64, ...) oder 'any' fuer + -- plattformunabhaengige Releases. Ohne diese Spalte ueberschrieben sich + -- Pakete verschiedener Plattformen derselben Version gegenseitig. + platform VARCHAR(32) NOT NULL DEFAULT 'any', release_notes TEXT NULL, download_url VARCHAR(255) NOT NULL, sha256_hash VARCHAR(64) NULL, git_commit VARCHAR(64) NULL, size_bytes BIGINT NOT NULL DEFAULT 0, manifest_json JSON NULL, + -- RSA-SHA256 ueber die kanonische Release-Zeile, base64-kodiert. Signiert + -- mit security.release_private_key, geprueft vom Agenten gegen den + -- oeffentlichen Schluessel. Kein HMAC - der Pruefende laeuft auf fremden + -- Systemen und darf den Signierschluessel nicht besitzen. + manifest_signature TEXT NULL, is_critical TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uq_prod_ver_chan (product_slug, version, channel) + UNIQUE KEY uq_prod_ver_chan_plat (product_slug, version, channel, platform), + KEY ix_us_lookup (product_slug, channel, platform) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 5. Core Token Hierarchy & Bugtracker Tables diff --git a/src/Modules/UpdateService/ReleaseSigner.php b/src/Modules/UpdateService/ReleaseSigner.php new file mode 100644 index 0000000..a12743d --- /dev/null +++ b/src/Modules/UpdateService/ReleaseSigner.php @@ -0,0 +1,157 @@ +db = $db; } + /** + * Bringt eine Laufzeitkennung auf eine gueltige Form. + * + * Erwartet wird eine .NET-RID wie win-x64 oder linux-musl-arm64. Alles + * Leere wird zu 'any'; unbrauchbare Angaben ebenfalls, damit ein + * verunglueckter Parameter nicht dazu fuehrt, dass gar kein Release mehr + * gefunden wird. + */ + public static function normalizePlatform(?string $platform): string + { + $value = strtolower(trim((string)$platform)); + + if ($value === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,31}$/', $value) !== 1) { + return self::PLATFORM_ANY; + } + + return $value; + } + + /** + * Releases eines Kanals, gefiltert auf die passende Plattform. + * + * Die Regel lautet: je Version gewinnt das Paket der angefragten + * Plattform, sonst das plattformunabhaengige. Damit bleiben Releases von + * vor der Plattform-Migration ('any') fuer alle Clients erreichbar, + * waehrend ein Client mit Plattformangabe niemals das Paket einer fremden + * Plattform bekommt. + * + * Ein Client ohne Plattformangabe sieht ausschliesslich 'any'. Das ist + * bewusst streng: lieber kein Update als das Windows-Paket auf einem + * Linux-System. + * + * @return list> + */ + private function releasesFor(string $productSlug, string $channel, ?string $platform): array + { + $requested = self::normalizePlatform($platform); + + $candidates = $requested === self::PLATFORM_ANY + ? [self::PLATFORM_ANY] + : [$requested, self::PLATFORM_ANY]; + + $placeholders = implode(', ', array_map( + static fn(int $i): string => ':platform' . $i, + array_keys($candidates) + )); + + $stmt = $this->db->prepare(' + SELECT * FROM updateservice_releases + WHERE product_slug = :slug AND channel = :channel + AND platform IN (' . $placeholders . ') + '); + + $params = [':slug' => $productSlug, ':channel' => $channel]; + foreach ($candidates as $i => $candidate) { + $params[':platform' . $i] = $candidate; + } + $stmt->execute($params); + + $rows = $stmt->fetchAll() ?: []; + + // Je Version nur einen Datensatz behalten - den plattformgenauen. + $byVersion = []; + foreach ($rows as $row) { + $version = (string)($row['version'] ?? ''); + if ($version === '') { + continue; + } + + $rowPlatform = (string)($row['platform'] ?? self::PLATFORM_ANY); + $existing = $byVersion[$version] ?? null; + + if ($existing === null) { + $byVersion[$version] = $row; + continue; + } + + $existingPlatform = (string)($existing['platform'] ?? self::PLATFORM_ANY); + if ($existingPlatform === self::PLATFORM_ANY && $rowPlatform !== self::PLATFORM_ANY) { + $byVersion[$version] = $row; + } + } + + return array_values($byVersion); + } + /** * Ermittelt das neueste Release, das echt neuer ist als die uebergebene Version. * * @return array|null */ - public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array - { - $stmt = $this->db->prepare(' - SELECT * FROM updateservice_releases - WHERE product_slug = :slug AND channel = :channel - '); - $stmt->execute([':slug' => $productSlug, ':channel' => $channel]); - $releases = $stmt->fetchAll() ?: []; + public function checkUpdate( + string $productSlug, + string $currentVersion, + string $channel = 'prod', + ?string $platform = null + ): ?array { + $releases = $this->releasesFor($productSlug, $channel, $platform); if ($releases === []) { return null; @@ -52,14 +140,9 @@ final class UpdateManager } /** Hoechstes Release eines Kanals, unabhaengig von der Client-Version. */ - public function latestRelease(string $productSlug, string $channel = 'prod'): ?array + public function latestRelease(string $productSlug, string $channel = 'prod', ?string $platform = null): ?array { - $stmt = $this->db->prepare(' - SELECT * FROM updateservice_releases - WHERE product_slug = :slug AND channel = :channel - '); - $stmt->execute([':slug' => $productSlug, ':channel' => $channel]); - return Version::highest($stmt->fetchAll() ?: []); + return Version::highest($this->releasesFor($productSlug, $channel, $platform)); } /** @@ -78,41 +161,50 @@ final class UpdateManager int $sizeBytes = 0, ?string $manifestJson = null, bool $isCritical = false, - string $author = 'admin' + string $author = 'admin', + ?string $platform = null, + ?string $manifestSignature = null ): array { - $existing = $this->findRelease($productSlug, $version, $channel); + $platform = self::normalizePlatform($platform); + + $existing = $this->findRelease($productSlug, $version, $channel, $platform); $stmt = $this->db->prepare(' INSERT INTO updateservice_releases ( - product_slug, version, channel, release_notes, download_url, - sha256_hash, git_commit, size_bytes, manifest_json, is_critical + product_slug, version, channel, platform, release_notes, download_url, + sha256_hash, git_commit, size_bytes, manifest_json, manifest_signature, + is_critical ) VALUES ( - :slug, :version, :channel, :notes, :url, - :hash, :git, :size, :manifest, :critical + :slug, :version, :channel, :platform, :notes, :url, + :hash, :git, :size, :manifest, :signature, + :critical ) ON DUPLICATE KEY UPDATE - release_notes = VALUES(release_notes), - download_url = VALUES(download_url), - sha256_hash = VALUES(sha256_hash), - git_commit = VALUES(git_commit), - size_bytes = VALUES(size_bytes), - manifest_json = VALUES(manifest_json), - is_critical = VALUES(is_critical) + release_notes = VALUES(release_notes), + download_url = VALUES(download_url), + sha256_hash = VALUES(sha256_hash), + git_commit = VALUES(git_commit), + size_bytes = VALUES(size_bytes), + manifest_json = VALUES(manifest_json), + manifest_signature = VALUES(manifest_signature), + is_critical = VALUES(is_critical) '); $stmt->execute([ - ':slug' => $productSlug, - ':version' => $version, - ':channel' => $channel, - ':notes' => $releaseNotes, - ':url' => $downloadUrl, - ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null, - ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null, - ':size' => $sizeBytes, - ':manifest' => $manifestJson, - ':critical' => $isCritical ? 1 : 0, + ':slug' => $productSlug, + ':version' => $version, + ':channel' => $channel, + ':platform' => $platform, + ':notes' => $releaseNotes, + ':url' => $downloadUrl, + ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null, + ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null, + ':size' => $sizeBytes, + ':manifest' => $manifestJson, + ':signature' => $manifestSignature !== null && $manifestSignature !== '' ? $manifestSignature : null, + ':critical' => $isCritical ? 1 : 0, ]); - $release = $this->findRelease($productSlug, $version, $channel); + $release = $this->findRelease($productSlug, $version, $channel, $platform); $releaseId = $release !== null ? (int)$release['id'] : 0; // Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind, @@ -130,6 +222,7 @@ final class UpdateManager 'project_slug' => $productSlug, 'version' => $version, 'channel' => $channel, + 'platform' => $platform, 'is_critical' => $isCritical, 'download_url' => $downloadUrl, 'auto_resolved' => $autoResolved, @@ -139,10 +232,11 @@ final class UpdateManager } Logger::info('Release veroeffentlicht', [ - 'product' => $productSlug, - 'version' => $version, - 'channel' => $channel, - 'author' => $author, + 'product' => $productSlug, + 'version' => $version, + 'channel' => $channel, + 'platform' => $platform, + 'author' => $author, ]); return [ @@ -152,14 +246,24 @@ final class UpdateManager ]; } - public function findRelease(string $productSlug, string $version, string $channel): ?array - { + public function findRelease( + string $productSlug, + string $version, + string $channel, + ?string $platform = null + ): ?array { $stmt = $this->db->prepare(' SELECT * FROM updateservice_releases WHERE product_slug = :slug AND version = :version AND channel = :channel + AND platform = :platform LIMIT 1 '); - $stmt->execute([':slug' => $productSlug, ':version' => $version, ':channel' => $channel]); + $stmt->execute([ + ':slug' => $productSlug, + ':version' => $version, + ':channel' => $channel, + ':platform' => self::normalizePlatform($platform), + ]); $row = $stmt->fetch(); return is_array($row) ? $row : null; } @@ -176,8 +280,12 @@ final class UpdateManager * * @return list> */ - public function getReleases(?string $productSlug = null, ?string $channel = null, int $limit = 200): array - { + public function getReleases( + ?string $productSlug = null, + ?string $channel = null, + int $limit = 200, + ?string $platform = null + ): array { $where = []; $params = []; @@ -190,17 +298,26 @@ final class UpdateManager $params[':channel'] = $channel; } + // Hier wird bewusst exakt gefiltert statt aufgeloest: dieser Endpunkt + // listet den Bestand, er waehlt kein Paket aus. + if ($platform !== null && $platform !== '') { + $where[] = 'platform = :platform'; + $params[':platform'] = self::normalizePlatform($platform); + } + $sql = 'SELECT * FROM updateservice_releases'; if ($where !== []) { $sql .= ' WHERE ' . implode(' AND ', $where); } - $sql .= ' ORDER BY product_slug ASC, channel ASC, created_at DESC LIMIT ' . max(1, min($limit, 1000)); + $sql .= ' ORDER BY product_slug ASC, channel ASC, platform ASC, created_at DESC LIMIT ' + . max(1, min($limit, 1000)); $stmt = $this->db->prepare($sql); $stmt->execute($params); $releases = $stmt->fetchAll() ?: []; - // Innerhalb einer Produkt/Kanal-Gruppe nach echter Versionsordnung sortieren. + // Innerhalb einer Produkt/Kanal/Plattform-Gruppe nach echter + // Versionsordnung sortieren. usort($releases, static function (array $a, array $b): int { $bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']); if ($bySlug !== 0) { @@ -210,6 +327,13 @@ final class UpdateManager if ($byChannel !== 0) { return $byChannel; } + $byPlatform = strcmp( + (string)($a['platform'] ?? self::PLATFORM_ANY), + (string)($b['platform'] ?? self::PLATFORM_ANY) + ); + if ($byPlatform !== 0) { + return $byPlatform; + } return Version::compare((string)$b['version'], (string)$a['version']); }); diff --git a/src/Modules/Watchdog/Evaluator.php b/src/Modules/Watchdog/Evaluator.php index 229d7dc..e7c2e5e 100644 --- a/src/Modules/Watchdog/Evaluator.php +++ b/src/Modules/Watchdog/Evaluator.php @@ -25,7 +25,11 @@ use PDO; * stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen * im Event-Log und loesen Webhooks aus. * - * Aufruf per Cron (empfohlen minuetlich): + * Aufruf per Cron (empfohlen minuetlich), bevorzugt intern ohne HTTP: + * * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet + * + * Alternativ ueber die Schnittstelle, wenn der Cron auf einer anderen Maschine + * laeuft als die Anwendung: * curl -H "Authorization: Bearer " https://dc.example.com/api/watchdog/v1/evaluate */ final class Evaluator diff --git a/src/Modules/Watchdog/MonitorRepo.php b/src/Modules/Watchdog/MonitorRepo.php index 8a07f37..bf63c8b 100644 --- a/src/Modules/Watchdog/MonitorRepo.php +++ b/src/Modules/Watchdog/MonitorRepo.php @@ -86,7 +86,8 @@ final class MonitorRepo ?string $message, ?string $groupKey = null, ?string $os = null, - $checks = null + $checks = null, + ?string $appVersion = null ): array { $metricsJson = (is_array($metrics) || is_object($metrics)) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) @@ -139,11 +140,11 @@ final class MonitorRepo INSERT INTO watchdog_monitors ( source, instance, type, state, last_state_change_utc, expected_interval_sec, last_seen_utc, last_status, last_message, metrics_json, health_json, - failing_checks, group_key, os, created_utc, updated_utc + failing_checks, group_key, os, app_version, created_utc, updated_utc ) VALUES ( :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval, UTC_TIMESTAMP(), :last_status, :message, :metrics, :health, - :failing, :group_key, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP() + :failing, :group_key, :os, :app_version, UTC_TIMESTAMP(), UTC_TIMESTAMP() ) ON DUPLICATE KEY UPDATE -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von @@ -161,6 +162,9 @@ final class MonitorRepo failing_checks = VALUES(failing_checks), group_key = COALESCE(VALUES(group_key), group_key), os = COALESCE(VALUES(os), os), + -- COALESCE, damit ein Agent, der die Version nicht mitschickt, + -- eine frueher gemeldete nicht loescht. + app_version = COALESCE(VALUES(app_version), app_version), updated_utc = VALUES(updated_utc) '); @@ -177,6 +181,7 @@ final class MonitorRepo ':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null, ':group_key' => $groupKey, ':os' => $os, + ':app_version' => $appVersion !== null ? mb_substr($appVersion, 0, 64) : null, ]); $monitor = $this->getMonitor($source, $instance);