Public/ntp/Get-NTPPeer.ps1

#Requires -Version 5.1

function Get-NTPPeer {
    <#
    .SYNOPSIS
        Lists configured NTP peers on one or more Windows machines
 
    .DESCRIPTION
        Queries the Windows Time Service using w32tm /query /peers on local or remote
        machines and returns structured objects for each configured NTP peer. Supports
        both English and French locale output from w32tm by using locale-agnostic
        value-pattern-based parsing (no locale-specific label matching).
 
        Accepts pipeline input for ComputerName, enabling bulk queries across a fleet.
        Each machine is queried independently with per-machine error isolation: if one
        machine fails, the function continues to the next and writes a non-terminating
        error for the failed machine.
 
        Uses Invoke-Command for both local and remote execution to provide a uniform
        code path and simplify testability.
 
    .PARAMETER ComputerName
        One or more computer names to query. Accepts pipeline input. Defaults to the
        local machine ($env:COMPUTERNAME). Values 'localhost' and '.' are treated as
        local. Must be a non-empty string or array of non-empty strings.
 
    .EXAMPLE
        Get-NTPPeer
 
        Lists all NTP peers configured on the local machine.
 
    .EXAMPLE
        Get-NTPPeer -ComputerName 'SRV-DC01' -Verbose
 
        Queries NTP peers on a remote server with verbose logging enabled.
 
    .EXAMPLE
        'SRV-DC01', 'SRV-DC02', 'SRV-WEB01' | Get-NTPPeer | Format-Table -AutoSize
 
        Queries NTP peers on multiple machines via pipeline and formats as a table.
 
    .NOTES
        Author: Franck SALLET (k9fr4n)
        Version: 1.0.0
        Last Modified: 2026-03-12
        Requires: PowerShell 5.1+ / Windows only
        Permissions: Standard user for local queries; remote queries require
                       WinRM access to the target machine
    #>

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

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

        $w32tmScriptBlock = {
            $w32tmPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\w32tm.exe'
            if (-not (Test-Path -Path $w32tmPath)) {
                throw "[ERROR] w32tm.exe not found at '$w32tmPath'"
            }
            $peerOutput = & $w32tmPath /query /peers 2>&1
            if ($LASTEXITCODE -ne 0) {
                $outputText = $peerOutput -join ' '
                throw "[ERROR] w32tm /query /peers failed with exit code ${LASTEXITCODE}: $outputText"
            }
            $peerOutput
        }
    }

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

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

                if ($isLocal) {
                    Write-Verbose "[$($MyInvocation.MyCommand)] Executing locally (no -ComputerName)"
                    $rawOutput = Invoke-Command -ScriptBlock $w32tmScriptBlock -ErrorAction Stop
                } else {
                    Write-Verbose "[$($MyInvocation.MyCommand)] Executing remotely on '$targetComputer'"
                    $rawOutput = Invoke-Command -ComputerName $targetComputer -ScriptBlock $w32tmScriptBlock -ErrorAction Stop
                }

                if (-not $rawOutput) {
                    Write-Warning "[$($MyInvocation.MyCommand)] No output from w32tm on '$targetComputer'"
                    continue
                }

                # --- Parse peer count from header line (#Peers: N / #Homologues : N) ---
                $peerCount = 0
                foreach ($headerLine in $rawOutput) {
                    $headerText = $headerLine.ToString().Trim()
                    if ($headerText -match '^\s*#\w+') {
                        if ($headerText -match ':\s*(\d+)') {
                            $peerCount = [int]$Matches[1]
                        }
                        break
                    }
                }

                Write-Verbose "[$($MyInvocation.MyCommand)] Reported $peerCount peer(s) on '$targetComputer'"

                if ($peerCount -eq 0) {
                    Write-Warning "[$($MyInvocation.MyCommand)] No NTP peers configured on '$targetComputer'"
                    continue
                }

                # --- Parse peer blocks using locale-agnostic value patterns ---
                $peerBlocks = [System.Collections.Generic.List[hashtable]]::new()
                $currentBlock = $null

                foreach ($outputLine in $rawOutput) {
                    $lineText = $outputLine.ToString().Trim()
                    if ([string]::IsNullOrWhiteSpace($lineText)) {
                        continue
                    }

                    # Peer line detection: label : hostname,0xFlags
                    if ($lineText -match '^[^:]+:\s*(.+),(0x[0-9a-fA-F]+)\s*$') {
                        if ($currentBlock) {
                            $peerBlocks.Add($currentBlock)
                        }
                        $currentBlock = @{
                            PeerName      = $Matches[1].Trim()
                            PeerFlags     = $Matches[2]
                            State         = $null
                            TimeRemaining = [double]0
                            LastSyncTime  = $null
                            PollInterval  = [int]0
                        }
                        continue
                    }

                    if (-not $currentBlock) {
                        continue
                    }

                    # Time remaining: value ends with digits/decimal + 's'
                    if ($lineText -match ':\s*([\d.]+)\s*s\s*$') {
                        $currentBlock['TimeRemaining'] = [double]$Matches[1]
                    }
                    # Poll interval: digits followed by space and '('
                    elseif ($lineText -match ':\s*(\d+)\s*\(') {
                        $currentBlock['PollInterval'] = [int]$Matches[1]
                    }
                    # Last sync time: date pattern (digits separated by / . or -)
                    elseif ($lineText -match ':\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}\s+.+)$') {
                        $syncTimeString = $Matches[1].Trim()
                        try {
                            $currentBlock['LastSyncTime'] = [datetime]::Parse($syncTimeString)
                        } catch {
                            Write-Verbose "[$($MyInvocation.MyCommand)] Could not parse sync time: '$syncTimeString'"
                            $currentBlock['LastSyncTime'] = $null
                        }
                    }
                    # State: first unmatched key:value line after the peer line
                    elseif ($null -eq $currentBlock['State'] -and $lineText -match ':\s*(.+)$') {
                        $currentBlock['State'] = $Matches[1].Trim()
                    }
                }

                # Add the last block
                if ($currentBlock) {
                    $peerBlocks.Add($currentBlock)
                }

                Write-Verbose "[$($MyInvocation.MyCommand)] Parsed $($peerBlocks.Count) peer block(s) on '$targetComputer'"

                # --- Emit one object per peer ---
                foreach ($block in $peerBlocks) {
                    [PSCustomObject]@{
                        PSTypeName    = 'PSWinOps.NTPPeer'
                        ComputerName  = $targetComputer
                        PeerName      = $block['PeerName']
                        PeerFlags     = $block['PeerFlags']
                        State         = $block['State']
                        TimeRemaining = $block['TimeRemaining']
                        LastSyncTime  = $block['LastSyncTime']
                        PollInterval  = $block['PollInterval']
                        Timestamp     = Get-Date -Format 'o'
                    }
                }
            } catch {
                Write-Error "[$($MyInvocation.MyCommand)] Failed to query NTP peers on '${targetComputer}': $_"
            }
        }
    }

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