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