Easily test your DNS

At CustomInk, we recently made the switch to a new DNS provider. During the switch, we mass-imported records from our existing provider to our new provider. Did we get them all? Are they live yet? The immediate solution was to open up the terminal and fire off cURL requests, but there had to be a better way...

In this post/tutorial, I will show you how I created a test suite for our DNS records using RSpec. The solution is amazingly simple, and your specs will only be about 7 lines of code!

The End Result

I don't often do this, but to demonstrate just how awesome these tests are going to be, here is a final example spec:

require 'spec_helper'

describe 'www.customink.com' do
  expects ttl: 300, type: 'A', value: '1.2.3.4'
end

Getting Started

As with any Ruby-based project, there's some initial setup. Generate a new project and create a Gemfile with the following content:

source :rubygems

gem 'rspec', '~> 2.11.0'

Don't forget to run the bundle command to install the gems:

$ bundle

Next, create a folder named spec in your project directory and then create a spec/spec_helper.rb file with the following content:

require 'rspec'
require 'resolv'

require 'spec/macros/zones_macro'

RSpec.configure do |config|
  config.tty = true
  config.include ZonesMacro
end

There are a few things to cover here:

  1. We are requiring resolv, which is a Ruby 1.9 library for resolving DNS queries (it's like dig on steroids). Here is the official documentation for resolv.
  2. We require our custom macro - ZonesMacro.
  3. Lastly, we enable tty so that we get colored output on our CI server and include our ZoneMacro.

Writing ZoneMacro

The ZoneMacro is where the "hardest" of our logic will live. That being said, it's not very complicated.

Create the macros/zones_macro.rb file and start with the "boiler-plate" macro:

module ZonesMacro
  module ClassMethods
  end

  module InstanceMethods
  end

  def self.included(base)
    base.extend(ClassMethods)
    base.send :include, InstanceMethods
  end
end

In the context of RSpec (no pun intended), ClassMethods are things directly inside a describe block, and InstanceMethods are things inside an it declaration.

If you look back at our earlier example, you can see that we actually want ClassMethods for our expects method:

def expects(expected = {})
  matched_records = records.select do |record|
    record.type == expected[:type].upcase &&
    record.ttl == expected[:ttl].to_i &&
    record.value.include?(expected[:value].upcase)
  end

  it "should have the correct DNS entry" do
    matched_records.should_not be_empty
  end
end

So what is records? Well, we need to define it. Because records exists outside an it block, the declaration needs to go inside the ClassMethods module:

private
def records
  @records ||= begin
    Timeout::timeout(1) {
      Resolv::DNS.new.getresources(self.display_name, Resolv::DNS::Resource::IN::ANY)
    }
  rescue Timeout::Error => e
    $stderr.puts "Connection timed out for #{self.display_name}"
    []
  end
end

We add the Timeout::timeout so that resources that cannot be reached timeout in a reasonable amount of time.

Most of that is directly out of the Resolv Documentation, except for the self.class.display_name. This is provided by RSpec, and it's the value of the describe block.

In other words:

describe 'This is the self.display_name' do
  # ...
end

The Resolv block will make a DNS query to the display_name. Notice that we are returning an array of resources. You may also see a method called getresource (singular) for Resolve::DNS that returns only the first resource. If you have multiple records for the same key (like an A record and an MX record), your tests will not pass, because getresource just grabs the first record it sees...

We are also searching for a resource_type of ANY. This doesn't really matter because we are caching the result, and we are also already checking the type of the record returned.

If you run this right now, you'll get a lot of undefined method errors. That's because I also monkey-patched Resolv::DNS::Resource to return normalized data:

class Resolv::DNS::Resource
  def value
    %w(address data exchange name target).collect do |key|
      self.send(key.to_sym).to_s.upcase if self.respond_to?(key.to_sym)
    end.compact
  end

  def type
    self.class.name.split('::').last.upcase
  end
end

Dynamically Writing Tests

We have 500+ DNS records, so manually writing all of those tests was also a daunting task. Of course, I scripted it. You can get an export file of your current configuration from your DNS provider. The format may vary, but here's the simple little script I wrote to automatically generate all our tests.

# `config` is the existing DNS configuration file
config = File.readlines('/Users/svargo/Desktop/config').

# For each line, split on a space, remove the drunk, and
# create a hash for easy access.
collect do |line|
  split = line.split(' ').collect{ |l| l.strip.chomp('.') }
  { :url => split[0], :ttl => split[1], :type => split[3], :data => split[4] }
end

# For each record, write out the spec and expected results.
# I had to massage some data using `gsub` because of inconsistent formatting.
config.each do |record|
  str = <<-EOH
require 'spec_helper'

describe '#{record[:url]}' do
  expects ttl: #{record[:ttl]}, type: '#{record[:type]}', value: '#{record[:data].gsub('"', '')}'
end
EOH

  # Write the spec to the proper file
  File.open("spec/units/#{record[:url]}_spec.rb", 'w'){ |f| f.write(str) }
end

Needless to say, this saved a significant amount of time and energy.

Add to CI

I created a quick Jenkins job for these specs and set them to run every 15 minutes. Super simple and reliable CI for our DNS entires. Now, we can easily spot outdated records or problem servers.

A Question of Completeness?

Q: Is this a complete solution?
A: No


While this solution is expandable, it's far from complete. That being said, do you really require 100% coverage for all your DNS entires? It was very useful to have 100% coverage here, because we were migrating from our existing provider entirely. If you are writing these specs as "health checks", you will probably have significantly less...

This is definitely not a replacement for solutions like New Relic or other monitoring software, but it's an excellent homegrown solution in under 2 hours.

Disclaimer

This post was written using Ruby 1.9.3. It should work on Ruby 1.9.x, but I guarantee it won't work on 1.8.7.

by Seth Vargo