using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Deploymentcenter.Client.Models; namespace Deploymentcenter.Client { public class UpdateCheckResult { public bool UpdateAvailable { get; set; } public bool IsCritical { get; set; } public VersionInfo? LatestRelease { get; set; } public ReleaseManifest? FullManifest { get; set; } public string Message { get; set; } = string.Empty; public Exception? Error { get; set; } /// /// Die Release-Ablage hat die Zugangsdaten abgelehnt - typischerweise /// eine abgelaufene oder widerrufene Lizenz. Bewusst von einem /// gewoehnlichen Fehler unterschieden: Sonst sieht ein abgelaufener /// Vertrag aus wie eine Netzwerkstoerung. /// public bool Unauthorized { get; set; } } public class IntegrityCheckResult { public bool IsValid { get; set; } = true; public List MissingFiles { get; } = new List(); public List CorruptedFiles { get; } = new List(); public int TotalCheckedFiles { get; set; } } public class UpdateClient { private static readonly HttpClient SharedHttpClient = new HttpClient(); private readonly HttpClient _httpClient; public UpdateClient(HttpClient? httpClient = null) { _httpClient = httpClient ?? SharedHttpClient; } /// /// 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, ReleaseCredentials? credentials = 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 // 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. // // Der Rueckfall auf die API war zuvor unerreichbar: er lag in // einem catch, aber GetAsync wirft bei einem 404 keine Exception, // sondern liefert eine Antwort mit Statuscode. Fehlte die // latest.json, brach die Pruefung mit "HTTP Error NotFound" ab, // statt die API zu befragen. HttpResponseMessage? response = null; try { response = await SendWithCredentialsAsync(staticUrl, credentials, cancellationToken) .ConfigureAwait(false); } catch { response = null; } // Ein 401 auf die statische Ablage ist etwas anderes als ein // fehlendes Release: Die Lizenz traegt nicht mehr. Das ueber // den API-Rueckfall zu verschlucken hiesse, den Anwender im // Unklaren zu lassen, warum keine Updates mehr kommen. if (response != null && response.StatusCode == HttpStatusCode.Unauthorized) { result.Unauthorized = true; result.Message = ReleaseCredentials.DescribeUnauthorized(credentials); response.Dispose(); return result; } if (response == null || !response.IsSuccessStatusCode) { response?.Dispose(); string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check" + $"?product={Uri.EscapeDataString(projectId)}" + $"&version={Uri.EscapeDataString(currentVersion)}" + $"&channel={Uri.EscapeDataString(channel)}" + $"&platform={Uri.EscapeDataString(rid)}"; response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); } // Auch der API-Zweig kann 401 liefern - etwa wenn die // Lese-Endpunkte spaeter ebenfalls hinter den Zugangsschutz // wandern. Zuvor wurde das nur auf dem statischen Weg erkannt // und hier als gewoehnlicher HTTP-Fehler gemeldet. if (response.StatusCode == HttpStatusCode.Unauthorized) { result.Unauthorized = true; result.Message = ReleaseCredentials.DescribeUnauthorized(credentials); return result; } if (!response.IsSuccessStatusCode) { result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}"; return result; } string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; // Handle static latest.json format if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object) { var manifest = JsonSerializer.Deserialize(json); if (manifest?.Latest != null) { result.FullManifest = manifest; result.LatestRelease = manifest.Latest; if (IsVersionNewer(currentVersion, manifest.Latest.Version)) { result.UpdateAvailable = true; result.IsCritical = manifest.Latest.IsCritical; result.Message = $"New release v{manifest.Latest.Version} available."; } else { result.Message = "Application is up to date."; } } } // 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.ValueKind == JsonValueKind.True || (availProp.ValueKind == JsonValueKind.Number && availProp.GetInt32() != 0); result.UpdateAvailable = available; if (root.TryGetProperty("latest_release", out var relProp) && relProp.ValueKind == JsonValueKind.Object) { 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."; } } catch (Exception ex) { result.Error = ex; result.Message = $"Update check failed: {ex.Message}"; } return result; } /// /// Fuehrt eine GET-Anfrage aus und legt bei Bedarf die Zugangsdaten /// der Release-Ablage an. /// private async Task SendWithCredentialsAsync( string url, ReleaseCredentials? credentials, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, url); ReleaseCredentials.Apply(request, credentials); return await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); } /// /// 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. /// public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest) { var result = new IntegrityCheckResult(); if (manifest == null || manifest.Files == null || manifest.Files.Count == 0) { return result; } foreach (var entry in manifest.Files) { result.TotalCheckedFiles++; string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(fullPath)) { result.IsValid = false; result.MissingFiles.Add(entry.Path); continue; } if (!string.IsNullOrEmpty(entry.Sha256)) { string computedHash = ComputeSha256(fullPath); if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase)) { result.IsValid = false; result.CorruptedFiles.Add(entry.Path); } } } return result; } /// /// 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. /// /// /// Reicht --require-signature an den Agenten durch. Ohne diesen /// Parameter gab es dafuer nur den Kommandozeilenschalter - jede /// Anwendung, die dem Leitfaden folgt und ueber diese Methode /// startet, aktualisierte damit ungeprueft, waehrend derselbe Aufruf /// von Hand auf der Kommandozeile geschuetzt gewesen waere. Den /// oeffentlichen Schluessel muss dafuer niemand selbst verwalten: der /// Agent holt und cached ihn ohnehin von /api/updateservice/v1/pubkey. /// public static bool LaunchUpdateAgent( string agentPath, string projectId, string channel = "prod", string action = "update", string version = "latest", string? targetDir = null, bool exitCurrentApp = true, string? restartPath = null, string? currentVersion = null, string? platform = null, bool waitForCurrentProcess = true, string? licenseKey = null, int waitTimeoutSeconds = 60, bool requireSignature = false) { if (!File.Exists(agentPath)) { return false; } targetDir ??= AppDomain.CurrentDomain.BaseDirectory; var args = new StringBuilder(); 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!); } // Der Lizenzschluessel wird BEWUSST NICHT als Argument uebergeben. // // Was in argv steht, zeigt "ps" jedem Benutzer der Maschine. Aus // genau diesem Grund verwirft UPGRADE.md ยง5 den Weg, den Shared // Key im Crontab zu hinterlegen - fuer einen Lizenzschluessel // gilt dasselbe. // // Stattdessen wird die Variable auf dem eigenen Prozess gesetzt; // der Agent erbt den Umgebungsblock beim Start und liest sie dort. // Das funktioniert auch mit UseShellExecute=true, wo sich // ProcessStartInfo.Environment nicht setzen laesst. bool licenseKeySet = false; if (!string.IsNullOrWhiteSpace(licenseKey)) { Environment.SetEnvironmentVariable("DC_LICENSE_KEY", licenseKey); licenseKeySet = true; } if (waitForCurrentProcess) { AppendArg(args, "--wait-for-pid", Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture)); // Ohne diesen Wert galten fest 60 Sekunden. Eine Anwendung, // die allein fuer host.StopAsync 30 Sekunden braucht, kommt // damit gefaehrlich nah an die Grenze - und laeuft sie ab, // bricht der Agent ab, statt ueber gesperrte Dateien zu // schreiben. Sicher, aber eben auch kein Update. AppendArg(args, "--wait-timeout", Math.Max(5, waitTimeoutSeconds).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!); } if (requireSignature) { AppendFlag(args, "--require-signature"); } var startInfo = new ProcessStartInfo { FileName = agentPath, Arguments = args.ToString().TrimEnd(), WorkingDirectory = Path.GetDirectoryName(agentPath) ?? targetDir, UseShellExecute = true }; try { Process.Start(startInfo); } finally { // Nach dem Start wieder entfernen: Der Schluessel soll nur so // lange im eigenen Umgebungsblock stehen, wie er zum Vererben // gebraucht wird. if (licenseKeySet) { Environment.SetEnvironmentVariable("DC_LICENSE_KEY", null); } } if (exitCurrentApp) { // Environment.Exit beendet den Prozess sofort: laufende // finally-Bloecke anderer Threads, IHostApplicationLifetime // und Destruktoren kommen nicht mehr zum Zug. Fuer eine // Anwendung mit offenem Zustand - Positionen, Transaktionen, // ungeschriebene Puffer - ist das die falsche Wahl. Dort // gehoert exitCurrentApp:false gesetzt und danach der eigene // geordnete Weg gegangen; der Agent wartet ohnehin auf das // Prozessende. Environment.Exit(0); } 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 void AppendFlag(StringBuilder builder, string name) { builder.Append(name); 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(); using var stream = File.OpenRead(filePath); byte[] hash = sha256.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } /// /// Prueft, ob neuer ist als . /// /// Die vorherige Fassung entfernte zwar die Vorabkennung, nicht aber ein /// fuehrendes "v". Damit scheiterte Version.TryParse bei Angaben wie /// "v1.4.2" und es wurde auf einen alphabetischen Vergleich /// zurueckgefallen - dort gilt "v1.9.0" faelschlich als neuer als /// "v1.10.0". Das entspricht dem Fehler, der serverseitig in der /// SQL-Abfrage steckte. /// public static bool IsVersionNewer(string currentVer, string remoteVer) { if (string.IsNullOrWhiteSpace(remoteVer)) return false; if (string.IsNullOrWhiteSpace(currentVer)) return true; return CompareVersions(remoteVer, currentVer) > 0; } /// /// Vergleicht zwei Versionsangaben nach semantischer Ordnung. /// Rueckgabe: negativ wenn a < b, 0 bei Gleichstand, positiv wenn a > b. /// public static int CompareVersions(string a, string b) { var (coreA, preA) = ParseVersion(a); var (coreB, preB) = ParseVersion(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 partA.CompareTo(partB); } } // Eine Version ohne Vorabkennung rangiert ueber derselben mit: // 1.0.0 ist neuer als 1.0.0-rc.1 bool emptyA = preA.Count == 0; bool emptyB = preB.Count == 0; if (emptyA && emptyB) return 0; if (emptyA) return 1; if (emptyB) return -1; int preLength = Math.Max(preA.Count, preB.Count); for (int i = 0; i < preLength; i++) { if (i >= preA.Count) return -1; if (i >= preB.Count) return 1; bool numericA = int.TryParse(preA[i], out int numA); bool numericB = int.TryParse(preB[i], out int numB); if (numericA && numericB) { if (numA != numB) return numA.CompareTo(numB); continue; } // Rein numerische Bestandteile rangieren unter alphanumerischen. if (numericA != numericB) return numericA ? -1 : 1; int cmp = string.CompareOrdinal(preA[i], preB[i]); if (cmp != 0) return cmp > 0 ? 1 : -1; } return 0; } private static (List Core, List Prerelease) ParseVersion(string version) { string value = (version ?? string.Empty).Trim().TrimStart('v', 'V'); // Build-Metadaten sind fuer die Rangfolge ohne Bedeutung. int plus = value.IndexOf('+'); if (plus >= 0) value = value.Substring(0, plus); var prerelease = new List(); int dash = value.IndexOf('-'); if (dash >= 0) { string preString = value.Substring(dash + 1); value = value.Substring(0, dash); if (preString.Length > 0) { prerelease.AddRange(preString.Split('.')); } } 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, prerelease); } } }