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; }
}
}