Mercurial > servermonitor
changeset 17:68d7834dc28e
More comments.
author | Brad Greco <brad@bgreco.net> |
---|---|
date | Sat, 25 May 2019 15:14:26 -0400 |
parents | 7626b099aefd |
children | b713b9db4c82 |
files | ServerMonitor/Attributes.cs ServerMonitor/Controls/SizeUnitsComboBox.cs ServerMonitor/Helpers.cs ServerMonitor/Objects/CheckResult.cs ServerMonitor/Objects/Checks/DiskSpaceCheck.cs ServerMonitor/Objects/Checks/FileCheck.cs ServerMonitor/Objects/Checks/HttpCheck.cs ServerMonitor/Objects/Checks/PingCheck.cs ServerMonitor/Objects/Checks/SshCheck.cs ServerMonitor/Objects/Logger.cs ServerMonitor/Objects/Schedule.cs ServerMonitor/Objects/Server.cs ServerMonitor/Objects/ServerMonitor.cs ServerMonitor/Objects/UpdateCheckException.cs ServerMonitor/Program.cs ServerMonitor/ServerMonitor.csproj ServerMonitor/Win32Helpers.cs |
diffstat | 17 files changed, 580 insertions(+), 141 deletions(-) [+] |
line wrap: on
line diff
--- a/ServerMonitor/Attributes.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Attributes.cs Sat May 25 15:14:26 2019 -0400 @@ -1,10 +1,11 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace ServerMonitorApp { + /// <summary> + /// Attribute for use by check controls to indicate + /// the type of check they correspond to. + /// </summary> internal class CheckTypeAttribute : Attribute { public Type CheckType { get; private set; } @@ -12,6 +13,10 @@ public CheckTypeAttribute(Type checkType) { CheckType = checkType; } } + /// <summary> + /// Attribute for use by checks to determine the order + /// they appear in the check type selection combo box. + /// </summary> internal class DisplayWeightAttribute : Attribute { public int DisplayWeight { get; private set; }
--- a/ServerMonitor/Controls/SizeUnitsComboBox.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Controls/SizeUnitsComboBox.cs Sat May 25 15:14:26 2019 -0400 @@ -19,5 +19,6 @@ } /// <summary>Size units.</summary> + /// <remarks>The integer values must equal the power of 1024 needed to convert from bytes to each unit.</remarks> public enum SizeUnits { B = 0, KB = 1, MB = 2, GB = 3 } }
--- a/ServerMonitor/Helpers.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Helpers.cs Sat May 25 15:14:26 2019 -0400 @@ -1,49 +1,66 @@ -using Renci.SshNet; -using Renci.SshNet.Common; -using ServerMonitorApp.Properties; +using ServerMonitorApp.Properties; using System; -using System.Collections.Generic; using System.ComponentModel; using System.Drawing; using System.Linq; using System.Net.NetworkInformation; -using System.Text; using System.Windows.Forms; namespace ServerMonitorApp { static class Helpers { + /// <summary>Resizes the image on a button to fit inside the button's dimensions.</summary> + /// <param name="button">A button containing an image.</param> public static void FormatImageButton(Button button) { button.Image = new Bitmap(button.Image, button.Height - 8, button.Height - 8); } + /// <summary>Recursively collects the messages for an exception and its inner exceptions.</summary> + /// <param name="ex">The parent exception.</param> + /// <returns>A string containing the messages for the exception and its inner exceptions.</returns> public static string GetAllMessages(this Exception ex) { return "\t" + (ex.InnerException == null ? ex.Message : ex.Message + Environment.NewLine + ex.InnerException.GetAllMessages()); } + /// <summary>Gets the value of the DisplayNameAttribute for a type.</summary> + /// <param name="type">The type to get the display name of.</param> + /// <returns>The type's display name, or the type name if no display name is defined.</returns> public static string GetDisplayName(Type type) { return type?.GetAttribute<DisplayNameAttribute>()?.DisplayName ?? type?.Name ?? "null"; } + /// <summary>Checks whether a string is null, an empty string, or only contains white space.</summary> + /// <param name="aString">The string to test.</param> + /// <returns>True if the string is null, an empty string, or only contains white space. False otherwise.</returns> public static bool IsNullOrEmpty(this string aString) { return aString == null || aString.Trim() == string.Empty; } + /// <summary>Converts all newlines in a string to unix format.</summary> + /// <param name="aString">The string to convert.</param> + /// <returns>The string with all newlines converted to unix format.</returns> public static string ConvertNewlines(this string aString) { return aString.Replace("\r\n", "\n").Replace('\r', '\n'); } + /// <summary>Gets an attribute on a class.</summary> + /// <typeparam name="T">The type of the attribute to return.</typeparam> + /// <param name="type">The type of the class the attribute is on.</param> + /// <returns>The attribute, or null if the attribute does not exist on the class.</returns> public static T GetAttribute<T>(this Type type) where T : Attribute { return type.GetCustomAttributes(typeof(T), false).SingleOrDefault() as T; } + /// <summary>Gets an image associated with a check status for use in the UI.</summary> + /// <param name="checkStatus">The check status.</param> + /// <returns>The image associated with the check status.</returns> public static Image GetImage(this CheckStatus checkStatus) { switch (checkStatus) @@ -58,6 +75,9 @@ } } + /// <summary>Gets a program icon associated with a check status.</summary> + /// <param name="checkStatus">The check status.</param> + /// <returns>The program icon associated with the check status.</returns> public static Icon GetIcon(this CheckStatus checkStatus) { switch (checkStatus) @@ -69,6 +89,10 @@ } } + /// <summary>Returns whether an enum is in a list of values.</summary> + /// <param name="value">The value to check.</param> + /// <param name="values">The list of possible values.</param> + /// <returns>True if the value was in the list, false otherwise.</returns> public static bool In(this Enum value, params Enum[] values) { return values.Contains(value); }
--- a/ServerMonitor/Objects/CheckResult.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/CheckResult.cs Sat May 25 15:14:26 2019 -0400 @@ -1,41 +1,56 @@ using ServerMonitorApp.Properties; using System; -using System.Drawing; namespace ServerMonitorApp { + /// <summary> + /// The result of an executed check. + /// Contains data about the check's last execution including status, time, and log message. + /// </summary> public class CheckResult { - public const string dateFormat = "yyyy-MM-dd HH:mm:ss.fff"; + // The date format to use in log files. + private const string dateFormat = "yyyy-MM-dd HH:mm:ss.fff"; + /// <summary>The originating check of this check result.</summary> public Check Check { get; private set; } + /// <summary>The result status of the check execution.</summary> public CheckStatus CheckStatus { get; private set; } + /// <summary>The message generated by the check execution.</summary> public string Message { get; set; } + /// <summary>The time the check execution began.</summary> public DateTime StartTime { get; set; } + /// <summary>The time the check execution ended.</summary> public DateTime EndTime { get; set; } + /// <summary>Whether the check execution resulted in success or failure.</summary> public bool Failed => CheckStatus.In(CheckStatus.Error, CheckStatus.Warning, CheckStatus.Information); + /// <summary>Action to perform when the check fails.</summary> public FailAction FailAction { get { + // Use the global preferences for each status to determine the action to take. switch (CheckStatus) { case CheckStatus.Error: return Settings.Default.ErrorAction; case CheckStatus.Warning: return Settings.Default.WarningAction; case CheckStatus.Information: return Settings.Default.InformationAction; + // On success (or any other status), do nothing. default: return FailAction.None; } } } - public bool FlashTaskbar => FailAction == FailAction.FlashTaskbar; - + /// <summary>CheckResult constructor.</summary> + /// <param name="check">The originating check of this check result.</param> + /// <param name="status">The result status of the check execution.</param> + /// <param name="message">The message generated by the check execution.</param> public CheckResult(Check check, CheckStatus status, string message) { Check = check; @@ -43,22 +58,42 @@ Message = message; } + /// <summary>Generates a string representation of the check result that can be logged.</summary> + /// <returns>A string representation of the check result that can be logged.</returns> + /// <remarks> + /// The log string is in the format: + /// [Check ID] [Start time] [End time] [Check status] [Check output] + /// + /// The check ID is left-padded with zeros to simplify log parsing and filtering by check ID. + /// Dates are formatted according to the dateFormat defined in this class. + /// Newlines in check output are escaped so the log string contains no literal newline characters. + /// </remarks> public string ToLogString() { return string.Format("{0:00000} {1} {2} {3} {4}", Check.Id, - StartTime.ToString(dateFormat).Replace("T", " "), - EndTime.ToString(dateFormat).Replace("T", " "), + StartTime.ToString(dateFormat), + EndTime.ToString(dateFormat), CheckStatus, Message.ConvertNewlines().Replace("\n", "\\n")); } + /// <summary>Parses a log string to create a check result object.</summary> + /// <param name="check">The originating check for the check result.</param> + /// <param name="logString">The log string to parse.</param> + /// <returns>A check result object.</returns> public static CheckResult FromLogString(Check check, string logString) { + // The check ID, start time, and end time are fixed in length, so no pattern matching is needed. DateTime startTime = DateTime.Parse(logString.Substring(6, 23)); DateTime endTime = DateTime.Parse(logString.Substring(30, 23)); + // The check status is not fixed in length, but will not contain any spaces. + // So, the first space following the beginning of the checks status will + // mark the start of the result message. int messageStartPos = logString.IndexOf(' ', 54); + // Now we know the length of the status token, so we can extract and parse it. Enum.TryParse(logString.Substring(54, messageStartPos - 54), out CheckStatus status); + // Put it all together. return new CheckResult(check, status, logString.Substring(messageStartPos + 1)) { StartTime = startTime, EndTime = endTime }; } }
--- a/ServerMonitor/Objects/Checks/DiskSpaceCheck.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Checks/DiskSpaceCheck.cs Sat May 25 15:14:26 2019 -0400 @@ -2,47 +2,63 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace ServerMonitorApp { + /// <summary>Checks the available disk space on a remote server.</summary> [DisplayName("Disk space check"), Description("Check the remaining free disk space"), DisplayWeight(11)] public class DiskSpaceCheck : SshCheck { + /// <summary>The command to execute. Must return the POSIX output format of df(1).</summary> public override string Command => string.Format(DiskSpaceCommand, Device); + /// <summary>The command to execute, with a placeholder of {0} for the device to check.</summary> protected string DiskSpaceCommand => "df -P -k {0}"; + /// <summary>The device or file on the device to check.</summary> public string Device { get; set; } + /// <summary>The minimum free space allowed for the check to pass.</summary> public double MinFreeSpace { get; set; } + /// <summary>The storage units or percentage for MinFreeSpace.</summary> public FreeSpaceUnits FreeSpaceUnits { get; set; } public DiskSpaceCheck() { + // Set general SSH check settings for disk space checks. CheckExitCode = true; ExitCode = 0; } + /// <summary>Processes the output of the disk space check command.</summary> protected override List<CheckResult> ProcessCommandResult(string output, int exitCode) { + // Check for general SSH failures. List<CheckResult> results = base.ProcessCommandResult(output, exitCode); + // If there was an error running the command, fail immediately. if (results.Any(r => r.Failed)) return results; + /* Parse the command results, expected in the POSIX output format of df(1): + Filesystem 1024-blocks Used Available Capacity Mounted on + <file system name> <total space> <space used> <space free> <percentage used> <file system root> + */ + // Split the output into lines and remove the header row. List<string> lines = output.Split('\n').ToList(); lines.RemoveAt(0); + // Make sure there is only a single line of output remaining. if (lines.Count > 1) { results.Add(Fail("df output was more than one line: " + string.Join("\n", lines))); } else { + // Split the string into tokens on whitespace. string[] tokens = lines[0].Split(new char[0], StringSplitOptions.RemoveEmptyEntries); if (FreeSpaceUnits == FreeSpaceUnits.percent) { + // Test on percentage: calculate the capacity free percent from the capacity used percent reported. if (int.TryParse(tokens[4].Replace("%", ""), out int percent)) { percent = 100 - percent; @@ -59,9 +75,12 @@ } else { + // Test on bytes: calculate the remaining available space from the reported available space. if (int.TryParse(tokens[3], out int freeSpace)) { + // Available space is returned in KB. Convert to MB (our default unit). freeSpace /= 1024; + // If the unit is GB, convert MB to GB. if (FreeSpaceUnits == FreeSpaceUnits.GB) freeSpace /= 1024; string message = string.Format("Free disk space is {0} {1}", freeSpace, FreeSpaceUnits); @@ -79,6 +98,7 @@ return results; } + /// <summary>Validates disk space check options.</summary> public override string Validate(bool saving = true) { string message = base.Validate(); @@ -92,5 +112,14 @@ } } - public enum FreeSpaceUnits { MB = 0, GB = 1, percent = 2 } + /// <summary>The units to use when testing available disk space.</summary> + public enum FreeSpaceUnits + { + /// <summary>Tests available disk space in megabytes.</summary> + MB = 0, + /// <summary>Tests available disk space in gigabytes.</summary> + GB = 1, + /// <summary>Tests available disk space as a percentage of the total space.</summary> + percent = 2 + } } \ No newline at end of file
--- a/ServerMonitor/Objects/Checks/FileCheck.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Checks/FileCheck.cs Sat May 25 15:14:26 2019 -0400 @@ -2,74 +2,96 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; -using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System.Xml.Serialization; namespace ServerMonitorApp { + /// <summary>Checks file metadata on a remote server.</summary> [DisplayName("File check"), Description("Check file size or modified time"), DisplayWeight(12)] public class FileCheck : SshCheck { + /// <summary>The command to execute. Must return the output format of GNU ls(1) with the long-iso time style.</summary> + /// <remarks>Would be better to not rely on the output of ls.</remarks> public override string Command => string.Format(FileCommand, Regex.Replace(File, "^~", "$HOME")); + /// <summary>The command to execute, with a placeholder of {0} for the file to check.</summary> protected string FileCommand { get { - int timeZoneOffset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now).Hours * -1; // Invert because POSIX says so. + // Invert because POSIX says so. + int timeZoneOffset = TimeZoneInfo.Local.GetUtcOffset(DateTime.Now).Hours * -1; + // Set the date format to long-iso since it's easy to parse. + // Set the time zone to the match the client time zone so comparisons are reliable. return "export TIME_STYLE=long-iso; export TZ=UTC" + timeZoneOffset + "; ls -l \"{0}\""; } } + /// <summary>The path of the file to check.</summary> public string File { get; set; } + /// <summary>Whether the file size will be checked.</summary> public bool CheckFileSize { get; set; } + /// <summary>Whether the file is expected to be less than or greater than a certain size.</summary> public bool FileSizeLessThan { get; set; } + /// <summary>The size to compare the file against in bytes.</summary> public int FileSize { get; set; } + /// <summary>The size to compare the file against in the selected size units.</summary> public double FileSizeInSelectedUnits { get => Math.Round(ConvertBytesToSelectedUnits(FileSize), 1); set => FileSize = ConvertSelectedUnitsToBytes(value); } + /// <summary>The units of the file size.</summary> public SizeUnits FileSizeUnits { get; set; } + /// <summary>Whether the file modified time will be checked.</summary> public bool CheckDateModified { get; set; } + /// <summary>Whether the file is expected to be older than or newer than a certain date.</summary> public bool DateModifiedOlderThan { get; set; } + /// <summary>The number of time units to compare then file modified time against.</summary> public double DateModified { get; set; } + /// <summary>The units of the file date modified.</summary> public TimeUnits DateModifiedUnits { get; set; } public FileCheck() { + // Set general SSH check settings for file checks. CheckExitCode = true; ExitCode = 0; } + /// <summary>Processes the output of the file check command.</summary> protected override List<CheckResult> ProcessCommandResult(string output, int exitCode) { + // Check for general SSH failures. List<CheckResult> results = base.ProcessCommandResult(output, exitCode); + // If there was an error running the command, fail immediately. if (results.Any(r => r.Failed)) return results; + // Make sure there is only a single line of output. if (output.Split('\n').Length > 1) { results.Add(Fail("ls output was more than one line: " + output)); } else { + // Split the string into tokens on whitespace. string[] tokens = output.Split(new char[0], StringSplitOptions.RemoveEmptyEntries); if (CheckFileSize) { + // Check file size. if (int.TryParse(tokens[4], out int bytes)) { + // Prepare a human-readable message with the comparison results. string message = string.Format("File size is {0} {1}", Math.Round(ConvertBytesToSelectedUnits(bytes), 1), FileSizeUnits); if ((bytes < FileSize && FileSizeLessThan) || (bytes > FileSize && !FileSizeLessThan)) results.Add(Pass(message)); @@ -83,10 +105,13 @@ } if (CheckDateModified) { + // Check date modified. + // Tokens[5] contains the date, tokens[6] contains the time. string dateString = tokens[5] + " " + tokens[6]; if (DateTime.TryParse(dateString, out DateTime modified)) { string message = string.Format("Last modified date is {0}", modified); + // Determine how old the file is. The command output is in the client time zone. TimeSpan age = DateTime.Now.Subtract(modified); double ageCompare = DateModifiedUnits == TimeUnits.Minute ? age.TotalMinutes : DateModifiedUnits == TimeUnits.Hour ? age.TotalHours : @@ -105,6 +130,7 @@ return results; } + /// <summary>Validates file check options.</summary> public override string Validate(bool saving = true) { string message = base.Validate(); @@ -119,11 +145,17 @@ return message; } + /// <summary>Converts bytes to a different size unit.</summary> + /// <param name="sizeInBytes">The size to convert in bytes.</param> + /// <returns>The size in the units used by this file check.</returns> private double ConvertBytesToSelectedUnits(int sizeInBytes) { return sizeInBytes / Math.Pow(1024, (double)FileSizeUnits); } + /// <summary>Converts a size in a different unit to bytes.</summary> + /// <param name="sizeInSelectedUnits">The size to convert in the units used by this file check.</param> + /// <returns>The size in bytes.</returns> private int ConvertSelectedUnitsToBytes(double sizeInSelectedUnits) { return (int)(sizeInSelectedUnits * Math.Pow(1024, (int)FileSizeUnits));
--- a/ServerMonitor/Objects/Checks/HttpCheck.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Checks/HttpCheck.cs Sat May 25 15:14:26 2019 -0400 @@ -35,6 +35,32 @@ protected override Task<CheckResult> ExecuteCheckAsync(CancellationToken token) { throw new NotImplementedException(); + + + + + // Cancellable version that doesn't actulaly work + // might be useful as a TaskCompletionSource example though + // + //TaskCompletionSource<CheckResult> tcs = new TaskCompletionSource<CheckResult + // + //using (Ping ping = new Ping()) + //{ + // token.Register(ping.SendAsyncCancel); + // ping.PingCompleted += (sender, e) => + // { + // if (e.Error != null) + // tcs.SetResult(Fail("Ping failed: " + e.Error.GetBaseException().Message)); + // else if (e.Reply.Status != IPStatus.Success) + // tcs.SetResult(Fail("Ping failed: " + e.Reply.Status.ToString())); + // else + // tcs.SetResult(Pass("Ping completed in " + e.Reply.RoundtripTime + "ms")); + // }; + // ping.SendAsync(Server.Host, Timeout, null); + //} + + //return tcs.Task; + } public override string Validate(bool saving = true)
--- a/ServerMonitor/Objects/Checks/PingCheck.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Checks/PingCheck.cs Sat May 25 15:14:26 2019 -0400 @@ -1,17 +1,15 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; +using System.ComponentModel; using System.Net.NetworkInformation; -using System.Text; using System.Threading; using System.Threading.Tasks; namespace ServerMonitorApp { + /// <summary>Checks that a server responds to ping.</summary> [DisplayName("Ping check"), Description("Check if the server responds to a ping request"), DisplayWeight(0)] public class PingCheck : Check { + /// <summary>Sends a ping and waits for a response.</summary> protected async override Task<CheckResult> ExecuteCheckAsync(CancellationToken token) { using (Ping ping = new Ping()) @@ -29,28 +27,6 @@ return Fail(e); } } - - // Cancellable version that doesn't actulaly work - // might be useful as a TaskCompletionSource example though - // - //TaskCompletionSource<CheckResult> tcs = new TaskCompletionSource<CheckResult - // - //using (Ping ping = new Ping()) - //{ - // token.Register(ping.SendAsyncCancel); - // ping.PingCompleted += (sender, e) => - // { - // if (e.Error != null) - // tcs.SetResult(Fail("Ping failed: " + e.Error.GetBaseException().Message)); - // else if (e.Reply.Status != IPStatus.Success) - // tcs.SetResult(Fail("Ping failed: " + e.Reply.Status.ToString())); - // else - // tcs.SetResult(Pass("Ping completed in " + e.Reply.RoundtripTime + "ms")); - // }; - // ping.SendAsync(Server.Host, Timeout, null); - //} - - //return tcs.Task; } } }
--- a/ServerMonitor/Objects/Checks/SshCheck.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Checks/SshCheck.cs Sat May 25 15:14:26 2019 -0400 @@ -2,57 +2,70 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Linq; -using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace ServerMonitorApp { + /// <summary>Executes an SSH command and checks the output or exit code.</summary> [DisplayName("SSH check"), Description("Check the result of a command run over SSH"), DisplayWeight(10)] public class SshCheck : Check { + /// <summary>The command to execute on the server.</summary> public virtual string Command { get; set; } + /// <summary>Whether the exit code of the command should be checked.</summary> public bool CheckExitCode { get; set; } + /// <summary>The required exit code of the command if CheckExitCode is true.</summary> public int ExitCode { get; set; } + /// <summary>Whether the text output of the command should be checked.</summary> public bool CheckCommandOutput { get; set; } + /// <summary>The method to use when checking the command output against the pattern.</summary> public MatchType CommandOutputMatchType { get; set; } + /// <summary>The string or pattern that the command output must match if CommandOutputMatchType is true.</summary> public string CommandOutputPattern { get; set; } + /// <summary>Whether the CommandOutputPattern should be interpreted as a regular expression.</summary> public bool CommandOutputUseRegex { get; set; } + /// <summary>Executes the SSH command on the server.</summary> protected override Task<CheckResult> ExecuteCheckAsync(CancellationToken token) { - try - { - if (!Server.SshClient.IsConnected) - { - if (Server.LoginType == LoginType.PrivateKey && Server.PrivateKeyFile == null) - return Task.FromResult(Fail(string.Format("Private key '{0}' is locked or not accessible", Server.KeyFile))); - Server.SshClient.Connect(); - } - } - catch (Exception e) - { - return Task.FromResult(Fail(e)); - } return Task.Run(() => { try { + // Exit now if the user cancelled the execution. token.ThrowIfCancellationRequested(); + + // Connect to the server if needed. + if (!Server.SshClient.IsConnected) + { + // If the server private key file has not been opened, it is probably encrypted. + // Report failure until the user enters the password. + if (Server.LoginType == LoginType.PrivateKey && Server.PrivateKeyFile == null) + return Fail(string.Format("Private key '{0}' is locked or not accessible", Server.KeyFile)); + Server.SshClient.Connect(); + } + + // Exit now if the user cancelled the execution. + token.ThrowIfCancellationRequested(); + using (SshCommand command = Server.SshClient.CreateCommand(Command)) { + // Execute the command. token.Register(command.CancelAsync); IAsyncResult ar = command.BeginExecute(); token.ThrowIfCancellationRequested(); + // Store both the command output and the error streams so they can + // be logged and checked. string output = (command.EndExecute(ar).Trim() + command.Error.Trim()).ConvertNewlines(); + // Process the results (exit code and command output) and merge them into a single result. return MergeResults(ProcessCommandResult(output, command.ExitStatus).ToArray()); } } @@ -63,11 +76,17 @@ }, token); } + /// <summary>Processes the command results and checks they match the expected values.</summary> + /// <param name="output">The command output.</param> + /// <param name="exitCode">The command exit code.</param> + /// <returns>A list of check results according to user preferences.</returns> protected virtual List<CheckResult> ProcessCommandResult(string output, int exitCode) { List<CheckResult> results = new List<CheckResult>(); + // Check the actual output against the expected output if command output checking is enabled. if (CheckCommandOutput) results.Add(GetStringResult(CommandOutputMatchType, CommandOutputPattern, CommandOutputUseRegex, output, "Command output")); + // Check the actual exit code against the expected exit code if exit code checking is enabled. if (CheckExitCode) { CheckResult result = GetIntResult(ExitCode, exitCode, "Exit code"); @@ -78,6 +97,7 @@ return results; } + /// <summary>Validates SSH check options.</summary> public override string Validate(bool saving = true) { string message = base.Validate();
--- a/ServerMonitor/Objects/Logger.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Logger.cs Sat May 25 15:14:26 2019 -0400 @@ -3,26 +3,32 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using System.Text; -using System.Threading.Tasks; namespace ServerMonitorApp { + /// <summary>Manages reading and writing check results to a log file.</summary> public class Logger { private const int logVersion = 1; private readonly string logFile; + /// <summary>Logger constructor.</summary> + /// <param name="file">The path of the log file to use.</param> public Logger(string file) { logFile = file; } + /// <summary>Appends a string to the log file, creating the log file if it does not exist.</summary> + /// <param name="logString">The string to log.</param> public async void Log(string logString) { + // Create the file if it does not exist. + // Write the current version at the beginning in case we ever change the format. if (!File.Exists(logFile)) logString = "Server Monitor log version " + logVersion + Environment.NewLine + logString; + // Write the message. using (FileStream stream = new FileStream(logFile, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, true)) using (StreamWriter writer = new StreamWriter(stream, Encoding.UTF8)) { @@ -30,43 +36,61 @@ } } + /// <summary>Appends a check result to the log file, creating the log file if it does not exist.</summary> + /// <param name="result">The check result to log.</param> public void Log(CheckResult result) { Log(result.ToLogString()); } + /// <summary>Reads all check results from the log for a server.</summary> + /// <param name="server">The server whose check results should be read.</param> + /// <returns>A list of all check results found in the log file for the given server.</returns> public IList<CheckResult> Read(Server server) { + // Store the checks by ID. Dictionary<int, Check> checks = server.Checks.ToDictionary(c => c.Id); List<CheckResult> results = new List<CheckResult>(); using (FileStream stream = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.Read)) using (StreamReader reader = new StreamReader(stream, Encoding.UTF8)) { + // Read through the whole file, searching for results belonging to the server. while (true) { try { string line = reader.ReadLine(); + // Exit the loop when we reach the end. if (line == null) break; + // The check ID is a fixed length. + // Parse it and see if it matches any of the checks we are interested in. if (int.TryParse(line.Substring(0, 5), out int id) && checks.TryGetValue(id, out Check check)) { + // If it is, add it to our results list. results.Add(CheckResult.FromLogString(check, line)); } } + // Don't know why this catch block is empty. + // Maybe to ignore errors if the file does not exist? catch (Exception) { } } } + // Return the newest results first since that's how they are displayed. results.Reverse(); return results; } + /// <summary>Removes old entries from the log.</summary> public async void TrimLog() { + // Delete entries older than this date. DateTime maxAge = DateTime.Now.AddDays(-1 * Settings.Default.KeepLogDays); string tempFile = logFile + ".tmp"; try { + // Read through the log file and check the date of each entry. + // If it is newer than the max age, copy it to a temporary file. using (FileStream stream = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.Read)) using (StreamReader reader = new StreamReader(stream, Encoding.UTF8)) using (FileStream outStream = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None)) @@ -87,6 +111,7 @@ catch (Exception) { } } } + // Delete the original file and rename the temporary file that contains only recent entries. File.Delete(logFile); File.Move(tempFile, logFile); }
--- a/ServerMonitor/Objects/Schedule.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Schedule.cs Sat May 25 15:14:26 2019 -0400 @@ -2,10 +2,22 @@ namespace ServerMonitorApp { + /// <summary>Schedule to control when a check is automatically executed.<summary> public class Schedule { + // Required empty constructor for XML serialization. public Schedule() { } + /// <summary>Schedule constructor</summary> + /// <param name="units">The time units to use.</param> + /// <param name="frequency">How frequently to run the check.</param> + /// <param name="startTime">Time of day the check should begin running.</param> + /// <param name="endTime">Time of day the check should stop running.</param> + /// <remarks> + /// If endTime is before startTime, then the endTime is interpreted as being on the next day. + /// For example, if startTime = 17:00 and endTime is 8:00, then the check will start running + /// at 17:00 and stop running at 8:00 the next day. + /// </remarks> public Schedule(FrequencyUnits units, int frequency, TimeSpan startTime, TimeSpan endTime) { Units = units; @@ -14,32 +26,57 @@ EndTime = endTime; } + /// <summary>How often the check should be executed.</summary> public int Frequency { get; set; } + /// <summary>The time units used to interpret the frequency.</summary> public FrequencyUnits Units { get; set; } + /// <summary>Time of day the check should begin running.</summary> public TimeSpan StartTime { get; set; } + /// <summary>Time of day the check should stop running.</summary> public TimeSpan EndTime { get; set; } + /// <summary>Given the last time a check was scheduled to run, calculates the next time in the future it should run.</summary> + /// <param name="lastScheduledTime">The last time a check was scheduled to run.</param> public DateTime GetNextTime(DateTime lastScheduledTime) { return GetNextTime(lastScheduledTime, DateTime.Now); } + /// <summary>Given the last time a check was scheduled to run, calculates the next time after the given date it should run.</summary> + /// <param name="lastScheduledTime">The last time a check was scheduled to run.</param> + /// <param name="minStartTime">The earliest allowed time to return.</param> + /// <remarks> + /// The next execution time of a check cannot necessarily be determined by taking the + /// last execution time and adding the configured number of time units. The computer might + /// have been asleep or the program might have not been running, so the next date might + /// be in the past. + /// + /// To best follow the schedule, we take the last execution time and fast-forward time + /// by adding the configured time interval until we get a resulting time that is in the future. + /// For example, suppose a check is scheduled to run every 5 minutes starting at 7:00. + /// The check last ran at 7:40, and the computer was suspended shortly thereafter and resumed + /// at 8:28. The next execution time is determined by adding 5 minutes to 7:40 until we obtain + /// a time after 8:28, in this case, 8:30. + /// </remarks> public DateTime GetNextTime(DateTime lastScheduledTime, DateTime minStartTime) { + // Start by setting the next time to the last time the check was run. DateTime nextTime = lastScheduledTime; if (Units == FrequencyUnits.Day) { + // If the check is scheduled only once a day, simply add days until we find a time in the future. while (nextTime < minStartTime) nextTime = nextTime.AddDays(Frequency).Date.Add(StartTime); } else { - // If the last run time was more than a day ago, fast-forward to reduce the number of loops + // If the last run time was more than a day ago, fast-forward a day at a time to reduce the number of loops. if (nextTime < minStartTime.AddHours(-24)) nextTime = minStartTime.Date.Add(StartTime).AddHours(-24); + // Add the configured time interval to the last run time until we obtain a time in the future. while (nextTime < minStartTime) { switch (Units) @@ -50,15 +87,25 @@ default: throw new InvalidOperationException("Unexpected frequency units: " + Units); } } + // Now we have the next date and time, but we don't know yet if it is within + // the active times of day the check is allowed to run between. if (StartTime < EndTime) { + // The allowed start and end times are on the same day. + // If the next scheduled time is too early (before the allowed start time), + // wait to run it until the start time happens. if (nextTime.TimeOfDay < StartTime) nextTime = nextTime.Date + StartTime; + // If the next scheduled time is too late (after the allowed end time), + // wait to run it until the start time happens on the following day. else if (nextTime.TimeOfDay > EndTime) nextTime = nextTime.Date.AddDays(1) + StartTime; } else if (nextTime.TimeOfDay > EndTime && nextTime.TimeOfDay < StartTime) { + // The allowed start time is on the day after the allowed end time, + // and the next scheduled time is too early (before the allowed start time). + // Wait to run the check until the allowed start time happens. nextTime = nextTime.Date + StartTime; } } @@ -71,5 +118,6 @@ } } + /// <summary>Units of time that a check can be scheduled to run in intervals of.</summary> public enum FrequencyUnits { Second, Minute, Hour, Day } } \ No newline at end of file
--- a/ServerMonitor/Objects/Server.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/Server.cs Sat May 25 15:14:26 2019 -0400 @@ -1,17 +1,17 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Text; using System.Security.Cryptography; using System.ComponentModel; using Renci.SshNet; -using System.Runtime.Serialization; using System.Xml.Serialization; namespace ServerMonitorApp { + /// <summary>Types of SSH logins supported by the server monitor.</summary> public enum LoginType { PrivateKey = 0, Password = 1 }; + /// <summary>Remote server that checks can be run against.</summary> public class Server { private string _host; @@ -24,59 +24,78 @@ private byte[] passwordHash; private PrivateKeyFile _privateKeyFile; + /// <summary>Fires when a check belonging to this server is modifed.</summary> public event EventHandler CheckModified; + /// <summary>Fires when the server enabled state changes.</summary> public event EventHandler EnabledChanged; + /// <summary>The checks that belong to this server.</summary> public readonly BindingList<Check> Checks = new BindingList<Check>(); + /// <summary>Internal ID of the server.</summary> public int Id { get; set; } + /// <summary>Name of the server.</summary> public string Name { get; set; } + /// <summary>Hostname of the server.</summary> public string Host { get { return _host; } set { _host = value; InvalidateSshConnection(); } } + /// <summary>Port to use when connecting using SSH.</summary> public int Port { get { return _port; } set { _port = value; InvalidateSshConnection(); } } + /// <summary>Username to use when connecting using SSH.</summary> public string Username { get { return _username; } set { _username = value; InvalidateSshConnection(); } } + /// <summary>Login type to use when connecting using SSH.</summary> public LoginType LoginType { get { return _loginType; } set { _loginType = value; InvalidateSshConnection(); } } + /// <summary>Path to the private key file to use when connecting using SSH.</summary> public string KeyFile { get { return _keyFile; } set { _keyFile = value; InvalidateSshConnection(); } } + /// <summary>Password to use when connecting using SSH.</summary> + /// <remarks>The password is encrypted using the current Windows user account.</remarks> public string Password { get { return passwordHash == null ? null : - Encoding.UTF8.GetString(ProtectedData.Unprotect(passwordHash, Encoding.UTF8.GetBytes("Server".Reverse().ToString()), DataProtectionScope.CurrentUser)); + Encoding.UTF8.GetString(ProtectedData.Unprotect(passwordHash + , Encoding.UTF8.GetBytes("Server".Reverse().ToString()) // Super-secure obfuscation of additional entropy + , DataProtectionScope.CurrentUser)); } set { passwordHash = ProtectedData.Protect(Encoding.UTF8.GetBytes(value), - Encoding.UTF8.GetBytes("Server".Reverse().ToString()), // Minor obfuscation of additional entropy + Encoding.UTF8.GetBytes("Server".Reverse().ToString()), // Super-secure obfuscation of additional entropy DataProtectionScope.CurrentUser); } } + /// <summary>Private key file to use when connecting using SSH.</summary> + /// <remarks> + /// If the private key file is encrypted, will be null until the user enters + /// the decryption key. + /// </remarks> [XmlIgnore] public PrivateKeyFile PrivateKeyFile { @@ -88,11 +107,18 @@ { if (_privateKeyFile == null) { + // The private key has not been opened yet. + // Disable the server until the user enters the decryption key, + // and set the KeyStatus to indicate why the server was disabled. KeyStatus = KeyStatus.Closed; Enabled = false; } else { + // The private key is open and accessible. + // Automatically re-enable the server if it was previously disabled + // due to a locked or inaccessible private key (i.e. disabled + // programatically and not by user request). if (!KeyStatus.In(KeyStatus.Open, KeyStatus.Closed)) Enabled = true; KeyStatus = KeyStatus.Open; @@ -101,13 +127,18 @@ } } + /// <summary>The current status of the private key file.</summary> public KeyStatus KeyStatus { get; set; } + /// <summary>Whether this server's checks will be automatically executed on their schedules.</summary> public bool Enabled { get { return _enabled; } set { + // Do not allow enabling the server if the private key is not accessible. + // Do not fire the EnabledChanged event if the Enabled state is not actually changing + // from its existing value. if ((LoginType == LoginType.PrivateKey && PrivateKeyFile == null && value == true) || value == _enabled) return; _enabled = value; @@ -115,14 +146,23 @@ } } - //public bool WaitingForUser { get; set; } - + /// <summary>The status of the server.</summary> + /// <remarks> + /// The status of the server is the most severe status of all its enabled checks. + /// The integer value of the CheckStatus enum increases with the severity, + /// so the maximum value of all checks gives the most severe status. + /// </remarks> public CheckStatus Status => !Enabled ? CheckStatus.Disabled : Checks .Where(c => c.Enabled) .Select(c => c.LastRunStatus) .DefaultIfEmpty(CheckStatus.Success) .Max(); + /// <summary>The SSH client to use when running checks on the server.</summary> + /// <remarks> + /// The connection is stored and kept open at the server level so it can be reused + /// by all SSH checks. + /// </remarks> public SshClient SshClient { get @@ -136,19 +176,8 @@ } } - /*public Server() { } - - public Server(Server server) - { - Name = server.Name; - Host = server.Host; - Port = server.Port; - Username = server.Username; - LoginType = server.LoginType; - KeyFile = server.KeyFile; - Enabled = server.Enabled; - }*/ - + /// <summary>Deletes a check from the server.</summary> + /// <param name="check">The check to delete.</param> public void DeleteCheck(Check check) { Checks.Remove(check); @@ -156,6 +185,8 @@ CheckModified?.Invoke(check, new EventArgs()); } + /// <summary>Validates server settings.</summary> + /// <returns>An empty string if the server is valid, or the reason the server is invalid.</returns> public string Validate() { string message = string.Empty; @@ -166,11 +197,15 @@ return message.Length > 0 ? message : null; } + /// <summary>Updates a check.</summary> public void UpdateCheck(Check check) { + // See if there is already a check with this ID. Check oldCheck = Checks.FirstOrDefault(c => c.Id == check.Id); if (!ReferenceEquals(check, oldCheck)) { + // If there is already a check, but it is a different object instance, + // replace the old check with the new one (or add it if it is new). int index = Checks.IndexOf(oldCheck); if (index == -1) Checks.Add(check); @@ -180,6 +215,7 @@ CheckModified?.Invoke(check, new EventArgs()); } + /// <summary>Returns true if the server looks empty (no user data has been entered).</summary> public bool IsEmpty() { return Name.IsNullOrEmpty() @@ -187,6 +223,7 @@ && Checks.Count == 0; } + /// <summary>Generates the authentication method based on user preferences.</summary> private AuthenticationMethod GetAuthentication() { if (LoginType == LoginType.Password) @@ -195,6 +232,7 @@ return new PrivateKeyAuthenticationMethod(Username, PrivateKeyFile); } + /// <summary>Releases the open SSH connection.</summary> private void InvalidateSshConnection() { _sshClient?.Dispose(); @@ -207,13 +245,17 @@ } } + /// <summary>Possible statuses of the private key file.</summary> public enum KeyStatus { + /// <summary>The private key file is closed for an unspecified reason.</summary> Closed, + /// <summary>The private key file is accessible and open.</summary> Open, + /// <summary>The private key file is not accessible (missing, access denied, etc).</summary> NotAccessible, + /// <summary>The private key file is encrypted and the user has not entered the password yet.</summary> NeedPassword, } - }
--- a/ServerMonitor/Objects/ServerMonitor.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/ServerMonitor.cs Sat May 25 15:14:26 2019 -0400 @@ -4,11 +4,9 @@ using ServerMonitorApp.Properties; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Net.NetworkInformation; -using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; @@ -16,51 +14,74 @@ namespace ServerMonitorApp { + /// <summary>Central class for scheduling and executing checks against remote servers.</summary> public class ServerMonitor { private readonly string configFileDir; private readonly Logger logger; + // Cancellation tokens for executing checks, keyed by check ID. private readonly Dictionary<int, CancellationTokenSource> tokens = new Dictionary<int, CancellationTokenSource>(); + // SSH private keys, keyed by the path to the private key file. + // A value of NULL indicates that the private key is inaccessible or encrypted. private readonly Dictionary<string, PrivateKeyFile> privateKeys = new Dictionary<string, PrivateKeyFile>(); + // IDs of all checks that have been paused due to network unavailability, + // or due to the system being suspended. + // Not to be confused with checks that have been disabled by the user. private readonly List<int> pausedChecks = new List<int>(); private bool running, networkAvailable, suspend; + // List of check execution tasks that have been started. + // A check task begins by sleeping until the next scheduled execution time, + // then executes. private Dictionary<Task<CheckResult>, int> tasks; private ServerSummaryForm mainForm; - //private List<Task<CheckResult>> tasks; - + /// <summary>Fires when the status of a check changes.</summary> public event EventHandler<CheckStatusChangedEventArgs> CheckStatusChanged; + /// <summary>The collection of registered servers.</summary> public List<Server> Servers { get; private set; } = new List<Server>(); + /// <summary>A collection of all checks belonging to all registerd servers.</summary> public IEnumerable<Check> Checks => Servers.SelectMany(s => s.Checks); + /// <summary>Path to the file that stores server and check configuration.</summary> public string ConfigFile { get; private set; } + /// <summary>Path to the file that stores server and check configuration.</summary> public IEnumerable<string> LockedKeys { get { return privateKeys.Where(kvp => kvp.Value == null).Select(kvp => kvp.Key); } } + /// <summary>ServerMonitor constructor.</summary> + /// <param name="mainForm">A reference to the main form.</param> public ServerMonitor(ServerSummaryForm mainForm) { this.mainForm = mainForm; + // Store configuration in %appdata%\ServerMonitor configFileDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ServerMonitor"); ConfigFile = Path.Combine(configFileDir, "servers.xml"); logger = new Logger(Path.Combine(configFileDir, "monitor.log")); } + /// <summary>Registers a new server with the server monitor.</summary> + /// <param name="server">The server to be added.</param> public void AddServer(Server server) { Servers.Add(server); SaveServers(); } + /// <summary>Deletes a server from the server monitor.</summary> + /// <param name="server">The server to be deleted.</param> public void DeleteServer(Server server) { Servers.Remove(server); SaveServers(); } + /// <summary>Loads all servers and checks from the config file.</summary> public void LoadServers() { + bool triedBackup = false; + Read: TextReader reader = null; try @@ -69,16 +90,23 @@ XmlSerializer serializer = CreateXmlSerializer(); Servers.Clear(); Servers.AddRange((List<Server>)serializer.Deserialize(reader)); - // Update the Checks so they know what Server they belong to. - // Would rather do this in the Server object on deserialization, but - // that doesn't work when using the XML serializer for some reason. + // Do some more set-up now that the servers and checks have been loaded. foreach (Server server in Servers) { + // Read private keys into memory if they are accessible and not encrypted. + // If PrivateKeyFile != null, it means same the key has already been loaded for + // a different server and nothing more needs to be done. if (server.LoginType == LoginType.PrivateKey && server.PrivateKeyFile == null) OpenPrivateKey(server.KeyFile); foreach (Check check in server.Checks) { + // Update the checks so they know what server they belong to. + // Would rather do this in the Server object on deserialization, but + // that doesn't work when using the XML serializer for some reason. check.Server = server; + // If the program last exited while the check was running, change its status + // to the result of its last execution (since, at this point, the check is + // not running). if (check.Status == CheckStatus.Running) check.Status = check.LastRunStatus; } @@ -92,9 +120,23 @@ catch (InvalidOperationException) { reader?.Close(); - File.Copy(ConfigFile, ConfigFile + ".error", true); - File.Copy(ConfigFile + ".bak", ConfigFile, true); - goto Read; + // If there was an error parsing the XML, try again with the backup config file. + if (!triedBackup) + { + File.Copy(ConfigFile, ConfigFile + ".error", true); + string backupConfig = ConfigFile + ".bak"; + if (File.Exists(backupConfig)) + { + File.Copy(backupConfig, ConfigFile, true); + } + triedBackup = true; + goto Read; + } + else + { + // If there was an error reading the backup file too, give up. + throw; + } } finally { @@ -103,10 +145,14 @@ Application.ApplicationExit += Application_ApplicationExit; NetworkChange.NetworkAddressChanged += NetworkChange_NetworkAddressChanged; SystemEvents.PowerModeChanged += SystemEvents_PowerModeChanged; + + // Remove old entries from the log file according to user preferences. logger.TrimLog(); + Run(); } + /// <summary>Saves all servers and checks to the config file.</summary> public void SaveServers() { GenerateIds(); @@ -114,6 +160,7 @@ XmlSerializer serializer = null; try { + // Make a backup first in case something goes wrong in the middle of writing. File.Copy(ConfigFile, ConfigFile + ".bak", true); } catch { } @@ -125,6 +172,7 @@ } catch (DirectoryNotFoundException) { + // If the directory does not exist, create it and try again. Directory.CreateDirectory(configFileDir); writer = new StreamWriter(ConfigFile); serializer = CreateXmlSerializer(); @@ -136,11 +184,17 @@ } } + /// <summary>Main server monitor loop. Schedules and executes checks.</summary> private async void Run() { + // Do not run again if already running or if the system is suspending or resuming. if (running || suspend) return; + running = true; + + // If the network is available, immediately execute checks that were supposed to run + // earlier but could not due to network unavailability or the system being suspended. networkAvailable = Helpers.IsNetworkAvailable(); if (networkAvailable) { @@ -150,41 +204,110 @@ } pausedChecks.Clear(); } + + // Schedule all checks to run according to their schedules. + // Each check will sleep until it is scheduled to run, then execute. tasks = Checks.ToDictionary(c => ScheduleExecuteCheckAsync(c), c => c.Id); while (tasks.Count > 0) { + // When any check is done sleeping and executing, remove the completed + // task and queue a new task to schedule it again. Task<CheckResult> task = await Task.WhenAny(tasks.Keys); tasks.Remove(task); try { CheckResult result = await task; - // Result will be null if a scheduled check was disabled + // Do not schedule the task again if it is now disabled. + // Result will be null if a scheduled check was disabled. if (result != null && result.CheckStatus != CheckStatus.Disabled) tasks.Add(ScheduleExecuteCheckAsync(result.Check), result.Check.Id); } catch (OperationCanceledException) { - + // When a server's state changes to Disabled, any checks that are executing + // are immediately cancelled. Silently catch these expected exceptions. } } + // If there are no enabled checks scheduled, exit the main loop. + // It will be restarted when a check or server is enabled. running = false; } + /// <summary>Schedules a check to be run on its schedule.</summary> + /// <param name="check">The check to execute.</param> + /// <returns>The async check result.</returns> + private async Task<CheckResult> ScheduleExecuteCheckAsync(Check check) + { + // Do not schedule or execute the check if it or its server is disabled. + if (!check.Enabled || !check.Server.Enabled) + return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null)); + + // Create a cancellation token that will be used to cancel the check if it or + // its server is disabled while it is executing. + CancellationTokenSource cts = new CancellationTokenSource(); + tokens[check.Id] = cts; + + // Sleep until next time the check is supposed to be executed. + // Use the LastScheduledRunTime so manual executions by the user do not + // interfere with the schedule. + check.NextRunTime = check.Schedule.GetNextTime(check.LastScheduledRunTime); + int delay = Math.Max(0, (int)(check.NextRunTime - DateTime.Now).TotalMilliseconds); + await Task.Delay(delay, cts.Token); + check.LastScheduledRunTime = check.NextRunTime; + + // Execute the check if not cancelled. + if (!cts.IsCancellationRequested) + { + // If the network is available, execute the check. + // Otherwise, add it to the list of paused checks to be executed + // when the network becomes available again. + if (networkAvailable) + { + return await ExecuteCheckAsync(check, cts.Token); + } + else + { + if (!pausedChecks.Contains(check.Id)) + pausedChecks.Add(check.Id); + } + } + return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null)); + } + + /// <summary>Executes a check asynchronously.</summary> + /// <param name="check">The check to execute.</param> + /// <param name="token">A chancellation token that may be used to cancel the check execution.</param> + /// <returns>The async check result.</returns> public async Task<CheckResult> ExecuteCheckAsync(Check check, CancellationToken token = default(CancellationToken)) { + // Update the status. check.Status = CheckStatus.Running; OnCheckStatusChanged(check); + + // Execute the check. CheckResult result = await check.ExecuteAsync(token); + + // Increment the consecutive failure counter on failue, or reset + // the counter on success. if (result.Failed) check.ConsecutiveFailures++; + else + check.ConsecutiveFailures = 0; + OnCheckStatusChanged(check, result); HandleResultAsync(result); return result; } + /// <summary>Handles the result of a check execution.</summary> + /// <param name="result">The result.</param> private void HandleResultAsync(CheckResult result) { + // Log the result. logger.Log(result); + + // Notify the user of failure according to user preferences. + // If the check succeeded, result.FailAction will be None. if (result.Check.ConsecutiveFailures >= result.Check.MaxConsecutiveFailures) { if (result.FailAction == FailAction.FlashTaskbar) @@ -194,31 +317,41 @@ } } + /// <summary>Reads all check results from the log for a server.</summary> + /// <param name="server">The server whose check results should be read.</param> + /// <returns>A list of all check results found in the log file for the given server.</returns> public IList<CheckResult> GetLog(Server server) { return logger.Read(server); } + /// <summary>Saves the check settings and notifies event subscribers when the status of a check changes.</summary> + /// <param name="check">The check whose status has changed.</param> + /// <param name="result">The check result that caused the status to change, if any.</param> private void OnCheckStatusChanged(Check check, CheckResult result = null) { SaveServers(); CheckStatusChanged?.Invoke(check, new CheckStatusChangedEventArgs(check, result)); } + /// <summary>Handles user modifications to a check's settings.</summary> + /// <param name="sender">The check that was modified.</param> private void Server_CheckModified(object sender, EventArgs e) { Check check = (Check)sender; - Task<CheckResult> task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key; + + // No need to mess with the task queue if not currently running. if (running) { + Task<CheckResult> task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key; if (task == null) { - // No tasks associated with the check, so schedule a new one + // No tasks associated with the check, so schedule a new one. tasks.Add(ScheduleExecuteCheckAsync(check), check.Id); } else { - // Check was modified or deleted, so remove any waiting tasks + // Check was modified or deleted, so remove any waiting tasks. CancelCheck(check); if (check.Server != null) { @@ -230,19 +363,26 @@ } } } - // Run again in case removing a task above caused it to stop + // Run again in case removing a task above caused it to stop. Run(); } + /// <summary>Handles the enabled state of a server changing.</summary> + /// <param name="sender">The server that was enabled or disabled.</param> private void Server_EnabledChanged(object sender, EventArgs e) { Server server = (Server)sender; + + // Make sure the monitor is running. If no servers were enabled before this + // one was enabled, it is not running. if (server.Enabled) { Run(); } else { + // Cancel all queued and executing checks belonging to a + // server that was disabled. foreach (Check check in server.Checks) { CancelCheck(check); @@ -250,50 +390,42 @@ } } + /// <summary>Cancels a check that may be executing.</summary> + /// <param name="check">The check to cancel.</param> private void CancelCheck(Check check) { if (tasks == null) return; + + // Find the waiting or executing task for the check and remove it. Task<CheckResult> task = tasks.FirstOrDefault(kvp => kvp.Value == check.Id).Key; if (task != null) tasks.Remove(task); + + // Remove it from the list of paused checks so it doesn't get restarted later. pausedChecks.RemoveAll(id => id == check.Id); + + // Cancel the current execution. if (tokens.TryGetValue(check.Id, out CancellationTokenSource cts)) cts.Cancel(); } - private async Task<CheckResult> ScheduleExecuteCheckAsync(Check check) - { - if (!check.Enabled || !check.Server.Enabled) - return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null)); - - CancellationTokenSource cts = new CancellationTokenSource(); - tokens[check.Id] = cts; - check.NextRunTime = check.Schedule.GetNextTime(check.LastScheduledRunTime); - int delay = Math.Max(0, (int)(check.NextRunTime - DateTime.Now).TotalMilliseconds); - await Task.Delay(delay, cts.Token); - check.LastScheduledRunTime = check.NextRunTime; - if (networkAvailable && !cts.IsCancellationRequested) - { - return await ExecuteCheckAsync(check, cts.Token); - } - else - { - if (!pausedChecks.Contains(check.Id)) - pausedChecks.Add(check.Id); - return await Task.FromResult(new CheckResult(check, CheckStatus.Disabled, null)); - } - } - + /// <summary>Handles network state changing.</summary> private void NetworkChange_NetworkAddressChanged(object sender, EventArgs e) { networkAvailable = Helpers.IsNetworkAvailable(); + // If the network is available, it might not have been before. + // This method is not called from the correct thread, so special + // handling is needed to start it on the UI thread again. if (networkAvailable) mainForm.Invoke((MethodInvoker)(() => Run())); } + /// <summary>Handles system power mode changes.</summary> private async void SystemEvents_PowerModeChanged(object sender, PowerModeChangedEventArgs e) { + // If the system is being suspended, cancel all waiting and executing checks. + // Once all the checks are removed, the main loop will exit. if (e.Mode == PowerModes.Suspend) { foreach (Check check in Checks) @@ -304,32 +436,45 @@ } else if (e.Mode == PowerModes.Resume) { + // When resuming from suspend, examine each check to find out if it was + // scheduled to be executed during the time period when the systems was + // suspended. Add them to the paused checks list, to be executed almost + // immediately. + // Make sure the list is empty to start. pausedChecks.Clear(); foreach (Check check in Checks) { - //CancelCheck(check); if (check.Enabled && check.Server.Enabled && check.NextRunTime < DateTime.Now) { pausedChecks.Add(check.Id); } } + // Wait 10 seconds to give things time to quiet down after resuming. await Task.Delay(10000); suspend = false; Run(); } } + /// <summary>Unregister system events when exiting.</summary> private void Application_ApplicationExit(object sender, EventArgs e) { NetworkChange.NetworkAddressChanged -= NetworkChange_NetworkAddressChanged; SystemEvents.PowerModeChanged -= SystemEvents_PowerModeChanged; } + /// <summary>Attempts to read a private file.</summary> + /// <param name="path">The path to the private key file.</param> + /// <param name="password">The password used to encrypt the key.</param> + /// <returns>A status indicating the result of the attempt.</returns> public KeyStatus OpenPrivateKey(string path, string password = null) { KeyStatus keyStatus; + if (path == null) keyStatus = KeyStatus.NotAccessible; + + // Check if the key has already been open and read. if (privateKeys.TryGetValue(path, out PrivateKeyFile key) && key != null) keyStatus = KeyStatus.Open; else @@ -339,15 +484,20 @@ key = new PrivateKeyFile(path, password); keyStatus = KeyStatus.Open; } + // If the key is encrypted and the password is empty or incorrect, + // return the NeedPassword status. catch (Exception e) when (e is SshPassPhraseNullOrEmptyException || e is InvalidOperationException) { keyStatus = KeyStatus.NeedPassword; } + // For any other failure reason, return the NotAccessible status. catch (Exception) { keyStatus = KeyStatus.NotAccessible; } } + // A single private key may be used by multiple servers. Update all servers + // that use this private key with the results of the above operations. foreach (Server server in Servers) { if (server.KeyFile == path) @@ -356,14 +506,21 @@ server.KeyStatus = keyStatus; } } + // Keep a reference to this private key so we don't have to re-open + // it later if the same key is used on a different server. privateKeys[path] = key; + return keyStatus; } + /// <summary>Generates internal IDs for servers and checks.</summary> private void GenerateIds() { if (Servers.Any()) { + // Start at the maximum ID to make sure IDs are not reused + // if a server was deleted so old log entries do not get associated + // with a new server. int id = Servers.Max(s => s.Id); foreach (Server server in Servers) { @@ -374,6 +531,8 @@ if (Checks.Any()) { + // Start with the max check ID, same reasons as above. + // Is there a reason this is stored in a setting? int id = Math.Max(Settings.Default.MaxCheckId, Checks.Max(c => c.Id)); foreach (Check check in Checks) { @@ -385,16 +544,21 @@ } } + /// <summary>Creates an XML serializer that can handle servers and all check types.</summary> + /// <returns>An XML serializer that can handle servers and all check types.</returns> private XmlSerializer CreateXmlSerializer() { return new XmlSerializer(typeof(List<Server>), Check.CheckTypes); } } + /// <summary>Event arguments for when a check status changes.</summary> public class CheckStatusChangedEventArgs : EventArgs { + /// <summary>The check whose status changed.</summary> public Check Check { get; private set; } + /// <summary>The check result that caused the status to change, if any.</summary> public CheckResult CheckResult { get; private set; } public CheckStatusChangedEventArgs(Check check, CheckResult result) @@ -404,5 +568,15 @@ } } - public enum FailAction { FlashTaskbar = 0, NotificationBalloon = 1, None = 10 } + /// <summary>Possible actions that may be taken when a check fails.</summary> + public enum FailAction + { + /// <summary>Flashes the Server Monitor tasbar program icon.</summary> + FlashTaskbar = 0, + /// <summary>Shows a balloon in the notification area.</summary> + NotificationBalloon = 1, + /// <summary>Take no action.</summary> + None = 10 + } + }
--- a/ServerMonitor/Objects/UpdateCheckException.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Objects/UpdateCheckException.cs Sat May 25 15:14:26 2019 -0400 @@ -1,8 +1,8 @@ using System; -using System.Runtime.Serialization; namespace ServerMonitorApp { + /// <summary>Thrown when invalid data is entered on the check settings form.</summary> public class UpdateCheckException : Exception { public UpdateCheckException(string message) : base(message)
--- a/ServerMonitor/Program.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Program.cs Sat May 25 15:14:26 2019 -0400 @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Windows.Forms; @@ -16,8 +15,10 @@ [STAThread] static void Main() { + // Check if the program is already running. if (mutex.WaitOne(TimeSpan.Zero, true)) { + // Show unhandled exceptions to the user. Application.ThreadException += new ThreadExceptionEventHandler(Application_ThreadException); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException); @@ -28,6 +29,8 @@ } else { + // If the program is already running, send a message to the main form + // asking it to show itself, then exit. Win32Helpers.PostMessage( (IntPtr)Win32Helpers.HWND_BROADCAST, Win32Helpers.WM_SHOWMONITOR, @@ -36,18 +39,22 @@ } } + /// <summary>Shows unhandled exceptions in a message box.</summary> static void Application_ThreadException(object sender, ThreadExceptionEventArgs e) { - //if (!System.Diagnostics.Debugger.IsAttached) + if (!System.Diagnostics.Debugger.IsAttached) ShowError(e.Exception); } + /// <summary>Shows unhandled exceptions in a message box.</summary> static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { - //if (!System.Diagnostics.Debugger.IsAttached) + if (!System.Diagnostics.Debugger.IsAttached) ShowError(e.ExceptionObject as Exception); } + /// <summary>Shows exception details in a message box.</summary> + /// <param name="e">The exception to show.</param> static void ShowError(Exception e) { string simpleStackTrace = string.Join(Environment.NewLine, new System.Diagnostics.StackTrace(e).GetFrames()
--- a/ServerMonitor/ServerMonitor.csproj Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/ServerMonitor.csproj Sat May 25 15:14:26 2019 -0400 @@ -236,12 +236,6 @@ <None Include="App.config" /> </ItemGroup> <ItemGroup> - <None Include="Resources\Icon1.ico" /> - </ItemGroup> - <ItemGroup> - <None Include="Resources\Image1.png" /> - </ItemGroup> - <ItemGroup> <None Include="Resources\delete.png" /> </ItemGroup> <ItemGroup>
--- a/ServerMonitor/Win32Helpers.cs Tue Apr 30 20:40:58 2019 -0400 +++ b/ServerMonitor/Win32Helpers.cs Sat May 25 15:14:26 2019 -0400 @@ -1,13 +1,10 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; using System.Windows.Forms; namespace ServerMonitorApp { + /// <summary>Methods for interacting with the Win32 API.</summary> class Win32Helpers { [DllImport("user32.dll")] @@ -65,6 +62,8 @@ FLASHW_TIMERNOFG = 12 } + /// <summary>Flashes a window icon in the taskbar.</summary> + /// <param name="form">The form to flash.</param> public static bool FlashWindowEx(Form form) { IntPtr hWnd = form.Handle; @@ -79,6 +78,8 @@ return FlashWindowEx(ref fInfo); } + /// <summary>Stops flashing a window icon in the taskbar.</summary> + /// <param name="form">The form to stop flashing.</param> public static bool StopFlashWindowEx(Form form) { IntPtr hWnd = form.Handle;