diff --git a/src/ARP-Scanner/ARP-Scanner.csproj b/src/ARP-Scanner/ARP-Scanner.csproj index a740f6c..6c81d75 100644 --- a/src/ARP-Scanner/ARP-Scanner.csproj +++ b/src/ARP-Scanner/ARP-Scanner.csproj @@ -6,11 +6,14 @@ ARP_Scanner enable enable + false + + diff --git a/src/ARP-Scanner/JsonResult.cs b/src/ARP-Scanner/JsonResult.cs new file mode 100644 index 0000000..4beeea9 --- /dev/null +++ b/src/ARP-Scanner/JsonResult.cs @@ -0,0 +1,53 @@ +namespace ARP_Scanner; + +internal class JsonResult +{ + public IEnumerable Hosts { get; set; } = []; + + public IEnumerable NewHosts { get; set; } = []; + + public IEnumerable RemovedHosts { get; set; } = []; + + public static JsonResult Parse(IEnumerable activeHosts, List previousHosts) + { + JsonResult result = new JsonResult + { + 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 04a96b6..672a04c 100644 --- a/src/ARP-Scanner/MacVendorLookup.cs +++ b/src/ARP-Scanner/MacVendorLookup.cs @@ -12,8 +12,14 @@ internal partial class MacVendorLookup private MacDatabase macDatabase = new MacDatabase(); - public async Task Initialize() + 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 @@ -32,32 +38,49 @@ internal partial class MacVendorLookup } catch (Exception ex) { - ConsoleExt.WriteLine($"Failed to read MAC database cache: {ex.Message}", ConsoleColor.Red); + if (!silent) + { + ConsoleExt.WriteLine($"Failed to read MAC database cache: {ex.Message}", ConsoleColor.Red); + } } } // Update MAC database if it's older than a week if (macDatabase.LastUpdate > DateTime.Now.AddDays(-7)) { - ConsoleExt.WriteLine("Using cached MAC database...", ConsoleColor.DarkYellow); + if (!silent) + { + ConsoleExt.WriteLine("Using cached MAC database...", ConsoleColor.DarkYellow); + } return; } - ConsoleExt.WriteLine("Downloading MAC database from maclookup.app...", ConsoleColor.DarkYellow); + if (!silent) + { + ConsoleExt.WriteLine("Downloading MAC database from maclookup.app...", ConsoleColor.DarkYellow); + } - List? newMacInformations = null; + List? newMacInformation = null; try { - newMacInformations = await httpClient.GetFromJsonAsync>(macLookupUrl); + newMacInformation = await httpClient.GetFromJsonAsync>(macLookupUrl); } catch (Exception ex) { - ConsoleExt.WriteLine($"Failed to download MAC database: {ex.Message}", ConsoleColor.Red); + if (!silent) + { + ConsoleExt.WriteLine($"Failed to download MAC database: {ex.Message}", ConsoleColor.Red); + } } - if (newMacInformations is null) + if (newMacInformation is null) { + if (silent) + { + return; + } + if (macDatabase.MacInformations.Count != 0) { ConsoleExt.WriteLine("Failed to download MAC database, using cache...", ConsoleColor.DarkYellow); @@ -69,26 +92,29 @@ internal partial class MacVendorLookup } else { - ConsoleExt.WriteLine("MAC database downloaded successfully!", ConsoleColor.Green); + if (!silent) + { + ConsoleExt.WriteLine("MAC database downloaded successfully!", ConsoleColor.Green); + } _ = Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); - macDatabase.MacInformations = newMacInformations; + macDatabase.MacInformations = newMacInformation; macDatabase.LastUpdate = DateTime.Now; File.WriteAllText(cachePath, JsonSerializer.Serialize(macDatabase)); } } - public MacInformation GetInformation(string macAdress) + public MacInformation GetInformation(string macAddress) { - macAdress = macAdress.Replace("-", ":")[..8].ToUpper(); + macAddress = macAddress.Replace("-", ":")[..8].ToUpper(); - return macDatabase.MacInformations.Find(m => m.MacPrefix == macAdress) ?? new MacInformation() + return macDatabase.MacInformations.Find(m => m.MacPrefix == macAddress) ?? new MacInformation() { - MacPrefix = macAdress, + MacPrefix = macAddress, VendorName = "Unknown", BlockType = "Unknown", - Private = false, + Private = null, LastUpdate = "Unknown" }; } 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 a10b09a..8f0fe1b 100644 --- a/src/ARP-Scanner/Program.cs +++ b/src/ARP-Scanner/Program.cs @@ -1,60 +1,141 @@ using ArpLookup; +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 async Task Main(string[] args) - { - MacVendorLookup macVendorLookup = new MacVendorLookup(); + 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) { ConsoleExt.WriteLine("ARP is not supported on this platform!", ConsoleColor.Red); - return; + return 1; } - if (!IPAddressRange.TryParse(string.Join("", args), out IPAddressRange ipAddressRange)) + 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) + { + if (!IPAddressRange.TryParse(options.IpRange, out IPAddressRange ipAddressRange)) { ConsoleExt.WriteLine("Invalid IP range!", ConsoleColor.Red); - return; + return 2; } - await macVendorLookup.Initialize(); + await macVendorLookup.Initialize(options.Silent); long ipAddressesCount = ipAddressRange.Count(); long processedIpAddressesCount = 0; int numberOfDigits = ipAddressesCount.ToString().Length; + int exitCode = 0; - List header = ["IP", "MAC"]; ConcurrentBag activeHosts = []; - header.AddRange([nameof(MacInformation.VendorName), nameof(MacInformation.BlockType), nameof(MacInformation.Private), nameof(MacInformation.LastUpdate)]); + // If you want to change the header, you also need to change the JsonResult class + List header = [ + "IP", + "MAC", + nameof(MacInformation.VendorName).Humanize(LetterCasing.Title), + nameof(MacInformation.BlockType).Humanize(LetterCasing.Title), + nameof(MacInformation.Private).Humanize(LetterCasing.Title), + nameof(MacInformation.LastUpdate).Humanize(LetterCasing.Title)]; - ConsoleExt.WriteLine("Starting scan...", ConsoleColor.DarkYellow); + if (!options.Silent) + { + ConsoleExt.WriteLine("Starting scan...", ConsoleColor.DarkYellow); + } - await Parallel.ForEachAsync(ipAddressRange, async (IPAddress ipAddress, CancellationToken _) => + ParallelOptions parallelOptions = new() + { + MaxDegreeOfParallelism = options.Concurrency + }; + + await Parallel.ForEachAsync(ipAddressRange, parallelOptions, async (IPAddress ipAddress, CancellationToken _) => { PhysicalAddress? mac = null; bool fail = false; - - try + int retry = options.Retry; + do { - mac = await Arp.LookupAsync(ipAddress); - } - catch (Exception ex) - { - ConsoleExt.WriteLine($"Failed to lookup MAC address for {ipAddress}: {ex.Message}", ConsoleColor.Red); - fail = true; + try + { + mac = await Arp.LookupAsync(ipAddress); + fail = false; + } + catch (Exception ex) + { + if (!options.Silent) + { + ConsoleExt.WriteLine($"Failed to lookup MAC address for {ipAddress}: {ex.Message}", ConsoleColor.Red); + } + fail = true; + } } + while (retry-- > 0 && (mac is null || fail)); long localProcessedIpAddressesCount = Interlocked.Increment(ref processedIpAddressesCount); if (mac is not null && Array.Exists(mac.GetAddressBytes(), b => b != 0)) @@ -63,35 +144,199 @@ internal static class Program MacInformation macInformation = macVendorLookup.GetInformation(formattedMac); - List info = [ipAddress.ToString(), formattedMac, macInformation.VendorName, macInformation.BlockType, macInformation.Private.ToString() ?? "Unknown", macInformation.LastUpdate]; - ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Active: {ipAddress}", ConsoleColor.Green); + 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); + } activeHosts.Add([.. info]); } else if (fail) { - ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Failed: {ipAddress}", ConsoleColor.Red); + if (!options.Silent) + { + ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Failed: {ipAddress}", ConsoleColor.Red); + } } else { - ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Inactive: {ipAddress}", ConsoleColor.Red); + if (!options.Silent) + { + ConsoleExt.WriteLine($"Progress: {localProcessedIpAddressesCount.ToString().PadLeft(numberOfDigits)}/{ipAddressesCount} [{100d / ipAddressesCount * localProcessedIpAddressesCount,6:##0.00}%] | Inactive: {ipAddress}", ConsoleColor.Red); + } } }); - if (!activeHosts.IsEmpty) + if (previouslyActiveHosts.Count > 0 && !options.Silent) { - Console.WriteLine(Environment.NewLine + "Active hosts:"); + PrintDifference(activeHosts, header); + } + else if (!options.Silent) + { + PrintActiveHosts(activeHosts, header); + } + if (!options.Silent && (options.JsonPath is not null || options.CsvPath is not null)) + { + Console.WriteLine(); + } + + if (options.JsonPath is not null) + { + string jsonPath = GetOutputPath(options.JsonPath, ".json"); + + try + { + _ = 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 '{jsonPath}'", ConsoleColor.Green); + } + } + catch (Exception ex) + { + if (!options.Silent) + { + ConsoleExt.WriteLine($"Failed to save JSON to '{jsonPath}': {ex.Message}", ConsoleColor.Red); + } + + exitCode = 3; + } + } + + if (options.CsvPath is not null) + { + string csvPath = GetOutputPath(options.CsvPath, ".csv"); List? activeHostsTable = [.. activeHosts]; - activeHostsTable.Insert(0, [.. header]); - activeHostsTable.ToArray().To2D().PrintTable(TableStyle.List); + try + { + _ = Directory.CreateDirectory(Path.GetDirectoryName(csvPath) ?? string.Empty); + File.WriteAllLines(csvPath, activeHostsTable.Select(row => string.Join(",", row))); - ConsoleExt.WriteLine($"{Environment.NewLine}Found {activeHosts.Count} active hosts", ConsoleColor.Green); + if (!options.Silent) + { + ConsoleExt.WriteLine($"Saved CSV to '{csvPath}'", ConsoleColor.Green); + } + } + catch (Exception ex) + { + if (!options.Silent) + { + 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; + } + + private static Task HandleParseError(IEnumerable errors) + { + if (errors.IsHelp() || errors.IsVersion()) + { + return Task.FromResult(0); } else { - ConsoleExt.WriteLine($"{Environment.NewLine}No active hosts found", ConsoleColor.Red); + 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 new file mode 100644 index 0000000..cf8b9ba --- /dev/null +++ b/src/ARP-Scanner/ScanOptions.cs @@ -0,0 +1,25 @@ +using CommandLine; + +namespace ARP_Scanner; + +[Verb("scan", true, HelpText = "Scan the specified IP range.")] +internal class ScanOptions +{ + [Value(0, Required = true, MetaName = "IP range", HelpText = "The IP range to scan.")] + public required string IpRange { get; set; } + + [Option('s', "silent", Required = false, HelpText = "Don't print anything to the console.")] + public bool Silent { get; set; } + + [Option('r', "retry", Required = false, Default = 0, HelpText = "The number of retries for each ARP request.")] + public int Retry { get; set; } + + [Option('c', "concurrency", Required = false, Default = -1, HelpText = "The number of concurrent ARP requests.")] + public int Concurrency { get; set; } + + [Option("json", Required = false, HelpText = "The path to the JSON file to save the results.")] + public string? JsonPath { get; set; } + + [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