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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-09 19:56:35 +02:00
co-authored by Claude Opus 5
parent 5f9b0c5596
commit 2388b5abe1
37 changed files with 5498 additions and 491 deletions
@@ -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();
}
}
}