Your corporate telephone directory queryable via the DNS? Sure, I’ve shown you how to do it in Perl using Stanford::DNSserver, but I was recently asked wether a separate authoritative server really was required. An authoritative server isn’t required if you use Unbound.

Unbound is a recursive and DNSSEC-validating recursive server which can be built with support for Python modules. This enables me to add functionality to Unbound with a Python script:

  • Authoritative responses to queries can be generated on the fly. (This is the functionality we’ll be using here.)
  • Responses to queries obtained from the public DNS can be modified before being returned to the DNS client. This could be interesting, say, to increase or lower the TTL of a particular set of records, or to lie.
  • Packets can be logged.

The first item is particularly interesting because we can obtain data from the myriad of sources Python and its modules have to offer: do you want to build a customized DNS blacklist that uses a fast database, Redis, say, as a back-end? Want to query Twitter statuses over DNS, or provide a DNS gateway to a REST weather API? Do you have a parts database you want exposed over the DNS? Any and all of these and more are possible using Unbound, some Python and a bit of imagination.

I’m going to show you how to gateway a DNS query to an LDAP search result. This was a real requirement years ago, and at the time I wrote something that would allow me to query the DNS for a user’s unique identifier and get back her workstation’s IP address, telephone number, name, and e-mail address. The module I’m showing you here delivers a telephone number only, but you’ll quickly get the idea and be able to expand on it at your heart’s content.

My Telephone directory module is based on the response generation example. It provides DNS TXT records as responses to queries of the form surname.tele or first.last.tele in my arbitrarily chosen top-level domain .tele. In the former example, all LDAP entries for the surname are searched, in the latter case LDAP entries for common name are retrieved.

Here’s a sample LDAP entry in my directory:

dn: cn=Jane Jolie,o=mens.de
description: Actress
telephoneNumber: +49 555 6302547
mail: jane.jolie@example.org
givenName: Jane
objectClass: top
objectClass: person
objectClass: inetOrgPerson
sn: Jolie
displayName: Jane Jolie
cn: Jane Jolie

Let me query for Jane’s number; I know her first and surnames, so I use both:

dig jane.jolie.tele txt
...
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; ANSWER SECTION:
jane.jolie.tele.	3600	IN	TXT	"Jane Jolie: +49 555 6302547"

Note how the reply is authoritative (flags aa).

Specifying a surname only, I get a list of all jolie* in the LDAP directory. (Careful: a search for *.tele will also work!)

dig jolie.tele txt
...
;; ANSWER SECTION:
jolie.tele.		3600	IN	TXT	"Jane Jolie: +49 555 6302547"
jolie.tele.		3600	IN	TXT	"Brad Jolie: +1 555 2341983"

This data is provided by a recursive Unbound server running on my workstation or on a central DNS server your organization may have, and it logs the LDAP search filter used:

unbound[13092:0] info: tele: filter == (&(objectclass=person)(cn=jane jolie))
unbound[13092:0] info: tele: filter == (&(objectclass=person)(sn=jolie))

And here is the source code for the module; keep the structure documentation handy. Each python module must define four functions:

  • The init function is invoked once at Unbound startup, when our module is loaded. It is here we can connect to databases, initialize data, etc. cfg is a class which provides attributes on Unbound configured settings. For example, I can determine the name of the DLV trust anchor file so: print cfg.dlv_anchor_file
  • deinit is also invoked once, when our module is unloaded.
  • The documentation states, that inform_super is called when the “querystate is finished”. I’ve never seen this function invoked in my code.
  • The operate function is where the action happens; this function handles DNS queries received by the server. It accepts a new query or handles a pending query.
import sys
import ldap

LDAPURI = "ldap://127.0.0.1"
BASE    = "o=mens.de"
BINDDN  = ""
BINDPW  = ""
FILTER  = "(&(objectclass=person)(%s=%s))"
attrs = [ 'cn', 'telephonenumber' ]
global ld

def init(id, cfg):
    global ld

    ld = ldap.initialize(LDAPURI)
    ld.simple_bind_s(BINDDN, BINDPW)
    return True

def deinit(id):
    return True

def inform_super(id, qstate, superqstate, qdata):
    return True

def operate(id, event, qstate, qdata):

    # Handle a new DNS query or a query passed us by another
    # Unbound module
    if (event == MODULE_EVENT_NEW) or (event == MODULE_EVENT_PASS):
        qdn = qstate.qinfo.qname_str

        if qdn.endswith(".tele."):

            # Build a common name (CN) from the label parts: a query for
            # "a.b.tele" will result in a search for "CN=a b" 
            # whereas a query for "s.tele" will result in an LDAP search
            # for "SN=s"

            parts = qstate.qinfo.qname_list[0:-2]
            args = ' '.join(parts)
            if len(parts) == 1:
                filter = FILTER % ("sn", args)
            else:
                filter = FILTER % ("cn", args)
            log_info("tele: filter == %s" % filter)
            
            # Create a DNS packet for a TXT RR
            msg = DNSMessage(qdn, RR_TYPE_TXT, RR_CLASS_IN, PKT_QR | PKT_RA | PKT_AA)

            if (qstate.qinfo.qtype == RR_TYPE_TXT) or (qstate.qinfo.qtype == RR_TYPE_ANY):
                res = ld.search_s(BASE, ldap.SCOPE_SUBTREE, filter, attrs)

                if len(res) == 0:
                    qstate.return_rcode = RCODE_NXDOMAIN
                else:
                    for entry in res:
                        ttl = 3600
                        try:
                            tel = entry[1]['telephoneNumber'][0]
                        except KeyError:
                            tel = ''
                        cn = entry[1]['cn'][0]
                        msg.answer.append("%s %d IN TXT \"%s: %s\"" % (qdn, ttl, cn, tel))
                    qstate.return_rcode = RCODE_NOERROR
            # Set qstate.return_msg 
            if not msg.set_return_msg(qstate):
                qstate.ext_state[id] = MODULE_ERROR 
                return True

            # Indicate valid result
            qstate.return_msg.rep.security = 2
            qstate.ext_state[id] = MODULE_FINISHED 
            return True
        else:
            # Pass the query to validator
            qstate.ext_state[id] = MODULE_WAIT_MODULE 
            return True

    if event == MODULE_EVENT_MODDONE:
        log_info("tele: iterator module done")
        qstate.ext_state[id] = MODULE_FINISHED 
        return True
      
    log_err("tele: bad event")
    qstate.ext_state[id] = MODULE_ERROR
    return True

As far as Unbound’s configuration goes, adding a Python module script is easy. I configure python in the module-config and add the my script’s path to the python-script setting.

server:
   verbosity: 1
   chroot: ""
   username: "unbound"
   module-config: "validator python iterator"
   auto-trust-anchor-file: "/usr/local/etc/unbound/root.key"
   dlv-anchor-file: "/usr/local/etc/unbound/dlv.isc.org.key"
   trust-anchor-file: "/usr/local/etc/unbound/uno.aa"
   val-log-level: 2

python:
   python-script: "/usr/local/etc/unbound/u-ldap.py"

When Unbound processes a DNS query it calls the modules listed in modules-config from left to right. In our case the validator doesn’t have an answer (it only knows how to validate a reply) so the python module gets the query. It can either generate a response or pass the event on to the iterator, the last module in the list. When our python module is called with MODULE_EVENT_PASS it means the event has already been handled by the validator.

Unbound with the python module and the odd local-zone or local-data stanzas make for one very versatile recursive DNS server.

DNS, Python, and Unbound :: 09 Aug 2011 :: e-mail