networking.ps1

# --- Internal ----------------------------------------------------------------

function Resolve-IPv6PrefixData {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Data is a singular mass noun; there is no other option here.')]

  # Extracts the IPv6 address, masked prefix, and prefix length from a resolved
  # adapter's CIM parallel IPAddress/IPSubnet arrays. Shared by Get-NetworkPrefix
  # and Get-NetworkPrefixCIDR to avoid duplicating the masking logic.
  param([Parameter(Mandatory)][PSCustomObject]$Adapter)

  $addresses = $Adapter.CimConfig.IPAddress
  $subnets = $Adapter.CimConfig.IPSubnet

  for ($i = 0; $i -lt $addresses.Count; $i++) {
    if ($addresses[$i] -notmatch ':') { continue }

    $ipv6 = $addresses[$i]
    $sub = $subnets[$i]

    $prefixLength = if ($sub -match '^\d+$') {
      [int]$sub
    }
    else {
      $bits = 0
      foreach ($byte in [System.Net.IPAddress]::Parse($sub).GetAddressBytes()) {
        if ($byte -eq 0xFF) { $bits += 8; continue }
        for ($b = 7; $b -ge 0; $b--) { if (($byte -shr $b) -band 1) { $bits++ } else { break } }
        break
      }
      $bits
    }

    $ipBytes = [System.Net.IPAddress]::Parse($ipv6).GetAddressBytes()
    $prefixBytes = [byte[]]::new(16)
    $remaining = $prefixLength

    for ($j = 0; $j -lt 16; $j++) {
      if ($remaining -ge 8) { $prefixBytes[$j] = $ipBytes[$j]; $remaining -= 8 }
      elseif ($remaining -gt 0) { $prefixBytes[$j] = $ipBytes[$j] -band ([byte](0xFF -shl (8 - $remaining))); $remaining = 0 }
      else { $prefixBytes[$j] = 0 }
    }

    return [PSCustomObject]@{
      Address = $ipv6
      Prefix = [System.Net.IPAddress]::new($prefixBytes).ToString()
      PrefixLength = $prefixLength
    }
  }

  return $null
}

# --- Adapter resolution -------------------------------------------------------

