Skip to content

Commit a753ebd

Browse files
BornToBeRootclaude
andcommitted
Fix: IP Scanner: ARP / NDP used an old snapshot for the state before the probes (R19)
"Was Reachable before the probes" (R3) accepted a cached snapshot of the neighbor table of any age. In a large local scan, an entry that was Stale in a 30 s old snapshot may have been confirmed by other traffic since and still be Reachable after the host left - it skipped the verification and the host was shown as up. A snapshot older than 15 s (the minimum time Windows keeps an entry reachable) is treated as unknown, so a Reachable entry after the probes is verified. GetNeighborEntryAsync returns the timestamp of the snapshot with the entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9919c8d commit a753ebd

1 file changed

Lines changed: 51 additions & 18 deletions

File tree

‎Source/NETworkManager.Models/Network/IPScanner.cs‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ public sealed class IPScanner(IPScannerOptions options)
5151
/// </summary>
5252
private const int MaxConcurrentNeighborVerifications = 4;
5353

54+
/// <summary>
55+
/// Maximum age of the neighbor table snapshot that tells if the entry of a host was already
56+
/// <see cref="NeighborState.Reachable" /> before its probes (see
57+
/// <see cref="WasNeighborReachableBeforeAsync" />): the minimum time Windows keeps an entry reachable (reachable
58+
/// time 30 s, randomized to 15 to 45 s).
59+
/// </summary>
60+
private static readonly TimeSpan MaxNeighborSnapshotAgeBeforeProbes = TimeSpan.FromSeconds(15);
61+
5462
// Snapshot of the neighbor table (IP address -> entry), see GetNeighborEntryAsync. Replaced, never changed.
5563
// Snapshot and the time it was read (before reading) in one object, so they are always read together without a
5664
// lock. Only a read of the table takes the semaphore.
@@ -249,15 +257,13 @@ await ScanHostAsync(host.ipAddress, portScanParallelOptions, networkInterfaces,
249257
private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanParallelOptions,
250258
List<NetworkInterfaceInfo> networkInterfaces, CancellationToken cancellationToken)
251259
{
252-
// Neighbor entry before the probes are sent (from the cached snapshot, read before this host started): a
253-
// Reachable entry is not resolved again by the probes, so after them it doesn't prove an answer (see
254-
// IPScannerNeighborCheck.Check)
260+
// Neighbor entry before the probes are sent: a Reachable entry is not resolved again by the probes, so after
261+
// them it doesn't prove an answer (see IPScannerNeighborCheck.Check)
255262
var isNeighborCheckEnabled = options.ARPEnabled && IsInLocalSubnet(ipAddress, networkInterfaces);
256263

257264
var wasNeighborReachableBefore = isNeighborCheckEnabled &&
258-
(await GetNeighborEntryAsync(ipAddress, DateTime.MinValue, false,
259-
cancellationToken).ConfigureAwait(false))?.State ==
260-
NeighborState.Reachable;
265+
await WasNeighborReachableBeforeAsync(ipAddress, cancellationToken)
266+
.ConfigureAwait(false);
261267

262268
// Completed by the port scan when the first port answers (open or refused), so the host is found before
263269
// all ports are finished
@@ -690,7 +696,7 @@ private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo hos
690696
// host it contains the router), an unreachable host only reads it if it was not read yet.
691697
if (IsInLocalSubnet(ipAddress, networkInterfaces))
692698
macAddress = (await GetNeighborEntryAsync(ipAddress, discoveredAt, false, cancellationToken)
693-
.ConfigureAwait(false))?.MACAddress.ToString() ?? string.Empty;
699+
.ConfigureAwait(false)).entry?.MACAddress.ToString() ?? string.Empty;
694700

