Public/ntp/Get-NTPPeer.ps1

#Requires -Version 5.1

function Get-NTPPeer {
    <#
    .SYNOPSIS
        Retrieves NTP peer information from the Windows Time service
    .DESCRIPTION
        Parses the output of 'w32tm /query /peers' to return structured NTP peer objects.
        Supports both modern and legacy w32tm output formats, including French-locale output.
        Uses block-based parsing: raw output is split on blank lines, the header block is
        skipped, and each subsequent block is parsed as one peer entry.
 
        Note: LastSyncTime is not available from /query /peers. Use Get-NTPConfiguration
        (which queries /query /status) for last synchronization information.
    .PARAMETER ComputerName
        One or more computer names to query. Defaults to the local machine.
    .EXAMPLE
        Get-NTPPeer
        Returns NTP peer information for the local computer.
    .EXAMPLE
        Get-NTPPeer -ComputerName 'SRV01', 'SRV02'
        Returns NTP peer information for two remote servers.
    .EXAMPLE
        'SRV01', 'SRV02' | Get-NTPPeer
        Pipeline usage: queries NTP peers on both servers.
    .NOTES
        Author: Franck SALLET
        Version: 1.1.0
        Last Modified: 2026-03-13
        Requires: PowerShell 5.1+, Windows Time service (w32time)
        Permissions: Local user for local queries; remote admin for Invoke-Command remoting
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName = $env:COMPUTERNAME
    )

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

        $w32tmScriptBlock = {
            $w32tmPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\w32tm.exe'
            if (-not (Test-Path -Path $w32tmPath)) {
                throw "w32tm.exe not found at '$w32tmPath'"
            }
            $peerOutput = & $w32tmPath /query /peers 2>&1

            if ($LASTEXITCODE -ne 0) {
                throw "w32tm /query /peers failed (exit code $LASTEXITCODE): $($peerOutput -join ' ')"
            }
            $peerOutput
        }
    }

    process {
        foreach ($targetComputer in $ComputerName) {
            Write-Verbose "[$($MyInvocation.MyCommand)] Querying '$targetComputer'"

            try {
                $isLocal = ($targetComputer -eq $env:COMPUTERNAME) -or
                ($targetComputer -eq 'localhost') -or
                ($targetComputer -eq '.')

                if ($isLocal) {
                    $rawOutput = & $w32tmScriptBlock
                } else {
                    $rawOutput = Invoke-Command -ComputerName $targetComputer `
                        -ScriptBlock $w32tmScriptBlock -ErrorAction Stop
                }

                # Convert to string array and normalize
                $lines = @($rawOutput | ForEach-Object { "$_" })

                # Split into blocks on blank lines
                $blocks = [System.Collections.Generic.List[System.Collections.Generic.List[string]]]::new()
                $currentBlock = [System.Collections.Generic.List[string]]::new()

                foreach ($line in $lines) {
                    if ([string]::IsNullOrWhiteSpace($line)) {
                        if ($currentBlock.Count -gt 0) {
                            $blocks.Add($currentBlock)
                            $currentBlock = [System.Collections.Generic.List[string]]::new()
                        }
                    } else {
                        $currentBlock.Add($line.Trim())
                    }
                }
                if ($currentBlock.Count -gt 0) {
                    $blocks.Add($currentBlock)
                }

                # First block is the header (#Peers: N) -- skip it
                if ($blocks.Count -le 1) {
                    Write-Warning "[$($MyInvocation.MyCommand)] No NTP peers found on '$targetComputer'"
                    continue
                }

                $peerBlocks = $blocks.GetRange(1, $blocks.Count - 1)

                foreach ($peerBlock in $peerBlocks) {
                    # First line is the Peer line
                    $peerLine = $peerBlock[0]
                    $peerName = $null
                    $peerFlags = $null

                    if ($peerLine -match '^Peer:\s*(.+)$') {
                        $peerValue = $Matches[1].Trim()
                        if ($peerValue -match '^(.+?),\s*(.+)$') {
                            $peerName = $Matches[1].Trim()
                            $peerFlags = $Matches[2].Trim()
                        } else {
                            $peerName = $peerValue
                        }
                    }

                    # Parse remaining lines as key:value
                    $peerState = $null
                    $timeRemaining = [double]0
                    $peerMode = $null
                    $peerStratum = $null
                    $peerPollInterval = $null
                    $hostPollInterval = $null

                    for ($i = 1; $i -lt $peerBlock.Count; $i++) {
                        $kvLine = $peerBlock[$i]
                        $label = ''
                        $kvValue = ''

                        if ($kvLine -match '^([^:]+):\s*(.*)$') {
                            $label = $Matches[1].Trim()
                            $kvValue = $Matches[2].Trim()
                        }

                        # State
                        if ($label -match 'State|tat') {
                            $peerState = $kvValue
                        }
                        # Time Remaining / Temps restant
                        elseif ($label -match 'Time Remaining|restant') {
                            if ($kvValue -match '([\d,\.]+)\s*s') {
                                $numStr = $Matches[1] -replace ',', '.'
                                $timeRemaining = [double]$numStr
                            }
                        }
                        # Mode
                        elseif ($label -match '^Mode') {
                            $peerMode = $kvValue
                        }
                        # Stratum
                        elseif ($label -match 'Strat') {
                            $peerStratum = $kvValue
                        }
                        # PeerPoll Interval
                        elseif ($label -match 'PeerPoll') {
                            if ($kvValue -match '(\d+)') {
                                $peerPollInterval = [int]$Matches[1]
                            }
                        }
                        # HostPoll Interval
                        elseif ($label -match 'HostPoll') {
                            if ($kvValue -match '(\d+)') {
                                $hostPollInterval = [int]$Matches[1]
                            }
                        }
                    }

                    [PSCustomObject]@{
                        PSTypeName       = 'PSWinOps.NtpPeer'
                        ComputerName     = $targetComputer
                        PeerName         = $peerName
                        PeerFlags        = $peerFlags
                        State            = $peerState
                        TimeRemaining    = $timeRemaining
                        Mode             = $peerMode
                        Stratum          = $peerStratum
                        PeerPollInterval = $peerPollInterval
                        HostPollInterval = $hostPollInterval
                        Timestamp        = Get-Date -Format 'o'
                    }
                }
            } catch {
                Write-Error "[$($MyInvocation.MyCommand)] Failed to query '$targetComputer': $_"
                continue
            }
        }
    }

    end {
        Write-Verbose "[$($MyInvocation.MyCommand)] Completed"
    }
}