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.