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:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -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="// <auto-generated />" />
|
||||
<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=" /// <summary>Zur Uebersetzungszeit eingebettete Build-Daten.</summary>" />
|
||||
<BuildInfoLine Include=" public static class $(DeploymentcenterBuildInfoClass)" />
|
||||
<BuildInfoLine Include=" {" />
|
||||
<BuildInfoLine Include=" static BuildInfo()" />
|
||||
<BuildInfoLine Include=" public const string Version = "$(BuildVersion)"%3B" />
|
||||
<BuildInfoLine Include=" public const string GitCommit = "$(DcGitCommitLong)"%3B" />
|
||||
<BuildInfoLine Include=" public const string GitCommitShort = "$(DcGitCommitShort)"%3B" />
|
||||
<BuildInfoLine Include=" public const string BuildDateUtc = "$(BuildDateUtc)"%3B" />
|
||||
<BuildInfoLine Include=" public const string Channel = "$(BuildChannel)"%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 = "$(BuildVersion)"%3B" />
|
||||
<BuildInfoLine Include=" GitCommit = "$(GitCommitLong)"%3B" />
|
||||
<BuildInfoLine Include=" GitCommitShort = "$(GitCommitShort)"%3B" />
|
||||
<BuildInfoLine Include=" BuildDateUtc = "$(BuildDateUtc)"%3B" />
|
||||
<BuildInfoLine Include=" Channel = "$(BuildChannel)"%3B" />
|
||||
<BuildInfoLine Include=" get { return "v" + Version + " (" + GitCommitShort + ") built on " + BuildDateUtc + " [" + Channel + "]"%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 <Version> 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
Reference in New Issue
Block a user