Blame SOURCES/ntp2chrony.py

8a7337
#!/usr/bin/python
8a7337
#
8a7337
# Convert ntp configuration to chrony
8a7337
#
8a7337
# Copyright (C) 2018-2019  Miroslav Lichvar <mlichvar@redhat.com>
8a7337
#
8a7337
# Permission is hereby granted, free of charge, to any person obtaining
8a7337
# a copy of this software and associated documentation files (the
8a7337
# "Software"), to deal in the Software without restriction, including
8a7337
# without limitation the rights to use, copy, modify, merge, publish,
8a7337
# distribute, sublicense, and/or sell copies of the Software, and to
8a7337
# permit persons to whom the Software is furnished to do so, subject to
8a7337
# the following conditions:
8a7337
#
8a7337
# The above copyright notice and this permission notice shall be included
8a7337
# in all copies or substantial portions of the Software.
8a7337
#
8a7337
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
8a7337
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
8a7337
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
8a7337
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
8a7337
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
8a7337
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
8a7337
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8a7337
8a7337
8a7337
import argparse
8a7337
import ipaddress
8a7337
import logging
8a7337
import os
8a7337
import os.path
8a7337
import re
8a7337
import subprocess
8a7337
import sys
8a7337
8a7337
# python2 compatibility hacks
8a7337
if sys.version_info[0] < 3:
8a7337
    from io import open
8a7337
    reload(sys)
8a7337
    sys.setdefaultencoding("utf-8")
8a7337
8a7337
class NtpConfiguration(object):
8a7337
    def __init__(self, root_dir, ntp_conf, step_tickers):
8a7337
        self.root_dir = root_dir if root_dir != "/" else ""
8a7337
        self.ntp_conf_path = ntp_conf
8a7337
        self.step_tickers_path = step_tickers
8a7337
8a7337
        # Read and write files using an 8-bit transparent encoding
8a7337
        self.file_encoding = "latin-1"
8a7337
        self.enabled_services = set()
8a7337
        self.step_tickers = []
8a7337
        self.time_sources = []
8a7337
        self.fudges = {}
8a7337
        self.restrictions = {
8a7337
                # Built-in defaults
8a7337
                ipaddress.ip_network(u"0.0.0.0/0"): set(),
8a7337
                ipaddress.ip_network(u"::/0"): set(),
8a7337
        }
8a7337
        self.keyfile = ""
8a7337
        self.keys = []
8a7337
        self.trusted_keys = []
8a7337
        self.driftfile = ""
8a7337
        self.statistics = []
8a7337
        self.leapfile = ""
8a7337
        self.tos_options = {}
8a7337
        self.ignored_directives = set()
8a7337
        self.ignored_lines = []
8a7337
8a7337
        #self.detect_enabled_services()
8a7337
        self.parse_step_tickers()
8a7337
        self.parse_ntp_conf()
8a7337
8a7337
    def detect_enabled_services(self):
8a7337
        for service in ["ntpdate", "ntpd", "ntp-wait"]:
8a7337
            if os.path.islink("{}/etc/systemd/system/multi-user.target.wants/{}.service"
8a7337
                    .format(self.root_dir, service)):
8a7337
                self.enabled_services.add(service)
8a7337
        logging.info("Enabled services found in /etc/systemd/system: %s",
8a7337
                     " ".join(self.enabled_services))
8a7337
8a7337
    def parse_step_tickers(self):
8a7337
        if not self.step_tickers_path:
8a7337
            return
8a7337
8a7337
        path = os.path.join(self.root_dir, self.step_tickers_path)
8a7337
        if not os.path.isfile(path):
8a7337
            logging.info("Missing %s", path)
8a7337
            return
8a7337
8a7337
        with open(path, encoding=self.file_encoding) as f:
8a7337
            for line in f:
8a7337
                line = line[:line.find('#')]
8a7337
8a7337
                words = line.split()
8a7337
8a7337
                if not words:
8a7337
                    continue
8a7337
8a7337
                self.step_tickers.extend(words)
