feat: Add main application views and structure
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
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.
This commit is contained in:
186
OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Normal file
186
OTSSignsOrchestrator.Desktop/Services/SshConnectionService.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user