From 297989aa9795b4d5e3842bef10df43ddea7b240d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 20:03:40 +0000 Subject: [PATCH] fix(netbox): map IPv6 to primary_ip6; keep unknown reachability Export-NetBox put every node IP in primary_ip4 and treated Reachable=$null as offline. Place IPv6 in primary_ip6 (v6-only leaves primary_ip4 empty), leave status empty when reachability is unknown, and cover v4/v6/dual-stack plus the null-reachable regression in Pester. Closes #72. Co-authored-by: Jacobrakai --- CHANGELOG.md | 3 ++ NetDiagram-PS/Public/Export-NetBox.ps1 | 23 ++++++-- README.md | 4 +- tests/NetDiagram.Tests.ps1 | 75 +++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c8620..b150337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `actions/checkout@v7`, and asserts offline dual-stack DrawIO parenting. ### Fixed +- **`Export-NetBox` IPv6 and unknown reachability.** IPv6 identity addresses + go in `primary_ip6` (v6-only leaves `primary_ip4` empty). `Reachable=$null` + no longer maps to `offline`; status is left empty (NetBox has no unknown). - **`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). diff --git a/NetDiagram-PS/Public/Export-NetBox.ps1 b/NetDiagram-PS/Public/Export-NetBox.ps1 index f63df0a..e8a6e7e 100644 --- a/NetDiagram-PS/Public/Export-NetBox.ps1 +++ b/NetDiagram-PS/Public/Export-NetBox.ps1 @@ -3,9 +3,10 @@ function Export-NetBox { .SYNOPSIS Exports topology to NetBox bulk-import CSV .DESCRIPTION - Writes devices CSV for NetBox with columns name,role,manufacturer,device_type,site,status,primary_ip4. + Writes devices CSV for NetBox with columns name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6. Role mapping: router/core-router->router, switch/distribution->switch, server->server, workstation->workstation. - Vendor Unknown -> empty manufacturer. Reachable true->active else offline. + Vendor Unknown -> empty manufacturer. Reachable true->active, false->offline; $null leaves status empty + (NetBox has no unknown). Each node has one identity IP: IPv4 -> primary_ip4, IPv6 -> primary_ip6. .PARAMETER Topology Topology object with Nodes .PARAMETER OutFile @@ -58,7 +59,18 @@ function Export-NetBox { $mappedRole = if ($roleMap.ContainsKey($roleKey)) { $roleMap[$roleKey] } else { 'unknown' } $manufacturer = if ($node.PSObject.Properties['Vendor'] -and $node.Vendor -and $node.Vendor -ne 'Unknown') { [string]$node.Vendor } else { '' } $deviceType = if ($node.PSObject.Properties['OS'] -and $node.OS -and $node.OS -ne 'Unknown') { [string]$node.OS } else { 'unknown' } - $status = if ($node.Reachable -eq $true) { 'active' } else { 'offline' } + $status = if ($node.Reachable -eq $true) { 'active' } elseif ($node.Reachable -eq $false) { 'offline' } else { '' } + $primaryIp4 = '' + $primaryIp6 = '' + $parsedIp = $null + if ([System.Net.IPAddress]::TryParse([string]$node.IP, [ref]$parsedIp)) { + if ($parsedIp.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { + $primaryIp6 = [string]$node.IP + } + elseif ($parsedIp.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { + $primaryIp4 = [string]$node.IP + } + } [pscustomobject]@{ name = [string]$node.Hostname role = $mappedRole @@ -66,12 +78,13 @@ function Export-NetBox { device_type = $deviceType site = $Site status = $status - primary_ip4 = [string]$node.IP + primary_ip4 = $primaryIp4 + primary_ip6 = $primaryIp6 } } if ($null -eq $rows -or @($rows).Count -eq 0) { - 'name,role,manufacturer,device_type,site,status,primary_ip4' | Out-File -FilePath $OutFile -Encoding utf8 -Force + 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6' | Out-File -FilePath $OutFile -Encoding utf8 -Force } else { $rows | Export-Csv -LiteralPath $OutFile -NoTypeInformation -Encoding utf8 -Force diff --git a/README.md b/README.md index 37e9aad..a6d6483 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,9 @@ Check the `examples/` directory: | `workstation` | `workstation` | | *(other)* | `unknown` | -Reachable `true` → `active`, else `offline`; Vendor `Unknown` → empty `manufacturer`. +Reachable `true` → `active`, `false` → `offline`, `$null` → empty `status`. +IPv4 identity → `primary_ip4`; IPv6 identity → `primary_ip6` (the other column empty). +Vendor `Unknown` → empty `manufacturer`. --- diff --git a/tests/NetDiagram.Tests.ps1 b/tests/NetDiagram.Tests.ps1 index 0959c11..413b936 100644 --- a/tests/NetDiagram.Tests.ps1 +++ b/tests/NetDiagram.Tests.ps1 @@ -2110,7 +2110,7 @@ Describe 'Export-NetBox (#38 regression)' { $path = Join-Path $script:TestDataPath 'netbox.csv' $topo | Export-NetBox -OutFile $path -Site 'HQ' -Force $header = (Get-Content $path -TotalCount 1) -replace '"','' - $header | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4' + $header | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6' $rows = Import-Csv $path $rows.Count | Should -Be 6 ($rows | Where-Object name -eq 'r1').role | Should -Be 'router' @@ -2118,7 +2118,7 @@ Describe 'Export-NetBox (#38 regression)' { ($rows | Where-Object name -eq 'srv,1').name | Should -Be 'srv,1' ($rows | Where-Object name -eq 'r1').status | Should -Be 'active' ($rows | Where-Object name -eq 's1').status | Should -Be 'offline' - ($rows | Where-Object name -eq 'srv,1').status | Should -Be 'offline' + ($rows | Where-Object name -eq 'srv,1').status | Should -Be '' ($rows | Where-Object name -eq 'd1').role | Should -Be 'switch' ($rows | Where-Object primary_ip4 -eq '10.0.0.4').role | Should -Be 'workstation' } @@ -2128,6 +2128,7 @@ Describe 'Export-NetBox (#38 regression)' { $path = Join-Path $script:TestDataPath 'netbox-empty.csv' $topo | Export-NetBox -OutFile $path -Site 'HQ' -Force (Get-Content $path | Measure-Object).Count | Should -Be 1 + ((Get-Content $path -TotalCount 1) -replace '"','') | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6' { $topo | Export-NetBox -OutFile $path -Site 'HQ' } | Should -Throw "*already exists*Use -Force*" $whatIf = Join-Path $script:TestDataPath 'netbox-whatif.csv' $topo | Export-NetBox -OutFile $whatIf -Site 'HQ' -WhatIf @@ -2135,6 +2136,76 @@ Describe 'Export-NetBox (#38 regression)' { } } +Describe 'Export-NetBox (#72 IPv6 columns and unknown reachability)' { + It 'Puts an IPv6-only node in primary_ip6 and leaves primary_ip4 empty' { + $topo = [pscustomobject]@{ + Nodes = @( + [pscustomobject]@{ IP='2001:db8::1'; Hostname='v6-router'; Role='router'; Vendor='Cisco'; OS='IOS'; Layer='Core'; Reachable=$true } + ) + Edges=@(); Subnets=@() + } + $path = Join-Path $script:TestDataPath 'netbox-v6-only.csv' + $topo | Export-NetBox -OutFile $path -Site 'Lab' -Force + $row = Import-Csv $path + $row.primary_ip6 | Should -Be '2001:db8::1' + $row.primary_ip4 | Should -Be '' + $row.status | Should -Be 'active' + } + + It 'Leaves IPv4-only nodes in primary_ip4 with empty primary_ip6' { + $topo = [pscustomobject]@{ + Nodes = @( + [pscustomobject]@{ IP='192.0.2.10'; Hostname='v4-server'; Role='server'; Vendor='Dell'; OS='Ubuntu'; Layer='Servers'; Reachable=$false } + ) + Edges=@(); Subnets=@() + } + $path = Join-Path $script:TestDataPath 'netbox-v4-only.csv' + $topo | Export-NetBox -OutFile $path -Site 'Lab' -Force + $row = Import-Csv $path + $row.primary_ip4 | Should -Be '192.0.2.10' + $row.primary_ip6 | Should -Be '' + $row.status | Should -Be 'offline' + } + + It 'Maps a dual-stack fixture by address family (one identity IP per node)' { + $topo = [pscustomobject]@{ + Nodes = @( + [pscustomobject]@{ IP='192.0.2.20'; Hostname='dual-v4'; Role='router'; Vendor='Cisco'; OS='IOS'; Layer='Core'; Reachable=$true } + [pscustomobject]@{ IP='2001:db8::20'; Hostname='dual-v6'; Role='router'; Vendor='Cisco'; OS='IOS'; Layer='Core'; Reachable=$true } + ) + Edges=@(); Subnets=@() + } + $path = Join-Path $script:TestDataPath 'netbox-dual-stack.csv' + $topo | Export-NetBox -OutFile $path -Site 'Lab' -Force + $header = (Get-Content $path -TotalCount 1) -replace '"','' + $header | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6' + $rows = Import-Csv $path + $v4 = $rows | Where-Object name -eq 'dual-v4' + $v6 = $rows | Where-Object name -eq 'dual-v6' + $v4.primary_ip4 | Should -Be '192.0.2.20' + $v4.primary_ip6 | Should -Be '' + $v6.primary_ip4 | Should -Be '' + $v6.primary_ip6 | Should -Be '2001:db8::20' + } + + It 'Does not export Reachable=$null as offline' { + $topo = [pscustomobject]@{ + Nodes = @( + [pscustomobject]@{ IP='192.0.2.30'; Hostname='untested-v4'; Role='switch'; Vendor='X'; OS='X'; Layer='Access'; Reachable=$null } + [pscustomobject]@{ IP='2001:db8::30'; Hostname='untested-v6'; Role='switch'; Vendor='X'; OS='X'; Layer='Access'; Reachable=$null } + ) + Edges=@(); Subnets=@() + } + $path = Join-Path $script:TestDataPath 'netbox-null-reachable.csv' + $topo | Export-NetBox -OutFile $path -Site 'Lab' -Force + $rows = Import-Csv $path + foreach ($row in $rows) { + $row.status | Should -Be '' + $row.status | Should -Not -Be 'offline' + } + } +} + Describe 'Export overwrite protection (#27 regression)' { It 'Refuses to overwrite an existing file without -Force' { $topology = Import-Inventory -Path $script:TestInventoryPath