Files
123123/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs
T
GizzlerandCursor cfbf80ecbf Fix WinRM restart: avoid UTF-8 BOM PowerShell parse error.
Run scripts via -File (UTF-8 no BOM) and Base64 ScriptBlock, using official stopcontainer/startcontainer for MF restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 08:36:10 +02:00

184 lines
7.6 KiB
C#

using System.Diagnostics;
using System.Text;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Führt PowerShell-Befehle lokal oder via WinRM (Invoke-Command) auf dem Sonic-Server aus.
/// Schreibt Skripte als .ps1 (UTF-8 ohne BOM) und startet sie mit -File,
/// um den bekannten stdin/BOM-Fehler zu vermeiden
/// ("$ErrorActionPreference wurde nicht als Name eines Cmdlet erkannt").
/// </summary>
public sealed class WinRmExecutor
{
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
private readonly SonicConnection _connection;
public WinRmExecutor(SonicConnection connection)
{
_connection = connection;
}
public async Task<(bool Success, string? Error)> TestConnectionAsync(
CancellationToken cancellationToken = default)
{
if (_connection.ManagementMode == SonicManagementMode.LocalCmd)
{
(bool ok, string? output, string? error) = await RunScriptAsync(
"$env:COMPUTERNAME", cancellationToken);
return ok
? (true, null)
: (false, $"LocalCmd fehlgeschlagen: {error ?? output}");
}
string host = ExtractHost(_connection.ConnectionUrl);
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: Enable-PSRemoting -Force\n" +
"Oder ManagementMode=LocalCmd setzen und die App auf dem Sonic-PC starten.");
}
(bool sessionOk, _, string? sessionError) = await RunScriptAsync(
"$env:COMPUTERNAME", cancellationToken);
return sessionOk
? (true, null)
: (false, $"WinRM-Verbindung fehlgeschlagen: {sessionError}");
}
public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync(
string scriptBlock,
CancellationToken cancellationToken = default)
{
string fullScript = _connection.ManagementMode == SonicManagementMode.LocalCmd
? BuildLocalScript(scriptBlock)
: BuildRemoteScript(ExtractHost(_connection.ConnectionUrl), scriptBlock);
string tempFile = Path.Combine(
Path.GetTempPath(),
$"esb-winrm-{Guid.NewGuid():N}.ps1");
await File.WriteAllTextAsync(tempFile, fullScript, Utf8NoBom, cancellationToken);
try
{
ProcessStartInfo psi = new()
{
FileName = "powershell.exe",
Arguments = "-NonInteractive -NoProfile -ExecutionPolicy Bypass -File \"" + tempFile + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using Process process = new() { StartInfo = psi };
if (!process.Start())
{
return (false, null, "PowerShell-Prozess konnte nicht gestartet werden.");
}
using CancellationTokenSource timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 10, 600)));
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return (false, null, $"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}");
}
finally
{
try { File.Delete(tempFile); } catch { /* ignore */ }
}
}
private static string BuildLocalScript(string scriptBlock)
=> "$ErrorActionPreference = 'Stop'\n" + scriptBlock;
/// <summary>
/// Remote-Payload als Base64 einbetten und per [scriptblock]::Create ausführen.
/// Vermeidet verschachteltes ScriptBlock-Brace-Nesting und lokale $-Expansion.
/// </summary>
private string BuildRemoteScript(string host, string scriptBlock)
{
string escapedPwd = _connection.Password.Replace("'", "''");
string escapedUser = _connection.Username.Replace("'", "''");
string escapedHost = host.Replace("'", "''");
string remoteB64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptBlock));
return
"$ErrorActionPreference = 'Stop'\n" +
$"$secPwd = ConvertTo-SecureString '{escapedPwd}' -AsPlainText -Force\n" +
$"$cred = New-Object System.Management.Automation.PSCredential('{escapedUser}', $secPwd)\n" +
$"$remoteB64 = '{remoteB64}'\n" +
"$remoteScript = [System.Text.Encoding]::Unicode.GetString(" +
"[System.Convert]::FromBase64String($remoteB64))\n" +
"$sb = [scriptblock]::Create($remoteScript)\n" +
$"Invoke-Command -ComputerName '{escapedHost}' -Port {_connection.WinRmPort} " +
"-Credential $cred -ScriptBlock $sb -ErrorAction Stop";
}
public static string ApplyScriptTemplate(
string template,
string containerName,
string domainName = "",
string sonicHome = "",
string xapiPath = "",
string connectionUrl = "",
string username = "",
string password = "")
=> template
.Replace("{container}", containerName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{domain}", domainName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{sonicHome}", sonicHome.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{xapiPath}", xapiPath.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{connectionUrl}", connectionUrl.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{username}", username.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{password}", password.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] + "…";
}