Initial commit: ESB Certificate Manager.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 08:10:13 +02:00
co-authored by Cursor
commit ed8e2822d1
36 changed files with 5111 additions and 0 deletions
@@ -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] + "…";
}