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
13 changes: 8 additions & 5 deletions NetDiagram-PS/NetDiagram-PS.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,25 @@
#>

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$local:ErrorActionPreference = 'Stop'

# Module-level variables
$script:ModuleVersion = '1.4.1'

# Dot-source private helpers first (so public functions can call them),
# then public commands.
$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue)
$Public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue)
# then public commands. Fail fast if directories are missing — SilentlyContinue
# would hide a broken install as a half-loaded module.
$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction Stop)
$Public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction Stop)
if ($Private.Count -eq 0) { throw "NetDiagram-PS: no Private/*.ps1 files found under $PSScriptRoot" }
if ($Public.Count -eq 0) { throw "NetDiagram-PS: no Public/*.ps1 files found under $PSScriptRoot" }

foreach ($file in @($Private + $Public)) {
try {
. $file.FullName
}
catch {
Write-Error "Failed to import $($file.FullName): $_"
throw "Failed to import $($file.FullName): $_"
}
}

Expand Down
11 changes: 9 additions & 2 deletions NetDiagram-PS/Private/ConvertFrom-ArpText.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ function ConvertFrom-ArpText {
}
}
else {
throw "Unable to parse macOS ARP entry: $line"
Write-Warning "Skipping unparseable macOS ARP entry: $line"
continue
}
}
elseif ($Format -eq 'LinuxIp') {
Expand All @@ -53,7 +54,13 @@ function ConvertFrom-ArpText {
else {
if ($line -notmatch '\s+at\s+') { continue }
if ($line -notmatch '\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-fA-F:]{17})') {
throw "Unable to parse Linux arp entry: $line"
$parsed = $null
if ([System.Net.IPAddress]::TryParse(($line -replace '.*\(([^)]+)\).*', '$1'), [ref]$parsed)) {
Write-Warning "Skipping unparseable Linux arp entry (invalid IP): $line"
} else {
Write-Warning "Skipping unparseable Linux arp entry: $line"
}
continue
}
$entry = [pscustomobject]@{
IPAddress = $matches[1]; MACAddress = $matches[2].ToUpperInvariant()
Expand Down
7 changes: 6 additions & 1 deletion NetDiagram-PS/Private/Get-IPv6CidrScanTarget.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ function Get-IPv6CidrScanTarget {

$bytes = [byte[]]::new(16)
[Array]::Copy($baseBytes, $bytes, 16)
$bytes[15] = [byte]($baseBytes[15] + $offset)
$carry = $offset
for ($b = 15; $b -ge 0 -and $carry -gt 0; $b--) {
$sum = $bytes[$b] + $carry
$bytes[$b] = [byte]($sum -band 0xFF)
$carry = $sum -shr 8
}
$null = $targets.Add(([System.Net.IPAddress]::new($bytes)).ToString())
}

Expand Down
6 changes: 5 additions & 1 deletion NetDiagram-PS/Private/Wait-DnsLookupTask.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ function Wait-DnsLookupTask {

try {
if ($LookupTask.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) {
if ($LookupTask.IsFaulted) { throw $LookupTask.Exception.InnerException ?? $LookupTask.Exception }
if ($LookupTask.IsCanceled) { throw [System.OperationCanceledException]::new("DNS lookup canceled for $IPAddress") }
$result = $LookupTask.Result
return [pscustomobject]@{
IPAddress = $IPAddress
Expand All @@ -16,10 +18,12 @@ function Wait-DnsLookupTask {
}
}

Write-Verbose "DNS lookup for $IPAddress timed out after $TimeoutSeconds second(s)"
Write-Verbose "DNS lookup for $IPAddress timed out after $TimeoutSeconds second(s) — abandoning task (observing fault to prevent UnobservedTaskException)"
if ($LookupTask.IsFaulted) { $null = $LookupTask.Exception }
}
catch {
Write-Verbose "DNS lookup for $IPAddress failed: $($_.Exception.Message)"
if ($LookupTask.IsFaulted) { $null = $LookupTask.Exception }
}

return [pscustomobject]@{
Expand Down
2 changes: 1 addition & 1 deletion NetDiagram-PS/Public/Get-SnmpNeighbors.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ All SNMP attempts will be logged to verbose output.

Write-Verbose "Discovered $($discoveredEdges.Count) SNMP edges"
$summaryLine = "SNMP summary: queried $($snmpCounts.queried) answered $($snmpCounts.answered) skippedNoCredential $($snmpCounts.skippedNoCredential) skippedError $($snmpCounts.skippedError) skippedNoData $($snmpCounts.skippedNoData)"
Write-Host $summaryLine
Write-Information $summaryLine -Tags 'NetDiagram'
Write-Verbose $summaryLine

if ($TryPublic -and $publicAttempts -gt 0) {
Expand Down
4 changes: 2 additions & 2 deletions NetDiagram-PS/Public/Invoke-PortScan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function Invoke-PortScan {
}
}
catch {
# Banner grab failed, that's OK
Write-Verbose "Banner grab failed for ${ip}:$port — $($_.Exception.Message)"
}
}

Expand All @@ -99,7 +99,7 @@ function Invoke-PortScan {
Write-Verbose " Port $port - Open"
}
catch {
# Port closed or filtered
Write-Verbose "Port $port on $ip closed or filtered — $($_.Exception.Message)"
}
finally {
if ($connectResult -and $connectResult.AsyncWaitHandle) {
Expand Down
51 changes: 33 additions & 18 deletions NetDiagram-PS/Public/Invoke-SnmpWalk.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -80,24 +80,39 @@ function Invoke-SnmpWalk {
}

try {
$arguments = @(
'-' + $Version.ToLower()
'-c', $Community
'-t', $TimeoutSeconds.ToString()
$TargetIP
$OID
)

# Never emit the community string in verbose output. Redact '-c <community>'
# to '-c ****'. (The community is still visible in the process argument list
# while snmpwalk runs - see the SECURITY note in the function help.)
$redactedArgs = @(
'-' + $Version.ToLower()
'-c', '****'
'-t', $TimeoutSeconds.ToString()
$TargetIP
$OID
)
$isV3 = $Version.ToLower() -eq 'v3'
if ($isV3) {
$arguments = @(
'-v', '3'
'-u', $Community
'-t', $TimeoutSeconds.ToString()
$TargetIP
$OID
)
$redactedArgs = @(
'-v', '3'
'-u', '****'
'-t', $TimeoutSeconds.ToString()
$TargetIP
$OID
)
}
else {
$arguments = @(
'-' + $Version.ToLower()
'-c', $Community
'-t', $TimeoutSeconds.ToString()
$TargetIP
$OID
)
$redactedArgs = @(
'-' + $Version.ToLower()
'-c', '****'
'-t', $TimeoutSeconds.ToString()
$TargetIP
$OID
)
}
Write-Verbose "Running: $snmpWalkCmd $($redactedArgs -join ' ')"

$output = & $snmpWalkCmd @arguments 2>&1
Expand Down
4 changes: 3 additions & 1 deletion NetDiagram-PS/Public/Test-DeviceReachability.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ function Test-DeviceReachability {
$tcp.Dispose()
if ($ok) { $null = $resultsDict.TryAdd($node.IP, $true); continue }
} else { $tcp.Dispose() }
} catch {}
} catch {
Write-Verbose "TCP fallback probe failed for $($node.IP):$fallbackPort — $($_.Exception.Message)"
}
}
$null = $resultsDict.TryAdd($node.IP, $false)
}
Expand Down
83 changes: 83 additions & 0 deletions tests/NetDiagram.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,89 @@ Describe 'Export-DrawIO subnet container parenting' {
}
}

Describe 'P0 research fixes: IPv6 carry + SNMPv3 + loader + ARP + DNS task' {
It 'Get-IPv6CidrScanTarget carries overflow across bytes (ff + 1 = 100)' {
InModuleScope 'NetDiagram-PS' {
$r = Get-IPv6CidrScanTarget -NetworkAddress '2001:db8::ff' -PrefixLength 120 -ScanDepth Full
$r | Should -Contain '2001:db8::100'
$r | Should -Contain '2001:db8::1fe'
$r.Count | Should -Be 256
$r2 = Get-IPv6CidrScanTarget -NetworkAddress '2001:db8::fe' -PrefixLength 120 -ScanDepth Full
$r2[2] | Should -Be '2001:db8::100'
$r2[0] | Should -Be '2001:db8::fe'
}
}

It 'Invoke-SnmpWalk uses -u for v3 and -c for v2c (no -c leak for v3)' {
Mock Get-Command { [pscustomobject]@{ Source = '/usr/bin/snmpwalk' } } -ModuleName 'NetDiagram-PS' -ParameterFilter { $Name -like 'snmpwalk*' }
# v3 should not fail parameter validation; it should attempt the walk (which returns @() without throwing when binary is mocked)
{ Invoke-SnmpWalk -TargetIP '2001:db8::1' -Community 'myv3user' -Version v3 -WarningAction SilentlyContinue } | Should -Not -Throw
{ Invoke-SnmpWalk -TargetIP '10.0.0.1' -Community 'public' -Version v2c -WarningAction SilentlyContinue } | Should -Not -Throw
# Invalid version should still throw
{ Invoke-SnmpWalk -TargetIP '10.0.0.1' -Community 'public' -Version 'v9' } | Should -Throw
}

It 'ConvertFrom-ArpText skips bad macOS/LinuxArp lines with warning, still throws for LinuxIp malformed' {
InModuleScope 'NetDiagram-PS' {
$warn = @()
$r = @(ConvertFrom-ArpText -Lines @('? (192.168.1.99) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]', 'garbage no at') -Format MacOS -WarningVariable warn -WarningAction SilentlyContinue)
$r.Count | Should -Be 1
$r[0].IPAddress | Should -Be '192.168.1.99'
{ ConvertFrom-ArpText -Lines @('192.168.1.1 dev eth0 lladdr malformed REACHABLE') -Format LinuxIp } | Should -Throw '*Unable to parse Linux ip-neigh*'
$w2 = @()
$r2 = @(ConvertFrom-ArpText -Lines @('bad arp (xx) at yy on zz') -Format LinuxArp -WarningVariable w2 -WarningAction SilentlyContinue)
$r2.Count | Should -Be 0
$w2.Count | Should -BeGreaterThan 0
}
}

It 'Wait-DnsLookupTask observes faulted task and returns failure without throwing' {
InModuleScope 'NetDiagram-PS' {
$src = [System.Threading.Tasks.TaskCompletionSource[System.Net.IPHostEntry]]::new()
$src.SetException([InvalidOperationException]::new('fixture fault'))
$result = Wait-DnsLookupTask -LookupTask $src.Task -IPAddress '192.0.2.99' -TimeoutSeconds 1
$result.Success | Should -Be $false
$result.IPAddress | Should -Be '192.0.2.99'
$src2 = [System.Threading.Tasks.TaskCompletionSource[System.Net.IPHostEntry]]::new()
$result2 = Wait-DnsLookupTask -LookupTask $src2.Task -IPAddress '192.0.2.98' -TimeoutSeconds 1
$result2.Success | Should -Be $false
$result2.IPAddress | Should -Be '192.0.2.98'
}
}
}

Describe 'Lint hygiene: verbose catch and Information stream (1.4.1)' {
It 'Get-SnmpNeighbors emits SNMP summary to Information stream, not Host' {
$credPath = Join-Path $script:TestDataPath 'credmap-info.json'
'{}' | Out-File -FilePath $credPath -Force
$topology = Import-Inventory -Path $script:TestInventoryPath
Mock Invoke-SnmpWalk { @('1 = IpAddress: 192.168.1.10') } -ModuleName 'NetDiagram-PS'

$info = @()
$null = $topology | Get-SnmpNeighbors -CredentialMapPath $credPath -TryPublic -WarningAction SilentlyContinue -InformationVariable info -InformationAction Continue 2>&1

($info -join ' ') | Should -Match 'SNMP summary: queried'
$info | Should -Not -BeNullOrEmpty
}

It 'Invoke-PortScan does not throw when scanning a closed port and logs verbosely' {
# 192.0.2.1 is TEST-NET-1, guaranteed unroutable; port should be closed/filtered
{ Invoke-PortScan -IPAddress '192.0.2.2' -Ports @(65534) -TimeoutMs 200 -Verbose 4>&1 | Out-Null } | Should -Not -Throw
$result = Invoke-PortScan -IPAddress '192.0.2.2' -Ports @(65534) -TimeoutMs 200
$result.OpenPorts | Should -HaveCount 0
$result.IPAddress | Should -Be '192.0.2.2'
}

It 'Test-DeviceReachability TCP fallback failure is logged verbosely, not silently swallowed' {
$topology = [pscustomobject]@{
Nodes = @([pscustomobject]@{ IP='192.0.2.3'; Hostname='x'; Role='server'; Layer='Servers'; Vendor='X'; OS='X'; Reachable=$null })
Edges=@(); Subnets=@()
}
# ProbeScript forces TCP path by returning $false for ICMP, then TCP fallback to unreachable port
{ $topology | Test-DeviceReachability -TcpFallbackPort 65534 -TimeoutSeconds 1 -Verbose 4>&1 | Out-Null } | Should -Not -Throw
}
}

Describe 'Export-DrawIO dynamic container layout (#19 regression)' {
It 'Contains every node for a <Count>-node subnet' -TestCases @(
@{ Count = 0 }
Expand Down
Loading