From f5c330299810574b7d55ef15ccdb58729a54b4f4 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:50:05 +0200 Subject: [PATCH 01/34] Fix: IP Scanner / Port Scanner single completion path (B2, B3, B4, B16) - The model raises ScanComplete exactly once with ScanCompleteArgs (IsCanceled, Exception); UserHasCanceled is removed. - The token is no longer passed to Task.Run, so a cancel right before the scan starts can't skip the completion event (tab stuck). - Unexpected exceptions (also GetNetworkInterfaces) are reported instead of being swallowed as "nothing found" and are logged in the view model. - The view model resets its state in one place and unsubscribes the scanner's handlers; "Canceled by user" is no longer overwritten. Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 41 ++++++------ .../Network/PortScanner.cs | 34 +++++----- .../Network/ScanCompleteArgs.cs | 22 +++++++ .../ViewModels/IPScannerViewModel.cs | 63 +++++++++---------- .../ViewModels/PortScannerViewModel.cs | 57 ++++++++++------- Website/docs/changelog/next-release.md | 5 ++ 6 files changed, 131 insertions(+), 91 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/ScanCompleteArgs.cs diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 986ba87a82..1925dc7de6 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -51,13 +51,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 +71,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 +83,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, @@ -224,15 +219,19 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => IncreaseProgress(); }).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + isCanceled = true; + } + catch (Exception ex) { - OnUserHasCanceled(); + exception = ex; } finally { - OnScanComplete(); + OnScanComplete(new ScanCompleteArgs(isCanceled, exception)); } - }, cancellationToken); + }); } private async Task PingAsync(IPAddress ipAddress, CancellationToken cancellationToken) diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 64b87762bf..e58d99572b 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -50,11 +50,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 +68,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 +77,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 @@ -132,15 +134,19 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => }).ConfigureAwait(false); }).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + isCanceled = true; + } + catch (Exception ex) { - OnUserHasCanceled(); + exception = ex; } finally { - OnScanComplete(); + OnScanComplete(new ScanCompleteArgs(isCanceled, exception)); } - }, cancellationToken); + }); } private void IncreaseProgress() 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/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index ca92e67b44..b82e83cf65 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -538,7 +538,7 @@ private async Task Start() } catch (OperationCanceledException) { - UserHasCanceled(this, EventArgs.Empty); + ScanComplete(this, new ScanCompleteArgs(true)); return; } @@ -577,7 +577,6 @@ await PortRangeHelper.ConvertPortRangeToIntArrayAsync(SettingsManager.Current.IP ipScanner.HostScanned += HostScanned; ipScanner.ScanComplete += ScanComplete; ipScanner.ProgressChanged += ProgressChanged; - ipScanner.UserHasCanceled += UserHasCanceled; _resultsFlushTimer = new DispatcherTimer(DispatcherPriority.Background) { @@ -791,8 +790,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 +841,47 @@ 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) + { + StatusMessage = Strings.NoReachableHostsFound; + IsStatusMessageDisplayed = true; + } + PreparingScan = false; IsCanceling = false; IsRunning = false; }); diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 25de7c9763..d5e671a03c 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -470,7 +470,7 @@ private async Task Start() } catch (OperationCanceledException) { - UserHasCanceled(this, EventArgs.Empty); + ScanComplete(this, new ScanCompleteArgs(true)); return; } @@ -506,7 +506,6 @@ private async Task Start() portScanner.PortScanned += PortScanned; portScanner.ScanComplete += ScanComplete; portScanner.ProgressChanged += ProgressChanged; - portScanner.UserHasCanceled += UserHasCanceled; _resultsFlushTimer = new DispatcherTimer(DispatcherPriority.Background) { @@ -590,8 +589,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 +632,48 @@ 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) + { + StatusMessage = Strings.NoOpenPortsFound; + IsStatusMessageDisplayed = true; + } + PreparingScan = false; IsCanceling = false; IsRunning = false; }); diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 2f9d19a0f8..1fe1a81132 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -180,6 +180,11 @@ 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) +**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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) + **IP Geolocation** - Fixed the query history of **IP Geolocation** being written to the history of **Whois** instead of its own. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) From da867d8d95ba9d1910f93c997adfe176ac5767e4 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:51:28 +0200 Subject: [PATCH 02/34] Fix: IP Scanner / Port Scanner robust scan preparation (B5, B8, B11) - Catch every exception while the hosts are resolved and the ports are converted (Start() is fire-and-forget) and end the scan via the single completion path, so the tab can't get stuck. - Append "No reachable hosts found" / "No open ports found" to a message from the preparation (unresolved hostnames) instead of replacing it. - Detect subnet: try/catch/finally, so the button can't stay disabled. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerViewModel.cs | 88 ++++++++++++------- .../ViewModels/PortScannerViewModel.cs | 19 ++-- Website/docs/changelog/next-release.md | 2 + 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index b82e83cf65..0d97b2928b 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -528,19 +528,29 @@ 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) { ScanComplete(this, new ScanCompleteArgs(true)); return; } + catch (Exception ex) + { + ScanComplete(this, new ScanCompleteArgs(false, ex)); + return; + } // Show error message if (some) hostnames could not be resolved if (hosts.hostnamesNotResolved.Count > 0) @@ -566,7 +576,7 @@ 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_NetBIOSEnabled, SettingsManager.Current.IPScanner_NetBIOSTimeout, @@ -611,52 +621,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; } /// @@ -877,7 +900,10 @@ private void ScanComplete(object sender, ScanCompleteArgs e) } else if (Results.Count == 0) { - StatusMessage = Strings.NoReachableHostsFound; + // 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; } diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index d5e671a03c..5aa3b53026 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -460,19 +460,28 @@ 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) { ScanComplete(this, new ScanCompleteArgs(true)); return; } + catch (Exception ex) + { + ScanComplete(this, new ScanCompleteArgs(false, ex)); + return; + } // Show error message if (some) hostnames could not be resolved if (hosts.hostnamesNotResolved.Count > 0) @@ -482,9 +491,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; @@ -669,7 +675,10 @@ private void ScanComplete(object sender, ScanCompleteArgs e) } else if (Results.Count == 0) { - StatusMessage = Strings.NoOpenPortsFound; + // 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; } diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 1fe1a81132..e7de8e58ea 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -184,6 +184,8 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From f2be68235d1b65c7746bab0b99631eb2d16e8c41 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:52:12 +0200 Subject: [PATCH 03/34] Fix: Limit ICMP buffer to 65500 bytes (B4) .NET's Ping throws an ArgumentException for buffers larger than 65500 bytes, which made every host unreachable in the IP Scanner (and broke Ping Monitor / Traceroute). The settings allowed up to 65535. - Maximum 65500 in the IP Scanner, Ping Monitor and Traceroute settings - Lower stored values above 65500 on upgrade - Docs Co-Authored-By: Claude Opus 5.5 --- .../SettingsManager.cs | 19 +++++++++++++++++++ .../Views/IPScannerSettingsView.xaml | 2 +- .../Views/PingMonitorSettingsView.xaml | 2 +- .../Views/TracerouteSettingsView.xaml | 2 +- Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/ping-monitor.md | 2 +- Website/docs/application/traceroute.md | 2 +- Website/docs/changelog/next-release.md | 1 + 8 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 642a76c62a..1f2eadb7d8 100644 --- a/Source/NETworkManager.Settings/SettingsManager.cs +++ b/Source/NETworkManager.Settings/SettingsManager.cs @@ -792,6 +792,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/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 29b8164223..9a9d239296 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -23,7 +23,7 @@ - + diff --git a/Source/NETworkManager/Views/PingMonitorSettingsView.xaml b/Source/NETworkManager/Views/PingMonitorSettingsView.xaml index 0d3fc125ee..8e0a388a1e 100644 --- a/Source/NETworkManager/Views/PingMonitorSettingsView.xaml +++ b/Source/NETworkManager/Views/PingMonitorSettingsView.xaml @@ -14,7 +14,7 @@ - + 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 @@ - + Date: Fri, 23 Oct 2026 22:52:52 +0200 Subject: [PATCH 04/34] Fix: IP Scanner ICMP attempts ignored (B1) PingAsync returned on the first non-success reply, so a single lost echo marked the host as unreachable. Try again while the request timed out (up to the configured attempts); other states (e.g. destination host unreachable) are final. Cancellation is handled by SendPingAsync. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager.Models/Network/IPScanner.cs | 18 ++++++++++-------- Website/docs/application/ip-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 1925dc7de6..1ab3bb97c1 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -238,6 +238,8 @@ private async Task PingAsync(IPAddress ipAddress, CancellationToken ca { using var ping = new System.Net.NetworkInformation.Ping(); + PingInfo pingInfo = null; + for (var i = 0; i < options.ICMPAttempts; i++) { // Get timestamp @@ -274,22 +276,22 @@ 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); } private async Task> PortScanAsync(IPAddress ipAddress, ParallelOptions parallelOptions, diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 19fb95192d..4ae0f28708 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -111,7 +111,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`] diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 227adfd00d..51ca4e10ac 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -180,6 +180,7 @@ 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`. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Scanner** / **Port Scanner** From 39374e1d7193f0b7828e903a082e94c3f3ebdce0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:53:38 +0200 Subject: [PATCH 05/34] Fix: Port probe classification and TIME_WAIT (B17, B18) - Only a refused connection (TCP reset) is "Closed"; other connect errors (host/network unreachable, local socket errors) mean no answer and are "TimedOut" now. Before, every error was "Closed". - Close with SO_LINGER 0 (reset), so open ports no longer leave sockets in TIME_WAIT that can exhaust the ephemeral ports on large scans. Shared by the IP Scanner and Port Scanner. Co-Authored-By: Claude Opus 5.5 --- .../Network/PortProbe.cs | 25 +++++++++++++++---- Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 2 ++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Source/NETworkManager.Models/Network/PortProbe.cs b/Source/NETworkManager.Models/Network/PortProbe.cs index 911192d1c6..8c882f47c0 100644 --- a/Source/NETworkManager.Models/Network/PortProbe.cs +++ b/Source/NETworkManager.Models/Network/PortProbe.cs @@ -20,11 +20,20 @@ internal static class PortProbe /// Port to connect to. /// Timeout in milliseconds after which the port is considered timed out. /// Token to monitor for cancellation requests. - /// The of the probed port. + /// + /// 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, CancellationToken cancellationToken) { using var tcpClient = new TcpClient(ipAddress.AddressFamily); + + // 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. + tcpClient.LingerState = new LingerOption(true, 0); + using var timeoutCts = new CancellationTokenSource(timeoutMs); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); @@ -44,13 +53,19 @@ public static async Task ProbeAsync(IPAddress ipAddress, int port, in // Caller's token is why this was canceled -> propagate as a real cancellation throw; } - catch (Exception) + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) { - // 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). + // The host answered with a reset -> port is closed (and the host is reachable) return PortState.Closed; } + catch (Exception) + { + // Any other connect failure (host/network unreachable, local socket error, etc.) -> no answer + // from the host, same as a timeout. 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.TimedOut; + } finally { tcpClient.Close(); diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 4ae0f28708..9f42db2c6c 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -165,7 +165,7 @@ 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`] diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index cdcbd94457..3649b61283 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -163,7 +163,7 @@ 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`] diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 51ca4e10ac..341d742f06 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -188,6 +188,8 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 0864e44cc4c37e8b31ad5371ba6179b1c45770eb Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:54:34 +0200 Subject: [PATCH 06/34] Feature: IP Scanner detects hosts by a refused port (B17, D7) A closed port (connection refused / TCP reset) is an answer from the host, so the host is reachable even if ICMP is blocked and no port is open (same as nmap's host discovery). The port scan returns every port now; closed and timed out ports are only shown with "Show unreachable". Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Models/Network/IPScanner.cs | 12 +++++++++--- Website/docs/application/ip-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 1ab3bb97c1..832f7c85ed 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -136,6 +136,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 @@ -202,7 +203,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) @@ -294,6 +297,10 @@ private async Task PingAsync(IPAddress ipAddress, CancellationToken ca 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) { @@ -304,8 +311,7 @@ await Parallel.ForEachAsync(options.PortScanPorts, parallelOptions, async (port, var portState = await PortProbe.ProbeAsync(ipAddress, port, options.PortScanTimeout, 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/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 9f42db2c6c..13829dd455 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -15,7 +15,7 @@ keywords: # IP Scanner -With the **IP Scanner** you can scan for active devices based on the hostname or in IP ranges that are reachable via ICMP or have a common TCP port open. +With the **IP Scanner** you can scan for active devices based on the hostname or in IP ranges that are reachable via ICMP, answer on a common TCP port (open, or closed with a TCP reset) or reply to a NetBIOS request. ![IP Scanner](../img/ip-scanner.png) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 341d742f06..8e00b407e9 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -190,6 +190,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 08220a2d0df2c90fb54c3d8bf792bb367fc86f2a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:56:08 +0200 Subject: [PATCH 07/34] Chore: New IP Scanner / Port Scanner defaults (P4, PS4, D1, D5) The time per host was dominated by the port probes: 11 default ports with 4 port threads = 3 rounds x 4000 ms = ~12 s for every host with filtered ports (all unreachable hosts), an empty /24 took ~48 s. IP Scanner: - ICMP / port / NetBIOS timeout 4000 -> 2000 ms (Windows' initial SYN RTO is 1 s, so 2 s still allows one retransmission) - Max. concurrent port threads 4 -> 16 (all default ports at once), max 32 - Max. concurrent host threads 64 -> 128, max 512 -> 256 (128 x 11 = ~1.4k connections in flight, worst case 256 x 32 = 8k, half of the ephemeral port range) Port Scanner: - Timeout 4000 -> 2000 ms, Max. concurrent port threads 64 -> 128 Migration in UpgradeToLatest replaces the existing block: values still on the default of the last release (2026.7.7) or of a pre-release of this release are updated, IP Scanner host threads above 256 are lowered. Docs explain what a "thread" is and that a firewall may block too many simultaneous connections. Co-Authored-By: Claude Opus 5.5 --- .../GlobalStaticConfiguration.cs | 14 ++--- .../SettingsManager.cs | 58 ++++++++++++++----- .../Views/IPScannerSettingsView.xaml | 4 +- Website/docs/application/ip-scanner.md | 26 ++++----- Website/docs/application/port-scanner.md | 16 ++--- Website/docs/changelog/next-release.md | 6 +- 6 files changed, 72 insertions(+), 52 deletions(-) diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index ee187255ba..6b4397a668 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -138,22 +138,22 @@ 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_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 ExportFileType IPScanner_ExportFileType => ExportFileType.Csv; // 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 ExportFileType PortScanner_ExportFileType => ExportFileType.Csv; // Application: Ping Monitor diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs index 1f2eadb7d8..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.IPScanner_NetBIOSTimeout == 4000) + { + Log.Info($"Update \"IPScanner_NetBIOSTimeout\" from 4000 to {GlobalStaticConfiguration.IPScanner_NetBIOSTimeout}..."); + Current.IPScanner_NetBIOSTimeout = GlobalStaticConfiguration.IPScanner_NetBIOSTimeout; } - if (Current.PortScanner_MaxPortThreads == 256) + if (Current.PortScanner_Timeout == 4000) { - Log.Info("Update \"PortScanner_MaxPortThreads\" from 256 to 64..."); - Current.PortScanner_MaxPortThreads = 64; + 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") diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index 9a9d239296..fbec9e1549 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -134,9 +134,9 @@ - + - + \ No newline at end of file diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 13829dd455..85ce68c9bd 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -123,7 +123,7 @@ Timeout in milliseconds for each ICMP request, after which the packet is conside **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` ### Buffer @@ -169,7 +169,7 @@ Timeout in milliseconds after which a port is considered timed out (no answer). **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` ### Scan for NetBIOS @@ -185,7 +185,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 @@ -232,32 +232,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/port-scanner.md b/Website/docs/application/port-scanner.md index 3649b61283..4fc040e22b 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -167,7 +167,7 @@ Timeout in milliseconds after which a port is considered timed out (no answer). **Type:** `Integer` [Min `100`, Max `15000`] -**Default:** `4000` +**Default:** `2000` ### Resolve hostname @@ -179,7 +179,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 +187,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/changelog/next-release.md b/Website/docs/changelog/next-release.md index 8e00b407e9..d5abc62bed 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -66,8 +66,7 @@ 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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 +75,7 @@ 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) **Ping Monitor** From ba28a5a1a5352e648776ec8b0824eca52a62385e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:58:48 +0200 Subject: [PATCH 08/34] Fix: De-duplicate and expand host ranges faster (B7, B20, P7, P8) HostRangeHelper (IP Scanner, Port Scanner, Ping Monitor): - Remove duplicate IP addresses of overlapping inputs, keep the entry with a hostname - Expand ranges sequentially into a pre-sized list instead of Parallel.For into a ConcurrentBag; the result of a single range is already sorted, so the sort is skipped after one linear check - Check for cancellation while expanding (Parallel.For's state.Break() didn't stop it) IPAddressHelper.CompareIPAddresses no longer allocates two arrays per call (stackalloc + TryWriteBytes), used by every sorted IP list in the app. Co-Authored-By: Claude Opus 5.5 --- .../Network/HostRangeHelper.cs | 250 +++++++++++------- Source/NETworkManager.Utilities/ByteHelper.cs | 11 + .../IPAddressHelper.cs | 13 +- Website/docs/changelog/next-release.md | 1 + 4 files changed, 177 insertions(+), 98 deletions(-) diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index be420e8bd2..9bca6b1342 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,112 @@ 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 AddIPv4RangeToBag(IPAddress start, IPAddress end, - ConcurrentBag<(IPAddress ipAddress, string hostname)> hostsBag, CancellationToken ct) + private static void AddIPv4Range(IPAddress start, IPAddress end, + List<(IPAddress ipAddress, string hostname)> hosts, CancellationToken ct) { var from = (long)unchecked((uint)IPv4Address.ToInt32(start)); var to = (long)unchecked((uint)IPv4Address.ToInt32(end)); - Parallel.For(from, to + 1, (i, state) => + // 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) + { + // Get the numbers of each octet + var octets = new List>(); + + foreach (var octet in host.Split('.')) + { + 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); + } + + // 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 (ct.IsCancellationRequested) - state.Break(); + if (count++ % CancellationCheckInterval == 0) + ct.ThrowIfCancellationRequested(); - hostsBag.Add((IPv4Address.FromInt32(unchecked((int)i)), string.Empty)); - }); + hosts.Add((new IPAddress([i, j, k, h]), string.Empty)); + } } + /// + /// 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); if an IP address was + /// also entered as hostname, the entry with the hostname is kept. + /// + /// 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(); + + // 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(hosts, new ParallelOptions { CancellationToken = cancellationToken }, - async (host, ct) => + 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 +152,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 +161,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 +170,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 +186,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 +201,71 @@ 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. + /// If an IP address exists with and without hostname, the entry with the hostname is kept. + /// + 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 (IPAddressHelper.CompareIPAddresses(hosts[i - 1].ipAddress, hosts[i].ipAddress) < 0) + continue; + + isSortedAndDistinct = false; + break; + } + + if (isSortedAndDistinct) + return hosts; + + hosts.Sort((x, y) => IPAddressHelper.CompareIPAddresses(x.ipAddress, y.ipAddress)); + + var distinctHosts = new List<(IPAddress ipAddress, string hostname)>(hosts.Count); + + foreach (var host in hosts) + { + if (distinctHosts.Count > 0 && + IPAddressHelper.CompareIPAddresses(distinctHosts[^1].ipAddress, host.ipAddress) == 0) + { + // Keep the entry with the hostname (e.g. "server01.example.com; 10.0.0.0/24") + if (string.IsNullOrEmpty(distinctHosts[^1].hostname) && !string.IsNullOrEmpty(host.hostname)) + distinctHosts[^1] = host; + + continue; + } + + distinctHosts.Add(host); + } - return ([.. hostsBag.OrderBy(x => x.ipAddress, comparer)], [.. hostnamesNotResovledBag]); + return distinctHosts; } -} \ No newline at end of file +} 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..c9e8b97edc 100644 --- a/Source/NETworkManager.Utilities/IPAddressHelper.cs +++ b/Source/NETworkManager.Utilities/IPAddressHelper.cs @@ -1,4 +1,5 @@ -using System.Net; +using System; +using System.Net; namespace NETworkManager.Utilities; @@ -35,6 +36,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/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index d5abc62bed..d3c8059396 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -189,6 +189,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 390ffd86787c1680e9b55c404ac90080c83cfca5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:22 +0200 Subject: [PATCH 09/34] Fix: Confirm large scans and refuse too large ones (B6) A /1 (allowed by the input validation) created 2^31 entries and ran out of memory. HostRangeHelper.GetHostCount counts the inputs without expanding or resolving them (hostname = 1), the scan is then - confirmed above 65,536 IP addresses (Port Scanner: hosts x ports) - refused above 16,777,216 (a /8) with a status message before any state is changed. Limits in GlobalStaticConfiguration. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 40 +++++++ .../Resources/Strings.resx | 16 +++ .../Network/HostRangeHelper.cs | 102 ++++++++++++++++-- .../GlobalStaticConfiguration.cs | 6 ++ .../ViewModels/IPScannerViewModel.cs | 24 +++++ .../ViewModels/PortScannerViewModel.cs | 28 +++++ Website/docs/application/ip-scanner.md | 4 + Website/docs/application/port-scanner.md | 4 + Website/docs/changelog/next-release.md | 1 + 9 files changed, 215 insertions(+), 10 deletions(-) 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 9bca6b1342..a1416880ed 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -66,7 +66,28 @@ private static void AddIPv4Range(IPAddress start, IPAddress end, private static void AddIPv4SpecialRange(string host, List<(IPAddress ipAddress, string hostname)> hosts, CancellationToken ct) { - // Get the numbers of each octet + 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 List> GetIPv4SpecialRangeOctets(string host) + { var octets = new List>(); foreach (var octet in host.Split('.')) @@ -94,19 +115,80 @@ private static void AddIPv4SpecialRange(string host, List<(IPAddress ipAddress, octets.Add(numbers); } - // Build the new ipv4 - var count = 0; + return octets; + } - foreach (var i in octets[0]) - foreach (var j in octets[1]) - foreach (var k in octets[2]) - foreach (var h in octets[3]) + /// + /// 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) { - if (count++ % CancellationCheckInterval == 0) - ct.ThrowIfCancellationRequested(); + switch (host) + { + // 192.168.0.1 + case var _ when RegexHelper.IPv4AddressRegex().IsMatch(host): + count++; + break; - hosts.Add((new IPAddress([i, j, k, h]), string.Empty)); + // 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) + { + var from = (long)unchecked((uint)IPv4Address.ToInt32(start)); + var to = (long)unchecked((uint)IPv4Address.ToInt32(end)); + + return Math.Max(to - from + 1, 0); } /// diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 6b4397a668..e5afdef926 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -149,12 +149,18 @@ public static class GlobalStaticConfiguration public static bool IPScanner_NetBIOSEnabled => true; public static int IPScanner_NetBIOSTimeout => 2000; 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 => 128; public static int PortScanner_Timeout => 2000; 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/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 0d97b2928b..d42772da53 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -499,6 +499,30 @@ 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 + var hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host)); + + 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; + } + IsStatusMessageDisplayed = false; IsRunning = true; PreparingScan = true; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 5aa3b53026..adf4943678 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -429,6 +429,34 @@ 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 + var hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host)); + var portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(Ports).Length; + 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; + } + IsStatusMessageDisplayed = false; StatusMessage = string.Empty; diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 85ce68c9bd..bdd6767d67 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -39,6 +39,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` +IP addresses that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24`) are scanned only once. The network and broadcast address of a subnet are scanned as well. + +A scan of more than `65,536` IP addresses (e.g. a `/15`) must be confirmed, more than `16,777,216` IP addresses (a `/8`) cannot be scanned at once. + ::: ### Toolbar diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 4fc040e22b..0baf546d13 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 that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24`) are scanned only once. + +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 diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index d3c8059396..f230a6c92e 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -190,6 +190,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From e1d14cabc5fcb232e08e864d162c836ab8ee8f94 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:48 +0200 Subject: [PATCH 10/34] Fix: Scan ports only once (B19) PortRangeHelper.ConvertPortRangeToIntArray removes duplicates (first occurrence, order kept), e.g. "80; 1-100" or multiple selected port profiles scanned port 80 twice (two rows, counted twice). Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Utilities/PortRangeHelper.cs | 6 ++++-- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) 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/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 0baf546d13..bf77e1fcd5 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -50,7 +50,7 @@ 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 that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24`) are scanned only once. +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. 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. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index f230a6c92e..a82421cf77 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -191,6 +191,7 @@ Release date: **xx.xx.2026** - **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **Port Scanner**: Fixed ports being scanned twice if they are part of multiple inputs (e.g. `80; 1-100` or multiple port profiles). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 3190984f108010042755716af3f07b958bd76b84 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:02:12 +0200 Subject: [PATCH 11/34] Fix: Validate IP ranges across 128.0.0.0 (B14) MultipleHostsRangeValidator (IP Scanner, Port Scanner, Ping Monitor, profiles, run command) compared the signed IPv4 value, so a range like 127.255.255.250 - 128.0.0.5 was rejected as "start > end". Compare the unsigned value like HostRangeHelper. Co-Authored-By: Claude Opus 5.5 --- .../MultipleHostsRangeValidator.cs | 14 +++++++++++--- Website/docs/changelog/next-release.md | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs index 7f79762569..ffd81a6936 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,13 @@ 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 + /// (the signed value of 128.0.0.0 and above is negative). + /// + private static uint ToUInt32(string ipAddress) + { + return unchecked((uint)IPv4Address.ToInt32(IPAddress.Parse(ipAddress))); + } } \ No newline at end of file diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index a82421cf77..fece99c032 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -192,6 +192,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Port Scanner**: Fixed ports being scanned twice if they are part of multiple inputs (e.g. `80; 1-100` or multiple port profiles). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- Fixed IP ranges across `128.0.0.0` (e.g. `127.255.255.250 - 128.0.0.5`) being rejected as invalid. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From b037745b683a3a14a673ab6abe24d100839ae08b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:03:32 +0200 Subject: [PATCH 12/34] Fix: Port Scanner sort by IP address crash, sorted results (PS1-PS3) - Sorting the IP address column used the built-in sort on IPAddress, which isn't IComparable -> exception as soon as the results contained more than one host. Sort with IPAddressHelper.CompareIPAddresses (+ port) in a Sorting handler like the IP Scanner. - Results are sorted by IP address and port by default instead of the order in which the ports finished. - The PTR lookup runs while the ports are scanned (each result awaits the hostname, which is needed for the grouping) instead of before. Co-Authored-By: Claude Opus 5.5 --- .../Network/PortScanner.cs | 27 ++++++---- .../ViewModels/PortScannerViewModel.cs | 8 +++ .../NETworkManager/Views/PortScannerView.xaml | 1 + .../Views/PortScannerView.xaml.cs | 52 +++++++++++++++++++ Website/docs/changelog/next-release.md | 2 + 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index e58d99572b..47061c0e30 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -100,17 +100,10 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => { - // Resolve Hostname (PTR) - var hostname = string.Empty; - - if (_options.ResolveHostname) - { - var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(host.ipAddress) - .WaitAsync(hostCt).ConfigureAwait(false); - - if (!dnsResult.HasError) - hostname = dnsResult.Value; - } + // 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) => @@ -118,6 +111,10 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout, 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) @@ -149,6 +146,14 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => }); } + 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() { // Increase the progress diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index adf4943678..cedf0d70ed 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(); } 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/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index fece99c032..2b0ab932ec 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -193,6 +193,8 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Port Scanner**: Fixed ports being scanned twice if they are part of multiple inputs (e.g. `80; 1-100` or multiple port profiles). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Fixed IP ranges across `128.0.0.0` (e.g. `127.255.255.250 - 128.0.0.5`) being rejected as invalid. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **Port Scanner**: Fixed the application crashing when the results of multiple hosts were sorted by IP address. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 95b33e160944a2b6b4f6311af004f9c7941216be Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:05:41 +0200 Subject: [PATCH 13/34] Fix: IP Scanner NetBIOS column sort/visibility and Copy menu (B9, B10, C6) - "NetBIOS reachable" column: SortMemberPath pointed to a property that doesn't exist (no sort), now NetBIOSInfo.IsReachable; hidden with NetBIOS disabled like the row details. - Copy > Hostname / MAC address / Vendor use the same visibility as their columns (DNS / MAC resolution OR NetBIOS). - Copy > Status copies "Up" / "Down" (new HostReachableToStringConverter) instead of True / False. Co-Authored-By: Claude Opus 5.5 --- .../HostReachableToStringConverter.cs | 39 +++++++++++++++++ .../NETworkManager/Views/IPScannerView.xaml | 42 +++++++++++++++---- Website/docs/changelog/next-release.md | 2 + 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 Source/NETworkManager.Converters/HostReachableToStringConverter.cs 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/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" /> **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`. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From 18d7307a000f6e4f7588fbee95f82460d2596e61 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:06:45 +0200 Subject: [PATCH 14/34] Fix: IP Scanner custom commands - hostname injection, CanExecute (S1, B15, T2) - {{Hostname}} comes from the network (PTR / NetBIOS name). A name with e.g. "&" could run additional commands with "cmd.exe /c ...". Only a valid hostname (RegexHelper.HostnameOrDomainRegex) is used, otherwise an empty value + log warning. Docs note. - Settings: Edit / Delete need a selected command (F2 / Del without a selection opened an empty dialog / asked to delete nothing); the delete confirmation names the command. - Log a warning if a custom command no longer exists (old ToDo). Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerSettingsViewModel.cs | 18 +++++++++++++++--- .../ViewModels/IPScannerViewModel.cs | 13 ++++++++++++- Website/docs/application/ip-scanner.md | 6 ++++++ Website/docs/changelog/next-release.md | 2 ++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index b41389cd73..ac4ea8df82 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; @@ -351,7 +352,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 +374,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 +460,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 d42772da53..db112ae476 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -717,10 +717,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, diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index bdd6767d67..10617b85f5 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -228,6 +228,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. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index f99ecde6e6..51a90c7e88 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -197,6 +197,8 @@ Release date: **xx.xx.2026** - **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **IP Scanner**: Fixed sorting by **NetBIOS reachable** not working and the column being shown with NetBIOS disabled. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **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`. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) **IP Geolocation** From cd7a0f43d78f8458b894e1eb08244b65a9718d5e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:26 +0200 Subject: [PATCH 15/34] Improvement: IP Scanner MAC lookup from a cached neighbor table (P1, B12, D2) - NeighborTable.GetMACAddressMap() reads the table once into a map (IPv6 without scope id); it replaces GetMACAddress, which read and marshalled the whole table for every reachable host. - The IP Scanner caches the map per scan and only reads it again on a miss if the snapshot is older than the time the host answered. Before a host can answer, its ARP/NDP entry exists, so a host is always looked up in a table read after its answer; unreachable hosts never trigger a read. - Local MAC: also the own IPv6 (incl. link-local) addresses; link-local neighbors entered without scope id are found. - Resolve MAC address and vendor: enabled by default for new installations (documented as enabled, the code default was false). Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 51 +++++++++++++++++-- .../Network/NeighborTable.cs | 21 ++++++-- .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 2 +- .../IPAddressHelper.cs | 14 +++++ Website/docs/changelog/next-release.md | 2 + 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 832f7c85ed..28c1b8bd46 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -24,6 +24,11 @@ public sealed class IPScanner(IPScannerOptions options) private readonly InterlockedCounter _hostsUp = new(); private readonly InterlockedCounter _hostsDown = new(); + // Snapshot of the neighbor table (IP address -> MAC address), see GetMACAddressFromNeighborTable + private readonly Lock _neighborTableLock = new(); + 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. @@ -125,6 +130,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; @@ -167,14 +176,19 @@ 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 (an unreachable host never needs a new snapshot) + arpMACAddress = GetMACAddressFromNeighborTable(host.ipAddress, + isReachable ? discoveredAt : DateTime.MinValue); // 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(); @@ -237,6 +251,37 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => }); } + /// + /// 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. + /// Time the host answered, to use the cache only. + /// MAC address or an empty string. + private string GetMACAddressFromNeighborTable(IPAddress ipAddress, DateTime discoveredAt) + { + var key = IPAddressHelper.WithoutScopeId(ipAddress); + + lock (_neighborTableLock) + { + 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); + } + } + private async Task PingAsync(IPAddress ipAddress, CancellationToken cancellationToken) { using var ping = new System.Net.NetworkInformation.Ping(); 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.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index e5afdef926..7a4a521483 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -148,6 +148,7 @@ public static class GlobalStaticConfiguration public static int IPScanner_MaxPortThreads => 16; public static bool IPScanner_NetBIOSEnabled => true; 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; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 46bff0369c..ad3b777ae1 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -1349,7 +1349,7 @@ public bool IPScanner_ResolveMACAddress field = value; OnPropertyChanged(); } - } + } = GlobalStaticConfiguration.IPScanner_ResolveMACAddress; public ObservableCollection IPScanner_CustomCommands { diff --git a/Source/NETworkManager.Utilities/IPAddressHelper.cs b/Source/NETworkManager.Utilities/IPAddressHelper.cs index c9e8b97edc..988289f0da 100644 --- a/Source/NETworkManager.Utilities/IPAddressHelper.cs +++ b/Source/NETworkManager.Utilities/IPAddressHelper.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Net.Sockets; namespace NETworkManager.Utilities; @@ -28,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. /// diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 51a90c7e88..10fee81ff7 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -67,6 +67,8 @@ Release date: **xx.xx.2026** - 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) - 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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** From 10f7c3a7a8de4cbbe02d75aaf9082025b46149f2 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:09:04 +0200 Subject: [PATCH 16/34] Improvement: IP Scanner / Port Scanner export in the background (P5) Copy the results (all or selected) on the UI thread - they may still grow while a scan runs - and run ExportManager.Export with Task.Run, so a large export (e.g. a /16 with "show unreachable") doesn't freeze the window. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager/ViewModels/IPScannerViewModel.cs | 12 +++++++----- .../ViewModels/PortScannerViewModel.cs | 12 +++++++----- Website/docs/changelog/next-release.md | 1 + 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index db112ae476..9a0893826b 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -777,11 +777,13 @@ 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 + var results = instance.ExportAll + ? Results.ToList() + : SelectedResults.Cast().ToList(); + + await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); } catch (Exception ex) { diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index cedf0d70ed..66da94ed27 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -582,11 +582,13 @@ 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 + var results = instance.ExportAll + ? Results.ToList() + : SelectedResults.Cast().ToList(); + + await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); } catch (Exception ex) { diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 10fee81ff7..a0dbb0bbfb 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -69,6 +69,7 @@ Release date: **xx.xx.2026** - 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- The export runs in the background, so the window stays responsive while a large result is exported (also in the **Port Scanner**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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** From e7471cb823710b83e0dc91d320e6ee80427c3d84 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:09:54 +0200 Subject: [PATCH 17/34] Docs: IP Scanner / Port Scanner context menu, result and shortcuts (D-1) - IP Scanner: complete context menu table (open in other tools, DNS lookup / RDAP sub items, custom commands, Add profile...), multi selection - Result section: up/down or open/closed summary, row details, which results are shown, status messages, port states (open / closed / timed out) - Keyboard shortcuts: Enter, Esc, Ctrl+T Co-Authored-By: Claude Opus 5.5 --- Website/docs/application/ip-scanner.md | 30 ++++++++++++++++++++---- Website/docs/application/port-scanner.md | 14 +++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 10617b85f5..8e428eb18e 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -63,10 +63,32 @@ with link-local addresses (`169.254.x.x`) given lower priority. Right-clicking a result allows forwarding device information to another tool (e.g. [Port Scanner](./port-scanner), Remote Desktop), querying the registration data of its IP address or hostname via [RDAP](./rdap), creating a new profile, or executing a [custom command](#custom-commands). -| Action | Description | -| ------------- | ------------------------------------------------ | -| **Copy** | Copies the selected information to the clipboard | -| **Export...** | Exports the selected or all results to a file | +| Action | Description | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Port Scanner** / **Ping Monitor** / **Traceroute** | Opens the host (hostname if resolved, otherwise the IP address) in the tool | +| **DNS lookup...** > **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 diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index bf77e1fcd5..20a5835089 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -69,6 +69,20 @@ A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`) | **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 From cf5904d5da7397e5ccbba5cfb74b204026ccf115 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:10:31 +0200 Subject: [PATCH 18/34] Fix: Handle errors while counting the hosts / ports (B5, B6) The size check added for B6 runs before the preparation's try/catch, so an unexpected parse error would have been lost in the fire-and-forget Start(). Show it as status message and log it. Co-Authored-By: Claude Opus 5.5 --- .../ViewModels/IPScannerViewModel.cs | 16 ++++++++++++++- .../ViewModels/PortScannerViewModel.cs | 20 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 9a0893826b..8c67ab2b32 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -501,7 +501,21 @@ 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 - var hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host)); + 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) { diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 66da94ed27..e9089be276 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -439,8 +439,24 @@ 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 - var hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host)); - var portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(Ports).Length; + 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) From 296871dc6842925b82915194991dabe1f65da081 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:41:48 +0200 Subject: [PATCH 19/34] Improvement: Stagger the start of hosts / ports in the IP / Port Scanner All hosts of a "wave" started at the same moment and, because of the same timeouts, also finished at the same moment: ~1400 connection requests within a few milliseconds (128 hosts x 11 ports) and a progress that moved in large jumps. StartScheduler gives every host / port the next free start time (one per interval) and only waits until then, so the waits don't add up and there is no delay once all concurrent slots are busy. - IP Scanner: one host every 10 ms (128 hosts ramp up in ~1.3 s, empty /24 ~9.3 s instead of ~8 s) - Port Scanner: one port every 1 ms across all hosts (a larger interval would limit the throughput of 4 x 128 ports) Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 13 +++++ .../Network/PortScanner.cs | 13 +++++ .../Network/StartScheduler.cs | 48 +++++++++++++++++++ Website/docs/changelog/next-release.md | 1 + 4 files changed, 75 insertions(+) create mode 100644 Source/NETworkManager.Models/Network/StartScheduler.cs diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 28c1b8bd46..6b0459fdc1 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -20,6 +20,16 @@ public sealed class IPScanner(IPScannerOptions options) { #region Variables + /// + /// Minimum time between the start of two hosts, see . 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). + /// A host takes up to ~4 s (2 x 2000 ms ICMP), so once all slots are busy, the hosts start when a slot is + /// free anyway (no slowdown); the ramp-up costs ~1.3 s once per scan. + /// + private static readonly TimeSpan HostStartInterval = TimeSpan.FromMilliseconds(10); + + private readonly StartScheduler _hostStartScheduler = new(HostStartInterval); + private int _progressValue; private readonly InterlockedCounter _hostsUp = new(); private readonly InterlockedCounter _hostsDown = new(); @@ -117,6 +127,9 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, // 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); diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 47061c0e30..f2d59064ed 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -21,6 +21,16 @@ public PortScanner(PortScannerOptions options) #region Variables + /// + /// Minimum time between the start of two port probes (of all hosts), see . + /// Much smaller than in the IP Scanner, because ports are much more and faster: 4 hosts x 128 ports with a + /// 2000 ms timeout can finish up to ~256 ports per second, a 10 ms interval (100 per second) would slow + /// the scan down. 512 probes are started within ~0.5 s instead of at the same moment. + /// + private static readonly TimeSpan PortStartInterval = TimeSpan.FromMilliseconds(1); + + private readonly StartScheduler _portStartScheduler = new(PortStartInterval); + private int _progressValue; private readonly InterlockedCounter _portsOpen = new(); private readonly InterlockedCounter _portsClosed = new(); @@ -108,6 +118,9 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => // Check each port await Parallel.ForEachAsync(ports, portParallelOptions, async (port, 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, portCt) .ConfigureAwait(false); diff --git a/Source/NETworkManager.Models/Network/StartScheduler.cs b/Source/NETworkManager.Models/Network/StartScheduler.cs new file mode 100644 index 0000000000..155c11cd3b --- /dev/null +++ b/Source/NETworkManager.Models/Network/StartScheduler.cs @@ -0,0 +1,48 @@ +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. +/// +/// +/// 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. Once all concurrent slots are busy, an operation only starts when another +/// one has finished - usually later than its reserved time - so there is no additional delay. +/// +/// Minimum time between two starts. +internal sealed class StartScheduler(TimeSpan interval) +{ + private readonly Lock _lock = new(); + private readonly long _intervalTicks = (long)(interval.TotalSeconds * Stopwatch.Frequency); + private long _nextStartTimestamp; + + /// + /// Waits until the next free start time. + /// + /// 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) + { + 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/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index a0dbb0bbfb..e7f05a5314 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -70,6 +70,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The export runs in the background, so the window stays responsive while a large result is exported (also in the **Port Scanner**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- Hosts are started one after another (every 10 ms) 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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** From af6885e87a0342473f18d3f947d1ccde6f29c073 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:50:13 +0200 Subject: [PATCH 20/34] Refactor: Exception-free port probe and NetBIOS request Nearly every probe of a scan ended with a caught exception: a port timeout (OperationCanceledException), a refused / unreachable connection (SocketException) and every host without a NetBIOS answer - ~12 per host, ~3000 for a /24. Cheap without a debugger, but with Visual Studio attached every first-chance exception pauses the process, so the UI lagged while scanning. - SocketOperation: runs a SocketAsyncEventArgs operation and returns its SocketError as Task result (shared by PortProbe and NetBIOSResolver) - PortProbe: Socket.ConnectAsync(e); timeout / cancellation abort it with Socket.CancelConnectAsync (completes with OperationAborted). Same states as before (Success -> Open, ConnectionRefused -> Closed, else TimedOut), only a cancellation by the caller still throws. - NetBIOSResolver: SendToAsync / ReceiveFromAsync(e); timeout / cancellation close the socket. The response is parsed from the received bytes only. Tested on Linux (open / refused / dropped / IPv6 / 400 parallel probes, NetBIOS with a fake responder, no answer, cancellation): no first-chance exceptions except for the caller's cancellation. Co-Authored-By: Claude Opus 5.5 --- .../Network/NetBIOSResolver.cs | 56 ++++++++++------ .../Network/PortProbe.cs | 66 +++++++++++-------- .../Network/SocketOperation.cs | 39 +++++++++++ Website/docs/changelog/next-release.md | 1 + 4 files changed, 114 insertions(+), 48 deletions(-) create mode 100644 Source/NETworkManager.Models/Network/SocketOperation.cs diff --git a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs index ca44faf100..886b89d86d 100644 --- a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs +++ b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs @@ -43,34 +43,56 @@ 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]; - if (response.Buffer.Length < ResponseBaseLen || response.Buffer[ResponseTypePos] != ResponseTypeNbstat) + 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); + + var response = buffer[..receiveArgs.BytesTransferred]; + + 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; @@ -97,10 +119,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( diff --git a/Source/NETworkManager.Models/Network/PortProbe.cs b/Source/NETworkManager.Models/Network/PortProbe.cs index 8c882f47c0..459513d5f0 100644 --- a/Source/NETworkManager.Models/Network/PortProbe.cs +++ b/Source/NETworkManager.Models/Network/PortProbe.cs @@ -28,47 +28,55 @@ internal static class PortProbe public static async Task ProbeAsync(IPAddress ipAddress, int port, int timeoutMs, CancellationToken cancellationToken) { - using var tcpClient = new TcpClient(ipAddress.AddressFamily); - - // 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. - tcpClient.LingerState = new LingerOption(true, 0); - - using var timeoutCts = new CancellationTokenSource(timeoutMs); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + cancellationToken.ThrowIfCancellationRequested(); try { - await tcpClient.ConnectAsync(ipAddress, port, linkedCts.Token).ConfigureAwait(false); + using var socket = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp); - return tcpClient.Connected ? PortState.Open : PortState.Closed; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - // linkedCts was canceled, but not by the caller -> our own timeout fired - return PortState.TimedOut; + // 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); + + 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 (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) - { - // The host answered with a reset -> port is closed (and the host is reachable) - return PortState.Closed; - } catch (Exception) { - // Any other connect failure (host/network unreachable, local socket error, etc.) -> no answer - // from the host, same as a timeout. 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). + // Unexpected local error (e.g. no free local port to bind) -> no answer from the host return PortState.TimedOut; } - finally - { - tcpClient.Close(); - } } } 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/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index e7f05a5314..002e09d8ea 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -235,6 +235,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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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) From 61e454d9efffd0fc842dd07147585d1d620a4568 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:54:42 +0200 Subject: [PATCH 21/34] Fix: Don't start a confirmed large scan in a closed tab (B6) The confirmation for a large scan is shown before IsRunning is set, so closing the tab while the dialog was open didn't stop anything (OnClose only stops a running scan). Confirming afterwards started the scan in the closed tab, where it kept running in the background and was never canceled. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 4 ++++ Source/NETworkManager/ViewModels/PortScannerViewModel.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 8c67ab2b32..2aba75c2f3 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -535,6 +535,10 @@ private async Task Start() 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; diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index e9089be276..49b0b73ff1 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -479,6 +479,10 @@ private async Task Start() 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; From a267cc1c58a73f631163be7554ca6200f6ce99a5 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:06:59 +0200 Subject: [PATCH 22/34] Refactor: IPv4Address.ToUInt32 for unsigned IPv4 values (review #8) The unsigned conversion unchecked((uint)IPv4Address.ToInt32(...)) was written out in HostRangeHelper (twice) and the range validator. B14 came from comparing the signed value - one helper makes it the obvious choice for the next caller. Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager.Models/Network/HostRangeHelper.cs | 8 ++++---- Source/NETworkManager.Models/Network/IPv4Address.cs | 12 ++++++++++++ .../MultipleHostsRangeValidator.cs | 5 ++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index a1416880ed..7f536ae1a1 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -44,8 +44,8 @@ public static IEnumerable CreateListFromInput(string hosts) private static void AddIPv4Range(IPAddress start, IPAddress end, List<(IPAddress ipAddress, string hostname)> hosts, CancellationToken ct) { - var from = (long)unchecked((uint)IPv4Address.ToInt32(start)); - var to = (long)unchecked((uint)IPv4Address.ToInt32(end)); + 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) @@ -185,8 +185,8 @@ public static long GetHostCount(IEnumerable hosts) /// private static long GetIPv4RangeCount(IPAddress start, IPAddress end) { - var from = (long)unchecked((uint)IPv4Address.ToInt32(start)); - var to = (long)unchecked((uint)IPv4Address.ToInt32(end)); + long from = IPv4Address.ToUInt32(start); + long to = IPv4Address.ToUInt32(end); return Math.Max(to - from + 1, 0); } 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.Validators/MultipleHostsRangeValidator.cs b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs index ffd81a6936..2615fbb17c 100644 --- a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs +++ b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs @@ -110,11 +110,10 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) } /// - /// Converts an IPv4 address to its unsigned value, so ranges across 128.0.0.0 compare correctly - /// (the signed value of 128.0.0.0 and above is negative). + /// Converts an IPv4 address to its unsigned value, so ranges across 128.0.0.0 compare correctly. /// private static uint ToUInt32(string ipAddress) { - return unchecked((uint)IPv4Address.ToInt32(IPAddress.Parse(ipAddress))); + return IPv4Address.ToUInt32(IPAddress.Parse(ipAddress)); } } \ No newline at end of file From f1c1f3467b47debc79a7a3f804579e8bcc23196d Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:08:31 +0200 Subject: [PATCH 23/34] Fix: IP Scanner MAC lookup only for hosts in a local subnet (review #5) A host behind a router is never in the neighbor table (it contains the router), so every reachable remote host missed the cached snapshot and read the whole table again - one at a time under a lock, while up to 128 host workers blocked thread-pool threads waiting for it. - Look up the neighbor table only for hosts in a local subnet: IPv4 via the subnet mask of the local interfaces, IPv6 link-local or the same /64 as a local address. Remote hosts use the NetBIOS MAC address (as before, the neighbor table never had their MAC). - SemaphoreSlim.WaitAsync instead of lock, so waiting hosts don't block a thread while another one reads the table. Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 6b0459fdc1..1838174285 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -34,8 +34,8 @@ public sealed class IPScanner(IPScannerOptions options) private readonly InterlockedCounter _hostsUp = new(); private readonly InterlockedCounter _hostsDown = new(); - // Snapshot of the neighbor table (IP address -> MAC address), see GetMACAddressFromNeighborTable - private readonly Lock _neighborTableLock = 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; @@ -189,9 +189,11 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => if (options.ResolveMACAddress) { - // Get info from neighbor table (an unreachable host never needs a new snapshot) - arpMACAddress = GetMACAddressFromNeighborTable(host.ipAddress, - isReachable ? discoveredAt : DateTime.MinValue); + // 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)) @@ -271,14 +273,19 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => /// 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. + /// 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 string GetMACAddressFromNeighborTable(IPAddress ipAddress, DateTime discoveredAt) + private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt, + CancellationToken cancellationToken) { var key = IPAddressHelper.WithoutScopeId(ipAddress); - lock (_neighborTableLock) + // 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; @@ -293,6 +300,46 @@ private string GetMACAddressFromNeighborTable(IPAddress ipAddress, DateTime disc 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) From a19670b7e1e7bfc75fd6f5d7170d8cbef0c6e79c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:09:48 +0200 Subject: [PATCH 24/34] Fix: Start interval must not limit fast scans (review #2) The fixed interval (IP Scanner 10 ms, Port Scanner 1 ms) was a hard cap of 100 hosts / 1000 ports per second. With short timeouts and many threads the scan can finish more (e.g. 256 host threads, 1 x 500 ms ICMP: ~500 hosts/s). StartScheduler.GetInterval: the interval is at most duration / concurrency (a longer one starts fewer operations per second than can finish). - IP Scanner: duration = slowest of ICMP attempts x timeout, port scan (timeout x rounds) and NetBIOS timeout; defaults -> 10 ms as before - Port Scanner: duration = timeout, concurrency = host x port threads; defaults -> 1 ms as before Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 28 +++++++++++++------ .../Network/PortScanner.cs | 17 ++++++----- .../Network/StartScheduler.cs | 17 +++++++++++ Website/docs/changelog/next-release.md | 2 +- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index 1838174285..e908657d9a 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -21,14 +21,13 @@ public sealed class IPScanner(IPScannerOptions options) #region Variables /// - /// Minimum time between the start of two hosts, see . 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). - /// A host takes up to ~4 s (2 x 2000 ms ICMP), so once all slots are busy, the hosts start when a slot is - /// free anyway (no slowdown); the ramp-up costs ~1.3 s once per scan. + /// Time between the start of two hosts, see . With the default settings 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). A host takes up to ~4 s (2 x 2000 ms ICMP), so once all slots are busy, the hosts start + /// when a slot is free anyway (no slowdown); the ramp-up costs ~1.3 s once per scan. Shorter if the + /// settings allow more than 100 hosts per second (see ). /// - private static readonly TimeSpan HostStartInterval = TimeSpan.FromMilliseconds(10); - - private readonly StartScheduler _hostStartScheduler = new(HostStartInterval); + private static readonly TimeSpan MaxHostStartInterval = TimeSpan.FromMilliseconds(10); private int _progressValue; private readonly InterlockedCounter _hostsUp = new(); @@ -124,11 +123,24 @@ 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 * + (int)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) + : 0, + options.NetBIOSEnabled ? options.NetBIOSTimeout : 0)); + + var hostStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxHostStartInterval, + TimeSpan.FromMilliseconds(hostDuration), options.MaxHostThreads)); + // 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); + 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); diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index f2d59064ed..154823db90 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -22,14 +22,13 @@ public PortScanner(PortScannerOptions options) #region Variables /// - /// Minimum time between the start of two port probes (of all hosts), see . - /// Much smaller than in the IP Scanner, because ports are much more and faster: 4 hosts x 128 ports with a + /// Time between the start of two port probes (of all hosts), see . Much + /// smaller than in the IP Scanner, because ports are much more and faster: 4 hosts x 128 ports with a /// 2000 ms timeout can finish up to ~256 ports per second, a 10 ms interval (100 per second) would slow - /// the scan down. 512 probes are started within ~0.5 s instead of at the same moment. + /// the scan down. 512 probes are started within ~0.5 s instead of at the same moment. Shorter if the + /// settings allow more than 1000 ports per second (see ). /// - private static readonly TimeSpan PortStartInterval = TimeSpan.FromMilliseconds(1); - - private readonly StartScheduler _portStartScheduler = new(PortStartInterval); + private static readonly TimeSpan MaxPortStartInterval = TimeSpan.FromMilliseconds(1); private int _progressValue; private readonly InterlockedCounter _portsOpen = new(); @@ -108,6 +107,10 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, MaxDegreeOfParallelism = _options.MaxPortThreads }; + // Spread the start of the port probes over time instead of starting all at once + var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval, + TimeSpan.FromMilliseconds(_options.Timeout), _options.MaxHostThreads * _options.MaxPortThreads)); + await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => { // Resolve Hostname (PTR) while the ports are scanned @@ -119,7 +122,7 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) => { // Spread the start of the port probes over time instead of starting all at once - await _portStartScheduler.WaitAsync(portCt).ConfigureAwait(false); + await portStartScheduler.WaitAsync(portCt).ConfigureAwait(false); var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout, portCt) .ConfigureAwait(false); diff --git a/Source/NETworkManager.Models/Network/StartScheduler.cs b/Source/NETworkManager.Models/Network/StartScheduler.cs index 155c11cd3b..ba7bf13c1c 100644 --- a/Source/NETworkManager.Models/Network/StartScheduler.cs +++ b/Source/NETworkManager.Models/Network/StartScheduler.cs @@ -19,6 +19,23 @@ namespace NETworkManager.Models.Network; /// Minimum time between two starts. internal sealed class StartScheduler(TimeSpan interval) { + /// + /// Gets the interval between two starts: , but at most + /// / . A longer interval would start fewer + /// operations per second than can finish and limit the throughput (e.g. with short timeouts and many + /// threads), a shorter one only smooths the start. + /// + /// Interval that should be used, if it doesn't limit the throughput. + /// 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; diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 002e09d8ea..37b3eb813f 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -70,7 +70,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - The export runs in the background, so the window stays responsive while a large result is exported (also in the **Port Scanner**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- Hosts are started one after another (every 10 ms) 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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** From 9fe9d5eaaeb7714802242ef6d300abbc56c5b21b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:11:10 +0200 Subject: [PATCH 25/34] Fix: Keep different hostnames of an IP and the IPv6 scope id when de-duplicating (review #1, #3) SortAndRemoveDuplicates merged all entries with the same IP address into one. Two hostnames of one IP (e.g. web1/web2 of a load balancer) became one entry - in Ping Monitor one monitor instead of two - and because List.Sort is unstable, which one survived could change from run to run. The compare also ignored the IPv6 scope id, so fe80::1%3 and fe80::1%7 (two devices on different interfaces) were merged. - Every different hostname (case-insensitive) is kept once; an entry without hostname only if there is none with a hostname (B7 unchanged: "10.0.0.1; 10.0.0.0/24" and "server; " give one entry) - Stable sort (OrderBy), entries with the same IP keep the input order - Same bytes but different scope id = different hosts Co-Authored-By: Claude Opus 5.5 --- .../Network/HostRangeHelper.cs | 61 +++++++++++++------ Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 2 +- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index 7f536ae1a1..fe1889fc9a 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -193,8 +193,8 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end) /// /// 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); if an IP address was - /// also entered as hostname, the entry with the hostname is kept. + /// 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. @@ -305,8 +305,12 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), } /// - /// Merges the hosts of all inputs into one list sorted by IP address, with every IP address only once. - /// If an IP address exists with and without hostname, the entry with the hostname is kept. + /// 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) @@ -319,7 +323,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), for (var i = 1; i < hosts.Count; i++) { - if (IPAddressHelper.CompareIPAddresses(hosts[i - 1].ipAddress, hosts[i].ipAddress) < 0) + if (CompareHosts(hosts[i - 1].ipAddress, hosts[i].ipAddress) < 0) continue; isSortedAndDistinct = false; @@ -329,25 +333,48 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count), if (isSortedAndDistinct) return hosts; - hosts.Sort((x, y) => IPAddressHelper.CompareIPAddresses(x.ipAddress, y.ipAddress)); + // 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)>(hosts.Count); + var distinctHosts = new List<(IPAddress ipAddress, string hostname)>(sortedHosts.Count); - foreach (var host in hosts) + for (var start = 0; start < sortedHosts.Count;) { - if (distinctHosts.Count > 0 && - IPAddressHelper.CompareIPAddresses(distinctHosts[^1].ipAddress, host.ipAddress) == 0) - { - // Keep the entry with the hostname (e.g. "server01.example.com; 10.0.0.0/24") - if (string.IsNullOrEmpty(distinctHosts[^1].hostname) && !string.IsNullOrEmpty(host.hostname)) - distinctHosts[^1] = host; + // All entries with the same IP address + var end = start + 1; - continue; - } + 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]); - distinctHosts.Add(host); + 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 x.ScopeId.CompareTo(y.ScopeId); + } } diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 8e428eb18e..c5bb9db575 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -39,7 +39,7 @@ 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` -IP addresses that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24`) are scanned only once. The network and broadcast address of a subnet are scanned as well. +IP addresses that are part of multiple inputs (e.g. `10.0.0.1; 10.0.0.0/24`) are scanned only once. If an IP address is also entered as hostname, the hostname is kept; an IP address entered with different hostnames (e.g. two names of one server) is scanned once per hostname. The network and broadcast address of a subnet are scanned as well. A scan of more than `65,536` IP addresses (e.g. a `/15`) must be confirmed, more than `16,777,216` IP addresses (a `/8`) cannot be scanned at once. diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 20a5835089..c4d8fca2a9 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -50,7 +50,7 @@ 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. +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. diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 37b3eb813f..9613dd4726 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -193,7 +193,7 @@ Release date: **xx.xx.2026** - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Port Scanner**: Fixed ports being scanned twice if they are part of multiple inputs (e.g. `80; 1-100` or multiple port profiles). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Fixed IP ranges across `128.0.0.0` (e.g. `127.255.255.250 - 128.0.0.5`) being rejected as invalid. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) From 08c5478a6d9b289747fd54c6362216f3d2389554 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:11:55 +0200 Subject: [PATCH 26/34] Fix: Export the results in the order shown in the grid (review #10) Both scanners exported the Results collection, which is in the order the hosts / ports were finished, while the grid shows them sorted (Port Scanner since PS2, IP Scanner by IP address or the user's sort). - IP Scanner: export ResultsView (sorted) - Port Scanner: export group by group (grouped by host, sorted within the group) - like the grid also when the user sorts by another column - Selected results: in the same order instead of the selection order Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager/ViewModels/IPScannerViewModel.cs | 10 +++++++--- .../ViewModels/PortScannerViewModel.cs | 12 +++++++++--- Website/docs/changelog/next-release.md | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 2aba75c2f3..ee6bccd110 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -796,10 +796,14 @@ private Task Export() try { // 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 + // 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 - ? Results.ToList() - : SelectedResults.Cast().ToList(); + ? viewResults.ToList() + : viewResults.Where(new HashSet(SelectedResults.Cast()) + .Contains).ToList(); await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); } diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 49b0b73ff1..74a03a9534 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -603,10 +603,16 @@ private Task Export() try { // 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 + // 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 - ? Results.ToList() - : SelectedResults.Cast().ToList(); + ? viewResults.ToList() + : viewResults.Where(new HashSet(SelectedResults.Cast()) + .Contains).ToList(); await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results)); } diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 9613dd4726..19a0e931ce 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -69,7 +69,7 @@ Release date: **xx.xx.2026** - 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- The export runs in the background, so the window stays responsive while a large result is exported (also in the **Port Scanner**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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) From 7e9c5b6a8337400102175215f3f92019a8baf671 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:12:10 +0200 Subject: [PATCH 27/34] Docs: Note on port timeouts below 1000 ms (review #4) Windows retries a refused connect and reports the reset only after about 1 s, so with a port timeout below ~1000 ms closed ports become "timed out" and the IP Scanner can't detect a host by a closed port. The default (2000 ms) is not affected. Co-Authored-By: Claude Opus 5.5 --- Website/docs/application/ip-scanner.md | 6 ++++++ Website/docs/application/port-scanner.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index c5bb9db575..325bc571b0 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -197,6 +197,12 @@ Timeout in milliseconds after which a port is considered timed out (no answer). **Default:** `2000` +:::note + +Windows retries a connection that was refused, so a closed port is only reported after about 1 second. With a timeout below `1000` ms, closed ports are shown as timed out, and a host that blocks ICMP and only answers with closed ports (TCP reset) is shown as unreachable. + +::: + ### Scan for NetBIOS Scan each IP address for NetBIOS information. diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index c4d8fca2a9..7f2bc17364 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -187,6 +187,12 @@ Timeout in milliseconds after which a port is considered timed out (no answer). **Default:** `2000` +:::note + +Windows retries a connection that was refused, so a closed port is only reported after about 1 second. With a timeout below `1000` ms, closed ports are shown as timed out. + +::: + ### Resolve hostname Resolve the hostname for given IP addresses. From d876255b3ea523ccb0fc4f9dd8e81dfe414a868e Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:18:27 +0200 Subject: [PATCH 28/34] Fix: Report closed ports immediately on Windows (review #4) Windows retries the connection request (SYN) after a reset like after a lost request and reports ConnectionRefused only after ~2 s (measured: 2013-2075 ms). That's as long as the new default port timeout (2000 ms), so closed ports were shown as timed out and the IP Scanner couldn't detect a host by a closed port; with the old default every closed port blocked a slot for 2 s. SIO_TCP_INITIAL_RTO with TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS (measured: ConnectionRefused after 0 ms) and the probe timeout as initial RTO: the request is sent once, a reset is reported immediately, and Windows waits for an answer as long as the probe does. Ignored if not supported. Docs: replace the note from 7e9c5b6a8 (it said ~1 s and < 1000 ms). Co-Authored-By: Claude Opus 5.5 --- .../Network/PortProbe.cs | 42 +++++++++++++++++++ Website/docs/application/ip-scanner.md | 2 +- Website/docs/application/port-scanner.md | 2 +- Website/docs/changelog/next-release.md | 1 + 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Models/Network/PortProbe.cs b/Source/NETworkManager.Models/Network/PortProbe.cs index 459513d5f0..39727571e8 100644 --- a/Source/NETworkManager.Models/Network/PortProbe.cs +++ b/Source/NETworkManager.Models/Network/PortProbe.cs @@ -12,6 +12,17 @@ namespace NETworkManager.Models.Network; /// internal static class PortProbe { + /// + /// 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. @@ -38,6 +49,13 @@ public static async Task ProbeAsync(IPAddress ipAddress, int port, 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); @@ -79,4 +97,28 @@ public static async Task ProbeAsync(IPAddress ipAddress, int port, in 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); + } + catch (Exception) + { + // Not supported (older Windows) -> Windows default (closed ports are reported after ~2 s) + } + } } diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 325bc571b0..3b228d36fb 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -199,7 +199,7 @@ Timeout in milliseconds after which a port is considered timed out (no answer). :::note -Windows retries a connection that was refused, so a closed port is only reported after about 1 second. With a timeout below `1000` ms, closed ports are shown as timed out, and a host that blocks ICMP and only answers with closed ports (TCP reset) is shown as unreachable. +A closed port is reported as soon as the host refuses the connection. The connection request is sent once and not repeated, so on an unreliable network a port can occasionally be shown as timed out. ::: diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 7f2bc17364..bf8a37e0a5 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -189,7 +189,7 @@ Timeout in milliseconds after which a port is considered timed out (no answer). :::note -Windows retries a connection that was refused, so a closed port is only reported after about 1 second. With a timeout below `1000` ms, closed ports are shown as timed out. +A closed port is reported as soon as the host refuses the connection. The connection request is sent once and not repeated, so on an unreliable network a port can occasionally be shown as timed out - scan it again if in doubt. ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 19a0e931ce..3750ef9cc7 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -80,6 +80,7 @@ Release date: **xx.xx.2026** - 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) - 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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**. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) **Ping Monitor** From 3377f5fba47fa048ecfa7bdd3225767cdf066d1a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:42:42 +0200 Subject: [PATCH 29/34] Fix: Only stagger the ramp-up of a scan (review #1, #2, #5) The StartScheduler reserved a start time for every operation, so it capped a scan at one start per interval also after the ramp-up: the IP Scanner at 100 hosts/s, the Port Scanner at 1000 probes/s. Since closed ports are reported immediately, a host refusing most ports took >= 65 s for 1-65535. Only the first N starts (N = concurrency: min(hosts, host threads), for the Port Scanner x min(ports, port threads)) are spread. After that every slot is busy and an operation starts when another one has finished - spread like the ramp-up, without delay. Simulated (Task.Delay as work): 256 hosts x 4 s unchanged (9.3 s), 1024 hosts x 20 ms 1.3 s instead of >= 10.2 s, 65535 ports x 2 ms 2.1 s instead of >= 65 s. Comments also mention the ~15.6 ms timer resolution of Task.Delay (sub-tick intervals start a few operations per tick). Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 15 +++++---- .../Network/PortScanner.cs | 21 +++++++++---- .../Network/StartScheduler.cs | 31 +++++++++++++------ 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index e908657d9a..a81fbd5c7f 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -21,11 +21,10 @@ public sealed class IPScanner(IPScannerOptions options) #region Variables /// - /// Time between the start of two hosts, see . With the default settings 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). A host takes up to ~4 s (2 x 2000 ms ICMP), so once all slots are busy, the hosts start - /// when a slot is free anyway (no slowdown); the ramp-up costs ~1.3 s once per scan. Shorter if the - /// settings allow more than 100 hosts per second (see ). + /// 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); @@ -133,8 +132,12 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, : 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), options.MaxHostThreads)); + TimeSpan.FromMilliseconds(hostDuration), hostConcurrency), hostConcurrency); // Start scan await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) => diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 154823db90..9299e2a9e5 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; @@ -22,11 +23,11 @@ public PortScanner(PortScannerOptions options) #region Variables /// - /// Time between the start of two port probes (of all hosts), see . Much - /// smaller than in the IP Scanner, because ports are much more and faster: 4 hosts x 128 ports with a - /// 2000 ms timeout can finish up to ~256 ports per second, a 10 ms interval (100 per second) would slow - /// the scan down. 512 probes are started within ~0.5 s instead of at the same moment. Shorter if the - /// settings allow more than 1000 ports per second (see ). + /// 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); @@ -108,8 +109,16 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, }; // 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; + + 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.MaxHostThreads * _options.MaxPortThreads)); + TimeSpan.FromMilliseconds(_options.Timeout), portConcurrency), portConcurrency); await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => { diff --git a/Source/NETworkManager.Models/Network/StartScheduler.cs b/Source/NETworkManager.Models/Network/StartScheduler.cs index ba7bf13c1c..3cb5ff8fde 100644 --- a/Source/NETworkManager.Models/Network/StartScheduler.cs +++ b/Source/NETworkManager.Models/Network/StartScheduler.cs @@ -12,20 +12,24 @@ namespace NETworkManager.Models.Network; /// connection requests within a few milliseconds and makes the progress jump in large steps. /// /// -/// 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. Once all concurrent slots are busy, an operation only starts when another -/// one has finished - usually later than its reserved time - so there is no additional delay. +/// 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. -internal sealed class StartScheduler(TimeSpan 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 - /// / . A longer interval would start fewer - /// operations per second than can finish and limit the throughput (e.g. with short timeouts and many - /// threads), a shorter one only smooths the start. + /// / , so the ramp-up is never longer than + /// one operation (e.g. with short timeouts and many threads). /// - /// Interval that should be used, if it doesn't limit the throughput. + /// 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. @@ -39,9 +43,10 @@ public static TimeSpan GetInterval(TimeSpan maxInterval, TimeSpan duration, int 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. + /// Waits until the next free start time (only during the ramp-up, see ). /// /// Token to monitor for cancellation requests. public Task WaitAsync(CancellationToken cancellationToken) @@ -54,6 +59,12 @@ public Task WaitAsync(CancellationToken cancellationToken) 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; } From e10ab0e5bd3d4db6f086aeaec9b9c805926764b7 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:43:37 +0200 Subject: [PATCH 30/34] Fix: NetBIOS response without MAC address (review #6) The length check only covered the name table, but ExtractNames always read the 6 MAC bytes after it. A response without them (no statistics block) threw IndexOutOfRangeException and the catch dropped the names that were already parsed. Read the MAC address only if the response contains it (and don't format an empty one, GetDefaultFormat would throw as well). Tested with a fake responder: with MAC -> names + MAC, without -> names. Co-Authored-By: Claude Opus 5.5 --- .../Network/NetBIOSResolver.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs index 886b89d86d..ffb3be5a4a 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; @@ -111,7 +112,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 ); } @@ -153,11 +156,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); } From 2ae69aaf245f8e560409845f65a4db51172919af Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:44:18 +0200 Subject: [PATCH 31/34] Fix: Only accept the NetBIOS answer of the scanned host (review #7) ReceiveFromAsync accepted the first datagram from any sender, so a stray or spoofed reply to the ephemeral port could be credited to the scanned host (wrong computer name / MAC, marked reachable). Ignore datagrams from another address and keep waiting until the timeout. Tested: reply only from another address -> no answer (timeout), stray reply first and the real one 100 ms later -> real one is used. Co-Authored-By: Claude Opus 5.5 --- .../Network/NetBIOSResolver.cs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs index ffb3be5a4a..8fdc11ba91 100644 --- a/Source/NETworkManager.Models/Network/NetBIOSResolver.cs +++ b/Source/NETworkManager.Models/Network/NetBIOSResolver.cs @@ -72,18 +72,29 @@ public static async Task ResolveAsync(IPAddress ipAddress, int time return new NetBIOSInfo(ipAddress); var buffer = new byte[ResponseBufferSize]; + byte[] response; - 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); - - var response = buffer[..receiveArgs.BytesTransferred]; + // 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 From 785f689e22844818e31f1ee2f216f2bc7208f8af Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:44:55 +0200 Subject: [PATCH 32/34] Fix: Log when the SYN retransmissions can't be disabled (review #8) If SIO_TCP_INITIAL_RTO fails (should not happen since Windows 10 1703, but e.g. after a wrong refactoring of the buffer layout), closed ports silently went back to being reported after ~2 s. Log a warning once per application run (it would fail for every probe). Co-Authored-By: Claude Opus 5.5 --- .../NETworkManager.Models/Network/PortProbe.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Source/NETworkManager.Models/Network/PortProbe.cs b/Source/NETworkManager.Models/Network/PortProbe.cs index 39727571e8..9411fde1a2 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,13 @@ 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). @@ -116,9 +124,13 @@ private static void DisableSynRetransmissions(Socket socket, int timeoutMs) { socket.IOControl(SIO_TCP_INITIAL_RTO, parameters, null); } - catch (Exception) + catch (Exception ex) { - // Not supported (older Windows) -> Windows default (closed ports are reported after ~2 s) + // 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); } } } From 90a4802643c9fc1690b7a442d64445bc6d5ed2c1 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:47:06 +0200 Subject: [PATCH 33/34] Feature: Attempts setting for the port scan (IP Scanner, Port Scanner) (review #3) Since the SYN retransmissions are disabled (closed ports are reported immediately), a lost connection request makes an open port "timed out". Like the ICMP Attempts: another connection request is sent if there was no answer (timed out); open and closed ports are final, so only ports without answer take attempts x timeout. - Settings: IPScanner_PortScanAttempts, PortScanner_Attempts (default 1, 1-10), next to the port timeout in both settings pages - PortProbe.ProbeAsync(..., attempts, ...) loops over ProbeOnceAsync - The start interval / host duration include the attempts - Docs: tip for LAN (1) vs Wi-Fi / VPN / internet (2) Tested: dropped port 300 ms x 1/2/3 -> 317/601/901 ms TimedOut, refused with 3 attempts -> Closed after 1 ms. Co-Authored-By: Claude Opus 5.5 --- .../Network/IPScanner.cs | 5 ++-- .../Network/IPScannerOptions.cs | 9 ++++++- .../Network/PortProbe.cs | 27 ++++++++++++++++++- .../Network/PortScanner.cs | 5 ++-- .../Network/PortScannerOptions.cs | 11 ++++++-- .../GlobalStaticConfiguration.cs | 2 ++ .../NETworkManager.Settings/SettingsInfo.cs | 26 ++++++++++++++++++ .../ViewModels/IPScannerSettingsViewModel.cs | 20 ++++++++++++++ .../ViewModels/IPScannerViewModel.cs | 1 + .../PortScannerSettingsViewModel.cs | 20 ++++++++++++++ .../ViewModels/PortScannerViewModel.cs | 1 + .../Views/IPScannerSettingsView.xaml | 4 +++ .../Views/PortScannerSettingsView.xaml | 5 +++- Website/docs/application/ip-scanner.md | 16 ++++++++++- Website/docs/application/port-scanner.md | 16 ++++++++++- Website/docs/changelog/next-release.md | 1 + 16 files changed, 158 insertions(+), 11 deletions(-) diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs index a81fbd5c7f..7e1456f437 100644 --- a/Source/NETworkManager.Models/Network/IPScanner.cs +++ b/Source/NETworkManager.Models/Network/IPScanner.cs @@ -127,7 +127,7 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, var hostDuration = Math.Max(options.ICMPAttempts * options.ICMPTimeout, Math.Max( options.PortScanEnabled - ? options.PortScanTimeout * + ? options.PortScanTimeout * options.PortScanAttempts * (int)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads) : 0, options.NetBIOSEnabled ? options.NetBIOSTimeout : 0)); @@ -428,7 +428,8 @@ 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); results.Add(new PortInfo(port, PortLookup.LookupByPortAndProtocol(port), portState)); 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/PortProbe.cs b/Source/NETworkManager.Models/Network/PortProbe.cs index 9411fde1a2..4a341b87c6 100644 --- a/Source/NETworkManager.Models/Network/PortProbe.cs +++ b/Source/NETworkManager.Models/Network/PortProbe.cs @@ -38,13 +38,38 @@ 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. /// /// 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, + public static async Task ProbeAsync(IPAddress ipAddress, int port, int timeoutMs, int attempts, + CancellationToken cancellationToken) + { + var portState = PortState.TimedOut; + + for (var i = 0; i < Math.Max(attempts, 1); i++) + { + portState = await ProbeOnceAsync(ipAddress, port, timeoutMs, cancellationToken).ConfigureAwait(false); + + // Open or closed is a definitive answer + if (portState != PortState.TimedOut) + break; + } + + return portState; + } + + /// + /// Sends one connection request, see . + /// + private static async Task ProbeOnceAsync(IPAddress ipAddress, int port, int timeoutMs, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs index 9299e2a9e5..2716b0e342 100644 --- a/Source/NETworkManager.Models/Network/PortScanner.cs +++ b/Source/NETworkManager.Models/Network/PortScanner.cs @@ -118,7 +118,7 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, : _options.MaxPortThreads); var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval, - TimeSpan.FromMilliseconds(_options.Timeout), portConcurrency), portConcurrency); + TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency); await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) => { @@ -133,7 +133,8 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, 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, portCt) + 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 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.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 7a4a521483..94759bce27 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -144,6 +144,7 @@ public static class GlobalStaticConfiguration 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 => 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; @@ -158,6 +159,7 @@ public static class GlobalStaticConfiguration public static int PortScanner_MaxHostThreads => 4; 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; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index ad3b777ae1..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; @@ -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/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs index ac4ea8df82..a29f11ece2 100644 --- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs @@ -176,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. /// @@ -325,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; diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index ee6bccd110..c36ae8b305 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -620,6 +620,7 @@ private async Task Start() SettingsManager.Current.IPScanner_PortScanEnabled, ports, SettingsManager.Current.IPScanner_PortScanTimeout, + SettingsManager.Current.IPScanner_PortScanAttempts, SettingsManager.Current.IPScanner_NetBIOSEnabled, SettingsManager.Current.IPScanner_NetBIOSTimeout, SettingsManager.Current.IPScanner_ResolveMACAddress, 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 74a03a9534..f618cc7747 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -561,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 )); diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml index fbec9e1549..6ccfe212ca 100644 --- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml +++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml @@ -47,6 +47,10 @@ Margin="0,0,0,10" /> + + 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/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 3b228d36fb..f316e59143 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -199,7 +199,21 @@ Timeout in milliseconds after which a port is considered timed out (no answer). :::note -A closed port is reported as soon as the host refuses the connection. The connection request is sent once and not repeated, so on an unreliable network a port can occasionally be shown as timed out. +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). ::: diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index bf8a37e0a5..75ea12bbd1 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -189,7 +189,21 @@ Timeout in milliseconds after which a port is considered timed out (no answer). :::note -A closed port is reported as soon as the host refuses the connection. The connection request is sent once and not repeated, so on an unreliable network a port can occasionally be shown as timed out - scan it again if in doubt. +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. ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 3750ef9cc7..e1bf5b9009 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -81,6 +81,7 @@ Release date: **xx.xx.2026** - 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) - 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - 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**. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) - Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) **Ping Monitor** From f073bbe6d7175c5ff3e06b25303549c0421d0c48 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:56:33 +0200 Subject: [PATCH 34/34] Chore: Add PR --- Website/docs/changelog/next-release.md | 56 +++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index e1bf5b9009..6964fe76b5 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -66,11 +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) -- 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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** @@ -79,9 +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) -- 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) [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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**. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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** @@ -183,28 +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`. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- 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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **Port Scanner**: Fixed ports being scanned twice if they are part of multiple inputs (e.g. `80; 1-100` or multiple port profiles). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- Fixed IP ranges across `128.0.0.0` (e.g. `127.255.255.250 - 128.0.0.5`) being rejected as invalid. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **Port Scanner**: Fixed the application crashing when the results of multiple hosts were sorted by IP address. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **IP Scanner**: Fixed sorting by **NetBIOS reachable** not working and the column being shown with NetBIOS disabled. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **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`. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) -- **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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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** @@ -237,7 +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. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR) +- 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)