Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions docs/checks/commands/check_ntp_offset.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,31 @@ 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

### Filter Keywords

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 |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some of these attributes are only present in w32time.dll backend.

it is way more verbose than some other tools like chrony. i included all of them here, what is not needed can be trimmed.

| --------------------- | --------------------------------------------------------------------------------------------- |
| 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

Expand Down
224 changes: 168 additions & 56 deletions pkg/snclient/check_ntp_offset.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"runtime"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactored this to a struct to avoid golangci warnings

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)
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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" {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like it says in the PR description, this method likely does not work on a 32 bit system.

Need to find its own dll and test it, the structs could be packed differently.

Do not want to commit it without testing, so return an error here.

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" {
Expand Down
13 changes: 13 additions & 0 deletions pkg/snclient/check_ntp_offset_other.go
Original file line number Diff line number Diff line change
@@ -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)
}
63 changes: 63 additions & 0 deletions pkg/snclient/check_ntp_offset_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
Loading
Loading