Public/ntp/Get-NTPSyncStatus.ps1

#Requires -Version 5.1

function Get-NTPSyncStatus {
    <#
    .SYNOPSIS
        Retrieves NTP synchronization status on Windows machines
    .DESCRIPTION
        Queries the Windows Time Service (w32tm) to retrieve NTP
        synchronization details 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 direct w32tm calls for local queries and Invoke-Command for remote
        execution, avoiding unnecessary serialization overhead on the local machine.
    .PARAMETER ComputerName
        One or more computer names to query. 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
        Get-NTPSyncStatus
 
        Retrieves NTP sync status on the local machine with the default 1000ms threshold.
    .EXAMPLE
        Get-NTPSyncStatus -ComputerName 'DC01' -MaxOffsetMs 500
 
        Retrieves NTP sync status on remote server DC01 with a 500ms offset threshold.
    .EXAMPLE
        'DC01', 'DC02', 'WEB01' | Get-NTPSyncStatus -MaxOffsetMs 2000
 
        Pipeline example: retrieves NTP sync status on multiple machines with a 2-second threshold.
    .OUTPUTS
    PSWinOps.NtpSyncResult
        NTP synchronization status with offset and compliance flag.
 
    .NOTES
        Author: Franck SALLET
        Version: 2.1.0
        Last Modified: 2026-03-20
        Requires: PowerShell 5.1+ / Windows only
        Permissions: Admin rights required for remote queries (WinRM access)
     
    .LINK
    https://docs.microsoft.com/en-us/windows-server/networking/windows-time-service/windows-time-service-tools-and-settings
    #>

    [CmdletBinding()]
    [OutputType('PSWinOps.NtpSyncResult')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 2147483647)]
        [int]$MaxOffsetMs = 1000
    )

    begin {
        Write-Verbose "[$($MyInvocation.MyCommand)] Starting - PowerShell $($PSVersionTable.PSVersion)"

        # Script block used for REMOTE execution only (Invoke-Command).
        # Uses full path to w32tm.exe because remote sessions don't inherit local mock context.
        $w32tmRemoteScriptBlock = {
            $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 - direct w32tm call"
                    $rawOutput = w32tm /query /status 2>&1
                    if ($LASTEXITCODE -ne 0) {
                        throw "w32tm /query /status failed (exit code $LASTEXITCODE): $($rawOutput -join ' ')"
                    }
                } else {
                    Write-Verbose "[$($MyInvocation.MyCommand)] Remote execution on '$targetComputer'"
                    $rawOutput = Invoke-Command -ComputerName $targetComputer -ScriptBlock $w32tmRemoteScriptBlock -ErrorAction Stop
                }

                # 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"
    }
}