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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user