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