Files
123123/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs
T
2026-07-24 08:10:13 +02:00

165 lines
6.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] + "…";
}