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();
@@ -10,11 +10,21 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
using FluentFTP;
namespace Deploymentcenter.Packager
{
/// <summary>
/// Wird geworfen, wenn die vorhandene Versionshistorie nicht sicher
/// gelesen werden konnte. Dann darf latest.json nicht geschrieben werden.
/// </summary>
internal sealed class ReleaseHistoryException : Exception
{
public ReleaseHistoryException(string message) : base(message) { }
}
/// <summary>
/// Konfiguration des Packagers.
///
@@ -39,9 +49,34 @@ namespace Deploymentcenter.Packager
/// </summary>
public string ApiToken { get; set; } = "";
/// <summary>
/// Dateien, die gar nicht erst ins Paket kommen.
///
/// Die Muster werden seit dieser Fassung als echte Globs ausgewertet.
/// Zuvor verstand der Abgleich nur "*.endung" und exakte Namen, sodass
/// Eintraege wie "logs/**" nie zutrafen - sie standen in der
/// Beispielkonfiguration und erweckten den Eindruck, es sei etwas
/// ausgeschlossen.
/// </summary>
public List<string> ExcludePatterns { get; set; } = new List<string>
{
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*"
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json",
"*.log", "logs/**", "*.tmp"
};
/// <summary>
/// Dateien, die ins Paket gehoeren, am Ziel aber eine vorhandene
/// Fassung nicht ersetzen duerfen.
///
/// Ausschluss und Schutz sind zwei verschiedene Dinge: eine
/// Konfigurationsvorlage soll ausgeliefert werden, damit eine
/// Erstinstallation vollstaendig ist - beim Update darf sie die
/// eingerichteten Werte des Zielsystems aber nicht ueberschreiben.
/// </summary>
public List<string> PreservePatterns { get; set; } = new List<string>
{
"appsettings.json", "appsettings.Production.json", "settings.json",
"config.json", ".env"
};
/// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary>
@@ -72,9 +107,15 @@ namespace Deploymentcenter.Packager
static async Task<int> Main(string[] args)
{
Console.WriteLine("=================================================");
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 ");
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v2.0 ");
Console.WriteLine("=================================================");
if (HasFlag(args, "--help") || HasFlag(args, "-h"))
{
ShowHelp();
return 0;
}
string project = GetArg(args, "--project", "-p") ?? "myapp";
string version = GetArg(args, "--version", "-v") ?? "1.0.0";
string channel = GetArg(args, "--channel", "-c") ?? "prod";
@@ -119,6 +160,7 @@ namespace Deploymentcenter.Packager
Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter");
Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0");
Console.WriteLine(" ein Token mit dem Recht updateservice:publish.");
Console.WriteLine(" Ohne Registrierung entsteht ausserdem keine Signatur.");
Console.ResetColor();
}
@@ -126,34 +168,109 @@ namespace Deploymentcenter.Packager
if (!Directory.Exists(publishDir))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}");
Console.WriteLine($"[FEHLER] Publish-Verzeichnis existiert nicht: {publishDir}");
Console.ResetColor();
return 1;
}
Console.WriteLine($"[INFO] Packaging Project : {project}");
Console.WriteLine($"[INFO] Version : {version}");
Console.WriteLine($"[INFO] Channel : {channel}");
Console.WriteLine($"[INFO] Publish Directory : {publishDir}");
// ---------------------------------------------------------------
// Plattform bestimmen
// ---------------------------------------------------------------
// Ohne Plattform landeten Pakete verschiedener Laufzeitkennungen
// unter derselben Version im selben Kanal und ueberschrieben sich.
string platform;
string platformSource;
// 1. Gather files and filter exclusions
string? explicitPlatform = GetArg(args, "--platform") ?? GetArg(args, "--rid");
if (!string.IsNullOrWhiteSpace(explicitPlatform))
{
platform = PlatformId.Normalize(explicitPlatform);
platformSource = "Argument";
}
else
{
string? inferred = PlatformId.InferFromPath(publishDir);
if (inferred != null)
{
platform = inferred;
platformSource = "aus dem Publish-Pfad abgeleitet";
}
else
{
platform = PlatformId.Any;
platformSource = "Standard";
}
}
Console.WriteLine($"[INFO] Projekt : {project}");
Console.WriteLine($"[INFO] Version : {version}");
Console.WriteLine($"[INFO] Kanal : {channel}");
Console.WriteLine($"[INFO] Plattform : {platform} ({platformSource})");
Console.WriteLine($"[INFO] Publish-Verzeichnis: {publishDir}");
if (platform == PlatformId.Any && !HasFlag(args, "--allow-any-platform"))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[WARNUNG] Keine Plattform angegeben - das Release gilt als plattformunabhaengig.");
Console.WriteLine(" Wird fuer mehrere Laufzeitkennungen gebaut, ueberschreiben sich die");
Console.WriteLine(" Pakete gegenseitig. Mit --platform win-x64 (o. ae.) trennen.");
Console.ResetColor();
}
// ---------------------------------------------------------------
// Version gegen die Hauptassembly pruefen
// ---------------------------------------------------------------
// Weicht die veroeffentlichte Version von der einkompilierten ab,
// meldet die Anwendung nach dem Update weiterhin die alte Version,
// haelt das Release fuer neu und aktualisiert bei jedem Start
// erneut - eine Endlosschleife ueber die gesamte Installationsbasis.
if (!VerifyVersionAgainstAssembly(publishDir, project, version, GetArg(args, "--main-assembly"),
HasFlag(args, "--ignore-version-mismatch")))
{
return 1;
}
// ---------------------------------------------------------------
// Dateien einsammeln
// ---------------------------------------------------------------
var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories);
var filteredFiles = new List<string>();
var preservedRelPaths = new List<string>();
foreach (var file in allFiles)
{
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
if (IsExcluded(relPath, config.ExcludePatterns))
if (GlobMatcher.IsMatch(relPath, config.ExcludePatterns))
{
Console.WriteLine($" [EXCLUDED] {relPath}");
Console.WriteLine($" [AUSGESCHLOSSEN] {relPath}");
continue;
}
filteredFiles.Add(file);
if (GlobMatcher.IsMatch(relPath, config.PreservePatterns))
{
preservedRelPaths.Add(relPath);
}
}
Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}");
Console.WriteLine($"[INFO] Dateien im Paket : {filteredFiles.Count}");
// 2. Prepare staging directory
if (preservedRelPaths.Count > 0)
{
Console.WriteLine($"[INFO] Davon geschuetzt : {preservedRelPaths.Count} (ersetzen am Ziel keine vorhandene Datei)");
foreach (var p in preservedRelPaths)
{
Console.WriteLine($" [GESCHUETZT] {p}");
}
}
WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config);
// ---------------------------------------------------------------
// Paket bauen
// ---------------------------------------------------------------
string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outputTempDir);
@@ -165,16 +282,17 @@ namespace Deploymentcenter.Packager
string gitCommitShort = GetGitCommitShort();
string buildDateUtc = DateTime.UtcNow.ToString("o");
// Build Manifest
var packageManifest = new PackageManifest
{
ProjectId = project,
Version = version,
Channel = channel,
Platform = platform,
BuildDate = buildDateUtc,
GitCommit = gitCommit,
GitCommitShort = gitCommitShort,
Changelog = changelog,
Preserve = new List<string>(config.PreservePatterns),
Files = new List<PackageFileEntry>()
};
@@ -191,12 +309,11 @@ namespace Deploymentcenter.Packager
});
}
// Write manifest.json
string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true });
var manifestOptions = new JsonSerializerOptions { WriteIndented = true };
string manifestJson = JsonSerializer.Serialize(packageManifest, manifestOptions);
await File.WriteAllTextAsync(manifestJsonPath, manifestJson);
// 3. Create package.tar.gz
Console.WriteLine("[INFO] Creating package.tar.gz archive...");
Console.WriteLine("[INFO] Erzeuge package.tar.gz ...");
string archiveStaging = Path.Combine(outputTempDir, "archive_root");
Directory.CreateDirectory(archiveStaging);
@@ -208,10 +325,8 @@ namespace Deploymentcenter.Packager
File.Copy(file, targetFile, true);
}
// Also place manifest.json inside archive root
File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true);
// Compress to tar.gz using System.Formats.Tar + GZipStream
using (var fs = File.Create(packageTarGzPath))
using (var gz = new GZipStream(fs, CompressionLevel.Optimal))
{
@@ -222,16 +337,23 @@ namespace Deploymentcenter.Packager
string packageSha256 = ComputeSha256(packageTarGzPath);
await File.WriteAllTextAsync(sha256FilePath, packageSha256);
Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)");
Console.WriteLine($"[INFO] Package SHA256: {packageSha256}");
Console.WriteLine($"[OK] Paket erstellt ({packageSizeBytes} Bytes)");
Console.WriteLine($"[INFO] SHA256: {packageSha256}");
// 4. FTP Upload to LEMP Release Server
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}";
// ---------------------------------------------------------------
// Hochladen
// ---------------------------------------------------------------
// Plattformunabhaengige Releases behalten den alten Pfad ohne
// Zwischenebene, damit bereits ausgelieferte Anwendungen ihre
// Updates weiterhin finden.
string platformSegment = PlatformId.PathSegment(platform);
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}{platformSegment}";
string remoteVersionPath = $"{remoteChannelPath}/{version}";
Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})...");
Console.WriteLine($"[INFO] Upload nach {ftpHost}:{config.FtpPort} ({remoteVersionPath}) ...");
bool ftpSucceeded = false;
bool historyPreserved = true;
try
{
@@ -240,44 +362,76 @@ namespace Deploymentcenter.Packager
await ftp.CreateDirectory(remoteVersionPath);
// Upload package.tar.gz, package.tar.gz.sha256, and manifest.json
await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite);
await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite);
await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite);
Console.WriteLine("[SUCCESS] Files uploaded to version directory!");
Console.WriteLine("[OK] Paketdateien hochgeladen.");
// 5. Update remote channel latest.json
// -----------------------------------------------------------
// latest.json fortschreiben
// -----------------------------------------------------------
string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json";
ReleaseManifest channelManifest = new ReleaseManifest
var channelManifest = new ReleaseManifest
{
ProjectId = project,
Channel = channel,
Platform = platform,
Versions = new List<VersionInfo>()
};
// Read existing latest.json if present on FTP
// Die vorherige Fassung startete mit leerer Versionsliste und
// verschluckte jeden Fehler beim Lesen der bestehenden Datei
// in einem leeren catch. Schlug Download oder Parsen fehl,
// wurde die gesamte Historie durch einen einzigen Eintrag
// ersetzt - ohne jede Meldung. Jetzt bricht der Vorgang ab,
// bevor latest.json geschrieben wird.
if (await ftp.FileExists(remoteLatestJsonPath))
{
string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json");
var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
if (status == FtpStatus.Success && File.Exists(tempLatestLocal))
FtpStatus status;
try
{
try
{
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
var existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
if (existingManifest != null && existingManifest.Versions != null)
{
channelManifest.Versions = existingManifest.Versions;
}
}
catch { }
status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
}
catch (Exception ex)
{
throw new ReleaseHistoryException(
$"Die vorhandene latest.json konnte nicht geladen werden: {ex.Message}");
}
if (status != FtpStatus.Success || !File.Exists(tempLatestLocal))
{
throw new ReleaseHistoryException(
"Die vorhandene latest.json konnte nicht geladen werden (Download nicht erfolgreich).");
}
ReleaseManifest? existingManifest;
try
{
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
}
catch (Exception ex)
{
throw new ReleaseHistoryException(
$"Die vorhandene latest.json ist nicht lesbar: {ex.Message}");
}
if (existingManifest?.Versions == null)
{
throw new ReleaseHistoryException(
"Die vorhandene latest.json enthaelt keine auswertbare Versionsliste.");
}
channelManifest.Versions = existingManifest.Versions;
Console.WriteLine($"[INFO] Bestehende Historie gelesen: {channelManifest.Versions.Count} Eintraege.");
}
// Construct new version info
string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz";
string packagePublicUrl =
$"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz";
var newVersionInfo = new VersionInfo
{
@@ -289,65 +443,99 @@ namespace Deploymentcenter.Packager
Sha256 = packageSha256,
SizeBytes = packageSizeBytes,
Changelog = changelog,
IsCritical = isCritical
IsCritical = isCritical,
Platform = platform
};
// Remove duplicate version entry if re-publishing same version
channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase));
channelManifest.Versions.Insert(0, newVersionInfo);
// Keep last 15 releases
if (channelManifest.Versions.Count > 15)
// Nach Versionsordnung sortieren, damit "latest" auch dann
// stimmt, wenn nachtraeglich eine aeltere Version gebaut wird.
channelManifest.Versions.Sort((a, b) => UpdateClient.CompareVersions(b.Version, a.Version));
// Aeltere Eintraege werden nur aus der Liste genommen, die
// Dateien bleiben auf dem Server liegen. Ein Rollback auf eine
// herausgefallene Version ist ueber die Liste nicht mehr
// erreichbar - deshalb der Hinweis statt stiller Kuerzung.
const int keep = 15;
if (channelManifest.Versions.Count > keep)
{
channelManifest.Versions = channelManifest.Versions.Take(15).ToList();
var dropped = channelManifest.Versions.Skip(keep).Select(v => v.Version).ToList();
channelManifest.Versions = channelManifest.Versions.Take(keep).ToList();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[HINWEIS] latest.json fuehrt {keep} Versionen. Nicht mehr gelistet: "
+ string.Join(", ", dropped));
Console.WriteLine(" Die Dateien liegen weiterhin auf dem Server, sind ueber den Agenten");
Console.WriteLine(" aber nicht mehr auswaehlbar.");
Console.ResetColor();
}
channelManifest.Latest = channelManifest.Versions.FirstOrDefault();
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true });
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, manifestOptions);
string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json");
await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson);
await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite);
Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!");
Console.WriteLine("[OK] latest.json fortgeschrieben.");
await ftp.Disconnect();
ftpSucceeded = true;
}
catch (ReleaseHistoryException ex)
{
historyPreserved = false;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[FEHLER] {ex.Message}");
Console.WriteLine(" latest.json wurde NICHT geschrieben - die bestehende Historie ist");
Console.WriteLine(" unveraendert. Die Paketdateien dieser Version liegen bereits auf dem");
Console.WriteLine(" Server; nach Behebung der Ursache genuegt ein erneuter Aufruf.");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}");
Console.WriteLine(" Das Paket wurde NICHT ausgeliefert.");
Console.WriteLine(" Das Paket wurde NICHT vollstaendig ausgeliefert.");
Console.ResetColor();
}
// 6. Deploymentcenter benachrichtigen
//
// Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar
// nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit
// unsichtbar, waehrend das Programm am Ende Erfolg meldete.
// ---------------------------------------------------------------
// Deploymentcenter benachrichtigen
// ---------------------------------------------------------------
bool apiNotified = false;
bool signed = false;
string apiMessage = "uebersprungen (kein Token gesetzt)";
if (!string.IsNullOrWhiteSpace(apiToken))
{
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish";
var payload = new
// Das Dateimanifest wandert mit. Damit kann die API als
// vollwertiger Rueckfall dienen, wenn die statische
// latest.json fehlt oder der FTP-Upload scheiterte.
using var manifestDoc = JsonDocument.Parse(manifestJson);
var payload = new Dictionary<string, object?>
{
product_slug = project,
version = version,
channel = channel,
release_notes = changelog,
download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz",
sha256_hash = packageSha256,
git_commit = gitCommitShort,
size_bytes = packageSizeBytes,
is_critical = isCritical
["product_slug"] = project,
["version"] = version,
["channel"] = channel,
["platform"] = platform,
["release_notes"] = changelog,
["download_url"] =
$"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz",
["sha256_hash"] = packageSha256,
["git_commit"] = gitCommitShort,
["size_bytes"] = packageSizeBytes,
["is_critical"] = isCritical,
["manifest_json"] = manifestDoc.RootElement.Clone()
};
var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl)
@@ -363,6 +551,8 @@ namespace Deploymentcenter.Packager
{
apiNotified = true;
apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen.";
signed = string.Equals(ExtractJsonString(body, "signed"), "True", StringComparison.OrdinalIgnoreCase)
|| ExtractJsonString(body, "signed") == "true";
string? autoResolved = ExtractJsonString(body, "auto_resolved");
if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0")
@@ -385,35 +575,267 @@ namespace Deploymentcenter.Packager
if (apiNotified)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"[SUCCESS] {apiMessage}");
Console.WriteLine($"[OK] {apiMessage}");
Console.ResetColor();
if (!signed)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[HINWEIS] Das Release ist unsigniert - auf dem Server ist kein");
Console.WriteLine(" Signierschluessel hinterlegt (security.release_private_key).");
Console.WriteLine(" Der Agent kann die Herkunft des Pakets dann nicht pruefen.");
Console.ResetColor();
}
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}");
Console.ResetColor();
}
Console.ResetColor();
// Cleanup temp
try { Directory.Delete(outputTempDir, true); } catch { }
// Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist.
// Zuvor wurde immer 0 und "successfully published" gemeldet, selbst
// wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
bool fullySucceeded = ftpSucceeded && apiNotified;
Console.WriteLine();
Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow;
Console.WriteLine(fullySucceeded
? $"[FERTIG] Release {version} fuer {project} ({channel}) vollstaendig veroeffentlicht."
: $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}): "
? $"[FERTIG] Release {version} fuer {project} ({channel}, {platform}) vollstaendig veroeffentlicht."
: $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}, {platform}): "
+ $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, "
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}.");
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}"
+ (historyPreserved ? "." : ", Historie unveraendert."));
Console.ResetColor();
return fullySucceeded ? 0 : 2;
}
/// <summary>
/// Vergleicht die angegebene Version mit der, die tatsaechlich in der
/// Hauptassembly steht.
///
/// Rueckgabe false bedeutet: abbrechen. Laesst sich die Assembly nicht
/// bestimmen, wird nur gewarnt - ein nicht pruefbarer Fall ist kein
/// Fehler, ein nachgewiesener Widerspruch schon.
/// </summary>
static bool VerifyVersionAgainstAssembly(
string publishDir,
string project,
string declaredVersion,
string? mainAssemblyOverride,
bool ignoreMismatch)
{
string? assemblyPath = ResolveMainAssembly(publishDir, project, mainAssemblyOverride);
if (assemblyPath == null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[WARNUNG] Hauptassembly nicht gefunden - die Version konnte nicht gegengeprueft");
Console.WriteLine(" werden. Mit --main-assembly <datei> gezielt angeben.");
Console.ResetColor();
return true;
}
string? actual = ReadAssemblyVersion(assemblyPath);
if (actual == null)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] Aus {Path.GetFileName(assemblyPath)} liess sich keine Version lesen.");
Console.ResetColor();
return true;
}
if (VersionCoresMatch(actual, declaredVersion))
{
Console.WriteLine($"[OK] Version stimmt mit {Path.GetFileName(assemblyPath)} ueberein ({actual}).");
return true;
}
Console.ForegroundColor = ignoreMismatch ? ConsoleColor.Yellow : ConsoleColor.Red;
Console.WriteLine($"[{(ignoreMismatch ? "WARNUNG" : "FEHLER")}] Versionskonflikt:");
Console.WriteLine($" --version sagt : {declaredVersion}");
Console.WriteLine($" {Path.GetFileName(assemblyPath)} sagt : {actual}");
Console.WriteLine();
Console.WriteLine(" Wird so veroeffentlicht, meldet die Anwendung nach dem Update weiterhin");
Console.WriteLine(" ihre einkompilierte Version, haelt das Release fuer neu und aktualisiert");
Console.WriteLine(" bei jedem Start erneut - auf allen Installationen.");
Console.WriteLine();
Console.WriteLine(" Ueblicher Grund: <Version> steht nur in einem der beteiligten Projekte.");
Console.WriteLine(" Gehoert in die Directory.Build.props, damit alle denselben Wert tragen.");
if (!ignoreMismatch)
{
Console.WriteLine(" Bewusst gewollt? --ignore-version-mismatch");
}
Console.ResetColor();
return ignoreMismatch;
}
/// <summary>
/// Sucht die Assembly, deren Version fuer das Release massgeblich ist.
/// </summary>
static string? ResolveMainAssembly(string publishDir, string project, string? overrideName)
{
if (!string.IsNullOrWhiteSpace(overrideName))
{
string candidate = Path.IsPathRooted(overrideName!)
? overrideName!
: Path.Combine(publishDir, overrideName!);
return File.Exists(candidate) ? candidate : null;
}
// 1. Gleichnamig zum Projekt-Slug.
foreach (string extension in new[] { ".dll", ".exe" })
{
string candidate = Path.Combine(publishDir, project + extension);
if (File.Exists(candidate))
return candidate;
}
// 2. Ueber die runtimeconfig.json: sie traegt den Namen der
// Startassembly und existiert genau einmal je Anwendung.
var runtimeConfigs = Directory.GetFiles(publishDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
if (runtimeConfigs.Length == 1)
{
string baseName = Path.GetFileName(runtimeConfigs[0]);
baseName = baseName.Substring(0, baseName.Length - ".runtimeconfig.json".Length);
foreach (string extension in new[] { ".dll", ".exe" })
{
string candidate = Path.Combine(publishDir, baseName + extension);
if (File.Exists(candidate))
return candidate;
}
}
return null;
}
/// <summary>
/// Liest die Version einer Assembly, ohne sie zu laden.
/// ProductVersion entspricht InformationalVersion und damit dem, was
/// in der csproj unter &lt;Version&gt; steht.
/// </summary>
static string? ReadAssemblyVersion(string path)
{
try
{
var info = FileVersionInfo.GetVersionInfo(path);
if (!string.IsNullOrWhiteSpace(info.ProductVersion))
return info.ProductVersion!.Trim();
if (!string.IsNullOrWhiteSpace(info.FileVersion))
return info.FileVersion!.Trim();
}
catch { }
try
{
var name = System.Reflection.AssemblyName.GetAssemblyName(path);
return name.Version?.ToString();
}
catch { }
return null;
}
/// <summary>
/// Vergleicht nur den numerischen Kern. "1.4.3" und "1.4.3.0" sind
/// dieselbe Version; "1.4.3+abc123" ebenso - Build-Metadaten und
/// Vorabkennungen sind fuer diese Pruefung ohne Bedeutung.
/// </summary>
static bool VersionCoresMatch(string a, string b)
{
var coreA = VersionCore(a);
var coreB = VersionCore(b);
int length = Math.Max(coreA.Count, coreB.Count);
for (int i = 0; i < length; i++)
{
int partA = i < coreA.Count ? coreA[i] : 0;
int partB = i < coreB.Count ? coreB[i] : 0;
if (partA != partB)
return false;
}
return true;
}
static List<int> VersionCore(string version)
{
string value = (version ?? string.Empty).Trim().TrimStart('v', 'V');
int cut = value.IndexOfAny(new[] { '-', '+', ' ' });
if (cut >= 0)
value = value.Substring(0, cut);
var core = new List<int>();
foreach (string part in value.Split('.'))
{
string digits = new string(part.Where(char.IsDigit).ToArray());
core.Add(digits.Length > 0 ? int.Parse(digits) : 0);
}
if (core.Count == 0)
core.Add(0);
return core;
}
/// <summary>
/// Warnt vor Dateien, die nach Zugangsdaten aussehen und weder
/// ausgeschlossen noch geschuetzt sind.
///
/// Eine settings.json mit Datenbankpasswort und DC-Token, die im
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
/// beim Update die Konfiguration jedes Zielsystems.
/// </summary>
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
{
string[] suspicious =
{
"appsettings*.json", "settings.json", "*.config.json", ".env*",
"secrets.json", "connectionstrings.json", "*.pfx", "*.key", "*.pem"
};
var hits = new List<string>();
foreach (var file in files)
{
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
if (GlobMatcher.IsMatch(relPath, config.PreservePatterns))
continue;
if (GlobMatcher.IsMatch(relPath, suspicious))
hits.Add(relPath);
}
if (hits.Count == 0)
return;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine();
Console.WriteLine("[WARNUNG] Diese Dateien sehen nach Konfiguration oder Zugangsdaten aus, stehen");
Console.WriteLine(" aber weder unter excludePatterns noch unter preservePatterns:");
foreach (var hit in hits)
{
Console.WriteLine($" - {hit}");
}
Console.WriteLine();
Console.WriteLine(" Sie werden mit ausgeliefert UND ueberschreiben beim Update die Fassung");
Console.WriteLine(" auf dem Zielsystem. Entweder in excludePatterns (gar nicht ausliefern)");
Console.WriteLine(" oder in preservePatterns (ausliefern, aber nie ersetzen) aufnehmen.");
Console.ResetColor();
Console.WriteLine();
}
/// <summary>
/// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein
/// vollstaendiges Modell dafuer zu benoetigen.
@@ -468,32 +890,24 @@ namespace Deploymentcenter.Packager
try
{
string json = File.ReadAllText(path);
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json);
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (cfg != null) return cfg;
}
catch { }
catch (Exception ex)
{
// Eine unlesbare Konfiguration still zu ignorieren hiesse,
// mit leeren Zugangsdaten weiterzumachen und den Nutzer
// ueber die Ursache im Unklaren zu lassen.
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] {Path.GetFileName(path)} ist nicht lesbar: {ex.Message}");
Console.WriteLine(" Es gelten Umgebungsvariablen und CLI-Argumente.");
Console.ResetColor();
}
}
return new PackagerConfig();
}
static bool IsExcluded(string relPath, List<string> patterns)
{
string fileName = Path.GetFileName(relPath);
foreach (var pattern in patterns)
{
if (pattern.StartsWith("*."))
{
string ext = pattern.Substring(1);
if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
}
else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
static string ComputeSha256(string file)
{
using var sha256 = SHA256.Create();
@@ -520,6 +934,30 @@ namespace Deploymentcenter.Packager
return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
}
static void ShowHelp()
{
Console.WriteLine();
Console.WriteLine("Aufruf: pack-and-deploy [Optionen]");
Console.WriteLine();
Console.WriteLine(" --project, -p <slug> Projekt-Slug im Deploymentcenter");
Console.WriteLine(" --version, -v <version> Zu veroeffentlichende Version");
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev (Vorgabe: prod)");
Console.WriteLine(" --platform <rid> win-x64, linux-x64, ... (Vorgabe: aus dem");
Console.WriteLine(" Publish-Pfad abgeleitet, sonst 'any')");
Console.WriteLine(" --publish-dir, -d <pfad> Ausgabe von dotnet publish");
Console.WriteLine(" --changelog <text> Aenderungshinweise");
Console.WriteLine(" --critical Als kritisches Update kennzeichnen");
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
Console.WriteLine(" --token <token> Token mit updateservice:publish");
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");
Console.WriteLine();
Console.WriteLine("Rueckgabewerte: 0 vollstaendig, 1 Konfigurationsfehler, 2 teilweise fehlgeschlagen.");
Console.WriteLine();
}
static string GetGitCommitLong()
{
try
@@ -11,6 +11,7 @@
"_apiToken_comment": "Token mit dem Recht updateservice:publish. Im WebUI unter Token-Verwaltung erzeugen.",
"apiToken": "",
"_excludePatterns_comment": "Kommt gar nicht erst ins Paket. Echte Glob-Muster: * trifft innerhalb eines Ordners, ** ueber Ordnergrenzen, ? genau ein Zeichen. Muster ohne / gelten fuer den Dateinamen an beliebiger Stelle.",
"excludePatterns": [
"*.pdb",
"*.xml",
@@ -20,5 +21,14 @@
"logs/**",
"scratch/**",
"*.tmp"
],
"_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber niemals eine vorhandene Datei. Fuer Konfigurationsvorlagen: die Erstinstallation bekommt sie, ein Update laesst die eingerichteten Werte in Ruhe. Ausschluss und Schutz sind zwei verschiedene Dinge - was hier fehlt und Zugangsdaten enthaelt, ueberschreibt beim Update die Konfiguration jedes Zielsystems.",
"preservePatterns": [
"appsettings.json",
"appsettings.Production.json",
"settings.json",
"config.json",
".env"
]
}
@@ -6,10 +6,20 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AppName>Deploymentcenter Test Suite</AppName>
<RootNamespace>Deploymentcenter.TestClient</RootNamespace>
<Version>1.4.3</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
<!--
Bindet die BuildInfo-Erzeugung ein. Steht hier bewusst: der Pfad war
frueher in keinem Projekt eingebunden und wurde deshalb nie uebersetzt,
obwohl der UpdateService-Leitfaden ihn empfiehlt. So faellt ein Fehler
darin beim naechsten Build der Solution auf.
-->
<Import Project="..\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
</Project>
@@ -282,7 +282,9 @@ class Program
private static async Task TestUpdateServiceModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]...");
// Die vom Target erzeugte Klasse im eigenen Namensraum, nicht die des
// SDK: sie traegt die Version dieses Projekts (<Version> in der csproj).
Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {BuildInfo.Summary}]...");
Console.ResetColor();
try
@@ -0,0 +1,426 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Deploymentcenter.Client;
using Deploymentcenter.Client.Models;
namespace Deploymentcenter.UpdateAgent
{
/// <summary>Was ein Anwenden tun wuerde beziehungsweise getan hat.</summary>
internal sealed class ApplyPlan
{
/// <summary>Relative Pfade, die geschrieben werden.</summary>
public List<string> Write { get; } = new List<string>();
/// <summary>Geschuetzte Dateien, die am Ziel bereits vorhanden sind.</summary>
public List<string> Preserved { get; } = new List<string>();
/// <summary>Dateien der Vorversion, die es im neuen Release nicht mehr gibt.</summary>
public List<string> Orphans { get; } = new List<string>();
/// <summary>Relativer Pfad des laufenden Agenten, falls er im Paket liegt.</summary>
public string? SelfPath { get; set; }
/// <summary>Konnten verwaiste Dateien ueberhaupt bestimmt werden?</summary>
public bool OrphanDetectionPossible { get; set; }
}
/// <summary>
/// Schreibt ein entpacktes Release ins Zielverzeichnis.
///
/// Die Vorgaengerfassung war als "Atomic Replace with Backup" kommentiert,
/// tatsaechlich aber eine Kopierschleife: kein Backup, kein Rollback, kein
/// Aufraeumen. Brach sie in der Mitte ab - gesperrte Datei, volle Platte -
/// blieb eine halb aktualisierte Installation zurueck, aus der kein Weg
/// zurueckfuehrte. Dateien, die es im neuen Release nicht mehr gab, blieben
/// ausserdem fuer immer liegen; bei .NET ein realer Weg in kaputte
/// Assembly-Aufloesung.
/// </summary>
internal static class Installer
{
private const string BackupDirectoryName = ".dc-update-backup";
/// <summary>
/// Endung, unter der eine laufende ausfuehrbare Datei zur Seite gelegt
/// wird. Unter Windows laesst sich eine laufende Datei umbenennen, aber
/// nicht ueberschreiben oder loeschen - genau darauf baut der
/// Selbstaustausch auf.
/// </summary>
private const string ReplacedSuffix = ".dc-old";
/// <summary>
/// Bestimmt, was zu tun ist, ohne etwas zu veraendern.
/// </summary>
public static ApplyPlan BuildPlan(
string stagingDir,
string targetDir,
PackageManifest newManifest,
PackageManifest? installedManifest,
string? runningAgentPath)
{
var plan = new ApplyPlan();
var preservePatterns = newManifest.Preserve ?? new List<string>();
var staged = Directory
.GetFiles(stagingDir, "*", SearchOption.AllDirectories)
.Select(f => GlobMatcher.Normalize(Path.GetRelativePath(stagingDir, f)))
.ToList();
var stagedSet = new HashSet<string>(staged, StringComparer.OrdinalIgnoreCase);
foreach (string rel in staged)
{
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
// Geschuetzte Dateien werden nur bei der Erstinstallation
// geschrieben. Ein Update darf die eingerichtete Konfiguration
// des Zielsystems nicht ersetzen.
if (File.Exists(targetPath) && GlobMatcher.IsMatch(rel, preservePatterns))
{
plan.Preserved.Add(rel);
continue;
}
if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath))
{
plan.SelfPath = rel;
}
plan.Write.Add(rel);
}
// Verwaiste Dateien lassen sich nur bestimmen, wenn bekannt ist,
// was die Vorversion mitgebracht hat. Ohne dieses Wissen wird
// nichts geloescht - alles andere hiesse, fremde Dateien im
// Zielverzeichnis zu entfernen.
if (installedManifest?.Files != null && installedManifest.Files.Count > 0)
{
plan.OrphanDetectionPossible = true;
foreach (var entry in installedManifest.Files)
{
string rel = GlobMatcher.Normalize(entry.Path);
if (rel.Length == 0 || stagedSet.Contains(rel))
continue;
if (GlobMatcher.IsMatch(rel, preservePatterns))
continue;
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(targetPath))
continue;
if (runningAgentPath != null && IsSameFile(targetPath, runningAgentPath))
continue;
plan.Orphans.Add(rel);
}
}
return plan;
}
/// <summary>
/// Fuehrt den Plan aus. Bei einem Fehler wird der Ausgangszustand
/// wiederhergestellt und die Ausnahme weitergereicht.
/// </summary>
public static void Apply(
string stagingDir,
string targetDir,
ApplyPlan plan,
Action<string>? log = null)
{
Directory.CreateDirectory(targetDir);
string backupDir = Path.Combine(targetDir, BackupDirectoryName);
// Ein Rest aus einem frueheren Abbruch wuerde die Wiederherstellung
// mit fremden Daten fuettern.
SafeDeleteDirectory(backupDir);
Directory.CreateDirectory(backupDir);
// Merkt sich je Datei, was zurueckzunehmen waere.
var backedUp = new List<string>();
var created = new List<string>();
string? renamedSelf = null;
try
{
foreach (string rel in plan.Write)
{
string sourcePath = Path.Combine(stagingDir, rel.Replace('/', Path.DirectorySeparatorChar));
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
bool isSelf = plan.SelfPath != null
&& string.Equals(rel, plan.SelfPath, StringComparison.OrdinalIgnoreCase);
if (File.Exists(targetPath))
{
if (isSelf)
{
// Die laufende Datei kann nicht ueberschrieben,
// wohl aber umbenannt werden. Ohne diesen Schritt
// bricht das Update unter Windows mitten im
// Kopieren mit einer Zugriffsverletzung ab - und
// hinterliess bisher eine halbe Installation.
renamedSelf = targetPath + ReplacedSuffix;
SafeDelete(renamedSelf);
File.Move(targetPath, renamedSelf);
log?.Invoke($"Laufendes Agent-Binary zur Seite gelegt: {rel}");
}
else
{
MoveToBackup(targetPath, targetDir, backupDir, rel);
backedUp.Add(rel);
}
}
else
{
created.Add(rel);
}
File.Copy(sourcePath, targetPath, overwrite: true);
CopyExecutableBit(sourcePath, targetPath, rel);
}
foreach (string rel in plan.Orphans)
{
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(targetPath))
continue;
MoveToBackup(targetPath, targetDir, backupDir, rel);
backedUp.Add(rel);
log?.Invoke($"Entfernt (nicht mehr Teil des Releases): {rel}");
}
}
catch
{
Rollback(targetDir, backupDir, backedUp, created, plan.SelfPath, renamedSelf, log);
throw;
}
// Erst jetzt ist der alte Stand entbehrlich.
SafeDeleteDirectory(backupDir);
// Die zur Seite gelegte eigene Datei laesst sich waehrend des
// Laufens nicht loeschen; das erledigt der naechste Start.
if (renamedSelf != null)
{
log?.Invoke("Der Agent hat sich selbst erneuert. Die alte Fassung wird beim naechsten Start entfernt.");
}
RemoveEmptyDirectories(targetDir);
}
/// <summary>
/// Raeumt Reste eines Selbstaustauschs weg. Wird beim Start aufgerufen,
/// weil die Datei zu diesem Zeitpunkt nicht mehr in Benutzung ist.
/// </summary>
public static void CleanupPreviousSelfUpdate(string directory)
{
try
{
if (!Directory.Exists(directory))
return;
foreach (string leftover in Directory.GetFiles(directory, "*" + ReplacedSuffix, SearchOption.TopDirectoryOnly))
{
SafeDelete(leftover);
}
}
catch
{
// Ein liegengebliebener Rest ist unschoen, aber kein Grund,
// den Start zu verweigern.
}
}
private static void Rollback(
string targetDir,
string backupDir,
List<string> backedUp,
List<string> created,
string? selfRel,
string? renamedSelf,
Action<string>? log)
{
log?.Invoke("Update abgebrochen - stelle den vorherigen Stand wieder her ...");
// Neu angelegte Dateien wieder entfernen.
foreach (string rel in created)
{
try
{
SafeDelete(Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar)));
}
catch { }
}
// Gesicherte Dateien zuruecklegen.
foreach (string rel in backedUp)
{
try
{
string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar));
string targetPath = Path.Combine(targetDir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(backupPath))
continue;
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
SafeDelete(targetPath);
File.Move(backupPath, targetPath);
}
catch (Exception ex)
{
log?.Invoke($"Wiederherstellung von {rel} fehlgeschlagen: {ex.Message}");
}
}
// Den eigenen Namen zuruecknehmen, falls schon umbenannt wurde.
if (renamedSelf != null && selfRel != null)
{
try
{
string targetPath = Path.Combine(targetDir, selfRel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(targetPath) && File.Exists(renamedSelf))
{
File.Move(renamedSelf, targetPath);
}
}
catch { }
}
log?.Invoke("Vorheriger Stand wiederhergestellt.");
}
private static void MoveToBackup(string targetPath, string targetDir, string backupDir, string rel)
{
string backupPath = Path.Combine(backupDir, rel.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
SafeDelete(backupPath);
File.Move(targetPath, backupPath);
}
/// <summary>
/// Uebertraegt das Ausfuehrbar-Bit.
///
/// Wird unter Windows fuer linux-x64 gebaut, kennt das tar-Archiv keine
/// Unix-Rechte und alles landet als 644 - die Anwendung liesse sich auf
/// dem Zielsystem nicht starten. Betroffen sind der Apphost (traegt
/// unter Linux keine Endung) und Shell-Skripte.
/// </summary>
private static void CopyExecutableBit(string sourcePath, string targetPath, string rel)
{
// Bewusst OperatingSystem.IsWindows() statt des eigenen Helfers:
// nur diese Form erkennt die Plattformanalyse als Absicherung der
// unter Windows nicht unterstuetzten Unix-Rechte-Aufrufe.
if (OperatingSystem.IsWindows())
return;
try
{
var mode = File.GetUnixFileMode(sourcePath);
bool alreadyExecutable =
(mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
if (!alreadyExecutable && LooksExecutable(rel))
{
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
}
File.SetUnixFileMode(targetPath, mode);
}
catch
{
// Auf Dateisystemen ohne Unix-Rechte (etwa eine gemountete
// Windows-Freigabe) ist das schlicht nicht anwendbar.
}
}
private static bool LooksExecutable(string rel)
{
string name = rel;
int slash = name.LastIndexOf('/');
if (slash >= 0)
name = name.Substring(slash + 1);
if (name.EndsWith(".sh", StringComparison.OrdinalIgnoreCase))
return true;
// Der Apphost einer .NET-Anwendung traegt unter Linux keine
// Endung. Dateien ohne Punkt sind hier die einzigen Kandidaten.
return name.IndexOf('.') < 0;
}
/// <summary>
/// Entfernt Verzeichnisse, die durch das Aufraeumen leer geworden sind.
/// Das Zielverzeichnis selbst bleibt bestehen.
/// </summary>
private static void RemoveEmptyDirectories(string targetDir)
{
try
{
foreach (string dir in Directory
.GetDirectories(targetDir, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
{
try
{
if (Directory.GetFileSystemEntries(dir).Length == 0)
Directory.Delete(dir);
}
catch { }
}
}
catch { }
}
private static bool IsSameFile(string a, string b)
{
try
{
string fullA = Path.GetFullPath(a);
string fullB = Path.GetFullPath(b);
var comparison = OperatingSystemHelpers.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(fullA, fullB, comparison);
}
catch
{
return false;
}
}
private static void SafeDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch { }
}
private static void SafeDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
}
catch { }
}
}
}
File diff suppressed because it is too large Load Diff