diff --git a/src/Vantage.Core/Ssh/ISshCommandRunner.cs b/src/Vantage.Core/Ssh/ISshCommandRunner.cs new file mode 100644 index 0000000..e278f5c --- /dev/null +++ b/src/Vantage.Core/Ssh/ISshCommandRunner.cs @@ -0,0 +1,8 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Ssh; + +public interface ISshCommandRunner +{ + Task RunAsync(HostConfig host, string command, CancellationToken cancellationToken); +} diff --git a/src/Vantage.Core/Ssh/ShellArgs.cs b/src/Vantage.Core/Ssh/ShellArgs.cs new file mode 100644 index 0000000..3853f02 --- /dev/null +++ b/src/Vantage.Core/Ssh/ShellArgs.cs @@ -0,0 +1,6 @@ +namespace Vantage.Core.Ssh; + +public static class ShellArgs +{ + public static string Quote(string value) => "'" + value.Replace("'", "'\\''") + "'"; +} diff --git a/src/Vantage.Core/Ssh/SshNetCommandRunner.cs b/src/Vantage.Core/Ssh/SshNetCommandRunner.cs new file mode 100644 index 0000000..ab1886d --- /dev/null +++ b/src/Vantage.Core/Ssh/SshNetCommandRunner.cs @@ -0,0 +1,58 @@ +using Renci.SshNet; +using Renci.SshNet.Common; +using Vantage.Core.Models; + +namespace Vantage.Core.Ssh; + +public sealed class SshNetCommandRunner : ISshCommandRunner +{ + public async Task RunAsync(HostConfig host, string command, CancellationToken cancellationToken) + { + var connectionInfo = BuildConnectionInfo(host); + + using var client = new SshClient(connectionInfo); + await Task.Run(() => client.Connect(), cancellationToken).ConfigureAwait(false); + try + { + using var sshCommand = client.CreateCommand(command); + var output = await Task.Factory + .FromAsync(sshCommand.BeginExecute(), sshCommand.EndExecute) + .ConfigureAwait(false); + + if (sshCommand.ExitStatus != 0 && !string.IsNullOrEmpty(sshCommand.Error)) + throw new SshException($"Remote command failed (exit {sshCommand.ExitStatus}): {sshCommand.Error.Trim()}"); + + return output; + } + finally + { + client.Disconnect(); + } + } + + private static ConnectionInfo BuildConnectionInfo(HostConfig host) + { + var authentication = host.AuthMethod switch + { + SshAuthMethod.PrivateKey => PrivateKeyAuth(host), + SshAuthMethod.Password => new PasswordAuthenticationMethod(host.Username, host.Password ?? ""), + SshAuthMethod.Agent => throw new NotSupportedException( + "SSH agent authentication is not wired yet; configure PrivateKey or Password."), + _ => throw new ArgumentOutOfRangeException(nameof(host)) + }; + + return new ConnectionInfo(host.Address, host.Port, host.Username, authentication); + } + + private static AuthenticationMethod PrivateKeyAuth(HostConfig host) + { + if (string.IsNullOrWhiteSpace(host.PrivateKeyPath)) + throw new InvalidOperationException($"Host '{host.Name}' uses PrivateKey auth but no key path is set."); + + var keyFile = string.IsNullOrEmpty(host.PrivateKeyPassphrase) + ? new PrivateKeyFile(host.PrivateKeyPath) + : new PrivateKeyFile(host.PrivateKeyPath, host.PrivateKeyPassphrase); + + return new PrivateKeyAuthenticationMethod(host.Username, keyFile); + } +}