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,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
}
}