Initial commit: ESB Certificate Manager.
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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] + "…";
|
||||
}
|
||||
Reference in New Issue
Block a user