Fuenf von sechs Befunden einer externen Integration. Der sechste - unsignierte Lizenzurteile - ist bestaetigt, aber bewusst nicht Teil dieses Commits. 1. Lizenzschluessel stand in der Kommandozeile Der Agent nahm --license-key nur als Argument und las keine Umgebungsvariablen. "ps" zeigt argv jedem Benutzer der Maschine - exakt die Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Damit nahm das SDK einen bereits geloesten Sicherheitsbefund zurueck. Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und DC_DOWNLOAD_PASSWORD, Umgebung vor Argument. LaunchUpdateAgent uebergibt den Schluessel nicht mehr als Argument, sondern setzt die Variable auf dem eigenen Prozess: das Kind erbt den Umgebungsblock, danach wird sie wieder entfernt. Das funktioniert auch mit UseShellExecute=true, wo sich ProcessStartInfo.Environment nicht setzen laesst. 2. --wait-timeout war nicht durchgereicht Der Agent kannte den Parameter, LaunchUpdateAgent hatte keinen dafuer - es galten fest 60 Sekunden. Eine Anwendung, die allein fuer host.StopAsync 30 Sekunden braucht, kommt damit gefaehrlich nah an die Grenze. Neu: waitTimeoutSeconds. Ausserdem ist im Quelltext und in der Doku jetzt festgehalten, dass exitCurrentApp:true ueber Environment.Exit(0) laeuft und damit finally-Bloecke und IHostApplicationLifetime uebergeht - bei offenem Zustand die falsche Wahl. 3. ILicensePrompt war tot Der Konstruktor nahm es entgegen, legte es in _prompt ab und benutzte es nirgends. Wer darauf eine headless-Story aufbaute, baute auf Sand. Neu: EnsureLicensedAsync() - zwischengespeicherten Schluessel nehmen, sonst fragen, pruefen, bei Ablehnung erneut fragen. allowPrompt:false lehnt ohne Cache ab, statt auf eine Eingabe zu warten, die im Dienst nie kommt. Ein voruebergehender Netzfehler fuehrt nicht zur erneuten Abfrage - der Schluessel ist ja nicht falsch. 4. Der Packager warnte nur Er bricht jetzt ab. Anlass war ein echter API-Schluessel in einem oeffentlich abrufbaren Paket - und die Warnung war damals ausgerechnet unterdrueckt, weil die Datei auf der preserve-Liste stand. Zwei Stufen: Dateiname (appsettings.Local.json, master.key, *.pfx, *.db, server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_, dc_master_, AKIA, private Schluessel). Die Inhaltspruefung findet auch Dateien mit unverdaechtigem Namen. Platzhalter loesen bewusst nicht aus: "sk-DEIN-SCHLUESSEL-HIER" haette sonst jede ausgelieferte Vorlage blockiert, und --allow-secrets waere nach einer Woche Gewohnheit. Beim Erproben zuerst genau in diese Falle gelaufen. 5. BuildInfo.targets war nur per Pfad-Import zu haben Die Anleitung empfahl einen <Import> ins Nachbar-Repository - das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben Fassung stehen. Das Client-Projekt ist jetzt packbar und legt das Target unter build/ ins Paket, wo NuGet es selbst importiert. Ausserdem: Unauthorized wurde nur im statischen Zweig erkannt, im API-Zweig kam ein 401 als gewoehnlicher HTTP-Fehler an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1185 lines
50 KiB
C#
1185 lines
50 KiB
C#
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();
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const string UnknownVersion = "0.0.0";
|
|
|
|
private const string PublicKeyFileName = "dc-release-pubkey.pem";
|
|
|
|
static async Task<int> 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.
|
|
//
|
|
// Umgebungsvariablen haben Vorrang vor den Argumenten: 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 nichts anderes.
|
|
Credentials = ReadCredentials(args)
|
|
};
|
|
|
|
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<int> 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>();
|
|
string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} "
|
|
+ (isUpdateAvailable ? "[bold green]<- empfohlen[/]" : "[grey](aktuell)[/]");
|
|
choices.Add(latestLabel);
|
|
|
|
int idx = 1;
|
|
var verMap = new Dictionary<string, VersionInfo> { ["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<string>()
|
|
.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<int> 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<int> 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<int> 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<PackageManifest>(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
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
static async Task<ReleaseManifest?> FetchManifestAsync(AgentOptions options)
|
|
{
|
|
var fromStatic = await FetchStaticManifestAsync(options);
|
|
if (fromStatic?.Latest != null)
|
|
{
|
|
return fromStatic;
|
|
}
|
|
|
|
return await FetchApiManifestAsync(options);
|
|
}
|
|
|
|
static async Task<ReleaseManifest?> FetchStaticManifestAsync(AgentOptions options)
|
|
{
|
|
// Plattformunabhaengige Releases liegen weiterhin im alten Pfad
|
|
// ohne Zwischenebene.
|
|
string segment = PlatformId.PathSegment(options.Platform);
|
|
|
|
var candidates = new List<string>
|
|
{
|
|
$"{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<ReleaseManifest>(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<string>.Create(UpdateClient.CompareVersions))
|
|
.FirstOrDefault();
|
|
|
|
if (manifest.Latest != null)
|
|
return manifest;
|
|
}
|
|
catch
|
|
{
|
|
// Naechster Kandidat.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Baut die Release-Liste aus der API. Die Plattformaufloesung
|
|
/// geschieht hier, damit auch plattformunabhaengige Releases
|
|
/// beruecksichtigt werden.
|
|
/// </summary>
|
|
static async Task<ReleaseManifest?> 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<string, VersionInfo>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var element in releasesProp.EnumerateArray())
|
|
{
|
|
var info = JsonSerializer.Deserialize<ApiReleaseInfo>(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<string>.Create(UpdateClient.CompareVersions))
|
|
.ToList();
|
|
|
|
manifest.Latest = manifest.Versions.FirstOrDefault();
|
|
|
|
return manifest.Latest == null ? null : manifest;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Darf ein Paket dieser Plattform auf diesem System installiert
|
|
/// werden? "any" passt immer; sonst muss die Kennung uebereinstimmen.
|
|
/// </summary>
|
|
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
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Prueft die Serversignatur des Releases, sofern eine vorliegt.
|
|
/// Rueckgabe false bedeutet: nicht weitermachen.
|
|
/// </summary>
|
|
static async Task<bool> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
static async Task<string?> 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<string?> 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<string?> 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<ApiReleaseInfo>(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
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)}[/]");
|
|
}
|
|
}
|
|
|
|
/// <summary>Vollstaendiger Pfad der laufenden Agent-Datei.</summary>
|
|
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<PackageManifest>(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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ermittelt die Zugangsdaten fuer die Release-Ablage.
|
|
///
|
|
/// Reihenfolge: Umgebungsvariable vor Argument. Ein Geheimnis in argv
|
|
/// steht in der Prozessliste und ist damit fuer jeden Benutzer der
|
|
/// Maschine sichtbar; die Umgebung eines fremden Prozesses ist unter
|
|
/// Linux nur fuer den Eigentuemer und root lesbar.
|
|
///
|
|
/// Das SDK setzt die Variable vor dem Start auf sich selbst - der
|
|
/// Agent erbt sie und der Schluessel taucht nirgends in einer
|
|
/// Kommandozeile auf.
|
|
/// </summary>
|
|
static ReleaseCredentials? ReadCredentials(string[] args)
|
|
{
|
|
string? licenseKey = Env("DC_LICENSE_KEY") ?? GetArg(args, "--license-key");
|
|
|
|
var fromLicense = ReleaseCredentials.FromLicenseKey(licenseKey);
|
|
if (fromLicense != null)
|
|
{
|
|
return fromLicense;
|
|
}
|
|
|
|
string? user = Env("DC_DOWNLOAD_USER") ?? GetArg(args, "--download-user");
|
|
string? pass = Env("DC_DOWNLOAD_PASSWORD") ?? GetArg(args, "--download-password");
|
|
|
|
return ReleaseCredentials.FromUser(user, pass);
|
|
}
|
|
|
|
static string? Env(string name)
|
|
{
|
|
string? value = Environment.GetEnvironmentVariable(name);
|
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
|
}
|
|
|
|
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 <slug> Projekt-Slug");
|
|
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
|
|
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | check |");
|
|
Console.WriteLine(" update | repair | list");
|
|
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
|
|
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
|
|
Console.WriteLine(" --platform <rid> Laufzeitkennung (Vorgabe: die des Systems)");
|
|
Console.WriteLine(" --base-url <url> Adresse des Deploymentcenters");
|
|
Console.WriteLine(" --current-version <ver> Installierte Version, wenn keine manifest.json vorliegt");
|
|
Console.WriteLine(" --wait-for-pid <pid> Vor dem Anwenden auf das Ende dieses Prozesses warten");
|
|
Console.WriteLine(" --wait-timeout <sek> Geduld dabei (Vorgabe: 60)");
|
|
Console.WriteLine(" --restart <datei> Nach dem Update zu startende Anwendung");
|
|
Console.WriteLine(" --license-key <schluessel> Zugang zur Release-Ablage (Regelfall im Betrieb)");
|
|
Console.WriteLine(" --download-user <benutzer> Alternativ: Installationskonto");
|
|
Console.WriteLine(" --download-password <pw> dazugehoeriges Passwort");
|
|
Console.WriteLine(" --pubkey <datei> Oeffentlicher Schluessel zur Signaturpruefung");
|
|
Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren");
|
|
Console.WriteLine();
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>Gebuendelte Aufrufparameter.</summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Zugangsdaten fuer /releases/. Null bedeutet: ohne Anmeldung
|
|
/// versuchen - eine noch ungeschuetzte Ablage bleibt so erreichbar.
|
|
/// </summary>
|
|
public ReleaseCredentials? Credentials { get; set; }
|
|
}
|
|
}
|