Skip to content

Instantly share code, notes, and snippets.

@alexanderadam
Created February 12, 2025 12:30
Show Gist options
  • Save alexanderadam/f4ef343d960975f098a426a5a0645f02 to your computer and use it in GitHub Desktop.
Save alexanderadam/f4ef343d960975f098a426a5a0645f02 to your computer and use it in GitHub Desktop.
Simple Ruby script to check DNS visibility with `dig` on various DNS servers
#!/usr/bin/env ruby
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'resolv'
gem 'logger'
gem 'colorize'
gem 'optparse'
end
require 'resolv'
require 'open3'
require 'logger'
require 'colorize'
require 'optparse'
require 'net/http'
class DnsDebugger
ICONS = {
info: "ℹ️ ",
error: "❌",
success: "✅",
dns: "🌐",
search: "🔍",
warning: "⚠️ ",
suggestion: "💡",
alert: "🚨"
}
PUBLIC_DNS = {
'Google Global Primary' => ['8.8.8.8', 'US'],
'Google Global Secondary' => ['8.8.4.4', 'US'],
'Cloudflare Global Primary' => ['1.1.1.1', 'US'],
'Cloudflare Global Secondary' => ['1.0.0.1', 'US'],
'Cloudflare Secure Primary' => ['1.1.1.2', 'US'],
'Cloudflare Secure Secondary' => ['1.0.0.2', 'US'],
'Cloudflare Family Primary' => ['1.1.1.3', 'US'],
'Cloudflare Family Secondary' => ['1.0.0.3', 'US'],
'Quad9 Secure Primary' => ['9.9.9.9', 'CH'],
'Quad9 Secure Secondary' => ['149.112.112.112', 'CH'],
'OpenDNS Global Primary' => ['208.67.222.222', 'US'],
'OpenDNS Global Secondary' => ['208.67.220.220', 'US'],
'AdGuard Global Primary' => ['94.140.14.140', 'CY'],
'AdGuard Global Secondary' => ['94.140.14.141', 'CY'],
'Level3 Primary' => ['4.2.2.1', 'US'],
'Level3 Secondary' => ['4.2.2.2', 'US']
}
RESOLVER_LIST_URL = 'https://raw.githubusercontent.com/trickest/resolvers/refs/heads/main/resolvers-extended.txt'
RESOLVER_TIMEOUT = 3 # seconds
MAX_RETRIES = 3
RESOLVERS_TO_CHECK = 10 # number of random resolvers to check
RESOLVER_BACKUP_FACTOR = 2 # backup resolvers if primary ones fail
# Unicode offset to convert ASCII letters to Regional Indicator Symbols
# Regional Indicator Symbols start at U+1F1E6 (127462) for 'A'
# ASCII 'A' is 65, so offset is 127462 - 65 = 127397
REGIONAL_INDICATOR_OFFSET = 127397
def initialize(domain)
@domain = domain
@logger = Logger.new($stdout)
@logger.formatter = proc do |severity, _, _, msg|
color = case severity
when 'INFO' then :green
when 'ERROR' then :red
when 'DEBUG' then :blue
when 'WARN' then :yellow
else :white
end
icon = case severity
when 'INFO' then ICONS[:info]
when 'ERROR' then ICONS[:error]
when 'DEBUG' then ICONS[:search]
when 'WARN' then ICONS[:warning]
else ''
end
"#{icon} #{msg}".colorize(color) + "\n"
end
@results = {
local_resolution: false,
public_dns: [],
nameservers: [],
dnssec: nil,
records: {}
}
@nameservers = []
@dynamic_resolvers = {}
@resolved_ips = Hash.new { |h, k| h[k] = [] }
fetch_public_resolvers
end
def run_diagnostics
@logger.info("Starting DNS diagnostics for #{@domain}")
fetch_nameservers
check_local_resolution
check_public_resolvers
check_nameservers
check_dnssec
check_zone_records
show_suggestions
rescue => e
@logger.error("Diagnostic failed: #{e.message}")
end
private
def fetch_nameservers
@logger.info("#{ICONS[:dns]} Fetching authoritative nameservers...")
cmd = "dig +short NS #{@domain}"
stdout, stderr, status = Open3.capture3(cmd)
if status.success? && !stdout.strip.empty?
@nameservers = stdout.strip.split("\n").map(&:strip)
@logger.info("#{ICONS[:success]} Found nameservers: #{@nameservers.join(', ').bold}")
else
@logger.warn("#{ICONS[:warning]} Could not fetch nameservers, falling back to DNS queries")
@nameservers = []
end
rescue => e
@logger.error("Failed to fetch nameservers: #{e.message}")
@nameservers = []
end
def check_local_resolution
@logger.info("#{ICONS[:dns]} Checking local DNS resolution for #{@domain.bold}...")
addresses = Resolv.getaddresses(@domain)
if addresses.empty?
@logger.warn("No addresses found")
else
@logger.info("#{ICONS[:success]} Found addresses: #{addresses.join(', ').bold}")
end
@results[:local_resolution] = !addresses.empty?
rescue => e
@logger.error("Local resolution failed: #{e.message}")
end
def fetch_public_resolvers
@logger.info("#{ICONS[:dns]} Fetching additional public DNS resolvers...")
uri = URI(RESOLVER_LIST_URL)
response = Net::HTTP.get_response(uri)
if response.is_a?(Net::HTTPSuccess)
networks = {}
response.body.each_line do |line|
ip, network, country, _ = line.strip.split(/\s+/)
networks[network] ||= []
networks[network] << [ip, country]
end
# Select more resolvers than needed to have backups
backup_count = RESOLVERS_TO_CHECK * RESOLVER_BACKUP_FACTOR
selected = networks.keys.sample(backup_count).map do |network|
resolver = networks[network].sample
[network, resolver]
end.to_h
@dynamic_resolvers = selected
@logger.info("#{ICONS[:success]} Found #{RESOLVERS_TO_CHECK} resolvers to check (with #{@dynamic_resolvers.size - RESOLVERS_TO_CHECK} backups)")
end
rescue => e
@logger.debug("Could not fetch additional resolvers: #{e.message}")
end
def country_to_flag(country_code)
return '🌐' if country_code.nil? || country_code.empty?
country_code.upcase.chars.map { |c| (c.ord + REGIONAL_INDICATOR_OFFSET).chr('UTF-8') }.join
end
def check_resolver(name, server, retry_count = 0)
return if retry_count >= MAX_RETRIES
server, country = server.is_a?(Array) ? server : [server, nil]
cmd = "dig @#{server} #{@domain} +short +time=#{RESOLVER_TIMEOUT}"
stdout, _stderr, status = Open3.capture3(cmd)
result = stdout.strip
flag = country_to_flag(country)
if status.success? && !result.empty?
@results[:public_dns] << name
@logger.info("\t#{flag} #{name.bold} (#{server}): #{result.colorize(:cyan)}")
@resolved_ips[result] << [name, country || 'Unknown']
elsif status.success?
@logger.warn("\t#{flag} #{name.bold} (#{server}): No record found")
else
@logger.warn("\t#{flag} #{name.bold} (#{server}): Timeout/Error, trying another resolver...")
if retry_count < MAX_RETRIES && @dynamic_resolvers[name].is_a?(Array)
networks = @dynamic_resolvers.select { |k, _| k != name }
if replacement = networks.to_a.sample
new_name, (new_ip, new_country) = replacement
check_resolver(new_name, [new_ip, new_country], retry_count + 1)
end
end
end
end
def check_public_resolvers
@logger.info("#{ICONS[:dns]} Querying public DNS resolvers...")
PUBLIC_DNS.each do |name, server_info|
check_resolver(name, server_info)
end
@dynamic_resolvers.each do |network, info|
ip, country = info
check_resolver(network, [ip, country])
end
show_resolution_summary
end
def show_resolution_summary
return if @resolved_ips.empty?
@logger.info("\n#{ICONS[:dns]} Resolution Summary:")
@resolved_ips.each do |ip, resolvers|
@logger.info("\nIP: #{ip.to_s.colorize(:cyan).bold}")
@logger.info("Seen by #{resolvers.size} resolvers:")
by_country = resolvers.group_by { |_, country| country }
by_country.each do |country, entries|
flag = country_to_flag(country)
names = entries.map { |name, _| name }
@logger.info(" #{flag} #{country}: #{names.join(', ')}")
end
end
end
def check_nameservers
return if @nameservers.empty?
@logger.info("#{ICONS[:dns]} Checking authoritative nameservers...")
@nameservers.each do |ns|
cmd = "dig @#{ns} #{@domain} +short"
stdout, _stderr, status = Open3.capture3(cmd)
result = stdout.strip
if status.success? && !result.empty?
@logger.info("#{ICONS[:success]} #{ns.bold}: #{result}")
@results[:nameservers] << ns
else
@logger.warn("#{ICONS[:warning]} #{ns.bold}: No response")
end
end
end
def check_dnssec
@logger.info("Checking DNSSEC...")
cmd = "dig +dnssec #{@domain}"
stdout, stderr, status = Open3.capture3(cmd)
if status.success?
@logger.info("DNSSEC response received")
@logger.debug(stdout)
else
@logger.error("DNSSEC check failed: #{stderr}")
end
end
def check_zone_records
@logger.info("#{ICONS[:search]} Checking DNS record types...")
%w[A AAAA MX TXT NS SOA].each do |record_type|
cmd = "dig #{@domain} #{record_type} +short"
stdout, stderr, status = Open3.capture3(cmd)
result = stdout.strip
@results[:records][record_type] = !result.empty?
if status.success? && !result.empty?
@logger.info("#{ICONS[:success]} #{record_type.bold} records: #{result}")
else
@logger.warn("#{record_type.bold} records: None found")
end
end
end
def show_suggestions
@logger.info("\n#{ICONS[:suggestion]} Diagnostic Summary & Suggestions:")
if @results[:records]['A'].nil? && @results[:records]['AAAA'].nil?
@logger.warn("#{ICONS[:alert]} No DNS A or AAAA records found. This means your domain isn't pointing to any server.")
@logger.info("#{ICONS[:suggestion]} Suggestions:")
@logger.info(" • Configure A record(s) with your domain registrar")
@logger.info(" • Common A record values: your server's IP address")
@logger.info(" • Consider adding AAAA records for IPv6 support")
end
if @results[:records]['MX'].nil?
@logger.warn("#{ICONS[:alert]} No MX records found. This means email services won't work.")
@logger.info("#{ICONS[:suggestion]} Suggestions:")
@logger.info(" • Add MX records if you need email services")
@logger.info(" • Common providers: Google Workspace, Microsoft 365, or your own mail server")
end
if @results[:public_dns].empty?
@logger.warn("#{ICONS[:alert]} Domain not resolving on public DNS servers.")
@logger.info("#{ICONS[:suggestion]} Suggestions:")
@logger.info(" • Verify DNS propagation (can take up to 48 hours)")
@logger.info(" • Check your domain registrar's nameserver settings")
@logger.info(" • Confirm your DNS provider's configuration")
end
if @results[:records]['NS'].nil?
@logger.warn("#{ICONS[:alert]} No NS records found.")
@logger.info("#{ICONS[:suggestion]} Suggestions:")
@logger.info(" • Configure nameservers with your domain registrar")
@logger.info(" • Common values: ns1.exoscale.com, ns1.exoscale.ch, etc.")
end
if @results[:records]['TXT'].nil?
@logger.info("#{ICONS[:suggestion]} Consider adding TXT records for:")
@logger.info(" • SPF records for email security")
@logger.info(" • DMARC policy for email authentication")
@logger.info(" • Domain ownership verification for various services")
end
if @nameservers.empty?
@logger.warn("#{ICONS[:alert]} Could not determine authoritative nameservers.")
@logger.info("#{ICONS[:suggestion]} Suggestions:")
@logger.info(" • Verify nameserver configuration with your domain registrar")
@logger.info(" • Ensure NS records are properly configured")
end
@logger.info("\n#{ICONS[:info]} Need more help? Common next steps:")
@logger.info(" • Use a DNS propagation checker: https://dnschecker.org")
@logger.info(" • Contact your domain registrar's support")
@logger.info(" • Verify your DNS provider's configuration")
end
end
if __FILE__ == $0
domain = nil
parser = OptionParser.new do |opts|
opts.banner = "Usage: #{$0} -d DOMAIN"
opts.on('-d', '--domain DOMAIN', 'Domain to check') { |d| domain = d }
end
begin
parser.parse!
if domain.nil?
puts parser
exit 1
end
debugger = DnsDebugger.new(domain)
debugger.run_diagnostics
rescue OptionParser::InvalidOption => e
puts e
puts parser
exit 1
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment