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:
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
Full script + README on GitHub: ruby-devops-toolkit/win-time-service-audit
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.
- Ruby 2.7+ (RubyInstaller on Windows; tested on 3.3.6)
- Windows with
w32tm.exeon PATH (standard); an elevated prompt is not required for/query - Stdlib only: optparse, json, open3, time
Complete code
#!/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])
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.
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.
When it doesn't work
- 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 /statusonce, and adjust the key names inevaluate. - Access denied / service not running – the script prints the w32tm error and exits 2; start the service with
net start w32time.
Extending it
- Run against a fleet with
w32tm /query /computer:HOSTor WinRM - Add
w32tm /monitorto compare offsets across domain controllers - Emit JSON to a SIEM (already supported with
--json) - Check the
Time-Serviceevent log for source-change events