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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions NetDiagram-PS/Private/ConvertTo-NormalizedCidr.ps1
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions NetDiagram-PS/Private/ConvertTo-NormalizedIPAddress.ps1
Original file line number Diff line number Diff line change
@@ -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()
}
12 changes: 6 additions & 6 deletions NetDiagram-PS/Public/Import-Inventory.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
}
Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,26 +159,43 @@ 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": [
{
"cidr": "192.168.1.0/24",
"label": "Main Network",
"vlan": 1
},
{
"cidr": "2001:db8::/32",
"label": "IPv6 Lab",
"vlan": 100
}
]
}
```

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.

Expand Down
2 changes: 2 additions & 0 deletions STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions examples/inventory-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,25 @@
"role": "workstation",
"vendor": "HP",
"os": "Windows 11"
},
{
"ip": "2001:db8::1",
"hostname": "my-v6-router",
"role": "router",
"vendor": "Cisco",
"os": "IOS 15.x"
}
],
"subnets": [
{
"cidr": "192.168.1.0/24",
"label": "Main Network",
"vlan": 1
},
{
"cidr": "2001:db8::/32",
"label": "IPv6 Lab",
"vlan": 100
}
]
}
94 changes: 91 additions & 3 deletions tests/NetDiagram.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand All @@ -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
Expand Down Expand Up @@ -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)' {
Expand Down