Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
229 lines
8.0 KiB
C#
229 lines
8.0 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using OTSSignsOrchestrator.Core.Models.Entities;
|
|
using Renci.SshNet;
|
|
|
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
|
|
|
/// <summary>
|
|
/// Manages SSH connections to remote Docker Swarm hosts.
|
|
/// Creates and caches SshClient instances with key or password authentication.
|
|
/// </summary>
|
|
public class SshConnectionService : IDisposable
|
|
{
|
|
private readonly ILogger<SshConnectionService> _logger;
|
|
private readonly Dictionary<Guid, SshClient> _clients = new();
|
|
private readonly object _lock = new();
|
|
|
|
public SshConnectionService(ILogger<SshConnectionService> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get or create a connected SshClient for a given SshHost.
|
|
/// </summary>
|
|
public SshClient GetClient(SshHost host)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_clients.TryGetValue(host.Id, out var existing) && existing.IsConnected)
|
|
return existing;
|
|
|
|
// Dispose old client if disconnected
|
|
if (existing != null)
|
|
{
|
|
existing.Dispose();
|
|
_clients.Remove(host.Id);
|
|
}
|
|
|
|
var client = CreateClient(host);
|
|
client.Connect();
|
|
_clients[host.Id] = client;
|
|
|
|
_logger.LogInformation("SSH connected to {Host}:{Port} as {User}", host.Host, host.Port, host.Username);
|
|
return client;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test the SSH connection to a host. Returns (success, message).
|
|
/// </summary>
|
|
public async Task<(bool Success, string Message)> TestConnectionAsync(SshHost host)
|
|
{
|
|
return await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
using var client = CreateClient(host);
|
|
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10);
|
|
client.Connect();
|
|
|
|
if (client.IsConnected)
|
|
{
|
|
// Quick test: run a simple command
|
|
using var cmd = client.RunCommand("docker --version");
|
|
client.Disconnect();
|
|
|
|
if (cmd.ExitStatus == 0)
|
|
return (true, $"Connected. {cmd.Result.Trim()}");
|
|
else
|
|
return (true, $"Connected but docker not available: {cmd.Error}");
|
|
}
|
|
|
|
return (false, "Failed to connect.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "SSH connection test failed for {Host}:{Port}", host.Host, host.Port);
|
|
return (false, $"Connection failed: {ex.Message}");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run a command on the remote host and return (exitCode, stdout, stderr).
|
|
/// </summary>
|
|
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command)
|
|
{
|
|
return await Task.Run(() =>
|
|
{
|
|
var client = GetClient(host);
|
|
using var cmd = client.RunCommand(command);
|
|
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run a command on the remote host with a timeout.
|
|
/// Returns exit code -1 and an error message if the command times out.
|
|
/// </summary>
|
|
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command, TimeSpan timeout)
|
|
{
|
|
return await Task.Run(() =>
|
|
{
|
|
var client = GetClient(host);
|
|
using var cmd = client.CreateCommand(command);
|
|
cmd.CommandTimeout = timeout;
|
|
try
|
|
{
|
|
cmd.Execute();
|
|
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
|
}
|
|
catch (Renci.SshNet.Common.SshOperationTimeoutException)
|
|
{
|
|
_logger.LogWarning("SSH command timed out after {Timeout}s: {Command}",
|
|
timeout.TotalSeconds, command.Length > 120 ? command[..120] + "…" : command);
|
|
return (-1, string.Empty, $"Command timed out after {timeout.TotalSeconds}s");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
|
|
/// </summary>
|
|
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandWithStdinAsync(
|
|
SshHost host, string command, string stdinContent)
|
|
{
|
|
return await Task.Run(() =>
|
|
{
|
|
var client = GetClient(host);
|
|
|
|
// Use shell stream approach for stdin piping
|
|
// We pipe via: echo '<content>' | <command>
|
|
// But for large YAML, use a heredoc approach
|
|
var safeContent = stdinContent.Replace("'", "'\\''");
|
|
var fullCommand = $"printf '%s' '{safeContent}' | {command}";
|
|
|
|
using var cmd = client.RunCommand(fullCommand);
|
|
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disconnect and remove a cached client.
|
|
/// </summary>
|
|
public void Disconnect(Guid hostId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_clients.TryGetValue(hostId, out var client))
|
|
{
|
|
client.Disconnect();
|
|
client.Dispose();
|
|
_clients.Remove(hostId);
|
|
_logger.LogInformation("SSH disconnected from host {HostId}", hostId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private SshClient CreateClient(SshHost host)
|
|
{
|
|
var authMethods = new List<AuthenticationMethod>();
|
|
|
|
if (host.UseKeyAuth && !string.IsNullOrEmpty(host.PrivateKeyPath))
|
|
{
|
|
var keyFile = string.IsNullOrEmpty(host.KeyPassphrase)
|
|
? new PrivateKeyFile(host.PrivateKeyPath)
|
|
: new PrivateKeyFile(host.PrivateKeyPath, host.KeyPassphrase);
|
|
|
|
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, keyFile));
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(host.Password))
|
|
{
|
|
authMethods.Add(new PasswordAuthenticationMethod(host.Username, host.Password));
|
|
}
|
|
|
|
if (authMethods.Count == 0)
|
|
{
|
|
// Fall back to default SSH agent / key in ~/.ssh/
|
|
var defaultKeyPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
|
|
|
if (File.Exists(defaultKeyPath))
|
|
{
|
|
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, new PrivateKeyFile(defaultKeyPath)));
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"No authentication method configured for SSH host '{host.Label}'. " +
|
|
"Provide a private key path or password.");
|
|
}
|
|
}
|
|
|
|
var connInfo = new ConnectionInfo(host.Host, host.Port, host.Username, authMethods.ToArray());
|
|
return new SshClient(connInfo);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens an SSH local port-forward from 127.0.0.1:<auto> → <paramref name="remoteHost"/>:<paramref name="remotePort"/>
|
|
/// through the existing SSH connection for <paramref name="host"/>.
|
|
/// The caller must dispose the returned <see cref="ForwardedPortLocal"/> to close the tunnel.
|
|
/// </summary>
|
|
public ForwardedPortLocal OpenForwardedPort(SshHost host, string remoteHost, uint remotePort)
|
|
{
|
|
var client = GetClient(host);
|
|
// Port 0 lets the OS assign a free local port; SSH.NET updates BoundPort after Start().
|
|
var tunnel = new ForwardedPortLocal("127.0.0.1", 0, remoteHost, remotePort);
|
|
client.AddForwardedPort(tunnel);
|
|
tunnel.Start();
|
|
_logger.LogDebug("SSH tunnel opened: 127.0.0.1:{LocalPort} → {RemoteHost}:{RemotePort}",
|
|
tunnel.BoundPort, remoteHost, remotePort);
|
|
return tunnel;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
foreach (var client in _clients.Values)
|
|
{
|
|
try { client.Disconnect(); } catch { }
|
|
client.Dispose();
|
|
}
|
|
_clients.Clear();
|
|
}
|
|
}
|
|
}
|