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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-15 09:43:44 +02:00
co-authored by Claude Opus 5
parent 6e8ccba258
commit 5ccd4f5f4e
7 changed files with 122 additions and 16 deletions
+85 -11
View File
@@ -5,54 +5,128 @@ namespace Predictalytics.Hosting;
/// <summary>
/// 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.
/// </summary>
public static class DcUpdateService
{
private const string AgentFileName = "update-agent.exe";
/// <summary>
/// Runtime identifier the release directories are split by (win-x64, linux-x64, …).
/// Taken from the SDK so app and agent agree on the spelling.
/// </summary>
public static string Platform => PlatformId.Current;
public static async Task<UpdateCheckResult> CheckAsync(string channel, CancellationToken ct = default)
public static async Task<UpdateCheckResult> 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);
}
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
public static string? FindUpdateAgent()
{
var path = Path.Combine(AppContext.BaseDirectory, AgentFileName);
return File.Exists(path) ? path : null;
return UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Basic-auth credentials for the protected release directories. Falls back to the key
/// of the last successful activation, which also covers the headless path.
/// </summary>
private static ReleaseCredentials? ResolveCredentials(string? licenseKey)
=> ReleaseCredentials.FromLicenseKey(ResolveLicenseKey(licenseKey));
private static string? ResolveLicenseKey(string? licenseKey)
=> string.IsNullOrWhiteSpace(licenseKey)
? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug)
: licenseKey;
/// <summary>
/// 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.
/// </summary>
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();
}
@@ -2,9 +2,6 @@
<PropertyGroup>
<RootNamespace>Predictalytics.Hosting</RootNamespace>
<!-- Wird als current_version an den UpdateService und als build an den Fehler-Stream
gemeldet. Beim Release hier hochziehen. -->
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -32,6 +32,7 @@
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -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)
{
+4
View File
@@ -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" "$@"