diff --git a/CHANGELOG.md b/CHANGELOG.md index 8938539..149fe00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`Import-NmapScan` imports IPv6 hosts.** nmap `-oX` `address` elements with + `addrtype='ipv6'` are accepted when a host has no IPv4 address. Dual-stack + hosts keep a single node identity: IPv4 is preferred when both families are + present. Addresses are parsed with `[System.Net.IPAddress]::Parse` and stored + in canonical form; malformed addresses are skipped (same as invalid IPv4). + ### Changed - **`Import-Inventory` accepts dual-stack addresses.** Device `ip` and subnet `cidr` values may be IPv4 or IPv6. Both families are normalized (canonical diff --git a/NetDiagram-PS/Public/Import-NmapScan.ps1 b/NetDiagram-PS/Public/Import-NmapScan.ps1 index 876fe3b..b81d8f3 100644 --- a/NetDiagram-PS/Public/Import-NmapScan.ps1 +++ b/NetDiagram-PS/Public/Import-NmapScan.ps1 @@ -4,8 +4,13 @@ function Import-NmapScan { Imports topology from nmap XML output .DESCRIPTION Parses nmap -oX XML and returns a Topology with Nodes derived from hosts - whose status is up. Maps address/hostname/vendor/OS/ports. Role stays - unknown unless nmap data clearly indicates otherwise. + whose status is up. Reads IPv4 and IPv6 address elements (addrtype + ipv4/ipv6). When both families are present on one host, IPv4 is preferred + so the host keeps a single node identity; IPv6 is used only when no IPv4 + address is present. Addresses are validated with [System.Net.IPAddress]::Parse + and stored in canonical form; malformed addresses are skipped. Maps + hostname/vendor/OS/ports. Role stays unknown unless nmap data clearly + indicates otherwise. .PARAMETER Path Path to nmap XML file .EXAMPLE @@ -37,14 +42,26 @@ function Import-NmapScan { if ($state -and $state -ne 'up') { continue } $ipv4 = $null + $ipv6 = $null foreach ($addr in @($hostNode.address)) { - if ($addr.addrtype -eq 'ipv4' -and $addr.addr) { $ipv4 = $addr.addr; break } + if (-not $addr.addr) { continue } + if ($addr.addrtype -eq 'ipv4' -and -not $ipv4) { $ipv4 = $addr.addr } + elseif ($addr.addrtype -eq 'ipv6' -and -not $ipv6) { $ipv6 = $addr.addr } } - if (-not $ipv4) { continue } - try { $null = [System.Net.IPAddress]::Parse($ipv4) } catch { continue } + # One node per nmap host: prefer IPv4 when both families are present. + $chosen = if ($ipv4) { $ipv4 } else { $ipv6 } + if (-not $chosen) { continue } - $hostname = $ipv4 + try { + $parsed = [System.Net.IPAddress]::Parse($chosen) + } + catch { + continue + } + $ip = $parsed.ToString() + + $hostname = $ip if ($hostNode.PSObject.Properties['hostnames'] -and $hostNode.hostnames -and $hostNode.hostnames.PSObject.Properties['hostname'] -and $hostNode.hostnames.hostname) { $hn = @($hostNode.hostnames.hostname)[0] if ($hn.PSObject.Properties['name'] -and $hn.name) { $hostname = $hn.name } @@ -73,7 +90,7 @@ function Import-NmapScan { } $nodes += [pscustomobject]@{ - IP = $ipv4 + IP = $ip Hostname = $hostname Role = 'unknown' Vendor = $vendor diff --git a/README.md b/README.md index 364cb43..4c68b3f 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,8 @@ becomes `192.168.1.0/24` and `2001:db8::1/32` becomes `2001:db8::/32`). Uniqueness is the normalized address string, so compressed and expanded IPv6 forms that parse to the same address are duplicates. A dual-stack host that must appear on both families is two rows; when only one identity is needed, -prefer the IPv4 address. Mixed-family subnet placement does not match. +prefer the IPv4 address. `Import-NmapScan` applies the same rule: IPv4 when +both `addrtype` families are present on one nmap host, otherwise IPv6. Mixed-family subnet placement does not match. Unknown or omitted roles remain valid and are placed in the Access layer. See `examples/inventory-template.json` for a complete template. @@ -306,7 +307,7 @@ After importing the module, you have access to: | `Export-Topology` / `Import-Topology` | Persist topology to JSON | | `Export-NodeInventoryCsv` | Export inventory CSV | | `Export-Metadata` | Save scan statistics | -| `Import-NmapScan` | Import nmap XML | +| `Import-NmapScan` | Import nmap XML (IPv4 preferred; IPv6-only hosts imported) | | `Invoke-NetworkDiscovery` | Scan a CIDR without repo clone | | `Compare-NetworkScans` | Diff two topology snapshots | | `Invoke-SnmpWalk` | Direct SNMP queries | diff --git a/tests/NetDiagram.Tests.ps1 b/tests/NetDiagram.Tests.ps1 index 0f78b26..219d35f 100644 --- a/tests/NetDiagram.Tests.ps1 +++ b/tests/NetDiagram.Tests.ps1 @@ -621,6 +621,33 @@ Describe 'Import-NmapScan (#36 regression)' { { Import-NmapScan -Path $bad } | Should -Throw "*$bad*" { Import-NmapScan -Path $bad } | Should -Throw "*nmaprun*" } + + It 'Imports an IPv6-only up host and prefers IPv4 on dual-stack hosts (#63)' { + $fixture = Join-Path $PSScriptRoot 'fixtures' 'nmap-sample-ipv6.xml' + $topo = Import-NmapScan -Path $fixture + $topo.Nodes.Count | Should -Be 2 + + $v6Only = $topo.Nodes | Where-Object IP -eq '2001:db8::10' + $v6Only | Should -Not -BeNullOrEmpty + $v6Only.Hostname | Should -Be 'v6only.example.com' + $v6Only.Vendor | Should -Be 'V6Vendor' + $v6Only.OpenPorts | Should -Contain '22' + + $dual = $topo.Nodes | Where-Object IP -eq '192.168.1.30' + $dual | Should -Not -BeNullOrEmpty + $dual.Hostname | Should -Be 'dual.example.com' + ($topo.Nodes | Where-Object IP -eq '2001:db8::30') | Should -BeNullOrEmpty + + ($topo.Nodes | Where-Object IP -eq '2001:db8::99') | Should -BeNullOrEmpty + ($topo.Nodes | Where-Object IP -eq 'not-an-ip') | Should -BeNullOrEmpty + } + + It 'Help DESCRIPTION mentions IPv4 and IPv6 address elements' { + $help = Get-Help Import-NmapScan + $text = @($help.Description | ForEach-Object { $_.Text }) -join ' ' + $text | Should -Match 'IPv4' + $text | Should -Match 'IPv6' + } } Describe 'Topology persistence (#34 regression)' { diff --git a/tests/fixtures/nmap-sample-ipv6.xml b/tests/fixtures/nmap-sample-ipv6.xml new file mode 100644 index 0000000..b28343a --- /dev/null +++ b/tests/fixtures/nmap-sample-ipv6.xml @@ -0,0 +1,7 @@ + + +
+
+
+
+