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
16 changes: 12 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Per-function module split.** `NetDiagram-PS.psm1` is a loader that
dot-sources `Private/` then `Public/` (one function per file). See
[STRUCTURE.md](STRUCTURE.md).
- **`Invoke-NetworkDiscovery`.** Enumerate an IPv4 CIDR (Quick/Medium/Full,
/22 cap) and return a topology after `Install-Module` without a repo clone.
- **`Invoke-NetworkDiscovery`.** Enumerate a CIDR (IPv4 Quick/Medium/Full with
a /22 cap; IPv6 only /120 or longer) and return a topology after
`Install-Module` without a repo clone.
- **`Import-NmapScan`.** Ingest nmap `-oX` XML; up hosts become nodes.
- **`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).
- **Wizard and `Invoke-NetworkDiscovery` dual-stack scan.** Interface selection
prefers the active default-route interface (IPv4 default, else IPv6);
`-InterfaceName` still overrides. IPv4 keeps Quick/Medium/Full with the /22
cap. IPv6 is never swept as a /64: hosts come from the local neighbor table
(`Get-LocalARPTable` / ND) plus optional `-Cidr` for prefixes of /120 or
longer (at most 256 hosts). Oversized IPv6 `-Cidr` values fail immediately
with an error pointing at neighbor discovery.
- **`Export-Mermaid`.** Flowchart LR with subnet subgraphs; L2 solid,
L3-Inferred dotted.
- **`Export-NetBox`.** Devices CSV for NetBox bulk import (`-Site`,
Expand All @@ -40,8 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`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.
dual-stack host needs only one row. The wizard inventory write-out stays on
that contract.
- **Reachability is tri-state.** `Test-DeviceReachability` records `$true` /
`$false` / `$null` (indeterminate, e.g. local send failure). Optional
`-TcpFallbackPort` can promote ICMP-silent hosts. DrawIO paints unknown gray.
Expand Down
53 changes: 53 additions & 0 deletions NetDiagram-PS/Private/Get-IPv6CidrScanTarget.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
function Get-IPv6CidrScanTarget {
<#
.SYNOPSIS
Enumerate IPv6 addresses for a small CIDR (prefix /120 or longer).
.DESCRIPTION
IPv6 prefixes shorter than /120 — including a typical LAN /64 — are rejected
with an actionable error. A /64 is 2^64 addresses and must not be swept; use
Get-LocalARPTable (ND/ARP) for neighbor-assisted discovery instead.
Quick samples up to 8 addresses, Medium the first 50, Full all hosts up to 256
(the /120 cap). Host bits must already be cleared on NetworkAddress.
#>
param(
[Parameter(Mandatory)][string]$NetworkAddress,
[Parameter(Mandatory)][int]$PrefixLength,
[ValidateSet('Quick', 'Medium', 'Full')][string]$ScanDepth = 'Quick',
[int]$MinPrefixLength = 120,
[int]$MaxScanHosts = 256
)

if ($PrefixLength -lt $MinPrefixLength -or $PrefixLength -gt 128) {
$hostBits = 128 - $PrefixLength
throw "IPv6 CIDR '$NetworkAddress/$PrefixLength' (/$PrefixLength) is too large to sweep (2^$hostBits addresses). Do not sweep a /64 or other large prefix. Use a CIDR of /$MinPrefixLength or longer (at most $MaxScanHosts hosts), or discover IPv6 hosts from the local neighbor table with Get-LocalARPTable (ND/ARP) instead of a CIDR sweep."
}

$hostCount = [int][Math]::Pow(2, 128 - $PrefixLength)
$baseBytes = ([System.Net.IPAddress]::Parse($NetworkAddress)).GetAddressBytes()
if ($baseBytes.Count -ne 16) {
throw "Get-IPv6CidrScanTarget expected an IPv6 network address, got '$NetworkAddress'."
}

$limit = switch ($ScanDepth) {
'Quick' { [int][Math]::Min(8, $hostCount) }
'Medium' { [int][Math]::Min(50, $hostCount) }
default { [int][Math]::Min($MaxScanHosts, $hostCount) }
}

$targets = [System.Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $limit; $i++) {
$offset = if ($ScanDepth -eq 'Quick' -and $limit -gt 1) {
[int][Math]::Round(($i * ($hostCount - 1)) / ($limit - 1))
}
else {
$i
}

$bytes = [byte[]]::new(16)
[Array]::Copy($baseBytes, $bytes, 16)
$bytes[15] = [byte]($baseBytes[15] + $offset)
$null = $targets.Add(([System.Net.IPAddress]::new($bytes)).ToString())
}

return @($targets)
}
68 changes: 46 additions & 22 deletions NetDiagram-PS/Public/Invoke-NetworkDiscovery.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ function Invoke-NetworkDiscovery {
.SYNOPSIS
Discovers a topology for a single CIDR without requiring a repo clone
.DESCRIPTION
Enumerates host addresses for the given CIDR using the same prefix-aware math
as the wizard (Quick/Medium/Full with a /22 cap). Returns a Topology with
Nodes (Reachable = $null, to be probed via Test-DeviceReachability) and a
single Subnet entry. This makes discovery work after Install-Module.
Enumerates host addresses for the given CIDR. IPv4 uses the same prefix-aware
math as the wizard (Quick/Medium/Full with a /22 cap). IPv6 does not sweep a
/64 or any prefix shorter than /120 (256 hosts); those values fail immediately
with an error pointing at Get-LocalARPTable (ND/ARP) neighbor discovery.
Small IPv6 prefixes (/120–/128) are enumerated with the same Quick/Medium/Full
depths, capped at 256 hosts. Returns a Topology with Nodes (Reachable = $null,
to be probed via Test-DeviceReachability) and a single Subnet entry.
.PARAMETER Cidr
IPv4 CIDR to enumerate, e.g. 192.168.1.0/24
IPv4 or IPv6 CIDR to enumerate, e.g. 192.168.1.0/24 or 2001:db8::/120.
IPv6 prefixes shorter than /120 are rejected.
.PARAMETER ScanDepth
Quick (8 sampled), Medium (first 50), Full (all usable up to /22 = 1022)
Quick (8 sampled), Medium (first 50), Full (all usable IPv4 up to /22 = 1022,
or all IPv6 hosts in a /120–/128 up to 256)
.EXAMPLE
Invoke-NetworkDiscovery -Cidr 192.168.1.0/24 -ScanDepth Quick | Export-DrawIO -OutFile out.drawio
.EXAMPLE
Invoke-NetworkDiscovery -Cidr 2001:db8::/120 -ScanDepth Quick
#>
[CmdletBinding()]
param(
Expand All @@ -24,28 +31,45 @@ function Invoke-NetworkDiscovery {
[string]$ScanDepth = 'Quick'
)

if ([string]::IsNullOrWhiteSpace($Cidr)) { throw "Invalid CIDR value '$Cidr': expected IPv4 CIDR notation." }
$normalizedCidr = ConvertTo-NormalizedIPv4Cidr -Value $Cidr -Context "Invoke-NetworkDiscovery.Cidr"
if ([string]::IsNullOrWhiteSpace($Cidr)) { throw "Invalid CIDR value '$Cidr': expected IPv4 or IPv6 CIDR notation." }
$normalizedCidr = ConvertTo-NormalizedCidr -Value $Cidr -Context "Invoke-NetworkDiscovery.Cidr"
$parts = $normalizedCidr -split '/'
$networkIp = $parts[0]
$prefix = [int]$parts[1]
$parsed = [System.Net.IPAddress]::Parse($networkIp)

$networkVal = ConvertTo-UInt32Address -IPAddress $networkIp
$mask = Get-PrefixMask -PrefixLength $prefix
$broadcastVal = [uint32]($networkVal -bor ((-bnot $mask) -band [uint32]4294967295))
if ($parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) {
$hosts = @(Get-IPv6CidrScanTarget -NetworkAddress $networkIp -PrefixLength $prefix -ScanDepth $ScanDepth)
$nodes = foreach ($ip in $hosts) {
[pscustomobject]@{
IP = $ip
Hostname = $ip
Role = 'unknown'
Vendor = 'Unknown'
OS = 'Unknown'
Layer = Get-LayerFromRole -Role 'unknown'
Reachable = $null
}
}
}
else {
$networkVal = ConvertTo-UInt32Address -IPAddress $networkIp
$mask = Get-PrefixMask -PrefixLength $prefix
$broadcastVal = [uint32]($networkVal -bor ((-bnot $mask) -band [uint32]4294967295))

$hosts = Get-SubnetScanTarget -NetworkValue $networkVal -BroadcastValue $broadcastVal -ScanDepth $ScanDepth
$hosts = Get-SubnetScanTarget -NetworkValue $networkVal -BroadcastValue $broadcastVal -ScanDepth $ScanDepth

$nodes = foreach ($h in $hosts) {
$ip = ConvertFrom-UInt32Address -Value $h
[pscustomobject]@{
IP = $ip
Hostname = $ip
Role = 'unknown'
Vendor = 'Unknown'
OS = 'Unknown'
Layer = Get-LayerFromRole -Role 'unknown'
Reachable = $null
$nodes = foreach ($h in $hosts) {
$ip = ConvertFrom-UInt32Address -Value $h
[pscustomobject]@{
IP = $ip
Hostname = $ip
Role = 'unknown'
Vendor = 'Unknown'
OS = 'Unknown'
Layer = Get-LayerFromRole -Role 'unknown'
Reachable = $null
}
}
}

Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ pwsh .\examples\New-NetworkDiagram.ps1
```

The wizard performs the following actions:
- Discovers the network configuration
- Scans for active devices
- Discovers the network configuration (IPv4 and IPv6 on the default-route interface)
- Scans for active IPv4 devices (Quick/Medium/Full, /22 cap)
- Adds IPv6 neighbors from the local ND/ARP table (does **not** sweep a /64)
- Tests connectivity with `Test-DeviceReachability` (reachable / unreachable / unknown)
- Generates a draw.io diagram
- Saves an inventory file for later refinement
- Saves an inventory file for later refinement (`Import-Inventory` accepts the v6 rows)

---

Expand Down Expand Up @@ -91,8 +92,22 @@ pwsh .\examples\New-NetworkDiagram.ps1 -DnsTimeoutSeconds 1

# ICMP-silent gateway: fall back to a TCP connect on port 443
pwsh .\examples\New-NetworkDiagram.ps1 -TcpFallbackPort 443

# Optional: sweep a small IPv6 prefix (/120 or longer). A /64 is rejected.
pwsh .\examples\New-NetworkDiagram.ps1 -Cidr 2001:db8::/120
```

**Dual-stack discovery:** the wizard prefers the interface of the active default
route (IPv4 default if present, otherwise IPv6). `-InterfaceName` still
overrides. IPv4 scanning is unchanged (Quick / Medium / Full, capped at a /22).
IPv6 is **not** enumerated from the interface prefix — a LAN `/64` is 2^64
addresses. Instead, IPv6 hosts are taken from the local neighbor table
(`Get-LocalARPTable`, ND/ARP). Pass `-Cidr` only for a small IPv6 prefix
(`/120`–`/128`, at most 256 hosts). `Invoke-NetworkDiscovery -Cidr` accepts
IPv6 the same way and throws an actionable error for oversized prefixes
(pointing at `Get-LocalARPTable`) instead of hanging. The saved inventory stays
importable with `Import-Inventory`.

### Method 2: Manual Workflow (manual inventory)

```powershell
Expand Down Expand Up @@ -314,7 +329,7 @@ After importing the module, you have access to:
| `Export-NodeInventoryCsv` | Export inventory CSV |
| `Export-Metadata` | Save scan statistics |
| `Import-NmapScan` | Import nmap XML (IPv4 preferred; IPv6-only hosts imported) |
| `Invoke-NetworkDiscovery` | Scan a CIDR without repo clone |
| `Invoke-NetworkDiscovery` | Scan an IPv4 CIDR, or a small IPv6 CIDR (/120+); larger v6 prefixes are rejected |
| `Compare-NetworkScans` | Diff two topology snapshots |
| `Invoke-SnmpWalk` | Direct SNMP queries |

Expand Down
1 change: 1 addition & 0 deletions STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ NetDiagram-PS/
│ ├── ConvertTo-NormalizedIPv4Address.ps1
│ ├── ConvertTo-NormalizedIPv4Cidr.ps1
│ ├── ConvertTo-UInt32Address.ps1
│ ├── Get-IPv6CidrScanTarget.ps1
│ ├── Get-LayerFromRole.ps1
│ ├── Get-PrefixMask.ps1
│ ├── Get-ServiceName.ps1
Expand Down
Loading