Files
Deploymentcenter/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs
T
Deploymentcenter BotandClaude Opus 5 687ee0cefc feat(installer): Laufzeitpruefung, Host-Ueberwachung, Zugang fuer Adminkonten
Drei Dinge, die beim ersten Lauf des Installers auf einer Linux-Maschine
auffielen.

1. Die Release-Ablage wies Administratorkonten ab. ReleaseGuard nahm nur
   die Rolle 'installer' in die .htpasswd auf, waehrend Installskripte und
   Agent ausdruecklich sagten, ein Administratorkonto tue es auch:
   Anmeldung und Katalog gelangen, erst der Download endete mit 401 - und
   die Meldung sprach von abgelaufenen Lizenzen, die es bei einer
   Erstinstallation gar nicht geben kann. Adminkonten zaehlen jetzt zu den
   Installationskonten. FORMAT_VERSION auf 3, damit reconcile() die
   Dateien sofort neu schreibt statt erst beim naechsten turnusmaessigen
   Lauf; ein neu angelegtes Konto landet ausserdem unabhaengig von seiner
   Rolle sofort darin. Bei einem 401 mit Benutzerzugangsdaten nennt der
   Client jetzt Konto und zugangsberechtigte Rollen, und der Agent bricht
   ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar
   Schritte spaeter ein zweites Mal zu zeigen.

2. Der Installer prueft die .NET-Laufzeit. Bisher endete eine gelungene
   Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich
   nicht starten laesst - und die Fehlersuche begann beim
   Deploymentcenter, weil das der letzte bewusste Schritt war. Gelesen
   wird die runtimeconfig.json der Anwendung und mit "dotnet
   --list-runtimes" verglichen; fehlt etwas, nennt der Installer den
   Installationsbefehl fuer diese Plattform. Eigenstaendig
   veroeffentlichte Pakete werden nicht bemaengelt, rollForward wird
   beachtet.

3. Die Ueberwachung der Maschine entsteht im Installer. Zwei Fragen -
   Name im Dashboard und ob eingeplant werden soll - statt fuenf Schritten
   in der Oberflaeche an einem anderen Rechner. Monitor, Token mit genau
   watchdog:ping, Skript, Dateirechte, ein Heartbeat zur Probe und der
   Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen
   ohne Installation: --action monitor.

Die Agent-Skripte werden jetzt in src/Modules/Watchdog/AgentScript.php
erzeugt - von Oberflaeche und Installer gemeinsam - und melden Last,
Speicher, Plattenbelegung und Laufzeit mit, statt nur "status: ok". Beim
Ausfuehren fielen zwei Fehler auf, die dort behoben sind: df -P verrutscht
bei Geraetenamen mit Leerzeichen (gezaehlt wird jetzt von hinten), und
ohne LC_ALL=C erzeugt awk auf einem deutschen System "12,5" und damit
kaputtes JSON.

Neu: POST /api/setup/v1/agent. SDK 2.6.0 mit
SetupClient.RequestWatchdogAgentAsync().

Die OpenAPI-Beschreibung des neuen Endpunkts bleibt zunaechst aussen vor:
public/api/openapi.php traegt gerade auch fremde, noch nicht committete
Aenderungen aus einer parallel laufenden Arbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:51:37 +02:00

1246 lines
52 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);
case "monitor":
// Fuer Maschinen, auf denen nichts zu installieren ist -
// der Hypervisor unter den Anwendungen etwa - und zum
// Nachholen, wenn die Frage bei der Installation verneint
// wurde.
return await Monitoring.RunStandaloneAsync(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);
ManifestLookup lookup = new ManifestLookup();
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Lade verfuegbare Releases ...", async ctx =>
{
lookup = await FetchManifestAsync(options);
});
// Der Grund steht schon im Bild - hier noch einmal nach fehlenden
// Releases zu suchen, waere eine zweite, falsche Faehrte.
if (lookup.Unauthorized)
{
return 1;
}
ReleaseManifest? releaseManifest = lookup.Manifest;
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 lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
// Derselbe Rueckgabewert wie bei DoCheck - wer den Agenten aus
// einem Skript ruft, unterscheidet damit "kein Zugang" von
// "nichts veroeffentlicht".
Console.WriteLine("UNAUTHORIZED");
return 2;
}
var manifest = lookup.Manifest;
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 lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
return 1;
}
var releaseManifest = lookup.Manifest;
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>
/// <summary>
/// Ergebnis eines Manifest-Abrufs. Ein leeres Manifest wegen 401 ist
/// etwas anderes als ein leeres, weil nichts veroeffentlicht wurde -
/// die Aufrufer sollen dafuer nicht dieselbe Meldung zeigen.
/// </summary>
internal sealed class ManifestLookup
{
public ReleaseManifest? Manifest { get; init; }
/// <summary>Die Ablage hat die Zugangsdaten abgelehnt.</summary>
public bool Unauthorized { get; init; }
}
static async Task<ManifestLookup> FetchManifestAsync(AgentOptions options)
{
var (fromStatic, unauthorized) = await FetchStaticManifestAsync(options);
if (unauthorized)
{
// Ein 401 ist keine "Datei fehlt"-Lage: die Zugangsdaten
// tragen nicht. Die API wuerde zwar antworten, sie liegt nicht
// hinter dem Zugangsschutz - das Paket selbst aber schon. Der
// Lauf endete also ein paar Schritte spaeter mit genau
// derselben Meldung ein zweites Mal, dazwischen Ausgaben ueber
// Signaturen, die den eigentlichen Grund nach oben aus dem
// Bild schieben.
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return new ManifestLookup { Unauthorized = true };
}
if (fromStatic?.Latest != null)
{
return new ManifestLookup { Manifest = fromStatic };
}
return new ManifestLookup { Manifest = await FetchApiManifestAsync(options) };
}
static async Task<(ReleaseManifest? Manifest, bool Unauthorized)> 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 wird nach oben durchgereicht, statt hier
// gemeldet zu werden: sonst steht die Meldung schon im
// Bild, waehrend der Aufrufer noch weitersucht.
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
return (null, true);
}
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, false);
}
catch
{
// Naechster Kandidat.
}
}
return (null, false);
}
/// <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 | monitor |");
Console.WriteLine(" check | 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; }
}
}