big changes

This commit is contained in:
Mike Gisler
2026-07-28 08:03:12 +02:00
parent c73f53d740
commit 4d19873776
179 changed files with 14953 additions and 494 deletions
@@ -0,0 +1,250 @@
using Microsoft.Data.SqlClient;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class DeploymentTargetRepository
{
private readonly string _connectionString;
public DeploymentTargetRepository(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException(
"Der SQL-Connection-String ist nicht konfiguriert.",
nameof(connectionString));
}
_connectionString = connectionString;
}
public async Task<IReadOnlyList<DeploymentTarget>> GetActiveAsync(
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT
target.CertificateTargetId,
target.TargetDirectory,
target.TargetFileName,
target.BackupEnabled,
target.BackupDirectoryName,
target.BackupRetentionDays,
target.IsActive AS TargetIsActive,
container.SonicContainerId,
container.ContainerName,
container.ContainerDisplayName,
container.RestartTimeoutSeconds,
connection.ConnectionName,
service.ServiceCode,
service.ServiceName,
environment.EnvironmentCode,
environment.SortOrder AS EnvironmentSortOrder,
company.CompanyCode,
country.CountryCode
FROM dbo.CertificateTarget AS target
INNER JOIN dbo.SonicContainer AS container
ON container.SonicContainerId = target.SonicContainerId
INNER JOIN dbo.SonicConnection AS connection
ON connection.SonicConnectionId = container.SonicConnectionId
INNER JOIN dbo.EsbService AS service
ON service.EsbServiceId = container.EsbServiceId
INNER JOIN dbo.Company AS company
ON company.CompanyId = service.CompanyId
LEFT JOIN dbo.Country AS country
ON country.CountryId = service.CountryId
INNER JOIN dbo.Environment AS environment
ON environment.EnvironmentId = service.EnvironmentId
WHERE target.IsActive = 1
AND container.IsActive = 1
AND connection.IsActive = 1
AND service.IsActive = 1
AND company.IsActive = 1
AND environment.IsActive = 1
AND
(
country.CountryId IS NULL
OR country.IsActive = 1
)
ORDER BY
company.CompanyCode,
country.CountryCode,
environment.SortOrder,
service.ServiceCode,
container.ContainerName,
target.TargetDirectory,
target.TargetFileName;
""";
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 targetIdOrdinal =
reader.GetOrdinal("CertificateTargetId");
int targetDirectoryOrdinal =
reader.GetOrdinal("TargetDirectory");
int targetFileNameOrdinal =
reader.GetOrdinal("TargetFileName");
int backupEnabledOrdinal =
reader.GetOrdinal("BackupEnabled");
int backupDirectoryNameOrdinal =
reader.GetOrdinal("BackupDirectoryName");
int backupRetentionDaysOrdinal =
reader.GetOrdinal("BackupRetentionDays");
int targetIsActiveOrdinal =
reader.GetOrdinal("TargetIsActive");
int containerNameOrdinal =
reader.GetOrdinal("ContainerName");
int containerDisplayNameOrdinal =
reader.GetOrdinal("ContainerDisplayName");
int restartTimeoutOrdinal =
reader.GetOrdinal("RestartTimeoutSeconds");
int connectionNameOrdinal =
reader.GetOrdinal("ConnectionName");
int serviceCodeOrdinal =
reader.GetOrdinal("ServiceCode");
int serviceNameOrdinal =
reader.GetOrdinal("ServiceName");
int environmentCodeOrdinal =
reader.GetOrdinal("EnvironmentCode");
int environmentSortOrderOrdinal =
reader.GetOrdinal("EnvironmentSortOrder");
int companyCodeOrdinal =
reader.GetOrdinal("CompanyCode");
int countryCodeOrdinal =
reader.GetOrdinal("CountryCode");
while (await reader.ReadAsync(cancellationToken))
{
string containerName =
reader.GetString(containerNameOrdinal);
string? containerDisplayName =
ReadNullableString(
reader,
containerDisplayNameOrdinal);
string serviceCode =
reader.GetString(serviceCodeOrdinal);
string serviceName =
reader.GetString(serviceNameOrdinal);
string companyCode =
reader.GetString(companyCodeOrdinal);
string? countryCode =
ReadNullableString(reader, countryCodeOrdinal);
string displayName =
string.IsNullOrWhiteSpace(containerDisplayName)
? containerName
: containerDisplayName;
string locationCode =
string.IsNullOrWhiteSpace(countryCode)
? companyCode
: $"{companyCode}/{countryCode}";
targets.Add(new DeploymentTarget
{
Id = reader.GetInt32(targetIdOrdinal),
Name =
$"{locationCode} / {serviceName} / {displayName}",
Environment =
reader.GetString(environmentCodeOrdinal),
IsActive =
reader.GetBoolean(targetIsActiveOrdinal),
TargetDirectory =
reader.GetString(targetDirectoryOrdinal),
CertificateFileName =
reader.GetString(targetFileNameOrdinal),
BackupEnabled =
reader.GetBoolean(backupEnabledOrdinal),
BackupDirectoryName =
reader.GetString(backupDirectoryNameOrdinal),
BackupRetentionDays =
ReadNullableInt32(
reader,
backupRetentionDaysOrdinal),
ContainerName = containerName,
RestartType = RestartType.SonicContainer,
SonicConnectionName =
reader.GetString(connectionNameOrdinal),
RestartTimeoutSeconds =
reader.GetInt32(restartTimeoutOrdinal),
TlsHost = string.Empty,
TlsPort = null,
TlsServerName = string.Empty,
ExpectedFingerprint = null,
SortOrder =
reader.GetInt32(environmentSortOrderOrdinal)
});
}
return targets;
}
private static string? ReadNullableString(
SqlDataReader reader,
int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: reader.GetString(ordinal);
}
private static int? ReadNullableInt32(
SqlDataReader reader,
int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: reader.GetInt32(ordinal);
}
}
@@ -8,15 +8,24 @@ namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class DeploymentOrchestrator
{
private readonly RestartExecutor _restartExecutor;
private readonly LocalCertificateDeployer _localCertificateDeployer;
private readonly PreflightValidator _preflightValidator = new();
private readonly AppSettings _settings;
public DeploymentOrchestrator(AppSettings settings)
public DeploymentOrchestrator(
AppSettings settings,
IReadOnlyList<SonicConnection> sonicConnections)
{
_settings = settings;
_restartExecutor = new RestartExecutor(settings.SonicConnections);
_restartExecutor =
new RestartExecutor(sonicConnections);
_localCertificateDeployer =
new LocalCertificateDeployer();
}
public PreflightValidationResult ValidateRestartOnly(IReadOnlyList<DeploymentTarget> selectedTargets)
=> _preflightValidator.ValidateRestartOnly(selectedTargets);
@@ -52,7 +61,75 @@ public sealed class DeploymentOrchestrator
return runResult;
}
public async Task<DeploymentRunResult> DeployAsync(
string certificateFilePath,
string certificateFingerprint,
IReadOnlyList<DeploymentTarget> selectedTargets,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(certificateFilePath))
{
throw new ArgumentException(
"Es wurde kein Zertifikatsdateipfad angegeben.",
nameof(certificateFilePath));
}
if (!File.Exists(certificateFilePath))
{
throw new FileNotFoundException(
"Die ausgewählte Zertifikatsdatei wurde nicht gefunden.",
certificateFilePath);
}
if (selectedTargets.Count == 0)
{
throw new ArgumentException(
"Es wurde kein Bereitstellungsziel ausgewählt.",
nameof(selectedTargets));
}
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(certificateFilePath)}");
foreach (DeploymentTarget target in selectedTargets)
{
cancellationToken.ThrowIfCancellationRequested();
TargetStepResult result = await DeployTargetAsync(
certificateFilePath,
target,
logger,
progress,
cancellationToken);
results.Add(result);
}
DateTimeOffset finishedAt = DateTimeOffset.Now;
DeploymentRunResult runResult = new()
{
TargetResults = results,
StartedAt = startedAt,
FinishedAt = finishedAt,
CertificateFilePath = certificateFilePath,
CertificateFingerprint = certificateFingerprint
};
logger.Write(
$"Deployment beendet. Erfolg={runResult.OverallSuccess}; " +
$"Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; " +
$"Log={logger.LogFilePath}");
return runResult;
}
private async Task<TargetStepResult> RestartTargetOnlyAsync(
DeploymentTarget target,
RunLogger logger,
@@ -88,6 +165,125 @@ public sealed class DeploymentOrchestrator
progress?.Report(new TargetProgressUpdate(target.Id, restartStatus, restartOk));
return targetResult;
}
private async Task<TargetStepResult> DeployTargetAsync(
string certificateFilePath,
DeploymentTarget target,
RunLogger logger,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken)
{
List<string> steps = [];
DateTimeOffset targetStart = DateTimeOffset.Now;
try
{
progress?.Report(new TargetProgressUpdate(
target.Id,
$"Zertifikat wird nach '{target.TargetDirectory}' kopiert ...",
false));
string deployedFilePath =
await _localCertificateDeployer.DeployAsync(
certificateFilePath,
target.TargetDirectory,
target.CertificateFileName,
target.BackupEnabled,
target.BackupDirectoryName,
cancellationToken);
string copyStatus = $"Zertifikat kopiert: {Path.GetFileName(deployedFilePath)}";
steps.Add(copyStatus);
logger.Write($"[{target.Name}] {copyStatus} | Ziel={deployedFilePath}");
progress?.Report(new TargetProgressUpdate(
target.Id,
copyStatus,
true));
return new TargetStepResult
{
TargetId = target.Id,
TargetName = target.Name,
Success = true,
StatusText = "Kopieren erfolgreich",
Detail = deployedFilePath,
Steps = steps,
StartedAt = targetStart,
FinishedAt = DateTimeOffset.Now,
CopySucceeded = true,
RestartSucceeded = true,
TlsSucceeded = true,
ObservedFingerprint = null
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
string errorStatus = $"Kopieren fehlgeschlagen: {ex.Message}";
steps.Add(errorStatus);
logger.Write($"[{target.Name}] {errorStatus}");
progress?.Report(new TargetProgressUpdate(
target.Id,
errorStatus,
false));
return new TargetStepResult
{
TargetId = target.Id,
TargetName = target.Name,
Success = false,
StatusText = "Kopieren fehlgeschlagen",
Detail = ex.Message,
Steps = steps,
StartedAt = targetStart,
FinishedAt = DateTimeOffset.Now,
CopySucceeded = false,
RestartSucceeded = false,
TlsSucceeded = false,
ObservedFingerprint = null
};
}
}
private static string? CreateCertificateBackup(string currentCertificatePath)
{
if (string.IsNullOrWhiteSpace(currentCertificatePath) ||
!File.Exists(currentCertificatePath))
{
return null;
}
string certificateDirectory =
Path.GetDirectoryName(currentCertificatePath)
?? throw new InvalidOperationException(
$"Kein Zielordner für Zertifikat gefunden: {currentCertificatePath}");
string backupDirectory = Path.Combine(certificateDirectory, "Backup");
Directory.CreateDirectory(backupDirectory);
string fileNameWithoutExtension =
Path.GetFileNameWithoutExtension(currentCertificatePath);
string extension = Path.GetExtension(currentCertificatePath);
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string backupFileName =
$"{fileNameWithoutExtension}_{timestamp}{extension}";
string backupPath = Path.Combine(backupDirectory, backupFileName);
File.Copy(currentCertificatePath, backupPath, overwrite: false);
return backupPath;
}
}
public readonly record struct TargetProgressUpdate(int TargetId, string StatusText, bool? SuccessHint);
@@ -0,0 +1,10 @@
namespace ZA.CoreService.ESBCertificateManager.Services;
public interface ISecretProvider
{
StoredCredential GetCredential(string credentialReference);
}
public sealed record StoredCredential(
string UserName,
string Secret);
@@ -0,0 +1,185 @@
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class LocalCertificateDeployer
{
private static readonly HashSet<string> CertificateExtensions =
new(StringComparer.OrdinalIgnoreCase)
{
".cer",
".crt",
".pem",
".pfx"
};
public async Task<string> DeployAsync(
string sourceCertificatePath,
string configuredTargetDirectory,
string targetFileName,
bool backupEnabled,
string backupDirectoryName,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(sourceCertificatePath))
{
throw new ArgumentException(
"Es wurde keine Zertifikatsquelle angegeben.",
nameof(sourceCertificatePath));
}
if (!File.Exists(sourceCertificatePath))
{
throw new FileNotFoundException(
"Die ausgewählte Zertifikatsdatei wurde nicht gefunden.",
sourceCertificatePath);
}
if (string.IsNullOrWhiteSpace(configuredTargetDirectory))
{
throw new ArgumentException(
"Der Zertifikats-Zielordner ist nicht konfiguriert.",
nameof(configuredTargetDirectory));
}
if (string.IsNullOrWhiteSpace(targetFileName))
{
throw new ArgumentException(
"Der Zertifikats-Zieldateiname ist nicht konfiguriert.",
nameof(targetFileName));
}
if (Path.GetFileName(targetFileName) != targetFileName)
{
throw new InvalidOperationException(
"TargetFileName darf keinen Verzeichnispfad enthalten.");
}
string targetDirectory = Path.GetFullPath(
Path.IsPathRooted(configuredTargetDirectory)
? configuredTargetDirectory
: Path.Combine(
AppContext.BaseDirectory,
configuredTargetDirectory));
cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(targetDirectory);
string destinationPath = Path.Combine(
targetDirectory,
targetFileName);
string temporaryPath = destinationPath + ".tmp";
try
{
if (backupEnabled)
{
BackupExistingCertificates(
targetDirectory,
backupDirectoryName,
cancellationToken);
}
await using (FileStream source = new(
sourceCertificatePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
await using (FileStream temporaryTarget = new(
temporaryPath,
FileMode.Create,
FileAccess.Write,
FileShare.None))
{
await source.CopyToAsync(
temporaryTarget,
cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(
temporaryPath,
destinationPath,
overwrite: true);
return destinationPath;
}
catch
{
TryDeleteTemporaryFile(temporaryPath);
throw;
}
}
private static void BackupExistingCertificates(
string targetDirectory,
string configuredBackupDirectoryName,
CancellationToken cancellationToken)
{
List<string> filesToBackup = Directory
.EnumerateFiles(
targetDirectory,
"*",
SearchOption.TopDirectoryOnly)
.Where(path =>
CertificateExtensions.Contains(
Path.GetExtension(path)))
.ToList();
if (filesToBackup.Count == 0)
{
return;
}
string backupDirectoryName =
string.IsNullOrWhiteSpace(configuredBackupDirectoryName)
? "Backup"
: configuredBackupDirectoryName.Trim();
if (Path.GetFileName(backupDirectoryName)
!= backupDirectoryName)
{
throw new InvalidOperationException(
"BackupDirectoryName darf keinen Verzeichnispfad enthalten.");
}
string backupDirectory = Path.Combine(
targetDirectory,
backupDirectoryName,
DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"));
Directory.CreateDirectory(backupDirectory);
foreach (string certificateFile in filesToBackup)
{
cancellationToken.ThrowIfCancellationRequested();
string backupPath = Path.Combine(
backupDirectory,
Path.GetFileName(certificateFile));
File.Copy(
certificateFile,
backupPath,
overwrite: false);
}
}
private static void TryDeleteTemporaryFile(
string temporaryPath)
{
try
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
catch
{
// Der ursprüngliche Fehler darf nicht verdeckt werden.
// TODO: Aufräumfehler später separat protokollieren.
}
}
}
@@ -0,0 +1,85 @@
using System.Text.Json;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class LocalSetupSelectionStore
{
private static readonly JsonSerializerOptions JsonOptions =
new()
{
WriteIndented = true
};
private readonly string _filePath;
public LocalSetupSelectionStore()
{
string directory = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData),
"ZA.CoreService.ESBCertificateManager");
_filePath = Path.Combine(
directory,
"setup-selection.json");
}
public LocalSetupSelection? Load()
{
if (!File.Exists(_filePath))
{
return null;
}
try
{
string json =
File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<LocalSetupSelection>(
json,
JsonOptions);
}
catch
{
// Eine beschädigte lokale Auswahl darf den Start
// nicht verhindern. Das Setup wird erneut angezeigt.
return null;
}
}
public void Save(
LocalSetupSelection selection)
{
ArgumentNullException.ThrowIfNull(selection);
string? directory =
Path.GetDirectoryName(_filePath);
if (string.IsNullOrWhiteSpace(directory))
{
throw new InvalidOperationException(
"Der lokale Setup-Ordner konnte nicht bestimmt werden.");
}
Directory.CreateDirectory(directory);
string json =
JsonSerializer.Serialize(
selection,
JsonOptions);
File.WriteAllText(
_filePath,
json);
}
public void Clear()
{
if (File.Exists(_filePath))
{
File.Delete(_filePath);
}
}
}
@@ -14,6 +14,26 @@ public static class PathResolver
return Path.GetFullPath(path);
}
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
return Path.GetFullPath(
Path.Combine(
AppContext.BaseDirectory,
path));
}
public static string ResolveRequiredFile(
string configuredPath,
string description)
{
string resolvedPath =
ResolvePath(configuredPath);
if (!File.Exists(resolvedPath))
{
throw new FileNotFoundException(
$"{description} wurde nicht gefunden.",
resolvedPath);
}
return resolvedPath;
}
}
@@ -5,12 +5,13 @@ namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class RestartExecutor
{
private readonly IReadOnlyList<SonicConnection> _sonicConnections;
private readonly ISecretProvider _secretProvider;
public RestartExecutor(IReadOnlyList<SonicConnection> sonicConnections)
public RestartExecutor(
IReadOnlyList<SonicConnection> sonicConnections)
{
_sonicConnections = sonicConnections;
}
public async Task<(bool Success, string Status, string? Detail)> ExecuteAsync(
DeploymentTarget target,
CancellationToken cancellationToken = default)
@@ -29,7 +30,8 @@ public sealed class RestartExecutor
$"Ziel '{target.Name}': SonicConnection '{target.SonicConnectionName}' fehlt in appsettings.");
}
using SonicManagementClient client = new(connection);
using SonicManagementClient client =
new(connection);
return await client.RestartContainerAsync(target.ContainerName, cancellationToken);
}
}
@@ -0,0 +1,172 @@
using System.Diagnostics;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class RuntimeEnvironmentValidator
{
public async Task<RuntimeValidationResult> ValidateAsync(
RuntimeSettings settings,
CancellationToken cancellationToken = default)
{
List<string> issues = [];
string javaExecutablePath =
PathResolver.ResolvePath(
settings.JavaExecutablePath);
string sonicClientLibraryPath =
PathResolver.ResolvePath(
settings.SonicClientLibraryPath);
string javaVersion = string.Empty;
if (!File.Exists(javaExecutablePath))
{
issues.Add(
$"Java wurde nicht gefunden: {javaExecutablePath}");
}
else
{
try
{
javaVersion = await ReadJavaVersionAsync(
javaExecutablePath,
cancellationToken);
if (!ContainsJava8Version(javaVersion))
{
issues.Add(
"Die gefundene Java-Version ist nicht Java 8. " +
$"Ausgabe: {javaVersion}");
}
}
catch (Exception ex)
{
issues.Add(
$"Java konnte nicht ausgeführt werden: {ex.Message}");
}
}
if (!Directory.Exists(sonicClientLibraryPath))
{
issues.Add(
"Der Sonic-Client-Bibliotheksordner wurde " +
$"nicht gefunden: {sonicClientLibraryPath}");
}
else
{
string[] requiredJarNames =
[
"mgmt_client.jar",
"mfcontext.jar"
];
foreach (string requiredJarName in requiredJarNames)
{
string requiredJarPath = Path.Combine(
sonicClientLibraryPath,
requiredJarName);
if (!File.Exists(requiredJarPath))
{
issues.Add(
$"Erforderliche Sonic-Bibliothek fehlt: " +
$"{requiredJarPath}");
}
}
}
return new RuntimeValidationResult
{
IsValid = issues.Count == 0,
JavaExecutablePath = javaExecutablePath,
SonicClientLibraryPath = sonicClientLibraryPath,
JavaVersion = javaVersion,
Issues = issues
};
}
private static async Task<string> ReadJavaVersionAsync(
string javaExecutablePath,
CancellationToken cancellationToken)
{
ProcessStartInfo startInfo = new()
{
FileName = javaExecutablePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("-version");
using Process process = new()
{
StartInfo = startInfo
};
if (!process.Start())
{
throw new InvalidOperationException(
"Der Java-Prozess konnte nicht gestartet werden.");
}
Task<string> outputTask =
process.StandardOutput.ReadToEndAsync(
cancellationToken);
Task<string> errorTask =
process.StandardError.ReadToEndAsync(
cancellationToken);
await process.WaitForExitAsync(
cancellationToken);
string output = await outputTask;
string error = await errorTask;
string combined = string.Join(
Environment.NewLine,
new[] { output, error }
.Where(value =>
!string.IsNullOrWhiteSpace(value)));
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"java -version endete mit Code {process.ExitCode}. " +
combined);
}
return combined.Trim();
}
private static bool ContainsJava8Version(
string javaVersionOutput)
{
return javaVersionOutput.Contains(
"\"1.8.",
StringComparison.OrdinalIgnoreCase)
|| javaVersionOutput.Contains(
"version 8",
StringComparison.OrdinalIgnoreCase);
}
}
public sealed class RuntimeValidationResult
{
public bool IsValid { get; init; }
public string JavaExecutablePath { get; init; } =
string.Empty;
public string SonicClientLibraryPath { get; init; } =
string.Empty;
public string JavaVersion { get; init; } =
string.Empty;
public IReadOnlyList<string> Issues { get; init; } = [];
}
@@ -0,0 +1,333 @@
using System.Data;
using Microsoft.Data.SqlClient;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class SonicConnectionRepository
{
private readonly string _connectionString;
public SonicConnectionRepository(
string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException(
"Der SQL-Connection-String ist nicht konfiguriert.",
nameof(connectionString));
}
_connectionString = connectionString;
}
public async Task<IReadOnlyList<DatabaseSonicConnection>>
GetActiveAsync(
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT
SonicConnectionId,
ConnectionName,
ManagementHost,
ManagementPort,
ConnectionProtocol,
DomainName,
CredentialUserName,
CredentialReference,
CredentialSecret,
JavaHomePath,
SonicHomePath,
Notes,
IsActive
FROM dbo.SonicConnection
WHERE IsActive = 1
ORDER BY ConnectionName;
""";
List<DatabaseSonicConnection> connections = [];
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 idOrdinal =
reader.GetOrdinal("SonicConnectionId");
int nameOrdinal =
reader.GetOrdinal("ConnectionName");
int hostOrdinal =
reader.GetOrdinal("ManagementHost");
int portOrdinal =
reader.GetOrdinal("ManagementPort");
int protocolOrdinal =
reader.GetOrdinal("ConnectionProtocol");
int domainOrdinal =
reader.GetOrdinal("DomainName");
int userNameOrdinal =
reader.GetOrdinal("CredentialUserName");
int credentialReferenceOrdinal =
reader.GetOrdinal("CredentialReference");
int credentialSecretOrdinal =
reader.GetOrdinal("CredentialSecret");
int javaHomeOrdinal =
reader.GetOrdinal("JavaHomePath");
int sonicHomeOrdinal =
reader.GetOrdinal("SonicHomePath");
int notesOrdinal =
reader.GetOrdinal("Notes");
int isActiveOrdinal =
reader.GetOrdinal("IsActive");
while (await reader.ReadAsync(cancellationToken))
{
connections.Add(
new DatabaseSonicConnection
{
Id = reader.GetInt32(idOrdinal),
Name =
reader.GetString(nameOrdinal),
ManagementHost =
reader.GetString(hostOrdinal),
ManagementPort =
ReadNullableInt32(
reader,
portOrdinal),
ConnectionProtocol =
ReadNullableString(
reader,
protocolOrdinal),
DomainName =
ReadNullableString(
reader,
domainOrdinal),
CredentialUserName =
ReadNullableString(
reader,
userNameOrdinal),
CredentialReference =
ReadNullableString(
reader,
credentialReferenceOrdinal),
CredentialSecret =
ReadNullableString(
reader,
credentialSecretOrdinal),
JavaHomePath =
ReadNullableString(
reader,
javaHomeOrdinal),
SonicHomePath =
ReadNullableString(
reader,
sonicHomeOrdinal),
Notes =
ReadNullableString(
reader,
notesOrdinal),
IsActive =
reader.GetBoolean(
isActiveOrdinal)
});
}
return connections;
}
public async Task SaveCredentialsAsync(
int sonicConnectionId,
string credentialUserName,
string credentialSecret,
CancellationToken cancellationToken = default)
{
if (sonicConnectionId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicConnectionId),
"Die SonicConnectionId ist ungültig.");
}
if (string.IsNullOrWhiteSpace(
credentialUserName))
{
throw new ArgumentException(
"Der Sonic-Benutzername fehlt.",
nameof(credentialUserName));
}
if (string.IsNullOrEmpty(
credentialSecret))
{
throw new ArgumentException(
"Das Sonic-Kennwort fehlt.",
nameof(credentialSecret));
}
string normalizedUserName =
credentialUserName.Trim();
if (normalizedUserName.Length > 256)
{
throw new ArgumentException(
"Der Sonic-Benutzername darf maximal 256 Zeichen enthalten.",
nameof(credentialUserName));
}
if (credentialSecret.Length > 512)
{
throw new ArgumentException(
"Das Sonic-Kennwort darf maximal 512 Zeichen enthalten.",
nameof(credentialSecret));
}
const string sql = """
UPDATE dbo.SonicConnection
SET
CredentialUserName = @CredentialUserName,
CredentialSecret = @CredentialSecret,
UpdatedAtUtc = SYSUTCDATETIME()
WHERE SonicConnectionId = @SonicConnectionId
AND IsActive = 1;
""";
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlCommand command =
new(sql, connection);
command.Parameters.Add(
new SqlParameter(
"@CredentialUserName",
SqlDbType.NVarChar,
256)
{
Value = normalizedUserName
});
command.Parameters.Add(
new SqlParameter(
"@CredentialSecret",
SqlDbType.NVarChar,
512)
{
Value = credentialSecret
});
command.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
int affectedRows =
await command.ExecuteNonQueryAsync(
cancellationToken);
if (affectedRows != 1)
{
throw new InvalidOperationException(
"Die aktive Sonic-Verbindung wurde nicht eindeutig gefunden.");
}
}
public async Task ClearCredentialsAsync(
int sonicConnectionId,
CancellationToken cancellationToken = default)
{
if (sonicConnectionId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicConnectionId),
"Die SonicConnectionId ist ungültig.");
}
const string sql = """
UPDATE dbo.SonicConnection
SET
CredentialUserName = NULL,
CredentialSecret = NULL,
UpdatedAtUtc = SYSUTCDATETIME()
WHERE SonicConnectionId = @SonicConnectionId;
""";
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlCommand command =
new(sql, connection);
command.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
int affectedRows =
await command.ExecuteNonQueryAsync(
cancellationToken);
if (affectedRows != 1)
{
throw new InvalidOperationException(
"Die Sonic-Verbindung wurde nicht eindeutig gefunden.");
}
}
private static string? ReadNullableString(
SqlDataReader reader,
int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: reader.GetString(ordinal);
}
private static int? ReadNullableInt32(
SqlDataReader reader,
int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: reader.GetInt32(ordinal);
}
}
@@ -1,48 +0,0 @@
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Liefert Ziele nur aus appsettings KnownContainers (kein Live-Ping/List).
/// </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;
public IReadOnlyList<DeploymentTarget> BuildTargetsFromConfig()
{
List<DeploymentTarget> result = [];
int id = 1;
foreach (SonicConnection connection in _connections)
{
foreach (string containerName in connection.KnownContainers
.Where(n => !string.IsNullOrWhiteSpace(n)))
{
result.Add(new DeploymentTarget
{
Id = id++,
Name = $"{connection.Name} / {containerName}",
Environment = connection.DomainName,
IsActive = true,
TargetDirectory = string.Empty,
CertificateFileName = string.Empty,
ContainerName = containerName.Trim(),
RestartType = RestartType.SonicContainer,
SonicConnectionName = connection.Name,
SortOrder = id * 10
});
}
}
return result;
}
}
@@ -0,0 +1,301 @@
using System.Diagnostics;
using System.Text;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class SonicCredentialTester
{
private readonly RuntimeSettings _runtimeSettings;
public SonicCredentialTester(
RuntimeSettings runtimeSettings)
{
_runtimeSettings =
runtimeSettings
?? throw new ArgumentNullException(
nameof(runtimeSettings));
}
public async Task<CredentialTestResult> TestAsync(
SonicSystemOption system,
string userName,
string secret,
string containerName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(system);
if (string.IsNullOrWhiteSpace(userName))
{
return CredentialTestResult.Failed(
"Der Sonic-Benutzername fehlt.");
}
if (string.IsNullOrEmpty(secret))
{
return CredentialTestResult.Failed(
"Das Sonic-Kennwort fehlt.");
}
if (string.IsNullOrWhiteSpace(containerName))
{
return CredentialTestResult.Failed(
"Für den Verbindungstest fehlt ein Container.");
}
string javaPath =
PathResolver.ResolvePath(
_runtimeSettings.JavaExecutablePath);
string libraryPath =
PathResolver.ResolvePath(
_runtimeSettings.SonicClientLibraryPath);
string? toolsPath =
ResolveToolsDirectory();
if (!File.Exists(javaPath))
{
return CredentialTestResult.Failed(
$"Java wurde nicht gefunden: {javaPath}");
}
if (!Directory.Exists(libraryPath))
{
return CredentialTestResult.Failed(
$"Der Sonic-JAR-Ordner wurde nicht gefunden: {libraryPath}");
}
if (toolsPath is null)
{
return CredentialTestResult.Failed(
"SonicMfContainerTool.class wurde nicht gefunden.");
}
string[] jars =
Directory.GetFiles(
libraryPath,
"*.jar",
SearchOption.TopDirectoryOnly);
if (jars.Length == 0)
{
return CredentialTestResult.Failed(
"Im Sonic-Client-Ordner wurden keine JAR-Dateien gefunden.");
}
string classpath =
toolsPath
+ Path.PathSeparator
+ string.Join(
Path.PathSeparator,
jars.OrderBy(
Path.GetFileName,
StringComparer.OrdinalIgnoreCase));
ProcessStartInfo startInfo = new()
{
FileName = javaPath,
WorkingDirectory = toolsPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("-cp");
startInfo.ArgumentList.Add(classpath);
startInfo.ArgumentList.Add(
"SonicMfContainerTool");
startInfo.ArgumentList.Add("validate");
startInfo.ArgumentList.Add("--domain");
startInfo.ArgumentList.Add(
system.DomainName);
startInfo.ArgumentList.Add("--url");
startInfo.ArgumentList.Add(
system.ConnectionUrl);
startInfo.ArgumentList.Add("--user");
startInfo.ArgumentList.Add(
userName.Trim());
startInfo.ArgumentList.Add("--container");
startInfo.ArgumentList.Add(
containerName);
startInfo.ArgumentList.Add("--timeout");
startInfo.ArgumentList.Add("30");
startInfo.Environment[
"ESB_SONIC_PASSWORD"] = secret;
using Process process = new()
{
StartInfo = startInfo
};
try
{
if (!process.Start())
{
return CredentialTestResult.Failed(
"Der Java-Prozess konnte nicht gestartet werden.");
}
}
catch (Exception ex)
{
return CredentialTestResult.Failed(
$"Java konnte nicht gestartet werden: {ex.Message}");
}
Task<string> standardOutputTask =
process.StandardOutput.ReadToEndAsync();
Task<string> standardErrorTask =
process.StandardError.ReadToEndAsync();
using CancellationTokenSource timeoutSource =
CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutSource.CancelAfter(
TimeSpan.FromSeconds(35));
try
{
await process.WaitForExitAsync(
timeoutSource.Token);
}
catch (OperationCanceledException)
when (!cancellationToken.IsCancellationRequested)
{
TryKill(process);
return CredentialTestResult.Failed(
"Der Sonic-Verbindungstest hat das Zeitlimit überschritten.");
}
catch (OperationCanceledException)
{
TryKill(process);
throw;
}
string standardOutput =
(await standardOutputTask).Trim();
string standardError =
(await standardErrorTask).Trim();
string combinedOutput =
string.Join(
Environment.NewLine,
new[]
{
standardOutput,
standardError
}
.Where(value =>
!string.IsNullOrWhiteSpace(value)));
bool success =
process.ExitCode == 0
&& combinedOutput.Contains(
"OK:ConnectionValidated",
StringComparison.OrdinalIgnoreCase)
&& !combinedOutput.Contains(
"ERROR:",
StringComparison.OrdinalIgnoreCase);
if (success)
{
return CredentialTestResult.Successful();
}
string error =
ExtractError(combinedOutput)
?? $"Sonic-Verbindungstest fehlgeschlagen, " +
$"ExitCode={process.ExitCode}.";
return CredentialTestResult.Failed(error);
}
private static string? ResolveToolsDirectory()
{
string[] candidates =
[
Path.Combine(
AppContext.BaseDirectory,
"Tools"),
Path.Combine(
Directory.GetCurrentDirectory(),
"Tools")
];
return candidates.FirstOrDefault(
directory =>
File.Exists(
Path.Combine(
directory,
"SonicMfContainerTool.class")));
}
private static string? ExtractError(
string output)
{
return output
.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(
line =>
line.StartsWith(
"ERROR:",
StringComparison.OrdinalIgnoreCase))
?["ERROR:".Length..]
.Trim();
}
private static void TryKill(
Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(
entireProcessTree: true);
}
}
catch
{
// Der ursprüngliche Testfehler bleibt erhalten.
}
}
}
public sealed record CredentialTestResult(
bool Success,
string Message)
{
public static CredentialTestResult Successful()
{
return new CredentialTestResult(
true,
"Sonic-Anmeldung und lesender Containerzugriff waren erfolgreich.");
}
public static CredentialTestResult Failed(
string message)
{
return new CredentialTestResult(
false,
message);
}
}
@@ -10,7 +10,8 @@ public sealed class SonicManagementClient : IDisposable
private readonly SonicConnection _connection;
private readonly SonicMfApiExecutor _mfApi;
public SonicManagementClient(SonicConnection connection)
public SonicManagementClient(
SonicConnection connection)
{
_connection = connection;
_mfApi = new SonicMfApiExecutor(connection);
@@ -5,37 +5,99 @@ using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Startet nur den Container-Restart über SonicMfContainerTool (Java + MF-Client-JARs).
/// Startet den Container-Restart über SonicMfContainerTool
/// mit Java und den Sonic-MfApi-Clientbibliotheken.
/// </summary>
public sealed class SonicMfApiExecutor
{
private readonly SonicConnection _connection;
public SonicMfApiExecutor(SonicConnection connection)
public SonicMfApiExecutor(
SonicConnection connection)
{
_connection = connection;
_connection =
connection
?? throw new ArgumentNullException(
nameof(connection));
}
public (string SonicHome, string? JavaExe) ResolveRuntimePaths()
public (string SonicHome, string? JavaExe)
ResolveRuntimePaths()
{
string sonicHome = string.IsNullOrWhiteSpace(_connection.SonicHome)
? "(leer)"
: _connection.SonicHome.Trim();
return (sonicHome, ResolveJavaExe());
string sonicHome =
string.IsNullOrWhiteSpace(
_connection.SonicHome)
? "(leer)"
: _connection.SonicHome.Trim();
return (
sonicHome,
ResolveJavaExe());
}
public async Task<(bool Success, string? Output, string? Error)> RestartAsync(
string containerName,
CancellationToken cancellationToken = default)
public async Task<(
bool Success,
string? Output,
string? Error)> RestartAsync(
string containerName,
CancellationToken cancellationToken = default)
{
(bool prepared, string? javaExe, string? classDir, string? classpath, string? prepareError) =
PrepareTool();
if (!prepared)
if (string.IsNullOrWhiteSpace(containerName))
{
return (false, null, prepareError);
return (
false,
null,
"Der Sonic-Containername fehlt.");
}
ProcessStartInfo psi = new()
if (string.IsNullOrWhiteSpace(
_connection.Username)
|| string.IsNullOrEmpty(
_connection.Password))
{
return (
false,
null,
"Für die Sonic-Verbindung sind keine " +
"vollständigen Zugangsdaten eingerichtet. " +
"Bitte das Setup ausführen.");
}
if (string.IsNullOrWhiteSpace(
_connection.DomainName))
{
return (
false,
null,
"Für die Sonic-Verbindung fehlt DomainName.");
}
if (string.IsNullOrWhiteSpace(
_connection.ConnectionUrl))
{
return (
false,
null,
"Für die Sonic-Verbindung fehlt ConnectionUrl.");
}
(
bool prepared,
string? javaExe,
string? classDir,
string? classpath,
string? prepareError
) = PrepareTool();
if (!prepared)
{
return (
false,
null,
prepareError);
}
ProcessStartInfo startInfo = new()
{
FileName = javaExe!,
UseShellExecute = false,
@@ -47,173 +109,393 @@ public sealed class SonicMfApiExecutor
WorkingDirectory = classDir!
};
psi.ArgumentList.Add("-cp");
psi.ArgumentList.Add(classpath!);
psi.ArgumentList.Add("SonicMfContainerTool");
psi.ArgumentList.Add("restart");
psi.ArgumentList.Add("--domain");
psi.ArgumentList.Add(_connection.DomainName);
psi.ArgumentList.Add("--url");
psi.ArgumentList.Add(_connection.ConnectionUrl);
psi.ArgumentList.Add("--user");
psi.ArgumentList.Add(_connection.Username);
psi.ArgumentList.Add("--container");
psi.ArgumentList.Add(containerName);
psi.ArgumentList.Add("--timeout");
psi.ArgumentList.Add(Math.Clamp(_connection.TimeoutSeconds, 5, 600).ToString());
startInfo.ArgumentList.Add("-cp");
startInfo.ArgumentList.Add(classpath!);
startInfo.ArgumentList.Add(
"SonicMfContainerTool");
psi.Environment["ESB_SONIC_PASSWORD"] = _connection.Password ?? string.Empty;
startInfo.ArgumentList.Add("restart");
using Process process = new() { StartInfo = psi };
if (!process.Start())
startInfo.ArgumentList.Add("--domain");
startInfo.ArgumentList.Add(
_connection.DomainName);
startInfo.ArgumentList.Add("--url");
startInfo.ArgumentList.Add(
_connection.ConnectionUrl);
startInfo.ArgumentList.Add("--user");
startInfo.ArgumentList.Add(
_connection.Username);
startInfo.ArgumentList.Add("--container");
startInfo.ArgumentList.Add(
containerName);
startInfo.ArgumentList.Add("--timeout");
startInfo.ArgumentList.Add(
Math.Clamp(
_connection.TimeoutSeconds,
5,
600)
.ToString());
/*
* Das Kennwort wird nicht als Kommandozeilenargument
* übergeben. Dadurch erscheint das Kennwort nicht in
* der Prozessargumentliste.
*
* CredentialSecret wurde zuvor durch Microsoft.Data.SqlClient
* clientseitig entschlüsselt und als Laufzeitwert in
* SonicConnection.Password übernommen.
*/
startInfo.Environment[
"ESB_SONIC_PASSWORD"] =
_connection.Password;
using Process process = new()
{
return (false, null, "Java-Prozess konnte nicht gestartet werden.");
}
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);
StartInfo = startInfo
};
try
{
await process.WaitForExitAsync(timeoutCts.Token);
if (!process.Start())
{
return (
false,
null,
"Der Java-Prozess konnte nicht gestartet werden.");
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
catch (Exception ex)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return (false, null, $"MfApi Timeout nach {_connection.TimeoutSeconds}s.");
return (
false,
null,
$"Der Java-Prozess konnte nicht gestartet werden: " +
$"{ex.Message}");
}
string stdout = (await stdoutTask).Trim();
string stderr = (await stderrTask).Trim();
string combined = string.Join("\n", new[] { stdout, stderr }.Where(s => s.Length > 0));
using CancellationTokenSource timeoutCts =
CancellationTokenSource
.CreateLinkedTokenSource(
cancellationToken);
bool ok = process.ExitCode == 0
&& combined.Contains("OK:RestartInvoked", StringComparison.OrdinalIgnoreCase)
&& !combined.Contains("ERROR:", StringComparison.OrdinalIgnoreCase);
int timeoutSeconds =
Math.Clamp(
_connection.TimeoutSeconds,
10,
600);
if (ok)
timeoutCts.CancelAfter(
TimeSpan.FromSeconds(
timeoutSeconds));
Task<string> stdoutTask =
process.StandardOutput
.ReadToEndAsync();
Task<string> stderrTask =
process.StandardError
.ReadToEndAsync();
try
{
return (true, combined, null);
await process.WaitForExitAsync(
timeoutCts.Token);
}
string err = ExtractError(combined)
?? $"SonicMfContainerTool ExitCode={process.ExitCode}";
if (err.Contains("ClassNotFoundException", StringComparison.OrdinalIgnoreCase)
|| combined.Contains("JMSConnectorAddress", StringComparison.OrdinalIgnoreCase))
catch (OperationCanceledException)
when (!cancellationToken.IsCancellationRequested)
{
err +=
"\n\nClasspath unvollständig. MfClientLibPath auf Ordner mit mgmt_client.jar / mfcontext.jar setzen.";
TryKillProcess(process);
return (
false,
null,
$"MfApi-Timeout nach {timeoutSeconds} Sekunden.");
}
catch (OperationCanceledException)
{
TryKillProcess(process);
throw;
}
string detail = string.IsNullOrWhiteSpace(combined) ? err : err + "\n\n--- Tool-Output ---\n" + combined;
return (false, combined.Length > 0 ? combined : null, detail);
string stdout =
(await stdoutTask).Trim();
string stderr =
(await stderrTask).Trim();
string combined = string.Join(
Environment.NewLine,
new[] { stdout, stderr }
.Where(output =>
!string.IsNullOrWhiteSpace(output)));
bool succeeded =
process.ExitCode == 0
&& combined.Contains(
"OK:RestartInvoked",
StringComparison.OrdinalIgnoreCase)
&& !combined.Contains(
"ERROR:",
StringComparison.OrdinalIgnoreCase);
if (succeeded)
{
return (
true,
string.IsNullOrWhiteSpace(combined)
? null
: combined,
null);
}
string error =
ExtractError(combined)
?? $"SonicMfContainerTool ExitCode=" +
$"{process.ExitCode}";
if (error.Contains(
"ClassNotFoundException",
StringComparison.OrdinalIgnoreCase)
|| combined.Contains(
"JMSConnectorAddress",
StringComparison.OrdinalIgnoreCase))
{
error +=
Environment.NewLine +
Environment.NewLine +
"Der Classpath ist vermutlich unvollständig. " +
"Runtime:SonicClientLibraryPath muss auf den " +
"Ordner mit den benötigten Sonic-JARs zeigen.";
}
string detail =
string.IsNullOrWhiteSpace(combined)
? error
: error
+ Environment.NewLine
+ Environment.NewLine
+ "--- Tool-Output ---"
+ Environment.NewLine
+ combined;
return (
false,
string.IsNullOrWhiteSpace(combined)
? null
: combined,
detail);
}
private (bool Ok, string? JavaExe, string? ClassDir, string? Classpath, string? Error) PrepareTool()
private (
bool Ok,
string? JavaExe,
string? ClassDir,
string? Classpath,
string? Error) PrepareTool()
{
string? javaExe = ResolveJavaExe();
string? javaExe =
ResolveJavaExe();
if (javaExe is null)
{
return (false, null, null, null,
"Java nicht gefunden. In appsettings JavaPath auf java.exe (Java 8+) setzen.");
return (
false,
null,
null,
null,
"Java wurde nicht gefunden. " +
"Runtime:JavaExecutablePath prüfen.");
}
(string? libClasspath, string? libError) = ResolveSonicClasspath();
if (libClasspath is null)
(
string? libraryClasspath,
string? libraryError
) = ResolveSonicClasspath();
if (libraryClasspath is null)
{
return (false, null, null, null, libError);
return (
false,
null,
null,
null,
libraryError);
}
string? toolsDir = ResolveToolsDir();
if (toolsDir is null)
string? toolsDirectory =
ResolveToolsDir();
if (toolsDirectory is null)
{
return (false, null, null, null,
"Tools\\SonicMfContainerTool.class nicht gefunden. Projekt neu bauen.");
return (
false,
null,
null,
null,
"Tools\\SonicMfContainerTool.class " +
"wurde nicht gefunden. " +
"Projekt vollständig neu bauen.");
}
string classpath = toolsDir + Path.PathSeparator + libClasspath;
return (true, javaExe, toolsDir, classpath, null);
string classpath =
toolsDirectory
+ Path.PathSeparator
+ libraryClasspath;
return (
true,
javaExe,
toolsDirectory,
classpath,
null);
}
private string? ResolveJavaExe()
{
foreach (string? candidate in EnumerateJavaCandidates())
foreach (string? candidate
in EnumerateJavaCandidates())
{
if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate))
if (string.IsNullOrWhiteSpace(candidate))
{
return Path.GetFullPath(candidate);
continue;
}
string resolvedCandidate;
try
{
resolvedCandidate =
Path.IsPathRooted(candidate)
? Path.GetFullPath(candidate)
: Path.GetFullPath(
Path.Combine(
AppContext.BaseDirectory,
candidate));
}
catch
{
continue;
}
if (File.Exists(resolvedCandidate))
{
return resolvedCandidate;
}
}
return null;
}
private IEnumerable<string?> EnumerateJavaCandidates()
private IEnumerable<string?>
EnumerateJavaCandidates()
{
if (!string.IsNullOrWhiteSpace(_connection.JavaPath))
if (!string.IsNullOrWhiteSpace(
_connection.JavaPath))
{
yield return _connection.JavaPath.Trim();
yield return
_connection.JavaPath.Trim();
}
if (!string.IsNullOrWhiteSpace(_connection.JavaHome))
if (!string.IsNullOrWhiteSpace(
_connection.JavaHome))
{
yield return Path.Combine(_connection.JavaHome.Trim(), "bin", "java.exe");
yield return Path.Combine(
_connection.JavaHome.Trim(),
"bin",
"java.exe");
}
string? javaHome = Environment.GetEnvironmentVariable("JAVA_HOME");
string? javaHome =
Environment.GetEnvironmentVariable(
"JAVA_HOME");
if (!string.IsNullOrWhiteSpace(javaHome))
{
yield return Path.Combine(javaHome.Trim(), "bin", "java.exe");
yield return Path.Combine(
javaHome.Trim(),
"bin",
"java.exe");
}
string? jreHome = Environment.GetEnvironmentVariable("JRE_HOME");
string? jreHome =
Environment.GetEnvironmentVariable(
"JRE_HOME");
if (!string.IsNullOrWhiteSpace(jreHome))
{
yield return Path.Combine(jreHome.Trim(), "bin", "java.exe");
yield return Path.Combine(
jreHome.Trim(),
"bin",
"java.exe");
}
yield return "java.exe"; // PATH
yield return "java.exe";
}
private (string? Classpath, string? Error) ResolveSonicClasspath()
private (
string? Classpath,
string? Error) ResolveSonicClasspath()
{
List<string> libDirs = [];
List<string> libraryDirectories = [];
if (!string.IsNullOrWhiteSpace(_connection.MfClientLibPath))
if (!string.IsNullOrWhiteSpace(
_connection.MfClientLibPath))
{
libDirs.Add(_connection.MfClientLibPath.Trim());
libraryDirectories.Add(
ResolveDirectoryPath(
_connection.MfClientLibPath));
}
if (!string.IsNullOrWhiteSpace(_connection.SonicHome))
if (!string.IsNullOrWhiteSpace(
_connection.SonicHome))
{
string home = _connection.SonicHome.Trim();
libDirs.Add(Path.Combine(home, "lib"));
libDirs.Add(home);
string sonicHome =
ResolveDirectoryPath(
_connection.SonicHome);
libraryDirectories.Add(
Path.Combine(
sonicHome,
"lib"));
libraryDirectories.Add(
sonicHome);
}
HashSet<string> jars = new(StringComparer.OrdinalIgnoreCase);
foreach (string dir in libDirs.Where(Directory.Exists))
HashSet<string> jars =
new(
StringComparer.OrdinalIgnoreCase);
foreach (string directory
in libraryDirectories
.Where(Directory.Exists)
.Distinct(
StringComparer.OrdinalIgnoreCase))
{
foreach (string jar in Directory.EnumerateFiles(dir, "*.jar", SearchOption.TopDirectoryOnly))
foreach (string jar
in Directory.EnumerateFiles(
directory,
"*.jar",
SearchOption.TopDirectoryOnly))
{
jars.Add(jar);
jars.Add(
Path.GetFullPath(jar));
}
}
if (jars.Count == 0)
{
return (null,
"Keine Sonic-Client-JARs gefunden. MfClientLibPath oder SonicHome\\lib setzen "
+ "(mgmt_client.jar, mfcontext.jar, …).");
return (
null,
"Keine Sonic-Client-JARs gefunden. " +
"Runtime:SonicClientLibraryPath prüfen.");
}
// Explizite Kern-JARs zuerst, falls vorhanden
string[] preferred =
string[] preferredJarNames =
[
"mgmt_client.jar",
"mfcontext.jar",
@@ -222,58 +504,125 @@ public sealed class SonicMfApiExecutor
"mf_common.jar"
];
List<string> ordered = [];
foreach (string name in preferred)
List<string> orderedJars = [];
foreach (string preferredJarName
in preferredJarNames)
{
string? hit = jars.FirstOrDefault(j =>
string.Equals(Path.GetFileName(j), name, StringComparison.OrdinalIgnoreCase));
if (hit is not null)
string? matchingJar =
jars.FirstOrDefault(
jar => string.Equals(
Path.GetFileName(jar),
preferredJarName,
StringComparison.OrdinalIgnoreCase));
if (matchingJar is not null)
{
ordered.Add(hit);
orderedJars.Add(
matchingJar);
}
}
foreach (string jar in jars.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase))
foreach (string jar
in jars.OrderBy(
Path.GetFileName,
StringComparer.OrdinalIgnoreCase))
{
if (!ordered.Contains(jar, StringComparer.OrdinalIgnoreCase))
if (!orderedJars.Contains(
jar,
StringComparer.OrdinalIgnoreCase))
{
ordered.Add(jar);
orderedJars.Add(jar);
}
}
return (string.Join(Path.PathSeparator, ordered), null);
return (
string.Join(
Path.PathSeparator,
orderedJars),
null);
}
private static string ResolveDirectoryPath(
string configuredPath)
{
return Path.IsPathRooted(configuredPath)
? Path.GetFullPath(configuredPath)
: Path.GetFullPath(
Path.Combine(
AppContext.BaseDirectory,
configuredPath));
}
private static string? ResolveToolsDir()
{
string[] dirs =
string[] directories =
[
Path.Combine(AppContext.BaseDirectory, "Tools"),
Path.Combine(Directory.GetCurrentDirectory(), "Tools"),
Path.Combine(
AppContext.BaseDirectory,
"Tools"),
Path.Combine(
Directory.GetCurrentDirectory(),
"Tools"),
AppContext.BaseDirectory
];
foreach (string dir in dirs.Where(Directory.Exists))
foreach (string directory
in directories
.Where(Directory.Exists))
{
if (File.Exists(Path.Combine(dir, "SonicMfContainerTool.class")))
string toolPath =
Path.Combine(
directory,
"SonicMfContainerTool.class");
if (File.Exists(toolPath))
{
return dir;
return directory;
}
}
return null;
}
private static string? ExtractError(string combined)
private static string? ExtractError(
string combinedOutput)
{
foreach (string line in combined.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
foreach (string line
in combinedOutput.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase))
if (line.StartsWith(
"ERROR:",
StringComparison.OrdinalIgnoreCase))
{
return line["ERROR:".Length..].Trim();
return line[
"ERROR:".Length..]
.Trim();
}
}
return null;
}
}
private static void TryKillProcess(
Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(
entireProcessTree: true);
}
}
catch
{
// Der ursprüngliche Fehler oder Timeout
// darf nicht verdeckt werden.
}
}
}
@@ -0,0 +1,505 @@
using System.Data;
using Microsoft.Data.SqlClient;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class SonicSetupRepository
{
private readonly string _connectionString;
public SonicSetupRepository(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException(
"Der SQL-Connection-String fehlt.",
nameof(connectionString));
}
_connectionString = connectionString;
}
public async Task<IReadOnlyList<SonicSystemOption>> GetSystemsAsync(
string environmentCode,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(environmentCode))
{
throw new ArgumentException(
"Der EnvironmentCode fehlt.",
nameof(environmentCode));
}
const string sql = """
SELECT
connection.SonicConnectionId,
connection.ConnectionName,
environment.EnvironmentCode,
connection.DomainName,
connection.ManagementHost,
connection.ManagementPort,
connection.ConnectionProtocol,
MIN(container.ContainerName)
AS ValidationContainerName
FROM dbo.SonicConnection AS connection
INNER JOIN dbo.SonicContainer AS container
ON container.SonicConnectionId =
connection.SonicConnectionId
INNER JOIN dbo.EsbService AS service
ON service.EsbServiceId =
container.EsbServiceId
INNER JOIN dbo.Environment AS environment
ON environment.EnvironmentId =
service.EnvironmentId
WHERE connection.IsActive = 1
AND container.IsActive = 1
AND service.IsActive = 1
AND environment.IsActive = 1
AND environment.EnvironmentCode =
@EnvironmentCode
GROUP BY
connection.SonicConnectionId,
connection.ConnectionName,
environment.EnvironmentCode,
connection.DomainName,
connection.ManagementHost,
connection.ManagementPort,
connection.ConnectionProtocol
ORDER BY connection.ConnectionName;
""";
List<SonicSystemOption> systems = [];
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlCommand command =
new(sql, connection);
command.Parameters.Add(
new SqlParameter(
"@EnvironmentCode",
SqlDbType.NVarChar,
30)
{
Value = environmentCode
.Trim()
.ToUpperInvariant()
});
await using SqlDataReader reader =
await command.ExecuteReaderAsync(cancellationToken);
int sonicConnectionIdOrdinal =
reader.GetOrdinal("SonicConnectionId");
int connectionNameOrdinal =
reader.GetOrdinal("ConnectionName");
int environmentCodeOrdinal =
reader.GetOrdinal("EnvironmentCode");
int domainNameOrdinal =
reader.GetOrdinal("DomainName");
int managementHostOrdinal =
reader.GetOrdinal("ManagementHost");
int managementPortOrdinal =
reader.GetOrdinal("ManagementPort");
int connectionProtocolOrdinal =
reader.GetOrdinal("ConnectionProtocol");
int validationContainerNameOrdinal =
reader.GetOrdinal("ValidationContainerName");
while (await reader.ReadAsync(cancellationToken))
{
if (reader.IsDBNull(managementPortOrdinal))
{
continue;
}
systems.Add(
new SonicSystemOption
{
SonicConnectionId =
reader.GetInt32(
sonicConnectionIdOrdinal),
ConnectionName =
reader.GetString(
connectionNameOrdinal),
EnvironmentCode =
reader.GetString(
environmentCodeOrdinal),
DomainName =
reader.IsDBNull(domainNameOrdinal)
? string.Empty
: reader.GetString(
domainNameOrdinal),
ManagementHost =
reader.GetString(
managementHostOrdinal),
ManagementPort =
reader.GetInt32(
managementPortOrdinal),
ConnectionProtocol =
reader.IsDBNull(connectionProtocolOrdinal)
? "tcp"
: reader.GetString(
connectionProtocolOrdinal),
ValidationContainerName =
reader.IsDBNull(
validationContainerNameOrdinal)
? string.Empty
: reader.GetString(
validationContainerNameOrdinal)
});
}
return systems;
}
public async Task<int> AddCredentialAsync(
int sonicConnectionId,
string credentialName,
string userName,
string secret,
bool isDefault,
CancellationToken cancellationToken = default)
{
if (sonicConnectionId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicConnectionId));
}
if (string.IsNullOrWhiteSpace(credentialName))
{
throw new ArgumentException(
"Der Profilname fehlt.",
nameof(credentialName));
}
if (string.IsNullOrWhiteSpace(userName))
{
throw new ArgumentException(
"Der Sonic-Benutzername fehlt.",
nameof(userName));
}
if (string.IsNullOrEmpty(secret))
{
throw new ArgumentException(
"Das Sonic-Kennwort fehlt.",
nameof(secret));
}
string normalizedCredentialName =
credentialName.Trim();
string normalizedUserName =
userName.Trim();
if (normalizedCredentialName.Length > 150)
{
throw new ArgumentException(
"Der Profilname darf maximal 150 Zeichen enthalten.",
nameof(credentialName));
}
if (normalizedUserName.Length > 256)
{
throw new ArgumentException(
"Der Benutzername darf maximal 256 Zeichen enthalten.",
nameof(userName));
}
if (secret.Length > 512)
{
throw new ArgumentException(
"Das Kennwort darf maximal 512 Zeichen enthalten.",
nameof(secret));
}
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlTransaction transaction =
(SqlTransaction)
await connection.BeginTransactionAsync(
cancellationToken);
try
{
if (isDefault)
{
const string clearDefaultSql = """
UPDATE dbo.SonicCredential
SET
IsDefault = 0,
UpdatedAtUtc = SYSUTCDATETIME()
WHERE SonicConnectionId =
@SonicConnectionId
AND IsDefault = 1;
""";
await using SqlCommand clearDefaultCommand =
new(
clearDefaultSql,
connection,
transaction);
clearDefaultCommand.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
await clearDefaultCommand.ExecuteNonQueryAsync(
cancellationToken);
}
const string insertSql = """
INSERT INTO dbo.SonicCredential
(
SonicConnectionId,
CredentialName,
CredentialUserName,
CredentialSecret,
IsDefault,
IsActive,
CreatedAtUtc,
UpdatedAtUtc
)
OUTPUT INSERTED.SonicCredentialId
VALUES
(
@SonicConnectionId,
@CredentialName,
@CredentialUserName,
@CredentialSecret,
@IsDefault,
1,
SYSUTCDATETIME(),
NULL
);
""";
await using SqlCommand insertCommand =
new(
insertSql,
connection,
transaction);
insertCommand.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
insertCommand.Parameters.Add(
new SqlParameter(
"@CredentialName",
SqlDbType.NVarChar,
150)
{
Value = normalizedCredentialName
});
insertCommand.Parameters.Add(
new SqlParameter(
"@CredentialUserName",
SqlDbType.NVarChar,
256)
{
Value = normalizedUserName
});
insertCommand.Parameters.Add(
new SqlParameter(
"@CredentialSecret",
SqlDbType.NVarChar,
512)
{
Value = secret
});
insertCommand.Parameters.Add(
new SqlParameter(
"@IsDefault",
SqlDbType.Bit)
{
Value = isDefault
});
object? result =
await insertCommand.ExecuteScalarAsync(
cancellationToken);
if (result is null || result is DBNull)
{
throw new InvalidOperationException(
"Das Benutzerprofil konnte nicht angelegt werden.");
}
int credentialId =
Convert.ToInt32(result);
await transaction.CommitAsync(
cancellationToken);
return credentialId;
}
catch
{
try
{
await transaction.RollbackAsync(
CancellationToken.None);
}
catch
{
// Die ursprüngliche Exception soll erhalten bleiben.
}
throw;
}
}
public async Task<IReadOnlyList<SonicCredentialProfile>>
GetCredentialsAsync(
int sonicConnectionId,
CancellationToken cancellationToken = default)
{
if (sonicConnectionId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicConnectionId));
}
const string sql = """
SELECT
SonicCredentialId,
SonicConnectionId,
CredentialName,
CredentialUserName,
CredentialSecret,
IsDefault,
IsActive
FROM dbo.SonicCredential
WHERE SonicConnectionId = @SonicConnectionId
AND IsActive = 1
ORDER BY
IsDefault DESC,
CredentialName;
""";
List<SonicCredentialProfile> profiles = [];
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlCommand command =
new(sql, connection);
command.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
await using SqlDataReader reader =
await command.ExecuteReaderAsync(cancellationToken);
int credentialIdOrdinal =
reader.GetOrdinal("SonicCredentialId");
int connectionIdOrdinal =
reader.GetOrdinal("SonicConnectionId");
int credentialNameOrdinal =
reader.GetOrdinal("CredentialName");
int userNameOrdinal =
reader.GetOrdinal("CredentialUserName");
int secretOrdinal =
reader.GetOrdinal("CredentialSecret");
int isDefaultOrdinal =
reader.GetOrdinal("IsDefault");
int isActiveOrdinal =
reader.GetOrdinal("IsActive");
while (await reader.ReadAsync(cancellationToken))
{
string? userName =
reader.IsDBNull(userNameOrdinal)
? null
: reader.GetString(userNameOrdinal);
string? secret =
reader.IsDBNull(secretOrdinal)
? null
: reader.GetString(secretOrdinal);
if (string.IsNullOrWhiteSpace(userName)
|| string.IsNullOrEmpty(secret))
{
continue;
}
profiles.Add(
new SonicCredentialProfile
{
SonicCredentialId =
reader.GetInt32(
credentialIdOrdinal),
SonicConnectionId =
reader.GetInt32(
connectionIdOrdinal),
CredentialName =
reader.GetString(
credentialNameOrdinal),
UserName = userName,
Secret = secret,
IsDefault =
reader.GetBoolean(
isDefaultOrdinal),
IsActive =
reader.GetBoolean(
isActiveOrdinal)
});
}
return profiles;
}
}
@@ -0,0 +1,126 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class WindowsCredentialManagerSecretProvider
: ISecretProvider
{
private const uint GenericCredentialType = 1;
private const int ErrorNotFound = 1168;
public StoredCredential GetCredential(
string credentialReference)
{
if (string.IsNullOrWhiteSpace(credentialReference))
{
throw new ArgumentException(
"Die CredentialReference ist nicht konfiguriert.",
nameof(credentialReference));
}
bool success = CredRead(
credentialReference,
GenericCredentialType,
0,
out IntPtr credentialPointer);
if (!success)
{
int errorCode = Marshal.GetLastWin32Error();
if (errorCode == ErrorNotFound)
{
throw new InvalidOperationException(
$"Die Windows-Anmeldeinformation " +
$"'{credentialReference}' wurde nicht gefunden.");
}
throw new Win32Exception(
errorCode,
"Die Windows-Anmeldeinformation konnte nicht gelesen werden.");
}
try
{
NativeCredential nativeCredential =
Marshal.PtrToStructure<NativeCredential>(
credentialPointer);
string userName =
Marshal.PtrToStringUni(nativeCredential.UserName)
?? string.Empty;
string secret = string.Empty;
if (nativeCredential.CredentialBlob != IntPtr.Zero
&& nativeCredential.CredentialBlobSize > 0)
{
int characterCount = checked(
(int)nativeCredential.CredentialBlobSize / 2);
secret = Marshal.PtrToStringUni(
nativeCredential.CredentialBlob,
characterCount)
?? string.Empty;
}
if (string.IsNullOrWhiteSpace(userName))
{
throw new InvalidOperationException(
$"Die Windows-Anmeldeinformation " +
$"'{credentialReference}' enthält keinen Benutzernamen.");
}
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException(
$"Die Windows-Anmeldeinformation " +
$"'{credentialReference}' enthält kein Kennwort.");
}
return new StoredCredential(
userName,
secret);
}
finally
{
CredFree(credentialPointer);
}
}
[DllImport(
"advapi32.dll",
EntryPoint = "CredReadW",
CharSet = CharSet.Unicode,
SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredRead(
string target,
uint type,
int reservedFlag,
out IntPtr credentialPointer);
[DllImport("advapi32.dll")]
private static extern void CredFree(
IntPtr credentialPointer);
[StructLayout(
LayoutKind.Sequential,
CharSet = CharSet.Unicode)]
private struct NativeCredential
{
public uint Flags;
public uint Type;
public IntPtr TargetName;
public IntPtr Comment;
public long LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public IntPtr TargetAlias;
public IntPtr UserName;
}
}