xref: /netbsd-src/external/bsd/unbound/dist/pythonmod/examples/avahi-resolver.py (revision 01049ae6d55a7fce6c6379cd1e0c997c04dc0061)
1#!/usr/bin/env python3
2#
3# A plugin for the Unbound DNS resolver to resolve DNS records in
4# multicast DNS [RFC 6762] via Avahi.
5#
6# Copyright (C) 2018-2019 Internet Real-Time Lab, Columbia University
7# http://www.cs.columbia.edu/irt/
8#
9# Written by Jan Janak <janakj@cs.columbia.edu>
10#
11# Permission is hereby granted, free of charge, to any person
12# obtaining a copy of this software and associated documentation files
13# (the "Software"), to deal in the Software without restriction,
14# including without limitation the rights to use, copy, modify, merge,
15# publish, distribute, sublicense, and/or sell copies of the Software,
16# and to permit persons to whom the Software is furnished to do so,
17# subject to the following conditions:
18#
19# The above copyright notice and this permission notice shall be
20# included in all copies or substantial portions of the Software.
21#
22# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
23# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
24# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
25# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
26# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
27# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
28# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29# SOFTWARE.
30#
31#
32# Dependendies:
33#   Unbound with pythonmodule configured for Python 3
34#   dnspython [http://www.dnspython.org]
35#   pydbus [https://github.com/LEW21/pydbus]
36#
37# To enable Python 3 support, configure Unbound as follows:
38#   PYTHON_VERSION=3 ./configure --with-pythonmodule
39#
40# The plugin in meant to be used as a fallback resolver that resolves
41# records in multicast DNS if the upstream server cannot be reached or
42# provides no answer (NXDOMAIN).
43#
44# mDNS requests for negative records, i.e., records for which Avahi
45# returns no answer (NXDOMAIN), are expensive. Since there is no
46# single authoritative server in mDNS, such requests terminate only
47# via a timeout. The timeout is about a second (if MDNS_TIMEOUT is not
48# configured), or the value configured via MDNS_TIMEOUT. The
49# corresponding Unbound thread will be blocked for this amount of
50# time. For this reason, it is important to configure an appropriate
51# number of threads in unbound.conf and limit the RR types and names
52# that will be resolved via Avahi via the environment variables
53# described later.
54#
55# An example unbound.conf with the plugin enabled:
56#
57# | server:
58# |   module-config: "validator python iterator"
59# |   num-threads: 32
60# |   cache-max-negative-ttl: 60
61# |   cache-max-ttl: 60
62#
63#
64# The plugin can also be run interactively. Provide the name and
65# record type to be resolved as command line arguments and the
66# resolved record will be printed to standard output:
67#
68#   $ ./avahi-resolver.py voip-phx4.phxnet.org A
69#   voip-phx4.phxnet.org. 120 IN A 10.4.3.2
70#
71#
72# The behavior of the plugin can be controlled via the following
73# environment variables:
74#
75# DBUS_SYSTEM_BUS_ADDRESS
76#
77# The address of the system DBus bus, in the format expected by DBus,
78# e.g., unix:path=/run/avahi/system-bus.sock
79#
80#
81# DEBUG
82#
83# Set this environment variable to "yes", "true", "on", or "1" to
84# enable debugging. In debugging mode, the plugin will output a lot
85# more information about what it is doing either to the standard
86# output (when run interactively) or to Unbound via log_info and
87# log_error.
88#
89# By default debugging is disabled.
90#
91#
92# MDNS_TTL
93#
94# Avahi does not provide the TTL value for the records it returns.
95# This environment variable can be used to configure the TTL value for
96# such records.
97#
98# The default value is 120 seconds.
99#
100#
101# MDNS_TIMEOUT
102#
103# The maximum amount of time (in milliseconds) an Avahi request is
104# allowed to run. This value sets the time it takes to resolve
105# negative (non-existent) records in Avahi. If unset, the request
106# terminates when Avahi sends the "AllForNow" signal, telling the
107# client that more records are unlikely to arrive. This takes roughly
108# about one second. You may need to configure a longer value here on
109# slower networks, e.g., networks that relay mDNS packets such as
110# MANETs.
111#
112#
113# MDNS_GETONE
114#
115# If set to "true", "1", or "on", an Avahi request will terminate as
116# soon as at least one record has been found. If there are multiple
117# nodes in the mDNS network publishing the same record, only one (or
118# subset) will be returned.
119#
120# If set to "false", "0", or "off", the plugin will gather records for
121# MDNS_TIMEOUT and return all records found. This is only useful in
122# networks where multiple nodes are known to publish different records
123# under the same name and the client needs to be able to obtain them
124# all. When configured this way, all Avahi requests will always take
125# MDNS_TIMEOUT to complete!
126#
127# This option is set to true by default.
128#
129#
130# MDNS_REJECT_TYPES
131#
132# A comma-separated list of record types that will NOT be resolved in
133# mDNS via Avahi. Use this environment variable to prevent specific
134# record types from being resolved via Avahi. For example, if your
135# network does not support IPv6, you can put AAAA on this list.
136#
137# The default value is an empty list.
138#
139# Example: MDNS_REJECT_TYPES=aaaa,mx,soa
140#
141#
142# MDNS_ACCEPT_TYPES
143#
144# If set, a record type will be resolved via Avahi if and only if it
145# is present on this comma-separated list. In other words, this is a
146# whitelist.
147#
148# The default value is an empty list which means all record types will
149# be resolved via Avahi.
150#
151# Example: MDNS_ACCEPT_TYPES=a,ptr,txt,srv,aaaa,cname
152#
153#
154# MDNS_REJECT_NAMES
155#
156# If the name being resolved matches the regular expression in this
157# environment variable, the name will NOT be resolved via Avahi. In
158# other words, this environment variable provides a blacklist.
159#
160# The default value is empty--no names will be reject.
161#
162# Example: MDNS_REJECT_NAMES=(^|\.)example\.com\.$
163#
164#
165# MDNS_ACCEPT_NAMES
166#
167# If set to a regular expression, a name will be resolved via Avahi if
168# and only if it matches the regular expression. In other words, this
169# variable provides a whitelist.
170#
171# The default value is empty--all names will be resolved via Avahi.
172#
173# Example: MDNS_ACCEPT_NAMES=^.*\.example\.com\.$
174#
175
176import os
177import re
178import array
179import threading
180import traceback
181import dns.rdata
182import dns.rdatatype
183import dns.rdataclass
184from queue import Queue
185from gi.repository import GLib
186from pydbus import SystemBus
187
188
189IF_UNSPEC    = -1
190PROTO_UNSPEC = -1
191
192sysbus = None
193avahi = None
194trampoline = dict()
195thread_local = threading.local()
196dbus_thread = None
197dbus_loop = None
198
199
200def str2bool(v):
201    if v.lower() in ['false', 'no', '0', 'off', '']:
202        return False
203    return True
204
205
206def dbg(msg):
207    if DEBUG != False:
208        log_info('avahi-resolver: %s' % msg)
209
210
211#
212# Although pydbus has an internal facility for handling signals, we
213# cannot use that with Avahi. When responding from an internal cache,
214# Avahi sends the first signal very quickly, before pydbus has had a
215# chance to subscribe for the signal. This will result in lost signal
216# and missed data:
217#
218# https://github.com/LEW21/pydbus/issues/87
219#
220# As a workaround, we subscribe to all signals before creating a
221# record browser and do our own signal matching and dispatching via
222# the following function.
223#
224def signal_dispatcher(connection, sender, path, interface, name, args):
225    o = trampoline.get(path, None)
226    if o is None:
227        return
228
229    if   name == 'ItemNew':    o.itemNew(*args)
230    elif name == 'ItemRemove': o.itemRemove(*args)
231    elif name == 'AllForNow':  o.allForNow(*args)
232    elif name == 'Failure':    o.failure(*args)
233
234
235class RecordBrowser:
236    def __init__(self, callback, name, type_, timeout=None, getone=True):
237        self.callback = callback
238        self.records = []
239        self.error = None
240        self.getone = getone
241
242        self.timer = None if timeout is None else GLib.timeout_add(timeout, self.timedOut)
243
244        self.browser_path = avahi.RecordBrowserNew(IF_UNSPEC, PROTO_UNSPEC, name, dns.rdataclass.IN, type_, 0)
245        trampoline[self.browser_path] = self
246        self.browser = sysbus.get('.Avahi', self.browser_path)
247        self.dbg('Created RecordBrowser(name=%s, type=%s, getone=%s, timeout=%s)'
248                   % (name, dns.rdatatype.to_text(type_), getone, timeout))
249
250    def dbg(self, msg):
251        dbg('[%s] %s' % (self.browser_path, msg))
252
253    def _done(self):
254        del trampoline[self.browser_path]
255        self.dbg('Freeing')
256        self.browser.Free()
257
258        if self.timer is not None:
259            self.dbg('Removing timer')
260            GLib.source_remove(self.timer)
261
262        self.callback(self.records, self.error)
263
264    def itemNew(self, interface, protocol, name, class_, type_, rdata, flags):
265        self.dbg('Got signal ItemNew')
266        self.records.append((name, class_, type_, rdata))
267        if self.getone:
268            self._done()
269
270    def itemRemove(self, interface, protocol, name, class_, type_, rdata, flags):
271        self.dbg('Got signal ItemRemove')
272        self.records.remove((name, class_, type_, rdata))
273
274    def failure(self, error):
275        self.dbg('Got signal Failure')
276        self.error = Exception(error)
277        self._done()
278
279    def allForNow(self):
280        self.dbg('Got signal AllForNow')
281        if self.timer is None:
282            self._done()
283
284    def timedOut(self):
285        self.dbg('Timed out')
286        self._done()
287        return False
288
289
290#
291# This function runs the main event loop for DBus (GLib). This
292# function must be run in a dedicated worker thread.
293#
294def dbus_main():
295    global sysbus, avahi, dbus_loop
296
297    dbg('Connecting to system DBus')
298    sysbus = SystemBus()
299
300    dbg('Subscribing to .Avahi.RecordBrowser signals')
301    sysbus.con.signal_subscribe('org.freedesktop.Avahi',
302        'org.freedesktop.Avahi.RecordBrowser',
303        None, None, None, 0, signal_dispatcher)
304
305    avahi = sysbus.get('.Avahi', '/')
306
307    dbg("Connected to Avahi Daemon: %s (API %s) [%s]"
308             % (avahi.GetVersionString(), avahi.GetAPIVersion(), avahi.GetHostNameFqdn()))
309
310    dbg('Starting DBus main loop')
311    dbus_loop = GLib.MainLoop()
312    dbus_loop.run()
313
314
315#
316# This function must be run in the DBus worker thread. It creates a
317# new RecordBrowser instance and once it has finished doing it thing,
318# it will send the result back to the original thread via the queue.
319#
320def start_resolver(queue, *args, **kwargs):
321    try:
322        RecordBrowser(lambda *v: queue.put_nowait(v), *args, **kwargs)
323    except Exception as e:
324        queue.put_nowait((None, e))
325
326    return False
327
328
329#
330# To resolve a request, we setup a queue, post a task to the DBus
331# worker thread, and wait for the result (or error) to arrive over the
332# queue. If the worker thread reports an error, raise the error as an
333# exception.
334#
335def resolve(*args, **kwargs):
336    try:
337        queue = thread_local.queue
338    except AttributeError:
339        dbg('Creating new per-thread queue')
340        queue = Queue()
341        thread_local.queue = queue
342
343    GLib.idle_add(lambda: start_resolver(queue, *args, **kwargs))
344
345    records, error = queue.get()
346    queue.task_done()
347
348    if error is not None:
349        raise error
350
351    return records
352
353
354def parse_type_list(lst):
355    return list(map(dns.rdatatype.from_text, [v.strip() for v in lst.split(',') if len(v)]))
356
357
358def init(*args, **kwargs):
359    global dbus_thread, DEBUG
360    global MDNS_TTL, MDNS_GETONE, MDNS_TIMEOUT
361    global MDNS_REJECT_TYPES, MDNS_ACCEPT_TYPES
362    global MDNS_REJECT_NAMES, MDNS_ACCEPT_NAMES
363
364    DEBUG = str2bool(os.environ.get('DEBUG', str(False)))
365
366    MDNS_TTL = int(os.environ.get('MDNS_TTL', 120))
367    dbg("TTL for records from Avahi: %d" % MDNS_TTL)
368
369    MDNS_REJECT_TYPES = parse_type_list(os.environ.get('MDNS_REJECT_TYPES', ''))
370    if MDNS_REJECT_TYPES:
371        dbg('Types NOT resolved via Avahi: %s' % MDNS_REJECT_TYPES)
372
373    MDNS_ACCEPT_TYPES = parse_type_list(os.environ.get('MDNS_ACCEPT_TYPES', ''))
374    if MDNS_ACCEPT_TYPES:
375        dbg('ONLY resolving the following types via Avahi: %s' % MDNS_ACCEPT_TYPES)
376
377    v = os.environ.get('MDNS_REJECT_NAMES', None)
378    MDNS_REJECT_NAMES = re.compile(v, flags=re.I | re.S) if v is not None else None
379    if MDNS_REJECT_NAMES is not None:
380        dbg('Names NOT resolved via Avahi: %s' % MDNS_REJECT_NAMES.pattern)
381
382    v = os.environ.get('MDNS_ACCEPT_NAMES', None)
383    MDNS_ACCEPT_NAMES = re.compile(v, flags=re.I | re.S) if v is not None else None
384    if MDNS_ACCEPT_NAMES is not None:
385        dbg('ONLY resolving the following names via Avahi: %s' % MDNS_ACCEPT_NAMES.pattern)
386
387    v = os.environ.get('MDNS_TIMEOUT', None)
388    MDNS_TIMEOUT = int(v) if v is not None else None
389    if MDNS_TIMEOUT is not None:
390        dbg('Avahi request timeout: %s' % MDNS_TIMEOUT)
391
392    MDNS_GETONE = str2bool(os.environ.get('MDNS_GETONE', str(True)))
393    dbg('Terminate Avahi requests on first record: %s' % MDNS_GETONE)
394
395    dbus_thread = threading.Thread(target=dbus_main)
396    dbus_thread.daemon = True
397    dbus_thread.start()
398
399
400def deinit(*args, **kwargs):
401    dbus_loop.quit()
402    dbus_thread.join()
403    return True
404
405
406def inform_super(id, qstate, superqstate, qdata):
407    return True
408
409
410def get_rcode(msg):
411    if not msg:
412        return RCODE_SERVFAIL
413
414    return msg.rep.flags & 0xf
415
416
417def rr2text(rec, ttl):
418    name, class_, type_, rdata = rec
419    wire = array.array('B', rdata).tostring()
420    return '%s. %d %s %s %s' % (
421        name,
422        ttl,
423        dns.rdataclass.to_text(class_),
424        dns.rdatatype.to_text(type_),
425        dns.rdata.from_wire(class_, type_, wire, 0, len(wire), None))
426
427
428def operate(id, event, qstate, qdata):
429    qi = qstate.qinfo
430    name = qi.qname_str
431    type_ = qi.qtype
432    type_str = dns.rdatatype.to_text(type_)
433    class_ = qi.qclass
434    class_str = dns.rdataclass.to_text(class_)
435    rc = get_rcode(qstate.return_msg)
436
437    if event == MODULE_EVENT_NEW or event == MODULE_EVENT_PASS:
438        qstate.ext_state[id] = MODULE_WAIT_MODULE
439        return True
440
441    if event != MODULE_EVENT_MODDONE:
442        log_err("avahi-resolver: Unexpected event %d" % event)
443        qstate.ext_state[id] = MODULE_ERROR
444        return True
445
446    qstate.ext_state[id] = MODULE_FINISHED
447
448    # Only resolve via Avahi if we got NXDOMAIn from the upstream DNS
449    # server, or if we could not reach the upstream DNS server. If we
450    # got some records for the name from the upstream DNS server
451    # already, do not resolve the record in Avahi.
452    if rc != RCODE_NXDOMAIN and rc != RCODE_SERVFAIL:
453        return True
454
455    dbg("Got request for '%s %s %s'" % (name, class_str, type_str))
456
457    # Avahi only supports the IN class
458    if class_ != RR_CLASS_IN:
459        dbg('Rejected, Avahi only supports the IN class')
460        return True
461
462    # Avahi does not support meta queries (e.g., ANY)
463    if dns.rdatatype.is_metatype(type_):
464        dbg('Rejected, Avahi does not support the type %s' % type_str)
465        return True
466
467    # If we have a type blacklist and the requested type is on the
468    # list, reject it.
469    if MDNS_REJECT_TYPES and type_ in MDNS_REJECT_TYPES:
470        dbg('Rejected, type %s is on the blacklist' % type_str)
471        return True
472
473    # If we have a type whitelist and if the requested type is not on
474    # the list, reject it.
475    if MDNS_ACCEPT_TYPES and type_ not in MDNS_ACCEPT_TYPES:
476        dbg('Rejected, type %s is not on the whitelist' % type_str)
477        return True
478
479    # If we have a name blacklist and if the requested name matches
480    # the blacklist, reject it.
481    if MDNS_REJECT_NAMES is not None:
482        if MDNS_REJECT_NAMES.search(name):
483            dbg('Rejected, name %s is on the blacklist' % name)
484            return True
485
486    # If we have a name whitelist and if the requested name does not
487    # match the whitelist, reject it.
488    if MDNS_ACCEPT_NAMES is not None:
489        if not MDNS_ACCEPT_NAMES.search(name):
490            dbg('Rejected, name %s is not on the whitelist' % name)
491            return True
492
493    dbg("Resolving '%s %s %s' via Avahi" % (name, class_str, type_str))
494
495    recs = resolve(name, type_, getone=MDNS_GETONE, timeout=MDNS_TIMEOUT)
496
497    if not recs:
498        dbg('Result: Not found (NXDOMAIN)')
499        qstate.return_rcode = RCODE_NXDOMAIN
500        return True
501
502    m = DNSMessage(name, type_, class_, PKT_QR | PKT_RD | PKT_RA)
503    for r in recs:
504        s = rr2text(r, MDNS_TTL)
505        dbg('Result: %s' % s)
506        m.answer.append(s)
507
508    if not m.set_return_msg(qstate):
509        raise Exception("Error in set_return_msg")
510
511    if not storeQueryInCache(qstate, qstate.return_msg.qinfo, qstate.return_msg.rep, 0):
512        raise Exception("Error in storeQueryInCache")
513
514    qstate.return_msg.rep.security = 2
515    qstate.return_rcode = RCODE_NOERROR
516    return True
517
518
519#
520# It does not appear to be sufficient to check __name__ to determine
521# whether we are being run in interactive mode. As a workaround, try
522# to import module unboundmodule and if that fails, assume we're being
523# run in interactive mode.
524#
525try:
526    import unboundmodule
527    embedded = True
528except ImportError:
529    embedded = False
530
531if __name__ == '__main__' and not embedded:
532    import sys
533
534    def log_info(msg):
535        print(msg)
536
537    def log_err(msg):
538        print('ERROR: %s' % msg, file=sys.stderr)
539
540    if len(sys.argv) != 3:
541        print('Usage: %s <name> <rr_type>' % sys.argv[0])
542        sys.exit(2)
543
544    name = sys.argv[1]
545    type_str = sys.argv[2]
546
547    try:
548        type_ = dns.rdatatype.from_text(type_str)
549    except dns.rdatatype.UnknownRdatatype:
550        log_err('Unsupported DNS record type "%s"' % type_str)
551        sys.exit(2)
552
553    if dns.rdatatype.is_metatype(type_):
554        log_err('Meta record type "%s" cannot be resolved via Avahi' % type_str)
555        sys.exit(2)
556
557    init()
558    try:
559        recs = resolve(name, type_, getone=MDNS_GETONE, timeout=MDNS_TIMEOUT)
560        if not len(recs):
561            print('%s not found (NXDOMAIN)' % name)
562            sys.exit(1)
563
564        for r in recs:
565            print(rr2text(r, MDNS_TTL))
566    finally:
567        deinit()
568