begin application build overhaul
This commit is contained in:
307
Services/SshService.cs
Normal file
307
Services/SshService.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
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 process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = GetSshExecutable(),
|
||||
Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")),
|
||||
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> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user