#!/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)