commit ed8e2822d1c544f996a4b0d34cb94c572394f8dc Author: Mike Date: Fri Jul 24 08:10:13 2026 +0200 Initial commit: ESB Certificate Manager. EOF Co-authored-by: Cursor diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b03957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,367 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Lokale Offline-Deploy-/Log-Artefakte der App +DeploySandbox/ +**/DeploySandbox/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/ZA.CoreService.ESBCertificateManager.sln b/ZA.CoreService.ESBCertificateManager.sln new file mode 100644 index 0000000..39b04f4 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZA.CoreService.ESBCertificateManager", "ZA.CoreService.ESBCertificateManager\ZA.CoreService.ESBCertificateManager.csproj", "{4F3F9601-BBE8-4A11-8DF5-716911179223}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4F3F9601-BBE8-4A11-8DF5-716911179223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F3F9601-BBE8-4A11-8DF5-716911179223}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F3F9601-BBE8-4A11-8DF5-716911179223}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F3F9601-BBE8-4A11-8DF5-716911179223}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {AB798282-A87B-47F6-A9F1-F0CE0851167D} + EndGlobalSection +EndGlobal diff --git a/ZA.CoreService.ESBCertificateManager/Assets/TestCertificates/esb-test-cert.cer b/ZA.CoreService.ESBCertificateManager/Assets/TestCertificates/esb-test-cert.cer new file mode 100644 index 0000000..157aad8 Binary files /dev/null and b/ZA.CoreService.ESBCertificateManager/Assets/TestCertificates/esb-test-cert.cer differ diff --git a/ZA.CoreService.ESBCertificateManager/Assets/logo.png b/ZA.CoreService.ESBCertificateManager/Assets/logo.png new file mode 100644 index 0000000..1b85c62 Binary files /dev/null and b/ZA.CoreService.ESBCertificateManager/Assets/logo.png differ diff --git a/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs b/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs new file mode 100644 index 0000000..ee4dae1 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Configuration; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Configuration; + +public static class AppSettingsLoader +{ + public static AppSettings Load() + { + string basePath = AppContext.BaseDirectory; + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .Build(); + + AppSettings settings = new(); + configuration.Bind(settings); + return settings; + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Data/ITargetRepository.cs b/ZA.CoreService.ESBCertificateManager/Data/ITargetRepository.cs new file mode 100644 index 0000000..3850ce9 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Data/ITargetRepository.cs @@ -0,0 +1,9 @@ +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Data; + +public interface ITargetRepository +{ + Task> GetActiveTargetsAsync(CancellationToken cancellationToken = default); + string SourceDescription { get; } +} diff --git a/ZA.CoreService.ESBCertificateManager/Data/JsonTargetRepository.cs b/ZA.CoreService.ESBCertificateManager/Data/JsonTargetRepository.cs new file mode 100644 index 0000000..763f19e --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Data/JsonTargetRepository.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Data; + +public sealed class JsonTargetRepository : ITargetRepository +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly string _filePath; + + public JsonTargetRepository(string filePath) + { + _filePath = filePath; + } + + public string SourceDescription => $"Offline-Sample ({Path.GetFileName(_filePath)})"; + + public async Task> GetActiveTargetsAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(_filePath)) + { + throw new FileNotFoundException( + $"Sample-Zieldatei wurde nicht gefunden: {_filePath}", + _filePath); + } + + await using FileStream stream = File.OpenRead(_filePath); + List? targets = await JsonSerializer.DeserializeAsync>( + stream, + JsonOptions, + cancellationToken); + + return (targets ?? []) + .Where(t => t.IsActive) + .OrderBy(t => t.SortOrder) + .ThenBy(t => t.Name) + .ToList(); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Data/SqlRunLogger.cs b/ZA.CoreService.ESBCertificateManager/Data/SqlRunLogger.cs new file mode 100644 index 0000000..f82d6d3 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Data/SqlRunLogger.cs @@ -0,0 +1,167 @@ +using Microsoft.Data.SqlClient; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Data; + +/// +/// Schreibt Deployment-Lauf-Ergebnisse in die SQL-Datenbank. +/// Tabellen: dbo.DeploymentRuns (GUID-PK) + dbo.DeploymentTargetResults. +/// Ist kein ConnectionString konfiguriert, werden alle Operationen still übersprungen. +/// +public sealed class SqlRunLogger +{ + private readonly string? _connectionString; + + public SqlRunLogger(string? connectionString) + { + _connectionString = string.IsNullOrWhiteSpace(connectionString) + ? null + : connectionString; + } + + public bool IsEnabled => _connectionString is not null; + + /// + /// Legt einen neuen Lauf-Datensatz an. + /// + public async Task BeginRunAsync( + Guid runId, + DateTimeOffset startedAt, + string certificateFilePath, + string certificateFingerprint, + CancellationToken cancellationToken = default) + { + if (_connectionString is null) return; + + const string sql = """ + INSERT INTO dbo.DeploymentRuns + (Id, StartedAtUtc, SourceFile, SourceFingerprint, StartedBy, MachineName, OverallStatus) + VALUES + (@Id, @StartedAtUtc, @SourceFile, @SourceFingerprint, @StartedBy, @MachineName, N'Running'); + """; + + await using SqlConnection conn = new(_connectionString); + await conn.OpenAsync(cancellationToken); + await using SqlCommand cmd = new(sql, conn); + + cmd.Parameters.AddWithValue("@Id", runId); + cmd.Parameters.AddWithValue("@StartedAtUtc", startedAt.UtcDateTime); + cmd.Parameters.AddWithValue("@SourceFile", Path.GetFileName(certificateFilePath)); + cmd.Parameters.AddWithValue("@SourceFingerprint", certificateFingerprint); + cmd.Parameters.AddWithValue("@StartedBy", Environment.UserName); + cmd.Parameters.AddWithValue("@MachineName", Environment.MachineName); + + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + /// + /// Aktualisiert den Lauf-Datensatz mit Endzeitpunkt und Gesamtstatus. + /// + public async Task CompleteRunAsync( + Guid runId, + DateTimeOffset finishedAt, + bool overallSuccess, + int successCount, + int totalCount, + CancellationToken cancellationToken = default) + { + if (_connectionString is null) return; + + string status = overallSuccess + ? "Success" + : (successCount > 0 ? "PartialFailure" : "Failure"); + + const string sql = """ + UPDATE dbo.DeploymentRuns + SET FinishedAtUtc = @FinishedAtUtc, + OverallStatus = @OverallStatus + WHERE Id = @Id; + """; + + await using SqlConnection conn = new(_connectionString); + await conn.OpenAsync(cancellationToken); + await using SqlCommand cmd = new(sql, conn); + + cmd.Parameters.AddWithValue("@Id", runId); + cmd.Parameters.AddWithValue("@FinishedAtUtc", finishedAt.UtcDateTime); + cmd.Parameters.AddWithValue("@OverallStatus", status); + + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + /// + /// Schreibt das Ergebnis eines einzelnen Deployment-Ziels. + /// + public async Task WriteTargetResultAsync( + Guid runId, + TargetStepResult step, + CancellationToken cancellationToken = default) + { + if (_connectionString is null) return; + + string status = step.Success ? "Success" : "Failure"; + + const string sql = """ + INSERT INTO dbo.DeploymentTargetResults + (DeploymentRunId, TargetId, TargetName, + StartedAtUtc, FinishedAtUtc, + CopySucceeded, RestartSucceeded, TlsSucceeded, + ObservedFingerprint, Status, ErrorMessage) + VALUES + (@RunId, @TargetId, @TargetName, + @StartedAtUtc, @FinishedAtUtc, + @CopySucceeded, @RestartSucceeded, @TlsSucceeded, + @ObservedFingerprint, @Status, @ErrorMessage); + """; + + await using SqlConnection conn = new(_connectionString); + await conn.OpenAsync(cancellationToken); + await using SqlCommand cmd = new(sql, conn); + + cmd.Parameters.AddWithValue("@RunId", runId); + cmd.Parameters.AddWithValue("@TargetId", step.TargetId); + cmd.Parameters.AddWithValue("@TargetName", step.TargetName); + cmd.Parameters.AddWithValue("@StartedAtUtc", step.StartedAt.UtcDateTime); + cmd.Parameters.AddWithValue("@FinishedAtUtc", step.FinishedAt.UtcDateTime); + cmd.Parameters.AddWithValue("@CopySucceeded", step.CopySucceeded); + cmd.Parameters.AddWithValue("@RestartSucceeded", step.RestartSucceeded); + cmd.Parameters.AddWithValue("@TlsSucceeded", step.TlsSucceeded); + cmd.Parameters.AddWithValue("@ObservedFingerprint", (object?)step.ObservedFingerprint ?? DBNull.Value); + cmd.Parameters.AddWithValue("@Status", status); + cmd.Parameters.AddWithValue("@ErrorMessage", step.Success ? DBNull.Value : (object?)(step.Detail ?? step.StatusText)); + + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + /// + /// Persistiert ein vollständiges in einem einzigen Aufruf. + /// + public async Task PersistRunResultAsync( + DeploymentRunResult runResult, + CancellationToken cancellationToken = default) + { + if (_connectionString is null) return; + + await BeginRunAsync( + runResult.RunId, + runResult.StartedAt, + runResult.CertificateFilePath, + runResult.CertificateFingerprint, + cancellationToken); + + foreach (TargetStepResult step in runResult.TargetResults) + { + await WriteTargetResultAsync(runResult.RunId, step, cancellationToken); + } + + int successCount = runResult.TargetResults.Count(r => r.Success); + + await CompleteRunAsync( + runResult.RunId, + runResult.FinishedAt, + runResult.OverallSuccess, + successCount, + runResult.TargetResults.Count, + cancellationToken); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Data/SqlTargetRepository.cs b/ZA.CoreService.ESBCertificateManager/Data/SqlTargetRepository.cs new file mode 100644 index 0000000..ba9c1fa --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Data/SqlTargetRepository.cs @@ -0,0 +1,111 @@ +using Microsoft.Data.SqlClient; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Data; + +public sealed class SqlTargetRepository : ITargetRepository +{ + private readonly string _connectionString; + + public SqlTargetRepository(string connectionString) + { + _connectionString = connectionString; + } + + public string SourceDescription => "SQL Server (dbo.DeploymentTargets)"; + + public async Task> GetActiveTargetsAsync( + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_connectionString)) + { + throw new InvalidOperationException("Es ist keine SQL-Verbindungszeichenfolge konfiguriert."); + } + + const string sql = """ + SELECT + Id, + Name, + Environment, + IsActive, + CertificateTargetPath, + CertificateFileName, + ContainerName, + RestartType, + RestartCommand, + RestartArguments, + RestartTimeoutSeconds, + SonicConnectionName, + XapiSourcePath, + TlsHost, + TlsPort, + TlsServerName, + ExpectedFingerprint, + SortOrder + FROM dbo.DeploymentTargets + WHERE IsActive = 1 + ORDER BY SortOrder, Name; + """; + + List targets = []; + + await using SqlConnection connection = new(_connectionString); + await connection.OpenAsync(cancellationToken); + + await using SqlCommand command = new(sql, connection); + await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + + int id = reader.GetOrdinal("Id"); + int name = reader.GetOrdinal("Name"); + int environment = reader.GetOrdinal("Environment"); + int isActive = reader.GetOrdinal("IsActive"); + int certificateTargetPath = reader.GetOrdinal("CertificateTargetPath"); + int certificateFileName = reader.GetOrdinal("CertificateFileName"); + int containerName = reader.GetOrdinal("ContainerName"); + int restartType = reader.GetOrdinal("RestartType"); + int restartCommand = reader.GetOrdinal("RestartCommand"); + int restartArguments = reader.GetOrdinal("RestartArguments"); + int restartTimeoutSeconds = reader.GetOrdinal("RestartTimeoutSeconds"); + int sonicConnectionName = reader.GetOrdinal("SonicConnectionName"); + int xapiSourcePath = reader.GetOrdinal("XapiSourcePath"); + int tlsHost = reader.GetOrdinal("TlsHost"); + int tlsPort = reader.GetOrdinal("TlsPort"); + int tlsServerName = reader.GetOrdinal("TlsServerName"); + int expectedFingerprint = reader.GetOrdinal("ExpectedFingerprint"); + int sortOrder = reader.GetOrdinal("SortOrder"); + + while (await reader.ReadAsync(cancellationToken)) + { + RestartType parsedRestartType = Enum.TryParse( + reader.GetString(restartType), + ignoreCase: true, + out RestartType parsed) + ? parsed + : RestartType.None; + + targets.Add(new DeploymentTarget + { + Id = reader.GetInt32(id), + Name = reader.GetString(name), + Environment = reader.GetString(environment), + IsActive = reader.GetBoolean(isActive), + TargetDirectory = reader.GetString(certificateTargetPath), + CertificateFileName = reader.GetString(certificateFileName), + ContainerName = reader.GetString(containerName), + RestartType = parsedRestartType, + RestartCommand = reader.IsDBNull(restartCommand) ? string.Empty : reader.GetString(restartCommand), + RestartArguments = reader.GetString(restartArguments), + RestartTimeoutSeconds = reader.GetInt32(restartTimeoutSeconds), + SonicConnectionName = reader.GetString(sonicConnectionName), + XapiSourcePath = reader.GetString(xapiSourcePath), + TlsHost = reader.GetString(tlsHost), + TlsPort = reader.IsDBNull(tlsPort) ? null : reader.GetInt32(tlsPort), + TlsServerName = reader.GetString(tlsServerName), + ExpectedFingerprint = reader.IsDBNull(expectedFingerprint) ? null : reader.GetString(expectedFingerprint), + SortOrder = reader.GetInt32(sortOrder) + }); + } + + return targets; + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Data/TargetRepositoryFactory.cs b/ZA.CoreService.ESBCertificateManager/Data/TargetRepositoryFactory.cs new file mode 100644 index 0000000..4ca5f84 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Data/TargetRepositoryFactory.cs @@ -0,0 +1,44 @@ +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Data; + +public static class TargetRepositoryFactory +{ + public static async Task<(ITargetRepository Repository, string LoadMessage)> CreateAsync( + AppSettings settings, + CancellationToken cancellationToken = default) + { + string samplePath = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, settings.SampleTargetsPath)); + + if (settings.UseOfflineSampleData) + { + ITargetRepository jsonRepo = new JsonTargetRepository(samplePath); + _ = await jsonRepo.GetActiveTargetsAsync(cancellationToken); + return (jsonRepo, $"Ziele geladen aus {jsonRepo.SourceDescription}."); + } + + if (string.IsNullOrWhiteSpace(settings.ConnectionString)) + { + return OfflineSample(samplePath, "Keine SQL-Verbindung konfiguriert – Offline-Sample wird verwendet."); + } + + try + { + SqlTargetRepository sqlRepo = new(settings.ConnectionString); + _ = await sqlRepo.GetActiveTargetsAsync(cancellationToken); + return (sqlRepo, $"Ziele geladen aus {sqlRepo.SourceDescription}."); + } + catch (Exception ex) + { + return OfflineSample( + samplePath, + $"SQL nicht erreichbar ({ex.Message}). Offline-Sample wird verwendet."); + } + } + + private static (ITargetRepository Repository, string LoadMessage) OfflineSample( + string samplePath, + string message) + => (new JsonTargetRepository(samplePath), message); +} diff --git a/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json b/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json new file mode 100644 index 0000000..0676594 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json @@ -0,0 +1,82 @@ +[ + { + "Id": 1, + "Name": "ESB Local A", + "Environment": "DEV", + "IsActive": true, + "TargetDirectory": "DeploySandbox/target-a", + "CertificateFileName": "esb-cert.cer", + "ContainerName": "sonic-container-a", + "RestartType": "None", + "RestartCommand": "", + "RestartArguments": "", + "RestartTimeoutSeconds": 30, + "SonicConnectionName": "", + "XapiSourcePath": "", + "TlsHost": "", + "TlsPort": null, + "TlsServerName": "", + "ExpectedFingerprint": null, + "SortOrder": 10 + }, + { + "Id": 2, + "Name": "ESB Local B", + "Environment": "DEV", + "IsActive": true, + "TargetDirectory": "DeploySandbox/target-b", + "CertificateFileName": "esb-cert.cer", + "ContainerName": "sonic-container-b", + "RestartType": "Command", + "RestartCommand": "cmd.exe", + "RestartArguments": "/c echo Restart simulated for sonic-container-b", + "RestartTimeoutSeconds": 15, + "SonicConnectionName": "", + "XapiSourcePath": "", + "TlsHost": "", + "TlsPort": null, + "TlsServerName": "", + "ExpectedFingerprint": null, + "SortOrder": 20 + }, + { + "Id": 3, + "Name": "DE-Test Container A (Sonic)", + "Environment": "TEST", + "IsActive": false, + "TargetDirectory": "DeploySandbox/target-c", + "CertificateFileName": "esb-cert.cer", + "ContainerName": "sonic-container-a", + "RestartType": "SonicContainer", + "RestartCommand": "", + "RestartArguments": "", + "RestartTimeoutSeconds": 60, + "SonicConnectionName": "DE-Test", + "XapiSourcePath": "", + "TlsHost": "dekun-painwbdet", + "TlsPort": 443, + "TlsServerName": "esb-test.firma.local", + "ExpectedFingerprint": null, + "SortOrder": 30 + }, + { + "Id": 4, + "Name": "DE-Test Container B (Sonic + XApi)", + "Environment": "TEST", + "IsActive": false, + "TargetDirectory": "DeploySandbox/target-d", + "CertificateFileName": "esb-cert.cer", + "ContainerName": "sonic-container-b", + "RestartType": "SonicContainerWithXapi", + "RestartCommand": "", + "RestartArguments": "", + "RestartTimeoutSeconds": 60, + "SonicConnectionName": "DE-Test", + "XapiSourcePath": "Assets/xapi-resources.xml", + "TlsHost": "dekun-painwbdet", + "TlsPort": 443, + "TlsServerName": "esb-test.firma.local", + "ExpectedFingerprint": null, + "SortOrder": 40 + } +] diff --git a/ZA.CoreService.ESBCertificateManager/Demo/Central/esb-cert.cer b/ZA.CoreService.ESBCertificateManager/Demo/Central/esb-cert.cer new file mode 100644 index 0000000..bdf05db Binary files /dev/null and b/ZA.CoreService.ESBCertificateManager/Demo/Central/esb-cert.cer differ diff --git a/ZA.CoreService.ESBCertificateManager/Form1.Designer.cs b/ZA.CoreService.ESBCertificateManager/Form1.Designer.cs new file mode 100644 index 0000000..ebcbc8c --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Form1.Designer.cs @@ -0,0 +1,37 @@ +namespace ZA.CoreService.ESBCertificateManager +{ + partial class Form1 + { + private System.ComponentModel.IContainer components = null; + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + SuspendLayout(); + // + // Form1 + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(800, 450); + Name = "Form1"; + Text = "Form1"; + ResumeLayout(false); + } + + #endregion + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Form1.cs b/ZA.CoreService.ESBCertificateManager/Form1.cs new file mode 100644 index 0000000..bce8aae --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Form1.cs @@ -0,0 +1,1794 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using ZA.CoreService.ESBCertificateManager.Configuration; +using ZA.CoreService.ESBCertificateManager.Data; +using ZA.CoreService.ESBCertificateManager.Models; +using ZA.CoreService.ESBCertificateManager.Services; + +namespace ZA.CoreService.ESBCertificateManager +{ + public partial class Form1 : Form + { + // --------------------------------------------------------- + // ZIEHL-ABEGG-inspirierte Dark-Mode-Farbpalette + // --------------------------------------------------------- + + // Große Hintergrundflächen + private readonly Color BackgroundColor = Color.FromArgb(10, 18, 34); // #0A1222 + private readonly Color SidebarColor = Color.FromArgb(7, 23, 45); // #07172D + private readonly Color CardColor = Color.FromArgb(16, 38, 65); // #102641 + private readonly Color CardHoverColor = Color.FromArgb(23, 55, 94); // #17375E + private readonly Color BorderColor = Color.FromArgb(36, 74, 117); // #244A75 + + // ZIEHL-ABEGG-nahe Markenakzente + private readonly Color BlueColor = Color.FromArgb(0, 110, 182); // #006EB6 + private readonly Color BrightBlueColor = Color.FromArgb(0, 139, 210); // #008BD2 + private readonly Color GoldColor = Color.FromArgb(208, 171, 57); // #D0AB39 + private readonly Color LightGoldColor = Color.FromArgb(226, 196, 93); // #E2C45D + + // Schrift und Statusfarben + private readonly Color TextColor = Color.FromArgb(241, 245, 249); // #F1F5F9 + private readonly Color MutedTextColor = Color.FromArgb(169, 184, 200); // #A9B8C8 + private readonly Color GreenColor = Color.FromArgb(60, 203, 127); // #3CCB7F + private readonly Color RedColor = Color.FromArgb(239, 106, 106); // #EF6A6A + + private readonly AppSettings _settings; + private readonly DeploymentOrchestrator _orchestrator; + private readonly SonicContainerDiscovery _sonicDiscovery; + + private Label lblStatus = null!; + private Label lblStatusDot = null!; + private TextBox txtCertificatePath = null!; + private DataGridView dgvTargets = null!; + private ComboBox cmbSonicConnection = null!; + private Button btnLoadFromSonic = null!; + private Label lblSonicStatus = null!; + + private Panel navProgressPanel = null!; + + private Panel titleBar = null!; + private Button btnMinimize = null!; + private Button btnMaximize = null!; + private Button btnClose = null!; + private Button btnValidate = null!; + private Button btnDeploy = null!; + private Button btnCancelRun = null!; + + private bool isMaximized = false; + private Point dragStartPoint; + + private Label lblCertificateSubject = null!; + private Label lblCertificateIssuer = null!; + private Label lblCertificateExpiry = null!; + private Label lblCertificateFingerprint = null!; + private Label lblCertificateValidity = null!; + + private Button btnSelectFile = null!; + private bool isCertificateFileLoaded; + private CertificateInfo? _loadedCertificateInfo; + private IReadOnlyList _loadedTargets = []; + private bool _isOperationRunning; + private CancellationTokenSource? _runCts; + private int nvbarlaststate = 1; + + public Form1() + { + InitializeComponent(); + + _settings = AppSettingsLoader.Load(); + _orchestrator = new DeploymentOrchestrator(_settings); + _sonicDiscovery = new SonicContainerDiscovery(_settings.SonicConnections); + + BuildDesign(); + Shown += async (_, _) => await LoadTargetsAsync(); + } + private Button CreateWindowButton(string text) + { + return new Button + { + Text = text, + Width = 46, + Height = 34, + FlatStyle = FlatStyle.Flat, + FlatAppearance = + { + BorderSize = 0, + MouseOverBackColor = CardHoverColor + }, + BackColor = SidebarColor, + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 11), + Cursor = Cursors.Hand, + TabStop = false + }; + } + + private void ToggleMaximizeWindow() + { + if (isMaximized) + { + WindowState = FormWindowState.Normal; + isMaximized = false; + } + else + { + WindowState = FormWindowState.Maximized; + isMaximized = true; + } + } + + private void TitleBar_MouseDown(object? sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + dragStartPoint = e.Location; + } + } + + private void TitleBar_MouseMove(object? sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + Left += e.X - dragStartPoint.X; + Top += e.Y - dragStartPoint.Y; + } + } + private Panel BuildTitleBar() + { + Panel panel = new Panel + { + Height = 34, + BackColor = SidebarColor + }; + + Label appIcon = new Label + { + Text = "◆", + ForeColor = GoldColor, + Font = new Font("Segoe UI Symbol", 12, FontStyle.Bold), + AutoSize = true, + Location = new Point(12, 8) + }; + + Label appName = new Label + { + Text = "ESB Certificate Manager", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 9), + AutoSize = true, + Location = new Point(31, 9) + }; + + btnClose = CreateWindowButton("×"); + btnMaximize = CreateWindowButton("□"); + btnMinimize = CreateWindowButton("−"); + + btnClose.ForeColor = TextColor; + btnClose.FlatAppearance.MouseOverBackColor = Color.FromArgb(190, 35, 45); + + btnClose.Click += (_, _) => Close(); + + btnMinimize.Click += (_, _) => + { + WindowState = FormWindowState.Minimized; + }; + + btnMaximize.Click += (_, _) => + { + ToggleMaximizeWindow(); + }; + + panel.Resize += (_, _) => + { + btnClose.Left = panel.Width - btnClose.Width; + btnMaximize.Left = btnClose.Left - btnMaximize.Width; + btnMinimize.Left = btnMaximize.Left - btnMinimize.Width; + }; + + panel.MouseDown += TitleBar_MouseDown; + panel.MouseMove += TitleBar_MouseMove; + appIcon.MouseDown += TitleBar_MouseDown; + appIcon.MouseMove += TitleBar_MouseMove; + appName.MouseDown += TitleBar_MouseDown; + appName.MouseMove += TitleBar_MouseMove; + + panel.Controls.Add(appIcon); + panel.Controls.Add(appName); + panel.Controls.Add(btnMinimize); + panel.Controls.Add(btnMaximize); + panel.Controls.Add(btnClose); + + return panel; + } + private void BuildDesign() + { + Text = "ESB Certificate Manager"; + StartPosition = FormStartPosition.CenterScreen; + + MinimumSize = new Size(1100, 700); + Size = new Size(1400, 850); + + BackColor = BackgroundColor; + Font = new Font("Segoe UI", 10); + ForeColor = TextColor; + + // Entfernt die weiße Windows-Standardtitelleiste. + FormBorderStyle = FormBorderStyle.None; + + // Eigene dunkle Titelleiste. + titleBar = BuildTitleBar(); + titleBar.Dock = DockStyle.Top; + Controls.Add(titleBar); + + Panel sidebar = new Panel + { + Dock = DockStyle.Left, + Width = 205, + BackColor = SidebarColor + }; + + Panel content = new Panel + { + Dock = DockStyle.Fill, + BackColor = BackgroundColor, + Padding = new Padding(38, 48, 38, 34), + AutoScroll = true + }; + + // Wichtig für Docking: + // Erst der Fill-Bereich, dann die feste Sidebar. + Controls.Add(content); + Controls.Add(sidebar); + + // Dezente vertikale Linie rechts an der Sidebar. + Panel sidebarBorder = new Panel + { + Dock = DockStyle.Right, + Width = 1, + BackColor = BorderColor + }; + + sidebar.Controls.Add(sidebarBorder); + + BuildSidebar(sidebar); + BuildContent(content); + } + + private void BuildSidebar(Panel sidebar) + { + + // ------------------------- + // 1. Logo / Produktbereich + // ------------------------- + Panel logoPanel = new Panel + { + Dock = DockStyle.Top, + Height = 165, + Padding = new Padding(18, 18, 18, 8) + }; + + PictureBox pictureLogo = new PictureBox + { + Location = new Point(18, 14), + Size = new Size(165, 58), + SizeMode = PictureBoxSizeMode.Zoom, + BackColor = Color.Transparent + }; + + string logoPath = Path.Combine( + Application.StartupPath, + "Assets", + "logo.png"); + + if (File.Exists(logoPath)) + { + pictureLogo.Image = Image.FromFile(logoPath); + } + + Label logoSubtitle = new Label + { + Text = "ESB CERTIFICATE MANAGER", + ForeColor = TextColor, + Font = new Font("Segoe UI", 8, FontStyle.Bold), + AutoSize = true, + Location = new Point(18, 88) + }; + + + Panel logoSeparator = new Panel + { + BackColor = BorderColor, + Location = new Point(22, 138), + Size = new Size(168, 1) + }; + + logoPanel.Controls.Add(pictureLogo); + logoPanel.Controls.Add(logoSubtitle); + logoPanel.Controls.Add(logoSeparator); + + // ------------------------------------ + // 2. Linker Ablauf: keine Navigation, + // sondern Fortschritt im Deployment + // ------------------------------------ + Panel progressPanel = new Panel + { + Dock = DockStyle.Top, + Height = 360, + Padding = new Padding(18, 8, 18, 8) + }; + navProgressPanel = progressPanel; + + Label progressTitle = new Label + { + Text = "DEPLOYMENT-ABLAUF", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 7.5f, FontStyle.Bold), + AutoSize = true, + Location = new Point(18, 12) + }; + + AddStep( + progressPanel, + y: 48, + number: "1", + title: "Zertifikat", + description: "Datei auswählen", + isActive: true, + hasNextStep: true); + + AddStep( + progressPanel, + y: 116, + number: "2", + title: "Ziele", + description: "Systeme auswählen", + isActive: false, + hasNextStep: true); + + AddStep( + progressPanel, + y: 184, + number: "3", + title: "Bereitstellen", + description: "Kopieren & Neustart", + isActive: false, + hasNextStep: true); + + AddStep( + progressPanel, + y: 252, + number: "4", + title: "TLS-Prüfung", + description: "Zertifikat bestätigen", + isActive: false, + hasNextStep: false); + + progressPanel.Controls.Add(progressTitle); + + // ----------------------------- + // 3. Footer unten: Version + // ----------------------------- + Panel footerPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 70, + Padding = new Padding(18, 8, 12, 8) + }; + + Panel footerSeparator = new Panel + { + Dock = DockStyle.Top, + Height = 1, + BackColor = BorderColor + }; + + Label versionLabel = new Label + { + Text = "INTERNAL TOOL", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 7.5f, FontStyle.Bold), + AutoSize = true, + Location = new Point(18, 17) + }; + + Label versionNumber = new Label + { + Text = "Version 0.1.0", + ForeColor = BlueColor, + Font = new Font("Segoe UI", 8), + AutoSize = true, + Location = new Point(18, 37) + }; + + footerPanel.Controls.Add(footerSeparator); + footerPanel.Controls.Add(versionLabel); + footerPanel.Controls.Add(versionNumber); + + // Dock-Reihenfolge: Bottom, Top, Top + sidebar.Controls.Add(footerPanel); + sidebar.Controls.Add(progressPanel); + sidebar.Controls.Add(logoPanel); + } + + private void AddStep( + Panel parent, + int y, + string number, + string title, + string description, + bool isActive, + bool hasNextStep) + { + Color stepColor = isActive ? GoldColor : MutedTextColor; + Color titleColor = isActive ? TextColor : MutedTextColor; + + Label circle = new Label + { + Name = $"stepCircle{number}", + Text = number, + Location = new Point(18, y), + Size = new Size(25, 25), + TextAlign = ContentAlignment.MiddleCenter, + BackColor = isActive ? GoldColor : Color.FromArgb(25, 48, 76), + ForeColor = isActive ? SidebarColor : MutedTextColor, + Font = new Font("Segoe UI", 8, FontStyle.Bold) + }; + + Label titleLabel = new Label + { + Name = $"stepTitle{number}", + Text = title, + ForeColor = titleColor, + Font = new Font("Segoe UI", 9.5f, FontStyle.Bold), + AutoSize = true, + Location = new Point(56, y - 1) + }; + + Label descriptionLabel = new Label + { + Name = $"stepDescription{number}", + Text = description, + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 8), + AutoSize = true, + Location = new Point(56, y + 16) + }; + + parent.Controls.Add(circle); + parent.Controls.Add(titleLabel); + parent.Controls.Add(descriptionLabel); + + if (hasNextStep) + { + Panel line = new Panel + { + BackColor = BorderColor, + Location = new Point(30, y + 26), + Size = new Size(1, 40) + }; + + parent.Controls.Add(line); + line.SendToBack(); + } + } + private void UpdateToNextStep(int state) + { + + Control[] foundCircles = navProgressPanel.Controls.Find($"stepCircle{state}", true); + if (foundCircles.Length == 0) return; + Control[] foundTitels = navProgressPanel.Controls.Find($"stepTitle{state}", true); + if (foundTitels.Length == 0) return; + Label titel = (Label)foundTitels[0]; + Label circle = (Label)foundCircles[0]; + + + Control[] foundlastTitels = navProgressPanel.Controls.Find($"stepTitle{nvbarlaststate}", true); + if (foundlastTitels.Length == 0) return; + Label lasttitel = (Label)foundlastTitels[0]; + if(nvbarlaststate > state) + { + Control[] foundlastCircles = navProgressPanel.Controls.Find($"stepCircle{nvbarlaststate}", true); + if (foundlastCircles.Length == 0) return; + Label lastcircle = (Label)foundlastCircles[0]; + lastcircle.BackColor = Color.FromArgb(25, 48, 76); + lastcircle.ForeColor = MutedTextColor; + lasttitel.ForeColor = MutedTextColor; + } + else + { + lasttitel.ForeColor = MutedTextColor; + } + + + circle.BackColor = GoldColor; + circle.ForeColor = SidebarColor; + titel.ForeColor = TextColor; + nvbarlaststate = state; + } + + private Button CreateNavButton(string text, bool isActive) + { + Button button = new Button + { + Text = text, + Height = 43, + Dock = DockStyle.Top, + FlatStyle = FlatStyle.Flat, + FlatAppearance = + { + BorderSize = 0, + MouseOverBackColor = Color.FromArgb(37, 52, 74) + }, + BackColor = isActive ? Color.FromArgb(35, 58, 92) : SidebarColor, + ForeColor = isActive ? TextColor : MutedTextColor, + Font = new Font("Segoe UI", 10, isActive ? FontStyle.Bold : FontStyle.Regular), + TextAlign = ContentAlignment.MiddleLeft, + Padding = new Padding(14, 0, 0, 0), + Cursor = Cursors.Hand + }; + + button.Click += (_, _) => + { + SetStatus($"Navigation gewählt: {text.Trim()}", isError: false); + }; + + return button; + } + + private void BuildContent(Panel content) + { + // Status unten zuerst hinzufügen, weil Dock-Reihenfolge relevant ist + Panel statusBar = BuildStatusBar(); + statusBar.Dock = DockStyle.Bottom; + content.Controls.Add(statusBar); + + Panel actionPanel = BuildActionPanel(); + actionPanel.Dock = DockStyle.Bottom; + actionPanel.Height = 65; + content.Controls.Add(actionPanel); + + Panel targetsCard = BuildTargetsCard(); + targetsCard.Dock = DockStyle.Top; + targetsCard.Height = 380; + targetsCard.Margin = new Padding(0, 18, 0, 0); + content.Controls.Add(targetsCard); + + Panel topCards = BuildTopCards(); + topCards.Dock = DockStyle.Top; + topCards.Height = 225; + topCards.Margin = new Padding(0, 20, 0, 0); + content.Controls.Add(topCards); + + Panel header = BuildHeader(); + header.Dock = DockStyle.Top; + header.Height = 87; + content.Controls.Add(header); + } + + private Panel BuildHeader() + { + Panel header = new Panel + { + BackColor = BackgroundColor + }; + + Label title = new Label + { + Text = "Zertifikat bereitstellen", + ForeColor = TextColor, + Font = new Font("Segoe UI", 23, FontStyle.Bold), + AutoSize = true, + Location = new Point(0, 0) + }; + + Label subtitle = new Label + { + Text = "Verteile ein Zertifikat auf konfigurierte Ziele und prüfe anschließend die TLS-Verbindung.", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 10), + AutoSize = true, + Location = new Point(3, 43) + }; + + Label environmentBadge = new Label + { + Text = " UMGEBUNG ", + ForeColor = GoldColor, + BackColor = Color.FromArgb(65, 53, 17), + Font = new Font("Segoe UI", 8, FontStyle.Bold), + AutoSize = true, + Padding = new Padding(7, 6, 7, 6), + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + + environmentBadge.Location = new Point(900, 9); + + header.Resize += (_, _) => + { + environmentBadge.Left = header.Width - environmentBadge.Width; + }; + + header.Controls.Add(title); + header.Controls.Add(subtitle); + header.Controls.Add(environmentBadge); + + return header; + } + + private Panel BuildTopCards() + { + Panel container = new Panel + { + BackColor = BackgroundColor + }; + + Panel sourceCard = CreateCard(); + sourceCard.Dock = DockStyle.Left; + sourceCard.Width = 520; + + Panel detailsCard = CreateCard(); + detailsCard.Dock = DockStyle.Fill; + detailsCard.Margin = new Padding(18, 0, 0, 0); + + container.Controls.Add(detailsCard); + container.Controls.Add(sourceCard); + + BuildSourceCard(sourceCard); + BuildDetailsCard(detailsCard); + + return container; + } + private void BuildSourceCard(Panel card) + { + Label step = CreateSmallTitle("SCHRITT 1"); + step.Location = new Point(22, 18); + + Label title = CreateCardTitle("Zertifikatsquelle"); + title.Location = new Point(22, 40); + + Label description = CreateMutedLabel( + "Wähle die Zertifikatsdatei aus der zentralen Ablage."); + description.Location = new Point(22, 73); + + txtCertificatePath = new TextBox + { + Location = new Point(22, 112), + Size = new Size(320, 38), + BackColor = Color.FromArgb(20, 30, 48), + ForeColor = TextColor, + BorderStyle = BorderStyle.FixedSingle, + Font = new Font("Segoe UI", 9), + Text = string.Empty, + PlaceholderText = "Keine Datei ausgewählt", + ReadOnly = true + }; + + btnSelectFile = CreateSecondaryButton("Datei auswählen"); + btnSelectFile.Location = new Point(354, 111); + btnSelectFile.Size = new Size(140, 39); + SetFileButtonState(fileLoaded: false); + + btnSelectFile.Click += (_, _) => + { + if (isCertificateFileLoaded) + { + ClearSelectedCertificate(); + return; + } + + TrySelectCertificateFile(); + }; + + Label hint = new Label + { + Text = "Unterstützte Formate: .cer, .crt, .pem, .pfx", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 8.5f), + AutoSize = true, + Location = new Point(22, 164) + }; + + card.Controls.Add(step); + card.Controls.Add(title); + card.Controls.Add(description); + card.Controls.Add(txtCertificatePath); + card.Controls.Add(btnSelectFile); + card.Controls.Add(hint); + } + private void TrySelectCertificateFile() + { + using OpenFileDialog dialog = new OpenFileDialog + { + Title = "Zertifikatsdatei auswählen", + Filter = + "Unterstützte Zertifikatsdateien (*.cer;*.crt;*.pem;*.pfx)|*.cer;*.crt;*.pem;*.pfx|" + + "CER-Zertifikate (*.cer)|*.cer|" + + "CRT-Zertifikate (*.crt)|*.crt|" + + "PEM-Zertifikate (*.pem)|*.pem|" + + "PFX-Zertifikate (*.pfx)|*.pfx", + FilterIndex = 1, + Multiselect = false, + CheckFileExists = true, + CheckPathExists = true, + RestoreDirectory = true, + AddExtension = true, + DereferenceLinks = true + }; + + // Bei Abbruch bleibt der bisherige Zustand unverändert. + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + string selectedPath = dialog.FileName; + + if (!IsSupportedCertificateFile(selectedPath)) + { + MessageBox.Show( + this, + "Bitte wähle eine Zertifikatsdatei in einem der folgenden Formate aus:\n" + + ".cer, .crt, .pem oder .pfx", + "Nicht unterstütztes Dateiformat", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + + CertificateInfo? certificateInfo = TryReadSelectedCertificate(selectedPath); + if (certificateInfo == null) + { + return; + } + + txtCertificatePath.Text = selectedPath; + _loadedCertificateInfo = certificateInfo; + SetStatus($"Zertifikat ausgewählt: {Path.GetFileName(selectedPath)}", isError: false); + DisplayCertificateInfo(certificateInfo); + UpdateToNextStep(2); + SetFileButtonState(fileLoaded: true); + RefreshActionButtonStates(); + } + + private CertificateInfo? TryReadSelectedCertificate(string selectedPath) + { + bool isPfx = Path.GetExtension(selectedPath) + .Equals(".pfx", StringComparison.OrdinalIgnoreCase); + + try + { + // Zuerst ohne Passwort versuchen + return ReadCertificate(selectedPath, null); + } + catch (CryptographicException) when (isPfx) + { + // Passwort nur abfragen, wenn das Laden ohne Passwort fehlgeschlagen ist + string? pfxPassword = PromptForPfxPassword(); + if (pfxPassword == null) + { + return null; + } + + try + { + return ReadCertificate(selectedPath, pfxPassword); + } + catch (CryptographicException) + { + MessageBox.Show( + "Das angegebene Passwort ist falsch oder die Datei ist beschädigt.", + "Fehler beim Lesen des Zertifikats", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return null; + } + } + catch (Exception ex) + { + MessageBox.Show( + this, + $"Das Zertifikat konnte nicht gelesen werden.\n\n{ex.Message}", + "Fehler beim Lesen des Zertifikats", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + return null; + } + } + + private void ClearSelectedCertificate() + { + txtCertificatePath.Text = string.Empty; + _loadedCertificateInfo = null; + SetStatus("Keine Datei ausgewählt", isError: false); + + lblCertificateSubject.Text = "-"; + lblCertificateIssuer.Text = "-"; + lblCertificateExpiry.Text = "-"; + lblCertificateFingerprint.Text = "-"; + SetValidityBadge( + "○ NICHT GELADEN", + MutedTextColor, + Color.FromArgb(39, 52, 73)); + + UpdateToNextStep(1); + SetFileButtonState(fileLoaded: false); + RefreshActionButtonStates(); + } + + private void SetFileButtonState(bool fileLoaded) + { + isCertificateFileLoaded = fileLoaded; + + if (fileLoaded) + { + btnSelectFile.Text = "Datei auswerfen"; + btnSelectFile.BackColor = Color.FromArgb(72, 31, 38); + btnSelectFile.ForeColor = Color.FromArgb(255, 210, 210); + btnSelectFile.FlatAppearance.BorderColor = RedColor; + btnSelectFile.FlatAppearance.MouseOverBackColor = Color.FromArgb(96, 42, 50); + return; + } + + btnSelectFile.Text = "Datei auswählen"; + btnSelectFile.BackColor = Color.FromArgb(39, 52, 73); + btnSelectFile.ForeColor = TextColor; + btnSelectFile.FlatAppearance.BorderColor = BorderColor; + btnSelectFile.FlatAppearance.MouseOverBackColor = Color.FromArgb(54, 69, 94); + } + + private void SetValidityBadge(string text, Color foreColor, Color backColor) + { + lblCertificateValidity.Text = text; + lblCertificateValidity.ForeColor = foreColor; + lblCertificateValidity.BackColor = backColor; + RepositionValidityBadge(); + } + + private void RepositionValidityBadge() + { + if (lblCertificateValidity?.Parent == null) + { + return; + } + + lblCertificateValidity.Left = + lblCertificateValidity.Parent.Width - lblCertificateValidity.Width - 22; + } + + private static string? PromptForPfxPassword() + { + using Form dialog = new Form + { + Text = "PFX-Passwort eingeben", + Size = new Size(360, 150), + StartPosition = FormStartPosition.CenterParent, + FormBorderStyle = FormBorderStyle.FixedDialog, + MinimizeBox = false, + MaximizeBox = false + }; + + Label label = new Label + { + Text = "Passwort für die PFX-Datei:", + Location = new Point(12, 15), + AutoSize = true + }; + + TextBox txtPwd = new TextBox + { + Location = new Point(12, 38), + Width = 320, + UseSystemPasswordChar = true + }; + + Button btnOk = new Button + { + Text = "OK", + DialogResult = DialogResult.OK, + Location = new Point(176, 72), + Width = 75 + }; + + Button btnCancel = new Button + { + Text = "Abbrechen", + DialogResult = DialogResult.Cancel, + Location = new Point(257, 72), + Width = 75 + }; + + dialog.Controls.AddRange([label, txtPwd, btnOk, btnCancel]); + dialog.AcceptButton = btnOk; + dialog.CancelButton = btnCancel; + + return dialog.ShowDialog() == DialogResult.OK ? txtPwd.Text : null; + } + + private static CertificateInfo ReadCertificate(string certificatePath, string? pfxPassword = null) + { + if (string.IsNullOrWhiteSpace(certificatePath)) + { + throw new ArgumentException( + "Es wurde kein Zertifikatspfad angegeben.", + nameof(certificatePath)); + } + + if (!File.Exists(certificatePath)) + { + throw new FileNotFoundException( + "Die ausgewählte Zertifikatsdatei wurde nicht gefunden.", + certificatePath); + } + + string extension = Path.GetExtension(certificatePath); + + using X509Certificate2 certificate = + extension.Equals(".pfx", StringComparison.OrdinalIgnoreCase) + ? new X509Certificate2( + certificatePath, + pfxPassword, + X509KeyStorageFlags.EphemeralKeySet) + : new X509Certificate2(certificatePath); + + string subject = certificate.GetNameInfo( + X509NameType.SimpleName, + forIssuer: false); + + string issuer = certificate.GetNameInfo( + X509NameType.SimpleName, + forIssuer: true); + + if (string.IsNullOrWhiteSpace(subject)) + { + subject = certificate.Subject; + } + + if (string.IsNullOrWhiteSpace(issuer)) + { + issuer = certificate.Issuer; + } + + string fingerprint = certificate.GetCertHashString( + HashAlgorithmName.SHA256); + + fingerprint = FormatFingerprint(fingerprint); + + DateTimeOffset validFrom = + new DateTimeOffset(certificate.NotBefore); + + DateTimeOffset validUntil = + new DateTimeOffset(certificate.NotAfter); + + DateTimeOffset now = DateTimeOffset.Now; + + bool isCurrentlyValid = + now >= validFrom && + now <= validUntil; + + return new CertificateInfo + { + Subject = subject, + Issuer = issuer, + ValidFrom = validFrom, + ValidUntil = validUntil, + FingerprintSha256 = fingerprint, + IsCurrentlyValid = isCurrentlyValid + }; + } + + private void DisplayCertificateInfo(CertificateInfo certificateInfo) + { + lblCertificateSubject.Text = certificateInfo.Subject; + lblCertificateIssuer.Text = certificateInfo.Issuer; + lblCertificateExpiry.Text = + certificateInfo.ValidUntil.ToLocalTime().ToString("dd.MM.yyyy HH:mm"); + lblCertificateFingerprint.Text = certificateInfo.FingerprintSha256; + + if (certificateInfo.IsCurrentlyValid) + { + SetValidityBadge("● GÜLTIG", GreenColor, Color.FromArgb(20, 62, 46)); + return; + } + + SetValidityBadge( + "● ABGELAUFEN / NICHT GÜLTIG", + RedColor, + Color.FromArgb(72, 31, 38)); + } + private static string FormatFingerprint(string fingerprint) + { + if (string.IsNullOrWhiteSpace(fingerprint)) + { + return string.Empty; + } + + return string.Join( + ":", + Enumerable.Range(0, fingerprint.Length / 2) + .Select(index => fingerprint.Substring(index * 2, 2))); + } + private static bool IsSupportedCertificateFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return false; + } + + if (!File.Exists(filePath)) + { + return false; + } + + string extension = Path.GetExtension(filePath); + + string[] supportedExtensions = + { + ".cer", + ".crt", + ".pem", + ".pfx" + }; + + return supportedExtensions.Contains( + extension, + StringComparer.OrdinalIgnoreCase); + } + private void BuildDetailsCard(Panel card) + { + Label step = CreateSmallTitle("ERKANNTE INFORMATIONEN"); + step.Location = new Point(22, 18); + + Label title = CreateCardTitle("Zertifikatsdetails"); + title.Location = new Point(22, 40); + + lblCertificateValidity = new Label + { + Text = "○ NICHT GELADEN", + ForeColor = MutedTextColor, + BackColor = Color.FromArgb(39, 52, 73), + Font = new Font("Segoe UI", 8, FontStyle.Bold), + AutoSize = true, + Padding = new Padding(8, 5, 8, 5), + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + + lblCertificateValidity.Location = new Point(300, 40); + + card.Resize += (_, _) => RepositionValidityBadge(); + + Label subjectLabel = CreateMutedLabel("SUBJECT / CN"); + subjectLabel.Location = new Point(22, 84); + + lblCertificateSubject = CreateValueLabel("-"); + lblCertificateSubject.Location = new Point(22, 102); + lblCertificateSubject.AutoEllipsis = true; + lblCertificateSubject.MaximumSize = new Size(210, 0); + + Label issuerLabel = CreateMutedLabel("AUSSTELLER"); + issuerLabel.Location = new Point(22, 133); + + lblCertificateIssuer = CreateValueLabel("-"); + lblCertificateIssuer.Location = new Point(22, 151); + lblCertificateIssuer.AutoEllipsis = true; + lblCertificateIssuer.MaximumSize = new Size(210, 0); + + Label expiryLabel = CreateMutedLabel("GÜLTIG BIS"); + expiryLabel.Location = new Point(22, 182); + + lblCertificateExpiry = CreateValueLabel("-"); + lblCertificateExpiry.Location = new Point(22, 200); + + Label fingerprintLabel = CreateMutedLabel("SHA-256 FINGERPRINT"); + fingerprintLabel.Location = new Point(245, 84); + + lblCertificateFingerprint = CreateValueLabel("-"); + lblCertificateFingerprint.Location = new Point(245, 102); + lblCertificateFingerprint.AutoSize = false; + lblCertificateFingerprint.Size = new Size(310, 48); + + card.Controls.Add(step); + card.Controls.Add(title); + card.Controls.Add(lblCertificateValidity); + + card.Controls.Add(subjectLabel); + card.Controls.Add(lblCertificateSubject); + + card.Controls.Add(issuerLabel); + card.Controls.Add(lblCertificateIssuer); + + card.Controls.Add(expiryLabel); + card.Controls.Add(lblCertificateExpiry); + + card.Controls.Add(fingerprintLabel); + card.Controls.Add(lblCertificateFingerprint); + } + + private Panel BuildTargetsCard() + { + Panel card = CreateCard(); + + Label step = CreateSmallTitle("SCHRITT 2"); + step.Location = new Point(22, 18); + + Label title = CreateCardTitle("Bereitstellungsziele"); + title.Location = new Point(22, 40); + + Label description = CreateMutedLabel( + "Wähle aus, auf welche Systeme das Zertifikat verteilt werden soll."); + description.Location = new Point(22, 72); + + dgvTargets = new DataGridView + { + Location = new Point(22, 148), + Size = new Size(1000, 205), + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + BackgroundColor = CardColor, + BorderStyle = BorderStyle.None, + EnableHeadersVisualStyles = false, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + MultiSelect = false, + GridColor = BorderColor + }; + + dgvTargets.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle + { + BackColor = Color.FromArgb(24, 34, 52), + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), + Alignment = DataGridViewContentAlignment.MiddleLeft + }; + + dgvTargets.DefaultCellStyle = new DataGridViewCellStyle + { + BackColor = CardColor, + ForeColor = TextColor, + SelectionBackColor = Color.FromArgb(40, 57, 82), + SelectionForeColor = TextColor, + Font = new Font("Segoe UI", 9), + Padding = new Padding(4, 0, 4, 0) + }; + + dgvTargets.AlternatingRowsDefaultCellStyle = new DataGridViewCellStyle + { + BackColor = Color.FromArgb(28, 39, 57), + ForeColor = TextColor + }; + + dgvTargets.ColumnHeadersHeight = 34; + dgvTargets.RowTemplate.Height = 32; + + dgvTargets.Columns.Add(new DataGridViewCheckBoxColumn + { + Name = "Selected", + HeaderText = "", + FillWeight = 25 + }); + + dgvTargets.Columns.Add("Target", "Ziel"); + dgvTargets.Columns.Add("Environment", "Umgebung"); + dgvTargets.Columns.Add("Container", "Container / Service"); + dgvTargets.Columns.Add("Tls", "TLS-Prüfung"); + dgvTargets.Columns.Add("Status", "Status"); + + dgvTargets.CurrentCellDirtyStateChanged += (_, _) => + { + if (dgvTargets.IsCurrentCellDirty + && dgvTargets.CurrentCell is DataGridViewCheckBoxCell) + { + dgvTargets.CommitEdit(DataGridViewDataErrorContexts.Commit); + } + }; + + dgvTargets.CellValueChanged += (_, e) => + { + if (e.ColumnIndex >= 0 + && dgvTargets.Columns[e.ColumnIndex].Name == "Selected") + { + RefreshActionButtonStates(); + } + }; + + card.Resize += (_, _) => + { + dgvTargets.Width = card.Width - 44; + }; + + card.Controls.Add(step); + card.Controls.Add(title); + card.Controls.Add(description); + card.Controls.Add(dgvTargets); + + // ---- Sonic Management Console Discovery ---- + BuildSonicDiscoveryRow(card); + + return card; + } + + private Panel BuildActionPanel() + { + Panel panel = new Panel + { + BackColor = BackgroundColor + }; + + btnCancelRun = CreateSecondaryButton("Abbrechen"); + btnCancelRun.Size = new Size(120, 42); + btnCancelRun.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnCancelRun.Enabled = false; + btnCancelRun.Click += (_, _) => + { + _runCts?.Cancel(); + SetStatus("Abbruch angefordert…", isError: false); + }; + + btnValidate = CreateSecondaryButton("Prüfung durchführen"); + btnValidate.Size = new Size(175, 42); + btnValidate.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnValidate.Click += async (_, _) => await RunValidationAsync(); + + btnDeploy = CreatePrimaryButton("Deployment starten"); + btnDeploy.Size = new Size(170, 42); + btnDeploy.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnDeploy.Click += async (_, _) => await RunDeploymentAsync(); + + panel.Controls.Add(btnCancelRun); + panel.Controls.Add(btnValidate); + panel.Controls.Add(btnDeploy); + + panel.Resize += (_, _) => + { + btnDeploy.Left = panel.Width - btnDeploy.Width; + btnValidate.Left = btnDeploy.Left - btnValidate.Width - 12; + btnCancelRun.Left = btnValidate.Left - btnCancelRun.Width - 12; + }; + + RefreshActionButtonStates(); + return panel; + } + + private Panel BuildStatusBar() + { + Panel panel = new Panel + { + Height = 28, + BackColor = BackgroundColor + }; + + lblStatusDot = new Label + { + Text = "●", + ForeColor = GreenColor, + Font = new Font("Segoe UI", 10), + AutoSize = true, + Location = new Point(0, 4) + }; + + lblStatus = new Label + { + Text = "Warte auf Zertifikatsauswahl", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 9), + AutoSize = true, + Location = new Point(18, 5) + }; + + panel.Controls.Add(lblStatusDot); + panel.Controls.Add(lblStatus); + + return panel; + } + + private async Task LoadTargetsAsync() + { + try + { + SetStatus("Lade Bereitstellungsziele…", isError: false); + (ITargetRepository repository, string loadMessage) = + await TargetRepositoryFactory.CreateAsync(_settings); + + _loadedTargets = await repository.GetActiveTargetsAsync(); + BindTargetsToGrid(_loadedTargets); + SetStatus(loadMessage, isError: false); + } + catch (Exception ex) + { + _loadedTargets = []; + dgvTargets.Rows.Clear(); + SetStatus($"Ziele konnten nicht geladen werden: {ex.Message}", isError: true); + } + finally + { + RefreshActionButtonStates(); + } + } + + private void BindTargetsToGrid(IReadOnlyList targets) + { + dgvTargets.Rows.Clear(); + + foreach (DeploymentTarget target in targets) + { + int tlsPort = target.TlsPort is > 0 ? target.TlsPort.Value : 443; + string tls = string.IsNullOrWhiteSpace(target.TlsHost) + ? "—" + : $"{target.TlsHost}:{tlsPort}"; + + int rowIndex = dgvTargets.Rows.Add( + false, + target.Name, + target.Environment, + target.ContainerName, + tls, + "Bereit"); + + dgvTargets.Rows[rowIndex].Tag = target; + } + } + + private List GetSelectedTargets() + { + List selected = []; + + foreach (DataGridViewRow row in dgvTargets.Rows) + { + if (row.Cells["Selected"].Value is true && row.Tag is DeploymentTarget target) + { + selected.Add(target); + } + } + + return selected; + } + + private void SetTargetRowStatus(int targetId, string status) + { + foreach (DataGridViewRow row in dgvTargets.Rows) + { + if (row.Tag is DeploymentTarget target && target.Id == targetId) + { + row.Cells["Status"].Value = status; + break; + } + } + } + + private void RefreshActionButtonStates() + { + if (btnValidate is null || btnDeploy is null || btnCancelRun is null) + { + return; + } + + bool idle = !_isOperationRunning; + bool hasCertificate = isCertificateFileLoaded && _loadedCertificateInfo is not null; + bool hasTargets = _loadedTargets.Count > 0; + bool hasSelection = dgvTargets is not null && GetSelectedTargets().Count > 0; + + if (btnSelectFile is not null) + { + btnSelectFile.Enabled = idle; + } + + btnCancelRun.Enabled = _isOperationRunning; + btnValidate.Enabled = idle && hasCertificate && hasTargets; + btnDeploy.Enabled = idle && hasCertificate && hasSelection; + + if (dgvTargets is not null) + { + dgvTargets.Enabled = idle; + } + } + + private void SetStatus(string message, bool isError) + { + if (lblStatus is null) + { + return; + } + + lblStatus.Text = message; + lblStatus.ForeColor = isError ? RedColor : MutedTextColor; + if (lblStatusDot is not null) + { + lblStatusDot.ForeColor = isError ? RedColor : (_isOperationRunning ? GoldColor : GreenColor); + } + } + + private void SetOperationRunning(bool running) + { + _isOperationRunning = running; + RefreshActionButtonStates(); + } + + private void ShowPreflightIssues(PreflightValidationResult result, string statusMessage, string caption) + { + string details = string.Join(Environment.NewLine, result.Issues.Select(i => "• " + i.Message)); + SetStatus(statusMessage, isError: true); + MessageBox.Show(this, details, caption, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + + private Task RunValidationAsync() + { + if (_isOperationRunning) + { + return Task.CompletedTask; + } + + List selected = GetSelectedTargets(); + PreflightValidationResult result = _orchestrator.ValidatePreflight( + txtCertificatePath.Text, + _loadedCertificateInfo, + selected); + + HashSet failedTargetIds = result.Issues + .Where(i => i.TargetId is not null) + .Select(i => i.TargetId!.Value) + .ToHashSet(); + + foreach (DataGridViewRow row in dgvTargets.Rows) + { + if (row.Tag is not DeploymentTarget target) + { + continue; + } + + if (failedTargetIds.Contains(target.Id)) + { + SetTargetRowStatus(target.Id, "Prüfung fehlgeschlagen"); + } + else if (row.Cells["Selected"].Value is true) + { + SetTargetRowStatus(target.Id, "Bereit"); + } + } + + if (!result.IsValid) + { + ShowPreflightIssues(result, "Vorabprüfung fehlgeschlagen.", "Vorabprüfung"); + return Task.CompletedTask; + } + + UpdateToNextStep(3); + SetStatus($"Vorabprüfung ok – {selected.Count} Ziel(e) bereit.", isError: false); + MessageBox.Show( + this, + $"Alle Voraussetzungen sind erfüllt.\nAusgewählte Ziele: {selected.Count}", + "Vorabprüfung", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + return Task.CompletedTask; + } + + private async Task RunDeploymentAsync() + { + if (_isOperationRunning || _loadedCertificateInfo is null) + { + return; + } + + List selected = GetSelectedTargets(); + PreflightValidationResult preflight = _orchestrator.ValidatePreflight( + txtCertificatePath.Text, + _loadedCertificateInfo, + selected); + + if (!preflight.IsValid) + { + ShowPreflightIssues( + preflight, + "Deployment blockiert – Vorabprüfung fehlgeschlagen.", + "Deployment"); + return; + } + + _runCts?.Dispose(); + _runCts = new CancellationTokenSource(); + SetOperationRunning(true); + UpdateToNextStep(3); + SetStatus($"Deployment läuft für {selected.Count} Ziel(e)…", isError: false); + + Progress progress = new(update => + { + SetTargetRowStatus(update.TargetId, update.StatusText); + SetStatus(update.StatusText, isError: update.SuccessHint == false); + }); + + try + { + DeploymentRunResult runResult = await _orchestrator.RunAsync( + txtCertificatePath.Text, + _loadedCertificateInfo, + selected, + progress, + _runCts.Token); + + foreach (TargetStepResult targetResult in runResult.TargetResults) + { + SetTargetRowStatus(targetResult.TargetId, targetResult.StatusText); + } + + UpdateToNextStep(4); + SetStatus( + runResult.OverallSuccess + ? $"Deployment erfolgreich ({runResult.TargetResults.Count} Ziel(e))." + : "Deployment mit Fehlern beendet. Details in der Status-Spalte / Log.", + isError: !runResult.OverallSuccess); + } + catch (OperationCanceledException) + { + SetStatus("Deployment abgebrochen.", isError: true); + } + catch (Exception ex) + { + SetStatus($"Deployment fehlgeschlagen: {ex.Message}", isError: true); + MessageBox.Show( + this, + ex.Message, + "Deployment", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + finally + { + SetOperationRunning(false); + _runCts?.Dispose(); + _runCts = null; + } + } + + private Panel CreateCard() + { + return new Panel + { + BackColor = CardColor, + BorderStyle = BorderStyle.FixedSingle, + Padding = new Padding(20) + }; + } + + private Label CreateSmallTitle(string text) + { + return new Label + { + Text = text, + ForeColor = BlueColor, + Font = new Font("Segoe UI", 8, FontStyle.Bold), + AutoSize = true + }; + } + + private Label CreateCardTitle(string text) + { + return new Label + { + Text = text, + ForeColor = TextColor, + Font = new Font("Segoe UI", 14, FontStyle.Bold), + AutoSize = true + }; + } + + private Label CreateMutedLabel(string text) + { + return new Label + { + Text = text, + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 9), + AutoSize = true + }; + } + + private Label CreateValueLabel(string text) + { + return new Label + { + Text = text, + ForeColor = TextColor, + Font = new Font("Segoe UI", 9.5f, FontStyle.Regular), + AutoSize = true + }; + } + + private Button CreatePrimaryButton(string text) + { + Button button = new Button + { + Text = text, + BackColor = GoldColor, + ForeColor = Color.FromArgb(30, 25, 10), + FlatStyle = FlatStyle.Flat, + FlatAppearance = + { + BorderSize = 0, + MouseOverBackColor = Color.FromArgb(250, 204, 21) + }, + Font = new Font("Segoe UI", 9.5f, FontStyle.Bold), + Cursor = Cursors.Hand + }; + + return button; + } + + private Button CreateSecondaryButton(string text) + { + Button button = new Button + { + Text = text, + BackColor = Color.FromArgb(39, 52, 73), + ForeColor = TextColor, + FlatStyle = FlatStyle.Flat, + FlatAppearance = + { + BorderColor = BorderColor, + BorderSize = 1, + MouseOverBackColor = Color.FromArgb(54, 69, 94) + }, + Font = new Font("Segoe UI", 9, FontStyle.Bold), + Cursor = Cursors.Hand + }; + + return button; + } + + // --------------------------------------------------------------- + // Sonic Management Console – Container-Discovery + // --------------------------------------------------------------- + + private void BuildSonicDiscoveryRow(Panel card) + { + Label sonicLabel = new Label + { + Text = "Sonic Management:", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), + AutoSize = true, + Location = new Point(22, 108) + }; + + cmbSonicConnection = new ComboBox + { + Location = new Point(160, 104), + Size = new Size(220, 26), + DropDownStyle = ComboBoxStyle.DropDownList, + BackColor = Color.FromArgb(20, 30, 48), + ForeColor = TextColor, + FlatStyle = FlatStyle.Flat, + Font = new Font("Segoe UI", 9) + }; + + foreach (SonicConnection conn in _sonicDiscovery.Connections) + { + cmbSonicConnection.Items.Add(conn.Name); + } + + if (cmbSonicConnection.Items.Count > 0) + { + cmbSonicConnection.SelectedIndex = 0; + } + + btnLoadFromSonic = CreateSecondaryButton("Container laden"); + btnLoadFromSonic.Location = new Point(392, 103); + btnLoadFromSonic.Size = new Size(148, 28); + btnLoadFromSonic.Enabled = _sonicDiscovery.HasConnections; + btnLoadFromSonic.Click += async (_, _) => await LoadFromSonicAsync(); + + lblSonicStatus = new Label + { + Text = _sonicDiscovery.HasConnections + ? "Keine Verbindung konfiguriert" + : "Keine Sonic-Verbindung konfiguriert", + ForeColor = MutedTextColor, + Font = new Font("Segoe UI", 8.5f), + AutoSize = true, + Location = new Point(552, 109) + }; + + if (!_sonicDiscovery.HasConnections) + { + lblSonicStatus.Text = "Keine Sonic-Verbindung in appsettings konfiguriert"; + lblSonicStatus.ForeColor = RedColor; + } + else + { + lblSonicStatus.Text = $"{_sonicDiscovery.Connections.Count} Verbindung(en) konfiguriert – noch nicht geladen"; + } + + card.Controls.Add(sonicLabel); + card.Controls.Add(cmbSonicConnection); + card.Controls.Add(btnLoadFromSonic); + card.Controls.Add(lblSonicStatus); + } + + private async Task LoadFromSonicAsync() + { + if (_isOperationRunning) return; + + string? selectedConnection = cmbSonicConnection?.SelectedItem?.ToString(); + if (string.IsNullOrWhiteSpace(selectedConnection)) return; + + btnLoadFromSonic.Enabled = false; + lblSonicStatus.Text = $"Verbinde mit {selectedConnection}…"; + lblSonicStatus.ForeColor = GoldColor; + + try + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + SonicDiscoveryResult result = await _sonicDiscovery.DiscoverAsync( + selectedConnection, + _loadedTargets, + cts.Token); + + if (!result.Success) + { + lblSonicStatus.Text = $"Fehler: {result.ErrorMessage}"; + lblSonicStatus.ForeColor = RedColor; + SetStatus($"Sonic-Discovery fehlgeschlagen: {result.ErrorMessage}", isError: true); + return; + } + + // Grid mit entdeckten Containern befüllen + _loadedTargets = result.DiscoveredTargets; + BindTargetsToGrid(result.DiscoveredTargets); + + int configured = result.DiscoveredTargets.Count(t => !string.IsNullOrWhiteSpace(t.TargetDirectory)); + int discovered = result.RawContainerNames.Count; + + lblSonicStatus.Text = discovered > 0 + ? $"{discovered} Container gefunden, {configured} konfiguriert" + : $"Verbindung ok – keine Container in Domain '{selectedConnection}'"; + lblSonicStatus.ForeColor = discovered > 0 ? GreenColor : GoldColor; + + SetStatus( + $"Sonic {selectedConnection}: {discovered} Container geladen.", + isError: false); + + // Nicht-konfigurierte Ziele visuell markieren + MarkUnconfiguredRows(); + } + catch (OperationCanceledException) + { + lblSonicStatus.Text = "Timeout – Verbindung nicht erreichbar"; + lblSonicStatus.ForeColor = RedColor; + SetStatus($"Sonic-Discovery Timeout ({selectedConnection})", isError: true); + } + catch (Exception ex) + { + lblSonicStatus.Text = $"Fehler: {ex.Message}"; + lblSonicStatus.ForeColor = RedColor; + SetStatus($"Sonic-Discovery Fehler: {ex.Message}", isError: true); + } + finally + { + btnLoadFromSonic.Enabled = _sonicDiscovery.HasConnections && !_isOperationRunning; + RefreshActionButtonStates(); + } + } + + /// + /// Färbt Zeilen mit unvollständiger Konfiguration (kein TargetDirectory) orange ein + /// damit erkennbar ist, dass diese Container noch konfiguriert werden müssen. + /// + private void MarkUnconfiguredRows() + { + foreach (DataGridViewRow row in dgvTargets.Rows) + { + if (row.Tag is not DeploymentTarget target) continue; + + if (string.IsNullOrWhiteSpace(target.TargetDirectory)) + { + row.DefaultCellStyle.ForeColor = GoldColor; + if (row.Cells["Status"] is DataGridViewCell statusCell) + { + statusCell.Value = "⚠ Pfad fehlt"; + } + } + } + } + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Form1.resx b/ZA.CoreService.ESBCertificateManager/Form1.resx new file mode 100644 index 0000000..af32865 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Form1.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/ZA.CoreService.ESBCertificateManager/Models/AppSettings.cs b/ZA.CoreService.ESBCertificateManager/Models/AppSettings.cs new file mode 100644 index 0000000..68e8635 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Models/AppSettings.cs @@ -0,0 +1,17 @@ +namespace ZA.CoreService.ESBCertificateManager.Models; + +public sealed class AppSettings +{ + public string ConnectionString { get; set; } = string.Empty; + public bool UseOfflineSampleData { get; set; } = true; + public string SampleTargetsPath { get; set; } = "Data/targets.sample.json"; + public string LogDirectory { get; set; } = "Logs"; + public int TlsTimeoutSeconds { get; set; } = 8; + public int TlsRetryCount { get; set; } = 2; + + /// + /// Verbindungskonfigurationen für Progress Sonic ESB Management Instanzen. + /// Jeder Eintrag entspricht einer Sonic-Domain (z.B. einer Umgebung oder Tochtergesellschaft). + /// + public List SonicConnections { get; set; } = []; +} diff --git a/ZA.CoreService.ESBCertificateManager/Models/CertificateInfo.cs b/ZA.CoreService.ESBCertificateManager/Models/CertificateInfo.cs new file mode 100644 index 0000000..531fef0 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Models/CertificateInfo.cs @@ -0,0 +1,12 @@ +namespace ZA.CoreService.ESBCertificateManager.Models +{ + public sealed class CertificateInfo + { + public required string Subject { get; init; } + public required string Issuer { get; init; } + public required string FingerprintSha256 { get; init; } + public DateTimeOffset ValidFrom { get; init; } + public DateTimeOffset ValidUntil { get; init; } + public bool IsCurrentlyValid { get; init; } + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Models/DeploymentRunResult.cs b/ZA.CoreService.ESBCertificateManager/Models/DeploymentRunResult.cs new file mode 100644 index 0000000..e0d49b7 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Models/DeploymentRunResult.cs @@ -0,0 +1,40 @@ +namespace ZA.CoreService.ESBCertificateManager.Models; + +public sealed class DeploymentRunResult +{ + public Guid RunId { get; init; } = Guid.NewGuid(); + public required IReadOnlyList TargetResults { get; init; } + public bool OverallSuccess => TargetResults.All(r => r.Success); + public DateTimeOffset StartedAt { get; init; } + public DateTimeOffset FinishedAt { get; init; } + public string CertificateFilePath { get; init; } = string.Empty; + public string CertificateFingerprint { get; init; } = string.Empty; +} + +public sealed class TargetStepResult +{ + public required int TargetId { get; init; } + public required string TargetName { get; init; } + public bool Success { get; init; } + public required string StatusText { get; init; } + public string? Detail { get; init; } + public IReadOnlyList Steps { get; init; } = []; + public DateTimeOffset StartedAt { get; init; } + public DateTimeOffset FinishedAt { get; init; } + public bool CopySucceeded { get; init; } + public bool RestartSucceeded { get; init; } + public bool TlsSucceeded { get; init; } + public string? ObservedFingerprint { get; init; } +} + +public sealed class ValidationIssue +{ + public required string Message { get; init; } + public int? TargetId { get; init; } +} + +public sealed class PreflightValidationResult +{ + public bool IsValid => Issues.Count == 0; + public List Issues { get; } = []; +} diff --git a/ZA.CoreService.ESBCertificateManager/Models/DeploymentTarget.cs b/ZA.CoreService.ESBCertificateManager/Models/DeploymentTarget.cs new file mode 100644 index 0000000..f946d01 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Models/DeploymentTarget.cs @@ -0,0 +1,67 @@ +namespace ZA.CoreService.ESBCertificateManager.Models; + +public sealed class DeploymentTarget +{ + public int Id { get; init; } + public required string Name { get; init; } + public required string Environment { get; init; } + public bool IsActive { get; init; } = true; + public required string TargetDirectory { get; init; } + public required string CertificateFileName { get; init; } + + /// Name des Sonic-ESB-Containers (z.B. "sonic-container-a"). + public string ContainerName { get; init; } = string.Empty; + + public RestartType RestartType { get; init; } = RestartType.None; + + // --- Command-basierter Neustart --- + public string RestartCommand { get; init; } = string.Empty; + public string RestartArguments { get; init; } = string.Empty; + public int RestartTimeoutSeconds { get; init; } = 60; + + // --- Sonic-ESB-Management-Neustart --- + /// + /// Referenz auf den Namen einer in AppSettings. + /// Pflichtfeld bei RestartType = SonicContainer oder SonicContainerWithXapi. + /// + public string SonicConnectionName { get; init; } = string.Empty; + + /// + /// Pfad zur XApi-Ressourcendatei (.xml/.zip), die vor dem Neustart importiert wird. + /// Pflichtfeld bei RestartType = SonicContainerWithXapi. + /// + public string XapiSourcePath { get; init; } = string.Empty; + + // --- TLS-Probe nach Deployment --- + public string TlsHost { get; init; } = string.Empty; + public int? TlsPort { get; init; } + + /// + /// Hostname, der im TLS-Handshake als ServerName (SNI) verwendet wird. + /// Wichtig wenn TlsHost eine IP-Adresse ist, das Zertifikat aber einen DNS-Namen trägt. + /// Ist leer, wird TlsHost als ServerName verwendet. + /// + public string TlsServerName { get; init; } = string.Empty; + + /// + /// Erwarteter SHA-256-Fingerprint des Zertifikats nach dem Deployment (ohne Trennzeichen). + /// Wenn gesetzt, schlägt der TLS-Probe fehl wenn der Fingerprint abweicht. + /// + public string? ExpectedFingerprint { get; init; } + + public int SortOrder { get; init; } +} + +public enum RestartType +{ + None = 0, + + /// Neustart über einen lokalen Betriebssystem-Prozess (RestartCommand). + Command = 1, + + /// Container-Neustart über die Sonic ESB Management Console REST-API. + SonicContainer = 2, + + /// XApi-Ressourcen importieren und danach Container neu starten (Sonic ESB). + SonicContainerWithXapi = 3 +} diff --git a/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs b/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs new file mode 100644 index 0000000..e24a3bb --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs @@ -0,0 +1,107 @@ +namespace ZA.CoreService.ESBCertificateManager.Models; + +/// +/// Verbindungskonfiguration für eine Progress Sonic ESB Management Instanz. +/// +public sealed class SonicConnection +{ + /// + /// Eindeutiger Bezeichner; wird in referenziert. + /// + public required string Name { get; init; } + + /// Sonic-Domain-Name, z.B. "proalpha-test". + public required string DomainName { get; init; } + + /// + /// Sonic-Broker-/Management-URL im Sonic-Format, z.B. "tcp://dekun-painwbdet:13070". + /// Der Hostname wird daraus extrahiert (für HTTP-Modus: Basis-URL, für WinRM-Modus: Zielrechner). + /// + public required string ConnectionUrl { get; init; } + + /// Benutzername für die Management-Konsole. + public required string Username { get; init; } + + /// Passwort für die Management-Konsole. + public required string Password { get; init; } + + /// + /// Management-Modus: HttpApi (REST) oder WinRm (PowerShell Remoting). + /// Standard: WinRm – da Sonic 10.x kein HTTP REST API bereitstellt. + /// + public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.WinRm; + + // --------------------------------------------------------------- + // HTTP REST API (ManagementMode = HttpApi) + // --------------------------------------------------------------- + + /// HTTP-Port der Sonic Management Console REST-API (Standard: 8080). + public int ManagementHttpPort { get; init; } = 8080; + + /// Präfix für alle REST-API-Pfade (Standard: "/api/v1"). + public string ApiBasePath { get; init; } = "/api/v1"; + + /// Konfigurierter Pfad zum Auflisten aller Container. Leer = automatische Erkennung. + public string ContainerListPath { get; init; } = string.Empty; + + /// Konfigurierter Pfad zum Neustarten. Platzhalter: {domain}, {container}. + public string ContainerRestartPath { get; init; } = string.Empty; + + /// Konfigurierter Pfad zum Stoppen. Platzhalter: {domain}, {container}. + public string ContainerStopPath { get; init; } = string.Empty; + + /// Konfigurierter Pfad zum Starten. Platzhalter: {domain}, {container}. + public string ContainerStartPath { get; init; } = string.Empty; + + // --------------------------------------------------------------- + // WinRM / PowerShell Remoting (ManagementMode = WinRm) + // --------------------------------------------------------------- + + /// + /// WinRM-Port auf dem Zielrechner (Standard: 5985 = HTTP, 5986 = HTTPS). + /// + public int WinRmPort { get; init; } = 5985; + + /// + /// PowerShell-Scriptblock zum Neustarten eines Containers. + /// Platzhalter: {container} = Container-Name (nicht enkodiert), {domain} = Domain-Name. + /// Beispiel für Windows-Service: "Restart-Service -Name 'CT-ZADBService' -Force" + /// Beispiel für Sonic-Skript: "& 'C:\\Sonic\\bin\\stopContainer.bat' '{container}'; Start-Sleep 5; & 'C:\\Sonic\\bin\\startContainer.bat' '{container}'" + /// + public string WinRmRestartScript { get; init; } = string.Empty; + + /// + /// PowerShell-Scriptblock zum Auflisten aller Container der Domain. + /// Ausgabe: eine Zeile pro Container-Name. + /// Beispiel: "Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty Name" + /// + public string WinRmContainerListScript { get; init; } = string.Empty; + + /// + /// PowerShell-Scriptblock für XApi-Import. + /// Platzhalter: {container}, {xapiPath}. + /// + public string WinRmXapiImportScript { get; init; } = string.Empty; + + // --------------------------------------------------------------- + // Gemeinsame Einstellungen + // --------------------------------------------------------------- + + /// Timeout in Sekunden für einzelne API-/Script-Aufrufe (Standard: 60). + public int TimeoutSeconds { get; init; } = 60; + + /// Wartezeit in Sekunden nach einem Container-Neustart (Standard: 15). + public int PostRestartDelaySeconds { get; init; } = 15; +} + +public enum SonicManagementMode +{ + /// HTTP REST API (wenn vom Sonic-Server bereitgestellt). + HttpApi, + + /// + /// PowerShell Remoting (WinRM) – Standard für Sonic 10.x auf Windows. + /// Führt konfigurierte Scriptblöcke via Invoke-Command auf dem Sonic-Server aus. + /// + WinRm +} diff --git a/ZA.CoreService.ESBCertificateManager/Program.cs b/ZA.CoreService.ESBCertificateManager/Program.cs new file mode 100644 index 0000000..83997e2 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Program.cs @@ -0,0 +1,13 @@ +namespace ZA.CoreService.ESBCertificateManager +{ + internal static class Program + { + + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new Form1()); + } + } +} \ No newline at end of file diff --git a/ZA.CoreService.ESBCertificateManager/Services/CertificateDeployer.cs b/ZA.CoreService.ESBCertificateManager/Services/CertificateDeployer.cs new file mode 100644 index 0000000..ce7d4f2 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/CertificateDeployer.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +public sealed class CertificateDeployer +{ + public Task<(bool Success, string Status, string? Detail, string? BackupPath)> DeployAsync( + string sourceCertificatePath, + DeploymentTarget target, + CancellationToken cancellationToken = default) + { + return Task.Run(() => Deploy(sourceCertificatePath, target, cancellationToken), cancellationToken); + } + + private static (bool Success, string Status, string? Detail, string? BackupPath) Deploy( + string sourceCertificatePath, + DeploymentTarget target, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!File.Exists(sourceCertificatePath)) + { + return (false, "Quelle fehlt", $"Quelldatei nicht gefunden: {sourceCertificatePath}", null); + } + + string targetDirectory = PathResolver.ResolvePath(target.TargetDirectory); + Directory.CreateDirectory(targetDirectory); + + string destinationPath = Path.Combine(targetDirectory, target.CertificateFileName); + string? backupPath = null; + + try + { + if (File.Exists(destinationPath)) + { + backupPath = $"{destinationPath}.bak-{DateTime.Now:yyyyMMddHHmmss}"; + File.Copy(destinationPath, backupPath, overwrite: false); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(sourceCertificatePath, destinationPath, overwrite: true); + + string sourceHash = ComputeSha256(sourceCertificatePath); + string destHash = ComputeSha256(destinationPath); + + if (!string.Equals(sourceHash, destHash, StringComparison.OrdinalIgnoreCase)) + { + RestoreFromBackupOrDelete(destinationPath, backupPath); + return (false, "Hash-Fehler", "SHA-256 von Quelle und Ziel stimmen nicht überein. Rollback ausgeführt.", backupPath); + } + + return (true, "Kopiert", $"Ziel: {destinationPath}", backupPath); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + try + { + if (backupPath is not null && File.Exists(backupPath) && File.Exists(destinationPath)) + { + File.Copy(backupPath, destinationPath, overwrite: true); + } + } + catch + { + // Rollback best-effort + } + + return (false, "Kopierfehler", ex.Message, backupPath); + } + } + + private static void RestoreFromBackupOrDelete(string destinationPath, string? backupPath) + { + if (backupPath is not null && File.Exists(backupPath)) + { + File.Copy(backupPath, destinationPath, overwrite: true); + return; + } + + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + } + + public static string ComputeSha256(string filePath) + { + using FileStream stream = File.OpenRead(filePath); + byte[] hash = SHA256.HashData(stream); + return Convert.ToHexString(hash); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs b/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs new file mode 100644 index 0000000..215194c --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs @@ -0,0 +1,180 @@ +using ZA.CoreService.ESBCertificateManager.Data; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +public sealed class DeploymentOrchestrator +{ + private readonly CertificateDeployer _deployer = new(); + private readonly RestartExecutor _restartExecutor; + private readonly TlsCertificateProbe _tlsProbe; + private readonly PreflightValidator _preflightValidator = new(); + private readonly SqlRunLogger _sqlRunLogger; + private readonly AppSettings _settings; + + public DeploymentOrchestrator(AppSettings settings) + { + _settings = settings; + _tlsProbe = new TlsCertificateProbe(settings.TlsTimeoutSeconds, settings.TlsRetryCount); + _restartExecutor = new RestartExecutor(settings.SonicConnections); + _sqlRunLogger = new SqlRunLogger(settings.ConnectionString); + } + + public PreflightValidationResult ValidatePreflight( + string? certificatePath, + CertificateInfo? certificateInfo, + IReadOnlyList selectedTargets) + => _preflightValidator.Validate(certificatePath, certificateInfo, selectedTargets); + + public async Task RunAsync( + string certificatePath, + CertificateInfo certificateInfo, + IReadOnlyList selectedTargets, + IProgress? progress, + CancellationToken cancellationToken = default) + { + DateTimeOffset startedAt = DateTimeOffset.Now; + List results = []; + + using RunLogger logger = new(_settings.LogDirectory); + logger.Write($"Deployment gestartet für {selectedTargets.Count} Ziel(e). Zertifikat={Path.GetFileName(certificatePath)}"); + + foreach (DeploymentTarget target in selectedTargets) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(await RunTargetAsync(target, certificatePath, certificateInfo, logger, progress, cancellationToken)); + } + + DateTimeOffset finishedAt = DateTimeOffset.Now; + DeploymentRunResult runResult = new() + { + TargetResults = results, + StartedAt = startedAt, + FinishedAt = finishedAt, + CertificateFilePath = certificatePath, + CertificateFingerprint = certificateInfo.FingerprintSha256 + }; + + logger.Write( + $"Deployment beendet. Erfolg={runResult.OverallSuccess}; Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; Log={logger.LogFilePath}"); + + // Ergebnis in SQL-Datenbank protokollieren (wenn ConnectionString konfiguriert) + try + { + await _sqlRunLogger.PersistRunResultAsync(runResult, cancellationToken); + } + catch (Exception ex) + { + logger.Write($"SQL-Protokollierung fehlgeschlagen (nicht kritisch): {ex.Message}"); + } + + return runResult; + } + + private async Task RunTargetAsync( + DeploymentTarget target, + string certificatePath, + CertificateInfo certificateInfo, + RunLogger logger, + IProgress? progress, + CancellationToken cancellationToken) + { + List steps = []; + DateTimeOffset targetStart = DateTimeOffset.Now; + progress?.Report(new TargetProgressUpdate(target.Id, "Läuft…", false)); + + (bool copyOk, string copyStatus, string? copyDetail, _) = + await _deployer.DeployAsync(certificatePath, target, cancellationToken); + + RecordStep(steps, logger, target.Name, "Deploy", copyStatus, copyDetail); + if (!copyOk) + { + return Fail(target, copyStatus, copyDetail, steps, targetStart, progress); + } + + progress?.Report(new TargetProgressUpdate(target.Id, copyStatus, false)); + + (bool restartOk, string restartStatus, string? restartDetail) = + await _restartExecutor.ExecuteAsync(target, cancellationToken); + + RecordStep(steps, logger, target.Name, "Restart", restartStatus, restartDetail); + if (!restartOk) + { + return Fail(target, restartStatus, restartDetail, steps, targetStart, progress, + copySucceeded: copyOk); + } + + progress?.Report(new TargetProgressUpdate(target.Id, restartStatus, false)); + + (bool tlsOk, string tlsStatus, string? tlsDetail, string? observedFingerprint) = await _tlsProbe.ProbeAsync( + target, + certificateInfo.FingerprintSha256, + cancellationToken); + + RecordStep(steps, logger, target.Name, "TLS", tlsStatus, tlsDetail); + + bool success = tlsOk; + string finalStatus = success + ? (string.IsNullOrWhiteSpace(target.TlsHost) ? "Erfolg" : tlsStatus) + : tlsStatus; + + TargetStepResult targetResult = new() + { + TargetId = target.Id, + TargetName = target.Name, + Success = success, + StatusText = finalStatus, + Detail = tlsDetail, + Steps = steps, + StartedAt = targetStart, + FinishedAt = DateTimeOffset.Now, + CopySucceeded = copyOk, + RestartSucceeded = restartOk, + TlsSucceeded = tlsOk, + ObservedFingerprint = observedFingerprint + }; + progress?.Report(new TargetProgressUpdate(target.Id, finalStatus, success)); + return targetResult; + } + + private static void RecordStep( + List steps, + RunLogger logger, + string targetName, + string phase, + string status, + string? detail) + { + steps.Add($"{status}: {detail}"); + logger.Write($"[{targetName}] {phase}: {status} | {detail}"); + } + + private static TargetStepResult Fail( + DeploymentTarget target, + string status, + string? detail, + List steps, + DateTimeOffset startedAt, + IProgress? progress, + bool copySucceeded = false, + bool restartSucceeded = false) + { + progress?.Report(new TargetProgressUpdate(target.Id, status, false)); + return new TargetStepResult + { + TargetId = target.Id, + TargetName = target.Name, + Success = false, + StatusText = status, + Detail = detail, + Steps = steps, + StartedAt = startedAt, + FinishedAt = DateTimeOffset.Now, + CopySucceeded = copySucceeded, + RestartSucceeded = restartSucceeded, + TlsSucceeded = false + }; + } +} + +public readonly record struct TargetProgressUpdate(int TargetId, string StatusText, bool? SuccessHint); diff --git a/ZA.CoreService.ESBCertificateManager/Services/PathResolver.cs b/ZA.CoreService.ESBCertificateManager/Services/PathResolver.cs new file mode 100644 index 0000000..db05936 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/PathResolver.cs @@ -0,0 +1,19 @@ +namespace ZA.CoreService.ESBCertificateManager.Services; + +public static class PathResolver +{ + public static string ResolvePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return path; + } + + if (Path.IsPathRooted(path)) + { + return Path.GetFullPath(path); + } + + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path)); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs b/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs new file mode 100644 index 0000000..524cc20 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs @@ -0,0 +1,106 @@ +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +public sealed class PreflightValidator +{ + public PreflightValidationResult Validate( + string? certificatePath, + CertificateInfo? certificateInfo, + IReadOnlyList selectedTargets) + { + PreflightValidationResult result = new(); + + if (string.IsNullOrWhiteSpace(certificatePath) || !File.Exists(certificatePath)) + { + AddIssue(result, "Es ist keine gültige Zertifikatsdatei ausgewählt."); + } + + if (certificateInfo is null) + { + AddIssue(result, "Zertifikatsmetadaten sind nicht geladen."); + } + else if (!certificateInfo.IsCurrentlyValid) + { + AddIssue(result, "Das geladene Zertifikat ist abgelaufen oder ungültig."); + } + + if (selectedTargets.Count == 0) + { + AddIssue(result, "Bitte mindestens ein Ziel anhaken."); + } + + foreach (DeploymentTarget target in selectedTargets) + { + ValidateTarget(result, target); + } + + return result; + } + + private static void ValidateTarget(PreflightValidationResult result, DeploymentTarget target) + { + if (string.IsNullOrWhiteSpace(target.TargetDirectory)) + { + AddIssue(result, $"Ziel '{target.Name}': TargetDirectory fehlt.", target.Id); + } + + if (string.IsNullOrWhiteSpace(target.CertificateFileName)) + { + AddIssue(result, $"Ziel '{target.Name}': CertificateFileName fehlt.", target.Id); + } + + if (target.RestartType == RestartType.Command + && string.IsNullOrWhiteSpace(target.RestartCommand)) + { + AddIssue(result, $"Ziel '{target.Name}': RestartCommand fehlt bei RestartType=Command.", target.Id); + } + + if (target.RestartType is RestartType.SonicContainer or RestartType.SonicContainerWithXapi) + { + if (string.IsNullOrWhiteSpace(target.ContainerName)) + { + AddIssue(result, $"Ziel '{target.Name}': ContainerName fehlt bei RestartType={target.RestartType}.", target.Id); + } + + if (string.IsNullOrWhiteSpace(target.SonicConnectionName)) + { + AddIssue(result, $"Ziel '{target.Name}': SonicConnectionName fehlt bei RestartType={target.RestartType}.", target.Id); + } + } + + if (target.RestartType == RestartType.SonicContainerWithXapi + && string.IsNullOrWhiteSpace(target.XapiSourcePath)) + { + AddIssue(result, $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.", target.Id); + } + + if (string.IsNullOrWhiteSpace(target.TargetDirectory)) + { + return; + } + + try + { + string directory = PathResolver.ResolvePath(target.TargetDirectory); + Directory.CreateDirectory(directory); + + string probeFile = Path.Combine(directory, $".write-probe-{Guid.NewGuid():N}"); + File.WriteAllText(probeFile, "ok"); + File.Delete(probeFile); + } + catch (Exception ex) + { + AddIssue(result, $"Ziel '{target.Name}': Verzeichnis nicht beschreibbar ({ex.Message}).", target.Id); + } + } + + private static void AddIssue(PreflightValidationResult result, string message, int? targetId = null) + { + result.Issues.Add(new ValidationIssue + { + TargetId = targetId, + Message = message + }); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/RestartExecutor.cs b/ZA.CoreService.ESBCertificateManager/Services/RestartExecutor.cs new file mode 100644 index 0000000..34054cf --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/RestartExecutor.cs @@ -0,0 +1,150 @@ +using System.Diagnostics; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +public sealed class RestartExecutor +{ + private readonly IReadOnlyList _sonicConnections; + + public RestartExecutor(IReadOnlyList sonicConnections) + { + _sonicConnections = sonicConnections; + } + + public Task<(bool Success, string Status, string? Detail)> ExecuteAsync( + DeploymentTarget target, + CancellationToken cancellationToken = default) + => target.RestartType switch + { + RestartType.None => Task.FromResult<(bool, string, string?)>((true, "Neustart übersprungen", "RestartType=None")), + RestartType.Command => ExecuteCommandAsync(target, cancellationToken), + RestartType.SonicContainer => ExecuteSonicRestartAsync(target, importXapi: false, cancellationToken), + RestartType.SonicContainerWithXapi => ExecuteSonicRestartAsync(target, importXapi: true, cancellationToken), + _ => Task.FromResult<(bool, string, string?)>((false, "Unbekannter RestartType", $"RestartType={target.RestartType}")) + }; + + private async Task<(bool Success, string Status, string? Detail)> ExecuteSonicRestartAsync( + DeploymentTarget target, + bool importXapi, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(target.ContainerName)) + { + return (false, "Sonic-Neustart fehlgeschlagen", $"Ziel '{target.Name}': ContainerName fehlt."); + } + + SonicConnection? connection = _sonicConnections + .FirstOrDefault(c => string.Equals(c.Name, target.SonicConnectionName, StringComparison.OrdinalIgnoreCase)); + + if (connection is null) + { + return (false, "Sonic-Verbindung nicht gefunden", + $"Ziel '{target.Name}': SonicConnection '{target.SonicConnectionName}' ist nicht in AppSettings konfiguriert."); + } + + using SonicManagementClient client = new(connection); + + if (importXapi) + { + if (string.IsNullOrWhiteSpace(target.XapiSourcePath)) + { + return (false, "XApi-Import fehlgeschlagen", + $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi."); + } + + return await client.ImportXapiAndRestartAsync(target.ContainerName, target.XapiSourcePath, cancellationToken); + } + + return await client.RestartContainerAsync(target.ContainerName, cancellationToken); + } + + private async Task<(bool Success, string Status, string? Detail)> ExecuteCommandAsync( + DeploymentTarget target, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(target.RestartCommand)) + { + return (true, "Neustart übersprungen", "RestartType=None"); + } + + int timeoutSeconds = Math.Clamp(target.RestartTimeoutSeconds, 1, 600); + + ProcessStartInfo startInfo = new() + { + FileName = target.RestartCommand, + Arguments = target.RestartArguments ?? string.Empty, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + try + { + using Process process = new() { StartInfo = startInfo }; + if (!process.Start()) + { + return (false, "Neustart fehlgeschlagen", "Prozess konnte nicht gestartet werden."); + } + + Task stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } + return (false, "Neustart Timeout", $"Timeout nach {timeoutSeconds}s."); + } + + string detail = BuildDetail(process.ExitCode, await stdoutTask, await stderrTask); + return process.ExitCode == 0 + ? (true, "Neustart ok", detail) + : (false, "Neustart fehlgeschlagen", detail); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return (false, "Neustart fehlgeschlagen", ex.Message); + } + } + + private static string BuildDetail(int exitCode, string stdout, string stderr) + { + string detail = $"ExitCode={exitCode}"; + + stdout = Truncate(stdout).Trim(); + if (!string.IsNullOrWhiteSpace(stdout)) + { + detail += "; out=" + stdout; + } + + stderr = Truncate(stderr).Trim(); + if (!string.IsNullOrWhiteSpace(stderr)) + { + detail += "; err=" + stderr; + } + + return detail; + } + + private static string Truncate(string value, int max = 400) + { + if (string.IsNullOrEmpty(value) || value.Length <= max) + { + return value; + } + + return value[..max] + "…"; + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/RunLogger.cs b/ZA.CoreService.ESBCertificateManager/Services/RunLogger.cs new file mode 100644 index 0000000..301fd25 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/RunLogger.cs @@ -0,0 +1,64 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +public sealed class RunLogger : IDisposable +{ + private static readonly Regex ConnectionStringSecretRegex = new( + @"(Password|Pwd|Passwort)\s*=\s*[^;]+", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex InlineSecretRegex = new( + @"(Password|Pwd)\s*[:=]\s*\S+", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly StreamWriter _writer; + private readonly object _sync = new(); + public string LogFilePath { get; } + + public RunLogger(string logDirectory) + { + string directory = PathResolver.ResolvePath(logDirectory); + Directory.CreateDirectory(directory); + + string fileName = $"run-{DateTime.Now:yyyyMMdd-HHmmss}.log"; + LogFilePath = Path.Combine(directory, fileName); + + _writer = new StreamWriter(LogFilePath, append: false, Encoding.UTF8) + { + AutoFlush = true + }; + + Write($"Run gestartet von {Environment.UserName} auf {Environment.MachineName}"); + } + + public void Write(string message) + { + string line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff} | {Redact(message)}"; + lock (_sync) + { + _writer.WriteLine(line); + } + } + + public void Dispose() + { + lock (_sync) + { + _writer.Dispose(); + } + } + + private static string Redact(string message) + { + if (string.IsNullOrEmpty(message)) + { + return message; + } + + // Keine Passwörter / Connection-Secrets in Logs. + string redacted = ConnectionStringSecretRegex.Replace(message, "$1=***"); + return InlineSecretRegex.Replace(redacted, "$1=***"); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/SonicContainerDiscovery.cs b/ZA.CoreService.ESBCertificateManager/Services/SonicContainerDiscovery.cs new file mode 100644 index 0000000..a2e8077 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/SonicContainerDiscovery.cs @@ -0,0 +1,139 @@ +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +/// +/// Fragt alle konfigurierten Sonic-Management-Verbindungen nach ihren Containern +/// und gleicht die gefundenen Container mit den bereits konfigurierten Deployment-Zielen ab. +/// +public sealed class SonicContainerDiscovery +{ + private readonly IReadOnlyList _connections; + + public SonicContainerDiscovery(IReadOnlyList connections) + { + _connections = connections; + } + + public bool HasConnections => _connections.Count > 0; + public IReadOnlyList Connections => _connections; + + /// + /// Verbindet sich mit der angegebenen Sonic-Instanz, liest die Container-Liste + /// und reichert sie mit konfigurierten Ziel-Metadaten an. + /// + public async Task DiscoverAsync( + string connectionName, + IReadOnlyList knownTargets, + CancellationToken cancellationToken = default) + { + SonicConnection? connection = _connections + .FirstOrDefault(c => string.Equals(c.Name, connectionName, StringComparison.OrdinalIgnoreCase)); + + if (connection is null) + { + return SonicDiscoveryResult.Failed(connectionName, + $"Sonic-Verbindung '{connectionName}' ist nicht in AppSettings konfiguriert."); + } + + using SonicManagementClient client = new(connection); + + (bool reachable, string? pingError, string? resolvedPath) = await client.CheckConnectionAsync(cancellationToken); + if (!reachable) + { + return SonicDiscoveryResult.Failed(connectionName, + $"Management-Konsole nicht erreichbar: {pingError}"); + } + + (bool listOk, IReadOnlyList containerNames, string? listError) = + await client.GetContainersAsync(cancellationToken); + + if (!listOk) + { + // Fallback: CheckConnection erfolgreich, aber /containers nicht gefunden – + // dies kann passieren wenn die API-Pfade abweichen. Container-Namen sind dann leer. + containerNames = []; + } + + List discovered = BuildTargets(connection, containerNames, knownTargets); + + return new SonicDiscoveryResult + { + ConnectionName = connectionName, + Success = true, + ErrorMessage = listOk ? null : $"Container-Liste konnte nicht geladen werden: {listError}", + DiscoveredTargets = discovered, + RawContainerNames = containerNames + }; + } + + private static List BuildTargets( + SonicConnection connection, + IReadOnlyList containerNames, + IReadOnlyList knownTargets) + { + List result = []; + int syntheticId = -1; + + foreach (string containerName in containerNames) + { + // Bekanntes, voll-konfiguriertes Ziel suchen (nach ContainerName + SonicConnectionName) + DeploymentTarget? existing = knownTargets.FirstOrDefault(t => + string.Equals(t.ContainerName, containerName, StringComparison.OrdinalIgnoreCase) && + string.Equals(t.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase)); + + if (existing is not null) + { + result.Add(existing); + } + else + { + // Minimales Ziel aus der Discovery erzeugen. + // TargetDirectory/CertificateFileName sind leer → Preflight-Validator zeigt Warnung. + result.Add(new DeploymentTarget + { + Id = syntheticId--, + Name = $"{connection.Name} / {containerName}", + Environment = connection.DomainName, + IsActive = true, + TargetDirectory = string.Empty, + CertificateFileName = string.Empty, + ContainerName = containerName, + RestartType = RestartType.SonicContainer, + SonicConnectionName = connection.Name, + SortOrder = result.Count * 10 + }); + } + } + + // Konfigurierte Ziele dieser Verbindung, die NICHT in der Discovery aufgetaucht sind, + // trotzdem anzeigen (könnten offline / gestoppt sein). + foreach (DeploymentTarget known in knownTargets) + { + if (!string.Equals(known.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase)) + continue; + + bool alreadyAdded = result.Any(r => + string.Equals(r.ContainerName, known.ContainerName, StringComparison.OrdinalIgnoreCase)); + + if (!alreadyAdded) + { + result.Add(known); + } + } + + return result; + } +} + +public sealed class SonicDiscoveryResult +{ + public required string ConnectionName { get; init; } + public bool Success { get; init; } + public string? ErrorMessage { get; init; } + public IReadOnlyList DiscoveredTargets { get; init; } = []; + public IReadOnlyList RawContainerNames { get; init; } = []; + + public static SonicDiscoveryResult Failed(string connectionName, string error) + => new() { ConnectionName = connectionName, Success = false, ErrorMessage = error }; +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs b/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs new file mode 100644 index 0000000..ff1a242 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs @@ -0,0 +1,503 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +/// +/// Verwaltet Sonic ESB Container über die Management Console. +/// +/// Modus = WinRm (Standard, Sonic 10.x): +/// PowerShell Remoting (Invoke-Command) auf dem Sonic-Server. +/// Erfordert WinRM auf dem Zielrechner: Enable-PSRemoting -Force +/// +/// Modus = HttpApi: +/// HTTP REST API mit automatischer Pfad-Erkennung. +/// Probiert: /mf/rest/v1, /api/v1, /sonic/management, /containers +/// +public sealed class SonicManagementClient : IDisposable +{ + private readonly SonicConnection _connection; + private readonly HttpClient? _http; + private readonly WinRmExecutor? _winRm; + + // Gecachter HTTP-Basis-Pfad (nur im HttpApi-Modus) + private string? _resolvedContainerBasePath; + + private static readonly string[] CandidateContainerPaths = + [ + "/mf/rest/v1/domains/{domain}/containers", + "/api/v1/domains/{domain}/containers", + "/sonic/management/domains/{domain}/containers", + "/containers" + ]; + + public SonicManagementClient(SonicConnection connection) + { + _connection = connection; + + if (connection.ManagementMode == SonicManagementMode.WinRm) + { + _winRm = new WinRmExecutor(connection); + } + else + { + HttpClientHandler handler = new() + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + + _http = new HttpClient(handler) + { + BaseAddress = BuildHttpBaseUri(connection.ConnectionUrl, connection.ManagementHttpPort), + Timeout = TimeSpan.FromSeconds(Math.Clamp(connection.TimeoutSeconds, 5, 300)) + }; + + string credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{connection.Username}:{connection.Password}")); + _http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", credentials); + _http.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + } + } + + // --------------------------------------------------------------- + // Öffentliche API + // --------------------------------------------------------------- + + /// + /// Prüft die Verbindung zur Management Console. + /// WinRm: TCP-Ping auf WinRM-Port + Test-PSSession. + /// Http: Probe gegen bekannte API-Pfade. + /// + public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync( + CancellationToken cancellationToken = default) + { + if (_connection.ManagementMode == SonicManagementMode.WinRm) + { + (bool ok, string? error) = await _winRm!.TestConnectionAsync(cancellationToken); + return (ok, error, ok ? $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}" : null); + } + + try + { + string? path = await ResolveContainerBasePathAsync(cancellationToken); + if (path is null) + { + return (false, + $"Keine Sonic HTTP-API unter {_http!.BaseAddress} gefunden.\n" + + $"Geprüfte Pfade: {string.Join(", ", GetCandidatePaths())}\n" + + $"Tipp: ManagementMode auf 'WinRm' setzen falls kein HTTP-API vorhanden.", + null); + } + + return (true, null, path); + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (false, + $"HTTP-Timeout nach {_connection.TimeoutSeconds}s – Port {_connection.ManagementHttpPort} nicht erreichbar.\n" + + $"Tipp: ManagementMode auf 'WinRm' setzen.", + null); + } + catch (Exception ex) + { + return (false, ex.Message, null); + } + } + + /// + /// Listet alle Container der Domain auf. + /// + public async Task<(bool Success, IReadOnlyList ContainerNames, string? Error)> GetContainersAsync( + CancellationToken cancellationToken = default) + { + if (_connection.ManagementMode == SonicManagementMode.WinRm) + { + return await GetContainersViaWinRmAsync(cancellationToken); + } + + return await GetContainersViaHttpAsync(cancellationToken); + } + + /// + /// Startet den Container neu. + /// + public async Task<(bool Success, string Status, string? Detail)> RestartContainerAsync( + string containerName, + CancellationToken cancellationToken = default) + { + if (_connection.ManagementMode == SonicManagementMode.WinRm) + { + return await RestartViaWinRmAsync(containerName, cancellationToken); + } + + return await RestartViaHttpAsync(containerName, cancellationToken); + } + + /// + /// Importiert XApi-Ressourcen und startet den Container neu. + /// + public async Task<(bool Success, string Status, string? Detail)> ImportXapiAndRestartAsync( + string containerName, + string xapiSourcePath, + CancellationToken cancellationToken = default) + { + if (_connection.ManagementMode == SonicManagementMode.WinRm) + { + return await ImportXapiViaWinRmAsync(containerName, xapiSourcePath, cancellationToken); + } + + return await ImportXapiViaHttpAsync(containerName, xapiSourcePath, cancellationToken); + } + + // --------------------------------------------------------------- + // WinRM-Implementierungen + // --------------------------------------------------------------- + + private async Task<(bool, IReadOnlyList, string?)> GetContainersViaWinRmAsync( + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_connection.WinRmContainerListScript)) + { + return (false, [], + "WinRmContainerListScript ist nicht konfiguriert.\n" + + "Beispiel: \"Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty DisplayName\""); + } + + string script = WinRmExecutor.ApplyScriptTemplate( + _connection.WinRmContainerListScript, + containerName: string.Empty, + domainName: _connection.DomainName); + + (bool ok, string? output, string? error) = + await _winRm!.RunScriptAsync(script, cancellationToken); + + if (!ok) + { + return (false, [], $"WinRM Container-Liste fehlgeschlagen: {error}"); + } + + List names = (output ?? string.Empty) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(l => l.Length > 0) + .ToList(); + + return (true, names, null); + } + + private async Task<(bool, string, string?)> RestartViaWinRmAsync( + string containerName, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_connection.WinRmRestartScript)) + { + return (false, "Neustart fehlgeschlagen", + "WinRmRestartScript ist nicht konfiguriert.\n" + + "Beispiel für Windows-Service: \"Restart-Service -Name 'CT-ZADBService' -Force\"\n" + + "Platzhalter {container} wird durch den Container-Namen ersetzt."); + } + + string script = WinRmExecutor.ApplyScriptTemplate( + _connection.WinRmRestartScript, + containerName, + _connection.DomainName); + + (bool ok, string? output, string? error) = + await _winRm!.RunScriptAsync(script, cancellationToken); + + if (!ok) + { + return (false, "Neustart fehlgeschlagen (WinRM)", + $"Fehler: {error}\nAusgabe: {output}"); + } + + await Task.Delay( + TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)), + cancellationToken); + + return (true, "Container neugestartet (WinRM)", + $"Ausgabe: {output ?? "(keine)"}"); + } + + private async Task<(bool, string, string?)> ImportXapiViaWinRmAsync( + string containerName, string xapiSourcePath, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript)) + { + return (false, "XApi-Import fehlgeschlagen", + "WinRmXapiImportScript ist nicht konfiguriert."); + } + + string script = WinRmExecutor.ApplyScriptTemplate( + _connection.WinRmXapiImportScript, + containerName, + _connection.DomainName, + xapiSourcePath); + + (bool importOk, string? importOut, string? importErr) = + await _winRm!.RunScriptAsync(script, cancellationToken); + + if (!importOk) + { + return (false, "XApi-Import fehlgeschlagen (WinRM)", importErr ?? importOut); + } + + (bool restartOk, string restartStatus, string? restartDetail) = + await RestartViaWinRmAsync(containerName, cancellationToken); + + return restartOk + ? (true, "XApi importiert + Container neugestartet (WinRM)", + $"Import: {importOut} | Restart: {restartDetail}") + : (false, restartStatus, restartDetail); + } + + // --------------------------------------------------------------- + // HTTP-Implementierungen + // --------------------------------------------------------------- + + private async Task<(bool, IReadOnlyList, string?)> GetContainersViaHttpAsync( + CancellationToken cancellationToken) + { + try + { + string? basePath = await ResolveContainerBasePathAsync(cancellationToken); + if (basePath is null) + { + return (false, [], "Kein HTTP-API-Pfad gefunden. ManagementMode auf 'WinRm' setzen?"); + } + + using HttpResponseMessage response = await _http!.GetAsync(basePath, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return (false, [], $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"); + } + + string json = await response.Content.ReadAsStringAsync(cancellationToken); + return (true, ParseContainerNames(json), null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return (false, [], ex.Message); + } + } + + private async Task<(bool, string, string?)> RestartViaHttpAsync( + string containerName, CancellationToken cancellationToken) + { + string encoded = Uri.EscapeDataString(containerName); + + try + { + string? basePath = await ResolveContainerBasePathAsync(cancellationToken); + if (basePath is null) + { + return (false, "Neustart fehlgeschlagen", + "Kein HTTP-API-Pfad gefunden. ManagementMode auf 'WinRm' setzen."); + } + + if (!string.IsNullOrWhiteSpace(_connection.ContainerRestartPath)) + { + string customPath = ApplyTemplate(_connection.ContainerRestartPath, encoded); + (bool ok, _, string? d) = await PostAsync(customPath, cancellationToken); + if (ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet", d); } + } + + (bool r1Ok, _, string? r1d) = await PostAsync($"{basePath}/{encoded}/restart", cancellationToken); + if (r1Ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet", r1d); } + + (bool r2Ok, _, string? r2d) = await PutStateAsync(basePath, encoded, "running", cancellationToken); + if (r2Ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet (State-API)", r2d); } + + return await StopThenStartAsync(basePath, encoded, cancellationToken); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { return (false, "Neustart fehlgeschlagen", ex.Message); } + } + + private async Task<(bool, string, string?)> ImportXapiViaHttpAsync( + string containerName, string xapiSourcePath, CancellationToken cancellationToken) + { + if (!File.Exists(xapiSourcePath)) + { + return (false, "XApi-Import fehlgeschlagen", $"Quelldatei nicht gefunden: {xapiSourcePath}"); + } + + string encoded = Uri.EscapeDataString(containerName); + string? basePath = await ResolveContainerBasePathAsync(cancellationToken); + if (basePath is null) return (false, "XApi-Import fehlgeschlagen", "Kein HTTP-API-Pfad."); + + string path = $"{basePath}/{encoded}/xapi/import"; + await using FileStream fs = File.OpenRead(xapiSourcePath); + string mt = xapiSourcePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "application/zip" : "application/xml"; + using StreamContent content = new(fs); + content.Headers.ContentType = new MediaTypeHeaderValue(mt); + using HttpResponseMessage rsp = await _http!.PostAsync(path, content, cancellationToken); + + if (!rsp.IsSuccessStatusCode) + { + string body = await rsp.Content.ReadAsStringAsync(cancellationToken); + return (false, "XApi-Import fehlgeschlagen", $"HTTP {(int)rsp.StatusCode}: {Truncate(body)}"); + } + + (bool restartOk, string rs, string? rd) = await RestartViaHttpAsync(containerName, cancellationToken); + return restartOk + ? (true, "XApi importiert + Container neugestartet", rd) + : (false, rs, rd); + } + + // --------------------------------------------------------------- + // HTTP-Pfad-Erkennung + // --------------------------------------------------------------- + + private async Task ResolveContainerBasePathAsync(CancellationToken cancellationToken) + { + if (_resolvedContainerBasePath is not null) return _resolvedContainerBasePath; + + foreach (string p in GetCandidatePaths()) + { + try + { + using HttpResponseMessage r = await _http!.GetAsync(p, cancellationToken); + if (r.IsSuccessStatusCode || r.StatusCode == HttpStatusCode.Unauthorized) + { + _resolvedContainerBasePath = p; + return p; + } + } + catch (HttpRequestException) { } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { } + } + + return null; + } + + private IEnumerable GetCandidatePaths() + { + if (!string.IsNullOrWhiteSpace(_connection.ContainerListPath)) + yield return ApplyTemplate(_connection.ContainerListPath, string.Empty).TrimEnd('/'); + + if (!string.IsNullOrWhiteSpace(_connection.ApiBasePath)) + { + string d = Uri.EscapeDataString(_connection.DomainName); + yield return $"{_connection.ApiBasePath.TrimEnd('/')}/domains/{d}/containers"; + } + + foreach (string pattern in CandidateContainerPaths) + yield return pattern.Replace("{domain}", Uri.EscapeDataString(_connection.DomainName), StringComparison.OrdinalIgnoreCase); + } + + // --------------------------------------------------------------- + // HTTP-Aktions-Helfer + // --------------------------------------------------------------- + + private async Task<(bool, string, string?)> PostAsync(string path, CancellationToken ct) + { + using HttpResponseMessage r = await _http!.PostAsync(path, null, ct); + if (r.IsSuccessStatusCode) + return (true, $"OK ({(int)r.StatusCode})", $"POST {path} → {(int)r.StatusCode}"); + string body = await r.Content.ReadAsStringAsync(ct); + return (false, $"Fehler ({(int)r.StatusCode})", $"POST {path} → {(int)r.StatusCode}: {Truncate(body)}"); + } + + private async Task<(bool, string, string?)> PutStateAsync( + string basePath, string encoded, string state, CancellationToken ct) + { + string path = $"{basePath}/{encoded}"; + using StringContent body = new($"{{\"state\":\"{state}\"}}", Encoding.UTF8, "application/json"); + using HttpResponseMessage r = await _http!.PutAsync(path, body, ct); + if (r.IsSuccessStatusCode) + return (true, $"State={state}", $"PUT {path} state={state} → {(int)r.StatusCode}"); + string b = await r.Content.ReadAsStringAsync(ct); + return (false, "State fehlgeschlagen", $"PUT {path} → {(int)r.StatusCode}: {Truncate(b)}"); + } + + private async Task<(bool, string, string?)> StopThenStartAsync( + string basePath, string encoded, CancellationToken ct) + { + string stopPath = string.IsNullOrWhiteSpace(_connection.ContainerStopPath) + ? $"{basePath}/{encoded}/stop" + : ApplyTemplate(_connection.ContainerStopPath, encoded); + + string startPath = string.IsNullOrWhiteSpace(_connection.ContainerStartPath) + ? $"{basePath}/{encoded}/start" + : ApplyTemplate(_connection.ContainerStartPath, encoded); + + (bool sOk, _, string? sd) = await PostAsync(stopPath, ct); + if (!sOk) return (false, "Container-Stop fehlgeschlagen", sd); + + await Task.Delay(TimeSpan.FromSeconds(3), ct); + + (bool stOk, _, string? std) = await PostAsync(startPath, ct); + if (!stOk) return (false, "Container-Start fehlgeschlagen", std); + + await DelayAsync(ct); + return (true, "Container neugestartet (Stop+Start)", $"{sd} | {std}"); + } + + private async Task DelayAsync(CancellationToken ct) + { + int d = Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120); + if (d > 0) await Task.Delay(TimeSpan.FromSeconds(d), ct); + } + + private string ApplyTemplate(string template, string encodedName) + => template + .Replace("{domain}", Uri.EscapeDataString(_connection.DomainName), StringComparison.OrdinalIgnoreCase) + .Replace("{container}", encodedName, StringComparison.OrdinalIgnoreCase); + + private static Uri BuildHttpBaseUri(string connectionUrl, int httpPort) + { + try { return new Uri($"http://{new Uri(connectionUrl).Host}:{httpPort}"); } + catch { return new Uri(connectionUrl); } + } + + private static string ExtractHost(string connectionUrl) + { + try { return new Uri(connectionUrl).Host; } + catch { return connectionUrl; } + } + + private static List ParseContainerNames(string json) + { + try + { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + List names = []; + + IEnumerable elements = root.ValueKind == JsonValueKind.Array + ? root.EnumerateArray() + : root.ValueKind == JsonValueKind.Object + ? new[] { "containers", "data", "items", "result" } + .Where(root.TryGetProperty) + .SelectMany(k => { root.TryGetProperty(k, out JsonElement a); return a.EnumerateArray(); }) + : []; + + foreach (JsonElement el in elements) + { + string? name = el.ValueKind == JsonValueKind.String + ? el.GetString() + : new[] { "name", "containerName", "id", "configId" } + .Where(k => el.TryGetProperty(k, out _)) + .Select(k => { el.TryGetProperty(k, out JsonElement p); return p.GetString(); }) + .FirstOrDefault(); + if (name is not null) names.Add(name); + } + + return names; + } + catch { return []; } + } + + private static string Truncate(string v, int max = 400) + => v.Length <= max ? v : v[..max] + "…"; + + public void Dispose() + { + _http?.Dispose(); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/TlsCertificateProbe.cs b/ZA.CoreService.ESBCertificateManager/Services/TlsCertificateProbe.cs new file mode 100644 index 0000000..8d808c6 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/TlsCertificateProbe.cs @@ -0,0 +1,135 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +public sealed class TlsCertificateProbe +{ + private readonly int _timeoutSeconds; + private readonly int _retryCount; + + public TlsCertificateProbe(int timeoutSeconds = 8, int retryCount = 2) + { + _timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 60); + _retryCount = Math.Clamp(retryCount, 0, 5); + } + + public async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeAsync( + DeploymentTarget target, + string expectedFingerprintSha256, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(target.TlsHost)) + { + return (true, "TLS übersprungen", "Kein TlsHost konfiguriert.", null); + } + + int port = target.TlsPort is > 0 and <= 65535 ? target.TlsPort.Value : 443; + + // TlsServerName überschreibt den SNI-Hostnamen wenn gesetzt (wichtig bei IP-Adressen) + string serverName = string.IsNullOrWhiteSpace(target.TlsServerName) + ? target.TlsHost + : target.TlsServerName; + + string expected = NormalizeFingerprint(expectedFingerprintSha256); + + Exception? lastError = null; + + for (int attempt = 0; attempt <= _retryCount; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await ProbeOnceAsync( + target.TlsHost, + port, + serverName, + expected, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lastError = ex; + if (attempt < _retryCount) + { + await Task.Delay(400, cancellationToken); + } + } + } + + return ( + false, + "TLS nicht erreichbar", + lastError?.Message ?? $"Keine Verbindung zu {target.TlsHost}:{port}", + null); + } + + private async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeOnceAsync( + string host, + int port, + string serverName, + string expectedFingerprint, + CancellationToken cancellationToken) + { + using TcpClient client = new(); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds)); + + await client.ConnectAsync(host, port, timeoutCts.Token); + + await using SslStream sslStream = new( + client.GetStream(), + leaveInnerStreamOpen: false, + userCertificateValidationCallback: static (_, _, _, _) => true); + + await sslStream.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions + { + TargetHost = serverName, // SNI: muss zum CN/SAN im Zertifikat passen + EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 + | System.Security.Authentication.SslProtocols.Tls13 + }, + timeoutCts.Token); + + if (sslStream.RemoteCertificate is null) + { + return (false, "TLS Fail", "Kein Remote-Zertifikat erhalten.", null); + } + + using X509Certificate2 remote = new(sslStream.RemoteCertificate); + string actual = Convert.ToHexString(SHA256.HashData(remote.RawData)); + + if (string.IsNullOrEmpty(expectedFingerprint)) + { + // Kein erwarteter Fingerprint konfiguriert – nur Konnektivität prüfen + return (true, "TLS Pass (kein Fingerprint-Vergleich)", $"{host}:{port} erreichbar. Fingerprint={actual}", actual); + } + + if (string.Equals(actual, expectedFingerprint, StringComparison.OrdinalIgnoreCase)) + { + return (true, "TLS Pass", $"{host}:{port} Fingerprint stimmt überein.", actual); + } + + return ( + false, + "TLS Fail", + $"{host}:{port} Fingerprint weicht ab. Erwartet={expectedFingerprint}, Ist={actual}", + actual); + } + + private static string NormalizeFingerprint(string fingerprint) + { + return fingerprint + .Replace(":", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .ToUpperInvariant(); + } +} diff --git a/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs b/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs new file mode 100644 index 0000000..f1baefc --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using System.Text; +using ZA.CoreService.ESBCertificateManager.Models; + +namespace ZA.CoreService.ESBCertificateManager.Services; + +/// +/// Führt PowerShell-Befehle via WinRM (Invoke-Command) auf dem Sonic-Server aus. +/// +/// Voraussetzungen auf dem Zielrechner: +/// - WinRM muss aktiviert sein: Enable-PSRemoting -Force +/// - Ausführungsrichtlinie: Set-ExecutionPolicy RemoteSigned +/// +/// Voraussetzungen auf dem App-Rechner (einmalig, als Admin): +/// - Set-Item WSMan:\localhost\Client\TrustedHosts -Value "dekun-painwbdet" +/// +public sealed class WinRmExecutor +{ + private readonly SonicConnection _connection; + + public WinRmExecutor(SonicConnection connection) + { + _connection = connection; + } + + /// + /// Prüft die WinRM-Konnektivität und ob der Sonic-Server per TCP erreichbar ist. + /// + public async Task<(bool Success, string? Error)> TestConnectionAsync( + CancellationToken cancellationToken = default) + { + string host = ExtractHost(_connection.ConnectionUrl); + + // TCP-Ping auf WinRM-Port + try + { + using System.Net.Sockets.TcpClient tcp = new(); + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 5, 30))); + + await tcp.ConnectAsync(host, _connection.WinRmPort, cts.Token); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return (false, + $"WinRM-Port {_connection.WinRmPort} auf '{host}' nicht erreichbar: {ex.Message}\n" + + $"Auf dem Zielrechner ausführen: Enable-PSRemoting -Force"); + } + + // Kurztest: Hostname zurückgeben + (bool ok, string? output, string? error) = await RunScriptAsync( + "$env:COMPUTERNAME", cancellationToken); + + return ok + ? (true, null) + : (false, $"WinRM-Verbindung fehlgeschlagen: {error}"); + } + + /// + /// Führt einen Scriptblock auf dem Remote-Rechner aus und gibt Stdout zurück. + /// + public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync( + string scriptBlock, + CancellationToken cancellationToken = default) + { + string host = ExtractHost(_connection.ConnectionUrl); + + // Passwort als SecureString – bleibt im PowerShell-Prozess, wird nicht als Argument übergeben + // Stattdessen: Scriptblock über stdin senden + string fullScript = BuildScript(host, scriptBlock); + + ProcessStartInfo psi = new() + { + FileName = "powershell.exe", + Arguments = "-NonInteractive -NoProfile -ExecutionPolicy Bypass -Command -", + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardInputEncoding = Encoding.UTF8 + }; + + using Process process = new() { StartInfo = psi }; + + if (!process.Start()) + { + return (false, null, "PowerShell-Prozess konnte nicht gestartet werden."); + } + + // Skript über stdin – Credentials gehen NICHT als sichtbares Argument durch + await process.StandardInput.WriteAsync(fullScript); + process.StandardInput.Close(); + + using CancellationTokenSource timeoutCts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 10, 600))); + + Task stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } + return (false, null, $"WinRM-Ausführung Timeout nach {_connection.TimeoutSeconds}s."); + } + + string stdout = (await stdoutTask).Trim(); + string stderr = (await stderrTask).Trim(); + + if (process.ExitCode == 0) + { + return (true, stdout, stderr.Length > 0 ? stderr : null); + } + + return (false, stdout.Length > 0 ? stdout : null, + stderr.Length > 0 ? Truncate(stderr) : $"PowerShell ExitCode={process.ExitCode}"); + } + + private string BuildScript(string host, string scriptBlock) + { + // Passwort über Variable, nicht als Argument – verhindert Sichtbarkeit in Prozessliste + string escapedPwd = _connection.Password.Replace("'", "''"); + string escapedUser = _connection.Username.Replace("'", "''"); + string escapedHost = host.Replace("'", "''"); + + return + "$ErrorActionPreference = 'Stop'\n" + + $"$secPwd = ConvertTo-SecureString '{escapedPwd}' -AsPlainText -Force\n" + + $"$cred = New-Object System.Management.Automation.PSCredential('{escapedUser}', $secPwd)\n" + + $"Invoke-Command -ComputerName '{escapedHost}' -Port {_connection.WinRmPort} -Credential $cred -ScriptBlock {{\n" + + $" {scriptBlock}\n" + + "} -ErrorAction Stop"; + } + + /// + /// Ersetzt Platzhalter in einem konfigurierten WinRM-Script. + /// {container} → Container-Name (einfache Hochkommas werden verdoppelt) + /// {domain} → Domain-Name + /// {xapiPath} → Pfad zur XApi-Quelldatei + /// + public static string ApplyScriptTemplate(string template, string containerName, + string domainName = "", string xapiPath = "") + => template + .Replace("{container}", containerName.Replace("'", "''"), + StringComparison.OrdinalIgnoreCase) + .Replace("{domain}", domainName.Replace("'", "''"), + StringComparison.OrdinalIgnoreCase) + .Replace("{xapiPath}", xapiPath.Replace("'", "''"), + StringComparison.OrdinalIgnoreCase); + + private static string ExtractHost(string connectionUrl) + { + try { return new Uri(connectionUrl).Host; } + catch { return connectionUrl; } + } + + private static string Truncate(string s, int max = 600) + => s.Length <= max ? s : s[..max] + "…"; +} diff --git a/ZA.CoreService.ESBCertificateManager/Sql/001_CreateSchema.sql b/ZA.CoreService.ESBCertificateManager/Sql/001_CreateSchema.sql new file mode 100644 index 0000000..7f0e864 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Sql/001_CreateSchema.sql @@ -0,0 +1,157 @@ +/* + ESB Certificate Manager – Zielkonfiguration (SQL Server) + Offline vorbereitet; zur späteren Nutzung auf dem Ziel-SQL-Server ausführen. +*/ + +IF OBJECT_ID(N'dbo.SonicConnection', N'U') IS NULL +BEGIN + CREATE TABLE dbo.SonicConnection + ( + Id INT NOT NULL IDENTITY(1, 1), + Name NVARCHAR(128) NOT NULL, -- Eindeutiger Bezeichner, referenziert von DeploymentTarget + DomainName NVARCHAR(128) NOT NULL, -- Sonic-Domain-Name, z.B. "proalpha-test" + ManagementUrl NVARCHAR(512) NOT NULL, -- HTTP-URL der Management Console, z.B. "http://host:8080" + ApiBasePath NVARCHAR(128) NOT NULL CONSTRAINT DF_SonicConnection_ApiBasePath DEFAULT (N'/api/v1'), + Username NVARCHAR(128) NOT NULL, + -- Passwort wird in der Anwendung verschlüsselt gespeichert; hier nur als Verweis + PasswordHash NVARCHAR(512) NOT NULL CONSTRAINT DF_SonicConnection_PwdHash DEFAULT (N''), + TimeoutSeconds INT NOT NULL CONSTRAINT DF_SonicConnection_Timeout DEFAULT (30), + PostRestartDelaySecs INT NOT NULL CONSTRAINT DF_SonicConnection_Delay DEFAULT (15), + IsActive BIT NOT NULL CONSTRAINT DF_SonicConnection_IsActive DEFAULT (1), + CONSTRAINT PK_SonicConnection PRIMARY KEY CLUSTERED (Id), + CONSTRAINT UQ_SonicConnection_Name UNIQUE (Name) + ); +END +GO + +IF OBJECT_ID(N'dbo.DeploymentTarget', N'U') IS NULL +BEGIN + CREATE TABLE dbo.DeploymentTarget + ( + Id INT NOT NULL IDENTITY(1, 1), + Name NVARCHAR(128) NOT NULL, + Environment NVARCHAR(64) NOT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_DeploymentTarget_IsActive DEFAULT (1), + TargetDirectory NVARCHAR(512) NOT NULL, + CertificateFileName NVARCHAR(260) NOT NULL, + ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentTarget_Container DEFAULT (N''), + RestartType NVARCHAR(32) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartType DEFAULT (N'None'), + RestartCommand NVARCHAR(512) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartCmd DEFAULT (N''), + RestartArguments NVARCHAR(1024) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartArgs DEFAULT (N''), + RestartTimeoutSeconds INT NOT NULL CONSTRAINT DF_DeploymentTarget_RestartTimeout DEFAULT (60), + -- Sonic ESB Management + SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentTarget_SonicConn DEFAULT (N''), + XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DeploymentTarget_XapiSrc DEFAULT (N''), + -- TLS-Probe + TlsHost NVARCHAR(255) NOT NULL CONSTRAINT DF_DeploymentTarget_TlsHost DEFAULT (N''), + TlsPort INT NULL, + SortOrder INT NOT NULL CONSTRAINT DF_DeploymentTarget_SortOrder DEFAULT (0), + CONSTRAINT PK_DeploymentTarget PRIMARY KEY CLUSTERED (Id), + CONSTRAINT CK_DeploymentTarget_RestartType CHECK ( + RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi') + ) + ); +END +ELSE +BEGIN + -- Neue Spalten zu bestehender Tabelle hinzufügen (idempotent) + IF NOT EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID(N'dbo.DeploymentTarget') + AND name = N'SonicConnectionName') + BEGIN + ALTER TABLE dbo.DeploymentTarget + ADD SonicConnectionName NVARCHAR(128) NOT NULL + CONSTRAINT DF_DeploymentTarget_SonicConn DEFAULT (N''); + END + + IF NOT EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID(N'dbo.DeploymentTarget') + AND name = N'XapiSourcePath') + BEGIN + ALTER TABLE dbo.DeploymentTarget + ADD XapiSourcePath NVARCHAR(512) NOT NULL + CONSTRAINT DF_DeploymentTarget_XapiSrc DEFAULT (N''); + END + + -- CHECK-Constraint um neue RestartType-Werte erweitern + IF EXISTS (SELECT 1 FROM sys.check_constraints + WHERE parent_object_id = OBJECT_ID(N'dbo.DeploymentTarget') + AND name = N'CK_DeploymentTarget_RestartType') + BEGIN + ALTER TABLE dbo.DeploymentTarget DROP CONSTRAINT CK_DeploymentTarget_RestartType; + ALTER TABLE dbo.DeploymentTarget ADD CONSTRAINT CK_DeploymentTarget_RestartType + CHECK (RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi')); + END +END +GO + +IF OBJECT_ID(N'dbo.DeploymentRunHistory', N'U') IS NULL +BEGIN + CREATE TABLE dbo.DeploymentRunHistory + ( + Id BIGINT NOT NULL IDENTITY(1, 1), + StartedAtUtc DATETIME2(3) NOT NULL, + FinishedAtUtc DATETIME2(3) NULL, + UserName NVARCHAR(128) NOT NULL, + MachineName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentRunHistory_Machine DEFAULT (N''), + OverallSuccess BIT NULL, + Summary NVARCHAR(2000) NULL, + CONSTRAINT PK_DeploymentRunHistory PRIMARY KEY CLUSTERED (Id) + ); +END +ELSE +BEGIN + IF NOT EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID(N'dbo.DeploymentRunHistory') + AND name = N'MachineName') + BEGIN + ALTER TABLE dbo.DeploymentRunHistory + ADD MachineName NVARCHAR(128) NOT NULL + CONSTRAINT DF_DeploymentRunHistory_Machine DEFAULT (N''); + END +END +GO + +IF OBJECT_ID(N'dbo.DeploymentRunDetail', N'U') IS NULL +BEGIN + CREATE TABLE dbo.DeploymentRunDetail + ( + Id BIGINT NOT NULL IDENTITY(1, 1), + RunId BIGINT NOT NULL, + TargetId INT NOT NULL, + TargetName NVARCHAR(128) NOT NULL, + Success BIT NOT NULL, + StatusText NVARCHAR(256) NOT NULL, + Detail NVARCHAR(2000) NULL, + Steps NVARCHAR(MAX) NULL, + CONSTRAINT PK_DeploymentRunDetail PRIMARY KEY CLUSTERED (Id), + CONSTRAINT FK_DeploymentRunDetail_Run + FOREIGN KEY (RunId) REFERENCES dbo.DeploymentRunHistory (Id) + ); +END +GO + +/* +Beispiel-Insert: + +INSERT INTO dbo.SonicConnection (Name, DomainName, ManagementUrl, Username, PasswordHash) +VALUES (N'DE-Test', N'proalpha-test', N'http://dekun-painwbdet:8080', N'Administrator', N''); + +INSERT INTO dbo.DeploymentTarget +( + Name, Environment, IsActive, TargetDirectory, CertificateFileName, + ContainerName, RestartType, SonicConnectionName, + TlsHost, TlsPort, SortOrder +) +VALUES +( + N'DE-Test Container A', N'TEST', 1, N'\\share\esb\certs\a', N'esb-cert.cer', + N'sonic-container-a', N'SonicContainer', N'DE-Test', + N'dekun-painwbdet', 13070, 10 +), +( + N'DE-Test Container B (XApi)', N'TEST', 1, N'\\share\esb\certs\b', N'esb-cert.cer', + N'sonic-container-b', N'SonicContainerWithXapi', N'DE-Test', + N'dekun-painwbdet', 13070, 20 +); +*/ diff --git a/ZA.CoreService.ESBCertificateManager/Sql/002_DeploymentRunModel.sql b/ZA.CoreService.ESBCertificateManager/Sql/002_DeploymentRunModel.sql new file mode 100644 index 0000000..f94eac2 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/Sql/002_DeploymentRunModel.sql @@ -0,0 +1,193 @@ +/* + ESB Certificate Manager – vollständiges Datenbankschema v2 + Idempotent; kann auf einem leeren Schema oder nach 001_CreateSchema.sql ausgeführt werden. + + Tabellen: + dbo.SonicConnection – Sonic-ESB-Management-Instanzen + dbo.DeploymentTargets – Deployment-Ziele mit allen Konfigurationsfeldern + dbo.DeploymentRuns – Ein Eintrag pro Deployment-Lauf + dbo.DeploymentTargetResults – Detailergebnis pro Ziel und Lauf + + Always Encrypted (optional): + Zur Nutzung von Always Encrypted auf der Spalte PasswordHash in SonicConnection + die Blöcke unterhalb des Kommentars "-- ALWAYS ENCRYPTED" auskommentieren + und den Schlüsselnamen anpassen. +*/ + +-- ============================================================ +-- SonicConnection +-- ============================================================ +IF OBJECT_ID(N'dbo.SonicConnection', N'U') IS NULL +BEGIN + CREATE TABLE dbo.SonicConnection + ( + Id INT NOT NULL IDENTITY(1, 1), + Name NVARCHAR(128) NOT NULL, -- Eindeutiger Bezeichner, referenziert von DeploymentTargets + DomainName NVARCHAR(128) NOT NULL, -- Sonic-Domain, z.B. "proalpha-test" + ConnectionUrl NVARCHAR(512) NOT NULL, -- Sonic-Broker-URL, z.B. "tcp://dekun-painwbdet:13070" + ManagementHttpPort INT NOT NULL CONSTRAINT DF_SonicConnection_HttpPort DEFAULT (8080), + ApiBasePath NVARCHAR(128) NOT NULL CONSTRAINT DF_SonicConnection_ApiBase DEFAULT (N'/api/v1'), + Username NVARCHAR(128) NOT NULL, + PasswordHash NVARCHAR(512) NOT NULL CONSTRAINT DF_SonicConnection_PwdHash DEFAULT (N''), + -- ALWAYS ENCRYPTED: PasswordEncrypted NVARCHAR(512) ENCRYPTED WITH ( + -- COLUMN_ENCRYPTION_KEY = CEK_SonicPwd, + -- ENCRYPTION_TYPE = DETERMINISTIC, + -- ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256' + -- ) NULL, + TimeoutSeconds INT NOT NULL CONSTRAINT DF_SonicConnection_Timeout DEFAULT (30), + PostRestartDelaySecs INT NOT NULL CONSTRAINT DF_SonicConnection_Delay DEFAULT (15), + IsActive BIT NOT NULL CONSTRAINT DF_SonicConnection_Active DEFAULT (1), + CONSTRAINT PK_SonicConnection PRIMARY KEY CLUSTERED (Id), + CONSTRAINT UQ_SonicConnection_Name UNIQUE (Name) + ); +END +ELSE +BEGIN + -- ConnectionUrl-Spalte nachrüsten (Migration von ManagementUrl) + IF NOT EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID(N'dbo.SonicConnection') AND name = N'ConnectionUrl') + BEGIN + ALTER TABLE dbo.SonicConnection ADD ConnectionUrl NVARCHAR(512) NOT NULL + CONSTRAINT DF_SonicConnection_ConnUrl DEFAULT (N''); + END + + IF NOT EXISTS (SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID(N'dbo.SonicConnection') AND name = N'ManagementHttpPort') + BEGIN + ALTER TABLE dbo.SonicConnection ADD ManagementHttpPort INT NOT NULL + CONSTRAINT DF_SonicConnection_HttpPort DEFAULT (8080); + END +END +GO + +-- ============================================================ +-- DeploymentTargets +-- ============================================================ +IF OBJECT_ID(N'dbo.DeploymentTargets', N'U') IS NULL +BEGIN + CREATE TABLE dbo.DeploymentTargets + ( + Id INT NOT NULL IDENTITY(1, 1), + Name NVARCHAR(100) NOT NULL, + Environment NVARCHAR(64) NOT NULL CONSTRAINT DF_DT_Env DEFAULT (N''), + IsActive BIT NOT NULL CONSTRAINT DF_DT_IsActive DEFAULT (1), + + -- Zertifikat-Ablage + CertificateTargetPath NVARCHAR(500) NOT NULL, + CertificateFileName NVARCHAR(260) NOT NULL CONSTRAINT DF_DT_CertFile DEFAULT (N''), + + -- Neustart-Konfiguration + RestartType NVARCHAR(30) NOT NULL CONSTRAINT DF_DT_RestartType DEFAULT (N'None'), + RestartHost NVARCHAR(255) NULL, + RestartCommand NVARCHAR(2000) NULL, + RestartArguments NVARCHAR(1024) NOT NULL CONSTRAINT DF_DT_RestartArgs DEFAULT (N''), + RestartTimeoutSeconds INT NOT NULL CONSTRAINT DF_DT_RestartTimeout DEFAULT (60), + + -- Sonic ESB + SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_SonicConn DEFAULT (N''), + ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_Container DEFAULT (N''), + XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DT_XapiSrc DEFAULT (N''), + + -- TLS-Probe + TlsHost NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsHost DEFAULT (N''), + TlsPort INT NOT NULL CONSTRAINT DF_DT_TlsPort DEFAULT (443), + TlsServerName NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsServerName DEFAULT (N''), + ExpectedFingerprint NVARCHAR(128) NULL, + + SortOrder INT NOT NULL CONSTRAINT DF_DT_SortOrder DEFAULT (0), + + CONSTRAINT PK_DeploymentTargets PRIMARY KEY CLUSTERED (Id), + CONSTRAINT CK_DeploymentTargets_RestartType CHECK ( + RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi') + ) + ); +END +ELSE +BEGIN + -- Neue Spalten idempotent nachrüsten + IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'TlsServerName') + ALTER TABLE dbo.DeploymentTargets ADD TlsServerName NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsServerName DEFAULT (N''); + + IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'ExpectedFingerprint') + ALTER TABLE dbo.DeploymentTargets ADD ExpectedFingerprint NVARCHAR(128) NULL; + + IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'SonicConnectionName') + ALTER TABLE dbo.DeploymentTargets ADD SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_SonicConn DEFAULT (N''); + + IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'ContainerName') + ALTER TABLE dbo.DeploymentTargets ADD ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_Container DEFAULT (N''); + + IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'XapiSourcePath') + ALTER TABLE dbo.DeploymentTargets ADD XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DT_XapiSrc DEFAULT (N''); +END +GO + +-- ============================================================ +-- DeploymentRuns (ein Datensatz pro Deployment-Lauf) +-- ============================================================ +IF OBJECT_ID(N'dbo.DeploymentRuns', N'U') IS NULL +BEGIN + CREATE TABLE dbo.DeploymentRuns + ( + Id UNIQUEIDENTIFIER NOT NULL, + StartedAtUtc DATETIME2(3) NOT NULL, + FinishedAtUtc DATETIME2(3) NULL, + SourceFile NVARCHAR(500) NOT NULL, + SourceFingerprint NVARCHAR(128) NOT NULL, + StartedBy NVARCHAR(255) NOT NULL, + MachineName NVARCHAR(255) NOT NULL CONSTRAINT DF_DR_Machine DEFAULT (N''), + OverallStatus NVARCHAR(30) NOT NULL, -- 'Running' | 'Success' | 'PartialFailure' | 'Failure' + ErrorMessage NVARCHAR(MAX) NULL, + CONSTRAINT PK_DeploymentRuns PRIMARY KEY CLUSTERED (Id) + ); +END +GO + +-- ============================================================ +-- DeploymentTargetResults (ein Datensatz pro Ziel und Lauf) +-- ============================================================ +IF OBJECT_ID(N'dbo.DeploymentTargetResults', N'U') IS NULL +BEGIN + CREATE TABLE dbo.DeploymentTargetResults + ( + Id INT NOT NULL IDENTITY(1, 1), + DeploymentRunId UNIQUEIDENTIFIER NOT NULL, + TargetId INT NOT NULL, + TargetName NVARCHAR(128) NOT NULL, + StartedAtUtc DATETIME2(3) NOT NULL, + FinishedAtUtc DATETIME2(3) NULL, + CopySucceeded BIT NOT NULL CONSTRAINT DF_DTR_Copy DEFAULT (0), + RestartSucceeded BIT NOT NULL CONSTRAINT DF_DTR_Restart DEFAULT (0), + TlsSucceeded BIT NOT NULL CONSTRAINT DF_DTR_Tls DEFAULT (0), + ObservedFingerprint NVARCHAR(128) NULL, + Status NVARCHAR(30) NOT NULL, + ErrorMessage NVARCHAR(MAX) NULL, + CONSTRAINT PK_DeploymentTargetResults PRIMARY KEY CLUSTERED (Id), + CONSTRAINT FK_DTR_Run FOREIGN KEY (DeploymentRunId) REFERENCES dbo.DeploymentRuns (Id) + ); + + CREATE NONCLUSTERED INDEX IX_DTR_RunId ON dbo.DeploymentTargetResults (DeploymentRunId); +END +GO + +-- ============================================================ +-- Beispieldaten +-- ============================================================ +/* +INSERT INTO dbo.SonicConnection (Name, DomainName, ConnectionUrl, Username, PasswordHash) +VALUES (N'DE-Test', N'proalpha-test', N'tcp://dekun-painwbdet:13070', N'Administrator', N''); + +INSERT INTO dbo.DeploymentTargets + (Name, Environment, IsActive, CertificateTargetPath, CertificateFileName, + RestartType, SonicConnectionName, ContainerName, + TlsHost, TlsPort, TlsServerName, SortOrder) +VALUES + (N'DE-Test Container A', N'TEST', 1, + N'\\dekun-painwbdet\sonic\certs', N'server.pfx', + N'SonicContainer', N'DE-Test', N'sonic-container-a', + N'dekun-painwbdet', 443, N'esb-test.firma.local', 10), + (N'DE-Test Container B (XApi)', N'TEST', 1, + N'\\dekun-painwbdet\sonic\certs', N'server.pfx', + N'SonicContainerWithXapi', N'DE-Test', N'sonic-container-b', + N'dekun-painwbdet', 443, N'esb-test.firma.local', 20); +*/ diff --git a/ZA.CoreService.ESBCertificateManager/ZA.CoreService.ESBCertificateManager.csproj b/ZA.CoreService.ESBCertificateManager/ZA.CoreService.ESBCertificateManager.csproj new file mode 100644 index 0000000..b9d9b85 --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/ZA.CoreService.ESBCertificateManager.csproj @@ -0,0 +1,35 @@ + + + + WinExe + net8.0-windows + enable + true + enable + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/ZA.CoreService.ESBCertificateManager/appsettings.json b/ZA.CoreService.ESBCertificateManager/appsettings.json new file mode 100644 index 0000000..dbddb3f --- /dev/null +++ b/ZA.CoreService.ESBCertificateManager/appsettings.json @@ -0,0 +1,26 @@ +{ + "ConnectionString": "", + "UseOfflineSampleData": true, + "SampleTargetsPath": "Data/targets.sample.json", + "LogDirectory": "Logs", + "TlsTimeoutSeconds": 8, + "TlsRetryCount": 2, + "SonicConnections": [ + { + "Name": "DE-Test", + "DomainName": "proalpha-test", + "ConnectionUrl": "tcp://dekun-painwbdet:13070", + "ManagementHttpPort": 8080, + "ApiBasePath": "/api/v1", + "Username": "Administrator", + "Password": "Administrator", + "TimeoutSeconds": 30, + "PostRestartDelaySeconds": 15, + + "ContainerListPath": "", + "ContainerRestartPath": "", + "ContainerStopPath": "", + "ContainerStartPath": "" + } + ] +}