8a7337
8a7337
    def parse_ntp_conf(self, path=None):
8a7337
        if path is None:
8a7337
            path = os.path.join(self.root_dir, self.ntp_conf_path)
8a7337
8a7337
        with open(path, encoding=self.file_encoding) as f:
8a7337
            logging.info("Reading %s", path)
8a7337
8a7337
            for line in f:
8a7337
                line = line[:line.find('#')]
8a7337
8a7337
                words = line.split()
8a7337
8a7337
                if not words:
8a7337
                    continue
8a7337
8a7337
                if not self.parse_directive(words):
8a7337
                    self.ignored_lines.append(line)
8a7337
8a7337
    def parse_directive(self, words):
8a7337
        name = words.pop(0)
8a7337
        if name.startswith("logconfig"):
8a7337
            name = "logconfig"
8a7337
8a7337
        if words:
8a7337
            if name in ["server", "peer", "pool"]:
8a7337
                return self.parse_source(name, words)
8a7337
            elif name == "fudge":
8a7337
                return self.parse_fudge(words)
8a7337
            elif name == "restrict":
8a7337
                return self.parse_restrict(words)
8a7337
            elif name == "tos":
8a7337
                return self.parse_tos(words)
8a7337
            elif name == "includefile":
8a7337
                return self.parse_includefile(words)
8a7337
            elif name == "keys":
8a7337
                return self.parse_keys(words)
8a7337
            elif name == "trustedkey":
8a7337
                return self.parse_trustedkey(words)
8a7337
            elif name == "driftfile":
8a7337
                self.driftfile = words[0]
8a7337
            elif name == "statistics":
8a7337
                self.statistics = words
8a7337
            elif name == "leapfile":
8a7337
                self.leapfile = words[0]
8a7337
            else:
8a7337
                self.ignored_directives.add(name)
8a7337
                return False
8a7337
        else:
8a7337
            self.ignored_directives.add(name)
8a7337
            return False
8a7337
8a7337
        return True
8a7337
8a7337
    def parse_source(self, source_type, words):
8a7337
        ipv4_only = False
8a7337
        ipv6_only = False
8a7337
        source = {
8a7337
                "type": source_type,
8a7337
                "options": []
8a7337
        }
8a7337
8a7337
        if words[0] == "-4":
8a7337
            ipv4_only = True
8a7337
            words.pop(0)
8a7337
        elif words[0] == "-6":
8a7337
            ipv6_only = True
8a7337
            words.pop(0)
8a7337
8a7337
        if not words:
8a7337
            return False
8a7337
8a7337
        source["address"] = words.pop(0)
8a7337
8a7337
        # Check if -4/-6 corresponds to the address and ignore hostnames
8a7337
        if ipv4_only or ipv6_only:
8a7337
            try:
8a7337
                version = ipaddress.ip_address(source["address"]).version
8a7337
                if (ipv4_only and version != 4) or (ipv6_only and version != 6):
8a7337
                    return False
8a7337
            except ValueError:
8a7337
                return False
8a7337
8a7337
        if source["address"].startswith("127.127."):
8a7337
            if not source["address"].startswith("127.127.1."):
8a7337
                # Ignore non-LOCAL refclocks
8a7337
                return False
8a7337
8a7337
        while words:
8a7337
            if len(words) >= 2 and words[0] in ["minpoll", "maxpoll", "version", "key"]:
8a7337
                source["options"].append((words[0], words[1]))
8a7337
                words = words[2:]
8a7337
            elif words[0] in ["burst", "iburst", "noselect", "prefer", "true", "xleave"]:
8a7337
                source["options"].append((words[0],))
8a7337
                words.pop(0)
8a7337
            else:
8a7337
                return False
8a7337
8a7337
        self.time_sources.append(source)
8a7337
        return True
8a7337
8a7337
    def parse_fudge(self, words):
8a7337
        address = words.pop(0)
8a7337
        options = {}
8a7337
8a7337
        while words:
8a7337
            if len(words) >= 2 and words[0] in ["stratum"]:
