Use ConnectionUrl and SMC credentials with IAgentProxy.restart; keep WinRM only as optional fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
269 lines
11 KiB
C#
269 lines
11 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;
|
||
}
|
||
|
||
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<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);
|
||
}
|
||
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Remote-Payload als Base64 einbetten und per [scriptblock]::Create ausführen.
|
||
/// Vermeidet verschachteltes ScriptBlock-Brace-Nesting und lokale $-Expansion.
|
||
/// WinRM-Credentials: nur <see cref="SonicConnection.WinRmUsername"/> / WinRmPassword.
|
||
/// Leer = aktueller Windows-Benutzer (ohne -Credential).
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Erkennt typische WinRM-/Windows-Auth-Fehler und liefert eine klare Handlungsanweisung.
|
||
/// Username/Password in appsettings sind Sonic-SMC – nicht Windows/WinRM.
|
||
/// </summary>
|
||
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<string> 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] + "…";
|
||
}
|