Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
- 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.
187 lines
6.1 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|