functions/Invoke-DhcpTest.ps1
|
#Requires -Version 5.1 function Invoke-DhcpTest { <# .SYNOPSIS Sends DHCP DISCOVER (and optionally REQUEST) packets to test DHCP servers, without dhcptest.exe. .DESCRIPTION Implements RFC 2131/2132 DHCP DISCOVER/OFFER and optional REQUEST/ACK exchanges over System.Net.Sockets.UdpClient. Supports spoofing the client MAC address (chaddr), injecting arbitrary DHCP options, requesting specific options back from the server (option 55), and targeting either a broadcast segment or a specific server/relay IP. By default only DISCOVER/OFFER is performed — this is read-only and never causes a server to commit a lease. The REQUEST/ACK phase (which can cause the server to reserve/commit the offered address) is opt-in via -SendRequest and is gated by ShouldProcess. This function never binds the resulting address to a local network adapter or changes OS routing/interface configuration — it only sends packets and returns a result object. .PARAMETER ServerAddress DHCP server or relay IP to send to. Default '255.255.255.255' (broadcast). .PARAMETER ServerPort UDP port on the DHCP server. Default: 67. .PARAMETER ClientPort Local UDP port to bind. Must be 68 to receive the server's reply (servers always reply to port 68). Default: 68. .PARAMETER ClientMac Spoofed client MAC address (chaddr), any of xx:xx:xx:xx:xx:xx, xx-xx-xx-xx-xx-xx, or Cisco xxxx.xxxx.xxxx format. If omitted, a random locally-administered MAC is generated. .PARAMETER Bind Local IP address to bind the UDP socket to, for multi-NIC/VLAN hosts. Default: any address. .PARAMETER RelayAgentIPAddress Sets the BOOTP 'giaddr' field, simulating a relay agent for the given subnet so the server can select a scope other than the one matching this host's own network position. Per RFC 2131 §4.1, when giaddr is non-zero the server sends its reply to giaddr's address on port 67 (the DHCP server port), not to the client's port 68. To actually receive that reply you must also pass -Bind and -ClientPort 67 with the same address as -RelayAgentIPAddress, and that address must be one this host can genuinely receive traffic on (e.g. a real secondary IP/alias on the target subnet). Otherwise the server does reply, but never to us, and the exchange times out. Default: 0.0.0.0 (no relay simulation). .PARAMETER Hostname Convenience for DHCP option 12 (Host Name). .PARAMETER VendorClassIdentifier Convenience for DHCP option 60 (Vendor Class Identifier). .PARAMETER ClientIdentifier Convenience for DHCP option 61 (Client Identifier), sent as an ASCII string value. .PARAMETER CircuitId Convenience for DHCP option 82 (Relay Agent Information) sub-option 1, Agent Circuit ID. Many switches with DHCP snooping/relay enabled insert this (and RemoteId) into every real client's DISCOVER, and some DHCP servers use it to select the subnet/pool within a shared network — a spoofed packet without it may land in a different, possibly exhausted, pool even with a correct -RelayAgentIPAddress. Encoded per -CircuitIdType (default ASCII String); use 'HexString' if your network expects the vendor-specific binary format a real switch sends (check with your network team, e.g. by comparing to a real relayed packet capture). .PARAMETER CircuitIdType Encoding for -CircuitId: 'String' (ASCII, default) or 'HexString' (raw bytes from hex digits). .PARAMETER RemoteId Convenience for DHCP option 82 (Relay Agent Information) sub-option 2, Agent Remote ID — typically the relaying switch's own identifier (e.g. its MAC address). See -CircuitId. .PARAMETER RemoteIdType Encoding for -RemoteId: 'String' (ASCII, default) or 'HexString' (raw bytes from hex digits). .PARAMETER RequestOptions DHCP option codes (0-255) to request via option 55 (Parameter Request List). Default: 1,3,6,15,51,54,58,59 (subnet mask, router, DNS, domain name, lease time, server id, renewal/rebinding time). .PARAMETER Option Additional raw DHCP options to inject, as an array of hashtables with keys Code, Type, Value. Type must be one of Byte, UInt16, UInt32, String, IPAddress, HexString, ByteArray. Example: -Option @{Code=43;Type='HexString';Value='0102FF'}, @{Code=125;Type='IPAddress';Value='10.0.0.1'} .PARAMETER TransactionId Transaction ID (xid) to use. Default: a randomly generated value. .PARAMETER SendRequest After receiving an OFFER, send a REQUEST for the offered address and wait for ACK/NAK. This is the only phase that can cause a server to actually commit/reserve a lease, so it is gated by ShouldProcess (-WhatIf/-Confirm). .PARAMETER RequestedIPAddress Requested address (option 50). If given, it is included as a hint in the DISCOVER packet and used as the requested address in the REQUEST packet (defaulting there to the offered address if this override isn't also intended for REQUEST). Note most DHCP servers do not select a scope based on this hint — see -RelayAgentIPAddress for actual scope selection. .PARAMETER TimeoutSeconds Per-attempt wait for a reply, in seconds. Default: 5. .PARAMETER Retries Number of send attempts before giving up on a phase. Default: 3. .PARAMETER LogActivity Also write DISCOVER/OFFER/REQUEST/ACK events via Write-Log. .EXAMPLE Invoke-DhcpTest -Verbose Status : OfferReceived OfferedAddress : 192.168.1.87 ServerIdentifier : 192.168.1.1 LeaseTimeSeconds : 86400 .EXAMPLE Invoke-DhcpTest -ClientMac '02:11:22:33:44:55' -Hostname 'dhcptest-probe' -RequestOptions 1,3,6,15,51 .EXAMPLE Invoke-DhcpTest -SendRequest -Confirm:$false -Verbose Completes the full DISCOVER/OFFER/REQUEST/ACK handshake — will consume a real lease from the server's pool if it ACKs. .EXAMPLE Invoke-DhcpTest -ServerAddress 10.0.0.5 -Bind 10.0.1.20 Unicasts the DISCOVER to a specific DHCP server/relay from a specific local interface. .EXAMPLE Invoke-DhcpTest -ServerAddress 172.31.3.174 -RelayAgentIPAddress 10.55.2.1 -Bind 10.55.2.1 -ClientPort 67 Simulates a relay agent for the 10.55.2.0/24 scope so the server offers from that subnet instead of the one matching this host's own address. Requires 10.55.2.1 to be a real, reachable address on this host (e.g. a secondary IP/alias) and -ClientPort 67, since replies to a non-zero giaddr go to port 67, not 68. See -RelayAgentIPAddress notes. .EXAMPLE Invoke-DhcpTest -ServerAddress 172.31.3.179 -RelayAgentIPAddress 10.55.2.1 -Bind 10.55.2.1 -ClientPort 67 -CircuitIdType HexString -CircuitId '0004' -RemoteIdType HexString -RemoteId '0006AABBCCDDEEFF' Simulates a relay agent that also inserts Option 82 sub-options, for DHCP servers that use circuit-id/remote-id (not just giaddr) to select the pool within a shared network. .NOTES The BOOTP broadcast flag (0x8000) is always set in outgoing packets. With ciaddr=0 and giaddr=0 and no address bound to a real interface, an RFC-compliant server always replies via broadcast — there is no scenario in this tool where a unicast reply back to us would work, since we don't own the offered address at the network layer. Binding local UDP port 68 usually conflicts with the Windows DHCP Client service, which holds that port on any DHCP-enabled adapter. This function sets SO_REUSEADDR so it can coexist with it; if binding still fails, run PowerShell elevated and/or temporarily stop the service: Stop-Service Dhcp -Force (Start-Service Dhcp afterward). #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [Parameter()][string]$ServerAddress = '255.255.255.255', [Parameter()][ValidateRange(1, 65535)][int]$ServerPort = 67, [Parameter()][ValidateRange(1, 65535)][int]$ClientPort = 68, [Parameter()][Alias('MAC')][string]$ClientMac, [Parameter()][string]$Bind, [Parameter()][Alias('GiAddr')][string]$RelayAgentIPAddress, [Parameter()][string]$Hostname, [Parameter()][string]$VendorClassIdentifier, [Parameter()][string]$ClientIdentifier, [Parameter()][string]$CircuitId, [Parameter()][ValidateSet('String', 'HexString')][string]$CircuitIdType = 'String', [Parameter()][string]$RemoteId, [Parameter()][ValidateSet('String', 'HexString')][string]$RemoteIdType = 'String', [Parameter()][Alias('ParameterRequestList')][int[]]$RequestOptions = @(1, 3, 6, 15, 51, 54, 58, 59), [Parameter()][object[]]$Option = @(), [Parameter()][uint32]$TransactionId, [Parameter()][switch]$SendRequest, [Parameter()][string]$RequestedIPAddress, [Parameter()][ValidateRange(1, 60)][int]$TimeoutSeconds = 5, [Parameter()][ValidateRange(1, 10)][int]$Retries = 3, [Parameter()][switch]$LogActivity ) # ── DHCP message types (RFC 2132 §9.6) ───────────────────────────────────── $MSG_DISCOVER = [byte]1 $MSG_OFFER = [byte]2 $MSG_REQUEST = [byte]3 $MSG_ACK = [byte]5 $MSG_NAK = [byte]6 $MsgTypeNames = @{ 1 = 'Discover'; 2 = 'Offer'; 3 = 'Request'; 4 = 'Decline'; 5 = 'Ack'; 6 = 'Nak'; 7 = 'Release'; 8 = 'Inform' } $MagicCookie = [byte[]]@(99, 130, 83, 99) # ── Big-endian helpers ─────────────────────────────────────────────────── function WriteU16BE([uint16]$v) { [byte[]]@([byte]($v -shr 8), [byte]($v -band 0xFF)) } function WriteU32BE([uint32]$v) { [byte[]]@([byte](($v -shr 24) -band 0xFF), [byte](($v -shr 16) -band 0xFF), [byte](($v -shr 8) -band 0xFF), [byte]($v -band 0xFF)) } function ReadU16BE([byte[]]$b, [int]$i) { (([int]$b[$i]) -shl 8) -bor [int]$b[$i + 1] } function ReadU32BE([byte[]]$b, [int]$i) { ([uint32]$b[$i] -shl 24) -bor ([uint32]$b[$i + 1] -shl 16) -bor ([uint32]$b[$i + 2] -shl 8) -bor [uint32]$b[$i + 3] } # ── MAC helpers ─────────────────────────────────────────────────────────── function ConvertTo-MacBytes([string]$mac) { $cisco = $mac -imatch '^([0-9a-f]{4}\.){2}[0-9a-f]{4}$' $colonDash = $mac -imatch '^([0-9a-f]{2}(:|-)){5}[0-9a-f]{2}$' if (-not $cisco -and -not $colonDash) { throw "Invalid MAC address format: '$mac'. Expected xx:xx:xx:xx:xx:xx, xx-xx-xx-xx-xx-xx, or xxxx.xxxx.xxxx." } $hex = ($mac -replace '[:\-.]', '') $bytes = [byte[]]::new(6) for ($i = 0; $i -lt 6; $i++) { $bytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16) } return $bytes } function Format-MacBytes([byte[]]$b) { (($b | ForEach-Object { $_.ToString('x2') }) -join ':') } function New-RandomMacBytes() { $b = [byte[]]::new(6) (New-Object System.Random).NextBytes($b) $b[0] = [byte](($b[0] -band 0xFE) -bor 0x02) # locally-administered, unicast return $b } # ── DHCP option encode/decode ──────────────────────────────────────────── function ConvertTo-DhcpOptionBytes([int]$Code, [string]$Type, $Value) { switch ($Type) { 'Byte' { return [byte[]]@([byte]$Value) } 'UInt16' { return WriteU16BE ([uint16]$Value) } 'UInt32' { return WriteU32BE ([uint32]$Value) } 'String' { return [System.Text.Encoding]::ASCII.GetBytes([string]$Value) } 'IPAddress' { $ip = [System.Net.IPAddress]::Parse([string]$Value) return $ip.GetAddressBytes() } 'HexString' { $hex = ([string]$Value) -replace '[^0-9a-fA-F]', '' if ($hex.Length % 2 -ne 0) { throw "HexString value for option $Code has an odd number of hex digits." } $out = [byte[]]::new($hex.Length / 2) for ($i = 0; $i -lt $out.Length; $i++) { $out[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16) } return $out } 'ByteArray' { return [byte[]]$Value } default { throw "Unknown option Type '$Type' for option $Code. Valid: Byte, UInt16, UInt32, String, IPAddress, HexString, ByteArray." } } } function Add-DhcpOption([System.IO.MemoryStream]$ms, [int]$Code, [byte[]]$ValueBytes) { if ($ValueBytes.Length -gt 255) { throw "Option $Code value is $($ValueBytes.Length) bytes, exceeds the 255-byte TLV length limit." } $ms.WriteByte([byte]$Code) $ms.WriteByte([byte]$ValueBytes.Length) if ($ValueBytes.Length -gt 0) { $ms.Write($ValueBytes, 0, $ValueBytes.Length) } } function Read-DhcpOptions([byte[]]$pkt) { $opts = @{} $i = 240 while ($i -lt $pkt.Length) { $code = $pkt[$i] if ($code -eq 255) { break } if ($code -eq 0) { $i++; continue } if ($i + 1 -ge $pkt.Length) { break } $len = $pkt[$i + 1] $start = $i + 2 if ($start + $len -gt $pkt.Length) { break } $value = [byte[]]::new($len) if ($len -gt 0) { [Array]::Copy($pkt, $start, $value, 0, $len) } $opts[[int]$code] = $value $i = $start + $len } return $opts } function Format-IPBytes([byte[]]$b, [int]$offset) { "{0}.{1}.{2}.{3}" -f $b[$offset], $b[$offset + 1], $b[$offset + 2], $b[$offset + 3] } function Format-HexBytes([byte[]]$v) { ($v | ForEach-Object { $_.ToString('x2') }) -join '' } function ConvertFrom-DhcpOptions([hashtable]$rawOptions) { # Deliberately a plain hashtable, not [ordered]: OrderedDictionary has both an # object-key indexer and a positional int-index indexer, and since our keys are # integers, $decoded[$code] resolves to the positional one and throws # ArgumentOutOfRangeException ("index") the moment a code doesn't match an # existing position. $decoded = @{} foreach ($code in $rawOptions.Keys) { $v = $rawOptions[$code] $decoded[$code] = switch ($code) { 1 { if ($v.Length -ge 4) { Format-IPBytes $v 0 } else { Format-HexBytes $v } } 3 { if ($v.Length -ge 4) { $routers = New-Object System.Collections.Generic.List[string] for ($o = 0; $o + 3 -lt $v.Length; $o += 4) { $routers.Add((Format-IPBytes $v $o)) } , $routers.ToArray() } else { Format-HexBytes $v } } 6 { $servers = New-Object System.Collections.Generic.List[string] for ($o = 0; $o + 3 -lt $v.Length; $o += 4) { $servers.Add((Format-IPBytes $v $o)) } , $servers.ToArray() } 15 { [System.Text.Encoding]::ASCII.GetString($v) } 51 { if ($v.Length -ge 4) { ReadU32BE $v 0 } else { $null } } 53 { if ($v.Length -ge 1) { $MsgTypeNames[[int]$v[0]] } else { $null } } 54 { if ($v.Length -ge 4) { Format-IPBytes $v 0 } else { Format-HexBytes $v } } 56 { [System.Text.Encoding]::ASCII.GetString($v) } 58 { if ($v.Length -ge 4) { ReadU32BE $v 0 } else { $null } } 59 { if ($v.Length -ge 4) { ReadU32BE $v 0 } else { $null } } default { Format-HexBytes $v } } } return $decoded } function Get-DhcpMessageTypeName([hashtable]$rawOptions) { if ($rawOptions.ContainsKey(53) -and $rawOptions[53].Length -ge 1) { return $MsgTypeNames[[int]$rawOptions[53][0]] } return $null } # Returns @{Data=[byte[]]; EP=[IPEndPoint]} or $null on timeout function Recv([System.Net.Sockets.UdpClient]$u, [int]$ms) { $u.Client.ReceiveTimeout = $ms $ep = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0) try { $data = $u.Receive([ref]$ep) return @{ Data = $data; EP = $ep } } catch [System.Net.Sockets.SocketException] { if ($_.Exception.SocketErrorCode -eq [System.Net.Sockets.SocketError]::TimedOut) { return $null } throw } } function New-DhcpPacket { param( [byte]$MessageType, [uint32]$Xid, [byte[]]$ChaddrBytes, [System.Collections.Generic.List[hashtable]]$Options, [string]$RequestedIP, [string]$ServerId, [byte[]]$GiaddrBytes ) $hdr = [byte[]]::new(236) $hdr[0] = 1 # op = BOOTREQUEST $hdr[1] = 1 # htype = Ethernet $hdr[2] = 6 # hlen $hdr[3] = 0 # hops [Buffer]::BlockCopy([byte[]](WriteU32BE $Xid), 0, $hdr, 4, 4) [Buffer]::BlockCopy([byte[]](WriteU16BE 0), 0, $hdr, 8, 2) # secs [Buffer]::BlockCopy([byte[]](WriteU16BE 0x8000), 0, $hdr, 10, 2) # flags: broadcast bit always set # ciaddr/yiaddr/siaddr left zero if ($GiaddrBytes) { [Buffer]::BlockCopy($GiaddrBytes, 0, $hdr, 24, 4) } [Buffer]::BlockCopy($ChaddrBytes, 0, $hdr, 28, 6) # chaddr, remaining 10 bytes zero-padded $ms = [System.IO.MemoryStream]::new() $ms.Write($hdr, 0, $hdr.Length) $ms.Write($MagicCookie, 0, $MagicCookie.Length) Add-DhcpOption $ms 53 ([byte[]]@($MessageType)) if ($MessageType -eq $MSG_REQUEST) { Add-DhcpOption $ms 50 ([System.Net.IPAddress]::Parse($RequestedIP).GetAddressBytes()) Add-DhcpOption $ms 54 ([System.Net.IPAddress]::Parse($ServerId).GetAddressBytes()) } elseif ($RequestedIP) { Add-DhcpOption $ms 50 ([System.Net.IPAddress]::Parse($RequestedIP).GetAddressBytes()) } if ($RequestOptions -and $RequestOptions.Count -gt 0) { $prl = [byte[]]::new($RequestOptions.Count) for ($i = 0; $i -lt $RequestOptions.Count; $i++) { if ($RequestOptions[$i] -lt 0 -or $RequestOptions[$i] -gt 255) { throw "RequestOptions value $($RequestOptions[$i]) is out of range 0-255." } $prl[$i] = [byte]$RequestOptions[$i] } Add-DhcpOption $ms 55 $prl } foreach ($opt in $Options) { $valueBytes = ConvertTo-DhcpOptionBytes -Code $opt.Code -Type $opt.Type -Value $opt.Value Add-DhcpOption $ms $opt.Code $valueBytes } $ms.WriteByte(255) # End option $bytes = $ms.ToArray() if ($bytes.Length -lt 300) { $padded = [byte[]]::new(300) [Buffer]::BlockCopy($bytes, 0, $padded, 0, $bytes.Length) return $padded } return $bytes } # ── Argument validation / option assembly ──────────────────────────────── $xid = if ($PSBoundParameters.ContainsKey('TransactionId')) { $TransactionId } else { [uint32](Get-Random -Minimum 1 -Maximum ([int64]4294967295)) } $clientMacBytes = if ($ClientMac) { ConvertTo-MacBytes $ClientMac } else { New-RandomMacBytes } $clientMacString = Format-MacBytes $clientMacBytes $allOptions = [System.Collections.Generic.List[hashtable]]::new() $usedCodes = [System.Collections.Generic.HashSet[int]]::new() function Add-NamedOption([int]$Code, [string]$Type, $Value, [string]$Name) { if ($null -eq $Value -or $Value -eq '') { return } if (-not $usedCodes.Add($Code)) { throw "Option $Code specified both via -$Name and -Option; use only one." } $allOptions.Add(@{ Code = $Code; Type = $Type; Value = $Value }) } foreach ($opt in $Option) { if (-not $opt.ContainsKey('Code')) { throw "Each -Option entry must include a 'Code' key." } if (-not $opt.ContainsKey('Type')) { throw "Each -Option entry must include a 'Type' key." } if (-not $opt.ContainsKey('Value')) { throw "Each -Option entry must include a 'Value' key." } $code = [int]$opt.Code if ($code -le 0 -or $code -ge 255) { throw "Option code $code is invalid; valid custom option codes are 1-254." } if (-not $usedCodes.Add($code)) { throw "Option $code specified more than once." } $allOptions.Add(@{ Code = $code; Type = $opt.Type; Value = $opt.Value }) } Add-NamedOption 12 'String' $Hostname 'Hostname' Add-NamedOption 60 'String' $VendorClassIdentifier 'VendorClassIdentifier' Add-NamedOption 61 'String' $ClientIdentifier 'ClientIdentifier' if ($CircuitId -or $RemoteId) { if (-not $usedCodes.Add(82)) { throw "Option 82 specified both via -CircuitId/-RemoteId and -Option; use only one." } $subMs = [System.IO.MemoryStream]::new() if ($CircuitId) { $cidBytes = ConvertTo-DhcpOptionBytes -Code 82 -Type $CircuitIdType -Value $CircuitId if ($cidBytes.Length -gt 255) { throw "CircuitId value is $($cidBytes.Length) bytes, exceeds the 255-byte sub-option limit." } $subMs.WriteByte(1) $subMs.WriteByte([byte]$cidBytes.Length) $subMs.Write($cidBytes, 0, $cidBytes.Length) } if ($RemoteId) { $ridBytes = ConvertTo-DhcpOptionBytes -Code 82 -Type $RemoteIdType -Value $RemoteId if ($ridBytes.Length -gt 255) { throw "RemoteId value is $($ridBytes.Length) bytes, exceeds the 255-byte sub-option limit." } $subMs.WriteByte(2) $subMs.WriteByte([byte]$ridBytes.Length) $subMs.Write($ridBytes, 0, $ridBytes.Length) } $allOptions.Add(@{ Code = 82; Type = 'ByteArray'; Value = $subMs.ToArray() }) } $bindAddr = if ($Bind) { $parsed = $null if (-not [System.Net.IPAddress]::TryParse($Bind, [ref]$parsed)) { throw "Invalid -Bind address: '$Bind'." } $parsed } else { [System.Net.IPAddress]::Any } $giaddrBytes = if ($RelayAgentIPAddress) { $parsed = $null if (-not [System.Net.IPAddress]::TryParse($RelayAgentIPAddress, [ref]$parsed)) { throw "Invalid -RelayAgentIPAddress: '$RelayAgentIPAddress'." } $parsed.GetAddressBytes() } else { $null } $serverIP = $null try { $serverIP = [System.Net.IPAddress]::Parse($ServerAddress) } catch { try { $serverIP = [System.Net.Dns]::GetHostAddresses($ServerAddress) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | Select-Object -First 1 if (-not $serverIP) { throw "No IPv4 address." } } catch { throw "Cannot resolve -ServerAddress '$ServerAddress': $_" } } $destEP = [System.Net.IPEndPoint]::new($serverIP, $ServerPort) # ── Result object ───────────────────────────────────────────────────────── $result = [PSCustomObject]@{ Status = 'Unknown' TransactionId = '0x' + $xid.ToString('X8') ClientMac = $clientMacString ServerAddress = $ServerAddress ResponderAddress = $null ServerIdentifier = $null OfferedAddress = $null AckAddress = $null LeaseTimeSeconds = $null LeaseTime = $null SubnetMask = $null Router = $null DnsServers = $null DomainName = $null RenewalTimeT1 = $null RebindingTimeT2 = $null RequestSent = $false NakMessage = $null Options = $null Attempts = 0 ElapsedMs = $null Error = $null } # ── Socket setup ───────────────────────────────────────────────────────── $udp = [System.Net.Sockets.UdpClient]::new() try { $udp.Client.SetSocketOption([System.Net.Sockets.SocketOptionLevel]::Socket, [System.Net.Sockets.SocketOptionName]::ReuseAddress, $true) $udp.EnableBroadcast = $true try { $udp.Client.Bind([System.Net.IPEndPoint]::new($bindAddr, $ClientPort)) } catch [System.Net.Sockets.SocketException] { throw "Failed to bind UDP port $ClientPort ($($_.Exception.Message)). This usually means the Windows 'Dhcp' client service or another instance of this tool holds the port, or you are not running elevated. Try: (1) run PowerShell as Administrator, and/or (2) temporarily stop the DHCP Client service: Stop-Service Dhcp -Force (Start-Service Dhcp afterward)." } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { # ── DISCOVER → OFFER ───────────────────────────────────────────── $discoverPkt = New-DhcpPacket -MessageType $MSG_DISCOVER -Xid $xid -ChaddrBytes $clientMacBytes -Options $allOptions -RequestedIP $RequestedIPAddress -GiaddrBytes $giaddrBytes $attempt = 0 $offer = $null while (-not $offer -and $attempt -lt $Retries) { $null = $udp.Send($discoverPkt, $discoverPkt.Length, $destEP) $result.Attempts++ Write-Verbose "Sent DHCPDISCOVER (xid=$($result.TransactionId), chaddr=$clientMacString) to $($destEP), attempt $($attempt + 1)/$Retries" $r = Recv $udp ($TimeoutSeconds * 1000) if (-not $r) { $attempt++ Write-Verbose "No reply within $TimeoutSeconds s (retry $attempt/$Retries)" continue } $rawOpts = Read-DhcpOptions $r.Data $rxXid = ReadU32BE $r.Data 4 $msgType = Get-DhcpMessageTypeName $rawOpts if ($rxXid -ne $xid -or $msgType -ne 'Offer') { Write-Verbose "Ignoring unrelated packet (xid=0x$($rxXid.ToString('X8')), type=$msgType) from $($r.EP.Address)" continue } $offer = $r } if ($LogActivity) { Write-Log -Message "DISCOVER sent, xid=$($result.TransactionId), attempts=$($result.Attempts)" -Level INFO -fileNamePrefix 'Invoke-DhcpTest' } if (-not $offer) { $result.Status = 'Timeout' $result.Error = "No DHCPOFFER received after $Retries attempt(s) ($TimeoutSeconds sec each)." $result.ElapsedMs = $sw.Elapsed.TotalMilliseconds return $result } $offerRaw = Read-DhcpOptions $offer.Data $offerDecoded = ConvertFrom-DhcpOptions $offerRaw $offeredAddress = Format-IPBytes $offer.Data 16 $serverIdentifier = if ($offerDecoded.Contains(54)) { $offerDecoded[54] } else { $offer.EP.Address.ToString() } $result.Status = 'OfferReceived' $result.ResponderAddress = $offer.EP.Address.ToString() $result.ServerIdentifier = $serverIdentifier $result.OfferedAddress = $offeredAddress $result.LeaseTimeSeconds = if ($offerDecoded.Contains(51)) { [int]$offerDecoded[51] } else { $null } $result.LeaseTime = if ($result.LeaseTimeSeconds) { [TimeSpan]::FromSeconds($result.LeaseTimeSeconds) } else { $null } $result.SubnetMask = if ($offerDecoded.Contains(1)) { $offerDecoded[1] } else { $null } $result.Router = if ($offerDecoded.Contains(3)) { $offerDecoded[3] } else { $null } $result.DnsServers = if ($offerDecoded.Contains(6)) { $offerDecoded[6] } else { $null } $result.DomainName = if ($offerDecoded.Contains(15)) { $offerDecoded[15] } else { $null } $result.RenewalTimeT1 = if ($offerDecoded.Contains(58)) { [int]$offerDecoded[58] } else { $null } $result.RebindingTimeT2 = if ($offerDecoded.Contains(59)) { [int]$offerDecoded[59] } else { $null } $result.Options = $offerDecoded Write-Verbose "DHCPOFFER received: address=$offeredAddress server=$serverIdentifier lease=$($result.LeaseTimeSeconds)s" if (-not $SendRequest) { $result.ElapsedMs = $sw.Elapsed.TotalMilliseconds return $result } # ── REQUEST → ACK/NAK ──────────────────────────────────────────── $requestedIP = if ($RequestedIPAddress) { $RequestedIPAddress } else { $offeredAddress } if (-not $PSCmdlet.ShouldProcess("$ServerAddress`:$ServerPort", "Request lease $requestedIP via server $serverIdentifier")) { $result.ElapsedMs = $sw.Elapsed.TotalMilliseconds return $result } $requestPkt = New-DhcpPacket -MessageType $MSG_REQUEST -Xid $xid -ChaddrBytes $clientMacBytes -Options $allOptions -RequestedIP $requestedIP -ServerId $serverIdentifier -GiaddrBytes $giaddrBytes $result.RequestSent = $true $attempt = 0 $final = $null while (-not $final -and $attempt -lt $Retries) { $null = $udp.Send($requestPkt, $requestPkt.Length, $destEP) Write-Verbose "Sent DHCPREQUEST for $requestedIP (xid=$($result.TransactionId)), attempt $($attempt + 1)/$Retries" $r = Recv $udp ($TimeoutSeconds * 1000) if (-not $r) { $attempt++ Write-Verbose "No reply within $TimeoutSeconds s (retry $attempt/$Retries)" continue } $rawOpts = Read-DhcpOptions $r.Data $rxXid = ReadU32BE $r.Data 4 $msgType = Get-DhcpMessageTypeName $rawOpts if ($rxXid -ne $xid -or ($msgType -ne 'Ack' -and $msgType -ne 'Nak')) { Write-Verbose "Ignoring unrelated packet (xid=0x$($rxXid.ToString('X8')), type=$msgType) from $($r.EP.Address)" continue } $final = @{ Reply = $r; RawOpts = $rawOpts; MsgType = $msgType } } if ($LogActivity) { Write-Log -Message "REQUEST sent for $requestedIP, xid=$($result.TransactionId)" -Level INFO -fileNamePrefix 'Invoke-DhcpTest' } if (-not $final) { $result.Status = 'NoAckResponse' $result.Error = "No DHCPACK/DHCPNAK received after $Retries attempt(s) ($TimeoutSeconds sec each)." $result.ElapsedMs = $sw.Elapsed.TotalMilliseconds return $result } $finalDecoded = ConvertFrom-DhcpOptions $final.RawOpts $result.ResponderAddress = $final.Reply.EP.Address.ToString() $result.Options = $finalDecoded if ($final.MsgType -eq 'Nak') { $result.Status = 'NakReceived' $result.NakMessage = if ($finalDecoded.Contains(56)) { $finalDecoded[56] } else { $null } Write-Verbose "DHCPNAK received: $($result.NakMessage)" } else { $ackAddress = Format-IPBytes $final.Reply.Data 16 $result.Status = 'AckReceived' $result.AckAddress = $ackAddress $result.LeaseTimeSeconds = if ($finalDecoded.Contains(51)) { [int]$finalDecoded[51] } else { $result.LeaseTimeSeconds } $result.LeaseTime = if ($result.LeaseTimeSeconds) { [TimeSpan]::FromSeconds($result.LeaseTimeSeconds) } else { $null } $result.ServerIdentifier = if ($finalDecoded.Contains(54)) { $finalDecoded[54] } else { $result.ServerIdentifier } Write-Verbose "DHCPACK received: address=$ackAddress lease=$($result.LeaseTimeSeconds)s" } $result.ElapsedMs = $sw.Elapsed.TotalMilliseconds return $result } catch [System.Net.Sockets.SocketException] { $result.Status = 'Error' $result.Error = $_.Exception.Message $result.ElapsedMs = $sw.Elapsed.TotalMilliseconds return $result } } finally { $udp.Close() $udp.Dispose() } } |