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