diff --git a/src/ARP-Scanner/ARP-Scanner.csproj b/src/ARP-Scanner/ARP-Scanner.csproj index 13b889f..6c81d75 100644 --- a/src/ARP-Scanner/ARP-Scanner.csproj +++ b/src/ARP-Scanner/ARP-Scanner.csproj @@ -6,6 +6,7 @@ ARP_Scanner enable enable + false diff --git a/src/ARP-Scanner/JsonResult.cs b/src/ARP-Scanner/JsonResult.cs index 85d7f5f..4beeea9 100644 --- a/src/ARP-Scanner/JsonResult.cs +++ b/src/ARP-Scanner/JsonResult.cs @@ -1,23 +1,53 @@ namespace ARP_Scanner; + internal class JsonResult { - public required string Ip { get; set; } - public required string Mac { get; set; } - public required string VendorName { get; set; } - public required string BlockType { get; set; } - public required bool Private { get; set; } - public required string LastUpdate { get; set; } + public IEnumerable Hosts { get; set; } = []; - public static IEnumerable Parse(List activeHosts) + public IEnumerable NewHosts { get; set; } = []; + + public IEnumerable RemovedHosts { get; set; } = []; + + public static JsonResult Parse(IEnumerable activeHosts, List previousHosts) { - return activeHosts.Select(row => new JsonResult + JsonResult result = new JsonResult { - Ip = row[0], - Mac = row[1], - VendorName = row[2], - BlockType = row[3], - Private = bool.Parse(row[4]), - LastUpdate = row[5] - }); + Hosts = activeHosts.Select(HostInformation.Parse) + }; + + if (previousHosts.Count == 0) + { + return result; + } + + result.NewHosts = result.Hosts.Where(host => !previousHosts.Exists(previousHost => previousHost[1] == host.Mac)); + result.RemovedHosts = previousHosts.Where(previousHost => !result.Hosts.Any(host => host.Mac == previousHost[1])).Select(HostInformation.Parse); + + return result; } -} + + public class HostInformation + { + public required string Ip { get; set; } + public required string Mac { get; set; } + public required string VendorName { get; set; } + public required string BlockType { get; set; } + public bool? Private { get; set; } + public required string LastUpdate { get; set; } + + public static HostInformation Parse(string[] row) + { + bool success = bool.TryParse(row[4], out bool isPrivate); + + return new HostInformation + { + Ip = row[0], + Mac = row[1], + VendorName = row[2], + BlockType = row[3], + Private = success ? isPrivate : null, + LastUpdate = row[5] + }; + } + } +} \ No newline at end of file diff --git a/src/ARP-Scanner/MacVendorLookup.cs b/src/ARP-Scanner/MacVendorLookup.cs index 293c0cf..672a04c 100644 --- a/src/ARP-Scanner/MacVendorLookup.cs +++ b/src/ARP-Scanner/MacVendorLookup.cs @@ -14,6 +14,12 @@ internal partial class MacVendorLookup public async Task Initialize(bool silent) { + if (macDatabase.MacInformations.Count != 0) + { + // Already initialized + return; + } + string cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "macDatabase.json"); // Snap support diff --git a/src/ARP-Scanner/MonitorOptions.cs b/src/ARP-Scanner/MonitorOptions.cs new file mode 100644 index 0000000..4e9cd22 --- /dev/null +++ b/src/ARP-Scanner/MonitorOptions.cs @@ -0,0 +1,10 @@ +using CommandLine; + +namespace ARP_Scanner; + +[Verb("monitor", HelpText = "Continuously monitor the specified IP range.")] +internal class MonitorOptions : ScanOptions +{ + [Option('d', "delay", Required = false, Default = 60, HelpText = "The delay between each scan in seconds.")] + public int Delay { get; set; } +} \ No newline at end of file diff --git a/src/ARP-Scanner/Program.cs b/src/ARP-Scanner/Program.cs index 710897a..8f0fe1b 100644 --- a/src/ARP-Scanner/Program.cs +++ b/src/ARP-Scanner/Program.cs @@ -5,18 +5,30 @@ using CommandLine; using CuteUtils.Misc; using Humanizer; +using Humanizer.Localisation; using NetTools; using System.Collections.Concurrent; +using System.Diagnostics; using System.Net; using System.Net.NetworkInformation; using System.Text.Json; +using System.Text.Json.Serialization; namespace ARP_Scanner; internal static class Program { + private static readonly MacVendorLookup macVendorLookup = new(); + private static readonly List previouslyActiveHosts = []; + + private static readonly JsonSerializerOptions jsonSerializerOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private static async Task Main(string[] args) { if (!Arp.IsSupported) @@ -25,8 +37,45 @@ internal static class Program return 1; } - return await Parser.Default.ParseArguments(args) - .MapResult(StartScan, HandleParseError); + return await Parser.Default.ParseArguments(args) + .MapResult( + (MonitorOptions monitorOptions) => StartMonitor(monitorOptions), + (ScanOptions scanOptions) => StartScan(scanOptions), + HandleParseError); + } + + private static async Task StartMonitor(MonitorOptions options) + { + ConsoleExt.WriteLine($"Monitoring IP range '{options.IpRange}' every {options.Delay} seconds...", ConsoleColor.DarkYellow); + + while (true) + { + int exitCode = await StartScan(options); + + if (exitCode != 0) + { + return exitCode; + } + + Console.WriteLine(); + + Stopwatch stopwatch = Stopwatch.StartNew(); + + bool printed = false; + + while (stopwatch.Elapsed < TimeSpan.FromSeconds(options.Delay)) + { + TimeSpan remainingTime = TimeSpan.FromSeconds(options.Delay) - stopwatch.Elapsed; + Console.Write($"\rNext scan in: {remainingTime.Humanize(3, minUnit: TimeUnit.Second)} "); + await Task.Delay(1000); + printed = true; + } + + if (printed) + { + Console.Write($"\r{new string(' ', Console.WindowWidth)}\r"); + } + } } private static async Task StartScan(ScanOptions options) @@ -37,7 +86,6 @@ internal static class Program return 2; } - MacVendorLookup macVendorLookup = new MacVendorLookup(); await macVendorLookup.Initialize(options.Silent); long ipAddressesCount = ipAddressRange.Count(); @@ -45,7 +93,6 @@ internal static class Program int numberOfDigits = ipAddressesCount.ToString().Length; int exitCode = 0; - ConcurrentBag activeHosts = []; // If you want to change the header, you also need to change the JsonResult class @@ -97,7 +144,7 @@ internal static class Program MacInformation macInformation = macVendorLookup.GetInformation(formattedMac); - List info = [ipAddress.ToString(), formattedMac, macInformation.VendorName, macInformation.BlockType, macInformation.Private.ToString() ?? "Unknown", macInformation.LastUpdate]; + List info = [ipAddress.ToString(), formattedMac, macInformation.VendorName, macInformation.BlockType, macInformation.Private?.ToString() ?? "Unknown", macInformation.LastUpdate]; if (!options.Silent) { ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Active: {ipAddress}", ConsoleColor.Green); @@ -120,25 +167,13 @@ internal static class Program } }); - if (!options.Silent) + if (previouslyActiveHosts.Count > 0 && !options.Silent) { - Console.WriteLine(); - } - - List? activeHostsTable = [.. activeHosts]; - activeHostsTable.Insert(0, [.. header]); - - if (!activeHosts.IsEmpty) - { - Console.WriteLine($"Active host{(activeHosts.Count == 1 ? "s" : "")}:"); - - activeHostsTable.ToArray().To2D().PrintTable(TableStyle.List); - - ConsoleExt.WriteLine($"{Environment.NewLine}Found {"active host".ToQuantity(activeHosts.Count)}", ConsoleColor.Green); + PrintDifference(activeHosts, header); } else if (!options.Silent) { - ConsoleExt.WriteLine($"No active hosts found", ConsoleColor.Red); + PrintActiveHosts(activeHosts, header); } if (!options.Silent && (options.JsonPath is not null || options.CsvPath is not null)) @@ -148,20 +183,25 @@ internal static class Program if (options.JsonPath is not null) { + string jsonPath = GetOutputPath(options.JsonPath, ".json"); + try { - File.WriteAllText(options.JsonPath, JsonSerializer.Serialize(JsonResult.Parse([.. activeHosts]))); + _ = Directory.CreateDirectory(Path.GetDirectoryName(jsonPath) ?? string.Empty); + + string json = JsonSerializer.Serialize(JsonResult.Parse([.. activeHosts], previouslyActiveHosts), jsonSerializerOptions); + File.WriteAllText(jsonPath, json); if (!options.Silent) { - ConsoleExt.WriteLine($"Saved JSON to '{options.JsonPath}'", ConsoleColor.Green); + ConsoleExt.WriteLine($"Saved JSON to '{jsonPath}'", ConsoleColor.Green); } } catch (Exception ex) { if (!options.Silent) { - ConsoleExt.WriteLine($"Failed to save JSON to '{options.JsonPath}': {ex.Message}", ConsoleColor.Red); + ConsoleExt.WriteLine($"Failed to save JSON to '{jsonPath}': {ex.Message}", ConsoleColor.Red); } exitCode = 3; @@ -170,26 +210,37 @@ internal static class Program if (options.CsvPath is not null) { + string csvPath = GetOutputPath(options.CsvPath, ".csv"); + List? activeHostsTable = [.. activeHosts]; + activeHostsTable.Insert(0, [.. header]); + try { - File.WriteAllLines(options.CsvPath, activeHostsTable.Select(row => string.Join(",", row))); + _ = Directory.CreateDirectory(Path.GetDirectoryName(csvPath) ?? string.Empty); + File.WriteAllLines(csvPath, activeHostsTable.Select(row => string.Join(",", row))); if (!options.Silent) { - ConsoleExt.WriteLine($"Saved CSV to '{options.CsvPath}'", ConsoleColor.Green); + ConsoleExt.WriteLine($"Saved CSV to '{csvPath}'", ConsoleColor.Green); } } catch (Exception ex) { if (!options.Silent) { - ConsoleExt.WriteLine($"Failed to save CSV to '{options.CsvPath}': {ex.Message}", ConsoleColor.Red); + ConsoleExt.WriteLine($"Failed to save CSV to '{csvPath}': {ex.Message}", ConsoleColor.Red); } exitCode = 3; } } + previouslyActiveHosts.Clear(); + foreach (string[] activeHost in activeHosts) + { + previouslyActiveHosts.Add(activeHost); + } + return exitCode; } @@ -204,4 +255,88 @@ internal static class Program return Task.FromResult(1); } } + + private static string GetOutputPath(string path, string extension) + { + string directory = Path.GetDirectoryName(path) ?? string.Empty; + string fileName = Path.GetFileNameWithoutExtension(path); + string fileExtension = Path.GetExtension(path); + fileExtension = string.IsNullOrEmpty(fileExtension) ? extension : fileExtension; + + string dateTime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + + string newPath = Path.Combine(directory, $"{fileName}_{dateTime}{fileExtension}"); + + int counter = 0; + while (File.Exists(newPath)) + { + newPath = Path.Combine(directory, $"{fileName}_{dateTime}_{++counter}{fileExtension}"); + } + + return newPath; + } + + private static void PrintActiveHosts(ConcurrentBag activeHosts, List header) + { + List? activeHostsTable = [.. activeHosts]; + activeHostsTable.Insert(0, [.. header]); + + Console.WriteLine(); + + if (!activeHosts.IsEmpty) + { + Console.WriteLine($"Active hosts:"); + + activeHostsTable.ToArray().To2D().PrintTable(TableStyle.List); + + ConsoleExt.WriteLine($"{Environment.NewLine}Found {"active host".ToQuantity(activeHosts.Count)}", ConsoleColor.Green); + } + else + { + ConsoleExt.WriteLine($"No active hosts found", ConsoleColor.Red); + } + } + + private static void PrintDifference(ConcurrentBag activeHosts, List header) + { + List newHosts = activeHosts.Where(activeHost => !previouslyActiveHosts.Exists(previousHost => previousHost[1] == activeHost[1])).ToList(); + List removedHosts = previouslyActiveHosts.Where(previousHost => !activeHosts.Any(activeHost => activeHost[1] == previousHost[1])).ToList(); + + List? newHostsTable = [.. newHosts]; + newHostsTable.Insert(0, [.. header]); + + List? removedHostsTable = [.. removedHosts]; + removedHostsTable.Insert(0, [.. header]); + + Console.WriteLine(); + + if (newHosts.Count > 0) + { + Console.WriteLine($"New hosts:"); + + newHostsTable.ToArray().To2D().PrintTable(TableStyle.List); + + ConsoleExt.WriteLine($"{Environment.NewLine}Found {"active host".ToQuantity(activeHosts.Count)}", ConsoleColor.Green); + } + else + { + ConsoleExt.WriteLine($"No new hosts found", ConsoleColor.Blue); + } + + Console.WriteLine(); + + if (removedHosts.Count > 0) + { + // Better term for removed host? + Console.WriteLine($"Previously active hosts:"); + + removedHostsTable.ToArray().To2D().PrintTable(TableStyle.List); + + ConsoleExt.WriteLine($"{Environment.NewLine}Found {"previously active host".ToQuantity(removedHosts.Count)}", ConsoleColor.Red); + } + else + { + ConsoleExt.WriteLine($"No previously active hosts are currently inactive", ConsoleColor.Blue); + } + } } \ No newline at end of file diff --git a/src/ARP-Scanner/Properties/launchSettings.json b/src/ARP-Scanner/Properties/launchSettings.json index 50b3f07..264fb0f 100644 --- a/src/ARP-Scanner/Properties/launchSettings.json +++ b/src/ARP-Scanner/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "ARP-Scanner": { "commandName": "Project", - "commandLineArgs": "192.168.1.0 - 192.168.1.255" + "commandLineArgs": "scan 192.168.1.239" } } } \ No newline at end of file diff --git a/src/ARP-Scanner/ScanOptions.cs b/src/ARP-Scanner/ScanOptions.cs index d087a96..aedb303 100644 --- a/src/ARP-Scanner/ScanOptions.cs +++ b/src/ARP-Scanner/ScanOptions.cs @@ -1,7 +1,8 @@ using CommandLine; namespace ARP_Scanner; -[Verb("scan")] + +[Verb("scan", HelpText = "Scan the specified IP range.")] internal class ScanOptions { [Value(0, Required = true, MetaName = "IP range", HelpText = "The IP range to scan.")] @@ -10,9 +11,6 @@ internal class ScanOptions [Option('s', "silent", Required = false, HelpText = "Don't print anything to the console.")] public bool Silent { get; set; } - [Option('v', "verbose", Required = false, HelpText = "Print verbose information.")] - public bool Verbose { get; set; } - [Option('r', "retry", Required = false, Default = 0, HelpText = "The number of retries for each ARP request.")] public int Retry { get; set; } @@ -24,4 +22,4 @@ internal class ScanOptions [Option("csv", Required = false, HelpText = "The path to the CSV file to save the results.")] public string? CsvPath { get; set; } -} +} \ No newline at end of file