using System.Diagnostics; using System.Text; using ZA.CoreService.ESBCertificateManager.Models; namespace ZA.CoreService.ESBCertificateManager.Services; /// /// 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"). /// public sealed class WinRmExecutor { private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); private readonly SonicConnection _connection; public WinRmExecutor(SonicConnection connection) { _connection = connection; } private bool HasExplicitWinRmCredentials => !string.IsNullOrWhiteSpace(_connection.WinRmUsername); public async Task<(bool Success, string? Error)> TestConnectionAsync( CancellationToken cancellationToken = default) { if (_connection.EffectiveManagementMode == 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, sessionError ?? "WinRM-Verbindung fehlgeschlagen."); } public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync( string scriptBlock, CancellationToken cancellationToken = default) { string fullScript = _connection.EffectiveManagementMode == 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 stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); Task 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); } string rawError = stderr.Length > 0 ? stderr : $"PowerShell ExitCode={process.ExitCode}"; string error = _connection.EffectiveManagementMode == SonicManagementMode.WinRm ? FormatWinRmFailure(rawError) : Truncate(rawError); return (false, stdout.Length > 0 ? stdout : null, error); } finally { try { File.Delete(tempFile); } catch { /* ignore */ } } } private static string BuildLocalScript(string scriptBlock) => "$ErrorActionPreference = 'Stop'\n" + scriptBlock; /// /// Remote-Payload als Base64 einbetten und per [scriptblock]::Create ausführen. /// Vermeidet verschachteltes ScriptBlock-Brace-Nesting und lokale $-Expansion. /// WinRM-Credentials: nur / WinRmPassword. /// Leer = aktueller Windows-Benutzer (ohne -Credential). /// private string BuildRemoteScript(string host, string scriptBlock) { string escapedHost = host.Replace("'", "''"); string remoteB64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptBlock)); StringBuilder sb = new(); sb.Append("$ErrorActionPreference = 'Stop'\n"); if (HasExplicitWinRmCredentials) { string escapedPwd = (_connection.WinRmPassword ?? string.Empty).Replace("'", "''"); string escapedUser = _connection.WinRmUsername.Replace("'", "''"); sb.Append($"$secPwd = ConvertTo-SecureString '{escapedPwd}' -AsPlainText -Force\n"); sb.Append($"$cred = New-Object System.Management.Automation.PSCredential('{escapedUser}', $secPwd)\n"); } sb.Append($"$remoteB64 = '{remoteB64}'\n"); sb.Append("$remoteScript = [System.Text.Encoding]::Unicode.GetString("); sb.Append("[System.Convert]::FromBase64String($remoteB64))\n"); sb.Append("$sb = [scriptblock]::Create($remoteScript)\n"); sb.Append($"Invoke-Command -ComputerName '{escapedHost}' -Port {_connection.WinRmPort} "); if (HasExplicitWinRmCredentials) { sb.Append("-Credential $cred "); } sb.Append("-ScriptBlock $sb -ErrorAction Stop"); return sb.ToString(); } /// /// Erkennt typische WinRM-/Windows-Auth-Fehler und liefert eine klare Handlungsanweisung. /// Username/Password in appsettings sind Sonic-SMC – nicht Windows/WinRM. /// private string FormatWinRmFailure(string rawError) { string truncated = Truncate(rawError); if (!LooksLikeWinRmAuthFailure(rawError)) { return truncated; } string authHint = HasExplicitWinRmCredentials ? "WinRmUsername/WinRmPassword prüfen (Windows-Konto mit WinRM-Rechten auf dem Zielrechner)." : "Aktueller Windows-Benutzer hat keine WinRM-Berechtigung auf dem Zielrechner " + "(oder Kerberos/CredSSP fehlt). WinRmUsername/WinRmPassword setzen " + "oder App unter einem berechtigten Windows-Konto starten."; return "WinRM-Authentifizierung fehlgeschlagen: Windows-Anmeldedaten falsch oder fehlend.\n" + "Hinweis: Username/Password in appsettings sind Sonic-SMC-/Domain-Manager-Logins – " + "NICHT für WinRM/Windows.\n" + authHint + "\n" + "Alternativen:\n" + " • ManagementMode=LocalCmd setzen und die App direkt auf dem Sonic-Server starten (kein WinRM).\n" + " • WinRmUsername/WinRmPassword mit gültigem Windows-Konto befüllen.\n" + $"Details: {truncated}"; } private static bool LooksLikeWinRmAuthFailure(string error) { if (string.IsNullOrEmpty(error)) { return false; } ReadOnlySpan markers = [ "Benutzername oder das Kennwort ist falsch", "username or password is incorrect", "Access is denied", "Zugriff verweigert", "Logon failure", "Anmeldefehler", "PSRemotingTransportException", "UnauthorizedAccess", "WinRM cannot process the request", "der remotecomputer hat den netzwerkdatenverkehr verweigert" ]; foreach (string marker in markers) { if (error.Contains(marker, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } 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] + "…"; }