%PDF- %PDF-
Direktori : /backups/router/var/unbound/ |
Current File : //backups/router/var/unbound/dnsbl_module.py |
""" Copyright (c) 2022-2023 Deciso B.V. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------------- DNSBL module. Intercepts DNS queries and applies blocklist policies on them. This module is comprised of several objects with their own responsibilities accessible from the global scope as set by Unbound. They are: - mod_env['context']: module configuration - mod_env['dnsbl']: blocklist data and policy logic - mod_env['logger']: logging mechanism """ import os import json import time import errno import uuid import ipaddress import traceback import argparse import dns import dns.name from threading import Lock from collections import deque ACTION_PASS = 0 ACTION_BLOCK = 1 ACTION_DROP = 2 SOURCE_RECURSION = 0 SOURCE_LOCAL = 1 SOURCE_LOCALDATA = 2 SOURCE_CACHE = 3 RCODE_NOERROR = 0 RCODE_NXDOMAIN = 3 def obj_path_exists(obj, path): if obj and isinstance(path, str): for ref in path.split('.'): if hasattr(obj, ref): obj = getattr(obj, ref) else: return False return True return False class Query: """ Representation of a query. Can contain both Request and Response state. Used to send to the DNSBL class to match on a policy. This class is focused on safely parsing the qstate property to extract all necessary data. """ def __init__(self, cb_kwargs={}, cb_qinfo=None, qstate=None, client='', family='', type='' ,domain=''): self._created_at = int(time.time()) self._client = client self._family = family self._domain = domain self._type = type self._action = None self._source = None self._blocklist = None self._rcode = None self._resolve_time_ms = None self._dnssec_status = None self._ttl = None if obj_path_exists(cb_qinfo, 'qname_str'): self._domain = cb_qinfo.qname_str if obj_path_exists(cb_qinfo, 'qtype_str'): self._type = cb_qinfo.qtype_str if cb_kwargs is not None and 'repinfo' in cb_kwargs\ and obj_path_exists(cb_kwargs['repinfo'], 'addr') and obj_path_exists(cb_kwargs['repinfo'], 'family'): self._client = cb_kwargs['repinfo'].addr self._family = cb_kwargs['repinfo'].family if obj_path_exists(qstate, 'qinfo'): if obj_path_exists(qstate.qinfo, 'qname_str'): self._domain = qstate.qinfo.qname_str if obj_path_exists(qstate.qinfo, 'qtype_str'): self._type = qstate.qinfo.qtype_str if obj_path_exists(qstate, 'mesh_info.reply_list.query_reply.addr')\ and obj_path_exists(qstate, 'mesh_info.reply_list.query_reply.family'): self._client = qstate.mesh_info.reply_list.query_reply.addr self._family = qstate.mesh_info.reply_list.query_reply.family @property def client(self): return self._client @property def type(self): return self._type @type.setter def type(self, value): self._type = value @property def domain(self): return self._domain @domain.setter def domain(self, value): self._domain = value @property def request(self): return (self._created_at, self._client, self._family, self._type, self._domain) @property def response(self): return ( self._action, self._source, self._blocklist, self._rcode, self._resolve_time_ms, self._dnssec_status, self._ttl ) def set_response(self, action, source, blocklist, rcode, resolve_time_ms, dnssec_status, ttl): self._action = action self._source = source self._blocklist = blocklist self._rcode = rcode self._resolve_time_ms = resolve_time_ms self._dnssec_status = dnssec_status self._ttl = ttl return self class Logger: """ Handles logging by creating a fifo and sending data to a listening process (if there is one) """ def __init__(self): self._pipe_name = '/data/dns_logger' self._pipe_fd = None self._pipe_timer = 0 self.stats_enabled = os.path.exists('/data/stats') if self.stats_enabled: self._lock = Lock() self._pipe_buffer = deque(maxlen=100000) # buffer to hold qdata as long as a backend is not present self._retry_timer = 10 self._create_pipe_rdv() # Defines the rendezvous point, but does not open it. # Subsequent calls to log_entry will attempt to open the pipe if necessary while being throttled # by a default timer def _create_pipe_rdv(self): if os.path.exists(self._pipe_name): os.unlink(self._pipe_name) os.mkfifo(self._pipe_name) def _try_open_pipe(self): try: # try to obtain the fd in a non-blocking manner and catch the ENXIO exception # if the other side is not listening. self._pipe_fd = os.open(self._pipe_name, os.O_NONBLOCK | os.O_WRONLY) except OSError as e: if e.errno == errno.ENXIO: log_info("dnsbl_module: no logging backend found.") self._pipe_fd = None return False else: raise return True def close(self): if self.stats_enabled: with self._lock: if self._pipe_fd is not None: os.close(self._pipe_fd) try: os.unlink(self._pipe_name) except: pass def log_entry(self, query: Query): if not self.stats_enabled: return self._pipe_buffer.append((uuid.uuid4(),) + query.request + query.response) if self._pipe_fd is None: if (time.time() - self._pipe_timer) > self._retry_timer: self._pipe_timer = time.time() log_info("dnsbl_module: attempting to open pipe") if not self._try_open_pipe(): return log_info("dnsbl_module: successfully opened pipe") else: return with self._lock: l = None try: while len(self._pipe_buffer) > 0: l = self._pipe_buffer.popleft() res = "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}\n".format(*['' if x is None else x for x in l]) os.write(self._pipe_fd, res.encode()) except (BrokenPipeError, BlockingIOError, TypeError) as e: if e.__class__.__name__ == 'BrokenPipeError': log_info("dnsbl_module: Logging backend closed connection. Closing pipe and continuing.") os.close(self._pipe_fd) self._pipe_fd = None self._pipe_buffer.appendleft(l) class DNSBL: """ DNSBL implementation. Handles dynamically updating the blocklist as well as matching policies on incoming queries. """ def __init__(self, dnsbl_path='/data/dnsbl.json', size_file='/data/dnsbl.size'): self.dnsbl_path = dnsbl_path self.size_file = size_file self.dnsbl_mtime_cache = 0 self.dnsbl_update_time = 0 self.dnsbl_available = False self.dnsbl = None self._update_dnsbl() def _dnsbl_exists(self): return os.path.isfile(self.dnsbl_path) and os.path.getsize(self.dnsbl_path) > 0 def _update_dnsbl(self): t = time.time() if (t - self.dnsbl_update_time) > 60: self.dnsbl_update_time = t if not self._dnsbl_exists(): self.dnsbl_available = False return fstat = os.stat(self.dnsbl_path).st_mtime if fstat != self.dnsbl_mtime_cache: self.dnsbl_mtime_cache = fstat log_info("dnsbl_module: updating blocklist.") self._load_dnsbl() def _load_dnsbl(self): with open(self.dnsbl_path, 'r') as f: try: self.dnsbl = json.load(f) log_info('dnsbl_module: blocklist loaded. length is %d' % len(self.dnsbl['data'])) with open(self.size_file, 'w') as sfile: sfile.write(str(len(self.dnsbl['data']))) if mod_env and type(self.dnsbl.get('config')) is dict: mod_env['context'].set_config(self.dnsbl['config']) except (json.decoder.JSONDecodeError, KeyError) as e: if not self.dnsbl: log_err("dnsbl_module: unable to bootstrap blocklist, this is likely due to a corrupted \ file. Please re-apply the blocklist settings.") self.dnsbl_available = False return else: log_err("dnsbl_module: error parsing blocklist: %s, reusing last known list" % e) # translate source nets after loading the list, so we can easily match if in network # enforce our data structure to contain a "source_net" for every domain if 'data' in self.dnsbl and type(self.dnsbl['data']) is dict: for key in self.dnsbl['data']: if type(self.dnsbl['data'][key]) is dict: source_nets = [] if 'source_net' in self.dnsbl['data'][key]: if type(self.dnsbl['data'][key]['source_net']) is list: for item in self.dnsbl['data'][key]['source_net']: try: source_nets.append(ipaddress.ip_network(item, False)) except ValueError: log_err("dnsbl_module: unparsable network %s in %s" % (key, item)) self.dnsbl['data'][key]['source_net'] = source_nets self.dnsbl_available = True def _in_network(self, client, networks): if not networks: return True try: src_address = ipaddress.ip_address(client) except ValueError: # when no valid source address could be found, we won't be able to match a policy either log_err('dnsbl_module: unable to parse client source: %s' % traceback.format_exc().replace('\n', ' ')) return False for network in networks: if src_address in network: return True return False def policy_match(self, query: Query, qstate=None): self._update_dnsbl() if not self.dnsbl_available: return False if not query.type in ('A', 'AAAA', 'CNAME', 'HTTPS'): return False domain = query.domain.rstrip('.').lower() sub = domain match = None while match is None: if sub in self.dnsbl['data']: policy = self.dnsbl['data'][sub] is_full_domain = sub == domain if (is_full_domain) or (not is_full_domain and policy['wildcard']): if self._in_network(query.client, policy.get('source_net')): match = policy else: # allow query, but do not cache. if qstate and hasattr(qstate, 'no_cache_store'): qstate.no_cache_store = 1 return False if '.' not in sub or not mod_env['context'].config.get('has_wildcards', False): # either we have traversed all subdomains or there are no wildcards # in the dataset, in which case traversal is not necessary break else: sub = sub.split('.', maxsplit=1)[1] if match is not None: return match return False class ModuleContext: """ Module configuration context """ def __init__(self, env): self.config = None self.env = env self.dst_addr = '0.0.0.0' self.rcode = RCODE_NOERROR if self.env: self.dnssec_enabled = 'validator' in self.env.cfg.module_conf def set_config(self, config): """ set and parse configuration """ self.config = config self.dst_addr = self.config.get('dst_addr', '0.0.0.0') self.rcode = RCODE_NXDOMAIN if self.config.get('rcode') == 'NXDOMAIN' else RCODE_NOERROR def time_diff_ms(start): return round((time.time() - start) * 1000) def cache_cb(qinfo, qstate, rep, rcode, edns, opt_list_out, region, **kwargs): mod_env['logger'].log_entry( Query(kwargs, qinfo).set_response( action=ACTION_PASS, source=SOURCE_CACHE, blocklist=None, rcode=rcode, resolve_time_ms=0, dnssec_status=rep.security if rep else 0, # rep.ttl is stored as an epoch, so convert it to remaining seconds ttl=(rep.ttl - int(time.time())) if rep else 0 ) ) return True def local_cb(qinfo, qstate, rep, rcode, edns, opt_list_out, region, **kwargs): mod_env['logger'].log_entry( Query(kwargs, qinfo).set_response( action=ACTION_PASS, source=SOURCE_LOCALDATA, blocklist=None, rcode=rcode, resolve_time_ms=0, dnssec_status=rep.security if rep else 0, ttl=rep.ttl if rep else 0 ) ) return True def servfail_cb(qinfo, qstate, rep, rcode, edns, opt_list_out, region, **kwargs): mod_env['logger'].log_entry( Query(kwargs, qinfo).set_response( action=ACTION_DROP, source=SOURCE_LOCAL, blocklist=None, rcode=RCODE_SERVFAIL, resolve_time_ms=0, dnssec_status=rep.security if rep else 0, ttl=rep.ttl if rep else 0 ) ) return True def init_standard(id, env): ctx = ModuleContext(env) mod_env['context'] = ctx mod_env['logger'] = Logger() mod_env['dnsbl'] = DNSBL() if mod_env['logger'].stats_enabled: if not register_inplace_cb_reply_cache(cache_cb, env, id): log_err("dnsbl_module: unable to register cache reply callback") return False if not register_inplace_cb_reply_local(local_cb, env, id): log_err("dnsbl_module: unable to register local reply callback") return False if not register_inplace_cb_reply_servfail(servfail_cb, env, id): log_err("dnsbl_module: unable to register servfail reply callback") return False return True def deinit(id): logger = mod_env['logger'] logger.close() return True def inform_super(id, qstate, superqstate, qdata): return True def set_answer_block(qstate, qdata, query, bl=None): ctx = mod_env['context'] dnssec_status = sec_status_secure if ctx.dnssec_enabled else sec_status_unchecked logger = mod_env['logger'] if ctx.rcode == RCODE_NXDOMAIN: # exit early qstate.return_rcode = RCODE_NXDOMAIN if logger.stats_enabled: query.set_response(ACTION_BLOCK, SOURCE_LOCAL, bl, ctx.rcode, time_diff_ms(qdata['start_time']), dnssec_status, 0) mod_env['logger'].log_entry(query) return True ttl = 3600 msg = DNSMessage(query.domain, RR_TYPE_A, RR_CLASS_IN, PKT_QR | PKT_RA | PKT_AA) if (query.type == 'A'): msg.answer.append("%s %s IN A %s" % (query.domain, ttl, ctx.dst_addr)) if not msg.set_return_msg(qstate): log_err("dnsbl_module: unable to create response for %s, dropping query" % query.domain) if logger.stats_enabled: query.set_response(ACTION_DROP, SOURCE_LOCAL, bl, RCODE_SERVFAIL, time_diff_ms(qdata['start_time']), dnssec_status, 0) mod_env['logger'].log_entry(query) return False if ctx.dnssec_enabled: qstate.return_msg.rep.security = dnssec_status if logger.stats_enabled: query.set_response(ACTION_BLOCK, SOURCE_LOCAL, bl, ctx.rcode, time_diff_ms(qdata['start_time']), dnssec_status, ttl) mod_env['logger'].log_entry(query) return True def operate(id, event, qstate, qdata): if event == MODULE_EVENT_NEW: qdata['start_time'] = time.time() query = Query(qstate=qstate) match = mod_env['dnsbl'].policy_match(query, qstate) if match: if not set_answer_block(qstate, qdata, query, match.get('bl')): qstate.ext_state[id] = MODULE_ERROR return True qstate.ext_state[id] = MODULE_FINISHED return True else: qdata['query'] = query qstate.ext_state[id] = MODULE_WAIT_MODULE return True if event == MODULE_EVENT_MODDONE: # Iterator finished, show response (if any) logger = mod_env['logger'] qstate.ext_state[id] = MODULE_FINISHED if 'query' in qdata and 'start_time' in qdata: query = qdata['query'] if obj_path_exists(qstate, 'return_msg.rep'): r = qstate.return_msg.rep dnssec = r.security if r.security else sec_status_unchecked rcode = (r.flags & 0xF) if r.flags else RCODE_SERVFAIL ttl = r.ttl if r.ttl else 0 # if the count of RRsets > 1, then there are at least two different answer types. # this is most likely a CNAME, check if it is and refers to a fqdn that we should block if (obj_path_exists(r, 'an_numrrsets') and r.an_numrrsets > 1) and obj_path_exists(r, 'rrsets'): for i in range(r.an_numrrsets): rrset = r.rrsets[i] if obj_path_exists(rrset, 'rk') and obj_path_exists(rrset, 'entry.data'): rrset_key = rrset.rk data = rrset.entry.data if (obj_path_exists(rrset_key, 'type_str') and obj_path_exists(data, 'count')) and rrset_key.type_str == 'CNAME': # there might be multiple CNAMEs in the RRset for j in range(data.count): # temporarily change the queried domain name to the CNAME alias so we can apply our policy on it. # after we're done we change it back to the original query so as to not confuse users # looking at the logged queries. We do however change the type to CNAME if a match is found # to indicate that a CNAME was the reason for blocking this domain. tmp = query.domain query.domain = dns.name.from_wire(data.rr_data[j], 2)[0].to_text(omit_final_dot=True) match = mod_env['dnsbl'].policy_match(query, qstate) query.domain = tmp if match: # the iterator module has already resolved the answer and cached it, # make sure we remove it from the cache in order to block future queries for the same domain if obj_path_exists(qstate, 'return_msg.qinfo'): invalidateQueryInCache(qstate, qstate.return_msg.qinfo) query.type = 'CNAME' if not set_answer_block(qstate, qdata, query, match.get('bl')): qstate.ext_state[id] = MODULE_ERROR # block and exit on any match return True if logger.stats_enabled: query.set_response(ACTION_PASS, SOURCE_RECURSION, None, rcode, time_diff_ms(qdata['start_time']), dnssec, ttl) logger.log_entry(query) return True if event == MODULE_EVENT_PASS: qstate.ext_state[id] = MODULE_WAIT_MODULE return True log_err("dnsbl_module: bad event. Query was %s" % qstate.qinfo.qname_str) qstate.ext_state[id] = MODULE_ERROR return True def arg_parse_is_json_file(filename): try: json.load(open(filename)) except FileNotFoundError: raise argparse.ArgumentTypeError("non existing file") except json.JSONDecodeError: # in cases where a file exists, but we're unable to decode it (e.g. the file is empty), # we should assume the blocklist has no entries. pass except: raise argparse.ArgumentTypeError("No blocklist available") return filename try: import unboundmodule test_mode = False except ImportError: test_mode = True mod_env = {} if __name__ == '__main__' and test_mode: """ Command line blocklist test mode """ # override unbound log methods def log_info(str): return def log_err(str): return parser = argparse.ArgumentParser() parser.add_argument( '--src', help='client source address. Default 127.0.0.1', default='127.0.0.1' ) parser.add_argument('--domain', help='domain name to query', required=True) parser.add_argument( '--type', help='query type, e.g. AAAA. Default is A', default='A', choices=['A', 'AAAA', 'CNAME', 'HTTPS'] ) parser.add_argument( '--dnsbl_path', help='blocklist json input', default='/var/unbound/data/dnsbl.json', type=arg_parse_is_json_file ) inputargs = parser.parse_args() # create an empty global context mod_env['context'] = ModuleContext(None) dnsbl = DNSBL(dnsbl_path=inputargs.dnsbl_path, size_file='/dev/null') match = dnsbl.policy_match( Query( client=inputargs.src, family='ip6' if inputargs.src.count(':') else 'ip4', type=inputargs.type , domain=inputargs.domain ) ) if match: src_nets = match.get('source_net', []) for i in range(len(src_nets)): src_nets[i] = str(src_nets[i]) match['source_net'] = src_nets msg = {'status': 'OK','action': 'Block','policy': match} print(json.dumps(msg)) else: print(json.dumps({'status': 'OK','action': 'Pass'}))