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