Skip to content

Commit d4e2141

Browse files
BornToBeRootclaude
andauthored
Feature: IP Scanner / Port Scanner: bug fixes, faster and more reliable scans (#3653)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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; <its IP>" 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 7e9c5b6 (it said ~1 s and < 1000 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Chore: Add PR --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fdc441f commit d4e2141

38 files changed

Lines changed: 1676 additions & 398 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System;
2+
using System.Globalization;
3+
using System.Windows.Data;
4+
using NETworkManager.Localization.Resources;
5+
6+
namespace NETworkManager.Converters;
7+
8+
/// <summary>
9+
/// Convert a <see cref="bool" /> that indicates if a host is reachable to a translated <see cref="string" />
10+
/// ("Up" / "Down").
11+
/// </summary>
12+
public sealed class HostReachableToStringConverter : IValueConverter
13+
{
14+
/// <summary>
15+
/// Convert a <see cref="bool" /> that indicates if a host is reachable to a translated <see cref="string" />.
16+
/// </summary>
17+
/// <param name="value">Host reachable as <see cref="bool" />.</param>
18+
/// <param name="targetType"></param>
19+
/// <param name="parameter"></param>
20+
/// <param name="culture"></param>
21+
/// <returns>Translated "Up" or "Down".</returns>
22+
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
23+
{
24+
return value is true ? Strings.Up : Strings.Down;
25+
}
26+
27+
/// <summary>
28+
/// !!! Method not implemented !!!
29+
/// </summary>
30+
/// <param name="value"></param>
31+
/// <param name="targetType"></param>
32+
/// <param name="parameter"></param>
33+
/// <param name="culture"></param>
34+
/// <returns></returns>
35+
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
36+
{
37+
throw new NotImplementedException();
38+
}
39+
}

‎Source/NETworkManager.Localization/Resources/Strings.Designer.cs‎

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Source/NETworkManager.Localization/Resources/Strings.resx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5004,4 +5004,20 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri
50045004
<data name="PortProfile" xml:space="preserve">
50055005
<value>Port profile</value>
50065006
</data>
5007+
<data name="ScanManyIPAddressesMessage" xml:space="preserve">
5008+
<value>{0} IP addresses will be scanned. This can take a long time and needs a lot of memory.
5009+
5010+
Do you want to start the scan?</value>
5011+
</data>
5012+
<data name="ScanTooManyIPAddressesMessage" xml:space="preserve">
5013+
<value>{0} IP addresses cannot be scanned at once (maximum {1}). Split the input into smaller ranges.</value>
5014+
</data>
5015+
<data name="ScanManyPortsMessage" xml:space="preserve">
5016+
<value>{0} ports will be scanned ({1} hosts × {2} ports). This can take a long time and needs a lot of memory.
5017+
5018+
Do you want to start the scan?</value>
5019+
</data>
5020+
<data name="ScanTooManyPortsMessage" xml:space="preserve">
5021+
<value>{0} ports cannot be scanned at once ({1} hosts × {2} ports, maximum {3}). Split the input into fewer hosts or ports.</value>
5022+
</data>
50075023
</root>

0 commit comments

Comments
 (0)