Initial commit: ESB Certificate Manager.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 08:10:13 +02:00
co-authored by Cursor
commit ed8e2822d1
36 changed files with 5111 additions and 0 deletions
+63
View File
@@ -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
+367
View File
@@ -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
+25
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -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;
}
}
@@ -0,0 +1,9 @@
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
public interface ITargetRepository
{
Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(CancellationToken cancellationToken = default);
string SourceDescription { get; }
}
@@ -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<IReadOnlyList<DeploymentTarget>> 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<DeploymentTarget>? targets = await JsonSerializer.DeserializeAsync<List<DeploymentTarget>>(
stream,
JsonOptions,
cancellationToken);
return (targets ?? [])
.Where(t => t.IsActive)
.OrderBy(t => t.SortOrder)
.ThenBy(t => t.Name)
.ToList();
}
}
@@ -0,0 +1,167 @@
using Microsoft.Data.SqlClient;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
/// <summary>
/// Schreibt Deployment-Lauf-Ergebnisse in die SQL-Datenbank.
/// Tabellen: dbo.DeploymentRuns (GUID-PK) + dbo.DeploymentTargetResults.
/// Ist kein ConnectionString konfiguriert, werden alle Operationen still übersprungen.
/// </summary>
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;
/// <summary>
/// Legt einen neuen Lauf-Datensatz an.
/// </summary>
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);
}
/// <summary>
/// Aktualisiert den Lauf-Datensatz mit Endzeitpunkt und Gesamtstatus.
/// </summary>
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);
}
/// <summary>
/// Schreibt das Ergebnis eines einzelnen Deployment-Ziels.
/// </summary>
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);
}
/// <summary>
/// Persistiert ein vollständiges <see cref="DeploymentRunResult"/> in einem einzigen Aufruf.
/// </summary>
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);
}
}
@@ -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<IReadOnlyList<DeploymentTarget>> 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<DeploymentTarget> 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;
}
}
@@ -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);
}
@@ -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
}
]
+37
View File
@@ -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
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
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
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -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;
/// <summary>
/// Verbindungskonfigurationen für Progress Sonic ESB Management Instanzen.
/// Jeder Eintrag entspricht einer Sonic-Domain (z.B. einer Umgebung oder Tochtergesellschaft).
/// </summary>
public List<SonicConnection> SonicConnections { get; set; } = [];
}
@@ -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; }
}
}
@@ -0,0 +1,40 @@
namespace ZA.CoreService.ESBCertificateManager.Models;
public sealed class DeploymentRunResult
{
public Guid RunId { get; init; } = Guid.NewGuid();
public required IReadOnlyList<TargetStepResult> 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<string> 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<ValidationIssue> Issues { get; } = [];
}
@@ -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; }
/// <summary>Name des Sonic-ESB-Containers (z.B. "sonic-container-a").</summary>
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 ---
/// <summary>
/// Referenz auf den Namen einer <see cref="SonicConnection"/> in AppSettings.
/// Pflichtfeld bei RestartType = SonicContainer oder SonicContainerWithXapi.
/// </summary>
public string SonicConnectionName { get; init; } = string.Empty;
/// <summary>
/// Pfad zur XApi-Ressourcendatei (.xml/.zip), die vor dem Neustart importiert wird.
/// Pflichtfeld bei RestartType = SonicContainerWithXapi.
/// </summary>
public string XapiSourcePath { get; init; } = string.Empty;
// --- TLS-Probe nach Deployment ---
public string TlsHost { get; init; } = string.Empty;
public int? TlsPort { get; init; }
/// <summary>
/// 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.
/// </summary>
public string TlsServerName { get; init; } = string.Empty;
/// <summary>
/// Erwarteter SHA-256-Fingerprint des Zertifikats nach dem Deployment (ohne Trennzeichen).
/// Wenn gesetzt, schlägt der TLS-Probe fehl wenn der Fingerprint abweicht.
/// </summary>
public string? ExpectedFingerprint { get; init; }
public int SortOrder { get; init; }
}
public enum RestartType
{
None = 0,
/// <summary>Neustart über einen lokalen Betriebssystem-Prozess (RestartCommand).</summary>
Command = 1,
/// <summary>Container-Neustart über die Sonic ESB Management Console REST-API.</summary>
SonicContainer = 2,
/// <summary>XApi-Ressourcen importieren und danach Container neu starten (Sonic ESB).</summary>
SonicContainerWithXapi = 3
}
@@ -0,0 +1,107 @@
namespace ZA.CoreService.ESBCertificateManager.Models;
/// <summary>
/// Verbindungskonfiguration für eine Progress Sonic ESB Management Instanz.
/// </summary>
public sealed class SonicConnection
{
/// <summary>
/// Eindeutiger Bezeichner; wird in <see cref="DeploymentTarget.SonicConnectionName"/> referenziert.
/// </summary>
public required string Name { get; init; }
/// <summary>Sonic-Domain-Name, z.B. "proalpha-test".</summary>
public required string DomainName { get; init; }
/// <summary>
/// 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).
/// </summary>
public required string ConnectionUrl { get; init; }
/// <summary>Benutzername für die Management-Konsole.</summary>
public required string Username { get; init; }
/// <summary>Passwort für die Management-Konsole.</summary>
public required string Password { get; init; }
/// <summary>
/// Management-Modus: HttpApi (REST) oder WinRm (PowerShell Remoting).
/// Standard: WinRm da Sonic 10.x kein HTTP REST API bereitstellt.
/// </summary>
public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.WinRm;
// ---------------------------------------------------------------
// HTTP REST API (ManagementMode = HttpApi)
// ---------------------------------------------------------------
/// <summary>HTTP-Port der Sonic Management Console REST-API (Standard: 8080).</summary>
public int ManagementHttpPort { get; init; } = 8080;
/// <summary>Präfix für alle REST-API-Pfade (Standard: "/api/v1").</summary>
public string ApiBasePath { get; init; } = "/api/v1";
/// <summary>Konfigurierter Pfad zum Auflisten aller Container. Leer = automatische Erkennung.</summary>
public string ContainerListPath { get; init; } = string.Empty;
/// <summary>Konfigurierter Pfad zum Neustarten. Platzhalter: {domain}, {container}.</summary>
public string ContainerRestartPath { get; init; } = string.Empty;
/// <summary>Konfigurierter Pfad zum Stoppen. Platzhalter: {domain}, {container}.</summary>
public string ContainerStopPath { get; init; } = string.Empty;
/// <summary>Konfigurierter Pfad zum Starten. Platzhalter: {domain}, {container}.</summary>
public string ContainerStartPath { get; init; } = string.Empty;
// ---------------------------------------------------------------
// WinRM / PowerShell Remoting (ManagementMode = WinRm)
// ---------------------------------------------------------------
/// <summary>
/// WinRM-Port auf dem Zielrechner (Standard: 5985 = HTTP, 5986 = HTTPS).
/// </summary>
public int WinRmPort { get; init; } = 5985;
/// <summary>
/// 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}'"
/// </summary>
public string WinRmRestartScript { get; init; } = string.Empty;
/// <summary>
/// PowerShell-Scriptblock zum Auflisten aller Container der Domain.
/// Ausgabe: eine Zeile pro Container-Name.
/// Beispiel: "Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty Name"
/// </summary>
public string WinRmContainerListScript { get; init; } = string.Empty;
/// <summary>
/// PowerShell-Scriptblock für XApi-Import.
/// Platzhalter: {container}, {xapiPath}.
/// </summary>
public string WinRmXapiImportScript { get; init; } = string.Empty;
// ---------------------------------------------------------------
// Gemeinsame Einstellungen
// ---------------------------------------------------------------
/// <summary>Timeout in Sekunden für einzelne API-/Script-Aufrufe (Standard: 60).</summary>
public int TimeoutSeconds { get; init; } = 60;
/// <summary>Wartezeit in Sekunden nach einem Container-Neustart (Standard: 15).</summary>
public int PostRestartDelaySeconds { get; init; } = 15;
}
public enum SonicManagementMode
{
/// <summary>HTTP REST API (wenn vom Sonic-Server bereitgestellt).</summary>
HttpApi,
/// <summary>
/// PowerShell Remoting (WinRM) Standard für Sonic 10.x auf Windows.
/// Führt konfigurierte Scriptblöcke via Invoke-Command auf dem Sonic-Server aus.
/// </summary>
WinRm
}
@@ -0,0 +1,13 @@
namespace ZA.CoreService.ESBCertificateManager
{
internal static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new Form1());
}
}
}
@@ -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);
}
}
@@ -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<DeploymentTarget> selectedTargets)
=> _preflightValidator.Validate(certificatePath, certificateInfo, selectedTargets);
public async Task<DeploymentRunResult> RunAsync(
string certificatePath,
CertificateInfo certificateInfo,
IReadOnlyList<DeploymentTarget> selectedTargets,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken = default)
{
DateTimeOffset startedAt = DateTimeOffset.Now;
List<TargetStepResult> 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<TargetStepResult> RunTargetAsync(
DeploymentTarget target,
string certificatePath,
CertificateInfo certificateInfo,
RunLogger logger,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken)
{
List<string> 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<string> 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<string> steps,
DateTimeOffset startedAt,
IProgress<TargetProgressUpdate>? 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);
@@ -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));
}
}
@@ -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<DeploymentTarget> 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
});
}
}
@@ -0,0 +1,150 @@
using System.Diagnostics;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class RestartExecutor
{
private readonly IReadOnlyList<SonicConnection> _sonicConnections;
public RestartExecutor(IReadOnlyList<SonicConnection> 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<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> 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] + "…";
}
}
@@ -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=***");
}
}
@@ -0,0 +1,139 @@
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Fragt alle konfigurierten Sonic-Management-Verbindungen nach ihren Containern
/// und gleicht die gefundenen Container mit den bereits konfigurierten Deployment-Zielen ab.
/// </summary>
public sealed class SonicContainerDiscovery
{
private readonly IReadOnlyList<SonicConnection> _connections;
public SonicContainerDiscovery(IReadOnlyList<SonicConnection> connections)
{
_connections = connections;
}
public bool HasConnections => _connections.Count > 0;
public IReadOnlyList<SonicConnection> Connections => _connections;
/// <summary>
/// Verbindet sich mit der angegebenen Sonic-Instanz, liest die Container-Liste
/// und reichert sie mit konfigurierten Ziel-Metadaten an.
/// </summary>
public async Task<SonicDiscoveryResult> DiscoverAsync(
string connectionName,
IReadOnlyList<DeploymentTarget> 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<string> 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<DeploymentTarget> 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<DeploymentTarget> BuildTargets(
SonicConnection connection,
IReadOnlyList<string> containerNames,
IReadOnlyList<DeploymentTarget> knownTargets)
{
List<DeploymentTarget> 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<DeploymentTarget> DiscoveredTargets { get; init; } = [];
public IReadOnlyList<string> RawContainerNames { get; init; } = [];
public static SonicDiscoveryResult Failed(string connectionName, string error)
=> new() { ConnectionName = connectionName, Success = false, ErrorMessage = error };
}
@@ -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;
/// <summary>
/// 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
/// </summary>
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
// ---------------------------------------------------------------
/// <summary>
/// Prüft die Verbindung zur Management Console.
/// WinRm: TCP-Ping auf WinRM-Port + Test-PSSession.
/// Http: Probe gegen bekannte API-Pfade.
/// </summary>
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);
}
}
/// <summary>
/// Listet alle Container der Domain auf.
/// </summary>
public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync(
CancellationToken cancellationToken = default)
{
if (_connection.ManagementMode == SonicManagementMode.WinRm)
{
return await GetContainersViaWinRmAsync(cancellationToken);
}
return await GetContainersViaHttpAsync(cancellationToken);
}
/// <summary>
/// Startet den Container neu.
/// </summary>
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);
}
/// <summary>
/// Importiert XApi-Ressourcen und startet den Container neu.
/// </summary>
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>, 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<string> 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>, 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<string?> 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<string> 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<string> ParseContainerNames(string json)
{
try
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
List<string> names = [];
IEnumerable<JsonElement> 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();
}
}
@@ -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();
}
}
@@ -0,0 +1,164 @@
using System.Diagnostics;
using System.Text;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// 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"
/// </summary>
public sealed class WinRmExecutor
{
private readonly SonicConnection _connection;
public WinRmExecutor(SonicConnection connection)
{
_connection = connection;
}
/// <summary>
/// Prüft die WinRM-Konnektivität und ob der Sonic-Server per TCP erreichbar ist.
/// </summary>
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}");
}
/// <summary>
/// Führt einen Scriptblock auf dem Remote-Rechner aus und gibt Stdout zurück.
/// </summary>
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<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> 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";
}
/// <summary>
/// Ersetzt Platzhalter in einem konfigurierten WinRM-Script.
/// {container} → Container-Name (einfache Hochkommas werden verdoppelt)
/// {domain} → Domain-Name
/// {xapiPath} → Pfad zur XApi-Quelldatei
/// </summary>
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] + "…";
}
@@ -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'<encrypted>');
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
);
*/
@@ -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'<encrypted>');
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);
*/
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
</ItemGroup>
<ItemGroup>
<None Update="Assets\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Data\targets.sample.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Demo\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Sql\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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": ""
}
]
}