8a7337
                if not words[1].isdigit():
8a7337
                    return False
8a7337
                options[words[0]] = int(words[1])
8a7337
                words = words[2:]
8a7337
            elif len(words) >= 2:
8a7337
                words = words[2:]
8a7337
            else:
8a7337
                return False
8a7337
8a7337
        self.fudges[address] = options
8a7337
        return True
8a7337
8a7337
    def parse_restrict(self, words):
8a7337
        ipv4_only = False
8a7337
        ipv6_only = False
8a7337
        flags = set()
8a7337
        mask = ""
8a7337
8a7337
        if words[0] == "-4":
8a7337
            ipv4_only = True
8a7337
            words.pop(0)
8a7337
        elif words[0] == "-6":
8a7337
            ipv6_only = True
8a7337
            words.pop(0)
8a7337
8a7337
        if not words:
8a7337
            return False
8a7337
8a7337
        address = words.pop(0)
8a7337
8a7337
        while words:
8a7337
            if len(words) >= 2 and words[0] == "mask":
8a7337
                mask = words[1]
8a7337
                words = words[2:]
8a7337
            else:
8a7337
                if words[0] not in ["kod", "nomodify", "notrap", "nopeer", "noquery",
8a7337
                                    "limited", "ignore", "noserve"]:
8a7337
                    return False
8a7337
                flags.add(words[0])
8a7337
                words.pop(0)
8a7337
8a7337
        # Convert to IP network(s), ignoring restrictions with hostnames
8a7337
        networks = []
8a7337
        if address == "default" and not mask:
8a7337
            if not ipv6_only:
8a7337
                networks.append(ipaddress.ip_network(u"0.0.0.0/0"))
8a7337
            if not ipv4_only:
8a7337
                networks.append(ipaddress.ip_network(u"::/0"))
8a7337
        else:
8a7337
            try:
8a7337
                if mask:
8a7337
                    networks.append(ipaddress.ip_network(u"{}/{}".format(address, mask)))
8a7337
                else:
8a7337
                    networks.append(ipaddress.ip_network(address))
8a7337
            except ValueError:
8a7337
                return False
8a7337
8a7337
            if (ipv4_only and networks[-1].version != 4) or \
8a7337
                    (ipv6_only and networks[-1].version != 6):
8a7337
                return False
8a7337
8a7337
        for network in networks:
8a7337
            self.restrictions[network] = flags
8a7337
8a7337
        return True
8a7337
8a7337
    def parse_tos(self, words):
8a7337
        options = {}
8a7337
        while words:
8a7337
            if len(words) >= 2 and words[0] in ["minsane", "orphan"]:
8a7337
                if not words[1].isdigit():
8a7337
                    return False
8a7337
                options[words[0]] = int(words[1])
8a7337
                words = words[2:]
8a7337
            elif len(words) >= 2 and words[0] in ["maxdist"]:
8a7337
                # Check if it is a float value
8a7337
                if not words[1].replace('.', '', 1).isdigit():
8a7337
                    return False
8a7337
                options[words[0]] = float(words[1])
8a7337
                words = words[2:]
8a7337
            else:
8a7337
                return False
8a7337
8a7337
        self.tos_options.update(options)
8a7337
8a7337
        return True
8a7337
8a7337
    def parse_includefile(self, words):
8a7337
        path = os.path.join(self.root_dir, words[0])
8a7337
        if not os.path.isfile(path):
8a7337
            return False
8a7337
8a7337
        self.parse_ntp_conf(path)
8a7337
        return True
8a7337
8a7337
    def parse_keys(self, words):
8a7337
        keyfile = words[0]
8a7337
        path = os.path.join(self.root_dir, keyfile)
8a7337
        if not os.path.isfile(path):
8a7337
            logging.info("Missing %s", path)
8a7337
            return False
8a7337
8a7337
        with open(path, encoding=self.file_encoding) as f:
8a7337
            logging.info("Reading %s", path)
8a7337
            keys = []
8a7337
            for line in f:
8a7337
                words = line.split()
