using System; using System.Collections.Generic; using System.Diagnostics; using System.Formats.Tar; using System.IO; using System.IO.Compression; 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; using Spectre.Console; namespace Deploymentcenter.UpdateAgent { class Program { 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") ?? 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); var options = new AgentOptions { 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), // Die Release-Ablage verlangt Zugangsdaten. Im laufenden // Betrieb ist das der Lizenzschluessel der Anwendung; bei // einer Erstinstallation gibt es den noch nicht, dort treten // die Zugangsdaten des Installationskontos an seine Stelle. Credentials = ReleaseCredentials.FromLicenseKey(GetArg(args, "--license-key")) ?? ReleaseCredentials.FromUser( GetArg(args, "--download-user"), GetArg(args, "--download-password")) }; 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(options, currentVersion, versionKnown, currentBuildDate, currentGit, installedManifest); } switch (action.ToLowerInvariant()) { 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); case "install": return await Installation.RunAsync(options, HttpClient); case "configure": return await Installation.ConfigureOnlyAsync(options, HttpClient); default: return ShowHelp(); } } // ------------------------------------------------------------------ // Modi // ------------------------------------------------------------------ static async Task RunInteractiveMode( AgentOptions options, string currentVer, bool versionKnown, string currentBuildDate, string currentGit, PackageManifest? installedManifest) { AnsiConsole.Write( new FigletText("UpdateAgent") .LeftJustified() .Color(Color.DodgerBlue1)); string versionLabel = versionKnown ? currentVer : "unbekannt"; var panel = new Panel( $"[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); ReleaseManifest? releaseManifest = null; await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) .StartAsync("Lade verfuegbare Releases ...", async ctx => { releaseManifest = await FetchManifestAsync(options); }); if (releaseManifest?.Latest == null) { 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; } var latest = releaseManifest.Latest; 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)[/]"); choices.Add(latestLabel); int idx = 1; 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 (versionKnown && ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase)) { label += " [cyan]<- aktuell installiert[/]"; } choices.Add(label); verMap[key] = ver; } choices.Add("[R] Reparatur der aktuellen Version"); choices.Add("[Q] Beenden"); var selected = AnsiConsole.Prompt( new SelectionPrompt() .Title("\n[bold white]Bitte waehlen Sie eine Aktion:[/]") .PageSize(12) .AddChoices(choices)); if (selected.StartsWith("[Q]")) { AnsiConsole.MarkupLine("[yellow]Vorgang abgebrochen.[/]"); return 0; } string targetVersion; if (selected.StartsWith("[R]")) { 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 v{latest.Version} ...[/]"); } else { targetVersion = "latest"; foreach (var entry in verMap) { if (entry.Key != "latest" && selected.StartsWith($"[{entry.Key}]")) { targetVersion = entry.Value.Version; break; } } AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von v{targetVersion} ...[/]"); } return await DoUpdateOrRepair(options, targetVersion, installedManifest); } static async Task DoCheck(AgentOptions options, string currentVer) { var updateClient = new UpdateClient(HttpClient); var res = await updateClient.CheckForUpdateAsync( options.BaseUrl, options.Project, currentVer, options.Channel, options.Platform, options.Credentials); if (res.Unauthorized) { Console.WriteLine("UNAUTHORIZED: " + res.Message); return 2; } 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(AgentOptions options) { var manifest = await FetchManifestAsync(options); if (manifest == null) { Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar."); return 1; } 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}] [{v.Platform}] SHA: {v.Sha256}"); } return 0; } // ------------------------------------------------------------------ // Update und Reparatur // ------------------------------------------------------------------ internal static async Task DoUpdateOrRepair( AgentOptions options, string targetVersion, PackageManifest? installedManifest) { string tempDir = Path.Combine(Path.GetTempPath(), "dc_update_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); try { var releaseManifest = await FetchManifestAsync(options); if (releaseManifest == null) { AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]"); return 1; } 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 '{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; } // ---------------------------------------------------------- // Signatur pruefen // ---------------------------------------------------------- if (!await VerifyReleaseSignature(options, targetRelease)) { return 1; } string pkgUrl = string.IsNullOrEmpty(targetRelease.PackageUrl) ? BuildPackageUrl(options, targetRelease.Version) : targetRelease.PackageUrl; string localPkgPath = Path.Combine(tempDir, "package.tar.gz"); // ---------------------------------------------------------- // 1. Herunterladen // ---------------------------------------------------------- await AnsiConsole.Progress() .Columns(new ProgressColumn[] { new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), new RemainingTimeColumn(), new SpinnerColumn(), }) .StartAsync(async ctx => { var downloadTask = ctx.AddTask($"[green]Lade Paket v{targetRelease.Version} ...[/]"); using var pkgRequest = new HttpRequestMessage(HttpMethod.Get, pkgUrl); ReleaseCredentials.Apply(pkgRequest, options.Credentials); using var resp = await HttpClient.SendAsync(pkgRequest, HttpCompletionOption.ResponseHeadersRead); if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized) { throw new UnauthorizedAccessException( ReleaseCredentials.DescribeUnauthorized(options.Credentials)); } resp.EnsureSuccessStatusCode(); long? totalBytes = resp.Content.Headers.ContentLength; using var stream = await resp.Content.ReadAsStreamAsync(); using var fileStream = File.Create(localPkgPath); byte[] buffer = new byte[81920]; long readBytes = 0; int bytesRead; while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) { await fileStream.WriteAsync(buffer, 0, bytesRead); readBytes += bytesRead; if (totalBytes.HasValue && totalBytes.Value > 0) { downloadTask.Value = ((double)readBytes / totalBytes.Value) * 100; } } downloadTask.Value = 100; }); // ---------------------------------------------------------- // 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 stimmt nicht ueberein![/]"); AnsiConsole.MarkupLine($"Erwartet: {targetRelease.Sha256}"); AnsiConsole.MarkupLine($"Erhalten: {computedSha}"); return 1; } AnsiConsole.MarkupLine("[bold green] SHA256 verifiziert.[/]"); // ---------------------------------------------------------- // 3. Entpacken // ---------------------------------------------------------- string extractDir = Path.Combine(tempDir, "extracted"); Directory.CreateDirectory(extractDir); 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. 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); packageManifest = JsonSerializer.Deserialize(mJson); if (packageManifest != null) { var integrity = UpdateClient.VerifyIntegrity(extractDir, packageManifest); if (!integrity.IsValid) { 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.[/]"); // 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)}[/]"); } } if (plan.Orphans.Count > 0) { 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 waehrend des Updates: {Markup.Escape(ex.Message)}[/]"); return 1; } finally { try { Directory.Delete(tempDir, true); } catch { } } } // ------------------------------------------------------------------ // 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) { var fromStatic = await FetchStaticManifestAsync(options); if (fromStatic?.Latest != null) { return fromStatic; } return await FetchApiManifestAsync(options); } 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 { using var request = new HttpRequestMessage(HttpMethod.Get, url); ReleaseCredentials.Apply(request, options.Credentials); var resp = await HttpClient.SendAsync(request); // Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt // nicht mehr. Weiterzuprobieren wuerde die Ursache nur // hinter einer allgemeinen Fehlermeldung verstecken. if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized) { AnsiConsole.MarkupLine("[bold red]" + Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials)) + "[/]"); return null; } 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 = $"{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; string json = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("releases", out var releasesProp) || releasesProp.ValueKind != JsonValueKind.Array) { 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(); using var stream = File.OpenRead(file); byte[] hash = sha256.ComputeHash(stream); 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++) { if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) || (flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase))) { return args[i + 1]; } } 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("Aufruf: update-agent [Optionen]"); Console.WriteLine(); Console.WriteLine(" --project, -p Projekt-Slug"); Console.WriteLine(" --channel, -c prod | beta | dev"); Console.WriteLine(" --action, -a interactive | install | configure | check |"); Console.WriteLine(" 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(" --license-key Zugang zur Release-Ablage (Regelfall im Betrieb)"); Console.WriteLine(" --download-user Alternativ: Installationskonto"); Console.WriteLine(" --download-password dazugehoeriges Passwort"); 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; /// /// Zugangsdaten fuer /releases/. Null bedeutet: ohne Anmeldung /// versuchen - eine noch ungeschuetzte Ablage bleibt so erreichbar. /// public ReleaseCredentials? Credentials { get; set; } } }