Fuenf von sechs Befunden einer externen Integration. Der sechste - unsignierte Lizenzurteile - ist bestaetigt, aber bewusst nicht Teil dieses Commits. 1. Lizenzschluessel stand in der Kommandozeile Der Agent nahm --license-key nur als Argument und las keine Umgebungsvariablen. "ps" zeigt argv jedem Benutzer der Maschine - exakt die Begruendung, mit der UPGRADE.md §5 den Crontab-Weg verwirft. Damit nahm das SDK einen bereits geloesten Sicherheitsbefund zurueck. Der Agent liest jetzt DC_LICENSE_KEY, DC_DOWNLOAD_USER und DC_DOWNLOAD_PASSWORD, Umgebung vor Argument. LaunchUpdateAgent uebergibt den Schluessel nicht mehr als Argument, sondern setzt die Variable auf dem eigenen Prozess: das Kind erbt den Umgebungsblock, danach wird sie wieder entfernt. Das funktioniert auch mit UseShellExecute=true, wo sich ProcessStartInfo.Environment nicht setzen laesst. 2. --wait-timeout war nicht durchgereicht Der Agent kannte den Parameter, LaunchUpdateAgent hatte keinen dafuer - es galten fest 60 Sekunden. Eine Anwendung, die allein fuer host.StopAsync 30 Sekunden braucht, kommt damit gefaehrlich nah an die Grenze. Neu: waitTimeoutSeconds. Ausserdem ist im Quelltext und in der Doku jetzt festgehalten, dass exitCurrentApp:true ueber Environment.Exit(0) laeuft und damit finally-Bloecke und IHostApplicationLifetime uebergeht - bei offenem Zustand die falsche Wahl. 3. ILicensePrompt war tot Der Konstruktor nahm es entgegen, legte es in _prompt ab und benutzte es nirgends. Wer darauf eine headless-Story aufbaute, baute auf Sand. Neu: EnsureLicensedAsync() - zwischengespeicherten Schluessel nehmen, sonst fragen, pruefen, bei Ablehnung erneut fragen. allowPrompt:false lehnt ohne Cache ab, statt auf eine Eingabe zu warten, die im Dienst nie kommt. Ein voruebergehender Netzfehler fuehrt nicht zur erneuten Abfrage - der Schluessel ist ja nicht falsch. 4. Der Packager warnte nur Er bricht jetzt ab. Anlass war ein echter API-Schluessel in einem oeffentlich abrufbaren Paket - und die Warnung war damals ausgerechnet unterdrueckt, weil die Datei auf der preserve-Liste stand. Zwei Stufen: Dateiname (appsettings.Local.json, master.key, *.pfx, *.db, server_settings.xml) und Inhalt (gefuelltes Password=, sk-, ghp_, dc_master_, AKIA, private Schluessel). Die Inhaltspruefung findet auch Dateien mit unverdaechtigem Namen. Platzhalter loesen bewusst nicht aus: "sk-DEIN-SCHLUESSEL-HIER" haette sonst jede ausgelieferte Vorlage blockiert, und --allow-secrets waere nach einer Woche Gewohnheit. Beim Erproben zuerst genau in diese Falle gelaufen. 5. BuildInfo.targets war nur per Pfad-Import zu haben Die Anleitung empfahl einen <Import> ins Nachbar-Repository - das setzt voraus, dass beide Arbeitskopien nebeneinander liegen und in derselben Fassung stehen. Das Client-Projekt ist jetzt packbar und legt das Target unter build/ ins Paket, wo NuGet es selbst importiert. Ausserdem: Unauthorized wurde nur im statischen Zweig erkannt, im API-Zweig kam ein 401 als gewoehnlicher HTTP-Fehler an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1188 lines
52 KiB
C#
1188 lines
52 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Wird geworfen, wenn die vorhandene Versionshistorie nicht sicher
|
|
/// gelesen werden konnte. Dann darf latest.json nicht geschrieben werden.
|
|
/// </summary>
|
|
internal sealed class ReleaseHistoryException : Exception
|
|
{
|
|
public ReleaseHistoryException(string message) : base(message) { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// Token mit dem Recht updateservice:publish. Das Veroeffentlichen eines
|
|
/// Releases ist nicht mehr unauthentifiziert moeglich.
|
|
/// </summary>
|
|
public string ApiToken { get; set; } = "";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public List<string> ExcludePatterns { get; set; } = new List<string>
|
|
{
|
|
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json",
|
|
"*.log", "logs/**", "*.tmp"
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public List<string> PreservePatterns { get; set; } = new List<string>
|
|
{
|
|
"appsettings.json", "appsettings.Production.json", "settings.json",
|
|
"config.json", ".env"
|
|
};
|
|
|
|
/// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary>
|
|
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<int> 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<string>();
|
|
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<string>();
|
|
var preservedRelPaths = new List<string>();
|
|
|
|
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<string>(config.PreservePatterns),
|
|
Files = new List<PackageFileEntry>()
|
|
};
|
|
|
|
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<VersionInfo>()
|
|
};
|
|
|
|
// 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<ReleaseManifest>(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<string, object?>
|
|
{
|
|
["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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 <datei> 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: <Version> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sucht die Assembly, deren Version fuer das Release massgeblich ist.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest die Version einer Assembly, ohne sie zu laden.
|
|
/// ProductVersion entspricht InformationalVersion und damit dem, was
|
|
/// in der csproj unter <Version> steht.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<int> 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<int>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
static bool CheckForSecrets(List<string> files, string publishDir, PackagerConfig config, bool allowSecrets)
|
|
{
|
|
var byName = new List<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Erkennt Vorlagenwerte, damit eine ausgelieferte Musterdatei nicht
|
|
/// jeden Build blockiert.
|
|
/// </summary>
|
|
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<string> 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<string>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein
|
|
/// vollstaendiges Modell dafuer zu benoetigen.
|
|
/// </summary>
|
|
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<PackagerConfig>(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 <slug> Projekt-Slug im Deploymentcenter");
|
|
Console.WriteLine(" --version, -v <version> Zu veroeffentlichende Version");
|
|
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev (Vorgabe: prod)");
|
|
Console.WriteLine(" --platform <rid> win-x64, linux-x64, ... (Vorgabe: aus dem");
|
|
Console.WriteLine(" Publish-Pfad abgeleitet, sonst 'any')");
|
|
Console.WriteLine(" --publish-dir, -d <pfad> Ausgabe von dotnet publish");
|
|
Console.WriteLine(" --changelog <text> Aenderungshinweise");
|
|
Console.WriteLine(" --critical Als kritisches Update kennzeichnen");
|
|
Console.WriteLine(" --main-assembly <datei> 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 <datei> Abweichende packager.config.json");
|
|
Console.WriteLine(" --token <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";
|
|
}
|
|
}
|
|
}
|