Created
February 12, 2025 12:30
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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