diff --git a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj index 31669a4..87885ad 100644 --- a/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj +++ b/client-dotnet/Deploymentcenter.Client/Deploymentcenter.Client.csproj @@ -8,8 +8,41 @@ Deploymentcenter.Client Deploymentcenter.Client $(NoWarn);CA1416 + + + true + Deploymentcenter.Client + 2.5.0 + Deploymentcenter + Client-SDK für Lizenzprüfung, Update-Verteilung und Erstinstallation über das Deploymentcenter. + deploymentcenter;update;lizenz + + + + + diff --git a/client-dotnet/Deploymentcenter.Client/LicenseClient.cs b/client-dotnet/Deploymentcenter.Client/LicenseClient.cs index 6c7f137..acfd2e2 100644 --- a/client-dotnet/Deploymentcenter.Client/LicenseClient.cs +++ b/client-dotnet/Deploymentcenter.Client/LicenseClient.cs @@ -378,6 +378,99 @@ public class LicenseClient /// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen. /// Liefert null, wenn kein brauchbarer Cache vorliegt. /// + /// + /// Stellt sicher, dass eine gueltige Lizenz vorliegt - und fragt notfalls + /// nach dem Schluessel. + /// + /// Bis Version 2.5 nahm der Konstruktor ein + /// entgegen, legte es ab und benutzte es nie. Das Versprechen der + /// Schnittstelle - Schluessel abfragen, Fehler und Erfolg melden - loeste + /// nichts ein; wer darauf eine headless-Story aufbaute, baute auf Sand. + /// Diese Methode ist die Naht, die dabei fehlte. + /// + /// Ablauf: zwischengespeicherten Schluessel nehmen, sonst fragen; pruefen; + /// bei Ablehnung erneut fragen, bis + /// erschoepft ist. + /// + /// + /// Auf false setzen, wenn kein Mensch davorsitzt - etwa in einem Dienst. + /// Dann wird ausschliesslich der zwischengespeicherte Schluessel geprueft + /// und bei dessen Fehlen abgelehnt, statt auf eine Eingabe zu warten, die + /// nie kommt. + /// + public async Task EnsureLicensedAsync( + string productSlug, + string serverBaseUrl, + bool allowPrompt = true, + int maxAttempts = 3, + string? appVersion = null, + CancellationToken cancellationToken = default) + { + string? key = TryGetCachedKey(productSlug); + LicenseValidationResult? last = null; + + for (int attempt = 0; attempt < Math.Max(1, maxAttempts); attempt++) + { + if (string.IsNullOrWhiteSpace(key)) + { + if (!allowPrompt) + { + _prompt.ShowLicenseError( + "Keine Lizenz hinterlegt", + $"Fuer \"{productSlug}\" liegt kein Schluessel vor, und es kann nicht danach gefragt werden."); + + return last ?? new LicenseValidationResult + { + IsValid = false, + Status = "no_license", + Message = "Kein Lizenzschluessel hinterlegt." + }; + } + + key = await _prompt.RequestLicenseKeyAsync(productSlug).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(key)) + { + // Abbruch durch den Benutzer - erneutes Fragen waere + // Bevormundung. + return new LicenseValidationResult + { + IsValid = false, + Status = "no_license", + Message = "Es wurde kein Lizenzschluessel eingegeben." + }; + } + } + + last = await ValidateAsync(productSlug, key!, serverBaseUrl, appVersion, cancellationToken) + .ConfigureAwait(false); + + if (last.IsValid) + { + _prompt.ShowLicenseInfo("Lizenz gueltig", last.Message); + return last; + } + + // Ein Netzwerk- oder Serverproblem ist kein Grund, den Schluessel + // erneut abzufragen - er ist ja nicht falsch. + if (last.IsTransient) + { + _prompt.ShowLicenseError("Lizenzpruefung nicht moeglich", last.Message); + return last; + } + + _prompt.ShowLicenseError("Lizenz abgelehnt", last.Message); + key = null; + } + + return last ?? new LicenseValidationResult + { + IsValid = false, + Status = "invalid", + Message = "Lizenzpruefung fehlgeschlagen." + }; + } + public static string? TryGetCachedKey(string productSlug) { var cache = TryGetCachedState(productSlug); diff --git a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs index dca506b..0886857 100644 --- a/client-dotnet/Deploymentcenter.Client/UpdateClient.cs +++ b/client-dotnet/Deploymentcenter.Client/UpdateClient.cs @@ -123,6 +123,17 @@ namespace Deploymentcenter.Client response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); } + // Auch der API-Zweig kann 401 liefern - etwa wenn die + // Lese-Endpunkte spaeter ebenfalls hinter den Zugangsschutz + // wandern. Zuvor wurde das nur auf dem statischen Weg erkannt + // und hier als gewoehnlicher HTTP-Fehler gemeldet. + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + result.Unauthorized = true; + result.Message = ReleaseCredentials.DescribeUnauthorized(credentials); + return result; + } + if (!response.IsSuccessStatusCode) { result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}"; @@ -339,7 +350,8 @@ namespace Deploymentcenter.Client string? currentVersion = null, string? platform = null, bool waitForCurrentProcess = true, - string? licenseKey = null) + string? licenseKey = null, + int waitTimeoutSeconds = 60) { if (!File.Exists(agentPath)) { @@ -361,17 +373,37 @@ namespace Deploymentcenter.Client AppendArg(args, "--current-version", currentVersion!); } - // Ohne den Schluessel kommt der Agent nicht mehr an die Pakete - - // die Release-Ablage verlangt seit Version 2.4 Zugangsdaten. + // Der Lizenzschluessel wird BEWUSST NICHT als Argument uebergeben. + // + // Was in argv steht, zeigt "ps" jedem Benutzer der Maschine. Aus + // genau diesem Grund verwirft UPGRADE.md §5 den Weg, den Shared + // Key im Crontab zu hinterlegen - fuer einen Lizenzschluessel + // gilt dasselbe. + // + // Stattdessen wird die Variable auf dem eigenen Prozess gesetzt; + // der Agent erbt den Umgebungsblock beim Start und liest sie dort. + // Das funktioniert auch mit UseShellExecute=true, wo sich + // ProcessStartInfo.Environment nicht setzen laesst. + bool licenseKeySet = false; + if (!string.IsNullOrWhiteSpace(licenseKey)) { - AppendArg(args, "--license-key", licenseKey!); + Environment.SetEnvironmentVariable("DC_LICENSE_KEY", licenseKey); + licenseKeySet = true; } if (waitForCurrentProcess) { AppendArg(args, "--wait-for-pid", Process.GetCurrentProcess().Id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // Ohne diesen Wert galten fest 60 Sekunden. Eine Anwendung, + // die allein fuer host.StopAsync 30 Sekunden braucht, kommt + // damit gefaehrlich nah an die Grenze - und laeuft sie ab, + // bricht der Agent ab, statt ueber gesperrte Dateien zu + // schreiben. Sicher, aber eben auch kein Update. + AppendArg(args, "--wait-timeout", + Math.Max(5, waitTimeoutSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture)); } // Ohne ausdruecklichen Pfad die eigene ausfuehrbare Datei. @@ -394,10 +426,31 @@ namespace Deploymentcenter.Client UseShellExecute = true }; - Process.Start(startInfo); + try + { + Process.Start(startInfo); + } + finally + { + // Nach dem Start wieder entfernen: Der Schluessel soll nur so + // lange im eigenen Umgebungsblock stehen, wie er zum Vererben + // gebraucht wird. + if (licenseKeySet) + { + Environment.SetEnvironmentVariable("DC_LICENSE_KEY", null); + } + } if (exitCurrentApp) { + // Environment.Exit beendet den Prozess sofort: laufende + // finally-Bloecke anderer Threads, IHostApplicationLifetime + // und Destruktoren kommen nicht mehr zum Zug. Fuer eine + // Anwendung mit offenem Zustand - Positionen, Transaktionen, + // ungeschriebene Puffer - ist das die falsche Wahl. Dort + // gehoert exitCurrentApp:false gesetzt und danach der eigene + // geordnete Weg gegangen; der Agent wartet ohnehin auf das + // Prozessende. Environment.Exit(0); } diff --git a/client-dotnet/Deploymentcenter.Packager/Program.cs b/client-dotnet/Deploymentcenter.Packager/Program.cs index 7424fac..496230e 100644 --- a/client-dotnet/Deploymentcenter.Packager/Program.cs +++ b/client-dotnet/Deploymentcenter.Packager/Program.cs @@ -9,6 +9,7 @@ using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Deploymentcenter.Client; using Deploymentcenter.Client.Models; @@ -266,7 +267,10 @@ namespace Deploymentcenter.Packager } } - WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config); + if (!CheckForSecrets(filteredFiles, publishDir, config, HasFlag(args, "--allow-secrets"))) + { + return 1; + } // --------------------------------------------------------------- // Paket bauen @@ -798,6 +802,197 @@ namespace Deploymentcenter.Packager /// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt /// beim Update die Konfiguration jedes Zielsystems. /// + /// + /// Prueft die zu packenden Dateien auf Zugangsdaten und bricht ab, + /// wenn welche gefunden werden. + /// + /// Frueher wurde nur gewarnt. Das hat nicht gereicht: Ein echter + /// OpenRouter-Schluessel lag dadurch in einem oeffentlich abrufbaren + /// Paket. Der Zugangsschutz macht das nicht harmlos - dann ist er eben + /// fuer jeden Lizenznehmer lesbar statt fuer alle. + /// + /// Zwei Stufen: der Dateiname (eine appsettings.Local.json gehoert + /// nicht ins Paket) und der Inhalt (ein gefuelltes Password= oder ein + /// Schluessel im Format sk-... verraet sich selbst). Der zweite Teil + /// findet auch Dateien, deren Name unverdaechtig ist. + /// + /// Rueckgabe false bedeutet: nicht weitermachen. + /// + static bool CheckForSecrets(List files, string publishDir, PackagerConfig config, bool allowSecrets) + { + var byName = new List(); + var byContent = new List<(string Path, string Reason)>(); + + string[] suspiciousNames = + { + "appsettings.Local.json", "appsettings.*.Local.json", "*.local.json", + "secrets.json", "connectionstrings.json", + "master.key", "*.pfx", "*.key", "*.pem", "*.p12", + "server_settings.xml", "*.db", "*.sqlite", "*.sqlite3" + }; + + foreach (var file in files) + { + string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); + + if (GlobMatcher.IsMatch(relPath, suspiciousNames)) + { + byName.Add(relPath); + continue; + } + + string? reason = FindSecretInContent(file); + if (reason != null) + { + byContent.Add((relPath, reason)); + } + } + + if (byName.Count == 0 && byContent.Count == 0) + { + return true; + } + + Console.ForegroundColor = allowSecrets ? ConsoleColor.Yellow : ConsoleColor.Red; + Console.WriteLine(); + Console.WriteLine($"[{(allowSecrets ? "WARNUNG" : "ABBRUCH")}] Im Paket liegen mutmasslich Zugangsdaten:"); + + foreach (var name in byName) + { + Console.WriteLine($" - {name} (Dateiname)"); + } + + foreach (var (path, reason) in byContent) + { + Console.WriteLine($" - {path} ({reason})"); + } + + Console.WriteLine(); + Console.WriteLine(" Release-Pakete sind fuer jeden Lizenznehmer lesbar. Was hier steht,"); + Console.WriteLine(" ist damit an alle Kunden ausgeliefert."); + Console.WriteLine(); + Console.WriteLine(" - Gehoert die Datei gar nicht ins Paket: in excludePatterns aufnehmen"); + Console.WriteLine(" und pruefen, warum sie im Publish-Verzeichnis liegt (haeufig eine"); + Console.WriteLine(" CopyToOutputDirectory-Regel in der csproj)."); + Console.WriteLine(" - Ist es eine Vorlage: die echten Werte durch Platzhalter ersetzen"); + Console.WriteLine(" und sie in preservePatterns aufnehmen."); + + if (!allowSecrets) + { + Console.WriteLine(); + Console.WriteLine(" Bewusst gewollt? --allow-secrets"); + } + + Console.ResetColor(); + Console.WriteLine(); + + return allowSecrets; + } + + /// + /// Sucht in einer Textdatei nach Werten, die wie Zugangsdaten + /// aussehen. Binaerdateien und grosse Dateien werden uebersprungen - + /// dort waere die Trefferquote schlecht und die Laufzeit hoch. + /// + static string? FindSecretInContent(string path) + { + try + { + var info = new FileInfo(path); + + if (info.Length == 0 || info.Length > 512 * 1024) + return null; + + string extension = Path.GetExtension(path).ToLowerInvariant(); + string[] textLike = { ".json", ".xml", ".config", ".ini", ".yml", ".yaml", ".env", ".txt", ".toml" }; + + if (Array.IndexOf(textLike, extension) < 0 && extension.Length != 0) + return null; + + string content = File.ReadAllText(path); + + // Gefuelltes Passwortfeld - "Password=" allein ist eine leere + // Vorlage und damit in Ordnung. + var password = Regex.Match(content, + @"(?i)\b(password|passwort|pwd)\s*[=:]\s*""?([^""\s;,}]{4,})", + RegexOptions.CultureInvariant); + + if (password.Success && !LooksLikePlaceholder(password.Groups[2].Value)) + { + return "gefuelltes Passwortfeld"; + } + + // Verbreitete Schluesselformate. + // + // Auch hier wird der Treffer gegen die Platzhalterliste + // gehalten. Ohne das blockiert "sk-DEIN-SCHLUESSEL-HIER" in + // einer Vorlage jeden Build - und wer das erlebt, setzt + // --allow-secrets kuenftig gewohnheitsmaessig. Dann haette die + // Sperre genau nichts gebracht. + foreach (var (pattern, label) in new[] + { + (@"\bsk-[A-Za-z0-9_\-]{16,}", "API-Schluessel (sk-...)"), + (@"\bghp_[A-Za-z0-9]{20,}", "GitHub-Token"), + (@"\bdc_(master|sub|setup)_[0-9a-f]{16,}", "Deploymentcenter-Token"), + (@"\bAKIA[0-9A-Z]{16}\b", "AWS-Zugriffsschluessel"), + (@"-----BEGIN [A-Z ]*PRIVATE KEY-----", "privater Schluessel") + }) + { + foreach (Match match in Regex.Matches(content, pattern, RegexOptions.CultureInvariant)) + { + if (!LooksLikePlaceholder(match.Value)) + { + return label; + } + } + } + + return null; + } + catch + { + // Nicht lesbar heisst nicht verdaechtig. + return null; + } + } + + /// + /// Erkennt Vorlagenwerte, damit eine ausgelieferte Musterdatei nicht + /// jeden Build blockiert. + /// + static bool LooksLikePlaceholder(string value) + { + string lower = value.Trim().ToLowerInvariant(); + + if (lower.Length == 0) + return true; + + string[] markers = + { + "changeme", "change-me", "todo", "xxx", "platzhalter", "placeholder", + "your-", "dein-", "deine-", "<", "beispiel", "example", "secret-here", + "password", "schluessel-hier", "schlüssel-hier", "hier-einfuegen", + "replace-me", "insert-", "dummy", "sample" + }; + + foreach (var marker in markers) + { + if (lower.Contains(marker)) + return true; + } + + // Durchgehend Grossbuchstaben mit Bindestrichen und ohne Ziffern + // ist typisch fuer Platzhalter (SK-MEIN-SCHLUESSEL), aber nicht + // fuer echte Schluessel - die tragen praktisch immer Ziffern. + string body = value.Trim(); + if (body.Length > 4 && body == body.ToUpperInvariant() && !body.Any(char.IsDigit)) + { + return true; + } + + return false; + } + static void WarnAboutUnprotectedSecrets(List files, string publishDir, PackagerConfig config) { string[] suspicious = @@ -952,6 +1147,7 @@ namespace Deploymentcenter.Packager Console.WriteLine(" --main-assembly 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(" --allow-secrets Trotz gefundener Zugangsdaten veroeffentlichen"); Console.WriteLine(" --config Abweichende packager.config.json"); Console.WriteLine(" --token Token mit updateservice:publish"); Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir"); diff --git a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs index b72fe10..4d77758 100644 --- a/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs +++ b/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs @@ -73,10 +73,13 @@ namespace Deploymentcenter.UpdateAgent // Betrieb ist das der Lizenzschluessel der Anwendung; bei // einer Erstinstallation gibt es den noch nicht, dort treten // die Zugangsdaten des Installationskontos an seine Stelle. - Credentials = ReleaseCredentials.FromLicenseKey(GetArg(args, "--license-key")) - ?? ReleaseCredentials.FromUser( - GetArg(args, "--download-user"), - GetArg(args, "--download-password")) + // + // Umgebungsvariablen haben Vorrang vor den Argumenten: Was in + // argv steht, zeigt "ps" jedem Benutzer der Maschine. Aus + // genau diesem Grund verwirft UPGRADE.md §5 den Weg, den + // Shared Key im Crontab zu hinterlegen - fuer einen + // Lizenzschluessel gilt nichts anderes. + Credentials = ReadCredentials(args) }; PackageManifest? installedManifest = ReadInstalledManifest(targetDir); @@ -1068,6 +1071,40 @@ namespace Deploymentcenter.UpdateAgent return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } + /// + /// Ermittelt die Zugangsdaten fuer die Release-Ablage. + /// + /// Reihenfolge: Umgebungsvariable vor Argument. Ein Geheimnis in argv + /// steht in der Prozessliste und ist damit fuer jeden Benutzer der + /// Maschine sichtbar; die Umgebung eines fremden Prozesses ist unter + /// Linux nur fuer den Eigentuemer und root lesbar. + /// + /// Das SDK setzt die Variable vor dem Start auf sich selbst - der + /// Agent erbt sie und der Schluessel taucht nirgends in einer + /// Kommandozeile auf. + /// + static ReleaseCredentials? ReadCredentials(string[] args) + { + string? licenseKey = Env("DC_LICENSE_KEY") ?? GetArg(args, "--license-key"); + + var fromLicense = ReleaseCredentials.FromLicenseKey(licenseKey); + if (fromLicense != null) + { + return fromLicense; + } + + string? user = Env("DC_DOWNLOAD_USER") ?? GetArg(args, "--download-user"); + string? pass = Env("DC_DOWNLOAD_PASSWORD") ?? GetArg(args, "--download-password"); + + return ReleaseCredentials.FromUser(user, pass); + } + + static string? Env(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + static int ParsePid(string? value) { return int.TryParse(value, out int pid) && pid > 0 ? pid : 0; diff --git a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md index 40c7281..68ff89b 100644 --- a/docs/UPDATESERVICE_INTEGRATION_GUIDE.md +++ b/docs/UPDATESERVICE_INTEGRATION_GUIDE.md @@ -164,16 +164,28 @@ ein Dienst-Manager den Neustart übernimmt. ### B. MSBuild BuildInfo Generierung -Binde das `Deploymentcenter.BuildInfo.targets` Script in deine `.csproj` ein, damit Version, UTC-Build-Datum und Git Commit-Hash automatisch zur Übersetzungszeit generiert werden: +Version, UTC-Build-Datum und Git-Commit entstehen automatisch zur +Übersetzungszeit. **Seit Version 2.5 genügt dafür der Paketverweis:** ```xml 1.4.3 - + + + ``` +Das Targets-File liegt im Paket unter `build/` und wird von NuGet selbst +importiert — ein `` von Hand entfällt. + +> **Der bisherige Weg koppelte zwei Repositories.** Die Anleitung empfahl +> `` +> — das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in +> derselben Fassung stehen. Wer nur das Paket bezieht, hatte das Target nicht. +> Der Import per Pfad funktioniert weiterhin, ist aber nicht mehr nötig. + Die Klasse entsteht im Namensraum deines Projekts (`$(RootNamespace)`), nicht im SDK. Du erreichst sie also ohne `using`: @@ -360,8 +372,27 @@ Die Muster sind jetzt echte Globs: | `wwwroot/**/*.css` | auch in Unterverzeichnissen | | `appsettings*.json` | `appsettings.json`, `appsettings.Production.json`, … | -Der Packager warnt zusätzlich von sich aus, wenn eine Datei nach Zugangsdaten -aussieht und auf keiner der beiden Listen steht. +### Der Packager bricht bei Zugangsdaten ab + +Seit Version 2.5 nicht mehr nur eine Warnung. Anlass war ein echter +API-Schlüssel, der so in ein öffentlich abrufbares Paket geriet — und die +Warnung wurde damals ausgerechnet unterdrückt, weil die Datei auf der +`preserve`-Liste stand. + +| Stufe | Findet | +|---|---| +| Dateiname | `appsettings.Local.json`, `secrets.json`, `master.key`, `*.pfx`, `*.db`, `server_settings.xml`, … | +| Inhalt | gefüllte `Password=`-Felder, `sk-…`, `ghp_…`, `dc_master_…`, `AKIA…`, private Schlüssel | + +Die Inhaltsprüfung findet auch Dateien mit unverdächtigem Namen. Platzhalter +wie `sk-DEIN-SCHLUESSEL-HIER` oder `Password=changeme` lösen **nicht** aus — +sonst würde jede ausgelieferte Vorlage den Build blockieren, und +`--allow-secrets` wäre nach einer Woche Gewohnheit. + +**Der Zugangsschutz macht das nicht harmlos.** Ein Schlüssel im Paket ist dann +für jeden Lizenznehmer lesbar statt für alle — besser, aber immer noch falsch. + +Notausgang für bewusste Fälle: `--allow-secrets`. ### Versionsgegenprobe @@ -678,14 +709,51 @@ UpdateClient.LaunchUpdateAgent( licenseKey: meineLizenz); // reicht --license-key an den Agenten durch ``` -Auf der Kommandozeile: +Auf der Kommandozeile — **bevorzugt über die Umgebung**: ```bash -update-agent --project myapp --action update --license-key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX +DC_LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX-XXXXX \ + update-agent --project myapp --action update ``` -Ersatzweise `--download-user` und `--download-password` für ein -Installationskonto — das nutzt der Installer bei der Erstinstallation. +`--license-key` gibt es weiterhin, ist aber die schlechtere Wahl: Was in `argv` +steht, zeigt `ps` jedem Benutzer der Maschine. Aus genau diesem Grund verwirft +[UPGRADE.md §5](./UPGRADE.md#5-cron-für-den-watchdog-evaluator-einrichten) den +Weg, den Shared Key im Crontab zu hinterlegen — für einen Lizenzschlüssel gilt +dasselbe. Umgebungsvariablen haben Vorrang vor den Argumenten. + +`LaunchUpdateAgent` übergibt den Schlüssel deshalb **nicht** als Argument: Es +setzt `DC_LICENSE_KEY` auf dem eigenen Prozess, der Agent erbt den +Umgebungsblock beim Start, und danach wird die Variable wieder entfernt. + +Ersatzweise `DC_DOWNLOAD_USER` und `DC_DOWNLOAD_PASSWORD` (oder +`--download-user` / `--download-password`) für ein Installationskonto — das +nutzt der Installer bei der Erstinstallation. + +### Wartezeit beim Übergeben + +`LaunchUpdateAgent` gibt dem Agenten mit, wie lange er auf das Ende der +Anwendung warten soll. Vorgabe sind 60 Sekunden: + +```csharp +UpdateClient.LaunchUpdateAgent( + agentPath: agentPath, + projectId: "myapp", + licenseKey: meineLizenz, + waitTimeoutSeconds: 180, // reicht auch für ein langsames StopAsync + exitCurrentApp: false); // siehe unten +``` + +Läuft die Zeit ab, **bricht der Agent ab, ohne etwas zu verändern** — sicher, +aber eben auch kein Update. Wer allein für `host.StopAsync` 30 Sekunden +braucht, sollte den Wert heraufsetzen. + +> **`exitCurrentApp: true` beendet den Prozess mit `Environment.Exit(0)`.** +> Laufende `finally`-Blöcke anderer Threads, `IHostApplicationLifetime` und +> Destruktoren kommen dann nicht mehr zum Zug. Für eine Anwendung mit offenem +> Zustand — Positionen, Transaktionen, ungeschriebene Puffer — ist das die +> falsche Wahl. Dort `exitCurrentApp: false` setzen und anschließend den +> eigenen geordneten Weg gehen; der Agent wartet ohnehin auf das Prozessende. `UpdateCheckResult.Unauthorized` ist bewusst von einem gewöhnlichen Fehler getrennt. Ohne diese Unterscheidung sieht ein abgelaufener Vertrag aus wie eine diff --git a/public/docs/release.md b/public/docs/release.md index ebf8f27..0d62f7b 100644 --- a/public/docs/release.md +++ b/public/docs/release.md @@ -136,9 +136,18 @@ Eine `appsettings.json` mit echten Zugangsdaten gehört in **keine** von beiden Listen — sie gehört gar nicht erst ins Publish-Verzeichnis. Was ausgeliefert wird, muss eine **Vorlage mit Platzhaltern** sein. -`pack-and-deploy` warnt bei Dateien, die nach Zugangsdaten aussehen. **Nimm die -Warnung ernst.** Release-Pakete liegen hinter einem Zugangsschutz, aber jeder -lizenzierte Kunde kann sie auspacken. +**`pack-and-deploy` bricht ab, wenn es Zugangsdaten findet** — geprüft werden +Dateinamen (`appsettings.Local.json`, `master.key`, `*.pfx`, `*.db`, …) und der +Inhalt (gefüllte `Password=`-Felder, `sk-…`, `dc_master_…`, private Schlüssel). +Platzhalter wie `sk-DEIN-SCHLUESSEL-HIER` lösen nicht aus. + +Häufigste Ursache: eine `CopyToOutputDirectory`-Regel in der `.csproj`, die +eine lokale Konfigurationsdatei ins Ausgabeverzeichnis kopiert. Dann gehört sie +in `excludePatterns` — und die Regel auf den Prüfstand. + +Notausgang: `--allow-secrets`. **Nur benutzen, wenn du weißt, warum.** +Release-Pakete liegen hinter einem Zugangsschutz, aber jeder lizenzierte Kunde +kann sie auspacken. ### `setup.json` anlegen