Pages

Sunday, July 4, 2010

DNS Programming with Erlang

I have this strange fascination with DNS. By which I mean loathing. Yet somehow I've already written 3 small DNS servers in Erlang:
  • emdns: An unfinished multicast DNS server with unspecified yet no doubt awesome features. Someday I'll finish it. Maybe.
  • spood: A strange, little program; a spoofing DNS proxy that will send out your DNS requests from somebody else's IP address and sniff the responses. Maybe (if you're somewhat sketchy) you could use it to hide your DNS lookups. Maybe, you could use it to ramp up your DNS requests on networks that throttle them down. Not that I would do any of that.
  • seds: a DNS server that tunnels TCP/IP. I'm typing this blog over a DNS tunnel right now, stress testing it (with my blazing fast ASCII input) and trying to make seds crash. Also stress testing my patience.
Since the programmatic interfaces to DNS in Erlang are mostly undocumented, I thought I'd go over them briefly. So I'll remember how to use them if I ever finish emdns. I figured out how they worked mainly by reading the source and dumping DNS packets to see the record structures. The DNS parsing functions are kept in lib/kernel/src/inet_dns.erl. Pretty much the only functions that you will need from this module are encode/1 and decode/1. The tricky part is passing in the appropriate data structures.
  • decode/1 takes a binary and returns a #dns_rec{} record or {error, fmt} if the DNS payload cannot be decoded
  • encode/1 as you might expect, does the inverse, taking an appropriate record and returning a binary
The record structure is defined in lib/kernel/src/inet_dns.hrl.
-record(dns_rec,
    {
        header,       %% dns_header record
        qdlist = [],  %% list of question entries
        anlist = [],  %% list of answer entries
        nslist = [],  %% list of authority entries
        arlist = []   %% list of resource entries
    }).
  • The DNS header is another record:
    -record(dns_header,
        {
         id = 0,       %% ushort query identification number
         %% byte F0
         qr = 0,       %% :1   response flag
         opcode = 0,   %% :4   purpose of message
         aa = 0,       %% :1   authoritive answer
         tc = 0,       %% :1   truncated message
         rd = 0,       %% :1   recursion desired
         %% byte F1
         ra = 0,       %% :1   recursion available
         pr = 0,       %% :1   primary server required (non standard)
                       %% :2   unused bits
         rcode = 0     %% :4   response code
        }).
    
    While the defaults are initialized to small integers, inet_dns replaces them with atoms. So, the 1 bit values are either the atoms 'true' or 'false' and the opcode is set to an atom, for example, 'query'. Both integers and the atom representations are usually accepted by the functions though.

  • qdlist is a list of DNS query records:
    -record(dns_query,
        {
         domain,     %% query domain
         type,        %% query type
         class      %% query class
         }).
    
    • domain is a string representing the domain name, e.g., "foo.bar.example.com"
    • type is an atom describing the DNS type: a, cname, txt, null, srv, ns, ...
    • class will most commonly be 'in' (Internet), though multicast DNS uses "cache flush" (32769) for some operations

Making a valid Erlang DNS query would look something like:
-module(dns).
-compile(export_all).

-include_lib("kernel/src/inet_dns.hrl").

q(Domain, NS) ->
    Query = inet_dns:encode(
        #dns_rec{
            header = #dns_header{
                id = crypto:rand_uniform(1,16#FFFF),
                opcode = 'query',
                rd = true
            },
            qdlist = [#dns_query{
                domain = Domain,
                type = a,
                class = in
            }]
        }),
    {ok, Socket} = gen_udp:open(0, [binary, {active, false}]),
    gen_udp:send(Socket, NS, 53, Query),
    {ok, {NS, 53, Reply}} = gen_udp:recv(Socket, 65535),
    inet_dns:decode(Reply).
I enabled recursion because the request will be going through the one of the public Google nameservers (8.8.8.8) instead of going directly through the authoritative nameserver.

Testing the results:
$ erl
Erlang R14A (erts-5.8) [source] [smp:2:2] [rq:2] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.8  (abort with ^G)
1> {ok, Q} = dns:q("listincomprehension.com", {8,8,8,8}).
{ok,{dns_rec,{dns_header,7296,true,'query',false,false,
                         true,true,false,0},
             [{dns_query,"listincomprehension.com",a,in}],
             [{dns_rr,"listincomprehension.com",a,in,0,656,
                      {216,239,32,21},                      undefined,[],false},
              {dns_rr,"listincomprehension.com",a,in,0,656,
                      {216,239,34,21},
                      undefined,[],false},
              {dns_rr,"listincomprehension.com",a,in,0,656,
                      {216,239,36,21},
                      undefined,[],false},
              {dns_rr,"listincomprehension.com",a,in,0,656,
                      {216,239,38,21},
                      undefined,[],false}],
             [],[]}}
