From 5ccd4f5f4e825c46a990a69f4a165570d4d75623 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 15 Aug 2026 09:43:44 +0200 Subject: [PATCH] UpdateService: Zugangsschutz, Plattform und sauberer Agent-Uebergang Seit Deploymentcenter 2.4 liegt die Release-Ablage hinter HTTP-Basic-Auth mit dem Lizenzschluessel als Zugangsdatum. Ohne ihn antwortet jeder Paketabruf mit 401. Predictalytics hat noch kein Release veroeffentlicht; nach UPGRADE.md 16.1 entsteht der Schutz fuer ein Produkt mit dem ersten Upload, der erste ausgelieferte Build muss die Zugangsdaten also bereits mitbringen. - DcUpdateService reicht licenseKey und platform (PlatformId.Current) an CheckForUpdateAsync und LaunchUpdateAgent weiter. Fehlt der Schluessel am Aufruf, greift LicenseClient.TryGetCachedKey - damit ist der Headless-Pfad mit abgedeckt. - LaunchAgent startet ohne Lizenzschluessel gar nicht erst. Sonst schliesst sich die Anwendung, der Agent laeuft in einen 401, und zurueck bleibt eine geschlossene App ohne Update. - restartPath und waitForCurrentProcess werden gesetzt: der Agent wartet auf das Ende dieses Prozesses, statt auf noch gesperrte Assemblies zu schreiben, und startet die Anwendung danach wieder. Ein null-restartPath ist dafuer nicht brauchbar - das SDK ersetzt ihn durch denselben ProcessPath -, beim Start ueber "dotnet App.dll" wird deshalb der Apphost aufgeloest. - result.Unauthorized wird vor dem "ist aktuell"-Zweig behandelt. Bei 401 setzt das SDK weder Error noch UpdateAvailable; die Pruefung meldete bisher "aktuell", waehrend in Wirklichkeit keine Updates mehr ankamen. - Version zentral in Directory.Build.props, damit Hosting und Shell nicht auseinanderlaufen. - run.sh setzt unter Linux das beim Entpacken verlorene Ausfuehrungsbit. .gitattributes haelt Shell-Skripte auf LF: mit core.autocrlf=true traegt die Arbeitskopie sonst CRLF, und CopyToPublishDirectory nimmt genau die ins Linux-Paket - dort scheitert der Start an "bad interpreter". Co-Authored-By: Claude Opus 5 --- .gitattributes | 6 ++ Directory.Build.props | 3 + src/Predictalytics.Hosting/DcUpdateService.cs | 96 ++++++++++++++++--- .../Predictalytics.Hosting.csproj | 3 - .../Predictalytics.Shell.csproj | 1 + .../ViewModels/MainWindowViewModel.cs | 25 ++++- src/Predictalytics.Shell/run.sh | 4 + 7 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 .gitattributes create mode 100644 src/Predictalytics.Shell/run.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6bac1ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell-Skripte immer mit LF auschecken, auch unter Windows. +# +# run.sh wird per CopyToPublishDirectory in das Linux-Paket uebernommen. Mit +# core.autocrlf=true traegt die Arbeitskopie sonst CRLF, und die Zeile +# "#!/usr/bin/env bash\r" laesst den Start dort an "bad interpreter" scheitern. +*.sh text eol=lf diff --git a/Directory.Build.props b/Directory.Build.props index 0f40bc6..35999d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,8 @@ + + 1.0.0 net10.0 diff --git a/src/Predictalytics.Hosting/DcUpdateService.cs b/src/Predictalytics.Hosting/DcUpdateService.cs index 0a3403a..3fb4f9d 100644 --- a/src/Predictalytics.Hosting/DcUpdateService.cs +++ b/src/Predictalytics.Hosting/DcUpdateService.cs @@ -5,54 +5,128 @@ namespace Predictalytics.Hosting; /// /// Checks the Deployment Center UpdateService for a newer release -/// (GET /api/updateservice/v1/check, no token needed). +/// (GET /api/updateservice/v1/check). /// /// The check itself only reads metadata. Installing is done by the standalone /// update-agent, which replaces the running installation and therefore has to be /// started explicitly by the user. +/// +/// Since Deployment Center 2.4 the release directories sit behind HTTP basic auth: +/// the license key is the credential. Without it every download answers 401, so the +/// key has to travel with both the check and the agent handover. /// public static class DcUpdateService { - private const string AgentFileName = "update-agent.exe"; + /// + /// Runtime identifier the release directories are split by (win-x64, linux-x64, …). + /// Taken from the SDK so app and agent agree on the spelling. + /// + public static string Platform => PlatformId.Current; - public static async Task CheckAsync(string channel, CancellationToken ct = default) + public static async Task CheckAsync( + string channel, string? licenseKey = null, CancellationToken ct = default) { var client = new UpdateClient(); return await client.CheckForUpdateAsync( baseUrl: DcConfig.BaseUrl, projectId: DcConfig.ProductSlug, currentVersion: DcConfig.AppVersion, - channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel, + channel: NormalizeChannel(channel), + platform: Platform, + credentials: ResolveCredentials(licenseKey), cancellationToken: ct).ConfigureAwait(false); } /// Path of the update agent next to the exe, or null if it was not deployed. public static string? FindUpdateAgent() { - var path = Path.Combine(AppContext.BaseDirectory, AgentFileName); - return File.Exists(path) ? path : null; + return UpdateClient.ResolveAgentPath(AppContext.BaseDirectory); } /// /// Hands control to the update agent and ends this process. Returns false if the agent /// is not present — then the update has to be installed by hand. /// - public static bool LaunchAgent(string channel) + public static bool LaunchAgent(string channel, string? licenseKey = null) { var agent = FindUpdateAgent(); if (agent is null) { - Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", AgentFileName); + Log.Warning("Update-Agent ({Agent}) liegt nicht neben der Anwendung — Update bitte manuell einspielen.", + UpdateClient.AgentFileName); return false; } - Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}) und beende die Anwendung...", agent, channel); + var key = ResolveLicenseKey(licenseKey); + if (string.IsNullOrWhiteSpace(key)) + { + // The agent would download, get 401 and abort — with the app already closed. + Log.Warning("Kein Lizenzschlüssel verfügbar — der Update-Agent käme nicht an das Paket. " + + "Update wird nicht gestartet."); + return false; + } + + var restartPath = ResolveRestartPath(); + Log.Information("Starte Update-Agent {Agent} (Kanal {Channel}, Plattform {Platform}) und beende die Anwendung...", + agent, NormalizeChannel(channel), Platform); + return UpdateClient.LaunchUpdateAgent( agentPath: agent, projectId: DcConfig.ProductSlug, - channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel, + channel: NormalizeChannel(channel), action: "update", version: "latest", - exitCurrentApp: true); + exitCurrentApp: true, + restartPath: restartPath, + currentVersion: DcConfig.AppVersion, + platform: Platform, + // The agent waits for this process to go away before it copies over + // the installation — otherwise it writes onto still-locked assemblies. + waitForCurrentProcess: true, + licenseKey: key); } + + /// + /// Basic-auth credentials for the protected release directories. Falls back to the key + /// of the last successful activation, which also covers the headless path. + /// + private static ReleaseCredentials? ResolveCredentials(string? licenseKey) + => ReleaseCredentials.FromLicenseKey(ResolveLicenseKey(licenseKey)); + + private static string? ResolveLicenseKey(string? licenseKey) + => string.IsNullOrWhiteSpace(licenseKey) + ? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug) + : licenseKey; + + /// + /// Executable the agent should start again once it is done. + /// + /// Passing null is not an option: the SDK then falls back to Environment.ProcessPath + /// itself, so the "dotnet" case below cannot be opted out of — it has to be resolved + /// to the apphost here. + /// + private static string? ResolveRestartPath() + { + var path = Environment.ProcessPath; + + // Started as "dotnet Predictalytics.Shell.dll": ProcessPath is the shared runtime + // host, and restarting that without its argument would launch nothing. The apphost + // next to the entry assembly is what the user actually started from. + if (path is null || Path.GetFileNameWithoutExtension(path).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + var entry = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name; + if (!string.IsNullOrWhiteSpace(entry)) + { + var appHost = Path.Combine( + AppContext.BaseDirectory, + OperatingSystem.IsWindows() ? entry + ".exe" : entry!); + if (File.Exists(appHost)) return appHost; + } + } + + return path; + } + + private static string NormalizeChannel(string channel) + => string.IsNullOrWhiteSpace(channel) ? "prod" : channel.Trim(); } diff --git a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj index a7874c6..49b74d1 100644 --- a/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj +++ b/src/Predictalytics.Hosting/Predictalytics.Hosting.csproj @@ -2,9 +2,6 @@ Predictalytics.Hosting - - 1.0.0 diff --git a/src/Predictalytics.Shell/Predictalytics.Shell.csproj b/src/Predictalytics.Shell/Predictalytics.Shell.csproj index 496a8a0..5fc7249 100644 --- a/src/Predictalytics.Shell/Predictalytics.Shell.csproj +++ b/src/Predictalytics.Shell/Predictalytics.Shell.csproj @@ -32,6 +32,7 @@ Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + diff --git a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs index 376ae0f..73918be 100644 --- a/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/Predictalytics.Shell/ViewModels/MainWindowViewModel.cs @@ -191,7 +191,24 @@ public sealed partial class MainWindowViewModel : ObservableObject { try { - var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel); + var result = await DcUpdateService.CheckAsync(Options.DcUpdateChannel, License?.LicenseKey); + + // 401 is a licensing statement, not a network hiccup — and it must be caught + // before the "up to date" branch: the server answers no version at all, so + // silently reporting "aktuell" would hide that updates stopped arriving. + if (result.Unauthorized) + { + Log.Error("⛔ Die Release-Ablage weist die Lizenz zurück — es kommen keine Updates mehr an: {Message}", + result.Message); + BuildVersionText = $"v{DcConfig.AppVersion} — Update-Zugang abgelehnt"; + if (!silent && ShowError != null) + await ShowError("Deployment Center", + "Die Release-Ablage hat den Lizenzschlüssel abgelehnt (401).\n\n" + + $"{result.Message}\n\n" + + "Das ist kein Netzwerkproblem: Die Lizenz ist abgelaufen oder widerrufen. " + + "Solange das so bleibt, erreichen diese Installation keine Updates."); + return; + } if (result.Error is not null) { @@ -237,7 +254,11 @@ public sealed partial class MainWindowViewModel : ObservableObject // The agent replaces the running installation, so announce the shutdown first — // otherwise the monitor reports a crash a few minutes later. _heartbeat?.NotifyStopping(); - DcUpdateService.LaunchAgent(Options.DcUpdateChannel); + if (!DcUpdateService.LaunchAgent(Options.DcUpdateChannel, License?.LicenseKey) && ShowError != null) + { + await ShowError("Deployment Center", + "Der Update-Agent konnte nicht gestartet werden. Details stehen im Log."); + } } catch (Exception ex) { diff --git a/src/Predictalytics.Shell/run.sh b/src/Predictalytics.Shell/run.sh new file mode 100644 index 0000000..05cc574 --- /dev/null +++ b/src/Predictalytics.Shell/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +chmod +x "$SCRIPT_DIR/Predictalytics.Shell" "$SCRIPT_DIR/update-agent" 2>/dev/null +exec "$SCRIPT_DIR/Predictalytics.Shell" "$@"