using System; using System.Collections.Generic; using System.Diagnostics; using System.Formats.Tar; using System.IO; using System.IO.Compression; using System.Linq; 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; using FluentFTP; namespace Deploymentcenter.Packager { /// /// Wird geworfen, wenn die vorhandene Versionshistorie nicht sicher /// gelesen werden konnte. Dann darf latest.json nicht geschrieben werden. /// internal sealed class ReleaseHistoryException : Exception { public ReleaseHistoryException(string message) : base(message) { } } /// /// Konfiguration des Packagers. /// /// Die Zugangsdaten standen zuvor als Standardwerte direkt im Quelltext und /// lagen damit im Repository. Sie kommen jetzt ausschliesslich aus /// packager.config.json (nicht versioniert) oder aus Umgebungsvariablen. /// Fehlen sie, bricht das Programm mit einer klaren Meldung ab, statt sich /// mit veralteten Werten zu verbinden. /// public class PackagerConfig { public string FtpHost { get; set; } = ""; public int FtpPort { get; set; } = 21; public string FtpUser { get; set; } = ""; public string FtpPass { get; set; } = ""; public string FtpRemoteBaseDir { get; set; } = "/public_html/releases"; public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de"; /// /// Token mit dem Recht updateservice:publish. Das Veroeffentlichen eines /// Releases ist nicht mehr unauthentifiziert moeglich. /// public string ApiToken { get; set; } = ""; /// /// Dateien, die gar nicht erst ins Paket kommen. /// /// Die Muster werden seit dieser Fassung als echte Globs ausgewertet. /// Zuvor verstand der Abgleich nur "*.endung" und exakte Namen, sodass /// Eintraege wie "logs/**" nie zutrafen - sie standen in der /// Beispielkonfiguration und erweckten den Eindruck, es sei etwas /// ausgeschlossen. /// public List ExcludePatterns { get; set; } = new List { "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/**", "*.tmp" }; /// /// Dateien, die ins Paket gehoeren, am Ziel aber eine vorhandene /// Fassung nicht ersetzen duerfen. /// /// Ausschluss und Schutz sind zwei verschiedene Dinge: eine /// Konfigurationsvorlage soll ausgeliefert werden, damit eine /// Erstinstallation vollstaendig ist - beim Update darf sie die /// eingerichteten Werte des Zielsystems aber nicht ueberschreiben. /// public List PreservePatterns { get; set; } = new List { "appsettings.json", "appsettings.Production.json", "settings.json", "config.json", ".env" }; /// Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei. public void ApplyEnvironmentOverrides() { FtpHost = Env("DC_FTP_HOST", FtpHost); FtpUser = Env("DC_FTP_USER", FtpUser); FtpPass = Env("DC_FTP_PASS", FtpPass); ApiBaseUrl = Env("DC_API_URL", ApiBaseUrl); ApiToken = Env("DC_TOKEN", ApiToken); string port = Env("DC_FTP_PORT", ""); if (int.TryParse(port, out int parsedPort) && parsedPort > 0) { FtpPort = parsedPort; } } private static string Env(string name, string fallback) { string? value = Environment.GetEnvironmentVariable(name); return string.IsNullOrWhiteSpace(value) ? fallback : value; } } class Program { static async Task Main(string[] args) { Console.WriteLine("================================================="); Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v2.0 "); Console.WriteLine("================================================="); if (HasFlag(args, "--help") || HasFlag(args, "-h")) { ShowHelp(); return 0; } string project = GetArg(args, "--project", "-p") ?? "myapp"; string version = GetArg(args, "--version", "-v") ?? "1.0.0"; string channel = GetArg(args, "--channel", "-c") ?? "prod"; string publishDir = GetArg(args, "--publish-dir", "-d") ?? Directory.GetCurrentDirectory(); string changelog = GetArg(args, "--changelog") ?? $"Release v{version}"; bool isCritical = HasFlag(args, "--critical"); string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json"); PackagerConfig config = LoadConfig(configFile); config.ApplyEnvironmentOverrides(); // Reihenfolge: CLI-Argument, dann Umgebungsvariable, dann Datei. string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost; string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser; string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass; string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir; string apiToken = GetArg(args, "--token") ?? config.ApiToken; var missing = new List(); if (string.IsNullOrWhiteSpace(ftpHost)) missing.Add("FTP-Host (--ftp-host / DC_FTP_HOST / ftpHost)"); if (string.IsNullOrWhiteSpace(ftpUser)) missing.Add("FTP-Benutzer (--ftp-user / DC_FTP_USER / ftpUser)"); if (string.IsNullOrWhiteSpace(ftpPass)) missing.Add("FTP-Passwort (--ftp-pass / DC_FTP_PASS / ftpPass)"); if (missing.Count > 0) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("[FEHLER] Konfiguration unvollstaendig:"); foreach (var item in missing) { Console.WriteLine($" - {item}"); } Console.ResetColor(); Console.WriteLine(); Console.WriteLine($"Vorlage kopieren: {Path.GetFileName(configFile)}.example -> {Path.GetFileName(configFile)}"); return 1; } if (string.IsNullOrWhiteSpace(apiToken)) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("[WARNUNG] Kein API-Token gesetzt (--token / DC_TOKEN / apiToken)."); Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter"); Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0"); Console.WriteLine(" ein Token mit dem Recht updateservice:publish."); Console.WriteLine(" Ohne Registrierung entsteht ausserdem keine Signatur."); Console.ResetColor(); } publishDir = Path.GetFullPath(publishDir); if (!Directory.Exists(publishDir)) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[FEHLER] Publish-Verzeichnis existiert nicht: {publishDir}"); Console.ResetColor(); return 1; } // --------------------------------------------------------------- // Plattform bestimmen // --------------------------------------------------------------- // Ohne Plattform landeten Pakete verschiedener Laufzeitkennungen // unter derselben Version im selben Kanal und ueberschrieben sich. string platform; string platformSource; string? explicitPlatform = GetArg(args, "--platform") ?? GetArg(args, "--rid"); if (!string.IsNullOrWhiteSpace(explicitPlatform)) { platform = PlatformId.Normalize(explicitPlatform); platformSource = "Argument"; } else { string? inferred = PlatformId.InferFromPath(publishDir); if (inferred != null) { platform = inferred; platformSource = "aus dem Publish-Pfad abgeleitet"; } else { platform = PlatformId.Any; platformSource = "Standard"; } } Console.WriteLine($"[INFO] Projekt : {project}"); Console.WriteLine($"[INFO] Version : {version}"); Console.WriteLine($"[INFO] Kanal : {channel}"); Console.WriteLine($"[INFO] Plattform : {platform} ({platformSource})"); Console.WriteLine($"[INFO] Publish-Verzeichnis: {publishDir}"); if (platform == PlatformId.Any && !HasFlag(args, "--allow-any-platform")) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("[WARNUNG] Keine Plattform angegeben - das Release gilt als plattformunabhaengig."); Console.WriteLine(" Wird fuer mehrere Laufzeitkennungen gebaut, ueberschreiben sich die"); Console.WriteLine(" Pakete gegenseitig. Mit --platform win-x64 (o. ae.) trennen."); Console.ResetColor(); } // --------------------------------------------------------------- // Version gegen die Hauptassembly pruefen // --------------------------------------------------------------- // Weicht die veroeffentlichte Version von der einkompilierten ab, // meldet die Anwendung nach dem Update weiterhin die alte Version, // haelt das Release fuer neu und aktualisiert bei jedem Start // erneut - eine Endlosschleife ueber die gesamte Installationsbasis. if (!VerifyVersionAgainstAssembly(publishDir, project, version, GetArg(args, "--main-assembly"), HasFlag(args, "--ignore-version-mismatch"))) { return 1; } // --------------------------------------------------------------- // Dateien einsammeln // --------------------------------------------------------------- var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories); var filteredFiles = new List(); var preservedRelPaths = new List(); foreach (var file in allFiles) { string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); if (GlobMatcher.IsMatch(relPath, config.ExcludePatterns)) { Console.WriteLine($" [AUSGESCHLOSSEN] {relPath}"); continue; } filteredFiles.Add(file); if (GlobMatcher.IsMatch(relPath, config.PreservePatterns)) { preservedRelPaths.Add(relPath); } } Console.WriteLine($"[INFO] Dateien im Paket : {filteredFiles.Count}"); if (preservedRelPaths.Count > 0) { Console.WriteLine($"[INFO] Davon geschuetzt : {preservedRelPaths.Count} (ersetzen am Ziel keine vorhandene Datei)"); foreach (var p in preservedRelPaths) { Console.WriteLine($" [GESCHUETZT] {p}"); } } if (!CheckForSecrets(filteredFiles, publishDir, config, HasFlag(args, "--allow-secrets"))) { return 1; } // --------------------------------------------------------------- // Paket bauen // --------------------------------------------------------------- string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(outputTempDir); string packageTarGzPath = Path.Combine(outputTempDir, "package.tar.gz"); string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json"); string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256"); string gitCommit = GetGitCommitLong(); string gitCommitShort = GetGitCommitShort(); string buildDateUtc = DateTime.UtcNow.ToString("o"); var packageManifest = new PackageManifest { ProjectId = project, Version = version, Channel = channel, Platform = platform, BuildDate = buildDateUtc, GitCommit = gitCommit, GitCommitShort = gitCommitShort, Changelog = changelog, Preserve = new List(config.PreservePatterns), Files = new List() }; foreach (var file in filteredFiles) { string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); long size = new FileInfo(file).Length; string hash = ComputeSha256(file); packageManifest.Files.Add(new PackageFileEntry { Path = relPath, SizeBytes = size, Sha256 = hash }); } var manifestOptions = new JsonSerializerOptions { WriteIndented = true }; string manifestJson = JsonSerializer.Serialize(packageManifest, manifestOptions); await File.WriteAllTextAsync(manifestJsonPath, manifestJson); Console.WriteLine("[INFO] Erzeuge package.tar.gz ..."); string archiveStaging = Path.Combine(outputTempDir, "archive_root"); Directory.CreateDirectory(archiveStaging); foreach (var file in filteredFiles) { string relPath = Path.GetRelativePath(publishDir, file); string targetFile = Path.Combine(archiveStaging, relPath); Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!); File.Copy(file, targetFile, true); } File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true); using (var fs = File.Create(packageTarGzPath)) using (var gz = new GZipStream(fs, CompressionLevel.Optimal)) { TarFile.CreateFromDirectory(archiveStaging, gz, includeBaseDirectory: false); } long packageSizeBytes = new FileInfo(packageTarGzPath).Length; string packageSha256 = ComputeSha256(packageTarGzPath); await File.WriteAllTextAsync(sha256FilePath, packageSha256); Console.WriteLine($"[OK] Paket erstellt ({packageSizeBytes} Bytes)"); Console.WriteLine($"[INFO] SHA256: {packageSha256}"); // --------------------------------------------------------------- // Hochladen // --------------------------------------------------------------- // Plattformunabhaengige Releases behalten den alten Pfad ohne // Zwischenebene, damit bereits ausgelieferte Anwendungen ihre // Updates weiterhin finden. string platformSegment = PlatformId.PathSegment(platform); string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}{platformSegment}"; string remoteVersionPath = $"{remoteChannelPath}/{version}"; Console.WriteLine($"[INFO] Upload nach {ftpHost}:{config.FtpPort} ({remoteVersionPath}) ..."); bool ftpSucceeded = false; bool historyPreserved = true; try { using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort); ftp.Config.EncryptionMode = FtpEncryptionMode.Explicit; ftp.Config.ValidateAnyCertificate = true; await ftp.Connect(); await ftp.CreateDirectory(remoteVersionPath); await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite); await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite); await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite); Console.WriteLine("[OK] Paketdateien hochgeladen."); // ----------------------------------------------------------- // latest.json fortschreiben // ----------------------------------------------------------- string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json"; var channelManifest = new ReleaseManifest { ProjectId = project, Channel = channel, Platform = platform, Versions = new List() }; // Die vorherige Fassung startete mit leerer Versionsliste und // verschluckte jeden Fehler beim Lesen der bestehenden Datei // in einem leeren catch. Schlug Download oder Parsen fehl, // wurde die gesamte Historie durch einen einzigen Eintrag // ersetzt - ohne jede Meldung. Jetzt bricht der Vorgang ab, // bevor latest.json geschrieben wird. if (await ftp.FileExists(remoteLatestJsonPath)) { string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json"); FtpStatus status; try { status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite); } catch (Exception ex) { throw new ReleaseHistoryException( $"Die vorhandene latest.json konnte nicht geladen werden: {ex.Message}"); } if (status != FtpStatus.Success || !File.Exists(tempLatestLocal)) { throw new ReleaseHistoryException( "Die vorhandene latest.json konnte nicht geladen werden (Download nicht erfolgreich)."); } ReleaseManifest? existingManifest; try { string existingJson = await File.ReadAllTextAsync(tempLatestLocal); existingManifest = JsonSerializer.Deserialize(existingJson); } catch (Exception ex) { throw new ReleaseHistoryException( $"Die vorhandene latest.json ist nicht lesbar: {ex.Message}"); } if (existingManifest?.Versions == null) { throw new ReleaseHistoryException( "Die vorhandene latest.json enthaelt keine auswertbare Versionsliste."); } channelManifest.Versions = existingManifest.Versions; Console.WriteLine($"[INFO] Bestehende Historie gelesen: {channelManifest.Versions.Count} Eintraege."); } string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz"; var newVersionInfo = new VersionInfo { Version = version, BuildDate = buildDateUtc, GitCommit = gitCommit, GitCommitShort = gitCommitShort, PackageUrl = packagePublicUrl, Sha256 = packageSha256, SizeBytes = packageSizeBytes, Changelog = changelog, IsCritical = isCritical, Platform = platform }; channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase)); channelManifest.Versions.Insert(0, newVersionInfo); // Nach Versionsordnung sortieren, damit "latest" auch dann // stimmt, wenn nachtraeglich eine aeltere Version gebaut wird. channelManifest.Versions.Sort((a, b) => UpdateClient.CompareVersions(b.Version, a.Version)); // Aeltere Eintraege werden nur aus der Liste genommen, die // Dateien bleiben auf dem Server liegen. Ein Rollback auf eine // herausgefallene Version ist ueber die Liste nicht mehr // erreichbar - deshalb der Hinweis statt stiller Kuerzung. const int keep = 15; if (channelManifest.Versions.Count > keep) { var dropped = channelManifest.Versions.Skip(keep).Select(v => v.Version).ToList(); channelManifest.Versions = channelManifest.Versions.Take(keep).ToList(); Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[HINWEIS] latest.json fuehrt {keep} Versionen. Nicht mehr gelistet: " + string.Join(", ", dropped)); Console.WriteLine(" Die Dateien liegen weiterhin auf dem Server, sind ueber den Agenten"); Console.WriteLine(" aber nicht mehr auswaehlbar."); Console.ResetColor(); } channelManifest.Latest = channelManifest.Versions.FirstOrDefault(); string updatedLatestJson = JsonSerializer.Serialize(channelManifest, manifestOptions); string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json"); await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson); await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite); Console.WriteLine("[OK] latest.json fortgeschrieben."); await ftp.Disconnect(); ftpSucceeded = true; } catch (ReleaseHistoryException ex) { historyPreserved = false; Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[FEHLER] {ex.Message}"); Console.WriteLine(" latest.json wurde NICHT geschrieben - die bestehende Historie ist"); Console.WriteLine(" unveraendert. Die Paketdateien dieser Version liegen bereits auf dem"); Console.WriteLine(" Server; nach Behebung der Ursache genuegt ein erneuter Aufruf."); Console.ResetColor(); } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}"); Console.WriteLine(" Das Paket wurde NICHT vollstaendig ausgeliefert."); Console.ResetColor(); } // --------------------------------------------------------------- // Deploymentcenter benachrichtigen // --------------------------------------------------------------- bool apiNotified = false; bool signed = false; string apiMessage = "uebersprungen (kein Token gesetzt)"; if (!string.IsNullOrWhiteSpace(apiToken)) { try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish"; // Das Dateimanifest wandert mit. Damit kann die API als // vollwertiger Rueckfall dienen, wenn die statische // latest.json fehlt oder der FTP-Upload scheiterte. using var manifestDoc = JsonDocument.Parse(manifestJson); var payload = new Dictionary { ["product_slug"] = project, ["version"] = version, ["channel"] = channel, ["platform"] = platform, ["release_notes"] = changelog, ["download_url"] = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz", ["sha256_hash"] = packageSha256, ["git_commit"] = gitCommitShort, ["size_bytes"] = packageSizeBytes, ["is_critical"] = isCritical, ["manifest_json"] = manifestDoc.RootElement.Clone() }; var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl) { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") }; request.Headers.Add("Authorization", $"Bearer {apiToken}"); var response = await http.SendAsync(request); string body = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) { apiNotified = true; apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen."; signed = string.Equals(ExtractJsonString(body, "signed"), "True", StringComparison.OrdinalIgnoreCase) || ExtractJsonString(body, "signed") == "true"; string? autoResolved = ExtractJsonString(body, "auto_resolved"); if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0") { apiMessage += $" ({autoResolved} Bugtracker-Item(s) automatisch geschlossen)"; } } else { apiMessage = $"HTTP {(int)response.StatusCode}: " + (ExtractJsonString(body, "message") ?? body.Trim()); } } catch (Exception ex) { apiMessage = $"Aufruf fehlgeschlagen: {ex.Message}"; } } if (apiNotified) { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine($"[OK] {apiMessage}"); Console.ResetColor(); if (!signed) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("[HINWEIS] Das Release ist unsigniert - auf dem Server ist kein"); Console.WriteLine(" Signierschluessel hinterlegt (security.release_private_key)."); Console.WriteLine(" Der Agent kann die Herkunft des Pakets dann nicht pruefen."); Console.ResetColor(); } } else { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}"); Console.ResetColor(); } try { Directory.Delete(outputTempDir, true); } catch { } bool fullySucceeded = ftpSucceeded && apiNotified; Console.WriteLine(); Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow; Console.WriteLine(fullySucceeded ? $"[FERTIG] Release {version} fuer {project} ({channel}, {platform}) vollstaendig veroeffentlicht." : $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}, {platform}): " + $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, " + $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}" + (historyPreserved ? "." : ", Historie unveraendert.")); Console.ResetColor(); return fullySucceeded ? 0 : 2; } /// /// Vergleicht die angegebene Version mit der, die tatsaechlich in der /// Hauptassembly steht. /// /// Rueckgabe false bedeutet: abbrechen. Laesst sich die Assembly nicht /// bestimmen, wird nur gewarnt - ein nicht pruefbarer Fall ist kein /// Fehler, ein nachgewiesener Widerspruch schon. /// static bool VerifyVersionAgainstAssembly( string publishDir, string project, string declaredVersion, string? mainAssemblyOverride, bool ignoreMismatch) { string? assemblyPath = ResolveMainAssembly(publishDir, project, mainAssemblyOverride); if (assemblyPath == null) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("[WARNUNG] Hauptassembly nicht gefunden - die Version konnte nicht gegengeprueft"); Console.WriteLine(" werden. Mit --main-assembly gezielt angeben."); Console.ResetColor(); return true; } string? actual = ReadAssemblyVersion(assemblyPath); if (actual == null) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[WARNUNG] Aus {Path.GetFileName(assemblyPath)} liess sich keine Version lesen."); Console.ResetColor(); return true; } if (VersionCoresMatch(actual, declaredVersion)) { Console.WriteLine($"[OK] Version stimmt mit {Path.GetFileName(assemblyPath)} ueberein ({actual})."); return true; } Console.ForegroundColor = ignoreMismatch ? ConsoleColor.Yellow : ConsoleColor.Red; Console.WriteLine($"[{(ignoreMismatch ? "WARNUNG" : "FEHLER")}] Versionskonflikt:"); Console.WriteLine($" --version sagt : {declaredVersion}"); Console.WriteLine($" {Path.GetFileName(assemblyPath)} sagt : {actual}"); Console.WriteLine(); Console.WriteLine(" Wird so veroeffentlicht, meldet die Anwendung nach dem Update weiterhin"); Console.WriteLine(" ihre einkompilierte Version, haelt das Release fuer neu und aktualisiert"); Console.WriteLine(" bei jedem Start erneut - auf allen Installationen."); Console.WriteLine(); Console.WriteLine(" Ueblicher Grund: steht nur in einem der beteiligten Projekte."); Console.WriteLine(" Gehoert in die Directory.Build.props, damit alle denselben Wert tragen."); if (!ignoreMismatch) { Console.WriteLine(" Bewusst gewollt? --ignore-version-mismatch"); } Console.ResetColor(); return ignoreMismatch; } /// /// Sucht die Assembly, deren Version fuer das Release massgeblich ist. /// static string? ResolveMainAssembly(string publishDir, string project, string? overrideName) { if (!string.IsNullOrWhiteSpace(overrideName)) { string candidate = Path.IsPathRooted(overrideName!) ? overrideName! : Path.Combine(publishDir, overrideName!); return File.Exists(candidate) ? candidate : null; } // 1. Gleichnamig zum Projekt-Slug. foreach (string extension in new[] { ".dll", ".exe" }) { string candidate = Path.Combine(publishDir, project + extension); if (File.Exists(candidate)) return candidate; } // 2. Ueber die runtimeconfig.json: sie traegt den Namen der // Startassembly und existiert genau einmal je Anwendung. var runtimeConfigs = Directory.GetFiles(publishDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly); if (runtimeConfigs.Length == 1) { string baseName = Path.GetFileName(runtimeConfigs[0]); baseName = baseName.Substring(0, baseName.Length - ".runtimeconfig.json".Length); foreach (string extension in new[] { ".dll", ".exe" }) { string candidate = Path.Combine(publishDir, baseName + extension); if (File.Exists(candidate)) return candidate; } } return null; } /// /// Liest die Version einer Assembly, ohne sie zu laden. /// ProductVersion entspricht InformationalVersion und damit dem, was /// in der csproj unter <Version> steht. /// static string? ReadAssemblyVersion(string path) { try { var info = FileVersionInfo.GetVersionInfo(path); if (!string.IsNullOrWhiteSpace(info.ProductVersion)) return info.ProductVersion!.Trim(); if (!string.IsNullOrWhiteSpace(info.FileVersion)) return info.FileVersion!.Trim(); } catch { } try { var name = System.Reflection.AssemblyName.GetAssemblyName(path); return name.Version?.ToString(); } catch { } return null; } /// /// Vergleicht nur den numerischen Kern. "1.4.3" und "1.4.3.0" sind /// dieselbe Version; "1.4.3+abc123" ebenso - Build-Metadaten und /// Vorabkennungen sind fuer diese Pruefung ohne Bedeutung. /// static bool VersionCoresMatch(string a, string b) { var coreA = VersionCore(a); var coreB = VersionCore(b); int length = Math.Max(coreA.Count, coreB.Count); for (int i = 0; i < length; i++) { int partA = i < coreA.Count ? coreA[i] : 0; int partB = i < coreB.Count ? coreB[i] : 0; if (partA != partB) return false; } return true; } static List VersionCore(string version) { string value = (version ?? string.Empty).Trim().TrimStart('v', 'V'); int cut = value.IndexOfAny(new[] { '-', '+', ' ' }); if (cut >= 0) value = value.Substring(0, cut); var core = new List(); foreach (string part in value.Split('.')) { string digits = new string(part.Where(char.IsDigit).ToArray()); core.Add(digits.Length > 0 ? int.Parse(digits) : 0); } if (core.Count == 0) core.Add(0); return core; } /// /// Warnt vor Dateien, die nach Zugangsdaten aussehen und weder /// ausgeschlossen noch geschuetzt sind. /// /// Eine settings.json mit Datenbankpasswort und DC-Token, die im /// 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 = { "appsettings*.json", "settings.json", "*.config.json", ".env*", "secrets.json", "connectionstrings.json", "*.pfx", "*.key", "*.pem" }; var hits = new List(); foreach (var file in files) { string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); if (GlobMatcher.IsMatch(relPath, config.PreservePatterns)) continue; if (GlobMatcher.IsMatch(relPath, suspicious)) hits.Add(relPath); } if (hits.Count == 0) return; Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine(); Console.WriteLine("[WARNUNG] Diese Dateien sehen nach Konfiguration oder Zugangsdaten aus, stehen"); Console.WriteLine(" aber weder unter excludePatterns noch unter preservePatterns:"); foreach (var hit in hits) { Console.WriteLine($" - {hit}"); } Console.WriteLine(); Console.WriteLine(" Sie werden mit ausgeliefert UND ueberschreiben beim Update die Fassung"); Console.WriteLine(" auf dem Zielsystem. Entweder in excludePatterns (gar nicht ausliefern)"); Console.WriteLine(" oder in preservePatterns (ausliefern, aber nie ersetzen) aufnehmen."); Console.ResetColor(); Console.WriteLine(); } /// /// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein /// vollstaendiges Modell dafuer zu benoetigen. /// static string? ExtractJsonString(string json, string propertyName) { try { using var doc = JsonDocument.Parse(json); return FindProperty(doc.RootElement, propertyName); } catch { return null; } } static string? FindProperty(JsonElement element, string propertyName) { if (element.ValueKind != JsonValueKind.Object) { return null; } if (element.TryGetProperty(propertyName, out var direct)) { return direct.ValueKind == JsonValueKind.String ? direct.GetString() : direct.ToString(); } // Fehlerantworten verpacken die Nachricht in einem "error"-Objekt. foreach (var child in element.EnumerateObject()) { if (child.Value.ValueKind == JsonValueKind.Object) { string? nested = FindProperty(child.Value, propertyName); if (nested != null) { return nested; } } } return null; } static PackagerConfig LoadConfig(string path) { if (File.Exists(path)) { try { string json = File.ReadAllText(path); var cfg = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (cfg != null) return cfg; } catch (Exception ex) { // Eine unlesbare Konfiguration still zu ignorieren hiesse, // mit leeren Zugangsdaten weiterzumachen und den Nutzer // ueber die Ursache im Unklaren zu lassen. Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[WARNUNG] {Path.GetFileName(path)} ist nicht lesbar: {ex.Message}"); Console.WriteLine(" Es gelten Umgebungsvariablen und CLI-Argumente."); Console.ResetColor(); } } return new PackagerConfig(); } static string ComputeSha256(string file) { using var sha256 = SHA256.Create(); using var stream = File.OpenRead(file); byte[] hash = sha256.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } static string? GetArg(string[] args, string flagLong, string? flagShort = null) { for (int i = 0; i < args.Length - 1; i++) { if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) || (flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase))) { return args[i + 1]; } } return null; } static bool HasFlag(string[] args, string flag) { return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); } static void ShowHelp() { Console.WriteLine(); Console.WriteLine("Aufruf: pack-and-deploy [Optionen]"); Console.WriteLine(); Console.WriteLine(" --project, -p Projekt-Slug im Deploymentcenter"); Console.WriteLine(" --version, -v Zu veroeffentlichende Version"); Console.WriteLine(" --channel, -c prod | beta | dev (Vorgabe: prod)"); Console.WriteLine(" --platform win-x64, linux-x64, ... (Vorgabe: aus dem"); Console.WriteLine(" Publish-Pfad abgeleitet, sonst 'any')"); Console.WriteLine(" --publish-dir, -d Ausgabe von dotnet publish"); Console.WriteLine(" --changelog Aenderungshinweise"); Console.WriteLine(" --critical Als kritisches Update kennzeichnen"); 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"); Console.WriteLine(); Console.WriteLine("Rueckgabewerte: 0 vollstaendig, 1 Konfigurationsfehler, 2 teilweise fehlgeschlagen."); Console.WriteLine(); } static string GetGitCommitLong() { try { var psi = new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false }; using var p = Process.Start(psi); string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? ""; p?.WaitForExit(); if (!string.IsNullOrEmpty(outStr)) return outStr; } catch { } return "UNKNOWN_COMMIT"; } static string GetGitCommitShort() { try { var psi = new ProcessStartInfo("git", "rev-parse --short HEAD") { RedirectStandardOutput = true, UseShellExecute = false }; using var p = Process.Start(psi); string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? ""; p?.WaitForExit(); if (!string.IsNullOrEmpty(outStr)) return outStr; } catch { } return "UNKNOWN"; } } }