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