8a7337
                if len(words) < 3 or not words[0].isdigit():
8a7337
                    continue
8a7337
                keys.append((int(words[0]), words[1], words[2]))
8a7337
8a7337
            self.keyfile = keyfile
8a7337
            self.keys = keys
8a7337
8a7337
        return True
8a7337
8a7337
    def parse_trustedkey(self, words):
8a7337
        key_ranges = []
8a7337
        for word in words:
8a7337
            if word.isdigit():
8a7337
                key_ranges.append((int(word), int(word)))
8a7337
            elif re.match("^[0-9]+-[0-9]+$", word):
8a7337
                first, last = word.split("-")
8a7337
                key_ranges.append((int(first), int(last)))
8a7337
            else:
8a7337
                return False
8a7337
8a7337
        self.trusted_keys = key_ranges
8a7337
        return True
8a7337
8a7337
    def write_chrony_configuration(self, chrony_conf_path, chrony_keys_path,
8a7337
                                   dry_run=False, backup=False):
8a7337
        chrony_conf = self.get_chrony_conf(chrony_keys_path)
8a7337
        logging.debug("Generated %s:\n%s", chrony_conf_path, chrony_conf)
8a7337
8a7337
        if not dry_run:
8a7337
            self.write_file(chrony_conf_path, 0o644, chrony_conf, backup)
8a7337
8a7337
        chrony_keys = self.get_chrony_keys()
8a7337
        if chrony_keys:
8a7337
            logging.debug("Generated %s:\n%s", chrony_keys_path, chrony_keys)
8a7337
8a7337
        if not dry_run:
8a7337
            self.write_file(chrony_keys_path, 0o640, chrony_keys, backup)
8a7337
8a7337
    def get_processed_time_sources(self):
8a7337
        # Convert {0,1,2,3}.*pool.ntp.org servers to 2.*pool.ntp.org pools
8a7337
8a7337
        # Make shallow copies of all sources (only type will be modified)
8a7337
        time_sources = [s.copy() for s in self.time_sources]
8a7337
8a7337
        pools = {}
8a7337
        for source in time_sources:
8a7337
            if source["type"] != "server":
8a7337
                continue
8a7337
            m = re.match("^([0123])(\\.\\w+)?\\.pool\\.ntp\\.org$", source["address"])
8a7337
            if m is None:
8a7337
                continue
8a7337
            number = m.group(1)
8a7337
            zone = m.group(2)
8a7337
            if zone not in pools:
8a7337
                pools[zone] = []
8a7337
            pools[zone].append((int(number), source))
8a7337
8a7337
        remove_servers = set()
8a7337
        for zone, pool in pools.items():
8a7337
            # sort and skip all pools not in [0, 3] range
8a7337
            pool.sort()
8a7337
            if [number for number, source in pool] != [0, 1, 2, 3]:
8a7337
                # only exact group of 4 servers can be converted, nothing to do here
8a7337
                continue
8a7337
            # verify that parameters are the same for all servers in the pool
8a7337
            if not all([p[1]["options"] == pool[0][1]["options"] for p in pool]):
8a7337
                break
8a7337
            remove_servers.update([pool[i][1]["address"] for i in [0, 1, 3]])
8a7337
            pool[2][1]["type"] = "pool"
8a7337
8a7337
        processed_sources = []
8a7337
        for source in time_sources:
8a7337
            if source["type"] == "server" and source["address"] in remove_servers:
8a7337
                continue
8a7337
            processed_sources.append(source)
8a7337
        return processed_sources
8a7337
8a7337
    def get_chrony_conf_sources(self):
8a7337
        conf = ""
8a7337
8a7337
        if self.step_tickers:
8a7337
            conf += "# Specify NTP servers used for initial correction.\n"
8a7337
            conf += "initstepslew 0.1 {}\n".format(" ".join(self.step_tickers))
8a7337
            conf += "\n"
8a7337
8a7337
        conf += "# Specify time sources.\n"
8a7337
8a7337
        for source in self.get_processed_time_sources():
8a7337
            address = source["address"]
