diff --git a/data/templates/chrony/chrony.conf.j2 b/data/templates/chrony/chrony.conf.j2 index d02fbf71d..e3f078fdc 100644 --- a/data/templates/chrony/chrony.conf.j2 +++ b/data/templates/chrony/chrony.conf.j2 @@ -1,58 +1,68 @@ ### Autogenerated by service_ntp.py ### # This would step the system clock if the adjustment is larger than 0.1 seconds, # but only in the first three clock updates. makestep 1.0 3 # The rtcsync directive enables a mode where the system time is periodically # copied to the RTC and chronyd does not try to track its drift. This directive # cannot be used with the rtcfile directive. On Linux, the RTC copy is performed # by the kernel every 11 minutes. rtcsync # This directive specifies the maximum amount of memory that chronyd is allowed # to allocate for logging of client accesses and the state that chronyd as an # NTP server needs to support the interleaved mode for its clients. clientloglimit 1048576 driftfile /run/chrony/drift dumpdir /run/chrony ntsdumpdir /run/chrony pidfile {{ config_file | replace('.conf', '.pid') }} # Determine when will the next leap second occur and what is the current offset +{% if leap_second is vyos_defined('timezone') %} leapsectz right/UTC +{% elif leap_second is vyos_defined('ignore') %} +leapsecmode ignore +{% elif leap_second is vyos_defined('smear') %} +leapsecmode slew +maxslewrate 1000 +smoothtime 400 0.001024 leaponly +{% elif leap_second is vyos_defined('system') %} +leapsecmode system +{% endif %} user {{ user }} # NTP servers to reach out to {% if server is vyos_defined %} {% for server, config in server.items() %} {% set association = 'server' %} {% if config.pool is vyos_defined %} {% set association = 'pool' %} {% endif %} {{ association }} {{ server | replace('_', '-') }} iburst {{ 'nts' if config.nts is vyos_defined }} {{ 'noselect' if config.noselect is vyos_defined }} {{ 'prefer' if config.prefer is vyos_defined }} {% endfor %} {% endif %} # Allowed clients configuration {% if allow_client.address is vyos_defined %} {% for address in allow_client.address %} allow {{ address }} {% endfor %} {% else %} deny all {% endif %} {% if listen_address is vyos_defined or interface is vyos_defined %} # NTP should listen on configured addresses only {% if listen_address is vyos_defined %} {% for address in listen_address %} bindaddress {{ address }} {% endfor %} {% endif %} {% if interface is vyos_defined %} binddevice {{ interface }} {% endif %} {% endif %} diff --git a/interface-definitions/service_ntp.xml.in b/interface-definitions/service_ntp.xml.in index 65a45d7a1..c057b62b5 100644 --- a/interface-definitions/service_ntp.xml.in +++ b/interface-definitions/service_ntp.xml.in @@ -1,67 +1,95 @@ <?xml version="1.0"?> <!-- NTP configuration --> <interfaceDefinition> <node name="service"> <children> <node name="ntp" owner="${vyos_conf_scripts_dir}/service_ntp.py"> <properties> <help>Network Time Protocol (NTP) configuration</help> <priority>900</priority> </properties> <children> + #include <include/allow-client.xml.i> + #include <include/generic-interface.xml.i> + #include <include/listen-address.xml.i> + #include <include/interface/vrf.xml.i> + <leafNode name="leap-second"> + <properties> + <help>Leap second behavior</help> + <completionHelp> + <list>ignore smear system timezone</list> + </completionHelp> + <valueHelp> + <format>ignore</format> + <description>No correction is applied to the clock for the leap second</description> + </valueHelp> + <valueHelp> + <format>smear</format> + <description>Correct served time slowly be slewing instead of stepping</description> + </valueHelp> + <valueHelp> + <format>system</format> + <description>Kernel steps the system clock forward or backward</description> + </valueHelp> + <valueHelp> + <format>timezone</format> + <description>Use UTC timezone database to determine when will the next leap second occur</description> + </valueHelp> + <constraint> + <regex>(ignore|smear|system|timezone)</regex> + </constraint> + </properties> + <defaultValue>timezone</defaultValue> + </leafNode> <tagNode name="server"> <properties> <help>Network Time Protocol (NTP) server</help> <valueHelp> <format>ipv4</format> <description>IP address of NTP server</description> </valueHelp> <valueHelp> <format>ipv6</format> <description>IPv6 address of NTP server</description> </valueHelp> <valueHelp> <format>hostname</format> <description>Fully qualified domain name of NTP server</description> </valueHelp> <constraint> <validator name="ip-address"/> <validator name="fqdn"/> </constraint> </properties> <children> <leafNode name="noselect"> <properties> <help>Marks the server as unused</help> <valueless/> </properties> </leafNode> <leafNode name="nts"> <properties> <help>Enable Network Time Security (NTS) for the server</help> <valueless/> </properties> </leafNode> <leafNode name="pool"> <properties> <help>Associate with a number of remote servers</help> <valueless/> </properties> </leafNode> <leafNode name="prefer"> <properties> <help>Marks the server as preferred</help> <valueless/> </properties> </leafNode> </children> </tagNode> - #include <include/allow-client.xml.i> - #include <include/generic-interface.xml.i> - #include <include/listen-address.xml.i> - #include <include/interface/vrf.xml.i> </children> </node> </children> </node> </interfaceDefinition> diff --git a/smoketest/scripts/cli/test_service_ntp.py b/smoketest/scripts/cli/test_service_ntp.py index 5e385d5ad..ae45fe2f4 100755 --- a/smoketest/scripts/cli/test_service_ntp.py +++ b/smoketest/scripts/cli/test_service_ntp.py @@ -1,146 +1,169 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright (C) 2019-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import unittest from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.utils.process import cmd from vyos.utils.process import process_named_running PROCESS_NAME = 'chronyd' NTP_CONF = '/run/chrony/chrony.conf' base_path = ['service', 'ntp'] class TestSystemNTP(VyOSUnitTestSHIM.TestCase): @classmethod def setUpClass(cls): super(TestSystemNTP, cls).setUpClass() # ensure we can also run this test on a live system - so lets clean # out the current configuration :) cls.cli_delete(cls, base_path) def tearDown(self): self.assertTrue(process_named_running(PROCESS_NAME)) self.cli_delete(base_path) self.cli_commit() self.assertFalse(process_named_running(PROCESS_NAME)) - def test_01_ntp_options(self): + def test_base_options(self): # Test basic NTP support with multiple servers and their options servers = ['192.0.2.1', '192.0.2.2'] options = ['nts', 'noselect', 'prefer'] pools = ['pool.vyos.io'] for server in servers: for option in options: self.cli_set(base_path + ['server', server, option]) # Test NTP pool for pool in pools: self.cli_set(base_path + ['server', pool, 'pool']) # commit changes self.cli_commit() # Check generated configuration # this file must be read with higher permissions config = cmd(f'sudo cat {NTP_CONF}') self.assertIn('driftfile /run/chrony/drift', config) self.assertIn('dumpdir /run/chrony', config) self.assertIn('ntsdumpdir /run/chrony', config) self.assertIn('clientloglimit 1048576', config) self.assertIn('rtcsync', config) self.assertIn('makestep 1.0 3', config) self.assertIn('leapsectz right/UTC', config) for server in servers: self.assertIn(f'server {server} iburst ' + ' '.join(options), config) for pool in pools: self.assertIn(f'pool {pool} iburst', config) - def test_02_ntp_clients(self): + def test_clients(self): # Test the allowed-networks statement listen_address = ['127.0.0.1', '::1'] for listen in listen_address: self.cli_set(base_path + ['listen-address', listen]) networks = ['192.0.2.0/24', '2001:db8:1000::/64', '100.64.0.0', '2001:db8::ffff'] for network in networks: self.cli_set(base_path + ['allow-client', 'address', network]) # Verify "NTP server not configured" verify() statement with self.assertRaises(ConfigSessionError): self.cli_commit() servers = ['192.0.2.1', '192.0.2.2'] for server in servers: self.cli_set(base_path + ['server', server]) self.cli_commit() # Check generated client address configuration # this file must be read with higher permissions config = cmd(f'sudo cat {NTP_CONF}') for network in networks: self.assertIn(f'allow {network}', config) # Check listen address for listen in listen_address: self.assertIn(f'bindaddress {listen}', config) - def test_03_ntp_interface(self): + def test_interface(self): interfaces = ['eth0'] for interface in interfaces: self.cli_set(base_path + ['interface', interface]) servers = ['time1.vyos.net', 'time2.vyos.net'] for server in servers: self.cli_set(base_path + ['server', server]) self.cli_commit() # Check generated client address configuration # this file must be read with higher permissions config = cmd(f'sudo cat {NTP_CONF}') for interface in interfaces: self.assertIn(f'binddevice {interface}', config) - def test_04_ntp_vrf(self): + def test_vrf(self): vrf_name = 'vyos-mgmt' self.cli_set(['vrf', 'name', vrf_name, 'table', '12345']) self.cli_set(base_path + ['vrf', vrf_name]) servers = ['time1.vyos.net', 'time2.vyos.net'] for server in servers: self.cli_set(base_path + ['server', server]) self.cli_commit() # Check for process in VRF tmp = cmd(f'ip vrf pids {vrf_name}') self.assertIn(PROCESS_NAME, tmp) self.cli_delete(['vrf', 'name', vrf_name]) + def test_leap_seconds(self): + servers = ['time1.vyos.net', 'time2.vyos.net'] + for server in servers: + self.cli_set(base_path + ['server', server]) + + self.cli_commit() + + # Check generated client address configuration + # this file must be read with higher permissions + config = cmd(f'sudo cat {NTP_CONF}') + self.assertIn('leapsectz right/UTC', config) # CLI default + + for mode in ['ignore', 'system', 'smear']: + self.cli_set(base_path + ['leap-second', mode]) + self.cli_commit() + config = cmd(f'sudo cat {NTP_CONF}') + if mode != 'smear': + self.assertIn(f'leapsecmode {mode}', config) + else: + self.assertIn(f'leapsecmode slew', config) + self.assertIn(f'maxslewrate 1000', config) + self.assertIn(f'smoothtime 400 0.001024 leaponly', config) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py index 1cc23a7df..f11690ee6 100755 --- a/src/conf_mode/service_ntp.py +++ b/src/conf_mode/service_ntp.py @@ -1,136 +1,136 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from vyos.config import Config from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_interface_exists from vyos.utils.process import call from vyos.utils.permission import chmod_750 from vyos.utils.network import get_interface_config from vyos.template import render from vyos.template import is_ipv4 from vyos import ConfigError from vyos import airbag airbag.enable() config_file = r'/run/chrony/chrony.conf' systemd_override = r'/run/systemd/system/chrony.service.d/override.conf' user_group = '_chrony' def get_config(config=None): if config: conf = config else: conf = Config() base = ['service', 'ntp'] if not conf.exists(base): return None - ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) + ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_defaults=True) ntp['config_file'] = config_file ntp['user'] = user_group tmp = is_node_changed(conf, base + ['vrf']) if tmp: ntp.update({'restart_required': {}}) return ntp def verify(ntp): # bail out early - looks like removal from running config if not ntp: return None if 'server' not in ntp: raise ConfigError('NTP server not configured') verify_vrf(ntp) if 'interface' in ntp: # If ntpd should listen on a given interface, ensure it exists interface = ntp['interface'] verify_interface_exists(interface) # If we run in a VRF, our interface must belong to this VRF, too if 'vrf' in ntp: tmp = get_interface_config(interface) vrf_name = ntp['vrf'] if 'master' not in tmp or tmp['master'] != vrf_name: raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ f'does not belong to this VRF!') if 'listen_address' in ntp: ipv4_addresses = 0 ipv6_addresses = 0 for address in ntp['listen_address']: if is_ipv4(address): ipv4_addresses += 1 else: ipv6_addresses += 1 if ipv4_addresses > 1: raise ConfigError(f'NTP Only admits one ipv4 value for listen-address parameter ') if ipv6_addresses > 1: raise ConfigError(f'NTP Only admits one ipv6 value for listen-address parameter ') return None def generate(ntp): # bail out early - looks like removal from running config if not ntp: return None render(config_file, 'chrony/chrony.conf.j2', ntp, user=user_group, group=user_group) render(systemd_override, 'chrony/override.conf.j2', ntp, user=user_group, group=user_group) # Ensure proper permission for chrony command socket config_dir = os.path.dirname(config_file) chmod_750(config_dir) return None def apply(ntp): systemd_service = 'chrony.service' # Reload systemd manager configuration call('systemctl daemon-reload') if not ntp: # NTP support is removed in the commit call(f'systemctl stop {systemd_service}') if os.path.exists(config_file): os.unlink(config_file) if os.path.isfile(systemd_override): os.unlink(systemd_override) return # we need to restart the service if e.g. the VRF name changed systemd_action = 'reload-or-restart' if 'restart_required' in ntp: systemd_action = 'restart' call(f'systemctl {systemd_action} {systemd_service}') return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)