#!/usr/bin/python2 # -*- coding: utf-8 -*- """ @author: Tomas Hozza """ from gi.repository import NMClient import socket import struct import subprocess import os import os.path import syslog import sys # DO NOT CHANGE THE VALUE HERE, CHANGE IT IN **DNSSEC_CONF** file DEFAULT_VALIDATE_FORWARD_ZONES = True DEFAULT_ADD_WIFI_PROVIDED_ZONES = False STATE_DIR = "/var/run/dnssec-trigger" DNSSEC_CONF = "/etc/dnssec.conf" UNBOUND = "/usr/sbin/unbound" UNBOUND_CONTROL = "/usr/sbin/unbound-control" DNSSEC_TRIGGER = "/usr/sbin/dnssec-triggerd" DNSSEC_TRIGGER_CONTROL = "/usr/sbin/dnssec-trigger-control" PIDOF = "/usr/sbin/pidof" class FZonesConfig: """ Class representing dnssec-trigger script forward zones behaviour configuration. """ def __init__(self): self.validate_fzones = DEFAULT_VALIDATE_FORWARD_ZONES self.add_wifi_zones = DEFAULT_ADD_WIFI_PROVIDED_ZONES class ActiveConnection: """ Simple class representing NM Active Connection with information relevant for this script. """ TYPE_WIFI = "WIFI" TYPE_VPN = "VPN" TYPE_OTHER = "OTHER" def __init__(self): self.type = self.TYPE_OTHER self.is_default = False self.nameservers = [] self.domains = [] self.uuid = "" pass def __str__(self): string = "UUID: " + self.get_uuid() + "\n" string += "TYPE: " + str(self.get_type()) + "\n" string += "DEFAULT: " + str(self.get_is_default()) + "\n" string += "NS: " + str(self.get_nameservers()) + "\n" string += "DOMAINS: " + str(self.get_domains()) return string def get_uuid(self): return self.uuid def get_type(self): return self.type def get_is_default(self): return self.is_default def get_nameservers(self): return self.nameservers def get_domains(self): return self.domains def set_uuid(self, uuid=""): self.uuid = uuid def set_type(self, conn_type=TYPE_OTHER): if conn_type == self.TYPE_VPN: self.type = self.TYPE_VPN elif conn_type == self.TYPE_WIFI: self.type = self.TYPE_WIFI else: self.type = self.TYPE_OTHER def set_is_default(self, is_default=True): self.is_default = is_default def set_nameservers(self, servers=[]): self.nameservers = servers def set_domains(self, domains=[]): self.domains = domains def ip4_to_str(ip4): """ Converts IPv4 address from integer to string. """ return socket.inet_ntop(socket.AF_INET, struct.pack("=I", ip4)) def ip6_to_str(ip6): """ Converts IPv6 address from integer to string. """ addr_struct = ip6 return socket.inet_ntop(socket.AF_INET6, addr_struct) def get_fzones_settings_from_conf(conf_file=""): """ Reads the forward zones behaviour config from file. """ config = FZonesConfig() try: with open(conf_file, "r") as f: lines = [l.strip() for l in f.readlines() if l.strip() and not l.strip().startswith("#")] for line in lines: option_line = line.split("=") if option_line: if option_line[0].strip() == "validate_connection_provided_zones": if option_line[1].strip() == "yes": config.validate_fzones = True else: config.validate_fzones = False elif option_line[0].strip() == "add_wifi_provided_zones": if option_line[1].strip() == "yes": config.add_wifi_zones = True else: config.add_wifi_zones = False except IOError: # we don't mind if the config file does not exist pass return config def get_nm_active_connections(): """ Process Active Connections from NM and return list of ActiveConnection objects. Active Connections from NM without nameservers are ignored. """ result = [] client = NMClient.Client() ac = client.get_active_connections() for connection in ac: new_connection = ActiveConnection() # get the UUID new_connection.set_uuid(connection.get_uuid()) # Find out if the ActiveConnection is VPN, WIFI or OTHER try: connection.get_vpn_state() except AttributeError: # We don't need to change anything pass else: new_connection.set_type(ActiveConnection.TYPE_VPN) # if the connection is NOT VPN, then check if it's WIFI if new_connection.get_type() != ActiveConnection.TYPE_VPN: try: device_type = connection.get_devices()[ 0].get_device_type().value_name except IndexError: # if there is no device for a connection, the connection # is going down so ignore it... continue except AttributeError: # We don't need to change anything pass else: if device_type == "NM_DEVICE_TYPE_WIFI": new_connection.set_type(ActiveConnection.TYPE_WIFI) # Finc out if default connection for IP4 or IP6 if connection.get_default() or connection.get_default6(): new_connection.set_is_default(True) else: new_connection.set_is_default(False) # Get nameservers (IP4 + IP6) ips = [] try: ips4_int = connection.get_ip4_config().get_nameservers() except AttributeError: # we don't mind if there are no IP4 nameservers pass else: for ip4 in ips4_int: ips.append(ip4_to_str(ip4)) try: num = connection.get_ip6_config().get_num_nameservers() for i in range(0,num): ips.append(ip6_to_str(connection.get_ip6_config().get_nameserver(i))) except AttributeError: # we don't mind if there are no IP6 nameservers pass new_connection.set_nameservers(ips) # Get domains (IP4 + IP6) domains = [] try: domains.extend(connection.get_ip4_config().get_domains()) except AttributeError: # we don't mind if there are no IP6 domains pass try: domains.extend(connection.get_ip6_config().get_domains()) except AttributeError: # we don't mind if there are no IP6 domains pass new_connection.set_domains(domains) # If there are no nameservers in the connection, it is useless if new_connection.get_nameservers(): result.append(new_connection) return result def is_running(binary=""): """ Checks if the given binary is running. """ if binary: sp = subprocess.Popen(PIDOF + " " + binary, stdout=subprocess.PIPE, stderr=open(os.devnull, "wb"), shell=True) sp.wait() if sp.returncode == 0: # pidof returns "0" if at least one program with the name runs return True return False def dnssec_trigger_set_global_ns(servers=[]): """ Configures global nameservers into dnssec-trigger. """ if servers: servers_list = " ".join(servers) ret = subprocess.call( DNSSEC_TRIGGER_CONTROL + " submit " + servers_list, stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) if ret == 0: syslog.syslog( syslog.LOG_INFO, "Global forwarders added: " + servers_list) else: syslog.syslog( syslog.LOG_ERR, "Global forwarders NOT added: " + servers_list) def unbound_add_forward_zone(domain="", servers=[], secure=DEFAULT_VALIDATE_FORWARD_ZONES): """ Adds a forward zone into the unbound. """ if domain and servers: servers_list = " ".join(servers) # build the command cmd = UNBOUND_CONTROL + " forward_add" if not secure: cmd += " +i" cmd += " " + domain + " " + servers_list # Add the forward zone ret = subprocess.call(cmd, stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) # Flush cache subprocess.call(UNBOUND_CONTROL + " flush_zone " + domain, stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) subprocess.call(UNBOUND_CONTROL + " flush_requestlist", stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) if secure: validated = "(DNSSEC validated)" else: validated = "(*NOT* DNSSEC validated)" if ret == 0: syslog.syslog( syslog.LOG_INFO, "Added " + validated + " connection provided forward zone '" + domain + "' with NS: " + servers_list) else: syslog.syslog( syslog.LOG_ERR, "NOT added connection provided forward zone '" + domain + "' with NS: " + servers_list) def unbound_del_forward_zone(domain="", secure=DEFAULT_VALIDATE_FORWARD_ZONES): """ Deletes a forward zone from the unbound. """ if domain: cmd = UNBOUND_CONTROL + " forward_remove" if not secure: cmd += " +i" cmd += " " + domain # Remove the forward zone ret = subprocess.call(cmd, stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) # Flush cache subprocess.call(UNBOUND_CONTROL + " flush_zone " + domain, stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) subprocess.call(UNBOUND_CONTROL + " flush_requestlist", stdout=open(os.devnull, "wb"), stderr=subprocess.STDOUT, shell=True) if ret == 0: syslog.syslog( syslog.LOG_INFO, "Removed connection provided forward zone '" + domain + "'") else: syslog.syslog( syslog.LOG_ERR, "NOT removed connection provided forward zone '" + domain + "'") def unbound_get_forward_zones(): """ Returns list of currently configured forward zones from the unbound. """ zones = [] # get all configured forward zones sp = subprocess.Popen(UNBOUND_CONTROL + " list_forwards", stdout=subprocess.PIPE, stderr=open(os.devnull, "wb"), shell=True) sp.wait() if sp.returncode == 0: for line in sp.stdout.readlines(): zones.append(line.strip().split(" ")[0][:-1]) return zones ############################################################################## def append_fzone_to_file(uuid="", zone=""): """ Append forward zones from connection with UUID to the disk file. """ if uuid and zone: with open(os.path.join(STATE_DIR, uuid), "a") as f: f.write(zone + "\n") def write_fzones_to_file(uuid="", zones=[]): """ Write forward zones from connection with UUID to the disk file. """ if uuid and zones: with open(os.path.join(STATE_DIR, uuid), "w") as f: for zone in zones: f.write(zone + "\n") def get_fzones_from_file(uuid=""): """ Gets all zones from a file with specified UUID name din STATE_DIR """ zones = [] if uuid: with open(os.path.join(STATE_DIR, uuid), "r") as f: zones = [line.strip() for line in f.readlines()] return zones def get_fzones_from_disk(): """ Gets all forward zones from the disk STATE_DIR. Return a dict of "zone" : "connection UUID" """ zones = {} conn_files = os.listdir(STATE_DIR) for uuid in conn_files: for zone in get_fzones_from_file(uuid): zones[zone] = uuid return zones def del_all_fzones_from_file(uuid="", secure=DEFAULT_VALIDATE_FORWARD_ZONES): """ Removes all forward zones contained in file with UUID name in STATE_DIR. """ if uuid: with open(os.path.join(STATE_DIR, uuid), "r") as f: for line in f.readlines(): unbound_del_forward_zone(line.strip(), secure) def del_fzones_for_nonexisting_conn(ac=[], secure=DEFAULT_VALIDATE_FORWARD_ZONES): """ Removes all forward zones contained in file (in STATE_DIR) for non-existing active connections. """ ac_uuid_list = [conn.get_uuid() for conn in ac] conn_files = os.listdir(STATE_DIR) # Remove all non-existing connections zones for uuid in conn_files: if uuid not in ac_uuid_list: # remove all zones from the file del_all_fzones_from_file(uuid, secure) # remove the file os.unlink(os.path.join(STATE_DIR, uuid)) def del_fzone_from_file(uuid="", zone=""): """ Deletes a zone from file and writes changes into it. If there are no zones left, the file is deleted. """ if uuid and zone: zones = get_fzones_from_file(uuid) zones.remove(zone) if zones: write_fzones_to_file(uuid, zones) else: os.unlink(os.path.join(STATE_DIR, uuid)) ############################################################################## def configure_global_forwarders(active_connections=[]): """ Configure global forwarders using dnssec-trigger-control """ # get only default connections default_conns = filter(lambda x: x.get_is_default(), active_connections) # get forwarders from default connections default_forwarders = [] for conn in default_conns: default_forwarders.extend(conn.get_nameservers()) if default_forwarders: dnssec_trigger_set_global_ns(default_forwarders) ############################################################################## def configure_forward_zones(active_connections=[], fzones_config=None): """ Configures forward zones in the unbound using unbound-control. """ # Filter out WIFI connections if desirable if not fzones_config.add_wifi_zones: connections = filter( lambda x: x.get_type() != ActiveConnection.TYPE_WIFI, active_connections) else: connections = active_connections # If validate forward zones secure = fzones_config.validate_fzones # Filter active connections with domain(s) conns_with_domains = filter(lambda x: x.get_domains(), connections) fzones_from_ac = {} # Construct dict of domain -> active connection for conn in conns_with_domains: # iterate through all domains in the active connection for domain in conn.get_domains(): # if there is already such a domain if domain in fzones_from_ac: # if the "conn" is VPN and the conn for existing domain is not if fzones_from_ac[domain].get_type() != ActiveConnection.TYPE_VPN and conn.get_type() == ActiveConnection.TYPE_VPN: fzones_from_ac[domain] = conn # if none of there connections are VPNs or both are VPNs, # prefer the default one elif not fzones_from_ac[domain].get_is_default() and conn.get_is_default(): fzones_from_ac[domain] = conn else: fzones_from_ac[domain] = conn # Remove all zones which connection UUID does not match any existing AC del_fzones_for_nonexisting_conn(conns_with_domains, secure) # Remove all zones which connection UUID is different than the current AC # UUID for the zone fzones_from_disk = get_fzones_from_disk() for zone, uuid in fzones_from_disk.iteritems(): connection = fzones_from_ac[zone] # if the AC UUID is NOT the same as from the disk, remove the zone if connection.get_uuid() != uuid: unbound_del_forward_zone(zone, secure) del_fzone_from_file(uuid, zone) # get zones from unbound and delete them from fzones_from_ac # there may be zones manually configured in unbound.conf and we # don't want to replace them unbound_zones = unbound_get_forward_zones() for zone in unbound_zones: try: del fzones_from_ac[zone] except KeyError: # we don't mind if there is no such zone pass # Add forward zones that are not already configured fzones_from_disk = get_fzones_from_disk() for zone, connection in fzones_from_ac.iteritems(): if zone not in fzones_from_disk: unbound_add_forward_zone( zone, connection.get_nameservers(), secure) append_fzone_to_file(connection.get_uuid(), zone) ############################################################################## if __name__ == "__main__": if not is_running(DNSSEC_TRIGGER): syslog.syslog(syslog.LOG_ERR, "dnssec-triggerd daemon is not running!") sys.exit(1) if not is_running(UNBOUND): syslog.syslog(syslog.LOG_ERR, "unbound server daemon is not running!") sys.exit(1) fzones_config = get_fzones_settings_from_conf(DNSSEC_CONF) # Get all actove connections from NM ac = get_nm_active_connections() # Configure global forwarders configure_global_forwarders(ac) # Configure forward zones configure_forward_zones(ac, fzones_config)