Public/ntp/Test-NTPSync.ps1
|
#Requires -Version 5.1 function Test-NTPSync { <# .SYNOPSIS Tests NTP synchronization status on Windows machines .DESCRIPTION Queries the Windows Time Service (w32tm) to determine whether NTP synchronization is healthy on one or more machines. Parses the output of 'w32tm /query /status' to extract source, stratum, phase offset, last sync time, leap indicator, and poll interval. Supports both English and French locale w32tm output via locale-agnostic regex patterns. Uses Invoke-Command for both local and remote execution, enabling uniform testability and consistent error handling across targets. .PARAMETER ComputerName One or more computer names to test. Accepts pipeline input by value and by property name. Defaults to the local machine ($env:COMPUTERNAME). .PARAMETER MaxOffsetMs Maximum acceptable time offset in milliseconds. If the absolute parsed offset exceeds this value, IsSynced is set to $false. Must be at least 1. Defaults to 1000. .EXAMPLE Test-NTPSync Tests NTP sync status on the local machine with the default 1000ms threshold. .EXAMPLE Test-NTPSync -ComputerName 'DC01' -MaxOffsetMs 500 Tests NTP sync on remote server DC01 with a 500ms offset threshold. .EXAMPLE 'DC01', 'DC02', 'WEB01' | Test-NTPSync -MaxOffsetMs 2000 Pipeline example: tests NTP sync on multiple machines with a 2-second threshold. .NOTES Author: Ecritel IT Team Version: 1.0.0 Last Modified: 2026-03-12 Requires: PowerShell 5.1+ / Windows only Permissions: Admin rights required for remote queries (WinRM access) #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [ValidateRange(1, 2147483647)] [int]$MaxOffsetMs = 1000 ) begin { Write-Verbose "[$($MyInvocation.MyCommand)] Starting - PowerShell $($PSVersionTable.PSVersion)" # ScriptBlock executed on each target via Invoke-Command $w32tmScriptBlock = { $w32tmExe = Join-Path -Path $env:SystemRoot -ChildPath 'System32\w32tm.exe' if (-not (Test-Path -Path $w32tmExe)) { throw "[ERROR] w32tm.exe not found at '$w32tmExe'" } $w32tmOutput = & $w32tmExe /query /status 2>&1 if ($LASTEXITCODE -ne 0) { throw "[ERROR] w32tm exited with code $LASTEXITCODE : $($w32tmOutput -join ' ')" } $w32tmOutput } # Locale-agnostic regex patterns (EN + FR) $rxSource = '(?i)^Source\s*:\s*(.+)$' $rxStratum = '(?i)(?:Stratum|Strate)\s*:\s*(\d+)' $rxLeap = '(?i)(?:Leap Indicator|Indicateur de saut)\s*:\s*(.+)$' $rxLastSync = '(?i)(?:Last Successful Sync Time|Heure de la derni.re synchronisation r.ussie)\s*:\s*(.+)$' $rxPoll = '(?i)(?:Poll Interval|Intervalle d.interrogation)\s*:\s*(\d+)' $rxOffset = '(?i)(?:Phase Offset|D.calage de phase|Offset)\s*:\s*([+-]?\d+[\.,]\d+)s' # Sources indicating the clock is NOT synced to an external reference $unsyncedSourcePatterns = @( 'Free-Running System Clock' 'Local CMOS Clock' 'Horloge .* roue libre' ) } process { foreach ($targetComputer in $ComputerName) { Write-Verbose "[$($MyInvocation.MyCommand)] Querying NTP status on '$targetComputer'" try { # Determine if target is the local machine $isLocal = ($targetComputer -eq $env:COMPUTERNAME) -or ($targetComputer -eq 'localhost') -or ($targetComputer -eq '.') if ($isLocal) { Write-Verbose "[$($MyInvocation.MyCommand)] Local execution (no -ComputerName)" $rawOutput = Invoke-Command -ScriptBlock $w32tmScriptBlock } else { Write-Verbose "[$($MyInvocation.MyCommand)] Remote execution on '$targetComputer'" $rawOutput = Invoke-Command -ComputerName $targetComputer -ScriptBlock $w32tmScriptBlock } # Normalize output to trimmed string array $lines = @($rawOutput | ForEach-Object { "$_".Trim() } | Where-Object { $_ -ne '' }) # --- Parse Source --- $sourceValue = 'Unknown' foreach ($outputLine in $lines) { if ($outputLine -match $rxSource) { $sourceValue = $Matches[1].Trim() break } } # --- Parse Stratum --- $stratumValue = 0 foreach ($outputLine in $lines) { if ($outputLine -match $rxStratum) { $stratumValue = [int]$Matches[1] break } } # --- Parse Leap Indicator --- $leapValue = 'Unknown' foreach ($outputLine in $lines) { if ($outputLine -match $rxLeap) { $leapValue = $Matches[1].Trim() break } } # --- Parse Last Successful Sync Time --- $lastSyncValue = $null foreach ($outputLine in $lines) { if ($outputLine -match $rxLastSync) { $rawSyncTime = $Matches[1].Trim() try { $lastSyncValue = [datetime]::Parse($rawSyncTime) } catch { Write-Verbose "[$($MyInvocation.MyCommand)] Could not parse sync time: '$rawSyncTime'" } break } } # --- Parse Poll Interval --- $pollValue = $null foreach ($outputLine in $lines) { if ($outputLine -match $rxPoll) { $pollValue = [int]$Matches[1] break } } # --- Parse Phase Offset (seconds -> milliseconds) --- $offsetMs = 0.0 foreach ($outputLine in $lines) { if ($outputLine -match $rxOffset) { $offsetNumeric = $Matches[1] -replace ',', '.' $offsetMs = [math]::Abs([double]$offsetNumeric) * 1000.0 break } } # --- Determine IsSynced --- $isUnsyncedSource = $false foreach ($srcPattern in $unsyncedSourcePatterns) { if ($sourceValue -match $srcPattern) { $isUnsyncedSource = $true break } } $isSynced = (-not $isUnsyncedSource) -and ($offsetMs -le $MaxOffsetMs) # --- Emit result object --- [PSCustomObject]@{ PSTypeName = 'PSWinOps.NtpSyncResult' ComputerName = $targetComputer IsSynced = $isSynced Source = $sourceValue Stratum = $stratumValue OffsetMs = [math]::Round($offsetMs, 4) MaxOffsetMs = $MaxOffsetMs LastSyncTime = $lastSyncValue LeapIndicator = $leapValue PollInterval = $pollValue Timestamp = (Get-Date -Format 'o') } Write-Verbose "[$($MyInvocation.MyCommand)] '$targetComputer' - Synced: $isSynced, Source: $sourceValue, Offset: ${offsetMs}ms" } catch { Write-Error "[$($MyInvocation.MyCommand)] Failed to query NTP status on '${targetComputer}': $_" } } } end { Write-Verbose "[$($MyInvocation.MyCommand)] Completed" } } |