Blob Blame History Raw
#!/usr/bin/python2
# -*- coding: utf-8 -*-
"""
@author: Tomas Hozza <thozza@redhat.com>
"""

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)