Private/Invoke-InfraPulseTimeSyncCheck.ps1
|
function Invoke-InfraPulseTimeSyncCheck { [CmdletBinding()] param( [Parameter(Mandatory)] [psobject]$Context, [Parameter(Mandatory)] [System.Collections.IDictionary]$Settings ) $servers = @($Settings.Servers) if ($servers.Count -eq 0) { return New-InfraPulseResult -Status 'Skipped' -CheckName 'TimeSync' -Category 'Connectivity' -ComputerName $Context.ComputerName -Target 'NTP servers' -Message 'No NTP servers are configured.' -Recommendation 'Add NTP server names under Checks.TimeSync.Servers.' } $scriptBlock = { param($CheckSettings) function Write-NtpTimestamp { param( [byte[]]$Buffer, [int]$Offset, [datetime]$Timestamp ) $epoch = [datetime]::SpecifyKind([datetime]'1900-01-01 00:00:00', [DateTimeKind]::Utc) $totalSeconds = ($Timestamp.ToUniversalTime() - $epoch).TotalSeconds $seconds = [uint32][math]::Floor($totalSeconds) $fractionValue = [math]::Floor(($totalSeconds - [math]::Floor($totalSeconds)) * 4294967296.0) if ($fractionValue -gt [uint32]::MaxValue) { $fractionValue = [uint32]::MaxValue } $fraction = [uint32]$fractionValue $secondsBytes = [BitConverter]::GetBytes($seconds) $fractionBytes = [BitConverter]::GetBytes($fraction) if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($secondsBytes) [Array]::Reverse($fractionBytes) } [Array]::Copy($secondsBytes, 0, $Buffer, $Offset, 4) [Array]::Copy($fractionBytes, 0, $Buffer, $Offset + 4, 4) } function Read-NtpTimestamp { param( [byte[]]$Buffer, [int]$Offset ) $secondsBytes = New-Object byte[] 4 $fractionBytes = New-Object byte[] 4 [Array]::Copy($Buffer, $Offset, $secondsBytes, 0, 4) [Array]::Copy($Buffer, $Offset + 4, $fractionBytes, 0, 4) if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($secondsBytes) [Array]::Reverse($fractionBytes) } $seconds = [BitConverter]::ToUInt32($secondsBytes, 0) $fraction = [BitConverter]::ToUInt32($fractionBytes, 0) $epoch = [datetime]::SpecifyKind([datetime]'1900-01-01 00:00:00', [DateTimeKind]::Utc) return $epoch.AddSeconds([double]$seconds + ([double]$fraction / 4294967296.0)) } foreach ($server in @($CheckSettings.Servers)) { $serverName = [string]$server $udpClient = $null $timer = [System.Diagnostics.Stopwatch]::StartNew() try { $addresses = @( [System.Net.Dns]::GetHostAddresses($serverName) | Where-Object { $_.AddressFamily -in @( [System.Net.Sockets.AddressFamily]::InterNetwork, [System.Net.Sockets.AddressFamily]::InterNetworkV6 ) } ) $address = $addresses | Sort-Object -Property @{ Expression = { if ($_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { 0 } else { 1 } } } | Select-Object -First 1 if ($null -eq $address) { throw 'No IPv4 or IPv6 address was returned for the NTP server.' } $request = New-Object byte[] 48 $request[0] = 0x23 $t1 = [DateTime]::UtcNow Write-NtpTimestamp -Buffer $request -Offset 40 -Timestamp $t1 $udpClient = New-Object System.Net.Sockets.UdpClient -ArgumentList $address.AddressFamily $udpClient.Client.ReceiveTimeout = [int]$CheckSettings.TimeoutMilliseconds $udpClient.Connect($address, 123) $null = $udpClient.Send($request, $request.Length) $anyAddress = if ($address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { [System.Net.IPAddress]::IPv6Any } else { [System.Net.IPAddress]::Any } $remoteEndpoint = New-Object System.Net.IPEndPoint -ArgumentList ($anyAddress, 0) $response = $udpClient.Receive([ref]$remoteEndpoint) $t4 = [DateTime]::UtcNow $timer.Stop() if ($response.Length -lt 48) { throw "NTP response contained only $($response.Length) bytes." } for ($index = 0; $index -lt 8; $index++) { if ($response[24 + $index] -ne $request[40 + $index]) { throw 'The NTP response originate timestamp does not match the request.' } } $leapIndicator = ($response[0] -shr 6) -band 0x03 $version = ($response[0] -shr 3) -band 0x07 $mode = $response[0] -band 0x07 $stratum = [int]$response[1] if ($leapIndicator -eq 3) { throw 'The NTP server reports an unsynchronized clock.' } if ($version -lt 3 -or $version -gt 4) { throw "The NTP response used unsupported protocol version '$version'." } if ($mode -ne 4) { throw "The NTP response used mode '$mode' instead of server mode 4." } if ($stratum -eq 0) { throw 'The NTP server returned a kiss-of-death or invalid stratum response.' } if ($stratum -gt 15) { throw "The NTP server returned invalid or unsynchronized stratum '$stratum'." } $t2 = Read-NtpTimestamp -Buffer $response -Offset 32 $t3 = Read-NtpTimestamp -Buffer $response -Offset 40 $offsetSeconds = ((($t2 - $t1).TotalSeconds) + (($t3 - $t4).TotalSeconds)) / 2.0 $roundTripMs = ((($t4 - $t1).TotalSeconds - ($t3 - $t2).TotalSeconds) * 1000.0) if ($roundTripMs -lt 0) { $roundTripMs = 0 } [pscustomobject]@{ Server = $serverName Address = $address.IPAddressToString Success = $true OffsetSeconds = [math]::Round($offsetSeconds, 6) AbsoluteOffsetSeconds = [math]::Round([math]::Abs($offsetSeconds), 6) RoundTripMilliseconds = [math]::Round($roundTripMs, 2) Stratum = $stratum Version = $version Mode = $mode DurationMs = [math]::Round($timer.Elapsed.TotalMilliseconds, 2) Error = $null } } catch { $timer.Stop() [pscustomobject]@{ Server = $serverName Address = $null Success = $false OffsetSeconds = $null AbsoluteOffsetSeconds = $null RoundTripMilliseconds = $null Stratum = $null Version = $null Mode = $null DurationMs = [math]::Round($timer.Elapsed.TotalMilliseconds, 2) Error = $_.Exception.Message } } finally { if ($null -ne $udpClient) { $udpClient.Close() } } } } $raw = @(Invoke-InfraPulseCommand -Context $Context -ScriptBlock $scriptBlock -ArgumentList @($Settings)) $results = @() foreach ($sample in $raw) { if (-not [bool]$sample.Success) { $status = 'Unknown' $message = "NTP query to $($sample.Server) failed." $recommendation = 'Validate DNS, UDP/123 reachability, NTP policy, and the configured time source.' $observed = $null } elseif ([double]$sample.AbsoluteOffsetSeconds -ge [double]$Settings.CriticalOffsetSeconds) { $status = 'Critical' $message = "Clock offset against $($sample.Server) is $($sample.OffsetSeconds) seconds." $recommendation = 'Correct the time hierarchy and synchronization state immediately; large offsets can break authentication and distributed workloads.' $observed = [double]$sample.OffsetSeconds } elseif ([double]$sample.AbsoluteOffsetSeconds -ge [double]$Settings.WarningOffsetSeconds) { $status = 'Warning' $message = "Clock offset against $($sample.Server) is $($sample.OffsetSeconds) seconds." $recommendation = 'Review time-source selection, synchronization health, and network latency before the offset becomes service-impacting.' $observed = [double]$sample.OffsetSeconds } else { $status = 'Healthy' $message = "Clock offset against $($sample.Server) is $($sample.OffsetSeconds) seconds." $recommendation = '' $observed = [double]$sample.OffsetSeconds } $evidence = [ordered]@{ Server = [string]$sample.Server Address = [string]$sample.Address OffsetSeconds = $sample.OffsetSeconds AbsoluteOffsetSeconds = $sample.AbsoluteOffsetSeconds RoundTripMilliseconds = $sample.RoundTripMilliseconds Stratum = $sample.Stratum NtpVersion = $sample.Version Mode = $sample.Mode DurationMs = [double]$sample.DurationMs Error = [string]$sample.Error } $results += New-InfraPulseResult -Status $status -CheckName 'TimeSync' -Category 'Connectivity' -ComputerName $Context.ComputerName -Target ([string]$sample.Server) -Message $message -ObservedValue $observed -WarningThreshold (">= {0} seconds absolute offset" -f $Settings.WarningOffsetSeconds) -CriticalThreshold (">= {0} seconds absolute offset" -f $Settings.CriticalOffsetSeconds) -Recommendation $recommendation -Evidence $evidence -DurationMs ([double]$sample.DurationMs) -ErrorMessage ([string]$sample.Error) } return $results } |