diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f8caa..f285781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Export-DrawIO` L2-FDB edge style.** Bridge forwarding-table edges (`Confidence='L2-FDB'`) were falling through to the gray dotted “unknown” default. They now use a dedicated solid teal stroke (between verified L2-SNMP green and dashed amber heuristic). +- **Quick-start wizard now uses tri-state reachability.** Connectivity checks go + through `Test-DeviceReachability` instead of `Test-Connection -Quiet`, so a + local send failure stays `$null` (Unknown) rather than being painted + Unreachable. Optional `-TcpFallbackPort` is forwarded to the same helper. + The step-[4/5] summary reports reachable, unreachable, and unknown counts. ## [1.3.0] - 2026-07-30 diff --git a/README.md b/README.md index 2889411..d7c5e85 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ pwsh .\examples\New-NetworkDiagram.ps1 The wizard performs the following actions: - Discovers the network configuration - Scans for active devices -- Tests connectivity +- Tests connectivity with `Test-DeviceReachability` (reachable / unreachable / unknown) - Generates a draw.io diagram - Saves an inventory file for later refinement @@ -88,6 +88,9 @@ pwsh .\examples\New-NetworkDiagram.ps1 -InterfaceName en0 # Limit each reverse DNS lookup to one second pwsh .\examples\New-NetworkDiagram.ps1 -DnsTimeoutSeconds 1 + +# ICMP-silent gateway: fall back to a TCP connect on port 443 +pwsh .\examples\New-NetworkDiagram.ps1 -TcpFallbackPort 443 ``` ### Method 2: Manual Workflow (manual inventory) diff --git a/examples/New-NetworkDiagram.ps1 b/examples/New-NetworkDiagram.ps1 index 1e370cc..7f70350 100644 --- a/examples/New-NetworkDiagram.ps1 +++ b/examples/New-NetworkDiagram.ps1 @@ -18,6 +18,9 @@ used by the active IPv4 default route is selected. .PARAMETER DnsTimeoutSeconds Maximum time for each reverse DNS lookup during discovery (default: 2 seconds). +.PARAMETER TcpFallbackPort + Optional TCP port forwarded to Test-DeviceReachability. When ICMP is silent, + a successful connect on this port promotes the host to reachable. .EXAMPLE .\New-NetworkDiagram.ps1 @@ -26,6 +29,10 @@ .\New-NetworkDiagram.ps1 -ScanDepth Medium -OutputPath .\office-network.drawio Medium scan with custom output location +.EXAMPLE + .\New-NetworkDiagram.ps1 -TcpFallbackPort 443 + + Use TCP 443 as a fallback when ICMP is blocked #> [CmdletBinding()] @@ -38,7 +45,10 @@ param( [string]$InterfaceName, [ValidateRange(1, 30)] - [int]$DnsTimeoutSeconds = 2 + [int]$DnsTimeoutSeconds = 2, + + [ValidateRange(1, 65535)] + [int]$TcpFallbackPort ) function Get-WizardInventoryPath { @@ -187,6 +197,189 @@ function Select-ScanInterface { throw 'No IPv4 default-route interface could be selected. Use -InterfaceName with an available interface name or index.' } +function Invoke-WizardReachabilityProbe { + <# + .SYNOPSIS + Probe a node list with Test-DeviceReachability (true / false / $null). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Nodes, + + [Parameter()] + [int]$TimeoutSeconds = 1, + + [Parameter()] + [int]$MaxParallel = 32, + + [Parameter()] + [ValidateRange(1, 65535)] + [int]$TcpFallbackPort, + + [Parameter()] + [scriptblock]$ProbeScript + ) + + if ($null -eq $Nodes -or $Nodes.Count -eq 0) { + return + } + + $topology = [pscustomobject]@{ + Nodes = $Nodes + Edges = @() + Subnets = @() + } + + $params = @{ + Topology = $topology + TimeoutSeconds = $TimeoutSeconds + MaxParallel = $MaxParallel + } + if ($PSBoundParameters.ContainsKey('TcpFallbackPort')) { + $params.TcpFallbackPort = $TcpFallbackPort + } + if ($ProbeScript) { + $params.ProbeScript = $ProbeScript + } + + $null = Test-DeviceReachability @params +} + +function Update-WizardNodeReachability { + <# + .SYNOPSIS + Test previously untested wizard nodes via Test-DeviceReachability. + .DESCRIPTION + Leaves already-probed Reachable values untouched. Local send failures stay + $null (Unknown) instead of collapsing to $false the way Test-Connection + -Quiet does. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Nodes, + + [Parameter()] + [int]$TimeoutSeconds = 2, + + [Parameter()] + [int]$MaxParallel = 32, + + [Parameter()] + [ValidateRange(1, 65535)] + [int]$TcpFallbackPort, + + [Parameter()] + [scriptblock]$ProbeScript + ) + + $untested = @($Nodes | Where-Object { $null -eq $_.Reachable }) + $probeParams = @{ + Nodes = $untested + TimeoutSeconds = $TimeoutSeconds + MaxParallel = $MaxParallel + } + if ($PSBoundParameters.ContainsKey('TcpFallbackPort')) { + $probeParams.TcpFallbackPort = $TcpFallbackPort + } + if ($ProbeScript) { + $probeParams.ProbeScript = $ProbeScript + } + + Invoke-WizardReachabilityProbe @probeParams + return $Nodes +} + +function Find-WizardReachableScanHosts { + <# + .SYNOPSIS + Probe scan targets and return only confirmed-reachable hosts. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$ScanTargets, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$SkipIPs = @(), + + [Parameter()] + [int]$TimeoutSeconds = 1, + + [Parameter()] + [int]$MaxParallel = 20, + + [Parameter()] + [ValidateRange(1, 65535)] + [int]$TcpFallbackPort, + + [Parameter()] + [scriptblock]$ProbeScript + ) + + $skip = @{} + foreach ($ip in @($SkipIPs)) { + if (-not [string]::IsNullOrWhiteSpace($ip)) { + $skip[$ip] = $true + } + } + + $candidates = @( + $ScanTargets | Where-Object { $_ -and -not $skip.ContainsKey($_) } | ForEach-Object { + [pscustomobject]@{ + IP = $_ + Hostname = $null + Role = 'unknown' + Vendor = 'Unknown' + OS = 'Unknown' + Layer = 'Access' + Reachable = $null + } + } + ) + + $probeParams = @{ + Nodes = $candidates + TimeoutSeconds = $TimeoutSeconds + MaxParallel = $MaxParallel + } + if ($PSBoundParameters.ContainsKey('TcpFallbackPort')) { + $probeParams.TcpFallbackPort = $TcpFallbackPort + } + if ($ProbeScript) { + $probeParams.ProbeScript = $ProbeScript + } + + Invoke-WizardReachabilityProbe @probeParams + return @($candidates | Where-Object { $_.Reachable -eq $true }) +} + +function Get-WizardReachabilitySummary { + <# + .SYNOPSIS + Count reachable, unreachable, and unknown (indeterminate) nodes. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Nodes + ) + + $list = @($Nodes) + [pscustomobject]@{ + Reachable = @($list | Where-Object { $_.Reachable -eq $true }).Count + Unreachable = @($list | Where-Object { $_.Reachable -eq $false }).Count + Unknown = @($list | Where-Object { $null -eq $_.Reachable }).Count + Total = $list.Count + } +} + function ConvertFrom-MacOSInterfaceText { param([Parameter(Mandatory)][string[]]$Lines) @@ -512,19 +705,24 @@ $scanTargets = @($hostValues | ForEach-Object { ConvertFrom-UInt32Address $_ }) Write-Host " Scan depth: $ScanDepth ($($scanTargets.Count) addresses in $cidr)" -ForegroundColor Cyan Write-Host " This may take 10-60 seconds..." -ForegroundColor Gray -$discovered = [System.Collections.Concurrent.ConcurrentBag[object]]::new() - -$scanTargets | ForEach-Object -Parallel { - $testIP = $_ +$skipIPs = @($myIP, $gateway) + @($dnsServers) +$discoverParams = @{ + ScanTargets = $scanTargets + SkipIPs = $skipIPs + TimeoutSeconds = 1 + MaxParallel = 20 +} +if ($PSBoundParameters.ContainsKey('TcpFallbackPort')) { + $discoverParams.TcpFallbackPort = $TcpFallbackPort +} +$reachableScanHosts = @(Find-WizardReachableScanHosts @discoverParams) - # Skip IPs we already have - if ($testIP -in @($using:myIP, $using:gateway) + $using:dnsServers) { - return - } +$discovered = [System.Collections.Concurrent.ConcurrentBag[object]]::new() - $result = Test-Connection -ComputerName $testIP -Count 1 -TimeoutSeconds 1 -Quiet -ErrorAction SilentlyContinue +if ($reachableScanHosts.Count -gt 0) { + $reachableScanHosts | ForEach-Object -Parallel { + $testIP = $_.IP - if ($result) { $hostname = try { if (-not (Get-Command Resolve-IPHostname -ErrorAction SilentlyContinue)) { Import-Module $using:modulePath -Force @@ -545,8 +743,8 @@ $scanTargets | ForEach-Object -Parallel { Hostname = $hostname Reachable = $true }) - } -} -ThrottleLimit 20 + } -ThrottleLimit 20 +} $discoveredDevices = @($discovered) @@ -580,17 +778,20 @@ if ($discoveredDevices.Count -gt 0) { Write-Host " No additional devices found (ICMP may be blocked)" -ForegroundColor Yellow } -# Step 4: Test reachability +# Step 4: Test reachability (tri-state: true / false / $null) Write-Host "`n[4/5] Testing connectivity..." -ForegroundColor Yellow -foreach ($node in $nodes) { - if ($null -eq $node.Reachable) { - $node.Reachable = Test-Connection -ComputerName $node.IP -Count 1 -TimeoutSeconds 2 -Quiet -ErrorAction SilentlyContinue - } +$reachParams = @{ + Nodes = $nodes + TimeoutSeconds = 2 +} +if ($PSBoundParameters.ContainsKey('TcpFallbackPort')) { + $reachParams.TcpFallbackPort = $TcpFallbackPort } +$null = Update-WizardNodeReachability @reachParams -$reachableCount = @($nodes | Where-Object { $_.Reachable }).Count -Write-Host " $reachableCount of $($nodes.Count) devices are reachable" -ForegroundColor Green +$reachSummary = Get-WizardReachabilitySummary -Nodes $nodes +Write-Host " $($reachSummary.Reachable) reachable, $($reachSummary.Unreachable) unreachable, $($reachSummary.Unknown) unknown of $($reachSummary.Total) devices" -ForegroundColor Green # Create topology object $topology = [pscustomobject]@{ diff --git a/tests/NetDiagram.Tests.ps1 b/tests/NetDiagram.Tests.ps1 index 69e2ae1..d955f17 100644 --- a/tests/NetDiagram.Tests.ps1 +++ b/tests/NetDiagram.Tests.ps1 @@ -1664,6 +1664,63 @@ Describe 'Quick-start wizard output paths' { } } +Describe 'Quick-start wizard reachability (#64)' { + BeforeAll { + $exampleScript = Join-Path $PSScriptRoot '..' 'examples' 'New-NetworkDiagram.ps1' + . $exampleScript + } + + It 'Maps a local send failure to $null instead of $false' { + $nodes = @( + [pscustomobject]@{ IP = '10.0.0.1'; Hostname = 'this-host'; Role = 'workstation'; Reachable = $true } + [pscustomobject]@{ IP = '10.0.0.2'; Hostname = 'gw'; Role = 'router'; Reachable = $null } + [pscustomobject]@{ IP = '10.0.0.3'; Hostname = 'dns'; Role = 'server'; Reachable = $null } + ) + $probe = { + param($IP) + if ($IP -eq '10.0.0.2') { return $false } + throw 'No route to host' + } + + $result = Update-WizardNodeReachability -Nodes $nodes -ProbeScript $probe -WarningAction SilentlyContinue + + ($result | Where-Object IP -eq '10.0.0.1').Reachable | Should -Be $true + ($result | Where-Object IP -eq '10.0.0.2').Reachable | Should -Be $false + ($result | Where-Object IP -eq '10.0.0.3').Reachable | Should -Be $null + } + + It 'Summarizes reachable, unreachable, and unknown counts separately' { + $nodes = @( + [pscustomobject]@{ IP = '10.0.0.1'; Reachable = $true } + [pscustomobject]@{ IP = '10.0.0.2'; Reachable = $false } + [pscustomobject]@{ IP = '10.0.0.3'; Reachable = $null } + [pscustomobject]@{ IP = '10.0.0.4'; Reachable = $true } + ) + + $summary = Get-WizardReachabilitySummary -Nodes $nodes + $summary.Reachable | Should -Be 2 + $summary.Unreachable | Should -Be 1 + $summary.Unknown | Should -Be 1 + $summary.Total | Should -Be 4 + } + + It 'Discovery helper returns only confirmed-reachable hosts' { + $probe = { + param($IP) + if ($IP -eq '192.168.1.10') { return $true } + if ($IP -eq '192.168.1.11') { return $false } + throw 'Network is unreachable' + } + + $found = @(Find-WizardReachableScanHosts -ScanTargets @('192.168.1.1', '192.168.1.10', '192.168.1.11', '192.168.1.12') ` + -SkipIPs @('192.168.1.1') -ProbeScript $probe -WarningAction SilentlyContinue) + + $found | Should -HaveCount 1 + $found[0].IP | Should -Be '192.168.1.10' + $found[0].Reachable | Should -Be $true + } +} + Describe 'Quick-start wizard interface selection (#15 regression)' { BeforeAll { $exampleScript = Join-Path $PSScriptRoot '..' 'examples' 'New-NetworkDiagram.ps1'