Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Matt Batchelder 45c94b6536
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
feat: Add main application views and structure
- Implemented CreateInstanceView for creating new instances.
- Added HostsView for managing SSH hosts with CRUD operations.
- Created InstancesView for displaying and managing instances.
- Developed LogsView for viewing operation logs.
- Introduced SecretsView for managing secrets associated with hosts.
- Established SettingsView for configuring application settings.
- Created MainWindow as the main application window with navigation.
- Added app manifest and configuration files for logging and settings.
2026-02-18 10:43:27 -05:00

187 lines
6.1 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 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);
}
public void Dispose()
{
lock (_lock)
{
foreach (var client in _clients.Values)
{
try { client.Disconnect(); } catch { }
client.Dispose();
}
_clients.Clear();
}
}
}