2> rr("/usr/local/lib/erlang/lib/kernel-2.14/src/inet_dns.hrl").
[dns_header,dns_query,dns_rec,dns_rr,dns_rr_opt]
3> Q.
#dns_rec{header = #dns_header{id = 7296,qr = true,
                              opcode = 'query',aa = false,tc = false,rd = true,ra = true,
                              pr = false,rcode = 0},
         qdlist = [#dns_query{domain = "listincomprehension.com",
                              type = a,class = in}],
         anlist = [#dns_rr{domain = "listincomprehension.com",
                           type = a,class = in,cnt = 0,ttl = 656,
                           data = {216,239,32,21},
                           tm = undefined,bm = [],func = false},
                   #dns_rr{domain = "listincomprehension.com",type = a,
                           class = in,cnt = 0,ttl = 656,
                           data = {216,239,34,21},
                           tm = undefined,bm = [],func = false},
                   #dns_rr{domain = "listincomprehension.com",type = a,
                           class = in,cnt = 0,ttl = 656,
                           data = {216,239,36,21},
                           tm = undefined,bm = [],func = false},
                   #dns_rr{domain = "listincomprehension.com",type = a,
                           class = in,cnt = 0,ttl = 656,
                           data = {216,239,38,21},
                           tm = undefined,bm = [],func = false}],
         nslist = [],arlist = []}
The records are displayed as tuples. You can pretty print the records by using the shell rr() command to include the header file wherever it is on your system.

The query returned the same packet we sent with some changes to the header:
  • The response flag (qr) is set to true
  • The recursion available flag (ra) is also set to true
The answer to our query is a list bound to the anlist record atom. The #dns_rr{} record looks like:
-record(dns_rr,
    {
     domain = "",   %% resource domain
     type = any,    %% resource type
     class = in,    %% reource class
     cnt = 0,       %% access count
     ttl = 0,       %% time to live
     data = [],     %% raw data
      %%  
     tm,            %% creation time
         bm = [],       %% Bitmap storing domain character case information.
         func = false   %% Optional function calculating the data field.
    }).
The data field is interesting. Although it's initialized as an empty list, the data structure bound to it depends on the DNS record type. For example, from the ones I remember:
  • A: tuple representing the IP address
  • TXT: a list of strings
  • NULL: a binary
  • CNAME: a domain name string appropriately "labelled" (canonicalized by the "."'s), e.g., "ghs.google.com". inet_dns takes care of breaking the domain name into the appropriate, compressed domain name -- a weird form where the "."'s are replaced by nulls and each component is prefaced by a length or a pointer redirecting to another field (hence the compression).

Pattern Matching

The cool thing is that, since the DNS records are nested records, its very easy to pattern match on the results. Modifying the example above:
-module(dns1).
-compile(export_all).

-include_lib("kernel/src/inet_dns.hrl").

q(Type, Domain, NS) ->
    Query = inet_dns:encode(
        #dns_rec{
            header = #dns_header{
                id = crypto:rand_uniform(1,16#FFFF),
                opcode = 'query',
                rd = true
            },
            qdlist = [#dns_query{
                    domain = Domain,
                    type = Type,
                    class = in
                }]
        }),
    {ok, Socket} = gen_udp:open(0, [binary, {active, true}]),
    gen_udp:send(Socket, NS, 53, Query),
    loop(Socket, Type, Domain, NS).


loop(Socket, Type, Domain, NS) ->
    receive
        {udp, Socket, NS, _, Packet} ->
            {ok, Response} = inet_dns:decode(Packet),
            match(Type, Domain, Response)
    end.

match(a, Domain, #dns_rec{
        header = #dns_header{
            qr = true,
            opcode = 'query'
        },
        qdlist = [#dns_query{
                domain = Domain,
                type = a,
                class = in
            }],
        anlist = [#dns_rr{
                domain = Domain,
                type = a,
                class = in,
                data = {IP1, IP2, IP3, IP4}
            }|_]}) ->
    {a, Domain, {IP1,IP2,IP3,IP4}};
match(cname, Domain, #dns_rec{
        header = #dns_header{
            qr = true,
            opcode = 'query'
        },
        qdlist = [#dns_query{
                domain = Domain,
                type = cname,
                class = in
            }],
        anlist = [#dns_rr{
                domain = Domain,
                type = cname,
                class = in,
                data = Data
            }|_]}) ->
    {cname, Domain, Data}.

And the results:

$ erl
Erlang R14A (erts-5.8) [source] [smp:2:2] [rq:2] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.8 (abort with ^G)
1> dns1:q(cname, "blog.listincomprehension.com", {8,8,8,8}).
{cname,"blog.listincomprehension.com","ghs.google.com"}

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.