the shed // ruby // windows // w32tm

Kerberos gives up when two clocks drift more than five minutes apart, and the classic cause is a Windows Time service quietly running off the local CMOS clock. This script grades w32tm output so drift is caught before authentication starts failing.

Step through the build below:

win_time_service_audit.rb

When a domain member stops syncing, nothing complains until logons, LDAP binds or file-share access start failing with cryptic Kerberos errors. w32tm /query /status tells you the truth, but only if you read it: is the source a real time server or Local CMOS Clock? When was the last successful sync? Is the NtpClient provider even enabled? Is MaxPosPhaseCorrection set so high that one bad source can step the clock by days? This tutorial wraps w32tm with Open3, parses its Key: value output, and grades it PASS/WARN/FAIL.

// win_time_service_audit.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# win_time_service_audit.rb - audit the Windows Time service (w32tm) for the
# misconfigurations that silently break Kerberos (5-minute skew limit).
# Runs `w32tm /query /status` and `/query /configuration` and grades them.
# Usage (Windows):  ruby win_time_service_audit.rb [--json] [--max-sync-hours 24]
#       (anywhere):  ruby win_time_service_audit.rb --fixture DIR   # status.txt + config.txt
# Exit: 0 PASS, 1 WARN, 2 FAIL.  Stdlib only.
require 'optparse'
require 'json'
require 'open3'
require 'time'

opts = { json: false, max_sync_hours: 24, fixture: nil, domain_joined: true }
OptionParser.new do |o|
  o.on('--json') { opts[:json] = true }
  o.on('--max-sync-hours N', Float) { |v| opts[:max_sync_hours] = v }
  o.on('--fixture DIR', 'read status.txt/config.txt instead of running w32tm') { |v| opts[:fixture] = v }
  o.on('--standalone', 'host is not domain joined (expects Type NTP)') { opts[:domain_joined] = false }
end.parse!

def w32tm(args, fixture, fname)
  return File.read(File.join(fixture, fname)) if fixture
  out, err, st = Open3.capture3('w32tm', *args)
  raise "w32tm #{args.join(' ')} failed: #{err.strip.empty? ? out.strip : err.strip}" unless st.success?
  out
end

# "Key: value" lines -> hash. Keys repeat across [sections] in /configuration,
# so we track the current section and namespace keys as "Section/Key".
def parse_kv(text)
  section = nil
  text.each_line.with_object({}) do |line, h|
    line = line.strip
    if line =~ /\A\[(.+)\]\z/ then section = Regexp.last_match(1); next end
    next unless line =~ /\A([^:]+):\s*(.*?)\s*(\(Local\)|\(Policy\))?\z/
    key = Regexp.last_match(1).strip
    key = "#{section}/#{key}" if section
    h[key] = Regexp.last_match(2)
  end
end

# Locale-dependent timestamp; return nil rather than guess if we can't parse it.
def parse_time(s)
  Time.strptime(s, '%m/%d/%Y %I:%M:%S %p')
rescue ArgumentError
  (Time.parse(s) rescue nil)
end

Finding = Struct.new(:sev, :check, :detail)

def evaluate(status, config, opts, now: Time.now)
  f = []
  src = status['Source'].to_s
  if src.empty? || src =~ /Local CMOS Clock|Free-running/i
    f << Finding.new('FAIL', 'source', "clock is free-running (Source: #{src.empty? ? 'none' : src}); nothing is disciplining it")
  else
    f << Finding.new('PASS', 'source', "syncing from #{src}")
  end

  last = parse_time(status['Last Successful Sync Time'].to_s)
  if last.nil?
    f << Finding.new('WARN', 'last-sync', "unparseable/absent: #{status['Last Successful Sync Time'].inspect}")
  else
    hrs = (now - last) / 3600.0
    sev = hrs > opts[:max_sync_hours] * 2 ? 'FAIL' : hrs > opts[:max_sync_hours] ? 'WARN' : 'PASS'
    f << Finding.new(sev, 'last-sync', format('%.1f h ago (limit %g h)', hrs, opts[:max_sync_hours]))
  end

  stratum = status['Stratum'].to_s[/\d+/].to_i
  f << Finding.new(stratum.zero? || stratum > 5 ? 'WARN' : 'PASS', 'stratum', "stratum #{stratum}")

  type = config['NtpClient/Type'].to_s.split.first
  want = opts[:domain_joined] ? 'NT5DS' : 'NTP'
  if type == want || (type == 'AllSync' && !opts[:domain_joined])
    f << Finding.new('PASS', 'client-type', "Type=#{type}")
  else
    f << Finding.new('WARN', 'client-type', "Type=#{type.inspect}, expected #{want} for this host role")
  end

  if config['NtpClient/Enabled'].to_s.split.first == '0'
    f << Finding.new('FAIL', 'ntpclient', 'NtpClient provider is disabled')
  end

  pos = config['Config/MaxPosPhaseCorrection'].to_i
  neg = config['Config/MaxNegPhaseCorrection'].to_i
  if pos > 172_800 || neg > 172_800 # 48 h: the accidental "accept anything" setting
    f << Finding.new('WARN', 'phase-correction', "MaxPos/NegPhaseCorrection #{pos}/#{neg}s lets a bad source step the clock by days")
  end
  f
