feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback

Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen
verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im
Fehlerfall halb aktualisierte Installationen hinterliessen.

Server
- Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das
  zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version,
  weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System
  zog sich damit das Windows-Paket.
- Aufloesungsregel: je Version das plattformgenaue Paket, sonst das
  plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich
  'any' - lieber kein Update als das falsche.
- manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer,
  wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war.
- Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt
  /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf
  fremden Systemen und darf den Signierschluessel nicht besitzen.

Packager
- Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der
  bestehenden latest.json fehl, ersetzte ein leeres catch die komplette
  Historie durch einen einzigen Eintrag - ohne jede Meldung.
- Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der
  mitgelieferten Beispielkonfiguration nie zu.
- preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am
  Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten
  ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems.
- Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen.
- Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer
  Endlosschleife: Clients aktualisieren, melden weiter die alte Version,
  halten das Release erneut fuer neu.
- --platform mit Ableitung aus dem Publish-Pfad.

Agent
- Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als
  "Atomic Replace with Backup" kommentiert und war eine Kopierschleife.
- Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der
  Vorversion. Was nicht aus einem Release stammt, bleibt liegen.
- Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben.
- API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb
  die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte.
- Installierte Version aus --current-version oder manifest.json statt des
  Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen
  liess. Reparatur funktioniert damit auch ohne manifest.json.
- Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden.

SDK
- ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes
  "update-agent.exe" wird unter Linux nie gefunden.
- LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die
  Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit
  dem Herunterfahren) und --platform.

Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz-
Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen
sich nicht getrennt stagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-09 19:56:35 +02:00
co-authored by Claude Opus 5
parent 5f9b0c5596
commit 2388b5abe1
37 changed files with 5498 additions and 491 deletions
@@ -1,49 +1,117 @@
<Project>
<!-- MSBuild target to generate BuildInfo.g.cs automatically prior to compilation -->
<Target Name="GenerateDeploymentcenterBuildInfo" BeforeTargets="CoreCompile">
<!--
Erzeugt vor jeder Uebersetzung eine BuildInfo-Klasse mit Version,
UTC-Build-Datum und Git-Commit.
Einbindung in der .csproj des Consumers:
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
Die Klasse entsteht im Namensraum des einbindenden Projekts
($(RootNamespace)), nicht in dem des SDK.
Die vorherige Fassung erzeugte "public static partial class BuildInfo" im
Namensraum Deploymentcenter.Client.Models. Dort liefert das SDK aber bereits
eine gleichnamige, nicht partielle Klasse aus: im Consumer entstand ein
zweiter Typ mit demselben vollen Namen in einer zweiten Assembly (CS0433),
und der generierte statische Konstruktor setzte Eigenschaften, die in seiner
Teilklasse gar nicht deklariert waren (CS0103). Einbinden war damit
unmoeglich - obwohl der UpdateService-Leitfaden genau dazu riet. Der
generierte Code deklariert seine Werte jetzt selbst und braucht keine
Gegenstelle im SDK.
Ueberschreibbare Eigenschaften:
DeploymentcenterBuildInfoNamespace Zielnamensraum (Vorgabe: RootNamespace)
DeploymentcenterBuildInfoClass Klassenname (Vorgabe: BuildInfo)
BuildChannel prod, beta, ... (Vorgabe: prod)
GenerateDeploymentcenterBuildInfo auf false setzen, um abzuschalten
-->
<PropertyGroup>
<GenerateDeploymentcenterBuildInfo Condition="'$(GenerateDeploymentcenterBuildInfo)' == ''">true</GenerateDeploymentcenterBuildInfo>
<DeploymentcenterBuildInfoClass Condition="'$(DeploymentcenterBuildInfoClass)' == ''">BuildInfo</DeploymentcenterBuildInfoClass>
</PropertyGroup>
<Target Name="GenerateDeploymentcenterBuildInfo"
BeforeTargets="CoreCompile"
Condition="'$(GenerateDeploymentcenterBuildInfo)' == 'true'">
<PropertyGroup>
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
<!-- Zielnamensraum: ausdrueckliche Angabe, sonst RootNamespace, sonst Projektname. -->
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(RootNamespace)</DeploymentcenterBuildInfoNamespace>
<DeploymentcenterBuildInfoNamespace Condition="'$(DeploymentcenterBuildInfoNamespace)' == ''">$(MSBuildProjectName)</DeploymentcenterBuildInfoNamespace>
<BuildInfoFile>$(IntermediateOutputPath)DeploymentcenterBuildInfo.g.cs</BuildInfoFile>
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc>
<BuildVersion Condition="'$(Version)' != ''">$(Version)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">1.0.0</BuildVersion>
<!-- Version aus <Version>, ersatzweise <AssemblyVersion>, sonst 0.0.0. -->
<BuildVersion Condition="'$(BuildVersion)' == ''">$(Version)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">$(AssemblyVersion)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">0.0.0</BuildVersion>
<BuildChannel Condition="'$(BuildChannel)' == ''">prod</BuildChannel>
</PropertyGroup>
<Exec Command="git rev-parse HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitLong" />
<!--
ContinueOnError, damit ein Build ohne Git-Arbeitskopie oder ohne
installiertes Git nicht scheitert. IgnoreExitCode allein genuegte nicht:
fehlt die ausfuehrbare Datei, bricht Exec selbst ab.
-->
<Exec Command="git rev-parse HEAD"
ConsoleToMSBuild="true"
IgnoreExitCode="true"
ContinueOnError="true"
StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitLong" />
</Exec>
<Exec Command="git rev-parse --short HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitShort" />
<Exec Command="git rev-parse --short HEAD"
ConsoleToMSBuild="true"
IgnoreExitCode="true"
ContinueOnError="true"
StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitShort" />
</Exec>
<PropertyGroup>
<GitCommitLong Condition="'$(GitCommitLong)' == ''">UNKNOWN_COMMIT</GitCommitLong>
<GitCommitShort Condition="'$(GitCommitShort)' == ''">UNKNOWN</GitCommitShort>
<DcGitCommitLong Condition="'$(DcGitCommitLong)' == ''">UNKNOWN_COMMIT</DcGitCommitLong>
<DcGitCommitShort Condition="'$(DcGitCommitShort)' == ''">UNKNOWN</DcGitCommitShort>
<!-- Zeilenumbrueche aus der Git-Ausgabe entfernen, sie wuerden das Literal sprengen. -->
<DcGitCommitLong>$(DcGitCommitLong.Trim())</DcGitCommitLong>
<DcGitCommitShort>$(DcGitCommitShort.Trim())</DcGitCommitShort>
</PropertyGroup>
<ItemGroup>
<BuildInfoLine Include="// &lt;auto-generated /&gt;" />
<BuildInfoLine Include="using System%3B" />
<BuildInfoLine Include="namespace Deploymentcenter.Client.Models" />
<BuildInfoLine Include="// Erzeugt von Deploymentcenter.BuildInfo.targets - nicht von Hand aendern." />
<BuildInfoLine Include="namespace $(DeploymentcenterBuildInfoNamespace)" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public static partial class BuildInfo" />
<BuildInfoLine Include=" /// &lt;summary&gt;Zur Uebersetzungszeit eingebettete Build-Daten.&lt;/summary&gt;" />
<BuildInfoLine Include=" public static class $(DeploymentcenterBuildInfoClass)" />
<BuildInfoLine Include=" {" />
<BuildInfoLine Include=" static BuildInfo()" />
<BuildInfoLine Include=" public const string Version = &quot;$(BuildVersion)&quot;%3B" />
<BuildInfoLine Include=" public const string GitCommit = &quot;$(DcGitCommitLong)&quot;%3B" />
<BuildInfoLine Include=" public const string GitCommitShort = &quot;$(DcGitCommitShort)&quot;%3B" />
<BuildInfoLine Include=" public const string BuildDateUtc = &quot;$(BuildDateUtc)&quot;%3B" />
<BuildInfoLine Include=" public const string Channel = &quot;$(BuildChannel)&quot;%3B" />
<!-- Include darf nicht leer sein, daher ein Leerzeichen statt einer Leerzeile. -->
<BuildInfoLine Include=" " />
<BuildInfoLine Include=" public static string Summary" />
<BuildInfoLine Include=" {" />
<BuildInfoLine Include=" Version = &quot;$(BuildVersion)&quot;%3B" />
<BuildInfoLine Include=" GitCommit = &quot;$(GitCommitLong)&quot;%3B" />
<BuildInfoLine Include=" GitCommitShort = &quot;$(GitCommitShort)&quot;%3B" />
<BuildInfoLine Include=" BuildDateUtc = &quot;$(BuildDateUtc)&quot;%3B" />
<BuildInfoLine Include=" Channel = &quot;$(BuildChannel)&quot;%3B" />
<BuildInfoLine Include=" get { return &quot;v&quot; + Version + &quot; (&quot; + GitCommitShort + &quot;) built on &quot; + BuildDateUtc + &quot; [&quot; + Channel + &quot;]&quot;%3B }" />
<BuildInfoLine Include=" }" />
<BuildInfoLine Include=" }" />
<BuildInfoLine Include="}" />
</ItemGroup>
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
<WriteLinesToFile File="$(BuildInfoFile)"
Lines="@(BuildInfoLine)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />
<ItemGroup>
<Compile Include="$(BuildInfoFile)" />
<FileWrites Include="$(BuildInfoFile)" />
</ItemGroup>
</Target>
</Project>
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
namespace Deploymentcenter.Client
{
/// <summary>
/// Abgleich von Pfaden gegen Glob-Muster.
///
/// Der Packager verstand zuvor nur "*.endung" und exakte Namen. Muster wie
/// "logs/**" oder "wwwroot/cache/*" trafen deshalb nie zu - sie standen in
/// der mitgelieferten Beispielkonfiguration und weckten den Eindruck, die
/// betreffenden Dateien seien ausgeschlossen. Ein Konfigurationsfile, das
/// so unbemerkt ins Paket rutscht, ueberschreibt beim naechsten Update die
/// Einstellungen jedes Zielsystems.
///
/// Regeln, angelehnt an .gitignore:
/// * trifft beliebig viele Zeichen ausser dem Trenner /
/// ** trifft beliebig viele Zeichen einschliesslich /
/// ? trifft genau ein Zeichen ausser /
/// Muster ohne / werden gegen den Dateinamen geprueft,
/// Muster mit / gegen den vollstaendigen relativen Pfad.
/// Ein Muster, das auf / endet, trifft alles unterhalb dieses Ordners.
///
/// Verglichen wird ohne Beachtung der Gross-/Kleinschreibung: Ziel sind
/// Windows- und Linux-Systeme gleichermassen, und ein Muster, das nur auf
/// einer der beiden Plattformen greift, ist gefaehrlicher als ein Muster,
/// das etwas zu viel trifft.
/// </summary>
public static class GlobMatcher
{
private static readonly Dictionary<string, Regex> Cache = new Dictionary<string, Regex>(StringComparer.Ordinal);
private static readonly object CacheLock = new object();
/// <summary>
/// Prueft einen relativen Pfad gegen eine Liste von Mustern.
/// Leere Listen treffen nie.
/// </summary>
public static bool IsMatch(string relativePath, IEnumerable<string>? patterns)
{
if (patterns == null || string.IsNullOrEmpty(relativePath))
return false;
string normalized = Normalize(relativePath);
foreach (var pattern in patterns)
{
if (IsMatch(normalized, pattern, alreadyNormalized: true))
return true;
}
return false;
}
/// <summary>Prueft einen relativen Pfad gegen ein einzelnes Muster.</summary>
public static bool IsMatch(string relativePath, string? pattern, bool alreadyNormalized = false)
{
if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrEmpty(relativePath))
return false;
string path = alreadyNormalized ? relativePath : Normalize(relativePath);
string trimmed = pattern!.Trim();
if (trimmed.Length == 0)
return false;
// Ein fuehrendes ./ oder / bedeutet "ab Wurzel" und ist fuer den
// Vergleich mit einem ohnehin relativen Pfad ohne Bedeutung.
if (trimmed.StartsWith("./", StringComparison.Ordinal))
trimmed = trimmed.Substring(2);
else if (trimmed.StartsWith("/", StringComparison.Ordinal))
trimmed = trimmed.Substring(1);
// "logs/" meint alles unterhalb von logs.
if (trimmed.EndsWith("/", StringComparison.Ordinal))
trimmed += "**";
var regex = GetRegex(trimmed);
// Muster ohne Trenner gelten fuer den Dateinamen an beliebiger
// Stelle im Baum - "*.pdb" soll auch runtimes/x/y.pdb treffen.
if (trimmed.IndexOf('/') < 0)
{
int slash = path.LastIndexOf('/');
string fileName = slash >= 0 ? path.Substring(slash + 1) : path;
return regex.IsMatch(fileName);
}
return regex.IsMatch(path);
}
/// <summary>Vereinheitlicht Trenner und entfernt ein fuehrendes ./</summary>
public static string Normalize(string path)
{
string value = (path ?? string.Empty).Replace('\\', '/').TrimStart();
if (value.StartsWith("./", StringComparison.Ordinal))
value = value.Substring(2);
return value;
}
private static Regex GetRegex(string pattern)
{
lock (CacheLock)
{
if (Cache.TryGetValue(pattern, out var cached))
return cached;
var regex = new Regex(
"^" + Translate(pattern) + "$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Die Musterlisten stammen aus Konfigurationsdateien und sind
// klein; die Obergrenze verhindert nur unbegrenztes Wachsen,
// falls doch einmal dynamisch erzeugte Muster hereinkommen.
if (Cache.Count < 512)
Cache[pattern] = regex;
return regex;
}
}
/// <summary>Uebersetzt ein Glob-Muster in einen regulaeren Ausdruck.</summary>
private static string Translate(string pattern)
{
var builder = new StringBuilder();
for (int i = 0; i < pattern.Length; i++)
{
char c = pattern[i];
switch (c)
{
case '*':
bool doubleStar = i + 1 < pattern.Length && pattern[i + 1] == '*';
if (doubleStar)
{
i++;
// "a/**/b" muss auch "a/b" treffen, sonst waere ein
// Muster wie "logs/**" auf den Ordner selbst blind.
if (i + 1 < pattern.Length && pattern[i + 1] == '/')
{
i++;
builder.Append("(?:.*/)?");
}
else
{
builder.Append(".*");
}
}
else
{
builder.Append("[^/]*");
}
break;
case '?':
builder.Append("[^/]");
break;
default:
builder.Append(Regex.Escape(c.ToString()));
break;
}
}
return builder.ToString();
}
}
}
@@ -1,7 +1,9 @@
using System;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Deploymentcenter.Client;
@@ -13,25 +15,88 @@ public class LicenseValidationResult
public string Message { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty;
public bool IsCached { get; set; }
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches.</summary>
public long? ExpiresAt { get; set; }
/// <summary>
/// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client
/// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft.
/// </summary>
public long? CacheExpiresAt { get; set; }
/// <summary>
/// Wahr, wenn kein Lizenzurteil vorliegt, sondern nur die Verbindung zum
/// Server gescheitert ist (Netzfehler, HTTP 429/5xx, unlesbare Antwort).
///
/// Ein solcher Zustand darf eine Anwendung nicht beenden. Nur ein vom
/// Server geliefertes Urteil (revoked, expired, not_found,
/// activation_limit, suspended) ist endgueltig.
/// </summary>
public bool IsTransient { get; set; }
}
public class LicenseClient
{
/// <summary>
/// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf
/// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version.
/// </summary>
public static string? DefaultAppVersion { get; set; }
/// <summary>
/// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie
/// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der
/// Server nicht antwortete.
/// </summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
private static readonly Lazy<HttpClient> SharedHttpClient = new Lazy<HttpClient>(
() => new HttpClient { Timeout = DefaultTimeout },
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Standard-Gnadenfrist, wenn der Server keine TTL mitschickt.</summary>
private const int FallbackCacheTtlHours = 168;
private readonly HttpClient _httpClient;
private readonly ILicensePrompt _prompt;
/// <summary>
/// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit
/// <see cref="DefaultTimeout"/> verwendet. Ein uebergebener HttpClient
/// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer.
/// </summary>
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
{
_httpClient = httpClient ?? new HttpClient();
_httpClient = httpClient ?? SharedHttpClient.Value;
_prompt = prompt ?? new ConsoleLicensePrompt();
}
public async Task<LicenseValidationResult> ValidateAsync(string productSlug, string licenseKey, string serverBaseUrl)
public Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
CancellationToken cancellationToken)
=> ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken);
/// <param name="appVersion">
/// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des
/// Deploymentcenters. Ohne Angabe wird <see cref="DefaultAppVersion"/> und
/// danach die Version der Startassembly verwendet.
/// </param>
public async Task<LicenseValidationResult> ValidateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string? failureReason = null;
HttpResponseMessage? response = null;
try
{
var payload = new
@@ -44,7 +109,7 @@ public class LicenseClient
hwid_source = hwInfo.HwidSource,
platform = hwInfo.Platform,
hostname = Environment.MachineName,
app_version = "1.0.0",
app_version = ResolveAppVersion(appVersion),
nonce = Guid.NewGuid().ToString("N")
};
@@ -52,122 +117,318 @@ public class LicenseClient
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
HttpResponseMessage response = await _httpClient.PostAsync(endpoint, content);
if (response.IsSuccessStatusCode)
{
string resBody = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(resBody);
var root = doc.RootElement;
string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
string message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
long? expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number ? eProp.GetInt64() : null;
if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
{
// Save encrypted local cache
var cache = new LocalCacheData
{
SchemaVersion = 2,
ProductSlug = productSlug,
LicenseKey = licenseKey,
HardwareId = hwInfo.HardwareId,
Status = "valid",
IssuedAt = now,
ExpiresAt = expiresAt ?? (now + 7 * 86400),
MaxSeenTime = now,
Checksum = hwInfo.HardwareId
};
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt
};
}
else
{
return new LicenseValidationResult
{
IsValid = false,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false
};
}
}
response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Server request failed -> Fall back to encrypted offline cache
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
if (cache != null && cache.Status == "valid")
failureReason = ex.Message;
}
using (response)
{
if (response != null)
{
// Check time-rollback protection
if (now < cache.MaxSeenTime)
if (response.IsSuccessStatusCode)
{
return new LicenseValidationResult
string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now);
if (verdict != null)
{
IsValid = false,
Status = "clock_rollback",
Message = "System clock rollback detected! Online verification required.",
HardwareId = hwInfo.HardwareId
};
return verdict;
}
// Erfolgreiche Antwort, aber kein lesbares Urteil - etwa die
// Fehlerseite eines Proxys mit Statuscode 200. Das ist keine
// Aussage ueber die Lizenz, also wird sie auch nicht als
// solche behandelt.
failureReason = "Antwort des Servers war nicht auswertbar";
}
// Check cache expiry
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
else
{
return new LicenseValidationResult
{
IsValid = false,
Status = "cache_expired",
Message = "Cached license has expired.",
HardwareId = hwInfo.HardwareId
};
// WICHTIG: Ein HTTP-Fehler ist kein Lizenzurteil.
//
// Frueher lag der Cache-Zweig ausschliesslich im catch.
// Ein 429 (Drosselung) oder 500 warf keine Exception,
// sondern fiel aus dem Erfolgszweig heraus und endete als
// "unknown_error" - ohne den Cache auch nur zu befragen.
// Ein Serverfehler entzog damit die Lizenz, ein gezogenes
// Netzkabel nicht. Jetzt fuehrt jeder Nicht-Erfolg in
// denselben Offline-Zweig.
failureReason = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
}
}
}
// Update max_seen_time
cache.MaxSeenTime = now;
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar");
}
return new LicenseValidationResult
{
IsValid = true,
Status = "valid_offline",
Message = "License validated via secure offline cache",
HardwareId = hwInfo.HardwareId,
IsCached = true,
ExpiresAt = cache.ExpiresAt
};
/// <summary>
/// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares
/// Lizenzurteil enthaelt - dann greift der Offline-Zweig.
/// </summary>
private static LicenseValidationResult? TryReadVerdict(
string resBody,
string productSlug,
string licenseKey,
HardwareIdResult hwInfo,
long now)
{
string status;
string message;
long? expiresAt;
int cacheTtlHours;
try
{
using var doc = JsonDocument.Parse(resBody);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out var sProp))
{
return null;
}
status = sProp.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(status))
{
return null;
}
message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number
? eProp.GetInt64()
: (long?)null;
// Der Server bestimmt die Laenge der Offline-Gnadenfrist je Projekt.
// Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum
// der Lizenz eingetragen - bei einer Lizenz bis 2040 war die Frist
// praktisch unbegrenzt.
cacheTtlHours = root.TryGetProperty("cache_ttl_hours", out var tProp) && tProp.ValueKind == JsonValueKind.Number
? tProp.GetInt32()
: FallbackCacheTtlHours;
}
catch (JsonException)
{
return null;
}
if (cacheTtlHours <= 0)
{
cacheTtlHours = FallbackCacheTtlHours;
}
if (!status.Equals("valid", StringComparison.OrdinalIgnoreCase))
{
return new LicenseValidationResult
{
IsValid = false,
Status = "network_error",
Message = $"Server communication error and no valid cache available: {ex.Message}",
HardwareId = hwInfo.HardwareId
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt,
IsTransient = false
};
}
// Die Gnadenfrist endet mit der TTL - spaetestens aber mit der Lizenz.
long cacheExpiresAt = now + (long)cacheTtlHours * 3600L;
if (expiresAt.HasValue && expiresAt.Value > 0 && expiresAt.Value < cacheExpiresAt)
{
cacheExpiresAt = expiresAt.Value;
}
var cache = new LocalCacheData
{
SchemaVersion = StateStore.CurrentSchemaVersion,
ProductSlug = productSlug,
LicenseKey = licenseKey,
HardwareId = hwInfo.HardwareId,
Status = "valid",
IssuedAt = now,
ExpiresAt = expiresAt ?? 0,
CacheExpiresAt = cacheExpiresAt,
CacheTtlHours = cacheTtlHours,
MaxSeenTime = now,
Checksum = hwInfo.HardwareId
};
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = status,
Message = message,
HardwareId = hwInfo.HardwareId,
IsCached = false,
ExpiresAt = expiresAt,
CacheExpiresAt = cacheExpiresAt,
IsTransient = false
};
}
/// <summary>
/// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil
/// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort.
/// </summary>
private static LicenseValidationResult OfflineFallback(
string productSlug,
HardwareIdResult hwInfo,
long now,
string reason)
{
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
if (cache != null && cache.Status == "valid")
{
// Rueckdrehen der Systemuhr erkennen
if (now < cache.MaxSeenTime)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "clock_rollback",
Message = "System clock rollback detected! Online verification required.",
HardwareId = hwInfo.HardwareId,
IsTransient = false
};
}
long cacheExpiresAt = ResolveCacheExpiry(cache);
if (cacheExpiresAt > 0 && now > cacheExpiresAt)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "cache_expired",
Message = $"Offline-Gnadenfrist abgelaufen, Server nicht erreichbar ({reason}).",
HardwareId = hwInfo.HardwareId,
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
CacheExpiresAt = cacheExpiresAt,
IsTransient = true
};
}
// Auch offline darf eine abgelaufene Lizenz nicht weiterlaufen.
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
{
return new LicenseValidationResult
{
IsValid = false,
Status = "expired",
Message = "Cached license has expired.",
HardwareId = hwInfo.HardwareId,
ExpiresAt = cache.ExpiresAt,
IsTransient = false
};
}
cache.MaxSeenTime = now;
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
return new LicenseValidationResult
{
IsValid = true,
Status = "valid_offline",
Message = $"License validated via secure offline cache ({reason}).",
HardwareId = hwInfo.HardwareId,
IsCached = true,
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
CacheExpiresAt = cacheExpiresAt,
IsTransient = false
};
}
return new LicenseValidationResult
{
IsValid = false,
Status = "unknown_error",
Message = "Validation failed.",
HardwareId = hwInfo.HardwareId
Status = "server_unavailable",
Message = $"Server communication error and no valid cache available: {reason}",
HardwareId = hwInfo.HardwareId,
IsTransient = true
};
}
public async Task<bool> DeactivateAsync(string productSlug, string licenseKey, string serverBaseUrl, string authToken = "")
/// <summary>
/// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist
/// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt
/// unbegrenzt zu gelten.
/// </summary>
private static long ResolveCacheExpiry(LocalCacheData cache)
{
if (cache.CacheExpiresAt > 0)
{
return cache.CacheExpiresAt;
}
int ttlHours = cache.CacheTtlHours > 0 ? cache.CacheTtlHours : FallbackCacheTtlHours;
return cache.IssuedAt > 0 ? cache.IssuedAt + (long)ttlHours * 3600L : 0;
}
/// <summary>
/// Liest den zuletzt erfolgreich geprueften Schluessel aus dem lokalen
/// Cache. Damit laesst sich ein Schalter wie --license-status oder ein
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
/// </summary>
public static string? TryGetCachedKey(string productSlug)
{
var cache = TryGetCachedState(productSlug);
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
}
/// <summary>
/// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist,
/// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser
/// Maschine gehoert.
/// </summary>
public static LocalCacheData? TryGetCachedState(string productSlug)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return StateStore.Load(productSlug, hwInfo.HardwareId);
}
/// <summary>
/// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel.
/// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde.
/// </summary>
public async Task<LicenseValidationResult> RevalidateAsync(
string productSlug,
string serverBaseUrl,
string? appVersion = null,
CancellationToken cancellationToken = default)
{
string? key = TryGetCachedKey(productSlug);
if (key == null)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
return new LicenseValidationResult
{
IsValid = false,
Status = "not_found",
Message = "Kein zwischengespeicherter Lizenzschluessel vorhanden.",
HardwareId = hwInfo.HardwareId,
IsTransient = false
};
}
return await ValidateAsync(productSlug, key, serverBaseUrl, appVersion, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> DeactivateAsync(
string productSlug,
string licenseKey,
string serverBaseUrl,
string authToken = "",
CancellationToken cancellationToken = default)
{
var hwInfo = HardwareId.GetHardwareId(productSlug);
var payload = new
@@ -182,25 +443,79 @@ public class LicenseClient
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content
};
if (!string.IsNullOrWhiteSpace(authToken))
{
request.Headers.Add("X-Watchdog-Key", authToken);
// Nur noch der Standardweg. Der zusaetzliche X-Watchdog-Key war ein
// Ueberbleibsel des alten Servers und irrefuehrend benannt: hier
// gehoert der shared_key hin, kein Watchdog-Token. Erschwerend
// prueft der Server X-Watchdog-Key VOR Authorization - ein dort
// versehentlich eingetragenes Watchdog-Token haette das richtige
// Bearer-Token stillschweigend verdraengt.
request.Headers.Add("Authorization", $"Bearer {authToken}");
}
try
{
HttpResponseMessage response = await _httpClient.SendAsync(request);
using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return false;
}
}
private static string ResolveAppVersion(string? explicitVersion)
{
if (!string.IsNullOrWhiteSpace(explicitVersion))
{
return explicitVersion!.Trim();
}
if (!string.IsNullOrWhiteSpace(DefaultAppVersion))
{
return DefaultAppVersion!.Trim();
}
// Vorher stand hier fest "1.0.0". In der Aktivierungsliste des
// Deploymentcenters trug damit jede Installation dieselbe Version,
// obwohl die Spalte app_version dafuer vorgesehen ist.
try
{
var assembly = Assembly.GetEntryAssembly();
if (assembly != null)
{
string? informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
// Das SDK haengt bei manchen Buildeinstellungen "+<commit>" an.
int plus = informational!.IndexOf('+');
return plus > 0 ? informational.Substring(0, plus) : informational;
}
var version = assembly.GetName().Version;
if (version != null)
{
return version.ToString();
}
}
}
catch
{
// Version zu ermitteln darf eine Lizenzpruefung nie scheitern lassen.
}
return "unknown";
}
}
@@ -3,7 +3,18 @@ using System;
namespace Deploymentcenter.Client.Models
{
/// <summary>
/// Runtime accessibility for build metadata embedded at compile-time.
/// Zur Laufzeit setzbare Build-Daten des SDK selbst.
///
/// NICHT die Klasse, die Deploymentcenter.BuildInfo.targets erzeugt: die
/// entsteht im Namensraum des einbindenden Projekts und traegt dessen
/// Version. Diese hier ist nur ein Ablageort fuer Anwendungen, die ihre
/// Version zur Laufzeit von Hand setzen wollen.
///
/// Der Zielnamensraum des Targets laesst sich ueber
/// DeploymentcenterBuildInfoNamespace umstellen - er darf nur nicht auf
/// diesen hier zeigen: partial verbindet Teilklassen nur innerhalb
/// derselben Assembly, ueber Assemblygrenzen hinweg entstuenden zwei Typen
/// mit demselben vollen Namen (CS0433).
/// </summary>
public static class BuildInfo
{
@@ -0,0 +1,58 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Deploymentcenter.Client.Models
{
/// <summary>
/// Liest Wahrheitswerte, die nicht als JSON-Boolean ankommen.
///
/// Die Deploymentcenter-API reicht Release-Zeilen unveraendert aus MySQL
/// durch. <c>is_critical</c> ist dort TINYINT(1) und erscheint je nach
/// PDO-Einstellung als 1, "1" oder true. Der Standardkonverter von
/// System.Text.Json wirft bei allem ausser true/false - und der Fehler
/// haette den gesamten Release-Datensatz unbrauchbar gemacht.
/// </summary>
public class FlexibleBoolConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
case JsonTokenType.Null:
return false;
case JsonTokenType.Number:
return reader.TryGetInt64(out long number) ? number != 0 : reader.GetDouble() != 0d;
case JsonTokenType.String:
string? value = reader.GetString();
if (string.IsNullOrWhiteSpace(value))
return false;
value = value!.Trim();
if (bool.TryParse(value, out bool parsed))
return parsed;
if (long.TryParse(value, out long numeric))
return numeric != 0;
return value.Equals("yes", StringComparison.OrdinalIgnoreCase)
|| value.Equals("on", StringComparison.OrdinalIgnoreCase);
default:
return false;
}
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}
}
@@ -19,6 +19,14 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("channel")]
public string Channel { get; set; } = "prod";
/// <summary>
/// Laufzeitkennung des Pakets ("win-x64", "linux-x64", ...) oder
/// "any". Fehlt das Feld, stammt das Manifest aus der Zeit vor der
/// Plattform-Dimension und gilt als plattformunabhaengig.
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
[JsonPropertyName("buildDate")]
public string BuildDate { get; set; } = string.Empty;
@@ -31,6 +39,20 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("changelog")]
public string Changelog { get; set; } = string.Empty;
/// <summary>
/// Muster fuer Dateien, die zwar im Paket liegen, eine vorhandene
/// Datei am Ziel aber nicht ersetzen duerfen - typischerweise
/// Konfigurationsvorlagen wie appsettings.json.
///
/// Ausschluss beim Packen und Schutz beim Anwenden sind zwei
/// verschiedene Dinge. Wer eine Konfigurationsvorlage ausliefern will,
/// kann sie nicht einfach vom Paket ausnehmen; sie darf nur beim
/// Update nicht ueber die eingerichtete Fassung des Zielsystems
/// geschrieben werden.
/// </summary>
[JsonPropertyName("preserve")]
public List<string> Preserve { get; set; } = new List<string>();
[JsonPropertyName("files")]
public List<PackageFileEntry> Files { get; set; } = new List<PackageFileEntry>();
}
@@ -15,6 +15,13 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("channel")]
public string Channel { get; set; } = "prod";
/// <summary>
/// Laufzeitkennung des Kanals. Fehlt sie, stammt die Datei aus der
/// Zeit vor der Plattform-Dimension und gilt als "any".
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
[JsonPropertyName("latest")]
public VersionInfo? Latest { get; set; }
@@ -22,6 +29,80 @@ namespace Deploymentcenter.Client.Models
public List<VersionInfo> Versions { get; set; } = new List<VersionInfo>();
}
/// <summary>
/// Release-Daten so, wie sie <c>/api/updateservice/v1/check</c> unter
/// <c>latest_release</c> liefert: die Zeile aus <c>updateservice_releases</c>
/// mit ihren Spaltennamen.
///
/// Bewusst ein eigenes Modell. Zuvor wurde die API-Antwort in
/// <see cref="VersionInfo"/> deserialisiert, das die camelCase-Namen der
/// statischen latest.json traegt. Von beiden Formaten stimmt nur "version"
/// ueberein - ueber die API kamen also weder Download-Adresse noch Pruefsumme,
/// Changelog oder Kritikalitaet an. Der API-Zweig ist genau der Rueckfall,
/// wenn die latest.json fehlt; er degradierte damit still.
/// </summary>
public class ApiReleaseInfo
{
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("channel")]
public string Channel { get; set; } = "prod";
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
[JsonPropertyName("download_url")]
public string DownloadUrl { get; set; } = string.Empty;
/// <summary>
/// RSA-SHA256 ueber die kanonische Release-Zeile, base64. Der Server
/// bildet sie beim Veroeffentlichen; leer, wenn dort kein
/// Signierschluessel hinterlegt ist.
/// </summary>
[JsonPropertyName("manifest_signature")]
public string ManifestSignature { get; set; } = string.Empty;
[JsonPropertyName("sha256_hash")]
public string Sha256Hash { get; set; } = string.Empty;
[JsonPropertyName("release_notes")]
public string ReleaseNotes { get; set; } = string.Empty;
[JsonPropertyName("git_commit")]
public string GitCommit { get; set; } = string.Empty;
[JsonPropertyName("size_bytes")]
public long SizeBytes { get; set; }
[JsonPropertyName("created_at")]
public string CreatedAt { get; set; } = string.Empty;
/// <summary>
/// MySQL liefert TINYINT(1) als 0/1, PHP je nach Treiber auch als
/// Zeichenkette. <see cref="FlexibleBoolConverter"/> nimmt beides.
/// </summary>
[JsonPropertyName("is_critical")]
[JsonConverter(typeof(FlexibleBoolConverter))]
public bool IsCritical { get; set; }
/// <summary>Uebersetzt in das Modell, das Aufrufer bereits kennen.</summary>
public VersionInfo ToVersionInfo() => new VersionInfo
{
Version = Version,
BuildDate = CreatedAt,
GitCommit = GitCommit,
GitCommitShort = GitCommit.Length >= 7 ? GitCommit.Substring(0, 7) : GitCommit,
PackageUrl = DownloadUrl,
Sha256 = Sha256Hash,
SizeBytes = SizeBytes,
Changelog = ReleaseNotes,
IsCritical = IsCritical,
Platform = Platform,
Signature = ManifestSignature
};
}
/// <summary>
/// Individual release version details.
/// </summary>
@@ -53,5 +134,21 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("isCritical")]
public bool IsCritical { get; set; }
/// <summary>
/// Laufzeitkennung des Pakets. Aeltere latest.json-Dateien kennen das
/// Feld nicht; sie gelten dann als plattformunabhaengig.
/// </summary>
[JsonPropertyName("platform")]
public string Platform { get; set; } = PlatformId.Any;
/// <summary>
/// Signatur des Servers, sofern der Agent das Release ueber die API
/// bezogen hat. Die statische latest.json fuehrt sie nicht: sie wird
/// vom Packager geschrieben, und der besitzt den Signierschluessel
/// bewusst nicht.
/// </summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = string.Empty;
}
}
@@ -0,0 +1,203 @@
using System;
using System.Runtime.InteropServices;
namespace Deploymentcenter.Client
{
/// <summary>
/// Laufzeitkennung (.NET-RID) eines Releases.
///
/// Der UpdateService kannte lange nur Projekt, Kanal und Version. Sobald
/// fuer mehrere Plattformen gebaut wurde, landeten alle Pakete unter
/// derselben Version im selben Kanal und ueberschrieben sich - ein
/// Linux-System zog sich das Windows-Paket. Diese Klasse liefert die
/// gemeinsame Sprache dafuer: Packager, Agent und Server benutzen
/// dieselbe Schreibweise und dieselben Ablagepfade.
/// </summary>
public static class PlatformId
{
/// <summary>Kennung fuer plattformunabhaengige Releases.</summary>
public const string Any = "any";
/// <summary>
/// Kennung des laufenden Systems, z. B. "win-x64" oder "linux-arm64".
/// </summary>
public static string Current
{
get
{
#if NET8_0_OR_GREATER
string rid = RuntimeInformation.RuntimeIdentifier;
if (!string.IsNullOrWhiteSpace(rid))
{
return Normalize(rid);
}
#endif
return Normalize(ComposeFallback());
}
}
/// <summary>
/// Bringt eine Kennung auf die Form, die auch der Server verwendet.
/// Unbrauchbare Angaben werden zu "any" - ein verunglueckter Parameter
/// soll nicht dazu fuehren, dass gar kein Release mehr gefunden wird.
/// </summary>
public static string Normalize(string? platform)
{
string value = (platform ?? string.Empty).Trim().ToLowerInvariant();
if (value.Length == 0)
return Any;
// Portable RIDs tragen manchmal eine Betriebssystemversion
// ("win10-x64", "ubuntu.22.04-x64"). Fuer die Auswahl eines Pakets
// ist nur die Familie samt Architektur interessant.
value = CollapseVersioned(value);
foreach (char c in value)
{
bool allowed = (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '-' || c == '.' || c == '_';
if (!allowed)
return Any;
}
return value.Length > 32 ? Any : value;
}
/// <summary>Ist das die plattformunabhaengige Kennung?</summary>
public static bool IsAny(string? platform)
{
return Normalize(platform) == Any;
}
/// <summary>
/// Pfadsegment fuer die Release-Ablage.
///
/// Plattformunabhaengige Releases behalten bewusst den alten Pfad
/// ohne Zwischenebene. Andernfalls waeren alle bereits ausgelieferten
/// Anwendungen von einem Tag auf den anderen von ihren Updates
/// abgeschnitten.
/// </summary>
public static string PathSegment(string? platform)
{
string normalized = Normalize(platform);
return normalized == Any ? string.Empty : "/" + normalized;
}
/// <summary>
/// Versucht, die Kennung aus einem Publish-Pfad zu lesen, etwa
/// "bin/Release/net8.0/linux-x64/publish". Liefert null, wenn der Pfad
/// keine erkennbare Kennung enthaelt.
/// </summary>
public static string? InferFromPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
string[] segments = path!.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
// Von hinten, weil die Kennung in der Konvention unmittelbar vor
// "publish" steht und weiter vorne zufaellig gleichnamige Ordner
// liegen koennen.
for (int i = segments.Length - 1; i >= 0; i--)
{
string candidate = segments[i].ToLowerInvariant();
if (LooksLikeRid(candidate))
{
return Normalize(candidate);
}
}
return null;
}
private static bool LooksLikeRid(string value)
{
int dash = value.IndexOf('-');
if (dash <= 0 || dash == value.Length - 1)
return false;
string os = value.Substring(0, dash);
string rest = value.Substring(dash + 1);
bool knownOs = os == "win" || os == "linux" || os == "osx"
|| os.StartsWith("win", StringComparison.Ordinal)
|| os.StartsWith("linux", StringComparison.Ordinal)
|| os.StartsWith("osx", StringComparison.Ordinal);
if (!knownOs)
return false;
return rest == "x64" || rest == "x86" || rest == "arm64" || rest == "arm"
|| rest.EndsWith("-x64", StringComparison.Ordinal)
|| rest.EndsWith("-arm64", StringComparison.Ordinal);
}
/// <summary>
/// "win10-x64" und "ubuntu.22.04-x64" auf "win-x64" bzw. "linux-x64"
/// zurueckfuehren. Ohne das entstuenden fuer dasselbe Paket mehrere
/// Kennungen, je nachdem, auf welchem System gebaut wurde.
/// </summary>
private static string CollapseVersioned(string value)
{
int dash = value.LastIndexOf('-');
if (dash <= 0)
return value;
string os = value.Substring(0, dash);
string arch = value.Substring(dash + 1);
if (os.StartsWith("win", StringComparison.Ordinal))
return "win-" + arch;
if (os.StartsWith("osx", StringComparison.Ordinal) || os.StartsWith("macos", StringComparison.Ordinal))
return "osx-" + arch;
// musl ist eine eigene Zielplattform - ein glibc-Paket laeuft dort
// nicht, die Unterscheidung muss also erhalten bleiben.
if (os.Contains("musl"))
return "linux-musl-" + arch;
if (os.StartsWith("linux", StringComparison.Ordinal)
|| os.StartsWith("ubuntu", StringComparison.Ordinal)
|| os.StartsWith("debian", StringComparison.Ordinal)
|| os.StartsWith("alpine", StringComparison.Ordinal)
|| os.StartsWith("rhel", StringComparison.Ordinal)
|| os.StartsWith("centos", StringComparison.Ordinal)
|| os.StartsWith("fedora", StringComparison.Ordinal))
{
return os.StartsWith("alpine", StringComparison.Ordinal)
? "linux-musl-" + arch
: "linux-" + arch;
}
return value;
}
private static string ComposeFallback()
{
string os;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
os = "win";
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
os = "osx";
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
os = "linux";
else
return Any;
string arch;
switch (RuntimeInformation.ProcessArchitecture)
{
case Architecture.X64: arch = "x64"; break;
case Architecture.X86: arch = "x86"; break;
case Architecture.Arm64: arch = "arm64"; break;
case Architecture.Arm: arch = "arm"; break;
default: return Any;
}
return os + "-" + arch;
}
}
}
@@ -0,0 +1,134 @@
using System;
using System.Security.Cryptography;
using System.Text;
#if NETSTANDARD2_0
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
#endif
namespace Deploymentcenter.Client
{
/// <summary>
/// Prueft die Signatur eines Releases.
///
/// Der SHA256 eines Pakets stammt aus derselben Quelle wie das Paket
/// selbst. Wer den Webroot oder die FTP-Zugangsdaten kontrolliert, tauscht
/// beide gemeinsam aus - der Hash schuetzt dann gegen Uebertragungsfehler,
/// nicht gegen Manipulation. Ausgerechnet auf dem Pfad, der fremden Code
/// ausfuehrt.
///
/// Bewusst asymmetrisch: bei einem HMAC braeuchte der pruefende Agent
/// denselben geheimen Schluessel wie der signierende Server. Der Agent
/// laeuft auf Kundensystemen; ein dort ausgelesener Schluessel liesse sich
/// zum Signieren beliebiger Pakete verwenden. Der Server signiert daher
/// mit einem privaten RSA-Schluessel, der Agent prueft mit dem
/// oeffentlichen aus /api/updateservice/v1/pubkey.
/// </summary>
public static class ReleaseVerifier
{
/// <summary>
/// Kanonische Darstellung eines Releases - muss zeichengenau der
/// serverseitigen Fassung in ReleaseSigner::canonical() entsprechen.
/// Signiert wird bewusst diese Zeile und nicht das Manifest-JSON:
/// JSON-Ausgabe ist nicht bytestabil (Schluesselreihenfolge, Escaping,
/// Zahlenformat), eine Signatur darueber waere unzuverlaessig pruefbar.
/// </summary>
public static string BuildCanonical(
string productSlug,
string version,
string channel,
string platform,
string? sha256Hash,
string downloadUrl,
long sizeBytes)
{
return string.Join("\n", new[]
{
"dc-release-v1",
productSlug ?? string.Empty,
version ?? string.Empty,
channel ?? string.Empty,
platform ?? string.Empty,
(sha256Hash ?? string.Empty).ToLowerInvariant(),
downloadUrl ?? string.Empty,
sizeBytes.ToString(System.Globalization.CultureInfo.InvariantCulture)
});
}
/// <summary>
/// Prueft eine base64-kodierte RSA-SHA256-Signatur gegen einen
/// oeffentlichen Schluessel im PEM-Format.
/// </summary>
public static bool Verify(string canonical, string? signatureBase64, string? publicKeyPem)
{
if (string.IsNullOrWhiteSpace(signatureBase64) || string.IsNullOrWhiteSpace(publicKeyPem))
return false;
byte[] signature;
try
{
signature = Convert.FromBase64String(signatureBase64!.Trim());
}
catch (FormatException)
{
return false;
}
byte[] data = Encoding.UTF8.GetBytes(canonical);
try
{
#if NET8_0_OR_GREATER
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem!.ToCharArray());
return rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
#else
AsymmetricKeyParameter key = PublicKeyFactory.CreateKey(DecodePem(publicKeyPem!));
ISigner signer = SignerUtilities.GetSigner("SHA256withRSA");
signer.Init(false, key);
signer.BlockUpdate(data, 0, data.Length);
return signer.VerifySignature(signature);
#endif
}
catch
{
// Ein nicht lesbarer Schluessel oder eine unpassende Signatur
// sind kein Sonderfall, sondern schlicht "nicht geprueft".
return false;
}
}
/// <summary>
/// Fingerabdruck eines PEM-Schluessels. Damit erkennt der Agent den
/// einmal geholten Schluessel wieder, statt ihm bei jedem Aufruf neu
/// zu vertrauen - ein spaeter ausgetauschter Schluessel faellt so auf.
/// </summary>
public static string Fingerprint(string publicKeyPem)
{
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(publicKeyPem ?? string.Empty));
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
#if NETSTANDARD2_0
/// <summary>Entfernt Kopf- und Fusszeile eines PEM und dekodiert base64.</summary>
private static byte[] DecodePem(string pem)
{
var builder = new StringBuilder();
foreach (string line in pem.Split('\n'))
{
string trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith("-----", StringComparison.Ordinal))
continue;
builder.Append(trimmed);
}
return Convert.FromBase64String(builder.ToString());
}
#endif
}
}
@@ -15,13 +15,28 @@ namespace Deploymentcenter.Client;
public class LocalCacheData
{
public int SchemaVersion { get; set; } = 2;
public int SchemaVersion { get; set; } = StateStore.CurrentSchemaVersion;
public string ProductSlug { get; set; } = string.Empty;
public string LicenseKey { get; set; } = string.Empty;
public string HardwareId { get; set; } = string.Empty;
public string Status { get; set; } = "invalid";
public long IssuedAt { get; set; }
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), 0 wenn unbefristet.</summary>
public long ExpiresAt { get; set; }
/// <summary>
/// Ende der Offline-Gnadenfrist (Unix-Zeit). Getrennt von
/// <see cref="ExpiresAt"/>, weil eine Lizenz bis 2040 laufen kann, die
/// Frist ohne Serverkontakt aber nur ueber die vom Server gemeldeten
/// cache_ttl_hours. Schema 2 kannte das Feld nicht; dort wird die Frist
/// aus IssuedAt abgeleitet.
/// </summary>
public long CacheExpiresAt { get; set; }
/// <summary>Vom Server gemeldete Gnadenfrist in Stunden.</summary>
public int CacheTtlHours { get; set; }
public long MaxSeenTime { get; set; }
public string Checksum { get; set; } = string.Empty;
}
@@ -30,6 +45,16 @@ public static class StateStore
{
private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32
/// <summary>Schema, das dieser Client schreibt.</summary>
public const int CurrentSchemaVersion = 3;
/// <summary>
/// Schemata, die noch gelesen werden. Schema 2 hat keine getrennte
/// Cache-Frist; ein Aufsteigen darf keinen Zwang zur Online-Pruefung
/// ausloesen, nur weil das SDK aktualisiert wurde.
/// </summary>
private static readonly int[] SupportedSchemaVersions = { 2, 3 };
public static LocalCacheData? Load(string productSlug, string hardwareId)
{
try
@@ -70,25 +95,43 @@ public static class StateStore
string jsonStr = Encoding.UTF8.GetString(jsonBytes);
var data = JsonSerializer.Deserialize<LocalCacheData>(jsonStr);
if (data == null || data.SchemaVersion != 2)
if (data == null || Array.IndexOf(SupportedSchemaVersions, data.SchemaVersion) < 0)
return null; // Incompatible schema -> Treat as Cache Miss
if (!BelongsHere(data, productSlug, hardwareId))
return null;
return data;
}
// Legacy Migration Check (non-LLS2 file)
//
// Vorher wurde hier beliebiges JSON nach LocalCacheData
// deserialisiert und sofort im LLS2-Format zurueckgeschrieben.
// Passte kein einziges Feld, entstand ein Standardobjekt, das die
// urspruengliche Datei ueberschrieb. Da LicenseLabrador denselben
// Pfad und Dateinamen verwendet - GetStorageDirectory beruecksichtigt
// dafuer eigens LICENSELABRADOR_STORAGE_DIR - zerstoerte das den
// fremden Cache still. Uebernommen wird jetzt nur, was sich als
// Cache genau dieses Produkts auf genau dieser Maschine ausweist.
try
{
string legacyJson = Encoding.UTF8.GetString(payloadBytes);
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
if (legacyData != null)
// Ein Ueberbleibsel im Binaerformat ist kein JSON-Objekt.
if (legacyJson.TrimStart().StartsWith("{", StringComparison.Ordinal))
{
legacyData.SchemaVersion = 2;
Save(productSlug, hardwareId, legacyData);
return legacyData;
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
if (legacyData != null && BelongsHere(legacyData, productSlug, hardwareId))
{
legacyData.SchemaVersion = CurrentSchemaVersion;
Save(productSlug, hardwareId, legacyData);
return legacyData;
}
}
}
catch { }
catch (JsonException) { }
return null;
}
@@ -98,11 +141,35 @@ public static class StateStore
}
}
/// <summary>
/// Prueft, ob ein gelesener Cache tatsaechlich zu diesem Produkt gehoert.
///
/// Fuer LLS2-Dateien ist die Hardware-Bindung bereits durch die
/// Schluesselableitung gegeben - dort faellt die Entschluesselung sonst aus.
/// Entscheidend ist der Produktbezug: ohne ihn wuerde eine fremde
/// state.dat im selben Verzeichnis uebernommen und ueberschrieben.
/// </summary>
private static bool BelongsHere(LocalCacheData data, string productSlug, string hardwareId)
{
if (string.IsNullOrWhiteSpace(data.ProductSlug))
return false;
if (!string.Equals(data.ProductSlug, productSlug, StringComparison.OrdinalIgnoreCase))
return false;
// Ein Cache ohne Schluessel taugt zu nichts und ist meist ein
// Standardobjekt aus einer Datei, die gar keine unsrige war.
if (string.IsNullOrWhiteSpace(data.LicenseKey))
return false;
return true;
}
public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData)
{
try
{
cacheData.SchemaVersion = 2;
cacheData.SchemaVersion = CurrentSchemaVersion;
string dir = LicenseConfig.GetStorageDirectory(productSlug);
Directory.CreateDirectory(dir);
string statePath = Path.Combine(dir, "state.dat");
@@ -43,21 +43,31 @@ namespace Deploymentcenter.Client
/// <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,
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
// Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{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.
//
@@ -84,7 +94,8 @@ namespace Deploymentcenter.Client
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check"
+ $"?product={Uri.EscapeDataString(projectId)}"
+ $"&version={Uri.EscapeDataString(currentVersion)}"
+ $"&channel={Uri.EscapeDataString(channel)}";
+ $"&channel={Uri.EscapeDataString(channel)}"
+ $"&platform={Uri.EscapeDataString(rid)}";
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
}
@@ -121,16 +132,41 @@ namespace Deploymentcenter.Client
}
}
// 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.GetBoolean();
bool available = availProp.ValueKind == JsonValueKind.True
|| (availProp.ValueKind == JsonValueKind.Number && availProp.GetInt32() != 0);
result.UpdateAvailable = available;
if (root.TryGetProperty("latest_release", out var relProp))
if (root.TryGetProperty("latest_release", out var relProp)
&& relProp.ValueKind == JsonValueKind.Object)
{
var info = JsonSerializer.Deserialize<VersionInfo>(relProp.GetRawText());
result.LatestRelease = info;
result.IsCritical = info?.IsCritical ?? false;
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.";
}
}
@@ -143,6 +179,28 @@ namespace Deploymentcenter.Client
return result;
}
/// <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>
@@ -181,8 +239,56 @@ namespace Deploymentcenter.Client
}
/// <summary>
/// Launches UpdateAgent process with appropriate parameters and optionally exits current application.
/// 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>
public static bool LaunchUpdateAgent(
string agentPath,
string projectId,
@@ -190,7 +296,11 @@ namespace Deploymentcenter.Client
string action = "update",
string version = "latest",
string? targetDir = null,
bool exitCurrentApp = true)
bool exitCurrentApp = true,
string? restartPath = null,
string? currentVersion = null,
string? platform = null,
bool waitForCurrentProcess = true)
{
if (!File.Exists(agentPath))
{
@@ -200,16 +310,41 @@ namespace Deploymentcenter.Client
targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
var args = new StringBuilder();
args.Append($"--project \"{projectId}\" ");
args.Append($"--channel \"{channel}\" ");
args.Append($"--action \"{action}\" ");
args.Append($"--version \"{version}\" ");
args.Append($"--target-dir \"{targetDir}\"");
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!);
}
if (waitForCurrentProcess)
{
AppendArg(args, "--wait-for-pid",
Process.GetCurrentProcess().Id.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!);
}
var startInfo = new ProcessStartInfo
{
FileName = agentPath,
Arguments = args.ToString(),
Arguments = args.ToString().TrimEnd(),
WorkingDirectory = Path.GetDirectoryName(agentPath) ?? targetDir,
UseShellExecute = true
};
@@ -223,6 +358,34 @@ namespace Deploymentcenter.Client
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 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();