diff --git a/src/ARP-Scanner/ARP-Scanner.csproj b/src/ARP-Scanner/ARP-Scanner.csproj index 7ad33bc..13b889f 100644 --- a/src/ARP-Scanner/ARP-Scanner.csproj +++ b/src/ARP-Scanner/ARP-Scanner.csproj @@ -10,6 +10,7 @@ + diff --git a/src/ARP-Scanner/JsonResult.cs b/src/ARP-Scanner/JsonResult.cs new file mode 100644 index 0000000..85d7f5f --- /dev/null +++ b/src/ARP-Scanner/JsonResult.cs @@ -0,0 +1,23 @@ +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 static IEnumerable Parse(List activeHosts) + { + return activeHosts.Select(row => new JsonResult + { + Ip = row[0], + Mac = row[1], + VendorName = row[2], + BlockType = row[3], + Private = bool.Parse(row[4]), + LastUpdate = row[5] + }); + } +} diff --git a/src/ARP-Scanner/MacVendorLookup.cs b/src/ARP-Scanner/MacVendorLookup.cs index 946291d..293c0cf 100644 --- a/src/ARP-Scanner/MacVendorLookup.cs +++ b/src/ARP-Scanner/MacVendorLookup.cs @@ -12,7 +12,7 @@ internal partial class MacVendorLookup private MacDatabase macDatabase = new MacDatabase(); - public async Task Initialize() + public async Task Initialize(bool silent) { string cachePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "macDatabase.json"); @@ -32,18 +32,27 @@ 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? newMacInformation = null; @@ -53,11 +62,19 @@ internal partial class MacVendorLookup } 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 (newMacInformation is null) { + if (silent) + { + return; + } + if (macDatabase.MacInformations.Count != 0) { ConsoleExt.WriteLine("Failed to download MAC database, using cache...", ConsoleColor.DarkYellow); @@ -69,7 +86,10 @@ 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)!); diff --git a/src/ARP-Scanner/Program.cs b/src/ARP-Scanner/Program.cs index 75747c5..710897a 100644 --- a/src/ARP-Scanner/Program.cs +++ b/src/ARP-Scanner/Program.cs @@ -1,5 +1,7 @@ using ArpLookup; +using CommandLine; + using CuteUtils.Misc; using Humanizer; @@ -9,58 +11,84 @@ using NetTools; using System.Collections.Concurrent; using System.Net; using System.Net.NetworkInformation; +using System.Text.Json; namespace ARP_Scanner; internal static class Program { - private static async Task Main(string[] args) + private static async Task Main(string[] args) { - MacVendorLookup macVendorLookup = new MacVendorLookup(); - 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(StartScan, HandleParseError); + } + + 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(); + MacVendorLookup macVendorLookup = new MacVendorLookup(); + 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([ + // 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)]); + 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)) @@ -70,34 +98,110 @@ 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); + 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 (!options.Silent) + { + Console.WriteLine(); + } + + List? activeHostsTable = [.. activeHosts]; + activeHostsTable.Insert(0, [.. header]); + if (!activeHosts.IsEmpty) { - Console.WriteLine(Environment.NewLine + $"Active host{(activeHosts.Count == 1 ? "s" : "")}:"); - - List? activeHostsTable = [.. activeHosts]; - - activeHostsTable.Insert(0, [.. header]); + 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); } + else if (!options.Silent) + { + ConsoleExt.WriteLine($"No active hosts found", ConsoleColor.Red); + } + + if (!options.Silent && (options.JsonPath is not null || options.CsvPath is not null)) + { + Console.WriteLine(); + } + + if (options.JsonPath is not null) + { + try + { + File.WriteAllText(options.JsonPath, JsonSerializer.Serialize(JsonResult.Parse([.. activeHosts]))); + + if (!options.Silent) + { + ConsoleExt.WriteLine($"Saved JSON to '{options.JsonPath}'", ConsoleColor.Green); + } + } + catch (Exception ex) + { + if (!options.Silent) + { + ConsoleExt.WriteLine($"Failed to save JSON to '{options.JsonPath}': {ex.Message}", ConsoleColor.Red); + } + + exitCode = 3; + } + } + + if (options.CsvPath is not null) + { + try + { + File.WriteAllLines(options.CsvPath, activeHostsTable.Select(row => string.Join(",", row))); + + if (!options.Silent) + { + ConsoleExt.WriteLine($"Saved CSV to '{options.CsvPath}'", ConsoleColor.Green); + } + } + catch (Exception ex) + { + if (!options.Silent) + { + ConsoleExt.WriteLine($"Failed to save CSV to '{options.CsvPath}': {ex.Message}", ConsoleColor.Red); + } + + exitCode = 3; + } + } + + 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); } } } \ 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..d087a96 --- /dev/null +++ b/src/ARP-Scanner/ScanOptions.cs @@ -0,0 +1,27 @@ +using CommandLine; + +namespace ARP_Scanner; +[Verb("scan")] +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('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; } + + [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; } +}