end

begin
  status = parse_kv(w32tm(%w[/query /status], opts[:fixture], 'status.txt'))
  config = parse_kv(w32tm(%w[/query /configuration], opts[:fixture], 'config.txt'))
rescue StandardError => e
  warn "ERROR: #{e.message}"; exit 2
end

findings = evaluate(status, config, opts)
worst = findings.map(&:sev).include?('FAIL') ? 'FAIL' : findings.map(&:sev).include?('WARN') ? 'WARN' : 'PASS'
if opts[:json]
  puts JSON.pretty_generate(overall: worst, findings: findings.map(&:to_h))
else
  puts "win-time-service-audit: #{worst}"
  findings.each { |x| puts format('  [%-4s] %-16s %s', x.sev, x.check, x.detail) }
end
exit({ 'PASS' => 0, 'WARN' => 1, 'FAIL' => 2 }[worst])

1. Run w32tm through Open3. Open3.capture3 keeps stdout and stderr separate and returns the exit status, so a failure (service stopped) becomes a clear error and exit code 2 instead of a parse of garbage.

2. Parse Key: value output with sections. /query /configuration repeats keys such as Enabled and Type under several [Section] headers, so the parser namespaces each key as Section/Key and strips the trailing (Local)/(Policy) markers.

3. Grade five checks. Source (not free-running CMOS), last sync age against a threshold, stratum, NtpClient Type matching the host role (NT5DS for domain members, NTP for standalone), and the phase-correction limits.

4. Handle locale honestly. The last-sync timestamp is locale formatted. The parser tries the US format, then Time.parse, and reports WARN on failure rather than guessing.

5. Fixtures for off-Windows testing. --fixture DIR reads status.txt and config.txt instead of running w32tm. The four minitest cases stamp a fresh timestamp into the fixture so they stay deterministic.

// captured in the sandbox

win-time-service-audit: FAIL
  [FAIL] source           clock is free-running (Source: Local CMOS Clock); nothing is disciplining it
  [FAIL] last-sync        224.8 h ago (limit 24 h)
  [WARN] stratum          stratum 0
  [WARN] client-type      Type="NTP", expected NT5DS for this host role
  [FAIL] ntpclient        NtpClient provider is disabled
  [WARN] phase-correction MaxPos/NegPhaseCorrection 4294967295/172800s lets a bad source step the clock by days
exit=2
Finished in 0.414335s, 9.6540 runs/s, 26.5486 assertions/s.

4 runs, 11 assertions, 0 failures, 0 errors, 0 skips
Get the code

Full script + README on GitHub: ruby-devops-toolkit/win-time-service-audit

Data flow diagram for win_time_service_audit.rb

How the pieces fit together
the problem

Why this matters

When a domain member stops syncing, nothing complains until logons, LDAP binds or file-share access start failing with cryptic Kerberos errors. w32tm /query /status tells you the truth, but only if you read it: is the source a real time server or Local CMOS Clock? When was the last successful sync? Is the NtpClient provider even enabled? Is MaxPosPhaseCorrection set so high that one bad source can step the clock by days? This tutorial wraps w32tm with Open3, parses its Key: value output, and grades it PASS/WARN/FAIL.