8a7337
            if address.startswith("127.127."):
8a7337
                if address.startswith("127.127.1."):
8a7337
                    continue
8a7337
                # No other refclocks are expected from the parser
8a7337
                assert False
8a7337
            else:
8a7337
                conf += "{} {}".format(source["type"], address)
8a7337
                for option in source["options"]:
8a7337
                    if option[0] in ["minpoll", "maxpoll", "version", "key",
8a7337
                                     "iburst", "noselect", "prefer", "xleave"]:
8a7337
                        conf += " {}".format(" ".join(option))
8a7337
                    elif option[0] == "burst":
8a7337
                        conf += " presend 6"
8a7337
                    elif option[0] == "true":
8a7337
                        conf += " trust"
8a7337
                    else:
8a7337
                        # No other options are expected from the parser
8a7337
                        assert False
8a7337
                conf += "\n"
8a7337
        conf += "\n"
8a7337
8a7337
        return conf
8a7337
8a7337
    def get_chrony_conf_allows(self):
8a7337
        allowed_networks = filter(lambda n: "ignore" not in self.restrictions[n] and
8a7337
                                    "noserve" not in self.restrictions[n],
8a7337
                                  self.restrictions.keys())
8a7337
8a7337
        conf = ""
8a7337
        for network in sorted(allowed_networks, key=lambda n: (n.version, n)):
8a7337
            if network.num_addresses > 1:
8a7337
                conf += "allow {}\n".format(network)
8a7337
            else:
8a7337
                conf += "allow {}\n".format(network.network_address)
8a7337
8a7337
        if conf:
8a7337
            conf = "# Allow NTP client access.\n" + conf
8a7337
            conf += "\n"
8a7337
8a7337
        return conf
8a7337
8a7337
    def get_chrony_conf_cmdallows(self):
8a7337
        allowed_networks = filter(lambda n: "ignore" not in self.restrictions[n] and
8a7337
                                    "noquery" not in self.restrictions[n] and
8a7337
                                    n != ipaddress.ip_network(u"127.0.0.1/32") and
8a7337
                                    n != ipaddress.ip_network(u"::1/128"),
8a7337
                                  self.restrictions.keys())
8a7337
8a7337
        ip_versions = set()
8a7337
        conf = ""
8a7337
        for network in sorted(allowed_networks, key=lambda n: (n.version, n)):
8a7337
            ip_versions.add(network.version)
8a7337
            if network.num_addresses > 1:
8a7337
                conf += "cmdallow {}\n".format(network)
8a7337
            else:
8a7337
                conf += "cmdallow {}\n".format(network.network_address)
8a7337
8a7337
        if conf:
8a7337
            conf = "# Allow remote monitoring.\n" + conf
8a7337
            if 4 in ip_versions:
8a7337
                conf += "bindcmdaddress 0.0.0.0\n"
8a7337
            if 6 in ip_versions:
8a7337
                conf += "bindcmdaddress ::\n"
8a7337
            conf += "\n"
8a7337
8a7337
        return conf
8a7337
8a7337
    def get_chrony_conf(self, chrony_keys_path):
8a7337
        local_stratum = 0
8a7337
        maxdistance = 0.0
8a7337
        minsources = 1
8a7337
        orphan_stratum = 0
8a7337
        logs = []
8a7337
8a7337
        for source in self.time_sources:
8a7337
            address = source["address"]
8a7337
            if address.startswith("127.127.1."):
8a7337
                if address in self.fudges and "stratum" in self.fudges[address]:
8a7337
                    local_stratum = self.fudges[address]["stratum"]
8a7337
                else:
8a7337
                    local_stratum = 5
8a7337
8a7337
        if "maxdist" in self.tos_options:
8a7337
            maxdistance = self.tos_options["maxdist"]
8a7337
        if "minsane" in self.tos_options:
8a7337
            minsources = self.tos_options["minsane"]
8a7337
        if "orphan" in self.tos_options:
8a7337
            orphan_stratum = self.tos_options["orphan"]
