the shed // ruby / devops

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.

Get the code

Full script, tests, and README on GitHub: ruby-devops-toolkit/dns-resolver-checker

Step through the build below:

dns_resolver_checker.rb
“DNS is fine” almost always means “DNS is fine from the one resolver I happened to check.” A record that just changed can be correct on your laptop’s resolver and stale on a customer’s ISP resolver for minutes to hours. An internal split-horizon resolver can silently diverge from the public record and nobody notices until someone off the VPN can’t reach a service. Checking a handful of independent resolvers side by side — instead of just one — turns that invisible class of bug into a one-line alert.

#!/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
Jobs, not nested loops. 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
01 / prerequisites

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.9 are the defaults)
  • No gems — resolv, socket, optparse, json, timeout, and Queue/Thread are all Ruby standard library
02 / usage

Running it

usagebash
# 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 answer
  • 2 — CRIT: a record failed to resolve on every resolver, or didn’t match --expect anywhere
03 / walkthrough

How it works

dns-resolver-checker architecture diagram: job queue, worker pool, evaluate_type, severity output

Bounded-concurrency job queue -> evaluate_type -> OK/WARN/CRIT

The whole script is four pieces, each doing exactly one job:

  • Bounded concurrency. run_checks computes types.product(resolvers), loads every pair into a Queue, and spins up min(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 a host[:port] resolver spec (defaulting to port 53), points a fresh Resolv::DNS.new(nameserver_port: [[host, port]]) at that one specific resolver, and calls getresources inside a Timeout.timeout block with a blanket rescue. Each DNS record type stores its payload under a different accessor on the resource object (.address for A/AAAA, .name for CNAME/NS, .exchange for MX, .strings for TXT) — resource_value normalizes 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, no Resolv calls. 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_NAME guard wraps CLI parsing and the live run, so the test suite can require_relative the file and call run_checks/evaluate_type directly without triggering ARGV parsing or exit.
04 / full source

dns_resolver_checker.rb

dns_resolver_checker.rbruby
#!/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
05 / troubleshooting

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#getresources swallows it internally and simply returns no records rather than raising. fetch_record‘s rescue clause still catches genuine failures like a real Timeout::Error against a black-holed address, but “port closed” specifically does not surface as status: '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 with dig @resolver domain type from a real client outside Ruby.
  • MX/TXT values look truncated or oddly formattedresource_value reports 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.join reassembles them, which is correct but can look surprising.
Tested how

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.

06 / extending

Where to take it next

  • More record typesRESOURCE_CLASSES and resource_value are 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 getresources call with Process.clock_gettime before/after and add response time to each result, to catch a resolver that’s technically correct but degraded.
  • Per-domain expected-value sets — extend --expect to 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 --json into a Slack/PagerDuty webhook whenever findings contains anything other than OK.