Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
241 changes: 221 additions & 20 deletions examples/New-NetworkDiagram.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()]
Expand All @@ -38,7 +45,10 @@ param(
[string]$InterfaceName,

[ValidateRange(1, 30)]
[int]$DnsTimeoutSeconds = 2
[int]$DnsTimeoutSeconds = 2,

[ValidateRange(1, 65535)]
[int]$TcpFallbackPort
)

function Get-WizardInventoryPath {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -545,8 +743,8 @@ $scanTargets | ForEach-Object -Parallel {
Hostname = $hostname
Reachable = $true
})
}
} -ThrottleLimit 20
} -ThrottleLimit 20
}

$discoveredDevices = @($discovered)

Expand Down Expand Up @@ -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]@{
Expand Down
57 changes: 57 additions & 0 deletions tests/NetDiagram.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading