Blame SOURCES/ntp2chrony.py

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