Find-NetworkDevice.ps1
|
function Find-NetworkDevice { <# .SYNOPSIS Discovers network devices using ARP, resolving hostnames and MAC vendors. .DESCRIPTION Scans the local network to find devices by querying the ARP cache. Can optionally ping-sweep a subnet to populate the cache first. Resolves hostnames via DNS and looks up MAC vendor information. Works on both macOS and Windows. .PARAMETER Subnet CIDR notation subnet to scan (e.g., "192.168.1.0/24"). Defaults to the current network's subnet. .PARAMETER IPv6 Include IPv6 addresses in the results. .PARAMETER Force Ping-sweep the subnet first to populate the ARP cache. Without this, only devices already in the cache are shown. .EXAMPLE Find-NetworkDevice Lists devices currently in the ARP cache on the local subnet. .EXAMPLE Find-NetworkDevice -Force Ping-sweeps the local subnet first, then lists all responding devices. .EXAMPLE Find-NetworkDevice -Subnet "192.168.1.0/24" -Force Scans a specific subnet. .EXAMPLE Find-NetworkDevice -IPv6 Includes IPv6 addresses in the output. #> [CmdletBinding()] param ( [Parameter(Position = 0)] [ValidatePattern('^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$')] [string]$Subnet, [Parameter()] [switch]$IPv6, [Parameter()] [switch]$Force ) $IsMacOS = $PSVersionTable.OS -match 'Darwin' -or $IsMacOS $IsWindows = $PSVersionTable.OS -match 'Windows' -or (-not $IsMacOS -and -not $IsLinux) # Helper: Get current subnet in CIDR notation function Get-CurrentSubnet { if ($IsMacOS) { # Get default route interface $routeInfo = & route -n get default 2>&1 $interface = ($routeInfo | Select-String 'interface:\s*(\S+)').Matches.Groups[1].Value if (-not $interface) { $interface = 'en0' } # Get IP and netmask from ifconfig $ifconfig = & ifconfig $interface 2>&1 $inetLine = $ifconfig | Select-String 'inet\s+(\d+\.\d+\.\d+\.\d+)\s+netmask\s+(0x[0-9a-f]+)' if (-not $inetLine) { throw "Could not determine IP address for interface $interface" } $ip = $inetLine.Matches.Groups[1].Value $netmaskHex = $inetLine.Matches.Groups[2].Value # Convert hex netmask to prefix length $netmaskInt = [Convert]::ToUInt32($netmaskHex, 16) $prefix = 0 while ($netmaskInt -ne 0) { $prefix += $netmaskInt -band 1 $netmaskInt = $netmaskInt -shr 1 } } else { # Windows: use Get-NetIPAddress, prefer connected physical adapters $adapters = Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.PrefixOrigin -ne 'WellKnown' } # Try to find the best adapter (prefer ones with default gateway) $bestAdapter = $null foreach ($adp in $adapters) { $ifIndex = $adp.InterfaceIndex $route = Get-NetRoute -InterfaceIndex $ifIndex -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue if ($route) { $bestAdapter = $adp break } } # Fallback to first non-loopback adapter if (-not $bestAdapter) { $bestAdapter = $adapters | Select-Object -First 1 } if (-not $bestAdapter) { throw "Could not determine local network adapter" } $ip = $bestAdapter.IPAddress $prefix = $bestAdapter.PrefixLength Write-Verbose "Using adapter with IP: $ip/$prefix" } $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes() $maskBytes = [byte[]]::new(4) for ($i = 0; $i -lt 4; $i++) { $bits = [Math]::Min(8, [Math]::Max(0, $prefix - ($i * 8))) $maskBytes[$i] = [byte](256 - [Math]::Pow(2, 8 - $bits)) } $networkBytes = for ($i = 0; $i -lt 4; $i++) { $ipBytes[$i] -band $maskBytes[$i] } $network = ($networkBytes -join '.') return "$network/$prefix" } # Helper: Get IP range from CIDR function Get-IPRange { param ([string]$CIDR) $parts = $CIDR -split '/' $baseIP = [System.Net.IPAddress]::Parse($parts[0]) $prefix = [int]$parts[1] $ipBytes = $baseIP.GetAddressBytes() [Array]::Reverse($ipBytes) $ipInt = [BitConverter]::ToUInt32($ipBytes, 0) $hostBits = 32 - $prefix $numHosts = [Math]::Pow(2, $hostBits) - 2 $networkInt = $ipInt -band ([UInt32]::MaxValue -shl $hostBits) $ips = @() for ($i = 1; $i -le $numHosts -and $i -le 254; $i++) { $currentInt = $networkInt + $i $bytes = [BitConverter]::GetBytes([UInt32]$currentInt) [Array]::Reverse($bytes) $ips += ([System.Net.IPAddress]::new($bytes)).ToString() } return $ips } # Helper: Lookup MAC vendor from OUI file function Get-MacVendor { param ([string]$MAC) if (-not $script:OUITable) { $ouiPath = Join-Path $PSScriptRoot 'oui.txt' if (Test-Path $ouiPath) { $script:OUITable = @{} Get-Content $ouiPath | ForEach-Object { if ($_ -match '^([0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2})\t(.+)$') { $script:OUITable[$matches[1].ToUpper()] = $matches[2] } } } else { $script:OUITable = @{} } } # Normalize MAC and get prefix $normalizedMac = ($MAC -replace '-', ':').ToUpper() if ($normalizedMac.Length -ge 8) { $prefix = $normalizedMac.Substring(0, 8) return $script:OUITable[$prefix] } return $null } # Helper: Resolve hostname function Resolve-DeviceHostname { param ([string]$IP) try { $dns = [System.Net.Dns]::GetHostEntry($IP) if ($dns.HostName -and $dns.HostName -ne $IP) { return $dns.HostName } } catch { } return $null } # Main logic if (-not $Subnet) { $Subnet = Get-CurrentSubnet Write-Verbose "Detected subnet: $Subnet" } # Parse subnet for filtering $subnetParts = $Subnet -split '/' $subnetBase = $subnetParts[0] $subnetPrefix = [int]$subnetParts[1] # Calculate network address for filtering $baseBytes = [System.Net.IPAddress]::Parse($subnetBase).GetAddressBytes() $maskBytes = [byte[]]::new(4) for ($i = 0; $i -lt 4; $i++) { $bits = [Math]::Min(8, [Math]::Max(0, $subnetPrefix - ($i * 8))) $maskBytes[$i] = [byte](256 - [Math]::Pow(2, 8 - $bits)) } if ($Force) { Write-Verbose "Ping-sweeping subnet $Subnet..." $ips = Get-IPRange -CIDR $Subnet $isMac = $IsMacOS # Use parallel ping for speed $ips | ForEach-Object -ThrottleLimit 50 -Parallel { if ($using:isMac) { ping -c 1 -W 1 $_ 2>&1 | Out-Null } else { ping -n 1 -w 500 $_ 2>&1 | Out-Null } } -ErrorAction SilentlyContinue Start-Sleep -Milliseconds 500 } # Get ARP table Write-Verbose "Reading ARP cache..." $arpOutput = & arp -a 2>&1 $devices = @{} foreach ($line in $arpOutput) { $ip = $null $mac = $null if ($IsMacOS) { # macOS format: ? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet] # or: hostname (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet] if ($line -match '\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]{17})') { $ip = $matches[1] $mac = $matches[2].ToUpper() } } else { # Windows format: 192.168.1.1 aa-bb-cc-dd-ee-ff dynamic if ($line -match '^\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-f-]{17})\s+(\w+)') { $ip = $matches[1] $mac = $matches[2].ToUpper() -replace '-', ':' $type = $matches[3] if ($type -ne 'dynamic' -and $type -ne 'static') { continue } } } if ($ip -and $mac -and $mac -ne 'FF:FF:FF:FF:FF:FF' -and $mac -notmatch '^\(incomplete\)') { # Check if IP is in our subnet $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes() $inSubnet = $true for ($i = 0; $i -lt 4; $i++) { if (($ipBytes[$i] -band $maskBytes[$i]) -ne ($baseBytes[$i] -band $maskBytes[$i])) { $inSubnet = $false break } } if ($inSubnet) { $devices[$mac] = @{ IPAddress = $ip MACAddress = $mac } } } } # Get IPv6 neighbors if requested $ipv6Map = @{} if ($IPv6) { Write-Verbose "Reading IPv6 neighbor cache..." if ($IsMacOS) { # macOS: use ndp -an $ipv6Output = & ndp -an 2>&1 foreach ($line in $ipv6Output) { # Format: fe80::1%en0 aa:bb:cc:dd:ee:ff en0 ... if ($line -match '^(fe80[^\s%]+)%?\S*\s+([0-9a-f:]{17})') { $ip6 = $matches[1] $mac6 = $matches[2].ToUpper() if (-not $ipv6Map.ContainsKey($mac6)) { $ipv6Map[$mac6] = $ip6 } } } } else { # Windows: use netsh $ipv6Output = & netsh interface ipv6 show neighbors 2>&1 foreach ($line in $ipv6Output) { if ($line -match '^\s*(fe80[^\s]+)\s+([0-9a-f-]{17})') { $ip6 = $matches[1] $mac6 = $matches[2].ToUpper() -replace '-', ':' if (-not $ipv6Map.ContainsKey($mac6)) { $ipv6Map[$mac6] = $ip6 } } } } } # Build output objects Write-Verbose "Resolving hostnames and vendors..." foreach ($mac in $devices.Keys) { $device = $devices[$mac] [PSCustomObject]@{ Hostname = Resolve-DeviceHostname -IP $device.IPAddress IPAddress = $device.IPAddress IPv6Address = if ($IPv6) { $ipv6Map[$mac] } else { $null } MACAddress = $device.MACAddress Vendor = Get-MacVendor -MAC $device.MACAddress } } } |