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:
@@ -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
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<!-- Version der Anwendung. Wird von Deploymentcenter.BuildInfo.targets,
|
||||||
|
dem UpdateService, Fehler-Stream und Heartbeat genutzt. -->
|
||||||
|
<Version>1.0.0</Version>
|
||||||
<!-- Zielframework zentral. Ausnahme: Predictalytics.WinFormsHost setzt
|
<!-- Zielframework zentral. Ausnahme: Predictalytics.WinFormsHost setzt
|
||||||
net10.0-windows, weil WinForms das Windows-Desktop-Pack braucht. -->
|
net10.0-windows, weil WinForms das Windows-Desktop-Pack braucht. -->
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
|||||||
@@ -5,54 +5,128 @@ namespace Predictalytics.Hosting;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the Deployment Center UpdateService for a newer release
|
/// 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
|
/// The check itself only reads metadata. Installing is done by the standalone
|
||||||
/// update-agent, which replaces the running installation and therefore has to be
|
/// update-agent, which replaces the running installation and therefore has to be
|
||||||
/// started explicitly by the user.
|
/// 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>
|
/// </summary>
|
||||||
public static class DcUpdateService
|
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();
|
var client = new UpdateClient();
|
||||||
return await client.CheckForUpdateAsync(
|
return await client.CheckForUpdateAsync(
|
||||||
baseUrl: DcConfig.BaseUrl,
|
baseUrl: DcConfig.BaseUrl,
|
||||||
projectId: DcConfig.ProductSlug,
|
projectId: DcConfig.ProductSlug,
|
||||||
currentVersion: DcConfig.AppVersion,
|
currentVersion: DcConfig.AppVersion,
|
||||||
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
|
channel: NormalizeChannel(channel),
|
||||||
|
platform: Platform,
|
||||||
|
credentials: ResolveCredentials(licenseKey),
|
||||||
cancellationToken: ct).ConfigureAwait(false);
|
cancellationToken: ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
|
/// <summary>Path of the update agent next to the exe, or null if it was not deployed.</summary>
|
||||||
public static string? FindUpdateAgent()
|
public static string? FindUpdateAgent()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(AppContext.BaseDirectory, AgentFileName);
|
return UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
|
||||||
return File.Exists(path) ? path : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hands control to the update agent and ends this process. Returns false if the agent
|
/// 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.
|
/// is not present — then the update has to be installed by hand.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool LaunchAgent(string channel)
|
public static bool LaunchAgent(string channel, string? licenseKey = null)
|
||||||
{
|
{
|
||||||
var agent = FindUpdateAgent();
|
var agent = FindUpdateAgent();
|
||||||
if (agent is null)
|
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;
|
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(
|
return UpdateClient.LaunchUpdateAgent(
|
||||||
agentPath: agent,
|
agentPath: agent,
|
||||||
projectId: DcConfig.ProductSlug,
|
projectId: DcConfig.ProductSlug,
|
||||||
channel: string.IsNullOrWhiteSpace(channel) ? "prod" : channel,
|
channel: NormalizeChannel(channel),
|
||||||
action: "update",
|
action: "update",
|
||||||
version: "latest",
|
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>
|
<PropertyGroup>
|
||||||
<RootNamespace>Predictalytics.Hosting</RootNamespace>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
Link="wwwroot\%(RecursiveDir)%(Filename)%(Extension)"
|
||||||
CopyToOutputDirectory="PreserveNewest"
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
CopyToPublishDirectory="PreserveNewest" />
|
CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<None Include="run.sh" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -191,7 +191,24 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
try
|
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)
|
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 —
|
// The agent replaces the running installation, so announce the shutdown first —
|
||||||
// otherwise the monitor reports a crash a few minutes later.
|
// otherwise the monitor reports a crash a few minutes later.
|
||||||
_heartbeat?.NotifyStopping();
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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" "$@"
|
||||||
Reference in New Issue
Block a user