Files
Deploymentcenter/client-dotnet/Deploymentcenter.Client/UpdateClient.cs
T
Deploymentcenter Bot fc9b698141 feat(sdk): --require-signature ueber LaunchUpdateAgent erreichbar
Der Schalter gab es bisher nur auf der Kommandozeile. LaunchUpdateAgent,
der vom Leitfaden empfohlene Weg, hatte dafuer keinen Parameter - jede
Anwendung, die diesem Weg folgte, aktualisierte damit ungeprueft, waehrend
derselbe Vorgang von Hand geschuetzt gewesen waere.

Neuer optionaler Parameter requireSignature (Vorgabe false, keine
Verhaltensaenderung fuer bestehende Aufrufer). Der oeffentliche Schluessel
muss dafuer nicht separat verwaltet werden, der Agent holt und cached ihn
selbst. SDK auf 2.5.2, Changelog- und Leitfaden-Eintrag ergaenzt.
2026-08-15 13:38:33 +02:00

621 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Deploymentcenter.Client.Models;
namespace Deploymentcenter.Client
{
public class UpdateCheckResult
{
public bool UpdateAvailable { get; set; }
public bool IsCritical { get; set; }
public VersionInfo? LatestRelease { get; set; }
public ReleaseManifest? FullManifest { get; set; }
public string Message { get; set; } = string.Empty;
public Exception? Error { get; set; }
/// <summary>
/// Die Release-Ablage hat die Zugangsdaten abgelehnt - typischerweise
/// eine abgelaufene oder widerrufene Lizenz. Bewusst von einem
/// gewoehnlichen Fehler unterschieden: Sonst sieht ein abgelaufener
/// Vertrag aus wie eine Netzwerkstoerung.
/// </summary>
public bool Unauthorized { get; set; }
}
public class IntegrityCheckResult
{
public bool IsValid { get; set; } = true;
public List<string> MissingFiles { get; } = new List<string>();
public List<string> CorruptedFiles { get; } = new List<string>();
public int TotalCheckedFiles { get; set; }
}
public class UpdateClient
{
private static readonly HttpClient SharedHttpClient = new HttpClient();
private readonly HttpClient _httpClient;
public UpdateClient(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? SharedHttpClient;
}
/// <summary>
/// Checks for update availability against LEMP static latest.json or Deploymentcenter API.
/// </summary>
/// <param name="platform">
/// Laufzeitkennung des Systems (z. B. "win-x64"). Ohne Angabe wird die
/// des laufenden Prozesses verwendet. Wird bewusst mitgeschickt: ohne
/// sie liefert der Server nur plattformunabhaengige Releases, damit ein
/// Client nie das Paket einer fremden Plattform angeboten bekommt.
/// </param>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string baseUrl,
string projectId,
string currentVersion,
string channel = "prod",
string? platform = null,
ReleaseCredentials? credentials = null,
CancellationToken cancellationToken = default)
{
var result = new UpdateCheckResult();
try
{
string cleanBaseUrl = baseUrl.TrimEnd('/');
string rid = PlatformId.Normalize(platform ?? PlatformId.Current);
// Primary check: LEMP static channel latest.json
// Plattformunabhaengige Releases liegen weiterhin unter dem
// alten Pfad ohne Zwischenebene, damit bereits ausgelieferte
// Anwendungen ihre Updates finden.
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}{PlatformId.PathSegment(rid)}/latest.json";
// Zuerst die statische latest.json, danach die API.
//
// Der Rueckfall auf die API war zuvor unerreichbar: er lag in
// einem catch, aber GetAsync wirft bei einem 404 keine Exception,
// sondern liefert eine Antwort mit Statuscode. Fehlte die
// latest.json, brach die Pruefung mit "HTTP Error NotFound" ab,
// statt die API zu befragen.
HttpResponseMessage? response = null;
try
{
response = await SendWithCredentialsAsync(staticUrl, credentials, cancellationToken)
.ConfigureAwait(false);
}
catch
{
response = null;
}
// Ein 401 auf die statische Ablage ist etwas anderes als ein
// fehlendes Release: Die Lizenz traegt nicht mehr. Das ueber
// den API-Rueckfall zu verschlucken hiesse, den Anwender im
// Unklaren zu lassen, warum keine Updates mehr kommen.
if (response != null && response.StatusCode == HttpStatusCode.Unauthorized)
{
result.Unauthorized = true;
result.Message = ReleaseCredentials.DescribeUnauthorized(credentials);
response.Dispose();
return result;
}
if (response == null || !response.IsSuccessStatusCode)
{
response?.Dispose();
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check"
+ $"?product={Uri.EscapeDataString(projectId)}"
+ $"&version={Uri.EscapeDataString(currentVersion)}"
+ $"&channel={Uri.EscapeDataString(channel)}"
+ $"&platform={Uri.EscapeDataString(rid)}";
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
}
// Auch der API-Zweig kann 401 liefern - etwa wenn die
// Lese-Endpunkte spaeter ebenfalls hinter den Zugangsschutz
// wandern. Zuvor wurde das nur auf dem statischen Weg erkannt
// und hier als gewoehnlicher HTTP-Fehler gemeldet.
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
result.Unauthorized = true;
result.Message = ReleaseCredentials.DescribeUnauthorized(credentials);
return result;
}
if (!response.IsSuccessStatusCode)
{
result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}";
return result;
}
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Handle static latest.json format
if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object)
{
var manifest = JsonSerializer.Deserialize<ReleaseManifest>(json);
if (manifest?.Latest != null)
{
result.FullManifest = manifest;
result.LatestRelease = manifest.Latest;
if (IsVersionNewer(currentVersion, manifest.Latest.Version))
{
result.UpdateAvailable = true;
result.IsCritical = manifest.Latest.IsCritical;
result.Message = $"New release v{manifest.Latest.Version} available.";
}
else
{
result.Message = "Application is up to date.";
}
}
}
// Handle API response format
//
// Die API antwortet in snake_case und reicht die Release-Zeile
// aus updateservice_releases durch. Sie wird deshalb ueber
// ApiReleaseInfo gelesen und erst danach in das Modell
// uebersetzt, das Aufrufer kennen. Zuvor wurde direkt nach
// VersionInfo deserialisiert - dessen camelCase-Namen gehoeren
// aber zur statischen latest.json, sodass ausser "version"
// nichts ankam.
else if (root.TryGetProperty("update_available", out var availProp))
{
bool available = availProp.ValueKind == JsonValueKind.True
|| (availProp.ValueKind == JsonValueKind.Number && availProp.GetInt32() != 0);
result.UpdateAvailable = available;
if (root.TryGetProperty("latest_release", out var relProp)
&& relProp.ValueKind == JsonValueKind.Object)
{
var info = JsonSerializer.Deserialize<ApiReleaseInfo>(relProp.GetRawText());
result.LatestRelease = info?.ToVersionInfo();
}
// is_critical steht auf oberster Ebene der Antwort, nicht im
// Release-Objekt. Vorher wurde es aus dem deserialisierten
// Objekt gelesen und war damit immer false - ein kritisches
// Release wurde ueber diesen Weg nie als kritisch gemeldet.
if (root.TryGetProperty("is_critical", out var critProp))
{
result.IsCritical = ReadFlexibleBool(critProp);
}
else
{
result.IsCritical = result.LatestRelease?.IsCritical ?? false;
}
result.Message = available ? "Update available." : "Application is up to date.";
}
}
catch (Exception ex)
{
result.Error = ex;
result.Message = $"Update check failed: {ex.Message}";
}
return result;
}
/// <summary>
/// Fuehrt eine GET-Anfrage aus und legt bei Bedarf die Zugangsdaten
/// der Release-Ablage an.
/// </summary>
private async Task<HttpResponseMessage> SendWithCredentialsAsync(
string url,
ReleaseCredentials? credentials,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
ReleaseCredentials.Apply(request, credentials);
return await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Liest einen Wahrheitswert, der als Boolean, Zahl oder Zeichenkette
/// ankommen kann. MySQL liefert TINYINT(1), PHP gibt es je nach
/// PDO-Einstellung als 1 oder "1" weiter.
/// </summary>
private static bool ReadFlexibleBool(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.True:
return true;
case JsonValueKind.Number:
return element.TryGetInt64(out long number) && number != 0;
case JsonValueKind.String:
string value = (element.GetString() ?? string.Empty).Trim();
if (bool.TryParse(value, out bool parsed)) return parsed;
return long.TryParse(value, out long numeric) && numeric != 0;
default:
return false;
}
}
/// <summary>
/// Validates local application integrity against manifest.json.
/// </summary>
public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest)
{
var result = new IntegrityCheckResult();
if (manifest == null || manifest.Files == null || manifest.Files.Count == 0)
{
return result;
}
foreach (var entry in manifest.Files)
{
result.TotalCheckedFiles++;
string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
result.IsValid = false;
result.MissingFiles.Add(entry.Path);
continue;
}
if (!string.IsNullOrEmpty(entry.Sha256))
{
string computedHash = ComputeSha256(fullPath);
if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
{
result.IsValid = false;
result.CorruptedFiles.Add(entry.Path);
}
}
}
return result;
}
/// <summary>
/// Dateiname des Agenten auf dieser Plattform.
///
/// Unter Linux und macOS traegt das Binary keine Endung. Ein fest auf
/// "update-agent.exe" verdrahteter Name wird dort nie gefunden, und die
/// Anwendung meldet stumm "kein Agent vorhanden".
/// </summary>
public static string AgentFileName =>
OperatingSystemHelpers.IsWindows() ? "update-agent.exe" : "update-agent";
/// <summary>
/// Sucht den Agenten. Ohne Verzeichnisangabe wird neben der laufenden
/// Anwendung gesucht.
/// </summary>
public static string? ResolveAgentPath(string? directory = null)
{
string dir = string.IsNullOrWhiteSpace(directory)
? AppDomain.CurrentDomain.BaseDirectory
: directory!;
string candidate = Path.Combine(dir, AgentFileName);
if (File.Exists(candidate))
return candidate;
// Ein Paket, das fuer die jeweils andere Plattform gebaut wurde,
// bringt den Agenten unter dem dortigen Namen mit. Lieber finden
// als daran scheitern.
foreach (string alternative in new[] { "update-agent", "update-agent.exe" })
{
string path = Path.Combine(dir, alternative);
if (File.Exists(path))
return path;
}
return null;
}
/// <summary>
/// Startet den UpdateAgent und beendet auf Wunsch die laufende Anwendung.
/// </summary>
/// <param name="restartPath">
/// Anwendung, die der Agent nach dem Update wieder starten soll.
/// Wurde dieser Wert zuvor nie uebergeben - der Agent unterstuetzte
/// <c>--restart</c>, bekam es aber nie zu sehen, sodass die Anwendung
/// nach "Jetzt installieren" schlicht geschlossen blieb.
/// </param>
/// <param name="waitForCurrentProcess">
/// Uebergibt die eigene Prozesskennung, damit der Agent das Ende der
/// Anwendung abwartet, bevor er Dateien ersetzt. Ohne diesen Handschlag
/// kopiert er bei langsamem Herunterfahren ueber gesperrte Dateien.
/// </param>
/// <param name="requireSignature">
/// Reicht <c>--require-signature</c> an den Agenten durch. Ohne diesen
/// Parameter gab es dafuer nur den Kommandozeilenschalter - jede
/// Anwendung, die dem Leitfaden folgt und ueber diese Methode
/// startet, aktualisierte damit ungeprueft, waehrend derselbe Aufruf
/// von Hand auf der Kommandozeile geschuetzt gewesen waere. Den
/// oeffentlichen Schluessel muss dafuer niemand selbst verwalten: der
/// Agent holt und cached ihn ohnehin von <c>/api/updateservice/v1/pubkey</c>.
/// </param>
public static bool LaunchUpdateAgent(
string agentPath,
string projectId,
string channel = "prod",
string action = "update",
string version = "latest",
string? targetDir = null,
bool exitCurrentApp = true,
string? restartPath = null,
string? currentVersion = null,
string? platform = null,
bool waitForCurrentProcess = true,
string? licenseKey = null,
int waitTimeoutSeconds = 60,
bool requireSignature = false)
{
if (!File.Exists(agentPath))
{
return false;
}
targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
var args = new StringBuilder();
AppendArg(args, "--project", projectId);
AppendArg(args, "--channel", channel);
AppendArg(args, "--action", action);
AppendArg(args, "--version", version);
AppendArg(args, "--target-dir", targetDir);
AppendArg(args, "--platform", PlatformId.Normalize(platform ?? PlatformId.Current));
if (!string.IsNullOrWhiteSpace(currentVersion))
{
AppendArg(args, "--current-version", currentVersion!);
}
// Der Lizenzschluessel wird BEWUSST NICHT als Argument uebergeben.
//
// Was in argv steht, zeigt "ps" jedem Benutzer der Maschine. Aus
// genau diesem Grund verwirft UPGRADE.md §5 den Weg, den Shared
// Key im Crontab zu hinterlegen - fuer einen Lizenzschluessel
// gilt dasselbe.
//
// Stattdessen wird die Variable auf dem eigenen Prozess gesetzt;
// der Agent erbt den Umgebungsblock beim Start und liest sie dort.
// Das funktioniert auch mit UseShellExecute=true, wo sich
// ProcessStartInfo.Environment nicht setzen laesst.
bool licenseKeySet = false;
if (!string.IsNullOrWhiteSpace(licenseKey))
{
Environment.SetEnvironmentVariable("DC_LICENSE_KEY", licenseKey);
licenseKeySet = true;
}
if (waitForCurrentProcess)
{
AppendArg(args, "--wait-for-pid",
Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
// Ohne diesen Wert galten fest 60 Sekunden. Eine Anwendung,
// die allein fuer host.StopAsync 30 Sekunden braucht, kommt
// damit gefaehrlich nah an die Grenze - und laeuft sie ab,
// bricht der Agent ab, statt ueber gesperrte Dateien zu
// schreiben. Sicher, aber eben auch kein Update.
AppendArg(args, "--wait-timeout",
Math.Max(5, waitTimeoutSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture));
}
// Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei.
string? restart = restartPath;
if (string.IsNullOrWhiteSpace(restart))
{
restart = GetCurrentExecutablePath();
}
if (!string.IsNullOrWhiteSpace(restart))
{
AppendArg(args, "--restart", restart!);
}
if (requireSignature)
{
AppendFlag(args, "--require-signature");
}
var startInfo = new ProcessStartInfo
{
FileName = agentPath,
Arguments = args.ToString().TrimEnd(),
WorkingDirectory = Path.GetDirectoryName(agentPath) ?? targetDir,
UseShellExecute = true
};
try
{
Process.Start(startInfo);
}
finally
{
// Nach dem Start wieder entfernen: Der Schluessel soll nur so
// lange im eigenen Umgebungsblock stehen, wie er zum Vererben
// gebraucht wird.
if (licenseKeySet)
{
Environment.SetEnvironmentVariable("DC_LICENSE_KEY", null);
}
}
if (exitCurrentApp)
{
// Environment.Exit beendet den Prozess sofort: laufende
// finally-Bloecke anderer Threads, IHostApplicationLifetime
// und Destruktoren kommen nicht mehr zum Zug. Fuer eine
// Anwendung mit offenem Zustand - Positionen, Transaktionen,
// ungeschriebene Puffer - ist das die falsche Wahl. Dort
// gehoert exitCurrentApp:false gesetzt und danach der eigene
// geordnete Weg gegangen; der Agent wartet ohnehin auf das
// Prozessende.
Environment.Exit(0);
}
return true;
}
private static void AppendArg(StringBuilder builder, string name, string value)
{
builder.Append(name);
builder.Append(" \"");
// Ein abschliessender Backslash wuerde sonst das Anfuehrungszeichen
// maskieren und alle folgenden Argumente verschlucken - bei
// Windows-Pfaden wie C:\App\ ein realer Fall.
builder.Append(value.Replace("\"", "\\\"").TrimEnd('\\'));
builder.Append("\" ");
}
private static void AppendFlag(StringBuilder builder, string name)
{
builder.Append(name);
builder.Append(' ');
}
private static string? GetCurrentExecutablePath()
{
try
{
#if NET8_0_OR_GREATER
string? path = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(path))
return path;
#endif
return Process.GetCurrentProcess().MainModule?.FileName;
}
catch
{
return null;
}
}
public static string ComputeSha256(string filePath)
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(filePath);
byte[] hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
/// <summary>
/// Prueft, ob <paramref name="remoteVer"/> neuer ist als <paramref name="currentVer"/>.
///
/// Die vorherige Fassung entfernte zwar die Vorabkennung, nicht aber ein
/// fuehrendes "v". Damit scheiterte Version.TryParse bei Angaben wie
/// "v1.4.2" und es wurde auf einen alphabetischen Vergleich
/// zurueckgefallen - dort gilt "v1.9.0" faelschlich als neuer als
/// "v1.10.0". Das entspricht dem Fehler, der serverseitig in der
/// SQL-Abfrage steckte.
/// </summary>
public static bool IsVersionNewer(string currentVer, string remoteVer)
{
if (string.IsNullOrWhiteSpace(remoteVer)) return false;
if (string.IsNullOrWhiteSpace(currentVer)) return true;
return CompareVersions(remoteVer, currentVer) > 0;
}
/// <summary>
/// Vergleicht zwei Versionsangaben nach semantischer Ordnung.
/// Rueckgabe: negativ wenn a &lt; b, 0 bei Gleichstand, positiv wenn a &gt; b.
/// </summary>
public static int CompareVersions(string a, string b)
{
var (coreA, preA) = ParseVersion(a);
var (coreB, preB) = ParseVersion(b);
int length = Math.Max(coreA.Count, coreB.Count);
for (int i = 0; i < length; i++)
{
int partA = i < coreA.Count ? coreA[i] : 0;
int partB = i < coreB.Count ? coreB[i] : 0;
if (partA != partB)
{
return partA.CompareTo(partB);
}
}
// Eine Version ohne Vorabkennung rangiert ueber derselben mit:
// 1.0.0 ist neuer als 1.0.0-rc.1
bool emptyA = preA.Count == 0;
bool emptyB = preB.Count == 0;
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
int preLength = Math.Max(preA.Count, preB.Count);
for (int i = 0; i < preLength; i++)
{
if (i >= preA.Count) return -1;
if (i >= preB.Count) return 1;
bool numericA = int.TryParse(preA[i], out int numA);
bool numericB = int.TryParse(preB[i], out int numB);
if (numericA && numericB)
{
if (numA != numB) return numA.CompareTo(numB);
continue;
}
// Rein numerische Bestandteile rangieren unter alphanumerischen.
if (numericA != numericB) return numericA ? -1 : 1;
int cmp = string.CompareOrdinal(preA[i], preB[i]);
if (cmp != 0) return cmp > 0 ? 1 : -1;
}
return 0;
}
private static (List<int> Core, List<string> Prerelease) ParseVersion(string version)
{
string value = (version ?? string.Empty).Trim().TrimStart('v', 'V');
// Build-Metadaten sind fuer die Rangfolge ohne Bedeutung.
int plus = value.IndexOf('+');
if (plus >= 0) value = value.Substring(0, plus);
var prerelease = new List<string>();
int dash = value.IndexOf('-');
if (dash >= 0)
{
string preString = value.Substring(dash + 1);
value = value.Substring(0, dash);
if (preString.Length > 0)
{
prerelease.AddRange(preString.Split('.'));
}
}
var core = new List<int>();
foreach (string part in value.Split('.'))
{
string digits = new string(part.Where(char.IsDigit).ToArray());
core.Add(digits.Length > 0 ? int.Parse(digits) : 0);
}
if (core.Count == 0) core.Add(0);
return (core, prerelease);
}
}
}