function Get-DefaultNetworkAdapter {
  <#
    .SYNOPSIS
      Resolves the default network adapter via the IPv4 routing table.
    .DESCRIPTION
      Selects the lowest-metric 0.0.0.0/0 route and resolves the corresponding
      adapter. Physical type is identified via NDIS PhysicalMediaType, making the
      result locale-independent. Returns a structured object consumed by all
      downstream networking functions.
    .PARAMETER Type
      Filters by physical adapter type: WiFi (802.11), Ethernet (802.3),
      VPN (Unspecified), or Any. Defaults to Any.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [PSCustomObject] Name, ifIndex, PhysicalMedia, NetAdapter, CimConfig
    .EXAMPLE
      PS> Get-DefaultNetworkAdapter -Type Ethernet -Required
  #>

  param(
    [ValidateSet('Any', 'WiFi', 'Ethernet', 'VPN')]
    [string]$Type = 'Any',

    [switch]$Required
  )

  $defaultRoute = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
    Where-Object { $_.NextHop -ne '0.0.0.0' } |
    Sort-Object -Property RouteMetric |
    Select-Object -First 1

  if ($null -eq $defaultRoute) {
    $message = "No default IPv4 route found."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  $netAdapter = Get-NetAdapter -InterfaceIndex $defaultRoute.ifIndex -ErrorAction SilentlyContinue

  if ($null -eq $netAdapter) {
    $message = "Could not resolve adapter for interface index $($defaultRoute.ifIndex)."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  if ($Type -ne 'Any') {
    $isMatch = switch ($Type) {
      'WiFi' { $netAdapter.PhysicalMediaType -like '*802.11*' }
      'Ethernet' { $netAdapter.PhysicalMediaType -eq '802.3' }
      'VPN' { $netAdapter.PhysicalMediaType -eq 'Unspecified' }
    }

    if (-not $isMatch) {
      $message = "Default adapter '$($netAdapter.Name)' (type: $($netAdapter.PhysicalMediaType)) does not match the requested type '$Type'."
      if ($Required) { throw $message }
      Write-Log -Message $message -Color Red
      return $null
    }
  }

  $cimConfig = Get-CimInstance -Class Win32_NetworkAdapterConfiguration `
    -Filter "InterfaceIndex = $($defaultRoute.ifIndex)"

  if ($null -eq $cimConfig) {
    $message = "Could not retrieve CIM configuration for adapter '$($netAdapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return [PSCustomObject]@{
    Name = $netAdapter.Name
    ifIndex = $defaultRoute.ifIndex
    PhysicalMedia = $netAdapter.PhysicalMediaType
    NetAdapter = $netAdapter
    CimConfig = $cimConfig
  }
}

# --- Address retrieval --------------------------------------------------------

function Get-IPAddress {
  <#
    .SYNOPSIS
      Returns the IP address of the default network adapter.
    .DESCRIPTION
      For IPv6, global/unique-local addresses are preferred over link-local.
      Falls back to link-local if no routable address is assigned.
    .PARAMETER AddressFamily
      IPv4 or IPv6. Defaults to IPv4.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-IPAddress -AddressFamily IPv6
  #>

  param(
    [ValidateSet('IPv4', 'IPv6')]
    [string]$AddressFamily = 'IPv4',

    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $ip = if ($AddressFamily -eq 'IPv4') {
    $Adapter.CimConfig.IPAddress | Where-Object { $_ -notmatch ':' } | Select-Object -First 1
  }
  else {
    $global = $Adapter.CimConfig.IPAddress |
      Where-Object { $_ -match ':' -and $_ -notmatch '^fe80' } |
      Select-Object -First 1

    if ($null -ne $global) {
      $global
    }
    else {
      $Adapter.CimConfig.IPAddress | Where-Object { $_ -match ':' } | Select-Object -First 1
    }
  }

  if ($null -eq $ip) {
    $message = "No $AddressFamily address found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return $ip
}

function Get-SubnetMask {
  <#
    .SYNOPSIS
      Returns the IPv4 subnet mask of the default network adapter.
    .DESCRIPTION
      Resolves the mask from the CIM IPSubnet array at the index corresponding
      to the IPv4 entry in the parallel IPAddress array.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-SubnetMask
  #>

  param(
    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $addresses = $Adapter.CimConfig.IPAddress
  $subnets = $Adapter.CimConfig.IPSubnet
  $mask = $null

  for ($i = 0; $i -lt $addresses.Count; $i++) {
    if ($addresses[$i] -notmatch ':') { $mask = $subnets[$i]; break }
  }

  if ($null -eq $mask) {
    $message = "No IPv4 subnet mask found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return $mask
}

function Get-DefaultGateway {
  <#
    .SYNOPSIS
      Returns the default gateway of the default network adapter.
    .PARAMETER AddressFamily
      IPv4 or IPv6. Defaults to IPv4.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-DefaultGateway -AddressFamily IPv4
  #>

  param(
    [ValidateSet('IPv4', 'IPv6')]
    [string]$AddressFamily = 'IPv4',

    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $gateway = if ($AddressFamily -eq 'IPv4') {
    $Adapter.CimConfig.DefaultIPGateway | Where-Object { $_ -notmatch ':' } | Select-Object -First 1
  }
  else {
    $Adapter.CimConfig.DefaultIPGateway | Where-Object { $_ -match ':' } | Select-Object -First 1
  }

  if ($null -eq $gateway) {
    $message = "No $AddressFamily default gateway found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return $gateway
}

function Get-DNSServer {
  <#
    .SYNOPSIS
      Returns the configured DNS servers of the default network adapter.
    .PARAMETER AddressFamily
      IPv4 or IPv6. Defaults to IPv4.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string[]]
    .EXAMPLE
      PS> Get-DNSServer
  #>

  param(
    [ValidateSet('IPv4', 'IPv6')]
    [string]$AddressFamily = 'IPv4',

    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $servers = if ($AddressFamily -eq 'IPv4') {
    $Adapter.CimConfig.DNSServerSearchOrder | Where-Object { $_ -notmatch ':' }
  }
  else {
    $Adapter.CimConfig.DNSServerSearchOrder | Where-Object { $_ -match ':' }
  }

  if ($null -eq $servers -or @($servers).Count -eq 0) {
    $message = "No $AddressFamily DNS servers found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return $servers
}

function Get-MACAddress {
  <#
    .SYNOPSIS
      Returns the MAC address of the default network adapter.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-MACAddress
  #>

  param(
    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $mac = $Adapter.CimConfig.MACAddress

  if ($null -eq $mac) {
    $message = "No MAC address found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return $mac
}

# --- Network / prefix ---------------------------------------------------------

function Get-NetworkPrefix {
  <#
    .SYNOPSIS
      Returns the network address (IPv4) or prefix address (IPv6) of the default adapter.
    .PARAMETER AddressFamily
      IPv4 or IPv6. Defaults to IPv4.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-NetworkPrefix
    .EXAMPLE
      PS> Get-Prefix -AddressFamily IPv6
  #>

  [Alias('Get-Network', 'Get-Prefix')]
  param(
    [ValidateSet('IPv4', 'IPv6')]
    [string]$AddressFamily = 'IPv4',

    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  if ($AddressFamily -eq 'IPv4') {
    $ip = Get-IPAddress -AddressFamily IPv4 -Adapter $Adapter -Required:$Required
    $mask = Get-SubnetMask -Adapter $Adapter -Required:$Required

    if ($null -eq $ip -or $null -eq $mask) { return $null }

    $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes()
    $maskBytes = [System.Net.IPAddress]::Parse($mask).GetAddressBytes()
    $netBytes = [byte[]](0..3 | ForEach-Object { $ipBytes[$_] -band $maskBytes[$_] })

    return [System.Net.IPAddress]::new($netBytes).ToString()
  }

  $data = Resolve-IPv6PrefixData -Adapter $Adapter

  if ($null -eq $data) {
    $message = "No IPv6 address with a valid prefix length found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return $data.Prefix
}

function Get-NetworkPrefixCIDR {
  <#
    .SYNOPSIS
      Returns the network prefix in CIDR notation for the default adapter.
    .PARAMETER AddressFamily
      IPv4 or IPv6. Defaults to IPv4.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-NetworkPrefixCIDR
    .EXAMPLE
      PS> Get-PrefixCIDR -AddressFamily IPv6
  #>

  [Alias('Get-NetworkCIDR', 'Get-PrefixCIDR')]
  param(
    [ValidateSet('IPv4', 'IPv6')]
    [string]$AddressFamily = 'IPv4',

    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  if ($AddressFamily -eq 'IPv4') {
    $prefix = Get-NetworkPrefix -AddressFamily IPv4 -Adapter $Adapter -Required:$Required
    $mask = Get-SubnetMask -Adapter $Adapter -Required:$Required

    if ($null -eq $prefix -or $null -eq $mask) { return $null }

    $cidr = (([System.Net.IPAddress]::Parse($mask).GetAddressBytes() |
          ForEach-Object { [Convert]::ToString($_, 2) }) -join '').Replace('0', '').Length

    return "$prefix/$cidr"
  }

  $data = Resolve-IPv6PrefixData -Adapter $Adapter

  if ($null -eq $data) {
    $message = "No IPv6 address with a valid prefix length found on adapter '$($Adapter.Name)'."
    if ($Required) { throw $message }
    Write-Log -Message $message -Color Red
    return $null
  }

  return "$($data.Prefix)/$($data.PrefixLength)"
}

function Get-BroadcastAddress {
  <#
    .SYNOPSIS
      Returns the IPv4 broadcast address of the default network adapter.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-BroadcastAddress
  #>

  param(
    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $ip = Get-IPAddress -AddressFamily IPv4 -Adapter $Adapter -Required:$Required
  $mask = Get-SubnetMask -Adapter $Adapter -Required:$Required

  if ($null -eq $ip -or $null -eq $mask) { return $null }

  $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes()
  $maskBytes = [System.Net.IPAddress]::Parse($mask).GetAddressBytes()
  $broadcastBytes = [byte[]](0..3 | ForEach-Object { $ipBytes[$_] -bor (-bnot $maskBytes[$_] -band 0xFF) })

  return [System.Net.IPAddress]::new($broadcastBytes).ToString()
}

function Get-MulticastAddress {
  <#
    .SYNOPSIS
      Returns the solicited-node multicast address for the default adapter's IPv6 address.
    .DESCRIPTION
      Computes the solicited-node multicast address per RFC 4291 section 2.7.1 by combining
      the ff02::1:ff00:0/104 prefix with the lower 24 bits of the unicast address.
      Used by Neighbor Discovery as the IPv6 replacement for ARP.
    .PARAMETER Adapter
      Pre-resolved adapter from Get-DefaultNetworkAdapter. Resolved automatically if omitted.
    .PARAMETER Required
      Throws on failure instead of returning $null.
    .OUTPUTS
      [string]
    .EXAMPLE
      PS> Get-MulticastAddress
  #>

  param(
    [PSCustomObject]$Adapter,
    [switch]$Required
  )

  if ($null -eq $Adapter) { $Adapter = Get-DefaultNetworkAdapter -Required:$Required }
  if ($null -eq $Adapter) { return $null }

  $ip = Get-IPAddress -AddressFamily IPv6 -Adapter $Adapter -Required:$Required

  if ($null -eq $ip) { return $null }

  $ipBytes = [System.Net.IPAddress]::Parse($ip).GetAddressBytes()
  $multicastBytes = [byte[]]@(
    0xFF, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x01, 0xFF,
    $ipBytes[13], $ipBytes[14], $ipBytes[15]
  )

  return [System.Net.IPAddress]::new($multicastBytes).ToString()
}

# --- Validation ---------------------------------------------------------------

function Test-IPv4Address {
  <#
    .SYNOPSIS
      Returns $true if the input is a valid IPv4 address per RFC 791.
    .DESCRIPTION
      Validates via arithmetic decomposition: four dot-separated decimal octets,
      each in 0-255, no leading zeros. Does not use regex.
    .PARAMETER Address
      The string to validate.
    .OUTPUTS
      [bool]
    .EXAMPLE
      PS> Test-IPv4Address -Address '192.168.1.1'
  #>

  param(
    [Parameter(Mandatory)]
    [string]$Address
  )

  if ([string]::IsNullOrEmpty($Address)) { return $false }

  $octets = $Address -split '\.'
  if ($octets.Count -ne 4) { return $false }

  foreach ($octet in $octets) {
    if ($octet.Length -eq 0) { return $false }
    if ($octet.Length -gt 1 -and $octet[0] -eq '0') { return $false }
    $value = 0
    if (-not [int]::TryParse($octet, [ref]$value)) { return $false }
    if ($value -lt 0 -or $value -gt 255) { return $false }
  }

  return $true
}

function Test-IPv6Address {
  <#
    .SYNOPSIS
      Returns $true if the input is a valid IPv6 address per RFC 4291.
    .DESCRIPTION
      Validates via structural decomposition: handles full form, :: compression,
      and IPv4-mapped addresses (::ffff:x.x.x.x). The embedded IPv4 portion of
      mapped addresses is delegated to Test-IPv4Address.
    .PARAMETER Address
      The string to validate.
    .OUTPUTS
      [bool]
    .EXAMPLE
      PS> Test-IPv6Address -Address '2001:db8::ff00:42:8329'
  #>

  param(
    [Parameter(Mandatory)]
    [string]$Address
  )

  if ([string]::IsNullOrEmpty($Address)) { return $false }

  # Delegate embedded IPv4 portion of mapped addresses (e.g. ::ffff:192.168.1.1)
  $lastColon = $Address.LastIndexOf(':')
  if ($lastColon -ge 0) {
    $lastSegment = $Address.Substring($lastColon + 1)
    if ($lastSegment.Contains('.')) {
      if (-not (Test-IPv4Address -Address $lastSegment)) { return $false }
      $Address = $Address.Substring(0, $lastColon + 1) + '0'
    }
  }

  # :: must appear at most once
  $pos = 0; $doubleColonCount = 0
  while (($pos = $Address.IndexOf('::', $pos)) -ge 0) { $doubleColonCount++; $pos += 2 }
  if ($doubleColonCount -gt 1) { return $false }

  $hasCompression = $doubleColonCount -eq 1

  $groupsToValidate = if ($hasCompression) {
    $parts = $Address -split '::', 2
    $leftGroups = if ($parts[0]) { $parts[0] -split ':' | Where-Object { $_ } } else { @() }
    $rightGroups = if ($parts[1]) { $parts[1] -split ':' | Where-Object { $_ } } else { @() }
    $explicit = @($leftGroups) + @($rightGroups)
    if ($explicit.Count -gt 7) { return $false }
    $explicit
  }
  else {
    $all = $Address -split ':'
    if ($all.Count -ne 8) { return $false }
    $all
  }

  $hexDigits = [char[]]'0123456789abcdefABCDEF'

  foreach ($group in $groupsToValidate) {
    if ($group.Length -lt 1 -or $group.Length -gt 4) { return $false }
    foreach ($char in $group.ToCharArray()) { if ($char -notin $hexDigits) { return $false } }
    if ([Convert]::ToInt32($group, 16) -gt 0xFFFF) { return $false }
  }

  return $true
}