begin application build overhaul

This commit is contained in:
Nathan
2025-12-17 15:34:34 -07:00
parent 5cc6060323
commit 610cdb62a8
23 changed files with 2379 additions and 0 deletions

307
Services/SshService.cs Normal file
View 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;
}
}
}