From ed8e2822d1c544f996a4b0d34cb94c572394f8dc Mon Sep 17 00:00:00 2001 From: Mike Date: Fri, 24 Jul 2026 08:10:13 +0200 Subject: [PATCH] Initial commit: ESB Certificate Manager. EOF Co-authored-by: Cursor --- .gitattributes | 63 + .gitignore | 367 ++++ ZA.CoreService.ESBCertificateManager.sln | 25 + .../Assets/TestCertificates/esb-test-cert.cer | Bin 0 -> 794 bytes .../Assets/logo.png | Bin 0 -> 27995 bytes .../Configuration/AppSettingsLoader.cs | 21 + .../Data/ITargetRepository.cs | 9 + .../Data/JsonTargetRepository.cs | 46 + .../Data/SqlRunLogger.cs | 167 ++ .../Data/SqlTargetRepository.cs | 111 + .../Data/TargetRepositoryFactory.cs | 44 + .../Data/targets.sample.json | 82 + .../Demo/Central/esb-cert.cer | Bin 0 -> 823 bytes .../Form1.Designer.cs | 37 + ZA.CoreService.ESBCertificateManager/Form1.cs | 1794 +++++++++++++++++ .../Form1.resx | 120 ++ .../Models/AppSettings.cs | 17 + .../Models/CertificateInfo.cs | 12 + .../Models/DeploymentRunResult.cs | 40 + .../Models/DeploymentTarget.cs | 67 + .../Models/SonicConnection.cs | 107 + .../Program.cs | 13 + .../Services/CertificateDeployer.cs | 98 + .../Services/DeploymentOrchestrator.cs | 180 ++ .../Services/PathResolver.cs | 19 + .../Services/PreflightValidator.cs | 106 + .../Services/RestartExecutor.cs | 150 ++ .../Services/RunLogger.cs | 64 + .../Services/SonicContainerDiscovery.cs | 139 ++ .../Services/SonicManagementClient.cs | 503 +++++ .../Services/TlsCertificateProbe.cs | 135 ++ .../Services/WinRmExecutor.cs | 164 ++ .../Sql/001_CreateSchema.sql | 157 ++ .../Sql/002_DeploymentRunModel.sql | 193 ++ ...A.CoreService.ESBCertificateManager.csproj | 35 + .../appsettings.json | 26 + 36 files changed, 5111 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 ZA.CoreService.ESBCertificateManager.sln create mode 100644 ZA.CoreService.ESBCertificateManager/Assets/TestCertificates/esb-test-cert.cer create mode 100644 ZA.CoreService.ESBCertificateManager/Assets/logo.png create mode 100644 ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Data/ITargetRepository.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Data/JsonTargetRepository.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Data/SqlRunLogger.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Data/SqlTargetRepository.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Data/TargetRepositoryFactory.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Data/targets.sample.json create mode 100644 ZA.CoreService.ESBCertificateManager/Demo/Central/esb-cert.cer create mode 100644 ZA.CoreService.ESBCertificateManager/Form1.Designer.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Form1.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Form1.resx create mode 100644 ZA.CoreService.ESBCertificateManager/Models/AppSettings.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Models/CertificateInfo.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Models/DeploymentRunResult.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Models/DeploymentTarget.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Program.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/CertificateDeployer.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/PathResolver.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/RestartExecutor.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/RunLogger.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/SonicContainerDiscovery.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/TlsCertificateProbe.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs create mode 100644 ZA.CoreService.ESBCertificateManager/Sql/001_CreateSchema.sql create mode 100644 ZA.CoreService.ESBCertificateManager/Sql/002_DeploymentRunModel.sql create mode 100644 ZA.CoreService.ESBCertificateManager/ZA.CoreService.ESBCertificateManager.csproj create mode 100644 ZA.CoreService.ESBCertificateManager/appsettings.json 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 0000000000000000000000000000000000000000..157aad80621266beb4242c7bd4dc39cb29c48740 GIT binary patch literal 794 zcmXqLViq%KV*Iy&nTe5!Nuc6pN_SNJj4d814X=-P-R>~pW#iOp^Jx3d%gD&h%3vU8 zC}SYS#vIDR%p>d??4%o#T3n(Vm0DDinU#if1EtJomu_j7Uv$gt)-}^d+Ze@GZuSWLU(B`Gq-)VM(PUAH ziq)Ujwj3|^U^*glvF-UI58f3D8Z}?C+%B5FP_}4^Uom-0oVUHY)#9uLB@=x$USIs% z5R`DQOHZ!DZ+3{EjL5ZFHVc-Y@l91@;tV>X8gF7eOQKF^3FD;ZOv%q%-ad+aykvXd zql>=-y;twF+jDt;tg|K0j*xgCkNy)+E-h2Ny6^7MgBO^}-kkaJ*qB#!w}NtC{$A#n zTR7!D9l9y7YaYYe_sh95mRz%2=AFjovGz@sG@s1B^*R#1YNbrfj0}v60}T8O_<*q> z%g@O8pM`~)iFJX2EQqhlB4!}M#-Yu|$jZvj%mimKLZtav#8^b+4+g#z=*hQJTm13y z-z5UP=S>btA%{6Int@@?$e=72lX1*>RZv>K`+JiU4=QYC{_*_0Y|3{3t14k}7v!%* z$~nGgo^;rRSI&UJe23TOO#+vi@3l>HKM~>E{e?~cYb)OdrXOL>W$`zsGBnDGMm%Ln zu2tM?)vd3&lp*Y_jjm$L`fszFWF`ExrDM+e=;m*IoBDpr4=G>at7iOvcJV81{%dT~ z(kQTUW9>(6ahJ(&Gin&r4!IRnb%=z&VEW=#HS0B#T`S)$wZMbdJ{_rbaoBrkNvhaF zXI9srr}Nw|d-_Rt>{Bn?RGjUw_1V-z6I-_%o7tO+sQt}eHt({w>&@)9`8sTE-!|T9 e%05v2>h)^F2aihrtU9tc>(VRcS?=t<-PZt$xHMM) literal 0 HcmV?d00001 diff --git a/ZA.CoreService.ESBCertificateManager/Assets/logo.png b/ZA.CoreService.ESBCertificateManager/Assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1b85c62b98f51391e6626b8fe66ac9fac4e85d09 GIT binary patch literal 27995 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