8a7337
8a7337
        if "clockstats" in self.statistics:
8a7337
            logs.append("refclocks");
8a7337
        if "loopstats" in self.statistics:
8a7337
            logs.append("tracking")
8a7337
        if "peerstats" in self.statistics:
8a7337
            logs.append("statistics");
8a7337
        if "rawstats" in self.statistics:
8a7337
            logs.append("measurements")
8a7337
8a7337
        conf = "# This file was converted from {}{}.\n".format(
8a7337
                    self.ntp_conf_path,
8a7337
                    " and " + self.step_tickers_path if self.step_tickers_path else "")
8a7337
        conf += "\n"
8a7337
8a7337
        if self.ignored_lines:
8a7337
            conf += "# The following directives were ignored in the conversion:\n"
8a7337
8a7337
            for line in self.ignored_lines:
8a7337
                # Remove sensitive information
8a7337
                line = re.sub(r"\s+pw\s+\S+", " pw XXX", line.rstrip())
8a7337
                conf += "# " + line + "\n"
8a7337
            conf += "\n"
8a7337
8a7337
        conf += self.get_chrony_conf_sources()
8a7337
8a7337
        conf += "# Record the rate at which the system clock gains/losses time.\n"
8a7337
        if not self.driftfile:
8a7337
            conf += "#"
8a7337
        conf += "driftfile /var/lib/chrony/drift\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Allow the system clock to be stepped in the first three updates\n"
8a7337
        conf += "# if its offset is larger than 1 second.\n"
8a7337
        conf += "makestep 1.0 3\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Enable kernel synchronization of the real-time clock (RTC).\n"
8a7337
        conf += "rtcsync\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Enable hardware timestamping on all interfaces that support it.\n"
8a7337
        conf += "#hwtimestamp *\n"
8a7337
        conf += "\n"
8a7337
8a7337
        if maxdistance > 0.0:
8a7337
            conf += "# Specify the maximum distance of sources to be selectable.\n"
8a7337
            conf += "maxdistance {}\n".format(maxdistance)
8a7337
            conf += "\n"
8a7337
8a7337
        conf += "# Increase the minimum number of selectable sources required to adjust\n"
8a7337
        conf += "# the system clock.\n"
8a7337
        if minsources > 1:
8a7337
            conf += "minsources {}\n".format(minsources)
8a7337
        else:
8a7337
            conf += "#minsources 2\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += self.get_chrony_conf_allows()
8a7337
8a7337
        conf += self.get_chrony_conf_cmdallows()
8a7337
8a7337
        conf += "# Serve time even if not synchronized to a time source.\n"
8a7337
        if orphan_stratum > 0 and orphan_stratum < 16:
8a7337
            conf += "local stratum {} orphan\n".format(orphan_stratum)
8a7337
        elif local_stratum > 0 and local_stratum < 16:
8a7337
            conf += "local stratum {}\n".format(local_stratum)
8a7337
        else:
8a7337
            conf += "#local stratum 10\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Specify file containing keys for NTP authentication.\n"
8a7337
        conf += "keyfile {}\n".format(chrony_keys_path)
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Get TAI-UTC offset and leap seconds from the system tz database.\n"
8a7337
        conf += "leapsectz right/UTC\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Specify directory for log files.\n"
8a7337
        conf += "logdir /var/log/chrony\n"
8a7337
        conf += "\n"
8a7337
8a7337
        conf += "# Select which information is logged.\n"
8a7337
        if logs:
8a7337
            conf += "log {}\n".format(" ".join(logs))
8a7337
        else:
8a7337
            conf += "#log measurements statistics tracking\n"
8a7337
8a7337
        return conf
8a7337
8a7337
    def get_chrony_keys(self):
8a7337
        if not self.keyfile:
8a7337
            return ""
8a7337
8a7337
        keys = "# This file was converted from {}.\n".format(self.keyfile)
8a7337
        keys += "\n"
8a7337
8a7337
        for key in self.keys:
8a7337
            key_id = key[0]
8a7337
            key_type = key[1]
