modules/TimeSync/TimeSync.psm1

<#
    TimeSync.psm1 - Windows Time service configuration and hierarchy (read-only).

    Produces: TimeSync.

    Why this module exists: time is a hard dependency for Kerberos, for certificate
    validation, for database replication and for every log an incident is later
    reconstructed from. A server that is the authoritative time source for a domain
    cannot be retired without moving that role, and a server whose offset has drifted
    will start failing authentication in ways that look like everything except a clock.
    Neither fact was previously discoverable anywhere in the toolkit.

    Uses 'w32tm /query' with READ-ONLY verbs only (/status, /source, /configuration,
    /peers). Never runs /resync, /register, /unregister, or any /config write.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='TimeSync'; DisplayName='Time Synchronization'; Category='System'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('TimeSync')
        ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$true
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    if (Get-CommandAvailable -Name 'w32tm.exe') {
        return [pscustomobject]@{ ModuleName='TimeSync'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() }
    }
    [pscustomobject]@{ ModuleName='TimeSync'; CanRun=$false; Status='NotApplicable'; Reason='w32tm.exe not present.'; Limitations=@() }
}

function ConvertFrom-W32tmOffset {
    <#
        Parses the 'Phase Offset: 0.0012345s' line from w32tm /status into seconds.
        Returns $null when the value is absent or unparseable - an unknown offset is
        reported as unknown rather than silently becoming zero, because zero reads as
        'healthy' and would be a lie.
    #>

    param([string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    $m = [regex]::Match($Text, '(?im)^\s*Phase Offset:\s*(-?[\d\.]+)s')
    if (-not $m.Success) { return $null }
    $v = 0.0
    if ([double]::TryParse($m.Groups[1].Value, [ref]$v)) { return [math]::Round($v, 4) }
    return $null
}

function Get-W32tmField {
    param([string]$Text, [string]$Label)
    if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
    $m = [regex]::Match($Text, ('(?im)^\s*' + [regex]::Escape($Label) + ':\s*(.+)$'))
    if ($m.Success) { return $m.Groups[1].Value.Trim() }
    return ''
}

function Invoke-DiscoveryCollection {
    param([object]$Context)

    $statusText = ''
    $sourceText = ''

    try { $r = Invoke-CommandLineSafe -FilePath 'w32tm.exe' -Arguments @('/query','/status') -TimeoutSeconds 30; if ($r.Succeeded) { $statusText = $r.StdOut } } catch { }
    try { $r = Invoke-CommandLineSafe -FilePath 'w32tm.exe' -Arguments @('/query','/source') -TimeoutSeconds 30; if ($r.Succeeded) { $sourceText = ($r.StdOut).Trim() } } catch { }

    if (-not $statusText) {
        Add-Limitation -Context $Context -Module 'TimeSync' `
            -Message 'w32tm /query /status returned nothing (the Windows Time service may be stopped).' `
            -Impact 'Time sync state unknown' | Out-Null
    }

    $source  = if ($sourceText) { $sourceText } else { Get-W32tmField -Text $statusText -Label 'Source' }
    $stratum = Get-W32tmField -Text $statusText -Label 'Stratum'
    $lastSync = Get-W32tmField -Text $statusText -Label 'Last Successful Sync Time'
    $offset  = ConvertFrom-W32tmOffset -Text $statusText

    # NtpServer / Type come from policy or local config and are what an engineer has to
    # reproduce on the replacement server.
    $ntpServer = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name 'NtpServer'
    $syncType  = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name 'Type'
    $announce  = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Config' -Name 'AnnounceFlags'
    $ntpEnabled = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\TimeProviders\NtpServer' -Name 'Enabled'

    $svcState = ''
    $svcStart = ''
    try {
        $svc = Invoke-CimSafe -ClassName 'Win32_Service' -Filter "Name='W32Time'" | Select-Object -First 1
        if ($svc) { $svcState = [string]$svc.State; $svcStart = [string]$svc.StartMode }
    } catch { }

    # AnnounceFlags 5 (or any value with bit 2 set) means this host advertises itself as
    # a reliable time source - i.e. other machines follow its clock.
    $isAuthoritative = $false
    try { if ($null -ne $announce) { $isAuthoritative = [bool](([int]$announce -band 4) -ne 0) } } catch { }

    # A local-CMOS source on a domain member is the classic broken-time-hierarchy signature.
    $isDomainMember = $false
    try {
        if ($Context.DataSets.Contains('DomainContext')) {
            $isDomainMember = [bool](@($Context.DataSets['DomainContext'].Rows | Where-Object { $_.PartOfDomain -eq $true }).Count -gt 0)
        }
    } catch { }
    $usesLocalClock = [bool]($source -match '(?i)local cmos clock|free-?running')

    # Computed before the object literal: 'try' is a statement, not an expression, so it
    # cannot be inlined into a hashtable value in Windows PowerShell 5.1.
    $tzId = ''
    try { $tzId = [string](Get-TimeZone -ErrorAction SilentlyContinue).Id } catch { }
    if (-not $tzId) { try { $tzId = [string](Invoke-CimSafe -ClassName 'Win32_TimeZone' | Select-Object -First 1).StandardName } catch { } }

    $row = [pscustomobject]@{
        TimeSource              = [string]$source
        Stratum                 = [string]$stratum
        LastSuccessfulSync      = [string]$lastSync
        PhaseOffsetSeconds      = $offset
        OffsetUnknown           = [bool]($null -eq $offset)
        OffsetExceedsKerberosSkew = [bool](($null -ne $offset) -and ([math]::Abs($offset) -gt 300))
        ConfiguredNtpServer     = [string]$ntpServer
        SyncType                = [string]$syncType
        NtpServerProviderEnabled = [bool]($ntpEnabled -eq 1)
        AnnounceFlags           = [string]$announce
        IsAuthoritativeTimeSource = $isAuthoritative
        UsesLocalClockOnly      = $usesLocalClock
        BrokenDomainTimeHierarchy = [bool]($isDomainMember -and $usesLocalClock)
        ServiceState            = $svcState
        ServiceStartMode        = $svcStart
        LocalTime               = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
        TimeZone                = $tzId
        Note                    = 'Kerberos rejects tickets beyond a default five-minute skew; a drifted clock presents as authentication failure, not as a clock problem.'
    }

    if ($source -and -not $usesLocalClock) {
        Add-DependencyEdge -Context $Context -SourceType 'Server' -SourceName $Context.ComputerName `
            -DependencyType 'SynchronisesTimeWith' -Target ([string]$source) -Evidence 'w32tm /query /source' `
            -Confidence 'Confirmed' -SourceDataset 'TimeSync' -ProjectImpact 'Cutover Complexity' `
            -ValidationQuestion 'Does this time source survive the migration, and does the replacement server inherit the same hierarchy?' | Out-Null
    }

    if ($isAuthoritative) {
        Add-Unknown -Context $Context -Unknown 'This server advertises itself as a reliable time source; which machines follow it is not discoverable from here.' `
            -WhyItMatters 'Retiring an authoritative time source drifts every client that follows it, and the symptom is failed authentication.' `
            -Module 'TimeSync' -RecommendedValidationQuestion 'Which systems take their time from this server, and where will they take it from afterwards?' | Out-Null
    }

    return ,@{ TimeSync=@($row) }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    $rows = @(if ($RawData -and $RawData.TimeSync) { @($RawData.TimeSync) } else { @() })
    Add-DataSet -Context $Context -Name 'TimeSync' -Description 'Windows Time service source, hierarchy, and offset.' -Rows $rows -Visibility 'Internal' -SourceModule 'TimeSync' | Out-Null
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','ConvertFrom-W32tmOffset','Get-W32tmField'