diff --git a/Changes b/Changes index 8deaca0d..5cb90e45 100644 --- a/Changes +++ b/Changes @@ -5,6 +5,7 @@ next: - check_os_updates: use JSON for Windows update data and expose update metadata and driver attributes, tolerating unavailable properties - check_os_updates: exclude optional Windows updates by default, add --include-optional to include them - check_process/check_service: report recent CPU usage (average since the last check) instead of the lifetime average + - check_ntp_offset: new source w32time reads the windows time status directly from w32time.dll - check_drive_io: do not report duplicate entries for devices mounted multiple times - check_drive_io: pick up newly mounted or connected drives and forget removed ones after 5 minutes - check_cpu_utilization: emit zero metrics while the cpuinfo counter is cold so perfdata is not missing diff --git a/docs/checks/commands/check_ntp_offset.md b/docs/checks/commands/check_ntp_offset.md index ec62db29..00f40d39 100644 --- a/docs/checks/commands/check_ntp_offset.md +++ b/docs/checks/commands/check_ntp_offset.md @@ -32,8 +32,8 @@ Checks the NTP offset. | Argument | Description | | -------- | ---------------------------------------------------------------------------------------------------------- | -| server | Fetch the offset from these NTP servers. Uses the first valid response. | -| source | Choose the time data source instead of automatic detection. Valid values: auto, timedatectl, ntpq, chronyc, macos, w32tm. | +| server | Fetch offset from these NTP servers. First valid response is used. | +| source | Set source of time data instead of automatic detection. Valid values are: auto, timedatectl, ntpq, chronyc, macos, w32time, w32tm. | ## Attributes @@ -41,14 +41,22 @@ Checks the NTP offset. Use these attributes in filters and thresholds, along with the default attributes: -| Attribute | Description | -| -------------- | ---------------------------------------------------------------------------------------------------- | -| source | source of the NTP metrics | -| server | NTP server name | -| stratum | stratum value (distance to root NTP server) | -| jitter | jitter of the clock in milliseconds | -| offset | Time offset to the NTP server in milliseconds. SNClient adds this value as a metric. | -| offset_seconds | Time offset to the NTP server in seconds. SNClient does not add this value as a metric. SNClient converts thresholds from 'offset_seconds' to 'offset' without a message. | +| Attribute | Description | +| --------------------- | --------------------------------------------------------------------------------------------- | +| source | source of the NTP metrics | +| server | NTP server name | +| stratum | stratum value (distance to root NTP server) | +| jitter | jitter of the clock in milliseconds | +| offset | time offset to NTP server in milliseconds. This will be added as a metric. | +| offset_seconds | time offset to NTP server in seconds. This will not be added as a metric. Any thresholds using 'offset_seconds' will be converted to 'offset' silently. | +| leap_indicator | ntp leap indicator: 0 no warning, 1 last minute has 61 seconds, 2 last minute has 59 seconds, 3 not synchronized. Only set with source w32time. | +| state | state of the clock discipline: 0 unset, 1 hold, 2 sync, 3 spike. Only set with source w32time. | +| state_name | name of the clock discipline state, as shown by w32tm. Only set with source w32time. | +| last_sync_result | result of the last synchronization attempt: 0 success, 1 no data, 2 stale data, 3 change too big, 4 shutdown. Only set with source w32time. | +| last_sync_result_name | name of the last synchronization result. Only set with source w32time. | +| root_delay | total round-trip delay to the primary time source in milliseconds. Only set with source w32time. | +| root_dispersion | root dispersion, the maximum error relative to the primary time source in milliseconds. Only set with source w32time. | +| last_sync | Unix timestamp of the last successful synchronization in seconds with millisecond precision. Only set with source w32time. | ## Examples diff --git a/pkg/snclient/check_ntp_offset.go b/pkg/snclient/check_ntp_offset.go index c3ec2804..f84d66fb 100644 --- a/pkg/snclient/check_ntp_offset.go +++ b/pkg/snclient/check_ntp_offset.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "runtime" + "slices" "strings" "time" @@ -42,8 +43,8 @@ func (l *CheckNTPOffset) Build() *CheckData { State: CheckExitOK, }, args: map[string]CheckArgument{ - "server": {value: &l.ntpserver, description: "Fetch the offset from these NTP servers. Uses the first valid response."}, - "source": {value: &l.source, isFilter: true, description: "Choose the time data source instead of automatic detection. Valid values: auto, timedatectl, ntpq, chronyc, macos, w32tm."}, + "server": {value: &l.ntpserver, description: "Fetch offset from these NTP servers. First valid response is used."}, + "source": {value: &l.source, isFilter: true, description: "Set source of time data instead of automatic detection. Valid values are: auto, timedatectl, ntpq, chronyc, macos, w32time, w32tm."}, }, defaultFilter: "none", defaultWarning: "offset > 50 || offset < -50", @@ -57,9 +58,21 @@ func (l *CheckNTPOffset) Build() *CheckData { {name: "server", description: "NTP server name"}, {name: "stratum", description: "stratum value (distance to root NTP server)"}, {name: "jitter", description: "jitter of the clock in milliseconds"}, - {name: "offset", description: "Time offset to the NTP server in milliseconds. SNClient adds this value as a metric."}, - {name: "offset_seconds", description: "Time offset to the NTP server in seconds. SNClient does not add this value as a metric. " + - "SNClient converts thresholds from 'offset_seconds' to 'offset' without a message.", unit: UDuration}, + {name: "offset", description: "time offset to NTP server in milliseconds. This will be added as a metric."}, + {name: "offset_seconds", description: "time offset to NTP server in seconds. This will not be added as a metric. " + + " Any thresholds using 'offset_seconds' will be converted to 'offset' silently.", unit: UDuration}, + {name: "leap_indicator", description: "ntp leap indicator: 0 no warning, 1 last minute has 61 seconds, " + + "2 last minute has 59 seconds, 3 not synchronized. Only set with source w32time."}, + {name: "state", description: "state of the clock discipline: 0 unset, 1 hold, 2 sync, 3 spike. Only set with source w32time."}, + {name: "state_name", description: "name of the clock discipline state, as shown by w32tm. Only set with source w32time."}, + {name: "last_sync_result", description: "result of the last synchronization attempt: 0 success, 1 no data, " + + "2 stale data, 3 change too big, 4 shutdown. Only set with source w32time."}, + {name: "last_sync_result_name", description: "name of the last synchronization result. Only set with source w32time."}, + {name: "root_delay", description: "total round-trip delay to the primary time source in milliseconds. Only set with source w32time."}, + {name: "root_dispersion", description: "root dispersion, the maximum error relative to the primary time source in milliseconds. " + + "Only set with source w32time."}, + {name: "last_sync", description: "Unix timestamp of the last successful synchronization in seconds " + + "with millisecond precision. Only set with source w32time.", unit: UDate}, }, exampleDefault: ` check_ntp_offset @@ -115,6 +128,24 @@ func convertOffsetSecondOperandToOffset(condition *Condition) (err error) { return nil } +// ntpSource is a provider of time data, tried in the order of ntpSources +type ntpSource struct { + names []string + add func(context.Context, *CheckData, bool) error +} + +// names[0] is the canonical name and is used in the debug log +func (l *CheckNTPOffset) ntpSources() []ntpSource { + return []ntpSource{ + {names: []string{"timedatectl"}, add: l.addTimeDateCtl}, + {names: []string{"chronyc"}, add: l.addChronyc}, + {names: []string{"ntpq"}, add: l.addNTPQ}, + {names: []string{"w32time"}, add: l.addW32Time}, + {names: []string{"w32tm"}, add: l.addW32TM}, + {names: []string{"macos", "osx"}, add: l.addOSX}, + } +} + func (l *CheckNTPOffset) addSources(ctx context.Context, check *CheckData) (err error) { if len(l.ntpserver) > 0 { err = l.addNTPServer(ctx, check) @@ -127,63 +158,20 @@ func (l *CheckNTPOffset) addSources(ctx context.Context, check *CheckData) (err return nil } - if l.source == "auto" || l.source == "timedatectl" { - err = l.addTimeDateCtl(ctx, check, l.source == "timedatectl") - if err != nil { - log.Debugf("failed: timedatectl: %s", err.Error()) - if l.source != "auto" { - return err - } - } else { - return nil - } - } - - if l.source == "auto" || l.source == "chronyc" { - err = l.addChronyc(ctx, check, l.source == "chronyc") - if err != nil { - log.Debugf("failed: chronyc: %s", err.Error()) - if l.source != "auto" { - return err - } - } else { - return nil + for _, source := range l.ntpSources() { + if l.source != "auto" && !slices.Contains(source.names, l.source) { + continue } - } - if l.source == "auto" || l.source == "ntpq" { - err = l.addNTPQ(ctx, check, l.source == "ntpq") - if err != nil { - log.Debugf("failed: ntpq: %s", err.Error()) - if l.source != "auto" { - return err - } - } else { + err = source.add(ctx, check, l.source != "auto") + if err == nil { return nil } - } - if l.source == "auto" || l.source == "w32tm" { - err = l.addW32TM(ctx, check, l.source == "w32tm") - if err != nil { - log.Debugf("failed: w32tm: %s", err.Error()) - if l.source != "auto" { - return err - } - } else { - return nil - } - } + log.Debugf("failed: %s: %s", source.names[0], err.Error()) - if l.source == "auto" || l.source == "macos" || l.source == "osx" { - err = l.addOSX(ctx, check, l.source != "auto") - if err != nil { - log.Debugf("failed: macOS: %s", err.Error()) - if l.source != "auto" { - return err - } - } else { - return nil + if l.source != "auto" { + return err } } @@ -338,6 +326,130 @@ func (l *CheckNTPOffset) addNTPQ(ctx context.Context, check *CheckData, force bo return nil } +// w32timeState is the state of the clock discipline algorithm (MS-W32T 2.2.7). +type w32timeState uint32 + +const ( + w32timeStateUnset w32timeState = iota + w32timeStateHold + w32timeStateSync + w32timeStateSpike +) + +var w32timeStateNames = map[w32timeState]string{ + w32timeStateUnset: "Unset", + w32timeStateHold: "Hold", + w32timeStateSync: "Sync", + w32timeStateSpike: "Spike", +} + +// String returns the state name as printed by w32tm /query /status. +func (state w32timeState) String() string { + if name, ok := w32timeStateNames[state]; ok { + return name + } + + return fmt.Sprintf("Unknown(%d)", uint32(state)) +} + +// w32timeSyncResult is a TimeSync_ReturnResult code (MS-W32T 3.2.5.1). +type w32timeSyncResult uint32 + +const ( + w32timeSyncResultSuccess w32timeSyncResult = iota + w32timeSyncResultNoData + w32timeSyncResultStaleData + w32timeSyncResultChangeTooBig + w32timeSyncResultShutdown +) + +var w32timeSyncResultNames = map[w32timeSyncResult]string{ + w32timeSyncResultSuccess: "Success", + w32timeSyncResultNoData: "NoData", + w32timeSyncResultStaleData: "StaleData", + w32timeSyncResultChangeTooBig: "ChangeTooBig", + w32timeSyncResultShutdown: "Shutdown", +} + +// String returns a readable name for the result of the last synchronization. +func (result w32timeSyncResult) String() string { + if name, ok := w32timeSyncResultNames[result]; ok { + return name + } + + return fmt.Sprintf("Unknown(%d)", uint32(result)) +} + +// w32timeStatus is the status of the local Windows Time service. +// It is filled by queryW32TimeStatus, a port of the windows_exporter (Apache-2.0, Copyright The Prometheus Authors) +// See check_ntp_offset_windows.go for the origin. +type w32timeStatus struct { + LeapIndicator uint32 + Stratum uint32 + RootDelay time.Duration + RootDispersion time.Duration + Source string + // PhaseOffset is the time difference to the time source, the same value + // w32tm /query /status prints as Phase Offset. + PhaseOffset time.Duration + State w32timeState + LastSyncResult w32timeSyncResult + // LastSuccessfulSync is zero when the service has not synchronized yet. + LastSuccessfulSync time.Time +} + +// get offset from the w32time.dll of the windows time service, needs no admin rights. +// The status is read by queryW32TimeStatus, which is implemented in +// check_ntp_offset_windows.go. +func (l *CheckNTPOffset) addW32Time(_ context.Context, check *CheckData, force bool) error { + if !force && runtime.GOOS != "windows" { + return fmt.Errorf("w32time.dll is a windows library") + } + + // On 386 there is no w32time.dll in a 32 bit process and the structure layout + // differs, so automatic detection has to use w32tm. + if runtime.GOOS == "windows" && runtime.GOARCH == "386" { + return fmt.Errorf("w32time is not available on 386") + } + + status, err := queryW32TimeStatus() + if err != nil { + return fmt.Errorf("get windows time status: %w", err) + } + + entry := l.defaultEntry("w32time") + + fillW32TimeEntry(entry, &status) + + check.listData = append(check.listData, entry) + l.addMetrics(check, entry) + + return nil +} + +func fillW32TimeEntry(entry map[string]string, status *w32timeStatus) { + // the source can carry a flag suffix like "time.windows.com,0x8", w32tm shows the name only + entry["server"], _, _ = strings.Cut(status.Source, ",") + entry["stratum"] = fmt.Sprintf("%d", status.Stratum) + entry["offset"] = fmt.Sprintf("%f", float64(status.PhaseOffset.Nanoseconds())/1e6) + entry["offset_seconds"] = fmt.Sprintf("%f", convert.Float64(entry["offset"])/1e3) + entry["leap_indicator"] = fmt.Sprintf("%d", status.LeapIndicator) + entry["state"] = fmt.Sprintf("%d", uint32(status.State)) + entry["state_name"] = status.State.String() + entry["last_sync_result"] = fmt.Sprintf("%d", uint32(status.LastSyncResult)) + entry["last_sync_result_name"] = status.LastSyncResult.String() + entry["root_delay"] = fmt.Sprintf("%f", float64(status.RootDelay.Nanoseconds())/1e6) + entry["root_dispersion"] = fmt.Sprintf("%f", float64(status.RootDispersion.Nanoseconds())/1e6) + if !status.LastSuccessfulSync.IsZero() { + entry["last_sync"] = fmt.Sprintf("%.3f", float64(status.LastSuccessfulSync.UnixMilli())/1e3) + } + + if status.State != w32timeStateSync { + entry["_error"] = fmt.Sprintf("w32time: State Machine: %d (%s)", uint32(status.State), status.State) + entry["_exit"] = "2" + } +} + // get offset from windows w32tm.exe func (l *CheckNTPOffset) addW32TM(ctx context.Context, check *CheckData, force bool) error { if !force && runtime.GOOS != "windows" { diff --git a/pkg/snclient/check_ntp_offset_other.go b/pkg/snclient/check_ntp_offset_other.go new file mode 100644 index 00000000..0517c3a7 --- /dev/null +++ b/pkg/snclient/check_ntp_offset_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package snclient + +import ( + "errors" + "fmt" +) + +// queryW32TimeStatus reports that the Windows Time service only exists on windows. +func queryW32TimeStatus() (w32timeStatus, error) { + return w32timeStatus{}, fmt.Errorf("w32time.dll: %w", errors.ErrUnsupported) +} diff --git a/pkg/snclient/check_ntp_offset_test.go b/pkg/snclient/check_ntp_offset_test.go new file mode 100644 index 00000000..ba6f200d --- /dev/null +++ b/pkg/snclient/check_ntp_offset_test.go @@ -0,0 +1,63 @@ +package snclient + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCheckNTPOffsetW32TimeEntry(t *testing.T) { + l := &CheckNTPOffset{} + entry := l.defaultEntry("w32time") + + fillW32TimeEntry(entry, &w32timeStatus{ + LeapIndicator: 0, + Stratum: 4, + RootDelay: 38510100, + RootDispersion: 28135000, + Source: "time.windows.com,0x8", + PhaseOffset: 6151700, + State: w32timeStateSync, + LastSyncResult: w32timeSyncResultSuccess, + LastSuccessfulSync: time.Date(2023, 12, 20, 9, 30, 13, 659032000, time.UTC), + }) + + assert.Equal(t, map[string]string{ + "source": "w32time", + "server": "time.windows.com", + "stratum": "4", + "jitter": "", + "offset": "6.151700", + "offset_seconds": "0.006152", + "leap_indicator": "0", + "state": "2", + "state_name": "Sync", + "last_sync_result": "0", + "last_sync_result_name": "Success", + "root_delay": "38.510100", + "root_dispersion": "28.135000", + "last_sync": "1703064613.659", + }, entry) +} + +func TestCheckNTPOffsetW32TimeEntryHold(t *testing.T) { + l := &CheckNTPOffset{} + entry := l.defaultEntry("w32time") + + fillW32TimeEntry(entry, &w32timeStatus{ + Stratum: 4, + Source: "Free-running System Clock", + PhaseOffset: -7843300, + State: w32timeStateHold, + LastSyncResult: w32timeSyncResultStaleData, + LeapIndicator: 3, + }) + + assert.Equal(t, "w32time: State Machine: 1 (Hold)", entry["_error"]) + assert.Equal(t, "2", entry["_exit"]) + assert.Equal(t, "Free-running System Clock", entry["server"]) + assert.Equal(t, "-7.843300", entry["offset"]) + assert.Equal(t, "StaleData", entry["last_sync_result_name"]) + assert.Empty(t, entry["last_sync"]) +} diff --git a/pkg/snclient/check_ntp_offset_windows.go b/pkg/snclient/check_ntp_offset_windows.go new file mode 100644 index 00000000..2665bec2 --- /dev/null +++ b/pkg/snclient/check_ntp_offset_windows.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The windows part of the w32time source of check_ntp_offset is a port of the +// W32Time support of the windows_exporter, +// https://github.com/prometheus-community/windows_exporter, which was added in +// prometheus-community/windows_exporter#2813 ("time: add W32Time sync status +// metrics"): +// +// - internal/headers/w32time/w32time.go +// - the sync_status collector of internal/collector/time/time.go +// +// The W32TIME_STATUS_INFO layout, the field interpretation and the +// W32TimeQueryStatus/W32TimeBufferFree sequence are taken from there. The +// layout follows the W32Time Remote Protocol [MS-W32T] and was verified +// against w32tm /query /status on windows: +// +// [MS-W32T]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-w32t/0e425c15-8ae4-4c2a-b431-84a66b92986a +// +// It is not a plain copy. For snclient the lazyproc helper of the +// windows_exporter was replaced by a local sync.Once, the status types are +// shared with the platform independent check code, the poll interval and +// precision fields are skipped and the status is evaluated by the check +// instead of being exported as prometheus metrics. + +//go:build windows + +package snclient + +import ( + "errors" + "fmt" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + rpcServerUnavailable = 1722 // RPC_S_SERVER_UNAVAILABLE, W32Time is not running. + lastSyncTicksOffset = 24 // offset of qwLastSyncTicks, 8 byte aligned on 64 bit windows only. +) + +var ( + w32timeDLL = windows.NewLazySystemDLL("w32time.dll") + + procW32TimeQueryStatus = w32timeDLL.NewProc("W32TimeQueryStatus") + procW32TimeBufferFree = w32timeDLL.NewProc("W32TimeBufferFree") + + loadW32TimeOnce sync.Once + errW32TimeLoad error +) + +// loadW32Time checks that w32time.dll exports every function this file calls, +// a missing export would panic on the first call otherwise. +func loadW32Time() error { + loadW32TimeOnce.Do(func() { + for _, proc := range []*windows.LazyProc{procW32TimeQueryStatus, procW32TimeBufferFree} { + if err := proc.Find(); err != nil { + errW32TimeLoad = fmt.Errorf("w32time.dll: %w", err) + + return + } + } + }) + + return errW32TimeLoad +} + +// w32timeStatusInfo mirrors W32TIME_STATUS_INFO ([MS-W32T] 2.2.17) as the +// 64 bit C ABI lays it out in memory, which only windows/amd64 and windows/arm64 +// reproduce: on 386 Go aligns the 64 bit fields to 4 bytes instead of 8, so +// every field after qwLastSyncTicks moves 4 bytes up. parseW32TimeStatus checks +// that offset and rejects the structure instead of reading wrong values. +type w32timeStatusInfo struct { + ulSize uint32 + eLeapIndicator uint32 + nStratum uint32 + nPollInterval int32 // log2 of the poll interval in seconds. + refidSource uint32 + qwLastSyncTicks uint64 // FILETIME of the last successful synchronization. + toRootDelay int64 // 100 ns units. + tpRootDispersion uint64 // 100 ns units. + nClockPrecision int32 // log2 of the clock precision in seconds. + wszSource *uint16 + toSysPhaseOffset int64 // 100 ns units. + ulLcState uint32 + ulTSFlags uint32 + ulClockRate uint32 + ulNetlogonServiceBits uint32 + eLastSyncResult uint32 + tpTimeLastGoodSync uint64 // Time since the last good synchronization, 100 ns units. + cEntries uint32 + pEntries uintptr +} + +// queryW32TimeStatus returns the status of the local Windows Time service. It +// needs no administrative rights, but fails when the service is not running. +func queryW32TimeStatus() (w32timeStatus, error) { + if err := loadW32Time(); err != nil { + return w32timeStatus{}, err + } + + var info *w32timeStatusInfo + + result, _, _ := syscall.SyscallN(procW32TimeQueryStatus.Addr(), 0, uintptr(unsafe.Pointer(&info))) + if result != 0 { + if result == rpcServerUnavailable { + return w32timeStatus{}, fmt.Errorf("w32time.dll: the Windows Time service is not running: %w", windows.Errno(result)) + } + + return w32timeStatus{}, fmt.Errorf("W32TimeQueryStatus: %w", windows.Errno(result)) + } + + if info == nil { + return w32timeStatus{}, fmt.Errorf("W32TimeQueryStatus: %w", windows.ERROR_INVALID_DATA) + } + + // The strings are part of the same buffer, so only the outer buffer is freed. + //nolint:errcheck // the dll owns the buffer, a failure here cannot be handled + defer syscall.SyscallN(procW32TimeBufferFree.Addr(), uintptr(unsafe.Pointer(info))) + + return parseW32TimeStatus(info) +} + +//nolint:gosec // G115: the 100ns units and the FILETIME halves come from the service +func parseW32TimeStatus(info *w32timeStatusInfo) (w32timeStatus, error) { + // On 386 Go shifts the 64 bit fields, which the size check below cannot catch. + if unsafe.Offsetof(info.qwLastSyncTicks) != lastSyncTicksOffset { + return w32timeStatus{}, fmt.Errorf("W32TimeQueryStatus: unsupported W32TIME_STATUS_INFO layout on this architecture: %w", errors.ErrUnsupported) + } + + if uintptr(info.ulSize) < unsafe.Sizeof(*info) { + return w32timeStatus{}, fmt.Errorf("W32TimeQueryStatus: unexpected W32TIME_STATUS_INFO size %d, want %d", + info.ulSize, unsafe.Sizeof(*info)) + } + + status := w32timeStatus{ + LeapIndicator: info.eLeapIndicator, + Stratum: info.nStratum, + RootDelay: time.Duration(info.toRootDelay) * 100, + RootDispersion: time.Duration(info.tpRootDispersion) * 100, + PhaseOffset: time.Duration(info.toSysPhaseOffset) * 100, + State: w32timeState(info.ulLcState), + LastSyncResult: w32timeSyncResult(info.eLastSyncResult), + } + + if info.wszSource != nil { + status.Source = windows.UTF16PtrToString(info.wszSource) + } + + if info.qwLastSyncTicks != 0 { + lastSync := windows.Filetime{ + LowDateTime: uint32(info.qwLastSyncTicks), + HighDateTime: uint32(info.qwLastSyncTicks >> 32), + } + status.LastSuccessfulSync = time.Unix(0, lastSync.Nanoseconds()) + } + + return status, nil +} diff --git a/pkg/snclient/check_ntp_offset_windows_test.go b/pkg/snclient/check_ntp_offset_windows_test.go new file mode 100644 index 00000000..7415e62e --- /dev/null +++ b/pkg/snclient/check_ntp_offset_windows_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The status sample in this file was recorded by the windows_exporter project +// and is reused here to test the ported parser. + +//go:build windows + +package snclient + +import ( + "encoding/binary" + "encoding/hex" + "strings" + "testing" + "time" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// statusSample is the W32TIME_STATUS_INFO that W32TimeQueryStatus returned +// on a Windows Server 2025 Hyper-V guest, up to the source string. It was +// recorded by the windows_exporter project (Apache-2.0, Copyright The +// Prometheus Authors) and is reused to test the ported parser. +const statusSample = "" + + "78000000 00000000 01000000 06000000" + + "564d5450 00000000 f0b7a4eb a867dd01" + + "e5060000 00000000 a2860100 00000000" + + "e9ffffff 00000000 e8e5a530 70010000" + + "c0fdffff ffffffff 02000000 03000000" + + "59620200 40000000 00000000 00000000" + + "f6ccb901 00000000 00000000 00000000" + + "00000000 00000000" + +func TestParseW32TimeStatus(t *testing.T) { + raw, err := hex.DecodeString(strings.ReplaceAll(statusSample, " ", "")) + require.NoError(t, err) + require.Len(t, raw, int(unsafe.Sizeof(w32timeStatusInfo{}))) + + source, err := windows.UTF16PtrFromString("VM IC Time Synchronization Provider") + require.NoError(t, err) + + // The sample points into the native buffer, point it at a Go string instead. + binary.LittleEndian.PutUint64(raw[unsafe.Offsetof(w32timeStatusInfo{}.wszSource):], uint64(uintptr(unsafe.Pointer(source)))) + + info := new(w32timeStatusInfo) + copy(unsafe.Slice((*byte)(unsafe.Pointer(info)), len(raw)), raw) + + status, err := parseW32TimeStatus(info) + require.NoError(t, err) + require.True(t, status.LastSuccessfulSync.Equal(time.Date(2026, 10, 29, 13, 25, 9, 659032000, time.UTC)), + status.LastSuccessfulSync.String()) + + status.LastSuccessfulSync = time.Time{} + require.Equal(t, w32timeStatus{ + LeapIndicator: 0, + Stratum: 1, + RootDelay: 176500 * time.Nanosecond, + RootDispersion: 10000200 * time.Nanosecond, + Source: "VM IC Time Synchronization Provider", + PhaseOffset: -57600 * time.Nanosecond, + State: w32timeStateSync, + LastSyncResult: w32timeSyncResultSuccess, + }, status) +} + +func TestParseW32TimeStatusRejectsShortStructure(t *testing.T) { + _, err := parseW32TimeStatus(&w32timeStatusInfo{ulSize: 64}) + require.ErrorContains(t, err, "unexpected W32TIME_STATUS_INFO size 64") +} + +func TestCheckNTPOffsetW32Time(t *testing.T) { + snc := StartTestAgent(t, "") + defer StopTestAgent(t, snc) + + res := snc.RunCheck("check_ntp_offset", []string{"source=w32time"}) + output := string(res.BuildPluginOutput()) + + if strings.Contains(output, "the Windows Time service is not running") { + t.Skip("windows time service is not running") + } + + // a service in hold, spike or unset state reports no metrics + if strings.Contains(output, "State Machine:") { + t.Skip("windows time service is not synchronized") + } + + assert.Contains(t, output, "'offset'=") + assert.Contains(t, output, "'stratum'=") + assert.NotContains(t, output, "'jitter'=") +}