def main(): killer = Killer() args = _parse_argv() logger = get_logger("DNSWatch", log_level=args.loglevel, log_file=args.logfile) logger.info("Starting dnswatch v.{}.".format(__version__)) misc = Misc(logger) try: exit_code = 2 if not get_lock("dnswatch", timeout=5): misc.die("Lock exists") c = Config() action = None while True: config = c.read(args.config) dw = DNSWatch(config) if action == 'reload': dw.reload_config() else: dw.initial_config() try: action = dw.watch(pause=config["watch"]["pause"]) except: action = dw.watch() if action == "kill": # Do DNS cleanup and exit loop dw.cleanup() break elif action == "softkill": # Exit loop without DNS cleanup break elif action == "reload": # Do nothing pass else: misc.die("Unknown action requested: {}".format(action)) logger.info("Finished successfully.") exit_code = 0 except SystemExit: exit_code = sys.exc_info()[1] except: logger.error("Exiting with errors.") if args.trace: print(sys.exc_info()) trace = sys.exc_info()[2] traceback.print_tb(trace) exit_code = 1 finally: sys.exit(exit_code)
class DHClient: def __init__(self): self.logger = logging.getLogger("DNSWatch.DHClient") self.misc = Misc(self.logger) self.args = self._collect_args() self.config_files = ["/etc/dhcp/dhclient.conf"] self.config_updated = False def _collect_args(self): self.logger.debug("Looking for dhclient process arguments.") default_cmdline = ['dhclient', '-1', '-v', '-pf', '/run/dhclient.eth0.pid', '-lf', '/var/lib/dhcp/dhclient.eth0.leases', 'eth0'] for proc in psutil.process_iter(): if re.match("^dhclient\d*$", proc.name): self.logger.debug("dhclient cmdline: '{}'.".format( " ".join(proc.cmdline))) return proc.cmdline self.logger.warning( "dhclient process not found. Falling back to default: '{}'.".format( " ".join(default_cmdline))) self._request_lease(default_cmdline) return default_cmdline def set_nameserver(self, ns): self.logger.debug("Setting nameserver: {}.".format(ns)) if not self._set_option("supersede", "domain-name-servers", ", ".join(ns)): self.misc.die("Failed to set nameserver for dhclient") def get_nameserver(self): self.logger.debug("Getting nameserver from dhclient config.") return self._get_option("domain-name-servers", otype="supersede")[2] def set_search(self, domain): self.logger.debug("Setting search domain: {}.".format(domain)) if not self._set_option("prepend", "domain-name", '"{} "'.format(" ".join(domain))): self.misc.die("Failed to set search domain for dhclient") def renew_lease(self): self._release_lease(self.args) self._request_lease(self.args) self.config_updated = False def _release_lease(self, args_list): self.logger.debug("Releasing DHCP lease.") args = list(args_list) args.append("-r") FNULL = open(os.devnull, 'w') # If close_fds is true, all file descriptors except 0, 1 and 2 will be # closed before the child process is executed. (Unix only). check_call(args, stdout=FNULL, stderr=STDOUT, close_fds=True) def _request_lease(self, args_list): self.logger.debug("Requesting DHCP lease.") FNULL = open(os.devnull, 'w') # If close_fds is true, all file descriptors except 0, 1 and 2 will be # closed before the child process is executed. (Unix only). # # Here it's a must because in other case dhclient will inherit lock # file/socket. Which will prevent future starts. check_call(args_list, stdout=FNULL, stderr=STDOUT, close_fds=True) def _set_option(self, otype, option, value): if not otype in ["append", "prepend", "supersede"]: self.misc.die("Unknown dhclient option type: {}".format(otype)) new_line = "{} {} {};".format(otype, option, value) new_config = list() option_exist = False write_config = False config_file = self._get_config_file() config = self._read_config(config_file) for line in config: if re.match("^{}\s+{}\s+.*;$".format(otype, option), line): option_exist = True self.logger.debug("Option '{}' exist, checking value.".format(option)) if re.match("^{}\s+{}\s+{};$".format(otype, option, value), line): self.logger.debug("Value '{}' is the same, skipping.".format(value)) new_config.append(line) else: self.logger.debug("Values differ, updating to '{}'.".format(value)) write_config = True new_config.append(new_line) continue new_config.append(line) if not option_exist: write_config = True new_config.append(new_line) if write_config: if self._write_config(new_config, config_file): self.config_updated = True return True else: return False else: return True def _get_option(self, option, otype=".+"): config_file = self._get_config_file() config = self._read_config(config_file) return self._read_option(option, config, otype) def _read_option(self, option, config, otype): result = None for line in config: rm = re.match("^({})\s+{}\s+(.+);$".format(otype, option), line) if rm: otype = rm.group(1) value = [ e.strip() for e in rm.group(2).split(",")] result = [otype, option, value] return result def _get_config_file(self): for config_file in self.config_files: if os.path.isfile(config_file): return config_file def _read_config(self, config_file): config = list() full_line = "" with open(config_file, "r") as cf: for line in cf.readlines(): line = line.strip() if not re.match("^(.*#.*|)$", line): if len(full_line) > 0: line = full_line + line if line[-1] != ";": full_line = line else: config.append(line) full_line = "" return set(config) def _write_config(self, config, config_file): self._backup_config(config_file) self.logger.debug("Writing new config.") with open(config_file, "w") as cf: for line in config: cf.write("{}\n".format(line)) return True def _backup_config(self, config_file): backup_file = "{}.bak-{}".format(config_file, time.time()) self.logger.debug("Doing backup of {} to {}.".format(config_file, backup_file)) copyfile(config_file, backup_file) return True
class BindProvider: def __init__(self, config): self.logger = logging.getLogger("DNSWatch.BindProvider") self.misc = Misc(self.logger) self.dhcl = DHClient() self.dnso = DNSOps(config["dnsupdate"]) self.zone = config["dnsupdate"]["zone"] self.fqdn = config["host"]["fqdn"] self.private_ip = config["host"]["private_ip"] self.public_ip = config["host"]["public_ip"] self.alias_dict = config["dnsupdate"]["alias"] self.aliases = None def initial_config(self): """To do on start""" self._initial_config_wo_resolvers() if len(self.slaves["private"]) > 0: self._setup_resolver( self.slaves["private"], [self.zone]) else: self.misc.die("No private DNS slaves found: {}.".format(self.slaves)) def reload_config(self): """To do on reload""" self._initial_config_wo_resolvers() def _initial_config_wo_resolvers(self): self.dnso.setup_key() self.masters = self.dnso.get_masters() self.aliases = Provider()._look_for_alias(self.fqdn, self.zone, self.alias_dict) if not self._update_records(self.masters['private'], self.private_ip, ptr=True): self.misc.die("DNS update of PRIVATE view failed on all masters: {}".format(self.masters['private'])) if not self._update_records(self.masters['public'],self.public_ip, ptr=False): self.misc.die("DNS update of PUBLIC view failed on all masters: {}".format(self.masters['public'])) self.slaves = self.dnso.get_slaves(self.masters) def watch(self): """Some periodic actions""" # Check if masters changed new_masters = self.dnso.get_masters() if (self._list_changed(self.masters["private"], new_masters["private"]) or self._list_changed(self.masters["public"], new_masters["public"])): self.logger.warning("Masters list changed.") self.initial_config() else: # Check if slaves list was changed new_slaves = self.dnso.get_slaves(self.masters) if len(new_slaves["private"]) > 0: old_slaves = self.dhcl.get_nameserver() if self._list_changed(old_slaves, new_slaves["private"]): self.logger.warning("Slaves list changed.") self.slaves = dict(new_slaves) self._setup_resolver(self.slaves["private"], [self.zone]) else: self.logger.error("No private DNS slaves found: {}.".format(new_slaves)) def cleanup(self): """To do on shutdown""" # Delete aliases if any if self.aliases: for alias in self.aliases: self.dnso.delete_alias(self.masters["private"][0], alias, self.fqdn) self.dnso.delete_alias(self.masters["public"][0], alias, self.fqdn) # Delete hosts' records self.dnso.delete_host( self.masters["private"][0], self.fqdn, self.private_ip, ptr=True) self.dnso.delete_host( self.masters["public"][0], self.fqdn, self.public_ip) def _update_records(self, masters, ip, ptr): """Try update on any master""" for master in masters: self.logger.debug("Trying update at master: {}.".format(master)) try: self.dnso.update_host(master, self.fqdn, ip, ptr=ptr) # Add aliases if any if self.aliases: for alias in self.aliases: self.dnso.update_alias(master, alias, self.fqdn) return True except: continue return False def _setup_resolver(self, servers, domain): self.logger.info( "Configuring local resolver with: NS={}; domain={}.".format( servers, domain)) self.dhcl.set_nameserver(servers) self.dhcl.set_search(domain) if self.dhcl.config_updated: self.dhcl.renew_lease() def _list_changed(self, first, second): self.logger.debug("Comparing lists: {} vs {}.".format(first, second)) if len(first) != len(second): return True else: for i, element in enumerate(first): if element != second[i]: return True return False
class Route53: def __init__(self, config, sync=True): self.logger = logging.getLogger("DNSWatch.Route53") self.misc = Misc(self.logger) self.config = config self.sync = sync self.unchecked_requests = list() self.client = boto3.client( "route53", aws_access_key_id=config["update_key"]["name"], aws_secret_access_key=config["update_key"]["key"]) def update_host(self, host, ip, ptr=False): result = True return result def get_zones(self): self.logger.debug("Getting hosted DNS zones.") zones = dict() response = self.client.list_hosted_zones() if not response["IsTruncated"]: zones_info = response["HostedZones"] else: self.misc.die(("Truncated aswers are not supported yet")) for zone in zones_info: zone_id = self._extract_id(zone["Id"]) zones[zone_id] = { "Name": zone["Name"], "Private": zone["Config"]["PrivateZone"] } return zones def add_host(self, zone_id, hostname, ip, ptr=False): self._operate_record("create", zone_id, hostname, "A", ip) def delete_host(self, zone_id, hostname, ip, ptr=False): self._operate_record("delete", zone_id, hostname, "A", ip) def update_host(self, zone_id, hostname, ip, ptr=False): self._operate_record("upsert", zone_id, hostname, "A", ip) def add_ptr(self, zone_id, ptr_name, hostname): self._operate_record("create", zone_id, ptr_name, "PTR", self._ensure_fqdn(hostname)) def delete_ptr(self, zone_id, ptr_name, hostname): self._operate_record("delete", zone_id, ptr_name, "PTR", self._ensure_fqdn(hostname)) def update_ptr(self, zone_id, ptr_name, hostname): self._operate_record("upsert", zone_id, ptr_name, "PTR", self._ensure_fqdn(hostname)) def add_alias(self, zone_id, cname, hostname): self._operate_record("create", zone_id, cname, "CNAME", self._ensure_fqdn(hostname)) def delete_alias(self, zone_id, cname, hostname): self._operate_record("delete", zone_id, cname, "CNAME", self._ensure_fqdn(hostname)) def update_alias(self, zone_id, cname, hostname): self._operate_record("upsert", zone_id, cname, "CNAME", self._ensure_fqdn(hostname)) def _operate_record(self, action, zone_id, rdname, rdtype, data): action = action.upper() if not action in ["CREATE", "DELETE", "UPSERT"]: self.misc.die("{} with DNS record isn't supported".format(action)) self.logger.debug("Requesting {} of '{}':'{}' record at {} with data '{}'.".format( action, rdtype, rdname, zone_id, data)) response = self.client.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ "Comment": "made by dnswatch", "Changes": [ { "Action": action, "ResourceRecordSet": { "Name": rdname, "Type": rdtype, "TTL": self.config["ttl"], "ResourceRecords": [ { "Value": data }, ] }, }, ], } ) request_id = self._extract_id(response["ChangeInfo"]["Id"]) self.logger.debug("Request sent: %s." % request_id) if self.sync: self._wait_request(request_id) else: self.unchecked_requests.append(request_id) def check_request_status(self, request_id=None): if request_id: self._wait_request(request_id) self.unchecked_requests(request_id) else: for request_id in self.unchecked_requests: self._wait_request(request_id) self.unchecked_requests.remove(request_id) def _ensure_fqdn(self, name): """Make a proper FQDN from name""" if name[-1:] != ".": return "%s." % name else: return name def _extract_id(self, dirty_id): """Delete /prefix from Id returned by Amazon API""" if dirty_id[:1] == "/": return dirty_id.split("/")[-1] else: return dirty_id def _wait_request(self, request_id): self.logger.debug("Checking request: %s." % request_id) waiter = self.client.get_waiter('resource_record_sets_changed') try: waiter.wait(Id=request_id) self.logger.debug("Request completed: %s." % request_id) return True except: self.logger.error("Request failed: %s." % request_id) return False
class DNSOps: def __init__(self, config): self.logger = logging.getLogger("DNSWatch.DNSOps") self.misc = Misc(self.logger) self.config = config self.keyring = None self.key_algorithm = None def setup_key(self): update_key = self.config["update_key"] name = update_key["name"] key = update_key["key"] algorithm = update_key["algorithm"] self.logger.debug( "Creating keyring for domain '{}' with key '{}...'.".format( name, key[:10])) self.keyring = dns.tsigkeyring.from_text({name: key}) self.logger.debug("Setting key algorithm to '{}'.".format(algorithm)) self.key_algorithm = getattr(dns.tsig, algorithm) def get_masters(self): zone = self.config["zone"] self.logger.debug("Getting DNS masters for zone {}.".format(zone)) masters = dict() for mtype in ["private", "public"]: try: record = "dns-master-{}.{}".format(mtype, zone) self.logger.debug("Looking for TXT record {}.".format(record)) answer = self._query(record, "TXT") except dns.resolver.NXDOMAIN: upper_zone = zone.split(".", 1)[1] record = "dns-master-{}.{}".format(mtype, upper_zone) self.logger.debug( "Failed. Checking upper zone {}.".format(upper_zone)) answer = self._query(record, "TXT") self.logger.debug("Got {} masters: {}.".format(mtype, answer)) masters[mtype] = answer self.logger.debug("Masters: {}.".format(masters)) return masters def get_slaves(self, masters): zone = self.config["zone"] self.logger.debug("Getting DNS slaves for zone {}.".format(zone)) slaves = dict() for stype in masters.keys(): record = "dns-slave.{}".format(zone) self.logger.debug("Looking for TXT record {} at {}.".format( record, masters[stype])) answer = self._query( "dns-slave.{}".format(zone), "TXT", masters[stype]) slaves[stype] = answer self.logger.debug("Slaves: {}.".format(slaves)) return slaves def add_host(self, dnsserver, host, ip, ptr=False): self._operate_record("add", dnsserver, host, "A", ip) if ptr: self.add_ptr(dnsserver, host, ip) def delete_host(self, dnsserver, host, ip, ptr=False): self._operate_record("delete", dnsserver, host, "A", ip) if ptr: self.delete_ptr(dnsserver, host, ip) def update_host(self, dnsserver, host, ip, ptr=False): self._operate_record("replace", dnsserver, host, "A", ip) if ptr: self.update_ptr(dnsserver, host, ip) def add_ptr(self, dnsserver, host, ip): ptr_record = dns.reversename.from_address(ip) self._operate_record("add", dnsserver, ptr_record, "PTR", self._ensure_fqdn(host)) def delete_ptr(self, dnsserver, host, ip): ptr_record = dns.reversename.from_address(ip) self._operate_record("delete", dnsserver, ptr_record, "PTR", self._ensure_fqdn(host)) def update_ptr(self, dnsserver, host, ip): ptr_record = dns.reversename.from_address(ip) self._operate_record("replace", dnsserver, ptr_record, "PTR", self._ensure_fqdn(host)) def add_alias(self, dnsserver, cname, hostname): self._operate_record("add", dnsserver, cname, "CNAME", self._ensure_fqdn(hostname)) def delete_alias(self, dnsserver, cname, hostname): self._operate_record("delete", dnsserver, cname, "CNAME", self._ensure_fqdn(hostname)) def update_alias(self, dnsserver, cname, hostname): self._operate_record("replace", dnsserver, cname, "CNAME", self._ensure_fqdn(hostname)) def _operate_record(self, action, dnsserver, rdname, rdtype, data): if not action in ["add", "delete", "replace"]: self.misc.die("{} with DNS record isn't supported".format(action)) if not self.keyring: self.misc.die("Keyring for DNS action not found") if not self.key_algorithm: self.misc.die("Key algorithm for DNS action not specified") self.logger.debug("Doing {} of '{}':'{}' record at {} with data '{}'.".format( action, rdtype, rdname, dnsserver, data)) # Adjusting variables if rdtype == "PTR": rdname, origin = str(rdname).split(".", 1) else: origin = dns.name.from_text(self.config["zone"]) rdname = dns.name.from_text(rdname) - origin data = data.encode("utf-8") # Collecting arguments for DNS update args = list() if action in ["add", "replace"]: args.append(self.config["ttl"]) args.append(rdtype) if action in ["add", "replace"]: args.append(data) # Doing DNS update update = dns.update.Update( origin, keyring=self.keyring, keyalgorithm=self.key_algorithm) eval('update.{}(rdname, *args)'.format(action)) result = dns.query.tcp(update, dnsserver, timeout=self.config["timeout"]) rcode = self._compile_rcode(result) if rcode[0] != 0: self.misc.die("DNS update failed: rcode={}; message='{}'".format(rcode[0], rcode[1])) else: self.logger.debug("DNS update done: rcode={}; message='{}'.".format(rcode[0], rcode[1])) def _compile_rcode(self, message): text = str() code = message.rcode() for line in message.to_text().splitlines(): match = re.match("^rcode\s(.+)$", line) if match: text = match.group(1) return [ code, text ] def _query(self, name, rtype="A", nameservers=None): result = list() resolver = dns.resolver.Resolver() if nameservers: resolver.nameservers = nameservers answers = list() try: answers = resolver.query(name, rtype) except dns.exception.Timeout: self.logger.error( "Timeout reached while getting {} record {} from {}.".format( rtype, name, nameservers)) for answer in answers: if rtype == "TXT": for line in answer.strings: line = line.replace('"', "") result.extend(line.split(",")) else: result.append(answer.address) return result def _ensure_fqdn(self, name): """Make a proper FQDN from name""" if name[-1:] != ".": return "%s." % name else: return name