Prerequisites – Windows Server / Windows 10+
  • Ruby 2.7+ (RubyInstaller on Windows; tested on 3.3.6)
  • Windows with w32tm.exe on PATH (standard); an elevated prompt is not required for /query
  • Stdlib only: optparse, json, open3, time
the script

Complete code

win_time_service_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# win_time_service_audit.rb - audit the Windows Time service (w32tm) for the
# misconfigurations that silently break Kerberos (5-minute skew limit).
# Runs `w32tm /query /status` and `/query /configuration` and grades them.
# Usage (Windows):  ruby win_time_service_audit.rb [--json] [--max-sync-hours 24]
#       (anywhere):  ruby win_time_service_audit.rb --fixture DIR   # status.txt + config.txt
# Exit: 0 PASS, 1 WARN, 2 FAIL.  Stdlib only.
require 'optparse'
require 'json'
require 'open3'
require 'time'

opts = { json: false, max_sync_hours: 24, fixture: nil, domain_joined: true }
OptionParser.new do |o|
  o.on('--json') { opts[:json] = true }
  o.on('--max-sync-hours N', Float) { |v| opts[:max_sync_hours] = v }
  o.on('--fixture DIR', 'read status.txt/config.txt instead of running w32tm') { |v| opts[:fixture] = v }
  o.on('--standalone', 'host is not domain joined (expects Type NTP)') { opts[:domain_joined] = false }
end.parse!

def w32tm(args, fixture, fname)
  return File.read(File.join(fixture, fname)) if fixture
  out, err, st = Open3.capture3('w32tm', *args)
  raise "w32tm #{args.join(' ')} failed: #{err.strip.empty? ? out.strip : err.strip}" unless st.success?
  out
end

# "Key: value" lines -> hash. Keys repeat across [sections] in /configuration,
# so we track the current section and namespace keys as "Section/Key".
def parse_kv(text)
  section = nil
  text.each_line.with_object({}) do |line, h|
    line = line.strip
    if line =~ /\A\[(.+)\]\z/ then section = Regexp.last_match(1); next end
    next unless line =~ /\A([^:]+):\s*(.*?)\s*(\(Local\)|\(Policy\))?\z/
    key = Regexp.last_match(1).strip
    key = "#{section}/#{key}" if section
    h[key] = Regexp.last_match(2)
  end
end

# Locale-dependent timestamp; return nil rather than guess if we can't parse it.
def parse_time(s)
  Time.strptime(s, '%m/%d/%Y %I:%M:%S %p')
rescue ArgumentError
  (Time.parse(s) rescue nil)
end

Finding = Struct.new(:sev, :check, :detail)

def evaluate(status, config, opts, now: Time.now)
  f = []
  src = status['Source'].to_s
  if src.empty? || src =~ /Local CMOS Clock|Free-running/i
    f << Finding.new('FAIL', 'source', "clock is free-running (Source: #{src.empty? ? 'none' : src}); nothing is disciplining it")
  else
    f << Finding.new('PASS', 'source', "syncing from #{src}")
  end

  last = parse_time(status['Last Successful Sync Time'].to_s)
  if last.nil?
    f << Finding.new('WARN', 'last-sync', "unparseable/absent: #{status['Last Successful Sync Time'].inspect}")
  else
    hrs = (now - last) / 3600.0
    sev = hrs > opts[:max_sync_hours] * 2 ? 'FAIL' : hrs > opts[:max_sync_hours] ? 'WARN' : 'PASS'
    f << Finding.new(sev, 'last-sync', format('%.1f h ago (limit %g h)', hrs, opts[:max_sync_hours]))
  end

  stratum = status['Stratum'].to_s[/\d+/].to_i
  f << Finding.new(stratum.zero? || stratum > 5 ? 'WARN' : 'PASS', 'stratum', "stratum #{stratum}")

  type = config['NtpClient/Type'].to_s.split.first
  want = opts[:domain_joined] ? 'NT5DS' : 'NTP'
  if type == want || (type == 'AllSync' && !opts[:domain_joined])
    f << Finding.new('PASS', 'client-type', "Type=#{type}")
  else
    f << Finding.new('WARN', 'client-type', "Type=#{type.inspect}, expected #{want} for this host role")
  end

  if config['NtpClient/Enabled'].to_s.split.first == '0'
    f << Finding.new('FAIL', 'ntpclient', 'NtpClient provider is disabled')
  end

  pos = config['Config/MaxPosPhaseCorrection'].to_i
  neg = config['Config/MaxNegPhaseCorrection'].to_i
  if pos > 172_800 || neg > 172_800 # 48 h: the accidental "accept anything" setting
    f << Finding.new('WARN', 'phase-correction', "MaxPos/NegPhaseCorrection #{pos}/#{neg}s lets a bad source step the clock by days")
  end
  f