;*@3oz0WVu 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bdf05db324183c7f3f1cda607fa6e10a6d246666 GIT binary patch literal 823 zcmXqLVm3BtVv=6K%*4pVBvA87e`@ww={fGV#Vv|5CfgbCvT$cgw-J!gR$MhA$%|s9NS=yJQ zVw1N1zm)udQFE=1|DpBgI1N_&CuD1@$#mIxH_o5%&mhcxhWt_{W=00a#n}d#27JJH zkmYA&{LjL|%*49DKo-PTWf3zFVdKzdV`ODzXJ&%47!4#r(t<2P1_Didsl`dUDZr@H z%gIkp%z^0SV-aH!S!ioFd)}Y+$y+w0PDz(5OP^(-Z~{3xfN2029gGa8CTA|t68mx} z&YZ!@*L_*C@Yh7^jk{KhU*5GM-?#SH6?WfJ<$K$XJ$l-0k@U>o<7Srdnfq6{d5=Dc zJM`>%{(^v)nFe?EIB?!8UuGu3%OLo@;i!{p_Vd1d=IIB0zj(No_Z&Q_#bZ#;l-6e$ z^*7>U)R*Ufc=z+TvuCWXJ{I0yWmU4iYRws+*{lZ6`uj};-Z(kg&ip8O#4rD}y~btf zblZhnj>eoz`4PHw&MEKRWx~3V52iP8>`U)ncE;z*SA+bTET8EQnSHB{1Z7{c&+6ZG zPN$$Ov%TfoHvXHD8#Iq8)UA)7`MoeYGI+LFJ$E+uw>)&5J%3O3dMeC!ghTMf~ E0McYVIsgCw literal 0 HcmV?d00001 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": "" + } + ] +}