8a7337
            password = key[2]
8a7337
8a7337
            if key_type in ["m", "M"]:
8a7337
                key_type = "MD5"
8a7337
            elif key_type not in ["MD5", "SHA1", "SHA256", "SHA384", "SHA512"]:
8a7337
                continue
8a7337
8a7337
            prefix = "ASCII" if len(password) <= 20 else "HEX"
8a7337
8a7337
            for first, last in self.trusted_keys:
8a7337
                if first <= key_id <= last:
8a7337
                    trusted = True
8a7337
                    break
8a7337
            else:
8a7337
                trusted = False
8a7337
8a7337
            # Disable keys that were not marked as trusted
8a7337
            if not trusted:
8a7337
                keys += "#"
8a7337
8a7337
            keys += "{} {} {}:{}\n".format(key_id, key_type, prefix, password)
8a7337
8a7337
        return keys
8a7337
8a7337
    def write_file(self, path, mode, content, backup):
8a7337
        path = self.root_dir + path
8a7337
        if backup and os.path.isfile(path):
8a7337
            os.rename(path, path + ".old")
8a7337
8a7337
        with open(os.open(path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, mode), "w",
8a7337
                  encoding=self.file_encoding) as f:
8a7337
            logging.info("Writing %s", path)
8a7337
            f.write(u"" + content)
8a7337
8a7337
        # Fix SELinux context if restorecon is installed
8a7337
        try:
8a7337
            subprocess.call(["restorecon", path])
8a7337
        except OSError:
8a7337
            pass
8a7337
8a7337
8a7337
def main():
8a7337
    parser = argparse.ArgumentParser(description="Convert ntp configuration to chrony.")
8a7337
    parser.add_argument("-r", "--root", dest="roots", default=["/"], nargs="+",
8a7337
                        metavar="DIR", help="specify root directory (default /)")
8a7337
    parser.add_argument("--ntp-conf", action="store", default="/etc/ntp.conf",
8a7337
                        metavar="FILE", help="specify ntp config (default /etc/ntp.conf)")
8a7337
    parser.add_argument("--step-tickers", action="store", default="",
8a7337
                        metavar="FILE", help="specify ntpdate step-tickers config (no default)")
8a7337
    parser.add_argument("--chrony-conf", action="store", default="/etc/chrony.conf",
8a7337
                        metavar="FILE", help="specify chrony config (default /etc/chrony.conf)")
8a7337
    parser.add_argument("--chrony-keys", action="store", default="/etc/chrony.keys",
8a7337
                        metavar="FILE", help="specify chrony keyfile (default /etc/chrony.keys)")
8a7337
    parser.add_argument("-b", "--backup", action="store_true", help="backup existing configs before writing")
8a7337
    parser.add_argument("-L", "--ignored-lines", action="store_true", help="print ignored lines")
8a7337
    parser.add_argument("-D", "--ignored-directives", action="store_true",
8a7337
                        help="print names of ignored directives")
8a7337
    parser.add_argument("-n", "--dry-run", action="store_true", help="don't make any changes")
8a7337
    parser.add_argument("-v", "--verbose", action="count", default=0, help="increase verbosity")
8a7337
8a7337
    args = parser.parse_args()
8a7337
8a7337
    logging.basicConfig(format="%(message)s",
8a7337
                        level=[logging.ERROR, logging.INFO, logging.DEBUG][min(args.verbose, 2)])
8a7337
8a7337
    for root in args.roots:
8a7337
        conf = NtpConfiguration(root, args.ntp_conf, args.step_tickers)
8a7337
8a7337
        if args.ignored_lines:
8a7337
            for line in conf.ignored_lines:
8a7337
                print(line)
8a7337
8a7337
        if args.ignored_directives:
8a7337
            for directive in conf.ignored_directives:
8a7337
                print(directive)
8a7337
8a7337
        conf.write_chrony_configuration(args.chrony_conf, args.chrony_keys, args.dry_run, args.backup)
8a7337
8a7337
if __name__ == "__main__":
8a7337
    main()