diff --git a/Source/NETworkManager.Converters/HostReachableToStringConverter.cs b/Source/NETworkManager.Converters/HostReachableToStringConverter.cs new file mode 100644 index 0000000000..31de4e89ad --- /dev/null +++ b/Source/NETworkManager.Converters/HostReachableToStringConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Converters; + +/// +/// Convert a that indicates if a host is reachable to a translated +/// ("Up" / "Down"). +/// +public sealed class HostReachableToStringConverter : IValueConverter +{ + /// + /// Convert a that indicates if a host is reachable to a translated . + /// + /// Host reachable as . + /// + /// + /// + /// Translated "Up" or "Down". + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is true ? Strings.Up : Strings.Down; + } + + /// + /// !!! Method not implemented !!! + /// + /// + /// + /// + /// + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index bdfff7cd76..10a79a6f3e 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -11845,6 +11845,28 @@ public static string ScanForNetBIOS { } } + /// + /// Looks up a localized string similar to {0} IP addresses will be scanned. This can take a long time and needs a lot of memory. + /// + ///Do you want to start the scan?. + /// + public static string ScanManyIPAddressesMessage { + get { + return ResourceManager.GetString("ScanManyIPAddressesMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} ports will be scanned ({1} hosts × {2} ports). This can take a long time and needs a lot of memory. + /// + ///Do you want to start the scan?. + /// + public static string ScanManyPortsMessage { + get { + return ResourceManager.GetString("ScanManyPortsMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Scanned. /// @@ -11863,6 +11885,24 @@ public static string ScanPorts { } } + /// + /// Looks up a localized string similar to {0} IP addresses cannot be scanned at once (maximum {1}). Split the input into smaller ranges.. + /// + public static string ScanTooManyIPAddressesMessage { + get { + return ResourceManager.GetString("ScanTooManyIPAddressesMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} ports cannot be scanned at once ({1} hosts × {2} ports, maximum {3}). Split the input into fewer hosts or ports.. + /// + public static string ScanTooManyPortsMessage { + get { + return ResourceManager.GetString("ScanTooManyPortsMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Scroll = Zoom, Drag = Pan. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 348e14c743..7ab169bb31 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5004,4 +5004,20 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri Port profile + + {0} IP addresses will be scanned. This can take a long time and needs a lot of memory. + +Do you want to start the scan? + + + {0} IP addresses cannot be scanned at once (maximum {1}). Split the input into smaller ranges. + + + {0} ports will be scanned ({1} hosts × {2} ports). This can take a long time and needs a lot of memory. + +Do you want to start the scan? + + + {0} ports cannot be scanned at once ({1} hosts × {2} ports, maximum {3}). Split the input into fewer hosts or ports. + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index be420e8bd2..fe1889fc9a 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -1,6 +1,5 @@ using NETworkManager.Utilities; using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; @@ -17,6 +16,11 @@ namespace NETworkManager.Models.Network; /// public static class HostRangeHelper { + /// + /// Number of addresses after which a range expansion checks for cancellation. + /// + private const int CancellationCheckInterval = 65536; + /// /// Create a list of hosts from a string input like "10.0.0.1; example.com; 10.0.0.0/24". /// Inputs can also be separated by newlines (e.g. pasted from Excel, one host/range per line). @@ -33,41 +37,194 @@ public static IEnumerable CreateListFromInput(string hosts) /// /// Adds every IPv4 address in the inclusive range [, ] to - /// . Iterates using the unsigned IPv4 value widened to so the - /// inclusive upper bound stays representable even for the last address in the 32-bit space - /// (255.255.255.255), which would otherwise overflow arithmetic. + /// (in ascending order). Iterates using the unsigned IPv4 value widened to + /// so the inclusive upper bound stays representable even for the last address in the + /// 32-bit space (255.255.255.255), which would otherwise overflow arithmetic. + /// + private static void AddIPv4Range(IPAddress start, IPAddress end, + List<(IPAddress ipAddress, string hostname)> hosts, CancellationToken ct) + { + long from = IPv4Address.ToUInt32(start); + long to = IPv4Address.ToUInt32(end); + + // Allocate the list once instead of growing it step by step (e.g. 65536 entries for a /16) + if (hosts.Count == 0 && to >= from && to - from + 1 <= Array.MaxLength) + hosts.Capacity = (int)(to - from + 1); + + for (var i = from; i <= to; i++) + { + if ((i - from) % CancellationCheckInterval == 0) + ct.ThrowIfCancellationRequested(); + + hosts.Add((IPv4Address.FromInt32(unchecked((int)i)), string.Empty)); + } + } + + /// + /// Adds every IPv4 address of a special range like "192.168.[50-100,200].1" to . + /// + private static void AddIPv4SpecialRange(string host, List<(IPAddress ipAddress, string hostname)> hosts, + CancellationToken ct) + { + var octets = GetIPv4SpecialRangeOctets(host); + + // Build the new ipv4 + var count = 0; + + foreach (var i in octets[0]) + foreach (var j in octets[1]) + foreach (var k in octets[2]) + foreach (var h in octets[3]) + { + if (count++ % CancellationCheckInterval == 0) + ct.ThrowIfCancellationRequested(); + + hosts.Add((new IPAddress([i, j, k, h]), string.Empty)); + } + } + + /// + /// Gets the numbers of each octet of a special range like "192.168.[50-100,200].1". /// - private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, - ConcurrentBag<(IPAddress ipAddress, string hostname)> hostsBag, CancellationToken ct) + private static List> GetIPv4SpecialRangeOctets(string host) { - var from = (long)unchecked((uint)IPv4Address.ToInt32(start)); - var to = (long)unchecked((uint)IPv4Address.ToInt32(end)); + var octets = new List>(); - Parallel.For(from, to + 1, (i, state) => + foreach (var octet in host.Split('.')) { - if (ct.IsCancellationRequested) - state.Break(); + var numbers = new List(); + + // Create a range for each octet + if (Regex.IsMatch(octet, RegexHelper.SpecialRangeRegex)) + foreach (var numberOrRange in octet[1..^1].Split(',')) + // 50-100 + if (numberOrRange.Contains('-')) + { + var rangeNumbers = numberOrRange.Split('-'); + + for (var i = int.Parse(rangeNumbers[0]); i <= int.Parse(rangeNumbers[1]); i++) + numbers.Add((byte)i); + } // 200 + else + { + numbers.Add(byte.Parse(numberOrRange)); + } + else + numbers.Add(byte.Parse(octet)); + + octets.Add(numbers); + } - hostsBag.Add((IPv4Address.FromInt32(unchecked((int)i)), string.Empty)); - }); + return octets; } + /// + /// Gets the number of IP addresses of the inputs without expanding the ranges or resolving the hostnames + /// (a hostname counts as 1, overlapping inputs are counted multiple times). Used to warn about (or refuse) + /// very large scans before any memory is allocated. + /// + /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). + /// Number of IP addresses. + public static long GetHostCount(IEnumerable hosts) + { + long count = 0; + + foreach (var host in hosts) + { + switch (host) + { + // 192.168.0.1 + case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): + count++; + break; + + // 192.168.0.0/24 or 192.168.0.0/255.255.255.0 + case var _ when RegexHelper.IPv4AddressCidrRegex().IsMatch(host): + case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): + count += (long)IPNetwork2.Parse(host).Total; + break; + + // 192.168.0.1-100 + case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host): + var shortRange = host.Split('-'); + var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; + + count += GetIPv4RangeCount(IPAddress.Parse(shortRange[0]), + IPAddress.Parse($"{shortBase}.{shortRange[1]}")); + break; + + // 192.168.0.0 - 192.168.0.100 + case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): + var range = host.Split('-'); + + count += GetIPv4RangeCount(IPAddress.Parse(range[0]), IPAddress.Parse(range[1])); + break; + + // 192.168.[50-100].1 + case var _ when RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(host): + count += GetIPv4SpecialRangeOctets(host).Aggregate(1L, (product, octet) => product * octet.Count); + break; + + // example.com/24 or example.com/255.255.255.128 (IPv6 address and example.com count as 1) + case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithCidrRegex): + case var _ when Regex.IsMatch(host, RegexHelper.HostnameOrDomainWithSubnetmaskRegex): + count += (long)IPNetwork2.Parse($"0.0.0.0/{host.Split('/')[1]}").Total; + break; + + default: + count++; + break; + } + } + + return count; + } + + /// + /// Gets the number of IPv4 addresses in the inclusive range [, ]. + /// + private static long GetIPv4RangeCount(IPAddress start, IPAddress end) + { + long from = IPv4Address.ToUInt32(start); + long to = IPv4Address.ToUInt32(end); + + return Math.Max(to - from + 1, 0); + } + + /// + /// Resolves the inputs (IP addresses, ranges, subnets, hostnames) into a list of hosts. The list is sorted + /// by IP address and contains every IP address only once (e.g. overlapping ranges), see + /// . + /// + /// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ). + /// Prefer the A record over the AAAA record for hostnames. + /// Token to monitor for cancellation requests. + /// Sorted, distinct list of hosts and the hostnames that could not be resolved. public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved)> ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken) { - var hostsBag = new ConcurrentBag<(IPAddress ipAddress, string hostname)>(); - var hostnamesNotResovledBag = new ConcurrentBag(); + var inputs = hosts.ToList(); - await Parallel.ForEachAsync(hosts, new ParallelOptions { CancellationToken = cancellationToken }, - async (host, ct) => + // Results per input (index = position in the input) to keep the input order + var hostsPerInput = new List<(IPAddress ipAddress, string hostname)>[inputs.Count]; + var hostnameNotResolvedPerInput = new string[inputs.Count]; + + await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), + new ParallelOptions { CancellationToken = cancellationToken }, + async (index, ct) => { + var host = inputs[index]; + var list = new List<(IPAddress ipAddress, string hostname)>(); + + hostsPerInput[index] = list; + switch (host) { // 192.168.0.1 case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): // 2001:db8:85a3::8a2e:370:7334 case var _ when Regex.IsMatch(host, RegexHelper.IPv6AddressRegex): - hostsBag.Add((IPAddress.Parse(host), string.Empty)); + list.Add((IPAddress.Parse(host), string.Empty)); break; @@ -77,7 +234,7 @@ private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): var network = IPNetwork2.Parse(host); - AddIPv4RangeToBag(network.Network, network.Broadcast, hostsBag, ct); + AddIPv4Range(network.Network, network.Broadcast, list, ct); break; @@ -86,8 +243,8 @@ private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, var shortRange = host.Split('-'); var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; - AddIPv4RangeToBag(IPAddress.Parse(shortRange[0]), - IPAddress.Parse($"{shortBase}.{shortRange[1]}"), hostsBag, ct); + AddIPv4Range(IPAddress.Parse(shortRange[0]), + IPAddress.Parse($"{shortBase}.{shortRange[1]}"), list, ct); break; @@ -95,68 +252,13 @@ private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): var range = host.Split('-'); - AddIPv4RangeToBag(IPAddress.Parse(range[0]), IPAddress.Parse(range[1]), hostsBag, ct); + AddIPv4Range(IPAddress.Parse(range[0]), IPAddress.Parse(range[1]), list, ct); break; // 192.168.[50-100].1 case var _ when RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(host): - var octets = host.Split('.'); - - var list = new List>(); - - // Go through each octet... - foreach (var octet in octets) - { - var innerList = new ConcurrentBag(); - - // Create a range for each octet - if (Regex.IsMatch(octet, RegexHelper.SpecialRangeRegex)) - foreach (var numberOrRange in octet[1..^1].Split(',')) - // 50-100 - if (numberOrRange.Contains('-')) - { - var rangeNumbers = numberOrRange.Split('-'); - - Parallel.For(int.Parse(rangeNumbers[0]), int.Parse(rangeNumbers[1]) + 1, - (i, state) => - { - if (ct.IsCancellationRequested) - state.Break(); - - innerList.Add(i); - }); - } // 200 - else - { - innerList.Add(int.Parse(numberOrRange)); - } - else - innerList.Add(int.Parse(octet)); - - list.Add(innerList); - } - - // Build the new ipv4 - Parallel.ForEach(list[0], new ParallelOptions { CancellationToken = ct }, - i => - { - Parallel.ForEach(list[1], new ParallelOptions { CancellationToken = ct }, - j => - { - Parallel.ForEach(list[2], - new ParallelOptions { CancellationToken = ct }, - k => - { - Parallel.ForEach(list[3], - new ParallelOptions { CancellationToken = ct }, - h => - { - hostsBag.Add((IPAddress.Parse($"{i}.{j}.{k}.{h}"), string.Empty)); - }); - }); - }); - }); + AddIPv4SpecialRange(host, list, ct); break; @@ -166,9 +268,9 @@ private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, .WaitAsync(ct).ConfigureAwait(false); if (!dnsResult.HasError) - hostsBag.Add((IPAddress.Parse($"{dnsResult.Value}"), host)); + list.Add((IPAddress.Parse($"{dnsResult.Value}"), host)); else - hostnamesNotResovledBag.Add(host); + hostnameNotResolvedPerInput[index] = host; break; @@ -181,33 +283,98 @@ private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, var dnsResultWithSubnet = await DNSClientHelper.ResolveAorAaaaAsync(hostAndSubnet[0], true) .WaitAsync(ct).ConfigureAwait(false); - if (!dnsResultWithSubnet.HasError) + // Only support IPv4 for ranges for now + if (!dnsResultWithSubnet.HasError && + dnsResultWithSubnet.Value.AddressFamily == AddressFamily.InterNetwork) { - // Only support IPv4 for ranges for now - if (dnsResultWithSubnet.Value.AddressFamily == AddressFamily.InterNetwork) - { - network = IPNetwork2.Parse( - $"{dnsResultWithSubnet.Value}/{hostAndSubnet[1]}"); - - AddIPv4RangeToBag(network.Network, network.Broadcast, hostsBag, ct); - } - else - { - hostnamesNotResovledBag.Add(hostAndSubnet[0]); - } + network = IPNetwork2.Parse($"{dnsResultWithSubnet.Value}/{hostAndSubnet[1]}"); + + AddIPv4Range(network.Network, network.Broadcast, list, ct); } else { - hostnamesNotResovledBag.Add(hostAndSubnet[0]); + hostnameNotResolvedPerInput[index] = hostAndSubnet[0]; } break; } }).ConfigureAwait(false); - // Sort list and return - IPAddressComparer comparer = new(); + return (SortAndRemoveDuplicates(hostsPerInput), + [.. hostnameNotResolvedPerInput.Where(x => x != null)]); + } + + /// + /// Merges the hosts of all inputs into one list sorted by IP address, with every IP address only once + /// (e.g. overlapping ranges). If the same IP address was entered with different hostnames (e.g. two names of + /// one server), every hostname is kept once; an entry without hostname is only kept if there is no entry + /// with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is + /// the same on every run. IPv6 addresses with a different scope id (e.g. "fe80::1%3" and "fe80::1%7", + /// two devices on different interfaces) are different hosts. + /// + private static List<(IPAddress ipAddress, string hostname)> SortAndRemoveDuplicates( + List<(IPAddress ipAddress, string hostname)>[] hostsPerInput) + { + var hosts = hostsPerInput.Length == 1 ? hostsPerInput[0] : [.. hostsPerInput.SelectMany(x => x)]; + + // The common case (a single range or subnet) is already sorted and distinct - one cheap check + // instead of sorting millions of entries again + var isSortedAndDistinct = true; + + for (var i = 1; i < hosts.Count; i++) + { + if (CompareHosts(hosts[i - 1].ipAddress, hosts[i].ipAddress) < 0) + continue; + + isSortedAndDistinct = false; + break; + } + + if (isSortedAndDistinct) + return hosts; + + // Stable sort (OrderBy), entries with the same IP address keep the input order + var sortedHosts = hosts.OrderBy(x => x.ipAddress, Comparer.Create(CompareHosts)).ToList(); + + var distinctHosts = new List<(IPAddress ipAddress, string hostname)>(sortedHosts.Count); + + for (var start = 0; start < sortedHosts.Count;) + { + // All entries with the same IP address + var end = start + 1; + + while (end < sortedHosts.Count && + CompareHosts(sortedHosts[start].ipAddress, sortedHosts[end].ipAddress) == 0) + end++; + + var entriesWithHostname = sortedHosts.GetRange(start, end - start) + .Where(x => !string.IsNullOrEmpty(x.hostname)) + .DistinctBy(x => x.hostname, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (entriesWithHostname.Count > 0) + distinctHosts.AddRange(entriesWithHostname); + else + distinctHosts.Add(sortedHosts[start]); + + start = end; + } + + return distinctHosts; + } + + /// + /// Compares two IP addresses like , IPv6 addresses with the + /// same bytes are compared by their scope id. + /// + private static int CompareHosts(IPAddress x, IPAddress y) + { + var result = IPAddressHelper.CompareIPAddresses(x, y); + + // Same bytes -> same address family + if (result != 0 || x.AddressFamily != AddressFamily.InterNetworkV6) + return result; - return ([.. hostsBag.OrderBy(x => x.ipAddress, comparer)], [.. hostnamesNotResovledBag]); + return x.ScopeId.CompareTo(y.ScopeId); } -} \ No newline at end of file +} diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 986ba87a82..7e1456f437 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -20,10 +20,23 @@ public sealed class IPScanner(IPScannerOptions options) { #region Variables + /// + /// Time between the start of two hosts during the ramp-up, see . With the + /// default settings the first 128 hosts are started within ~1.3 s instead of at the same moment (~11 connection + /// requests per 10 ms instead of ~1400 at once), the following ones when a slot is free. Shorter if a host + /// takes less than 128 x 10 ms (see ). + /// + private static readonly TimeSpan MaxHostStartInterval = TimeSpan.FromMilliseconds(10); + private int _progressValue; private readonly InterlockedCounter _hostsUp = new(); private readonly InterlockedCounter _hostsDown = new(); + // Snapshot of the neighbor table (IP address -> MAC address), see GetMACAddressFromNeighborTableAsync + private readonly SemaphoreSlim _neighborTableSemaphore = new(1, 1); + private Dictionary _neighborTable = []; + private DateTime _neighborTableTimestamp = DateTime.MinValue; + /// /// Gets the number of hosts found to be reachable so far. Thread-safe; may be read from /// any thread while the scan is running. @@ -51,13 +64,14 @@ private void OnHostScanned(IPScannerHostScannedArgs e) } /// - /// Occurs when the scan is complete. + /// Occurs when the scan is complete. Raised exactly once per scan, also when the scan was canceled + /// or failed (see ). /// - public event EventHandler ScanComplete; + public event EventHandler ScanComplete; - private void OnScanComplete() + private void OnScanComplete(ScanCompleteArgs e) { - ScanComplete?.Invoke(this, EventArgs.Empty); + ScanComplete?.Invoke(this, e); } /// @@ -70,16 +84,6 @@ private void OnProgressChanged() ProgressChanged?.Invoke(this, new ProgressChangedArgs(_progressValue)); } - /// - /// Occurs when the user has canceled the scan. - /// - public event EventHandler UserHasCanceled; - - private void OnUserHasCanceled() - { - UserHasCanceled?.Invoke(this, EventArgs.Empty); - } - #endregion #region Methods @@ -92,16 +96,20 @@ private void OnUserHasCanceled() public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, CancellationToken cancellationToken) { - // Start the scan in a separate task + // Start the scan in a separate task. The token is not passed to Task.Run on purpose: with an + // already canceled token the delegate would never run and ScanComplete would never be raised. Task.Run(async () => { _progressValue = 0; - // Get all network interfaces (for local mac address lookup) - var networkInterfaces = options.ResolveMACAddress ? NetworkInterface.GetNetworkInterfaces() : []; + var isCanceled = false; + Exception exception = null; try { + // Get all network interfaces (for local mac address lookup) + var networkInterfaces = options.ResolveMACAddress ? NetworkInterface.GetNetworkInterfaces() : []; + var hostParallelOptions = new ParallelOptions { CancellationToken = cancellationToken, @@ -114,9 +122,29 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, MaxDegreeOfParallelism = options.MaxPortThreads }; + // Spread the start of the hosts over time instead of starting all at once - based on the time + // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS) + var hostDuration = Math.Max(options.ICMPAttempts * options.ICMPTimeout, + Math.Max( + options.PortScanEnabled + ? options.PortScanTimeout * options.PortScanAttempts * + (int)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) + : 0, + options.NetBIOSEnabled ? options.NetBIOSTimeout : 0)); + + var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount) + ? Math.Min(hostCount, options.MaxHostThreads) + : options.MaxHostThreads; + + var hostStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxHostStartInterval, + TimeSpan.FromMilliseconds(hostDuration), hostConcurrency), hostConcurrency); + // Start scan await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => { + // Spread the start of the hosts over time instead of starting all at once + await hostStartScheduler.WaitAsync(ct).ConfigureAwait(false); + // Start ping, port scan and netbios lookup concurrently - none of these block a thread anymore var pingTask = PingAsync(host.ipAddress, ct); @@ -130,6 +158,10 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => await Task.WhenAll(pingTask, portScanTask, netbiosTask).ConfigureAwait(false); + // A reachable host is in the neighbor table (ARP/NDP) from now on (before it can answer, the + // local system has to resolve its MAC address) + var discoveredAt = DateTime.UtcNow; + var pingInfo = pingTask.Result; var portScanResults = portScanTask.Result; var netBIOSInfo = netbiosTask.Result; @@ -141,6 +173,7 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => var isAnyPortOpen = portScanResults.Any(x => x.State == PortState.Open); var isReachable = pingInfo.Status == IPStatus.Success || // ICMP response isAnyPortOpen || // Any port is open + portScanResults.Any(x => x.State == PortState.Closed) || // Connection refused (TCP reset) netBIOSInfo.IsReachable; // NetBIOS response // Count reachable/unreachable hosts unconditionally, since ShowAllResults @@ -171,14 +204,21 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => if (options.ResolveMACAddress) { - // Get info from neighbor table - arpMACAddress = NeighborTable.GetMACAddress(host.ipAddress); + // Get info from neighbor table. Only hosts in a local subnet are in it (for any other + // host it contains the router), an unreachable host never needs a new snapshot. + if (IsInLocalSubnet(host.ipAddress, networkInterfaces)) + arpMACAddress = await GetMACAddressFromNeighborTableAsync(host.ipAddress, + isReachable ? discoveredAt : DateTime.MinValue, ct).ConfigureAwait(false); // Check if it is the local mac if (string.IsNullOrEmpty(arpMACAddress)) { + var ipAddress = IPAddressHelper.WithoutScopeId(host.ipAddress); + var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p => - p.IPv4Address.Any(x => x.Item1.Equals(host.ipAddress))); + p.IPv4Address.Any(x => x.Item1.Equals(ipAddress)) || + p.IPv6Address.Concat(p.IPv6AddressLinkLocal) + .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddress))); if (networkInterfaceInfo != null) arpMACAddress = networkInterfaceInfo.PhysicalAddress.ToString(); @@ -207,7 +247,9 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => : netBIOSInfo?.ComputerName ?? string.Empty, dnsHostname, isAnyPortOpen, - portScanResults.OrderBy(x => x.Port).ToList(), + portScanResults + .Where(x => x.State == PortState.Open || options.ShowAllResults) + .OrderBy(x => x.Port).ToList(), netBIOSInfo, // ARP/NDP is preferred, fallback to NetBIOS !string.IsNullOrEmpty(arpMACAddress) @@ -224,21 +266,103 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => IncreaseProgress(); }).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - OnUserHasCanceled(); + isCanceled = true; + } + catch (Exception ex) + { + exception = ex; } finally { - OnScanComplete(); + OnScanComplete(new ScanCompleteArgs(isCanceled, exception)); } - }, cancellationToken); + }); + } + + /// + /// Gets the MAC address of from the neighbor table (ARP / NDP). The table is + /// read once and cached for the scan instead of reading it for every host. It is only read again on a miss + /// if the cached snapshot is older than (the time the host answered, when + /// its entry was already created) - a host is always looked up in a table read after its answer, and hosts + /// that finish at the same time share one read. + /// + /// IP address of the host (in a local subnet, see ). + /// Time the host answered, to use the cache only. + /// Token to monitor for cancellation requests. + /// MAC address or an empty string. + private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt, + CancellationToken cancellationToken) + { + var key = IPAddressHelper.WithoutScopeId(ipAddress); + + // Hosts wait without blocking a thread while another host reads the table + await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (_neighborTable.TryGetValue(key, out var macAddress)) + return macAddress; + + // Already read after the host answered -> no entry + if (_neighborTableTimestamp > discoveredAt) + return string.Empty; + + // Timestamp before reading the table, so entries created while reading are covered by the next read + _neighborTableTimestamp = DateTime.UtcNow; + _neighborTable = NeighborTable.GetMACAddressMap(); + + return _neighborTable.GetValueOrDefault(key, string.Empty); + } + finally + { + _neighborTableSemaphore.Release(); + } + } + + /// + /// Checks if is in a subnet of a local network interface (on-link). Only these + /// hosts are in the neighbor table - for a host behind a router, its MAC address can't be resolved via ARP/NDP + /// (the table contains the router), so there is no need to look it up (and to read the table again). + /// IPv6: link-local addresses, other addresses if they are in the same /64 as a local address (the prefix + /// length is not known, local IPv6 subnets are /64). + /// + /// IP address of the host. + /// Local network interfaces. + /// True if the IP address is in a local subnet. + private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable networkInterfaces) + { + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) + { + var address = IPv4Address.ToUInt32(ipAddress); + + return networkInterfaces.Any(networkInterface => networkInterface.IPv4Address.Any(x => + { + if (x.Item2 == null) + return false; + + var subnetmask = IPv4Address.ToUInt32(x.Item2); + + return (address & subnetmask) == (IPv4Address.ToUInt32(x.Item1) & subnetmask); + })); + } + + if (ipAddress.IsIPv6LinkLocal) + return true; + + var prefix = ipAddress.GetAddressBytes().AsSpan(0, 8).ToArray(); + + return networkInterfaces.Any(networkInterface => networkInterface.IPv6Address.Any(x => + x.GetAddressBytes().AsSpan(0, 8).SequenceEqual(prefix))); } private async Task PingAsync(IPAddress ipAddress, CancellationToken cancellationToken) { using var ping = new System.Net.NetworkInformation.Ping(); + PingInfo pingInfo = null; + for (var i = 0; i < options.ICMPAttempts; i++) { // Get timestamp @@ -275,24 +399,28 @@ private async Task PingAsync(IPAddress ipAddress, CancellationToken ca } // Failed - if (pingReply != null) - return new PingInfo(timestamp, ipAddress, pingReply.Status); + pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status); + + // Only try again if the request timed out (e.g. a lost packet). Other states like + // "destination host unreachable" are a definitive answer. + if (pingReply.Status != IPStatus.TimedOut) + break; } catch (PingException) { // Ping failed with unknown status return new PingInfo(timestamp, ipAddress, IPStatus.Unknown); } - - // Don't scan again, if the user has canceled (when more than 1 attempt) - if (cancellationToken.IsCancellationRequested) - break; } - // Fall back to unknown status - return new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown); + // Result of the last attempt (falls back to unknown status if there was no attempt) + return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown); } + /// + /// Probes all configured ports of a host. Returns every port (also closed / timed out ones), because + /// a closed port is used to detect a reachable host as well - the caller filters what is shown. + /// private async Task> PortScanAsync(IPAddress ipAddress, ParallelOptions parallelOptions, CancellationToken cancellationToken) { @@ -300,11 +428,11 @@ private async Task> PortScanAsync(IPAddress ipAddress, ParallelOp await Parallel.ForEachAsync(options.PortScanPorts, parallelOptions, async (port, ct) => { - var portState = await PortProbe.ProbeAsync(ipAddress, port, options.PortScanTimeout, ct) + var portState = await PortProbe.ProbeAsync(ipAddress, port, options.PortScanTimeout, + options.PortScanAttempts, ct) .ConfigureAwait(false); - if (portState == PortState.Open || options.ShowAllResults) - results.Add(new PortInfo(port, PortLookup.LookupByPortAndProtocol(port), portState)); + results.Add(new PortInfo(port, PortLookup.LookupByPortAndProtocol(port), portState)); }).ConfigureAwait(false); return results.ToList(); diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs index 545e8502aa..b7c4fdcb3a 100644 --- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs @@ -16,6 +16,7 @@ public class IPScannerOptions /// Scan the ports of the host. /// List of ports to scan. /// Timeout in milliseconds after which a port is considered closed. + /// Number of attempts to connect to a port if there was no answer. /// Resolve the hostname for an IP address. /// Resolve the NetBIOS name for an IP address. /// Timeout in milliseconds after which a NetBIOS request is considered lost. @@ -23,7 +24,7 @@ public class IPScannerOptions /// Include unreachable IP addresses in the result. public IPScannerOptions(int maxHostThreads, int maxPortThreads, int icmpAttempts, int icmpTimeout, byte[] icmpBuffer, bool resolveHostname, bool portScanEnabled, int[] portScanPorts, int portScanTimeout, - bool netBIOSEnabled, int netBIOSTimeout, bool resolveMACAddress, bool showAllResults) + int portScanAttempts, bool netBIOSEnabled, int netBIOSTimeout, bool resolveMACAddress, bool showAllResults) { MaxHostThreads = maxHostThreads; MaxPortThreads = maxPortThreads; @@ -34,6 +35,7 @@ public IPScannerOptions(int maxHostThreads, int maxPortThreads, int icmpAttempts PortScanEnabled = portScanEnabled; PortScanPorts = portScanPorts; PortScanTimeout = portScanTimeout; + PortScanAttempts = portScanAttempts; NetBIOSEnabled = netBIOSEnabled; NetBIOSTimeout = netBIOSTimeout; ResolveMACAddress = resolveMACAddress; @@ -85,6 +87,11 @@ public IPScannerOptions(int maxHostThreads, int maxPortThreads, int icmpAttempts /// public int PortScanTimeout { get; } + /// + /// Number of attempts to connect to a port if there was no answer (timed out). + /// + public int PortScanAttempts { get; } + /// /// Resolve the NetBIOS name for an IP address. /// diff --git a/Source/NETworkManager.Models/Network/IPv4Address.cs b/Source/NETworkManager.Models/Network/IPv4Address.cs index 77a2069c11..ef1c386d6b 100644 --- a/Source/NETworkManager.Models/Network/IPv4Address.cs +++ b/Source/NETworkManager.Models/Network/IPv4Address.cs @@ -65,6 +65,18 @@ public static int ToInt32(IPAddress ipAddress) return BitConverter.ToInt32(bytes, 0); } + /// + /// Method to convert an to its unsigned value (e.g. "192.168.0.1" -> 3232235521). + /// Use this to compare IPv4 addresses or to calculate ranges: the signed value of + /// is negative for 128.0.0.0 and above, so a range across it compares wrong. + /// + /// . + /// IP address as + public static uint ToUInt32(IPAddress ipAddress) + { + return unchecked((uint)ToInt32(ipAddress)); + } + /// /// Method to convert an to an . /// diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs index 7eef1a3bfa..32ccd9d0e0 100644 --- a/Source/NETworkManager.Models/Network/NeighborTable.cs +++ b/Source/NETworkManager.Models/Network/NeighborTable.cs @@ -312,13 +312,24 @@ private static Dictionary BuildInterfaceAliasMap() } /// - /// Returns the MAC address for by scanning the neighbor - /// cache, or when no entry exists. Supports both IPv4 and IPv6. + /// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to MAC address + /// (e.g. "001122AABBCC"). IPv6 addresses are stored without scope id (see + /// ), entries without a MAC address are skipped. + /// Reading the table once and looking up many addresses is much cheaper than reading it per address. /// - public static string GetMACAddress(IPAddress ipAddress) + public static Dictionary GetMACAddressMap() { - var entry = GetTable().FirstOrDefault(x => x.IPAddress.Equals(ipAddress)); - return entry?.MACAddress.ToString(); + var map = new Dictionary(); + + foreach (var entry in GetTable()) + { + var macAddress = entry.MACAddress.ToString(); + + if (!string.IsNullOrEmpty(macAddress)) + map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress; + } + + return map; } /// diff --git a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs index ca44faf100..8fdc11ba91 100644 --- a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs +++ b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs @@ -26,6 +26,7 @@ public static class NetBIOSResolver private const int ResponseBaseLen = 57; private const int ResponseNameLen = 15; private const int ResponseBlockLen = 18; + private const int MacAddressLen = 6; private const int GroupNameFlag = 128; @@ -43,34 +44,67 @@ public static class NetBIOSResolver 0x00, 0x01 }; + /// + /// Maximum size of a NetBIOS name status response (57 bytes header + 255 names x 18 bytes + statistics). + /// + private const int ResponseBufferSize = 8192; + public static async Task ResolveAsync(IPAddress ipAddress, int timeout, CancellationToken cancellationToken) { - var udpClient = new UdpClient(); + try + { + using var socket = new Socket(ipAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); - var remoteEndPoint = new IPEndPoint(ipAddress, NetBIOSUdpPort); + // Closing the socket on timeout or cancellation aborts a pending send / receive, it completes with + // OperationAborted. The results are reported as SocketError, no exception if the host doesn't + // answer (see SocketOperation). + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + await using var registration = linkedCts.Token.Register(socket.Dispose); - // Linked so the receive is genuinely canceled (not just abandoned) on either timeout or - // caller cancellation - avoids leaving a pending receive that later faults, unobserved, - // once the socket is closed in the finally block below. - using var timeoutCts = new CancellationTokenSource(timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + using var sendArgs = new SocketAsyncEventArgs(); + sendArgs.RemoteEndPoint = new IPEndPoint(ipAddress, NetBIOSUdpPort); + sendArgs.SetBuffer(RequestData); - try - { - await udpClient.SendAsync(RequestData, RequestData.Length, remoteEndPoint); + if (await SocketOperation.RunAsync(sendArgs, socket.SendToAsync).ConfigureAwait(false) != + SocketError.Success) + return new NetBIOSInfo(ipAddress); - var response = await udpClient.ReceiveAsync(linkedCts.Token).ConfigureAwait(false); + var buffer = new byte[ResponseBufferSize]; + byte[] response; - if (response.Buffer.Length < ResponseBaseLen || response.Buffer[ResponseTypePos] != ResponseTypeNbstat) + // Wait for the answer of the host - a datagram from another address (e.g. a stray or spoofed one) is + // ignored, until the timeout + while (true) + { + using var receiveArgs = new SocketAsyncEventArgs(); + receiveArgs.RemoteEndPoint = new IPEndPoint( + ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0); + receiveArgs.SetBuffer(buffer); + + // No answer (timeout), port unreachable, etc. + if (await SocketOperation.RunAsync(receiveArgs, socket.ReceiveFromAsync).ConfigureAwait(false) != + SocketError.Success) + return new NetBIOSInfo(ipAddress); + + if (receiveArgs.RemoteEndPoint is not IPEndPoint remoteEndPoint || + IPAddressHelper.CompareIPAddresses(remoteEndPoint.Address, ipAddress) != 0) + continue; + + response = buffer[..receiveArgs.BytesTransferred]; + break; + } + + if (response.Length < ResponseBaseLen || response[ResponseTypePos] != ResponseTypeNbstat) return new NetBIOSInfo(ipAddress); // response was too short - var count = response.Buffer[ResponseBaseLen - 1] & 0xFF; + var count = response[ResponseBaseLen - 1] & 0xFF; - if (response.Buffer.Length < ResponseBaseLen + ResponseBlockLen * count) + if (response.Length < ResponseBaseLen + ResponseBlockLen * count) return new NetBIOSInfo(ipAddress); // data was truncated or something is wrong - var result = ExtractNames(response.Buffer, count); + var result = ExtractNames(response, count); var vendor = string.Empty; @@ -89,7 +123,9 @@ public static async Task ResolveAsync(IPAddress ipAddress, int time result.ComputerName, result.UserName, result.GroupName, - MACAddressHelper.GetDefaultFormat(result.MacAddress), + string.IsNullOrEmpty(result.MacAddress) + ? string.Empty + : MACAddressHelper.GetDefaultFormat(result.MacAddress), vendor ); } @@ -97,10 +133,6 @@ public static async Task ResolveAsync(IPAddress ipAddress, int time { return new NetBIOSInfo(ipAddress); } - finally - { - udpClient.Close(); - } } private static (string ComputerName, string UserName, string GroupName, string MacAddress) ExtractNames( @@ -135,11 +167,12 @@ private static (string ComputerName, string UserName, string GroupName, string M break; } - // MAC address - var macAddress = - $"{GetByte(response, count, 0):X2}{GetByte(response, count, 1):X2}" + - $"{GetByte(response, count, 2):X2}{GetByte(response, count, 3):X2}" + - $"{GetByte(response, count, 4):X2}{GetByte(response, count, 5):X2}"; + // MAC address (first 6 bytes after the names, only if the response contains them) + var macAddress = response.Length >= ResponseBaseLen + ResponseBlockLen * count + MacAddressLen + ? $"{GetByte(response, count, 0):X2}{GetByte(response, count, 1):X2}" + + $"{GetByte(response, count, 2):X2}{GetByte(response, count, 3):X2}" + + $"{GetByte(response, count, 4):X2}{GetByte(response, count, 5):X2}" + : string.Empty; return (computerName, userName, groupName, macAddress); } diff --git a/Source/NETworkManager.Models/Network/PortProbe.cs b/Source/NETworkManager.Models/Network/PortProbe.cs index 911192d1c6..4a341b87c6 100644 --- a/Source/NETworkManager.Models/Network/PortProbe.cs +++ b/Source/NETworkManager.Models/Network/PortProbe.cs @@ -1,3 +1,4 @@ +using log4net; using System; using System.Net; using System.Net.Sockets; @@ -12,6 +13,24 @@ namespace NETworkManager.Models.Network; /// internal static class PortProbe { + private static readonly ILog Log = LogManager.GetLogger(typeof(PortProbe)); + + /// + /// Set once the failure of was logged (it fails for every probe). + /// + private static int _disableSynRetransmissionsFailureLogged; + + /// + /// SIO_TCP_INITIAL_RTO: sets the initial retransmission timeout and the number of SYN + /// retransmissions of a socket (Windows 10 1703 and later). + /// + private const int SIO_TCP_INITIAL_RTO = unchecked((int)0x98000011); + + /// + /// TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS: don't retransmit the connection request (SYN). + /// + private const byte TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS = 0xFE; + /// /// Attempts a TCP connect to the given and and /// classifies the result. Never throws for expected connect failures. @@ -19,41 +38,124 @@ internal static class PortProbe /// IP address to connect to. /// Port to connect to. /// Timeout in milliseconds after which the port is considered timed out. + /// + /// Number of attempts. Another connection request is only sent if there was no answer (timed out) - the + /// connection request is not retransmitted by Windows (see ), so this + /// is the retry for a lost request on an unreliable network. + /// /// Token to monitor for cancellation requests. - /// The of the probed port. - public static async Task ProbeAsync(IPAddress ipAddress, int port, int timeoutMs, + /// + /// if the connect succeeded, if the host + /// refused the connection (TCP reset - the host is reachable) and if + /// there was no answer (timeout, host or network unreachable, local socket error). + /// + public static async Task ProbeAsync(IPAddress ipAddress, int port, int timeoutMs, int attempts, CancellationToken cancellationToken) { - using var tcpClient = new TcpClient(ipAddress.AddressFamily); - using var timeoutCts = new CancellationTokenSource(timeoutMs); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var portState = PortState.TimedOut; - try + for (var i = 0; i < Math.Max(attempts, 1); i++) { - await tcpClient.ConnectAsync(ipAddress, port, linkedCts.Token).ConfigureAwait(false); + portState = await ProbeOnceAsync(ipAddress, port, timeoutMs, cancellationToken).ConfigureAwait(false); - return tcpClient.Connected ? PortState.Open : PortState.Closed; + // Open or closed is a definitive answer + if (portState != PortState.TimedOut) + break; } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + + return portState; + } + + /// + /// Sends one connection request, see . + /// + private static async Task ProbeOnceAsync(IPAddress ipAddress, int port, int timeoutMs, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try { - // linkedCts was canceled, but not by the caller -> our own timeout fired - return PortState.TimedOut; + using var socket = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + // Close with a reset instead of a graceful shutdown. Otherwise every open port leaves a socket in + // TIME_WAIT for minutes, which can use up all local (ephemeral) ports on large scans. + socket.LingerState = new LingerOption(true, 0); + + // Windows retries the connection request after a reset like after a lost request, so a closed port + // is only reported after ~2 s (measured) - as long as the default timeout, so it was shown as timed + // out. Without SYN retransmissions a reset is reported immediately; the request is sent once and the + // answer is awaited until the timeout. + if (OperatingSystem.IsWindows()) + DisableSynRetransmissions(socket, timeoutMs); + + using var connectArgs = new SocketAsyncEventArgs(); + connectArgs.RemoteEndPoint = new IPEndPoint(ipAddress, port); + + using var timeoutCts = new CancellationTokenSource(timeoutMs); + using var linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + // The result is reported as SocketError, no exception for a refused connection or a timeout + // (see SocketOperation) + var connectTask = SocketOperation.RunAsync(connectArgs, socket.ConnectAsync); + + SocketError socketError; + + // Timeout or cancellation -> abort the connect, it completes with OperationAborted + await using (linkedCts.Token.Register(() => Socket.CancelConnectAsync(connectArgs))) + { + socketError = await connectTask.ConfigureAwait(false); + } + + // Caller's token is why this was aborted -> propagate as a real cancellation + cancellationToken.ThrowIfCancellationRequested(); + + return socketError switch + { + SocketError.Success => PortState.Open, + // The host answered with a reset -> port is closed (and the host is reachable) + SocketError.ConnectionRefused => PortState.Closed, + // Timeout (aborted), host/network unreachable, local socket error, etc. -> no answer from the host + _ => PortState.TimedOut + }; } catch (OperationCanceledException) { - // Caller's token is why this was canceled -> propagate as a real cancellation throw; } catch (Exception) { - // Any other connect failure (refused, unreachable, etc.) -> always Closed, regardless - // of whether cancellation also happens to be in flight concurrently for an unrelated - // reason (that used to race against this classification via a token-state check here). - return PortState.Closed; + // Unexpected local error (e.g. no free local port to bind) -> no answer from the host + return PortState.TimedOut; + } + } + + /// + /// Disables the SYN retransmissions of the socket and sets the initial retransmission timeout to + /// , so the connection request is sent once and Windows waits for the answer + /// as long as the probe does. Ignored if not supported. + /// + /// Socket that is not connected yet. + /// Timeout of the probe in milliseconds. + private static void DisableSynRetransmissions(Socket socket, int timeoutMs) + { + // TCP_INITIAL_RTO_PARAMETERS { USHORT Rtt; UCHAR MaxSynRetransmissions; } - 0xFFFF = unspecified Rtt + var rtt = (ushort)Math.Clamp(timeoutMs, 1, ushort.MaxValue - 1); + + byte[] parameters = [(byte)rtt, (byte)(rtt >> 8), TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS, 0]; + + try + { + socket.IOControl(SIO_TCP_INITIAL_RTO, parameters, null); } - finally + catch (Exception ex) { - tcpClient.Close(); + // Should not happen (supported since Windows 10 1703) - the Windows default is used: closed ports are + // only reported after ~2 s (as timed out with a timeout of 2000 ms or less) + if (Interlocked.Exchange(ref _disableSynRetransmissionsFailureLogged, 1) == 0) + Log.Warn("Could not disable the SYN retransmissions (SIO_TCP_INITIAL_RTO), " + + "closed ports are reported after ~2 s.", ex); } } } diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 64b87762bf..2716b0e342 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -21,6 +22,15 @@ public PortScanner(PortScannerOptions options) #region Variables + /// + /// Time between the start of two port probes (of all hosts) during the ramp-up, see + /// . Smaller than in the IP Scanner, because there are more concurrent probes: + /// with the default settings the first 4 hosts x 128 ports are started within ~0.5 s instead of at the same + /// moment, the following ones when a slot is free. Shorter if a probe takes less than 512 x 1 ms + /// (see ). + /// + private static readonly TimeSpan MaxPortStartInterval = TimeSpan.FromMilliseconds(1); + private int _progressValue; private readonly InterlockedCounter _portsOpen = new(); private readonly InterlockedCounter _portsClosed = new(); @@ -50,11 +60,15 @@ private void OnPortScanned(PortScannerPortScannedArgs e) PortScanned?.Invoke(this, e); } - public event EventHandler ScanComplete; + /// + /// Occurs when the scan is complete. Raised exactly once per scan, also when the scan was canceled + /// or failed (see ). + /// + public event EventHandler ScanComplete; - private void OnScanComplete() + private void OnScanComplete(ScanCompleteArgs e) { - ScanComplete?.Invoke(this, EventArgs.Empty); + ScanComplete?.Invoke(this, e); } public event EventHandler ProgressChanged; @@ -64,13 +78,6 @@ private void OnProgressChanged() ProgressChanged?.Invoke(this, new ProgressChangedArgs(_progressValue)); } - public event EventHandler UserHasCanceled; - - private void OnUserHasCanceled() - { - UserHasCanceled?.Invoke(this, EventArgs.Empty); - } - #endregion #region Methods @@ -80,8 +87,13 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, { _progressValue = 0; + // The token is not passed to Task.Run on purpose: with an already canceled token the delegate + // would never run and ScanComplete would never be raised. Task.Run(async () => { + var isCanceled = false; + Exception exception = null; + try { var hostParallelOptions = new ParallelOptions @@ -96,26 +108,39 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, MaxDegreeOfParallelism = _options.MaxPortThreads }; - await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => - { - // Resolve Hostname (PTR) - var hostname = string.Empty; + // Spread the start of the port probes over time instead of starting all at once + var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount) + ? Math.Min(hostCount, _options.MaxHostThreads) + : _options.MaxHostThreads; - if (_options.ResolveHostname) - { - var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(host.ipAddress) - .WaitAsync(hostCt).ConfigureAwait(false); + var portConcurrency = hostConcurrency * (ports.TryGetNonEnumeratedCount(out var portCount) + ? Math.Min(portCount, _options.MaxPortThreads) + : _options.MaxPortThreads); + + var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval, + TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency); - if (!dnsResult.HasError) - hostname = dnsResult.Value; - } + await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => + { + // Resolve Hostname (PTR) while the ports are scanned + var hostnameTask = _options.ResolveHostname + ? ResolveHostnameAsync(host.ipAddress, hostCt) + : Task.FromResult(string.Empty); // Check each port await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => { - var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout, portCt) + // Spread the start of the port probes over time instead of starting all at once + await portStartScheduler.WaitAsync(portCt).ConfigureAwait(false); + + var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout, + _options.Attempts, portCt) .ConfigureAwait(false); + // Every result needs the hostname (results are grouped by it) - usually resolved + // long before the first port is finished + var hostname = await hostnameTask.ConfigureAwait(false); + // Count open/closed ports unconditionally, since ShowAllResults (below) // may prevent closed ports from ever reaching PortScanned if (portState == PortState.Open) @@ -132,15 +157,27 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => }).ConfigureAwait(false); }).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - OnUserHasCanceled(); + isCanceled = true; + } + catch (Exception ex) + { + exception = ex; } finally { - OnScanComplete(); + OnScanComplete(new ScanCompleteArgs(isCanceled, exception)); } - }, cancellationToken); + }); + } + + private static async Task ResolveHostnameAsync(IPAddress ipAddress, CancellationToken cancellationToken) + { + var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress) + .WaitAsync(cancellationToken).ConfigureAwait(false); + + return dnsResult.HasError ? string.Empty : dnsResult.Value; } private void IncreaseProgress() diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs index 2bbca684c2..ef32159425 100644 --- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs +++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs @@ -11,14 +11,16 @@ public class PortScannerOptions /// Max cuncurrent threads for the host scan. /// Max cuncurrent threads for the port scan of each host. /// Timeout in ms after which the port is considered closed. + /// Number of attempts to connect to a port if there was no answer. /// Resolve the hostname for an IP address. /// Include closed ports in the result. - public PortScannerOptions(int maxHostThreads, int maxPortThreads, int timeout, bool resolveHostname, - bool showAllResults) + public PortScannerOptions(int maxHostThreads, int maxPortThreads, int timeout, int attempts, + bool resolveHostname, bool showAllResults) { MaxHostThreads = maxHostThreads; MaxPortThreads = maxPortThreads; Timeout = timeout; + Attempts = attempts; ResolveHostname = resolveHostname; ShowAllResults = showAllResults; } @@ -38,6 +40,11 @@ public PortScannerOptions(int maxHostThreads, int maxPortThreads, int timeout, b /// public int Timeout { get; set; } + /// + /// Number of attempts to connect to a port if there was no answer (timed out). + /// + public int Attempts { get; set; } + /// /// Resolve the hostname for an IP address. /// diff --git a/Source/NETworkManager.Models/Network/ScanCompleteArgs.cs b/Source/NETworkManager.Models/Network/ScanCompleteArgs.cs new file mode 100644 index 0000000000..74d83bd15f --- /dev/null +++ b/Source/NETworkManager.Models/Network/ScanCompleteArgs.cs @@ -0,0 +1,22 @@ +using System; + +namespace NETworkManager.Models.Network; + +/// +/// Event arguments of the ScanComplete event of and . +/// The event is raised exactly once per scan, also when the scan was canceled or failed. +/// +/// True if the scan was canceled by the user. +/// The exception that stopped the scan, or . +public class ScanCompleteArgs(bool isCanceled, Exception exception = null) : EventArgs +{ + /// + /// Gets a value indicating whether the scan was canceled by the user. + /// + public bool IsCanceled { get; } = isCanceled; + + /// + /// Gets the exception that stopped the scan, or if the scan completed or was canceled. + /// + public Exception Exception { get; } = exception; +} diff --git a/Source/NETworkManager.Models/Network/SocketOperation.cs b/Source/NETworkManager.Models/Network/SocketOperation.cs new file mode 100644 index 0000000000..36982365b9 --- /dev/null +++ b/Source/NETworkManager.Models/Network/SocketOperation.cs @@ -0,0 +1,39 @@ +using System; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace NETworkManager.Models.Network; + +/// +/// Runs socket operations with the pattern. Unlike the Task based socket +/// methods (e.g. ), these report the result as +/// instead of throwing an exception for every refused connection, timeout or +/// unreachable host. A scan has thousands of these, and every exception is slow with a debugger attached +/// (the whole application lags). Used by and . +/// +internal static class SocketOperation +{ + /// + /// Starts the operation and returns a task that completes with its . + /// A pending operation is aborted with by closing the socket + /// (or for a connect). + /// + /// Arguments of the operation (remote end point, buffer). Used for one operation only. + /// + /// Method that starts the operation, e.g. . Returns + /// false if the operation completed synchronously. + /// + /// Result of the operation, if it succeeded. + public static Task RunAsync(SocketAsyncEventArgs e, Func start) + { + var taskCompletionSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + e.Completed += (_, args) => taskCompletionSource.TrySetResult(args.SocketError); + + if (!start(e)) + taskCompletionSource.TrySetResult(e.SocketError); + + return taskCompletionSource.Task; + } +} diff --git a/Source/NETworkManager.Models/Network/StartScheduler.cs b/Source/NETworkManager.Models/Network/StartScheduler.cs new file mode 100644 index 0000000000..3cb5ff8fde --- /dev/null +++ b/Source/NETworkManager.Models/Network/StartScheduler.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace NETworkManager.Models.Network; + +/// +/// Spreads the start of concurrent operations over time (at most one start per interval) instead of starting +/// them all at the same moment. Used by and : without it, all +/// hosts / ports of a "wave" start (and, because of the same timeouts, also finish) at once. That sends hundreds of +/// connection requests within a few milliseconds and makes the progress jump in large steps. +/// +/// +/// Only the ramp-up is spread: the first count starts (the number of concurrent operations), where every +/// caller reserves the next free start time (one interval after the previous one) and only waits until then, so +/// the waits don't add up. After that, all slots are busy and an operation only starts when another one has +/// finished. These starts are spread like the ramp-up (the operations started at different times) and are not +/// delayed, so fast operations (e.g. a refused connection) are not limited to one per interval. +/// fires on the system timer (~15.6 ms on Windows), so an +/// interval below that starts a few operations per timer tick instead of exactly one per interval. +/// +/// Minimum time between two starts during the ramp-up. +/// Number of starts that are spread (the number of concurrent operations). +internal sealed class StartScheduler(TimeSpan interval, int count) +{ + /// + /// Gets the interval between two starts: , but at most + /// / , so the ramp-up is never longer than + /// one operation (e.g. with short timeouts and many threads). + /// + /// Interval that should be used, if the ramp-up isn't longer than one operation. + /// Maximum duration of one operation (e.g. the timeout). + /// Maximum number of concurrent operations. + /// Interval between two starts. + public static TimeSpan GetInterval(TimeSpan maxInterval, TimeSpan duration, int concurrency) + { + return concurrency <= 0 + ? maxInterval + : TimeSpan.FromTicks(Math.Min(maxInterval.Ticks, duration.Ticks / concurrency)); + } + + private readonly Lock _lock = new(); + private readonly long _intervalTicks = (long)(interval.TotalSeconds * Stopwatch.Frequency); + private long _nextStartTimestamp; + private int _starts; + + /// + /// Waits until the next free start time (only during the ramp-up, see ). + /// + /// Token to monitor for cancellation requests. + public Task WaitAsync(CancellationToken cancellationToken) + { + if (_intervalTicks <= 0) + return Task.CompletedTask; + + var now = Stopwatch.GetTimestamp(); + long start; + + lock (_lock) + { + // Ramp-up is done - this operation starts because another one has finished + if (_starts >= count) + return Task.CompletedTask; + + _starts++; + + start = Math.Max(now, _nextStartTimestamp); + _nextStartTimestamp = start + _intervalTicks; + } + + var delay = Stopwatch.GetElapsedTime(now, start); + + return delay > TimeSpan.Zero ? Task.Delay(delay, cancellationToken) : Task.CompletedTask; + } +} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index ee187255ba..94759bce27 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -138,23 +138,32 @@ public static class GlobalStaticConfiguration // Application: IP Scanner public static int IPScanner_ICMPAttempts => 2; - public static int IPScanner_ICMPTimeout => 4000; + public static int IPScanner_ICMPTimeout => 2000; public static int IPScanner_ICMPBuffer => 32; public static bool IPScanner_ResolveHostname => true; public static bool IPScanner_PortScanEnabled => true; public static string IPScanner_PortScanPorts => "22; 53; 80; 135; 139; 389; 636; 443; 445; 3389; 9100"; - public static int IPScanner_PortScanTimeout => 4000; - public static int IPScanner_MaxHostThreads => 64; - public static int IPScanner_MaxPortThreads => 4; + public static int IPScanner_PortScanTimeout => 2000; + public static int IPScanner_PortScanAttempts => 1; + public static int IPScanner_MaxHostThreads => 128; + public static int IPScanner_MaxPortThreads => 16; public static bool IPScanner_NetBIOSEnabled => true; - public static int IPScanner_NetBIOSTimeout => 4000; + public static int IPScanner_NetBIOSTimeout => 2000; + public static bool IPScanner_ResolveMACAddress => true; public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; + // Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory) + public static long IPScanner_ConfirmHostCount => 65536; + public static long IPScanner_MaxHostCount => 16777216; // Application: Port Scanner public static int PortScanner_MaxHostThreads => 4; - public static int PortScanner_MaxPortThreads => 64; - public static int PortScanner_Timeout => 4000; + public static int PortScanner_MaxPortThreads => 128; + public static int PortScanner_Timeout => 2000; + public static int PortScanner_Attempts => 1; public static ExportFileType PortScanner_ExportFileType => ExportFileType.Csv; + // Larger scans (hosts x ports) need a confirmation, even larger ones are refused + public static long PortScanner_ConfirmPortCount => 65536; + public static long PortScanner_MaxPortCount => 16777216; // Application: Ping Monitor public static int PingMonitor_Buffer => 32; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 46bff0369c..97891fd233 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1312,6 +1312,19 @@ public int IPScanner_PortScanTimeout } } = GlobalStaticConfiguration.IPScanner_PortScanTimeout; + public int IPScanner_PortScanAttempts + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.IPScanner_PortScanAttempts; + public bool IPScanner_NetBIOSEnabled { get; @@ -1349,7 +1362,7 @@ public bool IPScanner_ResolveMACAddress field = value; OnPropertyChanged(); } - } + } = GlobalStaticConfiguration.IPScanner_ResolveMACAddress; public ObservableCollection IPScanner_CustomCommands { @@ -1502,6 +1515,19 @@ public int PortScanner_Timeout } } = GlobalStaticConfiguration.PortScanner_Timeout; + public int PortScanner_Attempts + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.PortScanner_Attempts; + public int PortScanner_MaxHostThreads { get; diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 642a76c62a..f7a5395208 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -758,32 +758,62 @@ private static void UpgradeToLatest(Version version) defaultList.First(x => x.Name == ApplicationName.RDAP)); } - // IP Scanner / Port Scanner - lower the default concurrency (see changelog for why). - // Only applied if still at the old default, so a deliberately customized value is left alone. - Log.Info("Lower IP Scanner / Port Scanner default concurrency, if still unchanged from the old default..."); + // IP Scanner / Port Scanner - new default concurrency and timeouts (see changelog for why). + // Only applied if still at an old default (of the last release or of a pre-release of this + // release), so a deliberately customized value is left alone. + Log.Info("Update IP Scanner / Port Scanner default concurrency and timeouts, if still unchanged from an old default..."); - if (Current.IPScanner_MaxHostThreads == 256) + if (Current.IPScanner_MaxHostThreads is 256 or 64) { - Log.Info("Update \"IPScanner_MaxHostThreads\" from 256 to 64..."); - Current.IPScanner_MaxHostThreads = 64; + Log.Info($"Update \"IPScanner_MaxHostThreads\" from {Current.IPScanner_MaxHostThreads} to {GlobalStaticConfiguration.IPScanner_MaxHostThreads}..."); + Current.IPScanner_MaxHostThreads = GlobalStaticConfiguration.IPScanner_MaxHostThreads; + } + else if (Current.IPScanner_MaxHostThreads > 256) + { + Log.Info($"Update \"IPScanner_MaxHostThreads\" from {Current.IPScanner_MaxHostThreads} to the new maximum 256..."); + Current.IPScanner_MaxHostThreads = 256; } - if (Current.IPScanner_MaxPortThreads == 5) + if (Current.IPScanner_MaxPortThreads is 5 or 4) { - Log.Info("Update \"IPScanner_MaxPortThreads\" from 5 to 4..."); - Current.IPScanner_MaxPortThreads = 4; + Log.Info($"Update \"IPScanner_MaxPortThreads\" from {Current.IPScanner_MaxPortThreads} to {GlobalStaticConfiguration.IPScanner_MaxPortThreads}..."); + Current.IPScanner_MaxPortThreads = GlobalStaticConfiguration.IPScanner_MaxPortThreads; } if (Current.PortScanner_MaxHostThreads == 5) { - Log.Info("Update \"PortScanner_MaxHostThreads\" from 5 to 4..."); - Current.PortScanner_MaxHostThreads = 4; + Log.Info($"Update \"PortScanner_MaxHostThreads\" from 5 to {GlobalStaticConfiguration.PortScanner_MaxHostThreads}..."); + Current.PortScanner_MaxHostThreads = GlobalStaticConfiguration.PortScanner_MaxHostThreads; + } + + if (Current.PortScanner_MaxPortThreads is 256 or 64) + { + Log.Info($"Update \"PortScanner_MaxPortThreads\" from {Current.PortScanner_MaxPortThreads} to {GlobalStaticConfiguration.PortScanner_MaxPortThreads}..."); + Current.PortScanner_MaxPortThreads = GlobalStaticConfiguration.PortScanner_MaxPortThreads; + } + + if (Current.IPScanner_ICMPTimeout == 4000) + { + Log.Info($"Update \"IPScanner_ICMPTimeout\" from 4000 to {GlobalStaticConfiguration.IPScanner_ICMPTimeout}..."); + Current.IPScanner_ICMPTimeout = GlobalStaticConfiguration.IPScanner_ICMPTimeout; + } + + if (Current.IPScanner_PortScanTimeout == 4000) + { + Log.Info($"Update \"IPScanner_PortScanTimeout\" from 4000 to {GlobalStaticConfiguration.IPScanner_PortScanTimeout}..."); + Current.IPScanner_PortScanTimeout = GlobalStaticConfiguration.IPScanner_PortScanTimeout; } - if (Current.PortScanner_MaxPortThreads == 256) + if (Current.IPScanner_NetBIOSTimeout == 4000) { - Log.Info("Update \"PortScanner_MaxPortThreads\" from 256 to 64..."); - Current.PortScanner_MaxPortThreads = 64; + Log.Info($"Update \"IPScanner_NetBIOSTimeout\" from 4000 to {GlobalStaticConfiguration.IPScanner_NetBIOSTimeout}..."); + Current.IPScanner_NetBIOSTimeout = GlobalStaticConfiguration.IPScanner_NetBIOSTimeout; + } + + if (Current.PortScanner_Timeout == 4000) + { + Log.Info($"Update \"PortScanner_Timeout\" from 4000 to {GlobalStaticConfiguration.PortScanner_Timeout}..."); + Current.PortScanner_Timeout = GlobalStaticConfiguration.PortScanner_Timeout; } if (Current.IPScanner_PortScanPorts == "22; 53; 80; 139; 389; 636; 443; 445; 3389") @@ -792,6 +822,25 @@ private static void UpgradeToLatest(Version version) Current.IPScanner_PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts; } + // ICMP buffer - .NET's Ping throws for more than 65500 bytes, the settings allowed up to 65535 + if (Current.IPScanner_ICMPBuffer > 65500) + { + Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to 65500..."); + Current.IPScanner_ICMPBuffer = 65500; + } + + if (Current.PingMonitor_Buffer > 65500) + { + Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to 65500..."); + Current.PingMonitor_Buffer = 65500; + } + + if (Current.Traceroute_Buffer > 65500) + { + Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to 65500..."); + Current.Traceroute_Buffer = 65500; + } + // Add new Port Scanner port profiles foreach (var portProfile in PortProfile.GetDefaultList()) { diff --git a/Source/NETworkManager.Utilities/ByteHelper.cs b/Source/NETworkManager.Utilities/ByteHelper.cs index eeff6e044a..91f215cacd 100644 --- a/Source/NETworkManager.Utilities/ByteHelper.cs +++ b/Source/NETworkManager.Utilities/ByteHelper.cs @@ -11,6 +11,17 @@ public static class ByteHelper /// Byte array to compare. /// 0 if the byte arrays are equal, otherwise a negative or positive value. public static int Compare(byte[] x, byte[] y) + { + return Compare((ReadOnlySpan)x, y); + } + + /// + /// Compare two byte spans. + /// + /// Bytes to compare. + /// Bytes to compare. + /// 0 if the bytes are equal, otherwise a negative or positive value. + public static int Compare(ReadOnlySpan x, ReadOnlySpan y) { for (var i = 0; i < Math.Min(x.Length, y.Length); i++) if (x[i] != y[i]) diff --git a/Source/NETworkManager.Utilities/IPAddressHelper.cs b/Source/NETworkManager.Utilities/IPAddressHelper.cs index 0a7a1255de..988289f0da 100644 --- a/Source/NETworkManager.Utilities/IPAddressHelper.cs +++ b/Source/NETworkManager.Utilities/IPAddressHelper.cs @@ -1,4 +1,6 @@ -using System.Net; +using System; +using System.Net; +using System.Net.Sockets; namespace NETworkManager.Utilities; @@ -27,6 +29,19 @@ public static bool IsPrivateIPAddress(IPAddress ipAddress) } } + /// + /// Returns the IP address without the IPv6 scope id (e.g. "fe80::1%12" -> "fe80::1"), so it can be compared + /// with (or used as key for) an address that was entered without one. IPv4 addresses are returned as is. + /// + /// IP address. + /// IP address without scope id. + public static IPAddress WithoutScopeId(IPAddress ipAddress) + { + return ipAddress.AddressFamily == AddressFamily.InterNetworkV6 && ipAddress.ScopeId != 0 + ? new IPAddress(ipAddress.GetAddressBytes()) + : ipAddress; + } + /// /// Compare two IP addresses. /// @@ -35,6 +50,14 @@ public static bool IsPrivateIPAddress(IPAddress ipAddress) /// 0 if the IP addresses are equal, otherwise a negative or positive value. public static int CompareIPAddresses(IPAddress x, IPAddress y) { - return ByteHelper.Compare(x.GetAddressBytes(), y.GetAddressBytes()); + // Write the bytes to the stack instead of allocating two arrays per call (GetAddressBytes), + // this is called very often when sorting large lists (e.g. the hosts of an IP Scanner range) + Span xBytes = stackalloc byte[16]; + Span yBytes = stackalloc byte[16]; + + x.TryWriteBytes(xBytes, out var xLength); + y.TryWriteBytes(yBytes, out var yLength); + + return ByteHelper.Compare(xBytes[..xLength], yBytes[..yLength]); } } \ No newline at end of file diff --git a/Source/NETworkManager.Utilities/PortRangeHelper.cs b/Source/NETworkManager.Utilities/PortRangeHelper.cs index af7be3f295..f53bf0de43 100644 --- a/Source/NETworkManager.Utilities/PortRangeHelper.cs +++ b/Source/NETworkManager.Utilities/PortRangeHelper.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace NETworkManager.Utilities; @@ -31,7 +32,8 @@ public static Task ConvertPortRangeToIntArrayAsync(string portRange) return Task.Run(() => ConvertPortRangeToIntArray(portRange)); } - // Generate from a string like "9; 11-13; 15" an list with "9,11,12,13,15" + // Generate from a string like "9; 11-13; 15" an list with "9,11,12,13,15". Ports entered multiple times + // (e.g. "80; 1-100") are only added once (first occurrence, order is kept) public static int[] ConvertPortRangeToIntArray(string portRange) { var list = new List(); @@ -48,6 +50,6 @@ public static int[] ConvertPortRangeToIntArray(string portRange) list.Add(int.Parse(portOrRange)); } - return list.ToArray(); + return list.Distinct().ToArray(); } } \ No newline at end of file diff --git a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs index 7f79762569..2615fbb17c 100644 --- a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs +++ b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs @@ -41,8 +41,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) var shortRange = ipHostOrRange.Split('-'); var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; - if (IPv4Address.ToInt32(IPAddress.Parse(shortRange[0])) > - IPv4Address.ToInt32(IPAddress.Parse($"{shortBase}.{shortRange[1]}"))) + if (ToUInt32(shortRange[0]) > ToUInt32($"{shortBase}.{shortRange[1]}")) isValid = false; continue; @@ -53,7 +52,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) { var range = ipHostOrRange.Split('-'); - if (IPv4Address.ToInt32(IPAddress.Parse(range[0])) > IPv4Address.ToInt32(IPAddress.Parse(range[1]))) + if (ToUInt32(range[0]) > ToUInt32(range[1])) isValid = false; continue; @@ -109,4 +108,12 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) ? ValidationResult.ValidResult : new ValidationResult(false, Strings.EnterValidIPScanRange); } + + /// + /// Converts an IPv4 address to its unsigned value, so ranges across 128.0.0.0 compare correctly. + /// + private static uint ToUInt32(string ipAddress) + { + return IPv4Address.ToUInt32(IPAddress.Parse(ipAddress)); + } } \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index b41389cd73..a29f11ece2 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -3,6 +3,7 @@ using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; +using System; using System.ComponentModel; using System.Threading.Tasks; using System.Windows; @@ -175,6 +176,25 @@ public int PortScanTimeout } } + /// + /// Gets or sets the number of attempts to connect to a port if there was no answer. + /// + public int PortScanAttempts + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.IPScanner_PortScanAttempts = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether NetBIOS is enabled. /// @@ -324,6 +344,7 @@ private void LoadSettings() PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled; PortScanPorts = SettingsManager.Current.IPScanner_PortScanPorts; PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout; + PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts; NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled; NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout; ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress; @@ -351,7 +372,16 @@ private void AddCustomCommandAction() /// /// Gets the command to edit a custom command. /// - public ICommand EditCustomCommandCommand => new RelayCommand(_ => EditCustomCommandAction()); + public ICommand EditCustomCommandCommand => + new RelayCommand(_ => EditCustomCommandAction(), CustomCommandSelected_CanExecute); + + /// + /// Checks if a custom command is selected (edit and delete need one, e.g. F2 / Del without a selection). + /// + private bool CustomCommandSelected_CanExecute(object parameter) + { + return SelectedCustomCommand != null; + } /// /// Action to edit a custom command. @@ -364,7 +394,8 @@ private void EditCustomCommandAction() /// /// Gets the command to delete a custom command. /// - public ICommand DeleteCustomCommandCommand => new RelayCommand(_ => DeleteCustomCommandAction()); + public ICommand DeleteCustomCommandCommand => + new RelayCommand(_ => DeleteCustomCommandAction(), CustomCommandSelected_CanExecute); /// /// Action to delete a custom command. @@ -449,7 +480,8 @@ private async Task DeleteCustomCommand() { var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow, Strings.DeleteCustomCommand, - Strings.DeleteCustomCommandMessage, + Strings.DeleteCustomCommandMessage + Environment.NewLine + Environment.NewLine + + DialogHelper.FormatItemList([SelectedCustomCommand.Name]), ChildWindowIcon.Info, Strings.Delete); diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index ca92e67b44..c36ae8b305 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -499,6 +499,48 @@ private void ExportAction() /// private async Task Start() { + // Every IP address is kept in memory - ask before a large scan and refuse a too large one + // (e.g. 10.0.0.0/1) instead of running out of memory + long hostCount; + + try + { + hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host)); + } + catch (Exception ex) + { + Log.Error("Error while counting the hosts", ex); + + StatusMessage = ex.Message; + IsStatusMessageDisplayed = true; + + return; + } + + if (hostCount > GlobalStaticConfiguration.IPScanner_MaxHostCount) + { + StatusMessage = string.Format(Strings.ScanTooManyIPAddressesMessage, hostCount.ToString("N0"), + GlobalStaticConfiguration.IPScanner_MaxHostCount.ToString("N0")); + IsStatusMessageDisplayed = true; + + return; + } + + if (hostCount > GlobalStaticConfiguration.IPScanner_ConfirmHostCount) + { + var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive) ?? + Application.Current.MainWindow; + + if (!await DialogHelper.ShowConfirmationMessageAsync(window, Strings.Scan, + string.Format(Strings.ScanManyIPAddressesMessage, hostCount.ToString("N0")), + ChildWindowIcon.Warn, Strings.Scan)) + return; + + // The tab may have been closed while the dialog was open (OnClose only stops a running scan) + if (_closed) + return; + } + IsStatusMessageDisplayed = false; IsRunning = true; PreparingScan = true; @@ -528,17 +570,27 @@ private async Task Start() _cancellationTokenSource?.Dispose(); _cancellationTokenSource = new CancellationTokenSource(); - // Resolve hostnames + // Resolve hostnames and convert the ports. Start() is fire-and-forget, so every exception has to be + // handled here - otherwise the scan would never end (IsRunning stays true). (List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved) hosts; + int[] ports; try { hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host), SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token); + + ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync( + SettingsManager.Current.IPScanner_PortScanPorts); } catch (OperationCanceledException) { - UserHasCanceled(this, EventArgs.Empty); + ScanComplete(this, new ScanCompleteArgs(true)); + return; + } + catch (Exception ex) + { + ScanComplete(this, new ScanCompleteArgs(false, ex)); return; } @@ -566,8 +618,9 @@ private async Task Start() new byte[SettingsManager.Current.IPScanner_ICMPBuffer], SettingsManager.Current.IPScanner_ResolveHostname, SettingsManager.Current.IPScanner_PortScanEnabled, - await PortRangeHelper.ConvertPortRangeToIntArrayAsync(SettingsManager.Current.IPScanner_PortScanPorts), + ports, SettingsManager.Current.IPScanner_PortScanTimeout, + SettingsManager.Current.IPScanner_PortScanAttempts, SettingsManager.Current.IPScanner_NetBIOSEnabled, SettingsManager.Current.IPScanner_NetBIOSTimeout, SettingsManager.Current.IPScanner_ResolveMACAddress, @@ -577,7 +630,6 @@ await PortRangeHelper.ConvertPortRangeToIntArrayAsync(SettingsManager.Current.IP ipScanner.HostScanned += HostScanned; ipScanner.ScanComplete += ScanComplete; ipScanner.ProgressChanged += ProgressChanged; - ipScanner.UserHasCanceled += UserHasCanceled; _resultsFlushTimer = new DispatcherTimer(DispatcherPriority.Background) { @@ -612,52 +664,65 @@ private async Task DetectSubnet() { IsSubnetDetectionRunning = true; - // Try to detect local IP address based on routing to public IP - var localIP = await NetworkInterface.DetectLocalIPAddressBasedOnRoutingAsync(IPAddress.Parse(GlobalStaticConfiguration.Dashboard_PublicIPv4Address)); - - // Fallback: Try to detect local IP address from network interfaces -> Prefer non link-local addresses - localIP ??= await NetworkInterface.DetectLocalIPAddressFromNetworkInterfaceAsync(System.Net.Sockets.AddressFamily.InterNetwork); - - // If local IP address detected, try to find subnetmask from network interfaces - if (localIP != null) + try { - var subnetDetected = false; + // Try to detect local IP address based on routing to public IP + var localIP = await NetworkInterface.DetectLocalIPAddressBasedOnRoutingAsync(IPAddress.Parse(GlobalStaticConfiguration.Dashboard_PublicIPv4Address)); - // Get network interfaces, where local IP address is assigned - var networkInterface = (await NetworkInterface.GetNetworkInterfacesAsync()) - .FirstOrDefault(x => x.IPv4Address.Any(y => y.Item1.Equals(localIP))); + // Fallback: Try to detect local IP address from network interfaces -> Prefer non link-local addresses + localIP ??= await NetworkInterface.DetectLocalIPAddressFromNetworkInterfaceAsync(System.Net.Sockets.AddressFamily.InterNetwork); - // If found, get subnetmask - if (networkInterface != null) + // If local IP address detected, try to find subnetmask from network interfaces + if (localIP != null) { + var subnetDetected = false; - // Find the correct IP address and the associated subnetmask - var ipAddressWithSubnet = networkInterface.IPv4Address.First(x => x.Item1.Equals(localIP)); + // Get network interfaces, where local IP address is assigned + var networkInterface = (await NetworkInterface.GetNetworkInterfacesAsync()) + .FirstOrDefault(x => x.IPv4Address.Any(y => y.Item1.Equals(localIP))); - Host = $"{ipAddressWithSubnet.Item1}/{Subnetmask.ConvertSubnetmaskToCidr(ipAddressWithSubnet.Item2)}"; + // If found, get subnetmask + if (networkInterface != null) + { - subnetDetected = true; + // Find the correct IP address and the associated subnetmask + var ipAddressWithSubnet = networkInterface.IPv4Address.First(x => x.Item1.Equals(localIP)); - // Fix: If the user clears the TextBox and then clicks again on the button, the TextBox remains empty... - OnPropertyChanged(nameof(Host)); - } + Host = $"{ipAddressWithSubnet.Item1}/{Subnetmask.ConvertSubnetmaskToCidr(ipAddressWithSubnet.Item2)}"; + + subnetDetected = true; - // Show error message if subnet could not be detected - if (!subnetDetected) + // Fix: If the user clears the TextBox and then clicks again on the button, the TextBox remains empty... + OnPropertyChanged(nameof(Host)); + } + + // Show error message if subnet could not be detected + if (!subnetDetected) + { + var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); + + await DialogHelper.ShowMessageAsync(window, Strings.Error, Strings.CouldNotDetectSubnetmask, ChildWindowIcon.Error); + } + } + else { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); - await DialogHelper.ShowMessageAsync(window, Strings.Error, Strings.CouldNotDetectSubnetmask, ChildWindowIcon.Error); + await DialogHelper.ShowMessageAsync(window, Strings.Error, Strings.CouldNotDetectLocalIPAddressMessage, ChildWindowIcon.Error); } } - else + catch (Exception ex) { + Log.Error("Error while detecting the local subnet", ex); + var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); - await DialogHelper.ShowMessageAsync(window, Strings.Error, Strings.CouldNotDetectLocalIPAddressMessage, ChildWindowIcon.Error); + await DialogHelper.ShowMessageAsync(window, Strings.Error, ex.Message, ChildWindowIcon.Error); + } + finally + { + IsSubnetDetectionRunning = false; } - - IsSubnetDetectionRunning = false; } /// @@ -671,10 +736,21 @@ private async Task CustomCommand(object guid) var info = (CustomCommandInfo)CustomCommands.FirstOrDefault(x => x.ID == id)?.Clone(); if (info == null) - return; // ToDo: Log and error message + { + Log.Warn($"Custom command with ID \"{id}\" not found (deleted while the context menu was open?)"); + return; + } // Replace vars var hostname = !string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.Hostname.TrimEnd('.') : ""; + + // The hostname comes from the network (PTR record or NetBIOS name) and could contain characters + // like "&" that would run additional commands (e.g. "cmd.exe /c ... {{Hostname}}") - only use valid ones + if (!string.IsNullOrEmpty(hostname) && !RegexHelper.HostnameOrDomainRegex().IsMatch(hostname)) + { + Log.Warn($"Hostname \"{hostname}\" of {SelectedResult.PingInfo.IPAddress} is not a valid hostname and is not used for the custom command."); + hostname = ""; + } var ipAddress = SelectedResult.PingInfo.IPAddress.ToString(); info.FilePath = PlaceholderHelper.Resolve(info.FilePath, @@ -720,11 +796,17 @@ private Task Export() try { - ExportManager.Export(instance.FilePath, instance.FileType, - instance.ExportAll - ? Results - : new ObservableCollection(SelectedResults.Cast() - .ToArray())); + // Copy the results on the UI thread (they may still change while a scan is running) and + // export them in the background, so a large export doesn't freeze the window. In the order + // shown in the grid (Results is in the order the hosts were finished). + var viewResults = ResultsView.Cast(); + + var results = instance.ExportAll + ? viewResults.ToList() + : viewResults.Where(new HashSet(SelectedResults.Cast()) + .Contains).ToList(); + + await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); } catch (Exception ex) { @@ -791,8 +873,8 @@ private void HostScanned(object sender, IPScannerHostScannedArgs e) /// /// Moves buffered scan results into . Always called on the UI thread - - /// either from the tick, or from within a Dispatcher.Invoke - /// call in / . + /// either from the tick, or from within the Dispatcher.Invoke + /// call in . /// private void FlushResultsBuffer() { @@ -842,47 +924,50 @@ private void FlushProgress() } /// - /// Handles the ScanComplete event. + /// Handles the ScanComplete event. This is the only place where a scan ends (completed, canceled + /// or failed), so the state is reset exactly once and a new scan can't be started before it ran. + /// Also called directly (on the UI thread) if the scan is canceled while the hosts are resolved. /// - /// The source of the event. - /// The instance containing the event data. - private void ScanComplete(object sender, EventArgs e) + /// The instance raising the event, or this view model. + /// The instance containing the event data. + private void ScanComplete(object sender, ScanCompleteArgs e) { - // Run in UI thread with lower priority than HostScanned event - // to ensure all results are added first #3285 Application.Current.Dispatcher.Invoke(() => { + if (sender is IPScanner ipScanner) + { + ipScanner.HostScanned -= HostScanned; + ipScanner.ScanComplete -= ScanComplete; + ipScanner.ProgressChanged -= ProgressChanged; + } + + // Results are buffered - add the remaining ones _resultsFlushTimer?.Stop(); FlushResultsBuffer(); FlushProgress(); - if (Results.Count == 0) + if (e.IsCanceled) { - StatusMessage = Strings.NoReachableHostsFound; + StatusMessage = Strings.CanceledByUserMessage; IsStatusMessageDisplayed = true; } + else if (e.Exception != null) + { + Log.Error("Error while scanning", e.Exception); - IsCanceling = false; - IsRunning = false; - }, DispatcherPriority.Background); - } - - /// - /// Handles the UserHasCanceled event. - /// - /// The source of the event. - /// The instance containing the event data. - private void UserHasCanceled(object sender, EventArgs e) - { - Application.Current.Dispatcher.Invoke(() => - { - _resultsFlushTimer?.Stop(); - FlushResultsBuffer(); - FlushProgress(); - - StatusMessage = Strings.CanceledByUserMessage; - IsStatusMessageDisplayed = true; + StatusMessage = e.Exception.Message; + IsStatusMessageDisplayed = true; + } + else if (Results.Count == 0) + { + // Keep a message from the preparation (e.g. hostnames that could not be resolved) + StatusMessage = IsStatusMessageDisplayed + ? StatusMessage + Environment.NewLine + Strings.NoReachableHostsFound + : Strings.NoReachableHostsFound; + IsStatusMessageDisplayed = true; + } + PreparingScan = false; IsCanceling = false; IsRunning = false; }); diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs index 42bb4fff47..a2c07cbfde 100644 --- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs @@ -81,6 +81,25 @@ public int Timeout } } + /// + /// Gets or sets the number of attempts to connect to a port if there was no answer. + /// + public int Attempts + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.PortScanner_Attempts = value; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets a value indicating whether to resolve hostnames. /// @@ -164,6 +183,7 @@ private void LoadSettings() { ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults; Timeout = SettingsManager.Current.PortScanner_Timeout; + Attempts = SettingsManager.Current.PortScanner_Attempts; ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname; MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads; MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 25de7c9763..f618cc7747 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -320,6 +320,14 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t ResultsView = CollectionViewSource.GetDefaultView(Results); ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PortScannerPortInfo.HostAsString))); + // Sort by IP address and port - results are added in the order the ports are finished + ((ListCollectionView)ResultsView).CustomSort = Comparer.Create((x, y) => + { + var result = IPAddressHelper.CompareIPAddresses(x.IPAddress, y.IPAddress); + + return result != 0 ? result : x.Port.CompareTo(y.Port); + }); + LoadSettings(); } @@ -429,6 +437,54 @@ private async Task OpenPortProfileSelection() private async Task Start() { + // Every port is kept in memory - ask before a large scan and refuse a too large one + // (e.g. 10.0.0.0/8 with 1-65535) instead of running out of memory + long hostCount; + int portsPerHost; + + try + { + hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host)); + portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(Ports).Length; + } + catch (Exception ex) + { + Log.Error("Error while counting the hosts and ports", ex); + + StatusMessage = ex.Message; + IsStatusMessageDisplayed = true; + + return; + } + + var portCount = hostCount * portsPerHost; + + if (portCount > GlobalStaticConfiguration.PortScanner_MaxPortCount) + { + StatusMessage = string.Format(Strings.ScanTooManyPortsMessage, portCount.ToString("N0"), + hostCount.ToString("N0"), portsPerHost.ToString("N0"), + GlobalStaticConfiguration.PortScanner_MaxPortCount.ToString("N0")); + IsStatusMessageDisplayed = true; + + return; + } + + if (portCount > GlobalStaticConfiguration.PortScanner_ConfirmPortCount) + { + var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive) ?? + Application.Current.MainWindow; + + if (!await DialogHelper.ShowConfirmationMessageAsync(window, Strings.Scan, + string.Format(Strings.ScanManyPortsMessage, portCount.ToString("N0"), hostCount.ToString("N0"), + portsPerHost.ToString("N0")), + ChildWindowIcon.Warn, Strings.Scan)) + return; + + // The tab may have been closed while the dialog was open (OnClose only stops a running scan) + if (_closed) + return; + } + IsStatusMessageDisplayed = false; StatusMessage = string.Empty; @@ -460,17 +516,26 @@ private async Task Start() _cancellationTokenSource?.Dispose(); _cancellationTokenSource = new CancellationTokenSource(); - // Resolve hostnames + // Resolve hostnames and convert the ports. Start() is fire-and-forget, so every exception has to be + // handled here - otherwise the scan would never end (IsRunning stays true). (List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved) hosts; + int[] ports; try { hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host), SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token); + + ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports); } catch (OperationCanceledException) { - UserHasCanceled(this, EventArgs.Empty); + ScanComplete(this, new ScanCompleteArgs(true)); + return; + } + catch (Exception ex) + { + ScanComplete(this, new ScanCompleteArgs(false, ex)); return; } @@ -482,9 +547,6 @@ private async Task Start() IsStatusMessageDisplayed = true; } - // Convert ports to int array - var ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports); - PortsToScan = ports.Length * hosts.hosts.Count; PreparingScan = false; @@ -499,6 +561,7 @@ private async Task Start() SettingsManager.Current.PortScanner_MaxHostThreads, SettingsManager.Current.PortScanner_MaxPortThreads, SettingsManager.Current.PortScanner_Timeout, + SettingsManager.Current.PortScanner_Attempts, SettingsManager.Current.PortScanner_ResolveHostname, SettingsManager.Current.PortScanner_ShowAllResults )); @@ -506,7 +569,6 @@ private async Task Start() portScanner.PortScanned += PortScanned; portScanner.ScanComplete += ScanComplete; portScanner.ProgressChanged += ProgressChanged; - portScanner.UserHasCanceled += UserHasCanceled; _resultsFlushTimer = new DispatcherTimer(DispatcherPriority.Background) { @@ -541,11 +603,19 @@ private Task Export() try { - ExportManager.Export(instance.FilePath, instance.FileType, - instance.ExportAll - ? Results - : new ObservableCollection(SelectedResults.Cast() - .ToArray())); + // Copy the results on the UI thread (they may still change while a scan is running) and + // export them in the background, so a large export doesn't freeze the window. In the order + // shown in the grid: grouped by host, sorted within the group (Results is in the order the + // ports were finished). + var viewResults = ResultsView.Groups.Cast() + .SelectMany(group => group.Items.Cast()); + + var results = instance.ExportAll + ? viewResults.ToList() + : viewResults.Where(new HashSet(SelectedResults.Cast()) + .Contains).ToList(); + + await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); } catch (Exception ex) { @@ -590,8 +660,8 @@ private void PortScanned(object sender, PortScannerPortScannedArgs e) /// /// Moves buffered scan results into . Always called on the UI thread - - /// either from the tick, or from within a Dispatcher.Invoke - /// call in / . + /// either from the tick, or from within the Dispatcher.Invoke + /// call in . /// private void FlushResultsBuffer() { @@ -633,38 +703,51 @@ private void FlushProgress() PortsClosed = Volatile.Read(ref _latestPortsClosed); } - private void ScanComplete(object sender, EventArgs e) + /// + /// Handles the ScanComplete event. This is the only place where a scan ends (completed, canceled + /// or failed), so the state is reset exactly once and a new scan can't be started before it ran. + /// Also called directly (on the UI thread) if the scan is canceled while the hosts are resolved. + /// + /// The instance raising the event, or this view model. + /// The instance containing the event data. + private void ScanComplete(object sender, ScanCompleteArgs e) { - // Run in UI thread with lower priority than PortScanned event - // to ensure all results are added first #3285 Application.Current.Dispatcher.Invoke(() => { + if (sender is PortScanner portScanner) + { + portScanner.PortScanned -= PortScanned; + portScanner.ScanComplete -= ScanComplete; + portScanner.ProgressChanged -= ProgressChanged; + } + + // Results are buffered - add the remaining ones _resultsFlushTimer?.Stop(); FlushResultsBuffer(); FlushProgress(); - if (Results.Count == 0) + if (e.IsCanceled) { - StatusMessage = Strings.NoOpenPortsFound; + StatusMessage = Strings.CanceledByUserMessage; IsStatusMessageDisplayed = true; } + else if (e.Exception != null) + { + Log.Error("Error while scanning", e.Exception); - IsCanceling = false; - IsRunning = false; - }, DispatcherPriority.Background); - } - - private void UserHasCanceled(object sender, EventArgs e) - { - Application.Current.Dispatcher.Invoke(() => - { - _resultsFlushTimer?.Stop(); - FlushResultsBuffer(); - FlushProgress(); - - StatusMessage = Strings.CanceledByUserMessage; - IsStatusMessageDisplayed = true; + StatusMessage = e.Exception.Message; + IsStatusMessageDisplayed = true; + } + else if (Results.Count == 0) + { + // Keep a message from the preparation (e.g. hostnames that could not be resolved) + StatusMessage = IsStatusMessageDisplayed + ? StatusMessage + Environment.NewLine + Strings.NoOpenPortsFound + : Strings.NoOpenPortsFound; + IsStatusMessageDisplayed = true; + } + PreparingScan = false; IsCanceling = false; IsRunning = false; }); diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 29b8164223..6ccfe212ca 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -23,7 +23,7 @@ - + @@ -47,6 +47,10 @@ Margin="0,0,0,10" /> + + @@ -134,9 +138,9 @@ - + - + \ No newline at end of file diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index 30bb80b8c4..d7eeaffe48 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -23,6 +23,7 @@ + @@ -446,7 +447,7 @@ + Style="{StaticResource ResourceKey=CopyMenuItem}"> + + + + + + + + Style="{StaticResource ResourceKey=CopyMenuItem}"> + + + + + + + + Style="{StaticResource ResourceKey=CopyMenuItem}"> + + + + + + + @@ -600,7 +625,8 @@ MinWidth="150" /> - + diff --git a/Source/NETworkManager/Views/PortScannerSettingsView.xaml b/Source/NETworkManager/Views/PortScannerSettingsView.xaml index aaf35b0e34..1c2086bb6b 100644 --- a/Source/NETworkManager/Views/PortScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/PortScannerSettingsView.xaml @@ -92,7 +92,10 @@ Margin="0,0,0,10" /> - + + + diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index 089b88c7a4..9ee7b6a447 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -260,6 +260,7 @@ Grid.Row="3" ItemsSource="{Binding Path=ResultsView}" SelectedItem="{Binding Path=SelectedResult}" SelectedItemsList="{Binding Path=SelectedResults, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + Sorting="DataGrid_OnSorting" wpfHelpers:DataGridHelper.RefreshStarColumnsOnFirstRow="True" EnableRowVirtualization="True" ScrollViewer.CanContentScroll="True" diff --git a/Source/NETworkManager/Views/PortScannerView.xaml.cs b/Source/NETworkManager/Views/PortScannerView.xaml.cs index 2265162acf..cfe8d174ae 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerView.xaml.cs @@ -1,7 +1,12 @@ using System; +using System.Collections; +using System.ComponentModel; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; using NETworkManager.Controls; +using NETworkManager.Models.Network; +using NETworkManager.Utilities; using NETworkManager.ViewModels; namespace NETworkManager.Views; @@ -45,4 +50,51 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) if (sender is ContextMenu menu) menu.DataContext = _viewModel; } + + private void DataGrid_OnSorting(object sender, DataGridSortingEventArgs e) + { + // IPAddress doesn't implement IComparable - the built-in sort would throw as soon as + // the results contain more than one host + if (e.Column.SortMemberPath != nameof(PortScannerPortInfo.IPAddress)) + return; + + // Prevent the built-in sort from sorting + e.Handled = true; + + // Get the direction + var direction = e.Column.SortDirection != ListSortDirection.Ascending + ? ListSortDirection.Ascending + : ListSortDirection.Descending; + + // Update the sort direction (only one column is sorted) + foreach (var column in ((DataGrid)sender).Columns) + column.SortDirection = null; + + e.Column.SortDirection = direction; + + // Get the view + var view = (ListCollectionView)CollectionViewSource.GetDefaultView(((DataGrid)sender).ItemsSource); + + // Sort the view + view.CustomSort = new DataGridComparer(direction); + } + + /// + /// Sorts the results by IP address and port. + /// + public class DataGridComparer(ListSortDirection direction) : IComparer + { + public int Compare(object x, object y) + { + if (x is not PortScannerPortInfo first || y is not PortScannerPortInfo second) + return 0; + + var result = IPAddressHelper.CompareIPAddresses(first.IPAddress, second.IPAddress); + + if (result == 0) + result = first.Port.CompareTo(second.Port); + + return direction == ListSortDirection.Ascending ? result : -result; + } + } } \ No newline at end of file diff --git a/Source/NETworkManager/Views/TracerouteSettingsView.xaml b/Source/NETworkManager/Views/TracerouteSettingsView.xaml index 88a6eab13d..ce93725fb1 100644 --- a/Source/NETworkManager/Views/TracerouteSettingsView.xaml +++ b/Source/NETworkManager/Views/TracerouteSettingsView.xaml @@ -20,7 +20,7 @@ - + **IP address** / **Hostname** | Looks up the IP address (`PTR`) or the hostname in [DNS Lookup](./dns-lookup) | +| **RDAP...** > **IP address** / **Hostname** | Queries the registration data of the IP address or the registered domain of the hostname in [RDAP](./rdap) | +| **Remote Desktop** / **PowerShell** / **PuTTY** / **TigerVNC** / **SNMP** | Opens the host (hostname if resolved, otherwise the IP address) in the tool | +| **Custom commands** | Executes a [custom command](#custom-commands) for the host | +| **Add profile...** | Creates a new profile with the host (IP address), the hostname as name and the MAC address for Wake on LAN | +| **Copy** | Copies the selected information to the clipboard | +| **Export...** | Exports the selected or all results to a file | + +If multiple results are selected, only **Export...** is available. + +### Result + +The number of reachable (up) and unreachable (down) IP addresses is shown next to the **Result** header. Expand a row to see the ping details, the scanned ports and the NetBIOS information (computer name, domain/workgroup, user and MAC address) of the host. + +Only reachable hosts are shown, unless [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is enabled. A message below the input is shown if no reachable host was found, the scan was canceled, a hostname could not be resolved or an error occurred. + +### Keyboard shortcuts + +| Key | Action | +| -------- | ------------------------------ | +| `Enter` | Start the scan | +| `Esc` | Cancel the running scan | +| `Ctrl+T` | Open a new tab | ## Profile @@ -111,7 +137,7 @@ Show the scan result for all IP addresses and ports including the ones that are ### Attempts -Number of times an ICMP request is retried for each IP address if the request has timed out. +Maximum number of ICMP requests sent to each IP address. Another request is only sent if the previous one has timed out (e.g. a lost packet), other replies like "destination host unreachable" are final. **Type:** `Integer` [Min `1`, Max `10`] @@ -123,13 +149,13 @@ Timeout in milliseconds for each ICMP request, after which the packet is conside **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` ### Buffer Size of the buffer for each ICMP request in bytes. -**Type:** `Integer` [Min `1`, Max `65535`] +**Type:** `Integer` [Min `1`, Max `65500`] **Default:** `32` @@ -165,11 +191,31 @@ Multiple ports and port ranges can be combined with a semicolon (e.g. `22; 80; 4 ### Timeout (ms) -Timeout in milliseconds after which a port is considered closed / timed out. +Timeout in milliseconds after which a port is considered timed out (no answer). A port is closed if the host refused the connection. **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` + +:::note + +A closed port is reported as soon as the host refuses the connection. Each attempt sends the connection request once (see [Attempts](#attempts-1)). + +::: + +### Attempts + +Number of connection requests sent to each port. Another request is only sent if there was no answer (timed out), an open or closed port is final. + +**Type:** `Integer` [Min `1`, Max `10`] + +**Default:** `1` + +:::tip + +In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, VPN) or for hosts on the internet, a lost connection request can make an open port appear as timed out and a host appear as unreachable - increase it to `2` in this case. This only takes longer for ports without answer (attempts × timeout); with the default settings, unreachable hosts are not slower up to `2` attempts, because the ping takes as long (2 × 2000 ms). + +::: ### Scan for NetBIOS @@ -185,7 +231,7 @@ Timeout in milliseconds after which a NetBIOS request is considered lost. **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` ### Resolve MAC address and vendor @@ -224,6 +270,12 @@ In the arguments you can use the following placeholders: :::note +The hostname comes from the network (DNS PTR record or NetBIOS name). For security reasons, `{{Hostname}}` is replaced with an empty value if the hostname contains characters that are not allowed in a hostname (e.g. `&` or a space), so it can't be used to run other commands. + +::: + +:::note + Right-click on a selected custom command to `edit` or `delete` it. You can also use the Hotkeys `F2` (`edit`) or `Del` (`delete`) on a selected custom command. @@ -232,32 +284,28 @@ You can also use the Hotkeys `F2` (`edit`) or `Del` (`delete`) on a selected cus ### Max. concurrent host threads -Maximum number of threads used to scan for active hosts (1 thread = 1 host / IP address). +Maximum number of hosts (IP addresses) that are scanned at the same time. A thread is not an operating system thread, but one host that is pinged, scanned for ports and NetBIOS concurrently. -**Type:** `Integer` [Min `1`, Max `512`] +**Type:** `Integer` [Min `1`, Max `256`] -**Default:** `64` +**Default:** `128` :::warning -Too many simultaneous requests may be blocked by a firewall. You can reduce the number of threads to avoid this, but this will increase the scan time. - -Too many threads can also cause performance problems on the device. +Every host opens up to [Max. concurrent port threads](#max-concurrent-port-threads) TCP connections at the same time (with the default settings up to 128 × 11 = 1408). Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned devices) and can overload small routers. If hosts are missing in the result or the network becomes slow during the scan, reduce the number of threads. This will increase the scan time. ::: ### Max. concurrent port threads -Maximum number of threads that are used to scan for open ports for each host (IP address). +Maximum number of ports that are scanned at the same time for each host (IP address). If this is less than the number of [ports](#ports), the remaining ports are scanned after the first ones are finished, which increases the time per host by the [timeout](#timeout-ms-1). -**Type:** `Integer` [Min `1`, Max `10`] +**Type:** `Integer` [Min `1`, Max `32`] -**Default:** `4` +**Default:** `16` :::warning -Too many simultaneous requests may be blocked by a firewall. You can reduce the number of threads to avoid this, but this will increase the scan time. - -Too many threads can also cause performance problems on the device. +Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned devices). If hosts are missing in the result, reduce the number of threads. This will increase the scan time. ::: diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index 03e953395f..5932d56444 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -133,7 +133,7 @@ Timeout in milliseconds for each ICMP packet, after which the packet is consider Buffer size of the ICMP packet. -**Type:** `Integer` [Min `1`, Max `65535`] +**Type:** `Integer` [Min `1`, Max `65500`] **Default:** `32` diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index cdcbd94457..75ea12bbd1 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -50,6 +50,10 @@ Multiple inputs can be combined with a semicolon (`;`). A column pasted from Exc Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` or `1-1024; 8080; 8443` +IP addresses and ports that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24` or `80; 1-100`) are scanned only once. If an IP address is also entered as hostname, the hostname is kept; an IP address entered with different hostnames is scanned once per hostname. + +A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`) must be confirmed, more than `16,777,216` ports cannot be scanned at once. + ::: ### Toolbar @@ -65,6 +69,20 @@ Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` or `1-1024; 8080; 8443` | **Copy** | Copies the selected information to the clipboard | | **Export...** | Exports the selected or all results to a file | +If multiple results are selected, only **Export...** is available. + +### Result + +The results are grouped by host and sorted by IP address and port. The number of open and closed ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled. + +### Keyboard shortcuts + +| Key | Action | +| -------- | ------------------------------ | +| `Enter` | Start the scan | +| `Esc` | Cancel the running scan | +| `Ctrl+T` | Open a new tab | + ## Profile ### Inherit host from general @@ -163,11 +181,31 @@ Show closed ports in the result list. ### Timeout (ms) -Timeout in milliseconds after which a port is considered closed / timed out. +Timeout in milliseconds after which a port is considered timed out (no answer). A port is closed if the host refused the connection. **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` + +:::note + +A closed port is reported as soon as the host refuses the connection. Each attempt sends the connection request once (see [Attempts](#attempts)). + +::: + +### Attempts + +Number of connection requests sent to each port. Another request is only sent if there was no answer (timed out), an open or closed port is final. + +**Type:** `Integer` [Min `1`, Max `10`] + +**Default:** `1` + +:::tip + +In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, VPN) or for hosts on the internet, a lost connection request can make an open port appear as timed out - increase it to `2` in this case. This only takes longer for ports without answer (attempts × timeout), e.g. ports blocked by a firewall. + +::: ### Resolve hostname @@ -179,7 +217,7 @@ Resolve the hostname for given IP addresses. ### Max. concurrent host threads -Maximum number of threads used to scan hosts (1 thread = 1 host). +Maximum number of hosts that are scanned at the same time. A thread is not an operating system thread, but one host whose ports are scanned concurrently. **Type:** `Integer` [Min `1`, Max `10`] @@ -187,24 +225,20 @@ Maximum number of threads used to scan hosts (1 thread = 1 host). :::warning -Too many simultaneous requests may be blocked by a firewall. You can reduce the number of threads to avoid this, but this will increase the scan time. - -Too many threads can also cause performance problems on the device. +Every host opens up to [Max. concurrent port threads](#max-concurrent-port-threads) TCP connections at the same time (with the default settings up to 4 × 128 = 512). Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned host) and can overload small routers. If ports are missing in the result (shown as timed out) or the network becomes slow during the scan, reduce the number of threads. This will increase the scan time. ::: ### Max. concurrent port threads -Maximum number of threads used to scan for ports for each host (1 thread = 1 port per host). +Maximum number of ports that are scanned at the same time for each host. **Type:** `Integer` [Min `1`, Max `512`] -**Default:** `64` +**Default:** `128` :::warning -Too many simultaneous requests may be blocked by a firewall. You can reduce the number of threads to avoid this, but this will increase the scan time. - -Too many threads can also cause performance problems on the device. +Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned host). If ports are missing in the result (shown as timed out), reduce the number of threads. This will increase the scan time. ::: diff --git a/Website/docs/application/traceroute.md b/Website/docs/application/traceroute.md index 06e8bca556..a31b5a460c 100644 --- a/Website/docs/application/traceroute.md +++ b/Website/docs/application/traceroute.md @@ -103,7 +103,7 @@ Timeout in milliseconds for each ICMP packet, after which the packet is consider Buffer size of the ICMP packet. -**Type:** `Integer` [Min `1`, Max `65535`] +**Type:** `Integer` [Min `1`, Max `65500`] **Default:** `32` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 2f9d19a0f8..6964fe76b5 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -66,8 +66,11 @@ Release date: **xx.xx.2026** - Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. Thanks to [@dearmb](https://github.com/dearmb) [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Added `135` (RPC) and `9100` (raw printing) to the default **Ports** list used to detect if a host is reachable. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) -- Reduced the default **Max. concurrent port threads** from `5` to `4`. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) -- Reduced the default **Max. concurrent host threads** from `256` to `64`, a more conservative default that puts less simultaneous load on the scanned network. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) +- Faster scans with new defaults: all ports of a host are now scanned at the same time (**Max. concurrent port threads** `5` → `16`, max. `32`) and the timeouts are lower (**ICMP**, **port scan** and **NetBIOS** timeout `4000` → `2000` ms), so an unreachable host is finished after about 4 seconds (2 ICMP attempts) instead of up to 12 seconds. **Max. concurrent host threads** is lowered from `256` to `128` (max. `256` instead of `512`) to put less simultaneous load on the network. An empty `/24` network is scanned in about 8 seconds instead of about 50. Settings that are still on the old default are updated. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- The neighbor table (ARP/NDP) is read once and cached during a scan instead of once per reachable host to resolve the MAC address. The MAC address of the own IPv6 addresses and of IPv6 link-local neighbors is resolved as well. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the hosts were scanned (also in the **Port Scanner**). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Hosts are started one after another (every 10 ms, shorter with short timeouts and many threads) instead of all at the same moment, the **Port Scanner** does the same with ports (every 1 ms). This avoids bursts of more than a thousand connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Added **RDAP...** to the context menu to query the registration data of the IP address or hostname of a host in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) **Port Scanner** @@ -76,8 +79,9 @@ Release date: **xx.xx.2026** - Added a live count of ports open/closed next to the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572) - Host and Ports input fields now accept newline-separated entries (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) -- Reduced the default **Max. concurrent host threads** from `5` to `4`. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) -- Reduced the default **Max. concurrent port threads** from `256` to `64`, a more conservative default that puts less simultaneous load on the scanned host. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) +- New defaults: **Timeout** `4000` → `2000` ms (still allows one retransmission of a lost connection request), **Max. concurrent host threads** `5` → `4` and **Max. concurrent port threads** `256` → `128`, which puts less simultaneous load on the scanned hosts (up to 512 instead of 1280 connections at the same time). Settings that are still on the old default are updated. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Closed ports are reported immediately. Windows repeated the connection request after the host refused it and reported a closed port only after about 2 seconds, so a scan of a host that refuses most ports is much faster now (e.g. `1-1024`: about 1 second instead of about 16 seconds). The connection request is sent once. This also applies to the **IP Scanner**. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- New **Attempts** setting for the port scan (default `1`, also in the **IP Scanner**): another connection request is sent if there was no answer, e.g. on an unreliable network (Wi-Fi, VPN) or for hosts on the internet. Open and closed ports are not repeated. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) **Ping Monitor** @@ -179,6 +183,28 @@ Release date: **xx.xx.2026** - Fixed NetBIOS lookups (computer name, domain/workgroup, user name) not starting until a host's entire port scan had finished, since the port scan wasn't actually running asynchronously despite being awaited alongside it. Ping, port scan, and NetBIOS resolution now genuinely run concurrently for every host. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Fixed the application becoming unresponsive (including the window not reacting to input) during a large scan (e.g. a /24). Scan results and progress were both being pushed to the UI one item/update at a time via a dispatcher call per host/port, which could flood the UI thread's message queue on large scans. Both are now batched and flushed periodically (every 150ms) instead, for **IP Scanner** and **Port Scanner** alike. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) +- Fixed every host being shown as unreachable when the ICMP **Buffer** was set to more than `65500` bytes (Windows limit), the same applies to **Ping Monitor** and **Traceroute**. The maximum is now `65500`, higher values are lowered to `65500`. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the ICMP **Attempts** setting being ignored: a host was shown as unreachable after the first lost ping. Another ping is now sent if the previous one timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) + +**IP Scanner** / **Port Scanner** + +- Fixed the scan getting stuck (button disabled, scan never ends) when it was canceled right before the scan started, and when an unexpected error occurred. Errors are now shown as status message. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the message "Canceled by user" being replaced by "No reachable hosts found" / "No open ports found" when the scan was canceled before the first result, and a new scan started right after canceling showing no progress or stopping too early. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the message "The following hostnames could not be resolved" being replaced by "No reachable hosts found" / "No open ports found". Both messages are shown now. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the scan getting stuck when an error occurred while the hosts or ports were prepared, and the **IP Scanner** subnet detection button staying disabled after an error. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed ports being shown as closed when there was no answer at all (e.g. host or network unreachable). Closed now means the host refused the connection, otherwise the port is timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed large scans using up the local (ephemeral) ports: every open port left a connection in the `TIME_WAIT` state for several minutes, so later connections failed (also in other applications). Connections are now closed with a reset. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **IP Scanner**: Fixed hosts that block ICMP and refuse the connection on the scanned ports (TCP reset) being shown as unreachable. A refused connection is an answer from the host, so it is now shown as reachable. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed IP addresses being scanned twice if the inputs overlap (e.g. `10.0.0.1; 10.0.0.0/24`), the same applies to **Ping Monitor**. If an IP address is also entered as hostname, the hostname is kept; different hostnames of the same IP address (e.g. two names of one server) are all kept. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed the application running out of memory (and the scan getting stuck) for very large inputs like `10.0.0.0/1`. A scan of more than `65,536` IP addresses (**Port Scanner**: hosts × ports) must now be confirmed, more than `16,777,216` are refused with a message. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **Port Scanner**: Fixed ports being scanned twice if they are part of multiple inputs (e.g. `80; 1-100` or multiple port profiles). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- Fixed IP ranges across `128.0.0.0` (e.g. `127.255.255.250 - 128.0.0.5`) being rejected as invalid. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **Port Scanner**: Fixed the application crashing when the results of multiple hosts were sorted by IP address. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **Port Scanner**: Results are sorted by IP address and port (before, in the order the ports were finished), and the hostname is resolved while the ports are scanned instead of before. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **IP Scanner**: Fixed sorting by **NetBIOS reachable** not working and the column being shown with NetBIOS disabled. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **IP Scanner**: Fixed **Copy** > **Hostname** / **MAC address** / **Vendor** being hidden in the context menu while the column was shown (resolved via NetBIOS only), and **Copy** > **Status** copying `True` / `False` instead of `Up` / `Down`. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **IP Scanner**: Fixed a hostname received from the network (DNS PTR record or NetBIOS name) with special characters like `&` being able to run additional commands through a custom command with `{{Hostname}}` (e.g. `cmd.exe /c ...`). Such a hostname is now replaced with an empty value. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) +- **IP Scanner**: Fixed **Edit** (`F2`) and **Delete** (`Del`) of a custom command in the settings being available without a selected command. The delete confirmation shows the name of the command. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) **IP Geolocation** @@ -211,6 +237,7 @@ Release date: **xx.xx.2026** - Code cleanup & refactoring - Converted the **IP Scanner** and **Port Scanner** engines from `Parallel.ForEach` with blocking calls to genuinely asynchronous `Parallel.ForEachAsync`, removing the risk of exhausting the application's ThreadPool at high concurrency settings. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) +- The TCP port probe (**IP Scanner**, **Port Scanner**) and the NetBIOS request (**IP Scanner**) report a refused connection, a timeout or no answer as result instead of throwing an exception for each of them (thousands per scan). Faster, and the application no longer lags while scanning with a debugger attached. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653) - Refactored the **Network Connection** widget's check logic to reduce duplicated code and share the local IP address detection between the Computer and Router checks instead of detecting it twice. [#3553](https://github.com/BornToBeRoot/NETworkManager/pull/3553) - Consolidated the duplicated **Profiles** side panel (search, tag filter, grouped list, context menu, add/edit/copy/delete) used across 15 tool views into a single shared control, reducing code duplication. As part of this, the profile panel's expanded/width state is now shared across all tools instead of being tracked per tool. [#3537](https://github.com/BornToBeRoot/NETworkManager/pull/3537) - Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)