A domain that resolves cleanly on your laptop can be broken for a customer three hops away. This tutorial builds a pure-Ruby checker that queries a fleet of DNS resolvers concurrently and flags the exact class of bug that hides between them: propagation drift, split-horizon disagreement, and records that silently stopped resolving.
Full script, tests, and README on GitHub: ruby-devops-toolkit/dns-resolver-checker
Step through the build below:
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# dns_resolver_checker.rb
#
# Queries one or more DNS record types for a domain against a *fleet* of
# resolvers concurrently, and flags disagreement between resolvers
# (propagation lag, split-brain internal/external DNS), records that
# fail to resolve, and records that don't match an expected value.
#
# No gems required: `resolv`, `socket`, `optparse`, `json`, `timeout`,
# and `thread` (Queue) are all in the Ruby standard library.
#
# Usage:
# ruby dns_resolver_checker.rb example.com
# ruby dns_resolver_checker.rb example.com --types A,MX,TXT --resolvers 8.8.8.8,1.1.1.1,9.9.9.9
# ruby dns_resolver_checker.rb example.com --types A --expect 93.184.216.34
# ruby dns_resolver_checker.rb example.com --json
#
# Exit codes (cron/monitoring friendly):
# 0 = every record type resolves consistently everywhere (and matches --expect, if given)
# 1 = WARN -- resolvers disagree, or some (not all) resolvers failed to answer
# 2 = CRIT -- a record failed to resolve on every resolver, or didn't match --expect anywhere
require 'resolv'
require 'optparse'
require 'json'
require 'timeout'
RESOURCE_CLASSES = {
'A' => Resolv::DNS::Resource::IN::A,
'AAAA' => Resolv::DNS::Resource::IN::AAAA,
'CNAME' => Resolv::DNS::Resource::IN::CNAME,
'MX' => Resolv::DNS::Resource::IN::MX,
'TXT' => Resolv::DNS::Resource::IN::TXT,
'NS' => Resolv::DNS::Resource::IN::NS
}.freeze
# Pulls the human-readable value out of whichever Resolv::DNS::Resource
# subclass a query returned -- each record type stores its payload under
# a different accessor (address/name/exchange/strings).
def resource_value(type, resource)
case type
when 'A', 'AAAA' then resource.address.to_s
when 'CNAME', 'NS' then resource.name.to_s
when 'MX' then "#{resource.preference} #{resource.exchange}"
when 'TXT' then resource.strings.join
else resource.to_s
end
end
# ---------------------------------------------------------------------------
# fetch_record: one resolver, one record type, one domain. Never raises --
# any failure comes back as {status: 'error', error: '...'} so a single
# flaky resolver can't kill the whole run.
# ---------------------------------------------------------------------------
def fetch_record(resolver, type, domain, timeout)
host, port_str = resolver.split(':', 2)
port = port_str ? port_str.to_i : 53
Timeout.timeout(timeout) do
dns = Resolv::DNS.new(nameserver_port: [[host, port]])
begin
resources = dns.getresources(domain, RESOURCE_CLASSES.fetch(type))
{ status: 'ok', values: resources.map { |r| resource_value(type, r) }.sort }
ensure
dns.close
end
end
rescue StandardError => e
{ status: 'error', error: "#{e.class}: #{e.message}" }
end
# ---------------------------------------------------------------------------
# Bounded concurrency across every (type, resolver) pair -- same
# queue-of-jobs / fixed-worker-pool shape as this toolkit's other
# network checkers, so no more sockets are open at once than --concurrency.
# ---------------------------------------------------------------------------
def run_checks(domain, types, resolvers, options)
jobs = types.product(resolvers)
queue = Queue.new
jobs.each { |j| queue << j }
results = Queue.new
workers = Array.new([options[:concurrency], jobs.size].min) do
Thread.new do
loop do
type, resolver = begin
queue.pop(true)
rescue ThreadError
break
end
results << [type, resolver, fetch_record(resolver, type, domain, options[:timeout])]
end
end
end
workers.each(&:join)
out = Hash.new { |h, k| h[k] = {} }
Array.new(results.size) { results.pop }.each { |type, resolver, res| out[type][resolver] = res }
out
end
# ---------------------------------------------------------------------------
# evaluate_type: pure function, no sockets involved -- takes the
# {resolver => {status:, values:/error:}} hash for ONE record type and
# classifies it. Kept separate from run_checks/fetch_record so the test
# suite can exercise every branch with hand-built hashes.
# ---------------------------------------------------------------------------
def evaluate_type(type, resolver_results, expect)
errored = resolver_results.select { |_, r| r[:status] == 'error' }
ok = resolver_results.reject { |k, _| errored.key?(k) }
if ok.empty?
return { severity: 'CRIT', reasons: ["#{type}: every resolver failed to answer"] }
end
distinct_sets = ok.values.map { |r| r[:values] }.uniq
reasons = []
severity = 'OK'
if expect
unless ok.values.any? { |r| r[:values].include?(expect) }
severity = 'CRIT'
reasons << "#{type}: expected value #{expect.inspect} not returned by any resolver"
end
end
if distinct_sets.size > 1
severity = 'WARN' if severity == 'OK'
summary = ok.map { |name, r| "#{name}=#{r[:values].empty? ? 'EMPTY' : r[:values].join('|')}" }.join(', ')
reasons << "#{type}: resolvers disagree (#{summary})"
end
unless errored.empty?
severity = 'WARN' if severity == 'OK'
reasons << "#{type}: #{errored.size}/#{resolver_results.size} resolver(s) failed to answer (#{errored.keys.join(', ')})"
end
reasons << "#{type}: consistent across #{ok.size} resolver(s)" if reasons.empty?
{ severity: severity, reasons: reasons }
end
# ---------------------------------------------------------------------------
# Run -- only when executed directly, not when required by the test suite
# (see firewall-audit/firewall_audit.rb in this repo for the same pattern).
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = {
types: ['A'],
resolvers: ['8.8.8.8', '1.1.1.1', '9.9.9.9'],
timeout: 3,
concurrency: 8,
json: false,
expect: nil
}
parser = OptionParser.new do |opts|
opts.banner = 'Usage: dns_resolver_checker.rb DOMAIN [options]'
opts.on('--types LIST', Array, 'Comma-separated record types to check (default: A)') { |v| options[:types] = v.map(&:upcase) }
opts.on('--resolvers LIST', Array, 'Comma-separated resolver host[:port] list') { |v| options[:resolvers] = v }
opts.on('--expect VALUE', 'Fail with CRIT if no resolver returns this value for the (single) record type checked') { |v| options[:expect] = v }
opts.on('--timeout SECONDS', Integer, 'Per-query timeout (default: 3)') { |v| options[:timeout] = v }
opts.on('--concurrency N', Integer, 'Max concurrent queries (default: 8)') { |v| options[:concurrency] = v }
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!
domain = ARGV.first
if domain.nil?
warn parser.banner
exit 2
end
raw = run_checks(domain, options[:types], options[:resolvers], options)
findings = raw.map { |type, resolver_results| [type, evaluate_type(type, resolver_results, options[:types].size == 1 ? options[:expect] : nil)] }
if options[:json]
puts JSON.pretty_generate(
domain: domain,
results: raw,
findings: findings.to_h
)
else
findings.each do |type, v|
puts "[#{v[:severity].ljust(4)}] #{domain} #{type}"
v[:reasons].each { |r| puts " #{r}" }
end
crit = findings.count { |_, v| v[:severity] == 'CRIT' }
warn_n = findings.count { |_, v| v[:severity] == 'WARN' }
puts "\n#{findings.size} record type(s) checked across #{options[:resolvers].size} resolver(s), #{crit} CRIT, #{warn_n} WARN"
end
exit_code =
if findings.any? { |_, v| v[:severity] == 'CRIT' }
2
elsif findings.any? { |_, v| v[:severity] == 'WARN' }
1
else
0
end
exit exit_code
end
run_checks builds the full cross product of types x resolvers up front and loads every pair into a Queue, then spins up a fixed pool of worker threads that each pop until the queue is empty — the same bounded-concurrency shape this toolkit uses for every network checker.
fetch_record never raises. Every failure — a bad resolver, a timeout — becomes {status: 'error', error: '...'} instead of an exception, so one flaky resolver can’t kill the run.
evaluate_type is a pure function. No sockets, no Resolv calls — just a hash in, a severity out. That’s what makes the whole risk engine testable without touching the network at all.
$ ruby dns_resolver_checker_test.rb
evaluate_type: all resolvers agree -> OK
ok - severity
evaluate_type: resolvers disagree -> WARN
ok - severity
evaluate_type: every resolver errors -> CRIT
ok - severity
evaluate_type: one of two resolvers errors -> WARN
ok - severity
evaluate_type: --expect satisfied -> OK
ok - severity
evaluate_type: --expect not returned anywhere -> CRIT
ok - severity
live loopback test: two mock resolvers agree -> OK
ok - severity
live loopback test: two mock resolvers disagree -> WARN (propagation drift)
ok - severity
live loopback test: record legitimately absent everywhere (no --expect) -> OK
ok - severity
live loopback test: real value present, but --expect set to something else -> CRIT
ok - severity
live loopback test: unreachable resolver -- known Resolv::DNS limitation
ok - unreachable resolver comes back as ok/empty, not status=error
ok - ...so without --expect this reads as OK
ok - ...but WITH --expect it correctly reads as CRIT
13 checks, 0 failures
$ ruby dns_resolver_checker.rb app.example. --types A,TXT --resolvers <3 loopback resolvers, one stale> --timeout 2
[WARN] app.example. A
A: resolvers disagree (resolver-1=203.0.113.10, resolver-2=203.0.113.10, resolver-3=203.0.113.9)
[OK ] app.example. TXT
TXT: consistent across 3 resolver(s)
2 record type(s) checked across 3 resolver(s), 0 CRIT, 1 WARN
$ echo "exit=$?"
exit=1
$ ruby dns_resolver_checker.rb app.example. --types A --resolvers <2 agreeing resolvers> --timeout 2 --expect 203.0.113.10
[OK ] app.example. A
A: consistent across 2 resolver(s)
1 record type(s) checked across 2 resolver(s), 0 CRIT, 0 WARN
$ echo "exit=$?"
exit=0
What you need
- Ruby >= 2.7 (tested on 3.0.2)
- Outbound UDP/53 access to whichever resolvers you point it at for live use (public resolvers like
8.8.8.8,1.1.1.1,9.9.9.9are the defaults) - No gems —
resolv,socket,optparse,json,timeout, andQueue/Threadare all Ruby standard library
Running it
# Default: check the A record against Google/Cloudflare/Quad9
ruby dns_resolver_checker.rb example.com
# Multiple record types, custom resolver list
ruby dns_resolver_checker.rb example.com --types A,MX,TXT --resolvers 8.8.8.8,1.1.1.1,9.9.9.9
# Assert a specific value -- CRIT if no resolver returns it
ruby dns_resolver_checker.rb example.com --types A --expect 93.184.216.34
# Machine-readable output for a monitoring pipeline
ruby dns_resolver_checker.rb example.com --json
0— every record type resolved consistently everywhere (and matched--expect, if given)1— WARN: resolvers disagree with each other, or some (not all) resolvers failed to answer2— CRIT: a record failed to resolve on every resolver, or didn’t match--expectanywhere
How it works
The whole script is four pieces, each doing exactly one job:
- Bounded concurrency.
run_checkscomputestypes.product(resolvers), loads every pair into aQueue, and spins upmin(concurrency, jobs.size)worker threads that each pop jobs until the queue drains. This caps how many UDP sockets are open at once, regardless of how many record types or resolvers you pass on the command line. fetch_record(resolver, type, domain, timeout)parses ahost[:port]resolver spec (defaulting to port 53), points a freshResolv::DNS.new(nameserver_port: [[host, port]])at that one specific resolver, and callsgetresourcesinside aTimeout.timeoutblock with a blanket rescue. Each DNS record type stores its payload under a different accessor on the resource object (.addressfor A/AAAA,.namefor CNAME/NS,.exchangefor MX,.stringsfor TXT) —resource_valuenormalizes all of them to a plain string.evaluate_type(type, resolver_results, expect)is the risk engine, and it’s a pure function: no sockets, noResolvcalls. It takes the{resolver => {status:, values:/error:}}hash for one record type and classifies it — every resolver erroring is CRIT, an--expected value missing from every resolver’s answer is CRIT, resolvers returning different answer sets is WARN (propagation drift), some (not all) resolvers erroring is WARN, otherwise OK.- The
__FILE__ == $PROGRAM_NAMEguard wraps CLI parsing and the live run, so the test suite canrequire_relativethe file and callrun_checks/evaluate_typedirectly without triggeringARGVparsing orexit.
dns_resolver_checker.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# dns_resolver_checker.rb
#
# Queries one or more DNS record types for a domain against a *fleet* of
# resolvers concurrently, and flags disagreement between resolvers
# (propagation lag, split-brain internal/external DNS), records that
# fail to resolve, and records that don't match an expected value.
#
# No gems required: `resolv`, `socket`, `optparse`, `json`, `timeout`,
# and `thread` (Queue) are all in the Ruby standard library.
#
# Usage:
# ruby dns_resolver_checker.rb example.com
# ruby dns_resolver_checker.rb example.com --types A,MX,TXT --resolvers 8.8.8.8,1.1.1.1,9.9.9.9
# ruby dns_resolver_checker.rb example.com --types A --expect 93.184.216.34
# ruby dns_resolver_checker.rb example.com --json
#
# Exit codes (cron/monitoring friendly):
# 0 = every record type resolves consistently everywhere (and matches --expect, if given)
# 1 = WARN -- resolvers disagree, or some (not all) resolvers failed to answer
# 2 = CRIT -- a record failed to resolve on every resolver, or didn't match --expect anywhere
require 'resolv'
require 'optparse'
require 'json'
require 'timeout'
RESOURCE_CLASSES = {
'A' => Resolv::DNS::Resource::IN::A,
'AAAA' => Resolv::DNS::Resource::IN::AAAA,
'CNAME' => Resolv::DNS::Resource::IN::CNAME,
'MX' => Resolv::DNS::Resource::IN::MX,
'TXT' => Resolv::DNS::Resource::IN::TXT,
'NS' => Resolv::DNS::Resource::IN::NS
}.freeze
# Pulls the human-readable value out of whichever Resolv::DNS::Resource
# subclass a query returned -- each record type stores its payload under
# a different accessor (address/name/exchange/strings).
def resource_value(type, resource)
case type
when 'A', 'AAAA' then resource.address.to_s
when 'CNAME', 'NS' then resource.name.to_s
when 'MX' then "#{resource.preference} #{resource.exchange}"
when 'TXT' then resource.strings.join
else resource.to_s
end
end
# ---------------------------------------------------------------------------
# fetch_record: one resolver, one record type, one domain. Never raises --
# any failure comes back as {status: 'error', error: '...'} so a single
# flaky resolver can't kill the whole run.
# ---------------------------------------------------------------------------
def fetch_record(resolver, type, domain, timeout)
host, port_str = resolver.split(':', 2)
port = port_str ? port_str.to_i : 53
Timeout.timeout(timeout) do
dns = Resolv::DNS.new(nameserver_port: [[host, port]])
begin
resources = dns.getresources(domain, RESOURCE_CLASSES.fetch(type))
{ status: 'ok', values: resources.map { |r| resource_value(type, r) }.sort }
ensure
dns.close
end
end
rescue StandardError => e
{ status: 'error', error: "#{e.class}: #{e.message}" }
end
# ---------------------------------------------------------------------------
# Bounded concurrency across every (type, resolver) pair -- same
# queue-of-jobs / fixed-worker-pool shape as this toolkit's other
# network checkers, so no more sockets are open at once than --concurrency.
# ---------------------------------------------------------------------------
def run_checks(domain, types, resolvers, options)
jobs = types.product(resolvers)
queue = Queue.new
jobs.each { |j| queue << j }
results = Queue.new
workers = Array.new([options[:concurrency], jobs.size].min) do
Thread.new do
loop do
type, resolver = begin
queue.pop(true)
rescue ThreadError
break
end
results << [type, resolver, fetch_record(resolver, type, domain, options[:timeout])]
end
end
end
workers.each(&:join)
out = Hash.new { |h, k| h[k] = {} }
Array.new(results.size) { results.pop }.each { |type, resolver, res| out[type][resolver] = res }
out
end
# ---------------------------------------------------------------------------
# evaluate_type: pure function, no sockets involved -- takes the
# {resolver => {status:, values:/error:}} hash for ONE record type and
# classifies it. Kept separate from run_checks/fetch_record so the test
# suite can exercise every branch with hand-built hashes.
# ---------------------------------------------------------------------------
def evaluate_type(type, resolver_results, expect)
errored = resolver_results.select { |_, r| r[:status] == 'error' }
ok = resolver_results.reject { |k, _| errored.key?(k) }
if ok.empty?
return { severity: 'CRIT', reasons: ["#{type}: every resolver failed to answer"] }
end
distinct_sets = ok.values.map { |r| r[:values] }.uniq
reasons = []
severity = 'OK'
if expect
unless ok.values.any? { |r| r[:values].include?(expect) }
severity = 'CRIT'
reasons << "#{type}: expected value #{expect.inspect} not returned by any resolver"
end
end
if distinct_sets.size > 1
severity = 'WARN' if severity == 'OK'
summary = ok.map { |name, r| "#{name}=#{r[:values].empty? ? 'EMPTY' : r[:values].join('|')}" }.join(', ')
reasons << "#{type}: resolvers disagree (#{summary})"
end
unless errored.empty?
severity = 'WARN' if severity == 'OK'
reasons << "#{type}: #{errored.size}/#{resolver_results.size} resolver(s) failed to answer (#{errored.keys.join(', ')})"
end
reasons << "#{type}: consistent across #{ok.size} resolver(s)" if reasons.empty?
{ severity: severity, reasons: reasons }
end
# ---------------------------------------------------------------------------
# Run -- only when executed directly, not when required by the test suite
# (see firewall-audit/firewall_audit.rb in this repo for the same pattern).
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = {
types: ['A'],
resolvers: ['8.8.8.8', '1.1.1.1', '9.9.9.9'],
timeout: 3,
concurrency: 8,
json: false,
expect: nil
}
parser = OptionParser.new do |opts|
opts.banner = 'Usage: dns_resolver_checker.rb DOMAIN [options]'
opts.on('--types LIST', Array, 'Comma-separated record types to check (default: A)') { |v| options[:types] = v.map(&:upcase) }
opts.on('--resolvers LIST', Array, 'Comma-separated resolver host[:port] list') { |v| options[:resolvers] = v }
opts.on('--expect VALUE', 'Fail with CRIT if no resolver returns this value for the (single) record type checked') { |v| options[:expect] = v }
opts.on('--timeout SECONDS', Integer, 'Per-query timeout (default: 3)') { |v| options[:timeout] = v }
opts.on('--concurrency N', Integer, 'Max concurrent queries (default: 8)') { |v| options[:concurrency] = v }
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!
domain = ARGV.first
if domain.nil?
warn parser.banner
exit 2
end
raw = run_checks(domain, options[:types], options[:resolvers], options)
findings = raw.map { |type, resolver_results| [type, evaluate_type(type, resolver_results, options[:types].size == 1 ? options[:expect] : nil)] }
if options[:json]
puts JSON.pretty_generate(
domain: domain,
results: raw,
findings: findings.to_h
)
else
findings.each do |type, v|
puts "[#{v[:severity].ljust(4)}] #{domain} #{type}"
v[:reasons].each { |r| puts " #{r}" }
end
crit = findings.count { |_, v| v[:severity] == 'CRIT' }
warn_n = findings.count { |_, v| v[:severity] == 'WARN' }
puts "\n#{findings.size} record type(s) checked across #{options[:resolvers].size} resolver(s), #{crit} CRIT, #{warn_n} WARN"
end
exit_code =
if findings.any? { |_, v| v[:severity] == 'CRIT' }
2
elsif findings.any? { |_, v| v[:severity] == 'WARN' }
1
else
0
end
exit exit_code
end
When it doesn't behave
- A resolver that’s completely unreachable comes back as “OK, empty” instead of an error. This is a real, verified quirk of Ruby’s
Resolv::DNS, not a bug in this script: when a configured nameserver refuses the connection,Resolv::DNS#getresourcesswallows it internally and simply returns no records rather than raising.fetch_record‘s rescue clause still catches genuine failures like a realTimeout::Erroragainst a black-holed address, but “port closed” specifically does not surface asstatus: 'error'. Pair checks against resolvers you’re not 100% sure are alive with--expect, since an unreachable resolver returning nothing will otherwise look identical to a resolver correctly reporting “no such record.” - Everything reports WARN and never settles — you’re probably checking a record type that’s mid-propagation (TTL just expired after a change), or a resolver that caches far longer than the others. Re-run after the old TTL has fully expired everywhere before treating it as a real incident.
Resolv::ResolvError/ garbled responses — usually means you’re pointed at something on that port that isn’t actually a DNS server. Verify withdig @resolver domain typefrom a real client outside Ruby.- MX/TXT values look truncated or oddly formatted —
resource_valuereports the raw preference+exchange pair for MX and the concatenated string segments for TXT; very long TXT records (SPF includes, DKIM keys) are split across multiple wire segments and.strings.joinreassembles them, which is correct but can look surprising.
Two layers, mirroring this toolkit’s pattern for network scripts (see ntp-drift‘s loopback mock SNTP server): evaluate_type was unit-tested directly with hand-built resolver-result hashes (6 checks, zero network). Then fetch_record and run_checks — the parts that actually open UDP sockets and speak DNS wire protocol — were tested end-to-end against real loopback mock DNS servers built with Resolv::DNS::Message (the same class Ruby’s own Resolv::DNS uses), covering agreement, disagreement/drift, an --expect mismatch, and the unreachable-resolver quirk documented above (7 more checks, 13/13 total passing). This sandbox has no route to the public internet, so the default resolvers specifically weren’t reachable during testing — the wire-protocol code is identical whether the peer is 127.0.0.1 or a public resolver.
Where to take it next
- More record types —
RESOURCE_CLASSESandresource_valueare the only two places that know about a specific record type; adding SRV or CAA support is a two-line addition to each. - Latency tracking — wrap the
getresourcescall withProcess.clock_gettimebefore/after and add response time to each result, to catch a resolver that’s technically correct but degraded. - Per-domain expected-value sets — extend
--expectto accept a small JSON/YAML file of{domain => {type => expected_values}}for auditing many domains against a baseline in one run. - Historical drift tracking — append each run’s JSON output to a log and diff consecutive runs to catch a record that changed between runs, not just one that’s inconsistent within a run.
- Alerting integration — pipe
--jsoninto a Slack/PagerDuty webhook wheneverfindingscontains anything other than OK.