695701
// Check if it is the local mac
696702
if (string.IsNullOrEmpty(macAddress))
@@ -735,19 +741,21 @@ private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo hos
735741
/// <see cref="NeighborState.Incomplete" /> / <see cref="NeighborState.Stale" /> in an older snapshot).
736742
/// </param>
737743
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
738-
/// <returns>Neighbor entry or null.</returns>
739-
private async Task<NeighborInfo> GetNeighborEntryAsync(IPAddress ipAddress, DateTime discoveredAt,
740-
bool requireReachable, CancellationToken cancellationToken)
744+
/// <returns>Neighbor entry or null, and the time the snapshot it is from was read.</returns>
745+
private async Task<(NeighborInfo entry, DateTime timestamp)> GetNeighborEntryAsync(IPAddress ipAddress,
746+
DateTime discoveredAt, bool requireReachable, CancellationToken cancellationToken)
741747
{
742748
// Cached snapshot, no lock needed: an entry, or no newer entry since the snapshot was read after the host
743749
// answered. With the scope id if it was entered (e.g. "fe80::1%3" and "fe80::1%7" are two hosts on different
744750
// interfaces), see NeighborTable.GetNeighborMap.
745-
if (TryGetCachedNeighborEntry(_neighborTable, ipAddress, discoveredAt, requireReachable, out var entry))
746-
return entry;
751+
var snapshot = _neighborTable;
752+
753+
if (TryGetCachedNeighborEntry(snapshot, ipAddress, discoveredAt, requireReachable, out var entry))
754+
return (entry, snapshot.Timestamp);
747755

748756
// Hosts that finish in the meantime share the next read - wait without holding the lock, so lookups that are
749757
// answered from the cache never wait for it
750-
var wait = _neighborTable.Timestamp + MinNeighborTableReadInterval - DateTime.UtcNow;
758+
var wait = snapshot.Timestamp + MinNeighborTableReadInterval - DateTime.UtcNow;
751759

752760
if (wait > TimeSpan.Zero)
753761
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
@@ -758,8 +766,10 @@ private async Task<NeighborInfo> GetNeighborEntryAsync(IPAddress ipAddress, Date
758766
try
759767
{
760768
// Read by another host in the meantime
761-
if (TryGetCachedNeighborEntry(_neighborTable, ipAddress, discoveredAt, requireReachable, out entry))
762-
return entry;
769+
snapshot = _neighborTable;
770+
771+
if (TryGetCachedNeighborEntry(snapshot, ipAddress, discoveredAt, requireReachable, out entry))
772+
return (entry, snapshot.Timestamp);
763773

764774
// Timestamp before reading the table, so entries created while reading are covered by the next read
765775
var timestamp = DateTime.UtcNow;
@@ -786,14 +796,34 @@ private async Task<NeighborInfo> GetNeighborEntryAsync(IPAddress ipAddress, Date
786796

787797
_neighborTable = new NeighborTableSnapshot(entries, timestamp);
788798

789-
return _neighborTable.Entries.GetValueOrDefault(ipAddress);
799+
return (entries.GetValueOrDefault(ipAddress), timestamp);
790800
}
791801
finally
792802
{
793803
_neighborTableSemaphore.Release();
794804
}
795805
}
796806

807+
/// <summary>
808+
/// Checks if the neighbor entry of a host was already <see cref="NeighborState.Reachable" /> before its probes
809+
/// are sent (see <see cref="IPScannerNeighborCheck.Check" />), from the cached snapshot. A snapshot older than
810+
/// <see cref="MaxNeighborSnapshotAgeBeforeProbes" /> doesn't tell: the entry (e.g.
811+
/// <see cref="NeighborState.Stale" /> in the snapshot) may have been confirmed by other traffic since and still
812+
/// be Reachable from it, also if the host is gone - it is treated as reachable, so a Reachable entry after the
813+
/// probes is verified.
814+
/// </summary>
815+
/// <param name="ipAddress">IP address of the host (in a local subnet, see <see cref="IsInLocalSubnet" />).</param>
816+
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
817+
/// <returns>True if the entry was reachable before the probes, or if this is not known.</returns>
818+
private async Task<bool> WasNeighborReachableBeforeAsync(IPAddress ipAddress, CancellationToken cancellationToken)
819+
{
820+
var (entry, timestamp) = await GetNeighborEntryAsync(ipAddress, DateTime.MinValue, false, cancellationToken)
821+
.ConfigureAwait(false);
822+
823+
return entry?.State == NeighborState.Reachable ||
824+
timestamp < DateTime.UtcNow - MaxNeighborSnapshotAgeBeforeProbes;
825+
}
826+
797827
/// <summary>
798828
/// Looks up an entry in a snapshot of the neighbor table, see <see cref="GetNeighborEntryAsync" />.
799829
/// </summary>
@@ -822,13 +852,16 @@ private static bool TryGetCachedNeighborEntry(NeighborTableSnapshot snapshot, IP
822852
/// <see cref="MaxConcurrentNeighborVerifications" /> at the same time).
823853
/// </summary>
824854
/// <param name="ipAddress">IP address of the host (in a local subnet, see <see cref="IsInLocalSubnet" />).</param>
825-
/// <param name="wasReachableBefore">The entry was already reachable before the probes were sent.</param>
855+
/// <param name="wasReachableBefore">
856+
/// The entry was already reachable before the probes were sent, or this is not known (see
857+
/// <see cref="WasNeighborReachableBeforeAsync" />).
858+
/// </param>
826859
/// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
827860
/// <returns>True if the host is reachable.</returns>
828861
private async Task<bool> IsNeighborAsync(IPAddress ipAddress, bool wasReachableBefore,
829862
CancellationToken cancellationToken)
830863
{
831-
var entry = await GetNeighborEntryAsync(ipAddress, DateTime.UtcNow, true, cancellationToken)
864+
var (entry, _) = await GetNeighborEntryAsync(ipAddress, DateTime.UtcNow, true, cancellationToken)
832865
.ConfigureAwait(false);
833866

834867
// Gateways (computed once per scan, like the roles of the hosts)

0 commit comments

Comments
 (0)