functions/Test-NtpServer.ps1

<#
.SYNOPSIS
    Tests one or more NTP servers and returns time synchronization information.

.DESCRIPTION
    Queries NTP servers using UDP port 123 and retrieves time synchronization data.
    Returns the server time, offset from local clock, round-trip delay, and stratum level.
    Supports testing multiple servers in parallel.

.PARAMETER Server
    One or more NTP server hostnames or IP addresses to test.

.PARAMETER Port
    UDP port to use for NTP queries. Default is 123.

.PARAMETER Timeout
    Timeout in milliseconds for each NTP query. Default is 3000.

.PARAMETER Count
    Number of NTP queries to send per server for averaging. Default is 1.

.EXAMPLE
    Test-NtpServer -Server "pool.ntp.org"

    Server : pool.ntp.org
    Status : Success
    ServerTime : 2026-04-14 10:23:45
    OffsetMs : -12.34
    DelayMs : 25.67
    Stratum : 2
    ReferenceId : 192.5.41.40

.EXAMPLE
    Test-NtpServer -Server "0.pool.ntp.org","1.pool.ntp.org","time.windows.com"

.EXAMPLE
    "pool.ntp.org","time.cloudflare.com" | Test-NtpServer -Count 3

.NOTES
    NTP uses UDP port 123. Some firewalls may block this traffic.
    Stratum 1 = directly connected to reference clock (GPS, atomic clock).
    Stratum 2+ = synchronized from a higher-stratum server.
    Offset is the difference between server time and local clock (negative = local clock is ahead).
#>

function Test-NtpServer {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [string[]]$Server,

        [Parameter()]
        [ValidateRange(1, 65535)]
        [int]$Port = 123,

        [Parameter()]
        [ValidateRange(100, 30000)]
        [int]$Timeout = 3000,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$Count = 1
    )

    begin {
        $allServers = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($s in $Server) {
            $allServers.Add($s)
        }
    }

    end {
        foreach ($ntpServer in $allServers) {
            for ($i = 0; $i -lt $Count; $i++) {
                $result = [PSCustomObject]@{
                    Server      = $ntpServer
                    Status      = 'Unknown'
                    ServerTime  = $null
                    OffsetMs    = $null
                    DelayMs     = $null
                    Stratum     = $null
                    ReferenceId = $null
                    Error       = $null
                }

                try {
                    # NTP request packet (48 bytes). LI=0, VN=3, Mode=3 (client)
                    $ntpData = New-Object byte[] 48
                    $ntpData[0] = 0x1B  # LI=0, VN=3, Mode=3

                    $udpClient = New-Object System.Net.Sockets.UdpClient
                    $udpClient.Client.ReceiveTimeout = $Timeout

                    $t1 = [DateTime]::UtcNow

                    $udpClient.Connect($ntpServer, $Port)
                    [void]$udpClient.Send($ntpData, $ntpData.Length)

                    $remoteEndpoint = New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)
                    $response = $udpClient.Receive([ref]$remoteEndpoint)
                    $udpClient.Close()

                    $t4 = [DateTime]::UtcNow

                    if ($response.Length -lt 48) {
                        throw "Invalid NTP response: packet too short ($($response.Length) bytes)"
                    }

                    # NTP epoch starts 1900-01-01; .NET epoch starts 0001-01-01
                    $ntpEpoch = [DateTime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)

                    # Transmit Timestamp (bytes 40-47) - T3: when server sent reply
                    $secondsT3 = [BitConverter]::ToUInt32($response[43..40], 0)
                    $fractionT3 = [BitConverter]::ToUInt32($response[47..44], 0)
                    $t3 = $ntpEpoch.AddSeconds($secondsT3 + $fractionT3 / [Math]::Pow(2, 32))

                    # Receive Timestamp (bytes 32-39) - T2: when server received request
                    $secondsT2 = [BitConverter]::ToUInt32($response[35..32], 0)
                    $fractionT2 = [BitConverter]::ToUInt32($response[39..36], 0)
                    $t2 = $ntpEpoch.AddSeconds($secondsT2 + $fractionT2 / [Math]::Pow(2, 32))

                    # Round-trip delay = (T4 - T1) - (T3 - T2)
                    $delayMs = [Math]::Round((($t4 - $t1) - ($t3 - $t2)).TotalMilliseconds, 2)

                    # Clock offset = ((T2 - T1) + (T3 - T4)) / 2
                    $offsetMs = [Math]::Round((($t2 - $t1) + ($t3 - $t4)).TotalMilliseconds / 2, 2)

                    # Server time = T3 + half round-trip
                    $serverTime = $t3.AddMilliseconds($delayMs / 2).ToLocalTime()

                    # Stratum (byte 1)
                    $stratum = $response[1]

                    # Reference Identifier (bytes 12-15)
                    if ($stratum -le 1) {
                        # For stratum 1, reference ID is ASCII
                        $refId = [System.Text.Encoding]::ASCII.GetString($response[12..15]).TrimEnd("`0")
                    } else {
                        # For stratum 2+, reference ID is an IPv4 address
                        $refId = "$($response[12]).$($response[13]).$($response[14]).$($response[15])"
                    }

                    $result.Status     = 'Success'
                    $result.ServerTime = $serverTime.ToString('yyyy-MM-dd HH:mm:ss')
                    $result.OffsetMs   = $offsetMs
                    $result.DelayMs    = [Math]::Abs($delayMs)
                    $result.Stratum    = $stratum
                    $result.ReferenceId = $refId
                }
                catch [System.Net.Sockets.SocketException] {
                    $result.Status = 'TimedOut'
                    $result.Error  = $_.Exception.Message
                }
                catch {
                    $result.Status = 'Error'
                    $result.Error  = $_.Exception.Message
                    if ($udpClient) { try { $udpClient.Close() } catch {} }
                }

                $result
            }
        }
    }
}