end

begin
  status = parse_kv(w32tm(%w[/query /status], opts[:fixture], 'status.txt'))
  config = parse_kv(w32tm(%w[/query /configuration], opts[:fixture], 'config.txt'))
rescue StandardError => e
  warn "ERROR: #{e.message}"; exit 2
end

findings = evaluate(status, config, opts)
worst = findings.map(&:sev).include?('FAIL') ? 'FAIL' : findings.map(&:sev).include?('WARN') ? 'WARN' : 'PASS'
if opts[:json]
  puts JSON.pretty_generate(overall: worst, findings: findings.map(&:to_h))
else
  puts "win-time-service-audit: #{worst}"
  findings.each { |x| puts format('  [%-4s] %-16s %s', x.sev, x.check, x.detail) }
end
exit({ 'PASS' => 0, 'WARN' => 1, 'FAIL' => 2 }[worst])
walkthrough

How it works, step by step

1. Run w32tm through Open3

Open3.capture3 keeps stdout and stderr separate and returns the exit status, so a failure (service stopped) becomes a clear error and exit code 2 instead of a parse of garbage.

2. Parse Key: value output with sections

/query /configuration repeats keys such as Enabled and Type under several [Section] headers, so the parser namespaces each key as Section/Key and strips the trailing (Local)/(Policy) markers.

3. Grade five checks

Source (not free-running CMOS), last sync age against a threshold, stratum, NtpClient Type matching the host role (NT5DS for domain members, NTP for standalone), and the phase-correction limits.

4. Handle locale honestly

The last-sync timestamp is locale formatted. The parser tries the US format, then Time.parse, and reports WARN on failure rather than guessing.

5. Fixtures for off-Windows testing

--fixture DIR reads status.txt and config.txt instead of running w32tm. The four minitest cases stamp a fresh timestamp into the fixture so they stay deterministic.

example output

Run it

Windows-only command (w32tm.exe): logic verified with 4 minitest cases against fixture w32tm output on Linux; live w32tm was not executed in the sandbox.

win_time_service_audit.rb
win-time-service-audit: FAIL
[FAIL] source clock is free-running (Source: Local CMOS Clock); nothing is disciplining it
[FAIL] last-sync 224.8 h ago (limit 24 h)
[WARN] stratum stratum 0
[WARN] client-type Type="NTP", expected NT5DS for this host role
[FAIL] ntpclient NtpClient provider is disabled
[WARN] phase-correction MaxPos/NegPhaseCorrection 4294967295/172800s lets a bad source step the clock by days
exit=2
Finished in 0.414335s, 9.6540 runs/s, 26.5486 assertions/s.
4 runs, 11 assertions, 0 failures, 0 errors, 0 skips
troubleshooting

When it doesn't work

Troubleshooting
  • Honest limitation: w32tm.exe does not exist on Linux, so the live Open3 call was not run here. The parser, grading logic and exit codes were tested against realistic captured-format fixtures; the first run on a real Windows host is the true integration test.
  • Non-English Windows – w32tm labels are localized, so the key names may differ. Run w32tm /query /status once, and adjust the key names in evaluate.
  • Access denied / service not running – the script prints the w32tm error and exits 2; start the service with net start w32time.
next steps

Extending it

Ideas
  • Run against a fleet with w32tm /query /computer:HOST or WinRM
  • Add w32tm /monitor to compare offsets across domain controllers
  • Emit JSON to a SIEM (already supported with --json)
  • Check the Time-Service event log for source-change events

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating