functions/Invoke-DhcpTest.ps1

#Requires -Version 5.1
<#
.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 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 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
    Overrides the address requested in the REQUEST packet (option 50). Defaults to the address
    offered in the DISCOVER/OFFER phase.

.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.

.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).
#>

function Invoke-DhcpTest {
    [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()][string]$Hostname,
        [Parameter()][string]$VendorClassIdentifier,
        [Parameter()][string]$ClientIdentifier,
        [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 ConvertFrom-DhcpOptions([hashtable]$rawOptions) {
        $decoded = [ordered]@{}
        foreach ($code in $rawOptions.Keys) {
            $v = $rawOptions[$code]
            $decoded[$code] = switch ($code) {
                1 { Format-IPBytes $v 0 }
                3 { Format-IPBytes $v 0 }
                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 { ReadU32BE $v 0 }
                53 { $MsgTypeNames[[int]$v[0]] }
                54 { Format-IPBytes $v 0 }
                56 { [System.Text.Encoding]::ASCII.GetString($v) }
                58 { ReadU32BE $v 0 }
                59 { ReadU32BE $v 0 }
                default { ($v | ForEach-Object { $_.ToString('x2') }) -join '' }
            }
        }
        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
        )

        $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/giaddr left zero
        [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())
        }

        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'

    $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 }

    $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

            $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
            $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()
    }
}