diff --git a/CHANGELOG.md b/CHANGELOG.md index f285781..8938539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **`Import-Inventory` accepts dual-stack addresses.** Device `ip` and subnet + `cidr` values may be IPv4 or IPv6. Both families are normalized (canonical + string form; CIDR host bits cleared). Uniqueness is the normalized address + string. Each device still has a single identity address; prefer IPv4 when a + dual-stack host needs only one row. Discovery (`Invoke-NetworkDiscovery`) + remains IPv4-only. + ### Fixed - **`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 diff --git a/NetDiagram-PS/Private/ConvertTo-NormalizedCidr.ps1 b/NetDiagram-PS/Private/ConvertTo-NormalizedCidr.ps1 new file mode 100644 index 0000000..9d4cca0 --- /dev/null +++ b/NetDiagram-PS/Private/ConvertTo-NormalizedCidr.ps1 @@ -0,0 +1,42 @@ +function ConvertTo-NormalizedCidr { + param( + [Parameter(Mandatory)][string]$Value, + [Parameter(Mandatory)][string]$Context + ) + + if ($Value -notmatch '^(.+)\/(\d{1,3})$') { + throw "Invalid inventory $Context '$Value': expected IPv4 or IPv6 CIDR notation." + } + + $address = ConvertTo-NormalizedIPAddress -Value $matches[1] -Context $Context + $prefixLength = [int]$matches[2] + $parsed = [System.Net.IPAddress]::Parse($address) + $maxPrefix = if ($parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { + 32 + } + else { + 128 + } + if ($prefixLength -gt $maxPrefix) { + $familyName = if ($maxPrefix -eq 32) { 'IPv4' } else { 'IPv6' } + throw "Invalid inventory $Context '$Value': $familyName prefix must be between 0 and $maxPrefix." + } + + $bytes = $parsed.GetAddressBytes() + $bitsRemaining = $prefixLength + for ($index = 0; $index -lt $bytes.Count; $index++) { + $mask = if ($bitsRemaining -ge 8) { + 255 + } + elseif ($bitsRemaining -le 0) { + 0 + } + else { + 256 - [Math]::Pow(2, 8 - $bitsRemaining) + } + $bytes[$index] = [byte]($bytes[$index] -band [int]$mask) + $bitsRemaining -= 8 + } + + return "$([System.Net.IPAddress]::new($bytes))/$prefixLength" +} diff --git a/NetDiagram-PS/Private/ConvertTo-NormalizedIPAddress.ps1 b/NetDiagram-PS/Private/ConvertTo-NormalizedIPAddress.ps1 new file mode 100644 index 0000000..7232404 --- /dev/null +++ b/NetDiagram-PS/Private/ConvertTo-NormalizedIPAddress.ps1 @@ -0,0 +1,12 @@ +function ConvertTo-NormalizedIPAddress { + param( + [Parameter(Mandatory)][string]$Value, + [Parameter(Mandatory)][string]$Context + ) + + $address = $null + if (-not [System.Net.IPAddress]::TryParse($Value, [ref]$address)) { + throw "Invalid inventory $Context '$Value': expected an IPv4 or IPv6 address." + } + return $address.ToString() +} diff --git a/NetDiagram-PS/Public/Import-Inventory.ps1 b/NetDiagram-PS/Public/Import-Inventory.ps1 index 7d47433..dcd5fd5 100644 --- a/NetDiagram-PS/Public/Import-Inventory.ps1 +++ b/NetDiagram-PS/Public/Import-Inventory.ps1 @@ -4,10 +4,10 @@ function Import-Inventory { Imports network inventory from JSON file .DESCRIPTION Reads a JSON inventory file and creates a Topology object with Nodes and Subnets. - The stable inventory contract is IPv4-only. Device addresses and subnet CIDRs - are validated and normalized; subnet host bits are cleared. Duplicate device - addresses are rejected. Unknown or omitted roles are retained and placed in the - Access layer. + The inventory contract is dual-stack. Each device has one identity address + (IPv4 or IPv6). Device addresses and subnet CIDRs are validated and normalized; + subnet host bits are cleared. Duplicate normalized addresses are rejected. + Unknown or omitted roles are retained and placed in the Access layer. .PARAMETER Path Path to the inventory JSON file .EXAMPLE @@ -56,7 +56,7 @@ function Import-Inventory { throw "Invalid inventory knownDevices[$deviceIndex]: missing string 'ip'" } - $normalizedIP = ConvertTo-NormalizedIPv4Address -Value $device.ip -Context "knownDevices[$deviceIndex].ip" + $normalizedIP = ConvertTo-NormalizedIPAddress -Value $device.ip -Context "knownDevices[$deviceIndex].ip" if (-not $deviceAddresses.Add($normalizedIP)) { throw "Invalid inventory knownDevices[$deviceIndex].ip '$normalizedIP': duplicate device address" } @@ -88,7 +88,7 @@ function Import-Inventory { throw "Invalid inventory subnets[$subnetIndex]: missing string 'cidr'" } - $normalizedCidr = ConvertTo-NormalizedIPv4Cidr -Value $subnet.cidr -Context "subnets[$subnetIndex].cidr" + $normalizedCidr = ConvertTo-NormalizedCidr -Value $subnet.cidr -Context "subnets[$subnetIndex].cidr" if (-not $subnetCidrs.Add($normalizedCidr)) { throw "Invalid inventory subnets[$subnetIndex].cidr '$normalizedCidr': duplicate subnet" } diff --git a/README.md b/README.md index d7c5e85..364cb43 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,13 @@ Create a JSON file with your network devices: "role": "server", "vendor": "Dell", "os": "Ubuntu 22.04" + }, + { + "ip": "2001:db8::1", + "hostname": "my-v6-router", + "role": "router", + "vendor": "Cisco", + "os": "IOS 15.x" } ], "subnets": [ @@ -166,6 +173,11 @@ Create a JSON file with your network devices: "cidr": "192.168.1.0/24", "label": "Main Network", "vlan": 1 + }, + { + "cidr": "2001:db8::/32", + "label": "IPv6 Lab", + "vlan": 100 } ] } @@ -173,12 +185,17 @@ Create a JSON file with your network devices: Supported roles: `router`, `core-router`, `distribution`, `switch`, `server`, `workstation` -The stable inventory contract is IPv4-only. `knownDevices` is required and must be a -JSON array; `subnets`, when present, must also be an array. Each device requires a unique -IPv4 `ip`, and each subnet requires an IPv4 `cidr` with a prefix from 0 through 32. -Addresses are normalized, and host bits in CIDRs are cleared (for example, -`192.168.1.42/24` becomes `192.168.1.0/24`). IPv6 is rejected explicitly. Unknown or -omitted roles remain valid and are placed in the Access layer. +The inventory contract is dual-stack. `knownDevices` is required and must be a +JSON array; `subnets`, when present, must also be an array. Each device has one +identity `ip` (IPv4 or IPv6), and each subnet requires a matching-family `cidr` +(IPv4 prefix 0–32, IPv6 prefix 0–128). Addresses are normalized to canonical +form, and host bits in CIDRs are cleared (for example, `192.168.1.42/24` +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. +Unknown or omitted roles remain valid and are placed in the Access layer. See `examples/inventory-template.json` for a complete template. diff --git a/STRUCTURE.md b/STRUCTURE.md index 1064cd4..f416f21 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -29,6 +29,8 @@ NetDiagram-PS/ ├── Private/ # Internal helpers, not exported │ ├── ConvertFrom-ArpText.ps1 │ ├── ConvertFrom-UInt32Address.ps1 +│ ├── ConvertTo-NormalizedCidr.ps1 +│ ├── ConvertTo-NormalizedIPAddress.ps1 │ ├── ConvertTo-NormalizedIPv4Address.ps1 │ ├── ConvertTo-NormalizedIPv4Cidr.ps1 │ ├── ConvertTo-UInt32Address.ps1 diff --git a/examples/inventory-template.json b/examples/inventory-template.json index 0901da4..1056e99 100644 --- a/examples/inventory-template.json +++ b/examples/inventory-template.json @@ -27,6 +27,13 @@ "role": "workstation", "vendor": "HP", "os": "Windows 11" + }, + { + "ip": "2001:db8::1", + "hostname": "my-v6-router", + "role": "router", + "vendor": "Cisco", + "os": "IOS 15.x" } ], "subnets": [ @@ -34,6 +41,11 @@ "cidr": "192.168.1.0/24", "label": "Main Network", "vlan": 1 + }, + { + "cidr": "2001:db8::/32", + "label": "IPv6 Lab", + "vlan": 100 } ] } diff --git a/tests/NetDiagram.Tests.ps1 b/tests/NetDiagram.Tests.ps1 index d955f17..0f78b26 100644 --- a/tests/NetDiagram.Tests.ps1 +++ b/tests/NetDiagram.Tests.ps1 @@ -122,11 +122,54 @@ Describe 'Import-Inventory' { { Import-Inventory -Path $path } | Should -Throw '*knownDevices*0*ip*' } - It 'Rejects IPv6 explicitly' { + It 'Accepts an IPv6 device address and retains the normalized form' { $path = Join-Path $TestDrive 'ipv6.json' - '{"knownDevices":[{"ip":"2001:db8::1"}]}' | Set-Content -Path $path + '{"knownDevices":[{"ip":"2001:0DB8:0000:0000:0000:0000:0000:0001"}]}' | Set-Content -Path $path - { Import-Inventory -Path $path } | Should -Throw '*IPv6 is not supported*' + $topology = Import-Inventory -Path $path + $topology.Nodes | Should -HaveCount 1 + $topology.Nodes[0].IP | Should -Be '2001:db8::1' + } + + It 'Accepts a dual-stack inventory with both family CIDRs' { + $path = Join-Path $TestDrive 'dual-stack.json' + @{ + knownDevices = @( + @{ ip = '192.168.1.1'; hostname = 'v4-gw'; role = 'router' } + @{ ip = '2001:db8::10'; hostname = 'v6-gw'; role = 'router' } + ) + subnets = @( + @{ cidr = '192.168.1.42/24'; label = 'v4-net' } + @{ cidr = '2001:db8::1/32'; label = 'v6-net' } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -Path $path + + $topology = Import-Inventory -Path $path + $topology.Nodes.IP | Should -Contain '192.168.1.1' + $topology.Nodes.IP | Should -Contain '2001:db8::10' + $topology.Subnets.CIDR | Should -Contain '192.168.1.0/24' + $topology.Subnets.CIDR | Should -Contain '2001:db8::/32' + } + + It 'Rejects an invalid IPv6 address and identifies its entry' { + $path = Join-Path $TestDrive 'bad-ipv6.json' + '{"knownDevices":[{"ip":"2001:db8::zzzz"}]}' | Set-Content -Path $path + + { Import-Inventory -Path $path } | Should -Throw '*knownDevices*0*ip*' + } + + It 'Rejects malformed mixed-family address junk' { + $path = Join-Path $TestDrive 'mixed-junk.json' + '{"knownDevices":[{"ip":"192.168.1.1:2001:db8::1"}]}' | Set-Content -Path $path + + { Import-Inventory -Path $path } | Should -Throw '*knownDevices*0*ip*' + } + + It 'Rejects duplicate IPv6 addresses across compressed and expanded forms' { + $path = Join-Path $TestDrive 'duplicate-v6.json' + '{"knownDevices":[{"ip":"2001:db8::1"},{"ip":"2001:0db8:0000:0000:0000:0000:0000:0001"}]}' | Set-Content -Path $path + + { Import-Inventory -Path $path } | Should -Throw '*duplicate device address*' } It 'Rejects duplicate normalized device addresses' { @@ -143,6 +186,21 @@ Describe 'Import-Inventory' { { Import-Inventory -Path $path } | Should -Throw '*subnets*0*cidr*' } + It 'Rejects an invalid IPv6 prefix and identifies its entry' { + $path = Join-Path $TestDrive 'bad-v6-prefix.json' + '{"knownDevices":[],"subnets":[{"cidr":"2001:db8::/129"}]}' | Set-Content -Path $path + + { Import-Inventory -Path $path } | Should -Throw '*subnets*0*cidr*' + } + + It 'Normalizes IPv6 CIDR host bits' { + $path = Join-Path $TestDrive 'v6-cidr-hostbits.json' + '{"knownDevices":[],"subnets":[{"cidr":"2001:db8:aaaa:bbbb:cccc:dddd:eeee:ffff/32"}]}' | Set-Content -Path $path + + $topology = Import-Inventory -Path $path + $topology.Subnets[0].CIDR | Should -Be '2001:db8::/32' + } + It 'Normalizes subnet host bits and maps an unknown role to Access' { $path = Join-Path $TestDrive 'normalized.json' '{"knownDevices":[{"ip":"192.168.1.42","role":"printer"}],"subnets":[{"cidr":"192.168.1.42/24"}]}' | Set-Content -Path $path @@ -1468,6 +1526,36 @@ Describe 'Export-DrawIO subnet container parenting' { $v6Node.mxCell.parent | Should -Be $v6Container.id $v4Node.mxCell.parent | Should -Be $v4Container.id } + + It 'Parents imported IPv6 nodes into matching IPv6 containers after Import-Inventory' { + $path = Join-Path $TestDrive 'import-v6-parent.json' + @{ + knownDevices = @( + @{ ip = '2001:db8::10'; hostname = 'v6-node'; role = 'switch' } + @{ ip = '192.168.1.10'; hostname = 'v4-node'; role = 'switch' } + ) + subnets = @( + @{ cidr = '2001:db8::/32'; label = 'v6-net'; vlan = 100 } + @{ cidr = '192.168.1.0/24'; label = 'v4-net'; vlan = 1 } + ) + } | ConvertTo-Json -Depth 10 | Set-Content -Path $path + + $topology = Import-Inventory -Path $path + $drawioPath = Join-Path $script:TestDataPath 'ipv6-import-containers.drawio' + $topology | Export-DrawIO -OutFile $drawioPath + + $xml = [xml](Get-Content -Path $drawioPath -Raw) + $containers = @($xml.SelectNodes("//mxCell[contains(@style,'swimlane')]")) + $v6Container = $containers | Where-Object { $_.value -match 'v6-net' } + $v4Container = $containers | Where-Object { $_.value -match 'v4-net' } + $v6Container | Should -Not -BeNullOrEmpty + $v4Container | Should -Not -BeNullOrEmpty + + $v6Node = $xml.SelectSingleNode("//UserObject[@ip='2001:db8::10']") + $v4Node = $xml.SelectSingleNode("//UserObject[@ip='192.168.1.10']") + $v6Node.mxCell.parent | Should -Be $v6Container.id + $v4Node.mxCell.parent | Should -Be $v4Container.id + } } Describe 'Export-DrawIO dynamic container layout (#19 regression)' {