]> git.ipfire.org Git - thirdparty/unbound.git/commitdiff
- Fix #17: Add python module example from Jan Janak, that is a
authorWouter Wijngaards <wouter@nlnetlabs.nl>
Mon, 8 Apr 2019 14:34:58 +0000 (14:34 +0000)
committerWouter Wijngaards <wouter@nlnetlabs.nl>
Mon, 8 Apr 2019 14:34:58 +0000 (14:34 +0000)
  plugin for the Unbound DNS resolver to resolve DNS records in
  multicast DNS [RFC 6762] via Avahi.

git-svn-id: file:///svn/unbound/trunk@5151 be551aaa-1e26-0410-a405-d3ace91eadb9

doc/Changelog
pythonmod/examples/avahi-resolver.py [new file with mode: 0644]

index bd4393451006f169babb852faa6e39f905908254..bbb87dbc87f1b0e575462de4f3b26523b1bf2835 100644 (file)
@@ -2,6 +2,9 @@
        - Fix to use event_assign with libevent for thread-safety.
        - verbose information about auth zone lookup process, also lookup
          start, timeout and fail.
+       - Fix #17: Add python module example from Jan Janak, that is a
+         plugin for the Unbound DNS resolver to resolve DNS records in
+         multicast DNS [RFC 6762] via Avahi.
 
 5 April 2019: Wouter
        - Fix to reinit event structure for accepted TCP (and TLS) sockets.
