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 Options { get; set; } = new(); public int? Port { get; set; } public bool RequestPty { get; set; } } public class SshService { private readonly Dictionary _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? targetHost = null; int? port = null; bool requestPty = false; var optionsWithArgs = new HashSet { "-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 BuildSshArgs(SshConnectionParts parts, bool interactive) { var args = new List { "-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 BuildScpArgs(SshConnectionParts parts) { var args = new List { "-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 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 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 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 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 = true, CreateNoWindow = false } }; process.Start(); return process; } } }