using Renci.SshNet; namespace AiQ_GUI { internal class SSH { public const string SSHUsername = "mav"; public const string SSHPassword = "mavPA$$"; // Connects to camera over SSH and collects the Vaxtor packages, filesystem name, filesystem size, and tailscale status. public static SSHData CollectSSHData(string IPAddress) { SSHData Data = new(); try { using SshClient client = new SshClient(IPAddress, SSHUsername, SSHPassword); client.Connect(); try { Data.packages = GetVaxtorPackages(client); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Failed to get Vaxtor packages: {ex.Message}"); Data.packages = "Error"; } try { (Data.FilesystemName, Data.FilesystemSize) = GetRootFilesystemInfo(client); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Failed to get filesystem info: {ex.Message}"); Data.FilesystemName = "Unknown"; Data.FilesystemSize = "Unknown"; } try { Data.tailscale = IsTailscaleInstalled(client); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Failed to check Tailscale: {ex.Message}"); Data.tailscale = false; } client.Disconnect(); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"SSH connection failed: {ex.Message}. Check password or network."); } string LogMssg = string.Join(" | ", typeof(SSHData).GetProperties().Select(p => $"{p.Name}: {p.GetValue(Data)}")); Logging.LogMessage(LogMssg); // Log all of Data return Data; } // Gets a list of packages with Vaxtor in the name public static string GetVaxtorPackages(SshClient client) { try { SshCommand cmd = client.RunCommand("dpkg -l | grep vaxtor"); if (!string.IsNullOrWhiteSpace(cmd.Error)) throw new Exception(cmd.Error); string result = cmd.Result; if (string.IsNullOrWhiteSpace(result)) return "No Vaxtor packages found."; string[] lines = result.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); List packages = []; foreach (string line in lines) { string[] parts = line.Split([' '], StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 3) packages.Add($"{parts[1]} - {parts[2]}"); // Package name - Version } return packages.Count > 0 ? string.Join("\n", packages) : "No Vaxtor packages found."; } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error getting Vaxtor packages: {ex.Message}"); return "Error"; } } // Returns true if Tailscale is installed on the camera public static bool IsTailscaleInstalled(SshClient client) { try { SshCommand cmd = client.RunCommand("dpkg -l | grep '^ii' | grep tailscale"); if (!string.IsNullOrWhiteSpace(cmd.Error)) throw new Exception(cmd.Error); string command = cmd.Result; if (string.IsNullOrWhiteSpace(command)) return false; string[] lines = command.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); foreach (string line in lines) { string[] parts = line.Split([' '], StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 3 && parts[1].Equals("tailscale", StringComparison.OrdinalIgnoreCase)) return true; // Return true if any line contains "tailscale" as the package name } return false; } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error checking Tailscale: {ex.Message}"); return false; } } // Connects to camera over SSH and collects the Vaxtor packages, filesystem name, filesystem size, and tailscale status. public static (string filesystem, string size) CollectFSData(string IPAddress) { try { using SshClient client = new SshClient(IPAddress, SSHUsername, SSHPassword); client.Connect(); try { return GetRootFilesystemInfo(client); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Failed to get filesystem info: {ex.Message}"); } client.Disconnect(); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"SSH connection failed: {ex.Message}. Check password or network."); } return (string.Empty, string.Empty); } // Gets the filesystem size and partition name public static (string filesystem, string size) GetRootFilesystemInfo(SshClient client) { try { SshCommand cmd = client.RunCommand("df -h"); if (!string.IsNullOrWhiteSpace(cmd.Error)) throw new Exception(cmd.Error); string result = cmd.Result; if (string.IsNullOrWhiteSpace(result)) return ("Unknown", "Unknown"); IEnumerable lines = result.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Skip(1); foreach (string line in lines) { string[] parts = line.Split([' '], StringSplitOptions.RemoveEmptyEntries); // Long enough & Filesystem name isn't tmpfs varient or none & Mountpoint is root if (parts.Length >= 6 && !parts[0].Contains("tmpfs") && parts[0] != "none" && parts[5] == "/") return (parts[0], parts[1]); // Return name & size of partition } return ("Unknown", "Unknown"); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error getting filesystem info: {ex.Message}"); return ("Unknown", "Unknown"); } } // Sorts the SSH client for the filesystem call public static (string filesystem, string size) GetRootFilesystemInfo(string IPAddress) { try { using SshClient client = new(IPAddress, SSHUsername, SSHPassword); client.Connect(); (string FilesystemName, string FilesystemSize) = GetRootFilesystemInfo(client); client.Disconnect(); return (FilesystemName, FilesystemSize); } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error getting root filesystem info: {ex.Message}"); return ("Unknown", "Unknown"); } } // Checks the filesystem size and expands it if necessary, displays on the label how big the SD card is. public static async Task CheckFSSize(string IPAddress, Label LblFSSize, SSHData sshData) { const double MinGoodSize = 100.0; // 100GB const double MaxGoodSize = 150.0; // 150GB double currentSize = NormaliseFSSize(sshData.FilesystemSize); LblFSSize.Text = $"Filesystem Size = {currentSize}GB"; if (currentSize >= MinGoodSize && currentSize <= MaxGoodSize) { LblFSSize.ForeColor = Color.LightGreen; return sshData; } if (currentSize < MinGoodSize) { try { if (await ExpandFS(sshData.FilesystemName, IPAddress)) { (sshData.FilesystemName, sshData.FilesystemSize) = GetRootFilesystemInfo(IPAddress); double newSize = NormaliseFSSize(sshData.FilesystemSize); LblFSSize.Text = $"Filesystem Size = {newSize}GB"; if (newSize >= MinGoodSize && newSize <= MaxGoodSize) { LblFSSize.ForeColor = Color.LightGreen; return sshData; } } } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error expanding filesystem: {ex.Message}"); } LblFSSize.ForeColor = Color.Red; MainForm.Instance.AddToActionsList("Size is too small, failed to expand FS, please try manually."); return sshData; } LblFSSize.ForeColor = Color.Red; LblFSSize.Text += " Size is too big."; return sshData; } // Makes sure the units given are accounted for when calcualting the size of the SD card. public static double NormaliseFSSize(string rootSize) { try { if (string.IsNullOrWhiteSpace(rootSize)) return 0; // Extract value & unit System.Text.RegularExpressions.Match match = RegexCache.FileSizeRegex().Match(rootSize.Trim()); if (!match.Success) return 0; if (!double.TryParse(match.Groups["value"].Value, out double value)) return 0; string unit = match.Groups["unit"].Value.ToUpperInvariant(); switch (unit) // Normalize to gigabytes { case "T": value *= 1024; break; case "G": break; case "M": value /= 1024; break; case "K": value /= 1024 * 1024; break; case "": value /= 1024 * 1024 * 1024; break; // assume bytes default: value = 0; break; } return value; } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error normalizing FS size: {ex.Message}"); } return 0; } // Expands the filesystem to max public async static Task ExpandFS(string device, string IPAddress) { try { using SshClient ssh = new SshClient(IPAddress, SSHUsername, SSHPassword); ssh.Connect(); SshCommand checkDevice = ssh.RunCommand($"[ -b {device} ] && echo OK || echo NOT_FOUND"); if (!string.IsNullOrWhiteSpace(checkDevice.Error)) throw new Exception(checkDevice.Error); if (checkDevice.Result.Trim() != "OK") // Device not found { MainForm.Instance.AddToActionsList($"Block device {device} not found."); return false; } SshCommand umountCmd = ssh.RunCommand($"sudo umount {device}"); if (!string.IsNullOrWhiteSpace(umountCmd.Error) && !umountCmd.Error.Contains("not mounted")) { MainForm.Instance.AddToActionsList($"Unmount error: {umountCmd.Error}"); return false; } await Task.Delay(1000); // Wait for mount to settle SshCommand fsckCmd = ssh.RunCommand($"sudo e2fsck -f -y -v -C 0 {device}"); if (!string.IsNullOrWhiteSpace(fsckCmd.Error)) { MainForm.Instance.AddToActionsList($"e2fsck error: {fsckCmd.Error}"); return false; } SshCommand resizeFs = ssh.RunCommand($"sudo resize2fs {device}"); ssh.Disconnect(); if (!string.IsNullOrWhiteSpace(resizeFs.Error)) { MainForm.Instance.AddToActionsList($"resize2fs error: {resizeFs.Error}"); return false; } if (resizeFs.ExitStatus == 0) return true; MainForm.Instance.AddToActionsList($"resize2fs failed with exit code {resizeFs.ExitStatus}"); return false; } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Error expanding filesystem: {ex.Message}"); return false; } } public static void Sync(string IPAddress) { try { using SshClient ssh = new SshClient(IPAddress, SSHUsername, SSHPassword); ssh.Connect(); SshCommand checkDevice = ssh.RunCommand("sync"); if (!string.IsNullOrWhiteSpace(checkDevice.Error)) throw new Exception(checkDevice.Error); if (checkDevice.Result.Trim().Length > 1) // Device not found { MainForm.Instance.AddToActionsList($"Cannot sync files to disk. Replied: {checkDevice.Result}. DO NOT TURN OFF, GET SUPERVISOR"); return; } } catch (Exception ex) { MainForm.Instance.AddToActionsList($"Cannot sync becuase: {ex.Message}. DO NOT TURN OFF, GET SUPERVISOR"); } } } public class SSHData { public string packages { get; set; } = string.Empty; public string FilesystemName { get; set; } = string.Empty; public string FilesystemSize { get; set; } = string.Empty; public bool tailscale { get; set; } } }