diff --git a/pythonmod/examples/avahi-resolver.py b/pythonmod/examples/avahi-resolver.py
new file mode 100644 (file)
index 0000000..b1d4e36
--- /dev/null
@@ -0,0 +1,567 @@
+#!/usr/bin/env python3
+#
+# A plugin for the Unbound DNS resolver to resolve DNS records in
+# multicast DNS [RFC 6762] via Avahi.
+#
+# Copyright (C) 2018-2019 Internet Real-Time Lab, Columbia University
+# http://www.cs.columbia.edu/irt/
+#
+# Written by Jan Janak <janakj@cs.columbia.edu>
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+#
+# Dependendies:
+#   Unbound with pythonmodule configured for Python 3
+#   dnspython [http://www.dnspython.org]
+#   pydbus [https://github.com/LEW21/pydbus]
+#
+# To enable Python 3 support, configure Unbound as follows:
+#   PYTHON_VERSION=3 ./configure --with-pythonmodule
+#
+# The plugin in meant to be used as a fallback resolver that resolves
+# records in multicast DNS if the upstream server cannot be reached or
+# provides no answer (NXDOMAIN).
+#
+# mDNS requests for negative records, i.e., records for which Avahi
+# returns no answer (NXDOMAIN), are expensive. Since there is no
+# single authoritative server in mDNS, such requests terminate only
+# via a timeout. The timeout is about a second (if MDNS_TIMEOUT is not
+# configured), or the value configured via MDNS_TIMEOUT. The
+# corresponding Unbound thread will be blocked for this amount of
+# time. For this reason, it is important to configure an appropriate
+# number of threads in unbound.conf and limit the RR types and names
+# that will be resolved via Avahi via the environment variables
+# described later.
+#
+# An example unbound.conf with the plugin enabled:
+#
+# | server:
+# |   module-config: "validator python iterator"
+# |   num-threads: 32
+# |   cache-max-negative-ttl: 60
+# |   cache-max-ttl: 60
+#
+#
+# The plugin can also be run interactively. Provide the name and
+# record type to be resolved as command line arguments and the
+# resolved record will be printed to standard output:
+#
+#   $ ./avahi-resolver.py voip-phx4.phxnet.org A
+#   voip-phx4.phxnet.org. 120 IN A 10.4.3.2
+#
+#
+# The behavior of the plugin can be controlled via the following
+# environment variables:
+#
+# DBUS_SYSTEM_BUS_ADDRESS
+#
+# The address of the system DBus bus, in the format expected by DBus,
+# e.g., unix:path=/run/avahi/system-bus.sock
+#
+#
+# DEBUG
+#
+# Set this environment variable to "yes", "true", "on", or "1" to
+# enable debugging. In debugging mode, the plugin will output a lot
+# more information about what it is doing either to the standard
+# output (when run interactively) or to Unbound via log_info and
+# log_error.
+#
+# By default debugging is disabled.
+#
+#
+# MDNS_TTL
+#
+# Avahi does not provide the TTL value for the records it returns.
+# This environment variable can be used to configure the TTL value for
+# such records.
+#
+# The default value is 120 seconds.
+#
+#
+# MDNS_TIMEOUT
+#
+# The maximum amount of time (in milliseconds) an Avahi request is
+# allowed to run. This value sets the time it takes to resolve
+# negative (non-existent) records in Avahi. If unset, the request
+# terminates when Avahi sends the "AllForNow" signal, telling the
+# client that more records are unlikely to arrive. This takes roughly
+# about one second. You may need to configure a longer value here on
+# slower networks, e.g., networks that relay mDNS packets such as
+# MANETs.
+#
+#
+# MDNS_GETONE
+#
+# If set to "true", "1", or "on", an Avahi request will terminate as
+# soon as at least one record has been found. If there are multiple
+# nodes in the mDNS network publishing the same record, only one (or
+# subset) will be returned.
+#
+# If set to "false", "0", or "off", the plugin will gather records for
+# MDNS_TIMEOUT and return all records found. This is only useful in
+# networks where multiple nodes are known to publish different records
+# under the same name and the client needs to be able to obtain them
+# all. When configured this way, all Avahi requests will always take
+# MDNS_TIMEOUT to complete!
+#
+# This option is set to true by default.
+#
+#
+# MDNS_REJECT_TYPES
+#
+# A comma-separated list of record types that will NOT be resolved in
+# mDNS via Avahi. Use this environment variable to prevent specific
+# record types from being resolved via Avahi. For example, if your
+# network does not support IPv6, you can put AAAA on this list.
+#
+# The default value is an empty list.
+#
+# Example: MDNS_REJECT_TYPES=aaaa,mx,soa
+#
+#
+# MDNS_ACCEPT_TYPES
+#
+# If set, a record type will be resolved via Avahi if and only if it
+# is present on this comma-separated list. In other words, this is a
+# whitelist.
+#
+# The default value is an empty list which means all record types will
+# be resolved via Avahi.
+#
+# Example: MDNS_ACCEPT_TYPES=a,ptr,txt,srv,aaaa,cname
+#
+#
+# MDNS_REJECT_NAMES
+#
+# If the name being resolved matches the regular expression in this
+# environment variable, the name will NOT be resolved via Avahi. In
+# other words, this environment variable provides a blacklist.
+#
+# The default value is empty--no names will be reject.
+#
+# Example: MDNS_REJECT_NAMES=(^|\.)example\.com\.$
+#
+#
+# MDNS_ACCEPT_NAMES
+#
+# If set to a regular expression, a name will be resolved via Avahi if
+# and only if it matches the regular expression. In other words, this
+# variable provides a whitelist.
+#
+# The default value is empty--all names will be resolved via Avahi.
+#
+# Example: MDNS_ACCEPT_NAMES=^.*\.example\.com\.$
+#
+
+import os
+import re
+import array
+import threading
+import traceback
+import dns.rdata
+import dns.rdatatype
+import dns.rdataclass
+from queue import Queue
+from gi.repository import GLib
+from pydbus import SystemBus
+
+
+IF_UNSPEC    = -1
+PROTO_UNSPEC = -1
+
+sysbus = None
+avahi = None
+trampoline = dict()
+thread_local = threading.local()
+dbus_thread = None
+dbus_loop = None
+
+
+def str2bool(v):
+    if v.lower() in ['false', 'no', '0', 'off', '']:
+        return False
+    return True
+
+
+def dbg(msg):
+    if DEBUG != False:
+        log_info('avahi-resolver: %s' % msg)
+
+
+#
+# Although pydbus has an internal facility for handling signals, we
+# cannot use that with Avahi. When responding from an internal cache,
+# Avahi sends the first signal very quickly, before pydbus has had a
+# chance to subscribe for the signal. This will result in lost signal
+# and missed data:
+#
+# https://github.com/LEW21/pydbus/issues/87
+#
+# As a workaround, we subscribe to all signals before creating a
+# record browser and do our own signal matching and dispatching via
+# the following function.
+#
+def signal_dispatcher(connection, sender, path, interface, name, args):
+    o = trampoline.get(path, None)
+    if o is None:
+        return
+
+    if   name == 'ItemNew':    o.itemNew(*args)
+    elif name == 'ItemRemove': o.itemRemove(*args)
+    elif name == 'AllForNow':  o.allForNow(*args)
+    elif name == 'Failure':    o.failure(*args)
+
+
+class RecordBrowser:
+    def __init__(self, callback, name, type_, timeout=None, getone=True):
+        self.callback = callback
+        self.records = []
+        self.error = None
+        self.getone = getone
+
+        self.timer = None if timeout is None else GLib.timeout_add(timeout, self.timedOut)
+
+        self.browser_path = avahi.RecordBrowserNew(IF_UNSPEC, PROTO_UNSPEC, name, dns.rdataclass.IN, type_, 0)
+        trampoline[self.browser_path] = self
+        self.browser = sysbus.get('.Avahi', self.browser_path)
+        self.dbg('Created RecordBrowser(name=%s, type=%s, getone=%s, timeout=%s)'
+                   % (name, dns.rdatatype.to_text(type_), getone, timeout))
+
+    def dbg(self, msg):
+        dbg('[%s] %s' % (self.browser_path, msg))
+
+    def _done(self):
+        del trampoline[self.browser_path]
+        self.dbg('Freeing')
+        self.browser.Free()
+
+        if self.timer is not None:
+            self.dbg('Removing timer')
+            GLib.source_remove(self.timer)
+
+        self.callback(self.records, self.error)
+
+    def itemNew(self, interface, protocol, name, class_, type_, rdata, flags):
+        self.dbg('Got signal ItemNew')
+        self.records.append((name, class_, type_, rdata))
+        if self.getone:
+            self._done()
+
+    def itemRemove(self, interface, protocol, name, class_, type_, rdata, flags):
+        self.dbg('Got signal ItemRemove')
+        self.records.remove((name, class_, type_, rdata))
+
+    def failure(self, error):
+        self.dbg('Got signal Failure')
+        self.error = Exception(error)
+        self._done()
+
+    def allForNow(self):
+        self.dbg('Got signal AllForNow')
+        if self.timer is None:
+            self._done()
+
+    def timedOut(self):
+        self.dbg('Timed out')
+        self._done()
+        return False
+
+
+#
+# This function runs the main event loop for DBus (GLib). This
+# function must be run in a dedicated worker thread.
+#
+def dbus_main():
+    global sysbus, avahi, dbus_loop
+
+    dbg('Connecting to system DBus')
+    sysbus = SystemBus()
+
+    dbg('Subscribing to .Avahi.RecordBrowser signals')
+    sysbus.con.signal_subscribe('org.freedesktop.Avahi',
+        'org.freedesktop.Avahi.RecordBrowser',
+        None, None, None, 0, signal_dispatcher)
+
+    avahi = sysbus.get('.Avahi', '/')
+
+    dbg("Connected to Avahi Daemon: %s (API %s) [%s]"
+             % (avahi.GetVersionString(), avahi.GetAPIVersion(), avahi.GetHostNameFqdn()))
+
+    dbg('Starting DBus main loop')
+    dbus_loop = GLib.MainLoop()
+    dbus_loop.run()
+
+
+#
+# This function must be run in the DBus worker thread. It creates a
+# new RecordBrowser instance and once it has finished doing it thing,
+# it will send the result back to the original thread via the queue.
+#
+def start_resolver(queue, *args, **kwargs):
+    try:
+        RecordBrowser(lambda *v: queue.put_nowait(v), *args, **kwargs)
+    except Exception as e:
+        queue.put_nowait((None, e))
+
+    return False
+
+
+#
+# To resolve a request, we setup a queue, post a task to the DBus
+# worker thread, and wait for the result (or error) to arrive over the
+# queue. If the worker thread reports an error, raise the error as an
+# exception.
+#
+def resolve(*args, **kwargs):
+    try:
+        queue = thread_local.queue
+    except AttributeError:
+        dbg('Creating new per-thread queue')
+        queue = Queue()
+        thread_local.queue = queue
+
+    GLib.idle_add(lambda: start_resolver(queue, *args, **kwargs))
+
+    records, error = queue.get()
+    queue.task_done()
+
+    if error is not None:
+        raise error
+
+    return records
+
+
+def parse_type_list(lst):
+    return list(map(dns.rdatatype.from_text, [v.strip() for v in lst.split(',') if len(v)]))
+
+
+def init(*args, **kwargs):
+    global dbus_thread, DEBUG
+    global MDNS_TTL, MDNS_GETONE, MDNS_TIMEOUT
+    global MDNS_REJECT_TYPES, MDNS_ACCEPT_TYPES
+    global MDNS_REJECT_NAMES, MDNS_ACCEPT_NAMES
+
+    DEBUG = str2bool(os.environ.get('DEBUG', str(False)))
+
+    MDNS_TTL = int(os.environ.get('MDNS_TTL', 120))
+    dbg("TTL for records from Avahi: %d" % MDNS_TTL)
+
+    MDNS_REJECT_TYPES = parse_type_list(os.environ.get('MDNS_REJECT_TYPES', ''))
+    if MDNS_REJECT_TYPES:
+        dbg('Types NOT resolved via Avahi: %s' % MDNS_REJECT_TYPES)
+
+    MDNS_ACCEPT_TYPES = parse_type_list(os.environ.get('MDNS_ACCEPT_TYPES', ''))
+    if MDNS_ACCEPT_TYPES:
+        dbg('ONLY resolving the following types via Avahi: %s' % MDNS_ACCEPT_TYPES)
+
+    v = os.environ.get('MDNS_REJECT_NAMES', None)
+    MDNS_REJECT_NAMES = re.compile(v, flags=re.I | re.S) if v is not None else None
+    if MDNS_REJECT_NAMES is not None:
+        dbg('Names NOT resolved via Avahi: %s' % MDNS_REJECT_NAMES.pattern)
+
+    v = os.environ.get('MDNS_ACCEPT_NAMES', None)
+    MDNS_ACCEPT_NAMES = re.compile(v, flags=re.I | re.S) if v is not None else None
+    if MDNS_ACCEPT_NAMES is not None:
+        dbg('ONLY resolving the following names via Avahi: %s' % MDNS_ACCEPT_NAMES.pattern)
+
+    v = os.environ.get('MDNS_TIMEOUT', None)
+    MDNS_TIMEOUT = int(v) if v is not None else None
+    if MDNS_TIMEOUT is not None:
+        dbg('Avahi request timeout: %s' % MDNS_TIMEOUT)
+
+    MDNS_GETONE = str2bool(os.environ.get('MDNS_GETONE', str(True)))
+    dbg('Terminate Avahi requests on first record: %s' % MDNS_GETONE)
+
+    dbus_thread = threading.Thread(target=dbus_main)
+    dbus_thread.daemon = True
+    dbus_thread.start()
+
+
+def deinit(*args, **kwargs):
+    dbus_loop.quit()
+    dbus_thread.join()
+    return True
+
+
+def inform_super(id, qstate, superqstate, qdata):
+    return True
+
+
+def get_rcode(msg):
+    if not msg:
+        return RCODE_SERVFAIL
+
+    return msg.rep.flags & 0xf
+
+
+def rr2text(rec, ttl):
+    name, class_, type_, rdata = rec
+    wire = array.array('B', rdata).tostring()
+    return '%s. %d %s %s %s' % (
+        name,
+        ttl,
+        dns.rdataclass.to_text(class_),
+        dns.rdatatype.to_text(type_),
+        dns.rdata.from_wire(class_, type_, wire, 0, len(wire), None))
+
+
+def operate(id, event, qstate, qdata):
+    qi = qstate.qinfo
+    name = qi.qname_str
+    type_ = qi.qtype
+    type_str = dns.rdatatype.to_text(type_)
+    class_ = qi.qclass
+    class_str = dns.rdataclass.to_text(class_)
+    rc = get_rcode(qstate.return_msg)
+
+    if event == MODULE_EVENT_NEW or event == MODULE_EVENT_PASS:
+        qstate.ext_state[id] = MODULE_WAIT_MODULE
+        return True
+
+    if event != MODULE_EVENT_MODDONE:
+        log_err("avahi-resolver: Unexpected event %d" % event)
+        qstate.ext_state[id] = MODULE_ERROR
+        return True
+
+    qstate.ext_state[id] = MODULE_FINISHED
+
+    # Only resolve via Avahi if we got NXDOMAIn from the upstream DNS
+    # server, or if we could not reach the upstream DNS server. If we
+    # got some records for the name from the upstream DNS server
+    # already, do not resolve the record in Avahi.
+    if rc != RCODE_NXDOMAIN and rc != RCODE_SERVFAIL:
+        return True
+
+    dbg("Got request for '%s %s %s'" % (name, class_str, type_str))
+
+    # Avahi only supports the IN class
+    if class_ != RR_CLASS_IN:
+        dbg('Rejected, Avahi only supports the IN class')
+        return True
+
+    # Avahi does not support meta queries (e.g., ANY)
+    if dns.rdatatype.is_metatype(type_):
+        dbg('Rejected, Avahi does not support the type %s' % type_str)
+        return True
+
+    # If we have a type blacklist and the requested type is on the
+    # list, reject it.
+    if MDNS_REJECT_TYPES and type_ in MDNS_REJECT_TYPES:
+        dbg('Rejected, type %s is on the blacklist' % type_str)
+        return True
+
+    # If we have a type whitelist and if the requested type is not on
+    # the list, reject it.
+    if MDNS_ACCEPT_TYPES and type_ not in MDNS_ACCEPT_TYPES:
+        dbg('Rejected, type %s is not on the whitelist' % type_str)
+        return True
+
+    # If we have a name blacklist and if the requested name matches
+    # the blacklist, reject it.
+    if MDNS_REJECT_NAMES is not None:
+        if MDNS_REJECT_NAMES.search(name):
+            dbg('Rejected, name %s is on the blacklist' % name)
+            return True
+
+    # If we have a name whitelist and if the requested name does not
+    # match the whitelist, reject it.
+    if MDNS_ACCEPT_NAMES is not None:
+        if not MDNS_ACCEPT_NAMES.search(name):
+            dbg('Rejected, name %s is not on the whitelist' % name)
+            return True
+
+    dbg("Resolving '%s %s %s' via Avahi" % (name, class_str, type_str))
+
+    recs = resolve(name, type_, getone=MDNS_GETONE, timeout=MDNS_TIMEOUT)
+
+    if not recs:
+        dbg('Result: Not found (NXDOMAIN)')
+        qstate.return_rcode = RCODE_NXDOMAIN
+        return True
+
+    m = DNSMessage(name, type_, class_, PKT_QR | PKT_RD | PKT_RA)
+    for r in recs:
+        s = rr2text(r, MDNS_TTL)
+        dbg('Result: %s' % s)
+        m.answer.append(s)
+
+    if not m.set_return_msg(qstate):
+        raise Exception("Error in set_return_msg")
+
+    if not storeQueryInCache(qstate, qstate.return_msg.qinfo, qstate.return_msg.rep, 0):
+        raise Exception("Error in storeQueryInCache")
+
+    qstate.return_msg.rep.security = 2
+    qstate.return_rcode = RCODE_NOERROR
+    return True
+
+
+#
+# It does not appear to be sufficient to check __name__ to determine
+# whether we are being run in interactive mode. As a workaround, try
+# to import module unboundmodule and if that fails, assume we're being
+# run in interactive mode.
+#
+try:
+    import unboundmodule
+    embedded = True
+except ImportError:
+    embedded = False
+
+if __name__ == '__main__' and not embedded:
+    import sys
+
+    def log_info(msg):
+        print(msg)
+
+    def log_err(msg):
+        print('ERROR: %s' % msg, file=sys.stderr)
+
+    if len(sys.argv) != 3:
+        print('Usage: %s <name> <rr_type>' % sys.argv[0])
+        sys.exit(2)
+
+    name = sys.argv[1]
+    type_str = sys.argv[2]
+
+    try:
+        type_ = dns.rdatatype.from_text(type_str)
+    except dns.rdatatype.UnknownRdatatype:
+        log_err('Unsupported DNS record type "%s"' % type_str)
+        sys.exit(2)
+
+    if dns.rdatatype.is_metatype(type_):
+        log_err('Meta record type "%s" cannot be resolved via Avahi' % type_str)
+        sys.exit(2)
+
+    init()
+    try:
+        recs = resolve(name, type_, getone=MDNS_GETONE, timeout=MDNS_TIMEOUT)
+        if not len(recs):
+            print('%s not found (NXDOMAIN)' % name)
+            sys.exit(1)
+
+        for r in recs:
+            print(rr2text(r, MDNS_TTL))
+    finally:
+        deinit()