359 lines
12 KiB
C#
359 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using UnifiedFarmLauncher.Models;
|
|
|
|
namespace UnifiedFarmLauncher.Services
|
|
{
|
|
public class SshConnectionParts
|
|
{
|
|
public string Host { get; set; } = string.Empty;
|
|
public List<string> Options { get; set; } = new();
|
|
public int? Port { get; set; }
|
|
public bool RequestPty { get; set; }
|
|
}
|
|
|
|
public class SshService
|
|
{
|
|
private readonly Dictionary<string, string> _workerBasePathCache = new();
|
|
|
|
private static string GetSshExecutable()
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
{
|
|
// Try common Windows OpenSSH locations
|
|
var paths = new[]
|
|
{
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "OpenSSH", "ssh.exe"),
|
|
"ssh.exe" // In PATH
|
|
};
|
|
foreach (var path in paths)
|
|
{
|
|
if (File.Exists(path) || IsInPath("ssh.exe"))
|
|
return "ssh.exe";
|
|
}
|
|
return "ssh.exe";
|
|
}
|
|
return "ssh";
|
|
}
|
|
|
|
private static string GetScpExecutable()
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
{
|
|
var paths = new[]
|
|
{
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "OpenSSH", "scp.exe"),
|
|
"scp.exe"
|
|
};
|
|
foreach (var path in paths)
|
|
{
|
|
if (File.Exists(path) || IsInPath("scp.exe"))
|
|
return "scp.exe";
|
|
}
|
|
return "scp.exe";
|
|
}
|
|
return "scp";
|
|
}
|
|
|
|
private static bool IsInPath(string executable)
|
|
{
|
|
try
|
|
{
|
|
var process = Process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = executable,
|
|
Arguments = "-V",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
});
|
|
process?.WaitForExit(1000);
|
|
return process?.ExitCode == 0 || process?.ExitCode == 1; // SSH/SCP typically exit with 1 for version info
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public SshConnectionParts ParseConnectionParts(string rawArgs, string defaultHost)
|
|
{
|
|
var parts = new SshConnectionParts { Host = defaultHost };
|
|
if (string.IsNullOrWhiteSpace(rawArgs))
|
|
return parts;
|
|
|
|
var tokens = rawArgs.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
|
var options = new List<string>();
|
|
string? targetHost = null;
|
|
int? port = null;
|
|
bool requestPty = false;
|
|
|
|
var optionsWithArgs = new HashSet<string> { "-i", "-o", "-c", "-D", "-E", "-F", "-I", "-J", "-L", "-l", "-m", "-O", "-Q", "-R", "-S", "-W", "-w" };
|
|
|
|
for (int i = 0; i < tokens.Length; i++)
|
|
{
|
|
var token = tokens[i];
|
|
if (token == "-t" || token == "-tt")
|
|
{
|
|
requestPty = true;
|
|
continue;
|
|
}
|
|
|
|
if (token == "-p" && i + 1 < tokens.Length)
|
|
{
|
|
if (int.TryParse(tokens[i + 1], out var portValue))
|
|
{
|
|
port = portValue;
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (token.StartsWith("-"))
|
|
{
|
|
options.Add(token);
|
|
if (optionsWithArgs.Contains(token) && i + 1 < tokens.Length)
|
|
{
|
|
options.Add(tokens[i + 1]);
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (targetHost == null)
|
|
{
|
|
targetHost = token;
|
|
continue;
|
|
}
|
|
|
|
options.Add(token);
|
|
}
|
|
|
|
parts.Host = targetHost ?? defaultHost;
|
|
parts.Options = options;
|
|
parts.Port = port;
|
|
parts.RequestPty = requestPty;
|
|
return parts;
|
|
}
|
|
|
|
public List<string> BuildSshArgs(SshConnectionParts parts, bool interactive)
|
|
{
|
|
var args = new List<string>
|
|
{
|
|
"-o", "ServerAliveInterval=60",
|
|
"-o", "ServerAliveCountMax=30"
|
|
};
|
|
|
|
if (interactive && parts.RequestPty)
|
|
{
|
|
args.Add("-t");
|
|
}
|
|
else if (!interactive)
|
|
{
|
|
args.Add("-T");
|
|
}
|
|
|
|
args.AddRange(parts.Options);
|
|
|
|
if (parts.Port.HasValue)
|
|
{
|
|
args.Add("-p");
|
|
args.Add(parts.Port.Value.ToString());
|
|
}
|
|
|
|
args.Add(parts.Host);
|
|
return args;
|
|
}
|
|
|
|
public List<string> BuildScpArgs(SshConnectionParts parts)
|
|
{
|
|
var args = new List<string>
|
|
{
|
|
"-o", "ServerAliveInterval=60",
|
|
"-o", "ServerAliveCountMax=30"
|
|
};
|
|
|
|
args.AddRange(parts.Options);
|
|
|
|
if (parts.Port.HasValue)
|
|
{
|
|
args.Add("-P");
|
|
args.Add(parts.Port.Value.ToString());
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
public async Task<string> ExecuteRemoteCommandAsync(WorkerConfig worker, string command, bool interactive = false)
|
|
{
|
|
var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
|
|
var sshArgs = BuildSshArgs(parts, interactive);
|
|
sshArgs.Add(command);
|
|
|
|
var argsString = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\""));
|
|
|
|
var process = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = GetSshExecutable(),
|
|
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
Arguments = argsString,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = !interactive
|
|
}
|
|
};
|
|
|
|
var output = new StringBuilder();
|
|
var error = new StringBuilder();
|
|
|
|
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
|
|
process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
|
|
|
|
process.Start();
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
if (process.ExitCode != 0 && !interactive)
|
|
{
|
|
throw new InvalidOperationException($"SSH command failed with exit code {process.ExitCode}: {error}");
|
|
}
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
public async Task<string> ExecuteRemoteScriptAsync(WorkerConfig worker, string script, bool interactive = false)
|
|
{
|
|
var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
|
|
var sshArgs = BuildSshArgs(parts, interactive);
|
|
// Add PowerShell command as a single quoted argument for remote execution
|
|
sshArgs.Add("powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -Command -");
|
|
|
|
var argsString = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\""));
|
|
|
|
var process = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = GetSshExecutable(),
|
|
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
Arguments = argsString,
|
|
UseShellExecute = false,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = !interactive
|
|
}
|
|
};
|
|
|
|
var output = new StringBuilder();
|
|
var error = new StringBuilder();
|
|
|
|
process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
|
|
process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
|
|
|
|
process.Start();
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
// Write script to stdin
|
|
await process.StandardInput.WriteAsync(script);
|
|
process.StandardInput.Close();
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
if (process.ExitCode != 0 && !interactive)
|
|
{
|
|
throw new InvalidOperationException($"SSH script execution failed with exit code {process.ExitCode}: {error}");
|
|
}
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
public async Task<string> GetWorkerBasePathAsync(WorkerConfig worker)
|
|
{
|
|
if (_workerBasePathCache.TryGetValue(worker.Name, out var cached))
|
|
return cached;
|
|
|
|
var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
|
|
var scriptBlock = "$ProgressPreference='SilentlyContinue'; [Environment]::GetFolderPath('LocalApplicationData')";
|
|
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptBlock));
|
|
var remoteCmd = $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {encoded}";
|
|
|
|
var output = await ExecuteRemoteCommandAsync(worker, remoteCmd);
|
|
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
|
var basePath = lines.LastOrDefault()?.Trim();
|
|
|
|
if (string.IsNullOrEmpty(basePath))
|
|
throw new InvalidOperationException($"Unable to read LocalAppData path on {worker.Name}.");
|
|
|
|
var finalPath = Path.Combine(basePath, "UnifiedWorkers");
|
|
_workerBasePathCache[worker.Name] = finalPath;
|
|
return finalPath;
|
|
}
|
|
|
|
public async Task CopyFileToRemoteAsync(WorkerConfig worker, string localPath, string remotePath)
|
|
{
|
|
var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
|
|
var scpArgs = BuildScpArgs(parts);
|
|
scpArgs.Add(localPath);
|
|
scpArgs.Add($"{parts.Host}:\"{remotePath.Replace("\\", "/")}\"");
|
|
|
|
var process = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = GetScpExecutable(),
|
|
Arguments = string.Join(" ", scpArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")),
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
await process.WaitForExitAsync();
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
var error = await process.StandardError.ReadToEndAsync();
|
|
throw new InvalidOperationException($"SCP failed with exit code {process.ExitCode}: {error}");
|
|
}
|
|
}
|
|
|
|
public Process StartInteractiveSsh(WorkerConfig worker, string command)
|
|
{
|
|
var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
|
|
var sshArgs = BuildSshArgs(parts, true);
|
|
sshArgs.Add(command);
|
|
|
|
var process = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = GetSshExecutable(),
|
|
Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")),
|
|
UseShellExecute = true,
|
|
CreateNoWindow = false
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
return process;
|
|
}
|
|
}
|
|
}
|
|
|