A Rose By Any Other Name

In this blog post I'm going to talk about writing a Perl script to automatically change entries in my local /etc/hosts file, and I'll digress into brief discussions on Net::DNS, how to edit files in place using Tie::File, and the sneaky -s switch for dumb command line argument parsing.

The Problem I'm Trying to Solve

I'll just come out and say it: I don't like using the default DNS servers assigned to my laptop by DHCP servers. In the case of my home network I get the buggy DNS server from my ISP that doesn't work as often as I'd like. In the case of my work network I often get hostnames resolved to internal IP addresses for servers where (because of my particular job) I really want the public ones. To avoid the issue completely I hard code my DNS to always point at Google's free DNS service on 8.8.8.8. There's just one problem with this:
bash$ ssh sandbox1.dev.example.priv
ssh: Could not resolve hostname sandbox1.dev.example.prvi: nodename nor servname provided, or not known
Ooops! Entries for my development servers only exists on our local work DNS server and if I'm not using it I can't find any of them! Luckily my Mac (and other unix like boxes) allows me to override DNS servers using the /etc/hosts file (Windows has something similar too.) In its simplest form this file contains one override per line, an IP address followed by one or more hostnames it overrides. For example: 10.0.0.1 sandbox1.dev.example.priv 10.0.0.2 sandbox2.dev.example.priv 10.0.1.1 db.dev.example.priv And so on. My kludgy soliton is for each development server that I want to use to put a line in /etc/hosts so I don't have to remember it's IP address (and more importantly, so I can use the addresses in my browser and still have it map to the right virtual host on the webserver.) However, doing this by hand gets old real quick. Running dig against the company DNS server's IP address, copying and pasting the resolved IP address into the hosts file using a text editor is something that takes the better part of a minute, is prone to mistakes, and completely interrupts my train of thought. What I want is a simple command to automate the whole process of adding or updating an entry like this:
bash$ hostify sandbox.dev.example.com
And maybe I could have it update all the entries that it knows about so they don't get out of date whenever I type:
bash$ hostify -r
Right! Time to write some Perl.

Using Net::DNS to do DNS lookups

You'd think that dealing with the complexities of DNS would be the hard bit, but looking up domain names with Perl is actually really trivial. We can almost copy the example out of the perldoc for Net::DNS:
my $res = Net::DNS::Resolver->new(
  nameservers => [qw(10.5.0.1)],
);

my $query = $res->search($hostname);

if ($query) {
  foreach my $rr ($query->answer) {
    next unless $rr->type eq "A";
    say "Found an A record: ".$rr->address;
  }
}
And that's about all there is to it. Now for the hard bit...

Using Tie::File to edit a file in place

We either need to add an entry to our existing /etc/hosts file or update one or more entries in the middle of the file. However, if we were to use the standard open function that Perl provides we're going to quickly run into a problem: The open (and sysopen) syntax is optomised for either appending data onto the end of the file, or in a pinch overwriting byte for byte in the middle of the file. What it won't do is automatically handle the case where we want to replace something in the middle of the file with more or fewer bytes. We end up having to manually read in and echo out the tail end of the file which results in us having to write a lot of complex "bookkeeping" code we'd rather not concern ourselves with. One of the easiest ways in Perl to edit a file in place without worry about these niggly details is to instead use a core module called Tie::File. This module uses a magic feature in Perl called tieing where some functionality is tied to a Perl data structure - any attempts to read from or modify the tied data structure cause Perl code to be executed to do something clever instead of modifying a dumb chunk of memory. In the case of Tie::File each element in the array that it ties maps to a line in the file on disk - reading from the array reads in lines from the file, and writing to the array writes out to disk. So, for example, to tie our array to the hosts file, we just need to use the special tie syntax:
tie my @file, 'Tie::File', "/etc/hosts"
  or die "Can't open /etc/hosts: $!";
Now altering a line in the middle of our file is simple:
# alter the 21st line in the file
$file[20] = "10.0.69.1 filestore.example.priv";
Tie::File seamlessly handles all the complicated bits about moving the stuff after the line we've just altered. Perfect!

Rudimentary argument passing with -s

My script needs to be able to only accept one simple command line option to tell it to also update all hostnames it's previously inserted. Because I'm lazy, I didn't even use a module to do this but rather used the simple -s command line option to tell perl to shove anything it sees on the command line starting with a dash into a similarly named variable in the main namespace:
#!/usr/bin/env perl -s
if ($r) { print "Someone called us with -r\n" }
Of course, with strictures and warnings on I have to write something a little more complex:
#!/usr/bin/env perl -s
use 5.12.0;
use warnings;
if ($::r && $::r) { say "Someone called us with -r" }
I need to use $::r not $r because the former, being a fully qualified variable, doesn't need predeclaration when running under use strict (which is implicitly turned on when I asked to use 5.12.0.) I also need to use $::r && $::r not $::r because otherwise warnings would notice that the variable was only being used once in the entire run of the code and emit a warning (this is one of the rare cases where this isn't a bug - the variable really does get its value without ever being set by Perl code.)

The Complete Script

And here's the complete finished script.
#!/usr/bin/env perl -s

use 5.12.0;
use warnings;

use Net::DNS;
use Tie::File;

# look at the file
tie my @file, 'Tie::File', "/etc/hosts"
  or die "Can't open /etc/hosts: $!";

# did someone want to update all the cached entires?
if ($::r && $::r) {
  my $found = 0;
  foreach (@file) {
    # skip down until the comment in my /etc/hosts that
    # states that "cached entries are below this point"
    next unless $found ||= m/cached entries/; 

    # then replace each host entry
    s{\A\d+\.\d+\.\d+\.\d+\s+(?<host>.*)\z}{
       dns_lookup($+{host}) . " $+{host}";
     }e;
  }
  exit unless @ARGV;
}

my $host = shift // die "No hostname supplied\n";
my $ip = dns_lookup( $host );

# look for an existing entry and replace it
foreach (@file) {
  exit if s/\A\d+\.\d+\.\d+\.\d+\s+\Q$host\E\z/$ip $host/;
}

# not found?  Add it to the end
push @file, "$ip $host";

########################################################################

sub dns_lookup {
  my $hostname = shift;

  my $res = Net::DNS::Resolver->new(
    nameservers => [qw(10.5.0.1)],
  );

  my $query = $res->search($hostname);
  
  if ($query) {
    foreach my $rr ($query->answer) {
      next unless $rr->type eq "A";
      return $rr->address;
    }
    die "No A record for $hostname";
  }

  die "query for $hostname failed: ", $res->errorstring;
}

- to blog -

blog built using the cayman-theme by Jason Long. LICENSE