using Microsoft.Extensions.Logging; using OTSSignsOrchestrator.Core.Models.Entities; using Renci.SshNet; namespace OTSSignsOrchestrator.Desktop.Services; /// /// Manages SSH connections to remote Docker Swarm hosts. /// Creates and caches SshClient instances with key or password authentication. /// public class SshConnectionService : IDisposable { private readonly ILogger _logger; private readonly Dictionary _clients = new(); private readonly object _lock = new(); public SshConnectionService(ILogger logger) { _logger = logger; } /// /// Get or create a connected SshClient for a given SshHost. /// 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; } } /// /// Test the SSH connection to a host. Returns (success, message). /// 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}"); } }); } /// /// Run a command on the remote host and return (exitCode, stdout, stderr). /// 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); }); } /// /// Run a command on the remote host with a timeout. /// Returns exit code -1 and an error message if the command times out. /// 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"); } }); } /// /// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -). /// 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 '' | // 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); }); } /// /// Disconnect and remove a cached client. /// 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(); 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); } /// /// Opens an SSH local port-forward from 127.0.0.1:<auto> → : /// through the existing SSH connection for . /// The caller must dispose the returned to close the tunnel. /// 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(); } } }