From 8381edc12a116eee3a524ee5d060c057cb039f47 Mon Sep 17 00:00:00 2001 From: Ruben Herold Date: Mon, 3 Aug 2026 23:14:08 +0200 Subject: ntp: T9159: add source-address option for client requests chrony (the daemon backing VyOS' NTP service) has no way to pin the local address used for outgoing client requests to upstream servers. "interface"/"listen-address" only configure the server/listening role (binddevice/bindaddress); nothing controls the source address chosen for packets chronyd itself originates. On a router with more than one usable egress path to a given NTP server (e.g. multiple transit/peering sessions), the kernel's default source-address selection depends on whichever route wins at query time. If that route happens to egress through an interface whose own address is not globally reachable (a private peering-fabric segment, for example), the request goes out with an address the reply can never route back to - an intermittent, path-dependent failure with no existing workaround at the NTP layer. BGP and friends already solve the equivalent problem via "update-source" bound to the loopback; NTP had no analogous option. Add "service ntp source-address
", reusing the existing source-address-ipv4-ipv6-multi include (same pattern already used for e.g. RADIUS servers), and render it as chrony's "bindacqaddress" directive - the client-side counterpart to "bindaddress" that chrony already supports natively. --- data/templates/chrony/chrony.conf.j2 | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'data') diff --git a/data/templates/chrony/chrony.conf.j2 b/data/templates/chrony/chrony.conf.j2 index 23b452f01..9de3e9518 100644 --- a/data/templates/chrony/chrony.conf.j2 +++ b/data/templates/chrony/chrony.conf.j2 @@ -72,6 +72,14 @@ binddevice {{ interface }} {% endif %} {% endif %} +{% if source_address is vyos_defined %} +# Source address used for outgoing NTP client requests, independent of +# whichever interface the kernel's routing table selects for a given server +{% for address in source_address %} +bindacqaddress {{ address }} +{% endfor %} +{% endif %} + {% if timestamp.interface is vyos_defined %} # Enable hardware timestamping on the specified interfaces {% for iface, iface_config in timestamp.interface.items() %} -- cgit v1.2.3 From 21a55fd494ef9c73744da718560ad540dd3fc0bf Mon Sep 17 00:00:00 2001 From: Ruben Herold Date: Wed, 5 Aug 2026 08:38:05 +0200 Subject: ntp: T9159: add source-interface option for client requests Complements source-address (bindacqaddress) with a device-based bind (chrony's bindacqdevice) for outgoing NTP client requests. Addresses feedback on T9159 that a source IP alone cannot unambiguously select the egress path in multi-VRF setups with overlapping address ranges. Both options bind different socket properties and can be combined. If "vrf" is set, source-interface must belong to that VRF, mirroring the existing check for "interface". --- data/templates/chrony/chrony.conf.j2 | 15 ++++++--- interface-definitions/service_ntp.xml.in | 1 + smoketest/scripts/cli/test_service_ntp.py | 56 +++++++++++++++++++++++++++++++ src/conf_mode/service_ntp.py | 14 ++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) (limited to 'data') diff --git a/data/templates/chrony/chrony.conf.j2 b/data/templates/chrony/chrony.conf.j2 index 9de3e9518..271c6de87 100644 --- a/data/templates/chrony/chrony.conf.j2 +++ b/data/templates/chrony/chrony.conf.j2 @@ -72,12 +72,17 @@ binddevice {{ interface }} {% endif %} {% endif %} -{% if source_address is vyos_defined %} -# Source address used for outgoing NTP client requests, independent of -# whichever interface the kernel's routing table selects for a given server -{% for address in source_address %} +{% if source_address is vyos_defined or source_interface is vyos_defined %} +# Source used for outgoing NTP client requests, independent of whichever +# interface the kernel's routing table selects for a given server +{% if source_address is vyos_defined %} +{% for address in source_address %} bindacqaddress {{ address }} -{% endfor %} +{% endfor %} +{% endif %} +{% if source_interface is vyos_defined %} +bindacqdevice {{ source_interface }} +{% endif %} {% endif %} {% if timestamp.interface is vyos_defined %} diff --git a/interface-definitions/service_ntp.xml.in b/interface-definitions/service_ntp.xml.in index 5b481bd6e..40e4a5d15 100644 --- a/interface-definitions/service_ntp.xml.in +++ b/interface-definitions/service_ntp.xml.in @@ -13,6 +13,7 @@ #include #include #include + #include #include diff --git a/smoketest/scripts/cli/test_service_ntp.py b/smoketest/scripts/cli/test_service_ntp.py index 054cbeda9..6f1bb82e2 100755 --- a/smoketest/scripts/cli/test_service_ntp.py +++ b/smoketest/scripts/cli/test_service_ntp.py @@ -151,6 +151,62 @@ class TestSystemNTP(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path + ['source-address']) self.cli_commit() + def test_source_interface(self): + interface = 'eth0' + self.cli_set(base_path + ['source-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 source-interface configuration + config = read_file(NTP_CONF, sudo=True) + self.assertIn(f'bindacqdevice {interface}', config) + + def test_source_address_and_source_interface(self): + # bindacqaddress (IP) and bindacqdevice (interface) bind different + # socket properties and can be configured together + source_addresses = ['127.0.0.1', '::1'] + interface = 'eth0' + for address in source_addresses: + self.cli_set(base_path + ['source-address', address]) + self.cli_set(base_path + ['source-interface', interface]) + + servers = ['time1.vyos.net', 'time2.vyos.net'] + for server in servers: + self.cli_set(base_path + ['server', server]) + + self.cli_commit() + + config = read_file(NTP_CONF, sudo=True) + for address in source_addresses: + self.assertIn(f'bindacqaddress {address}', config) + self.assertIn(f'bindacqdevice {interface}', config) + + def test_source_interface_vrf_mismatch(self): + vrf_name = 'vyos-mgmt' + interface = 'eth0' + + self.cli_set(['vrf', 'name', vrf_name, 'table', '12345']) + self.cli_set(base_path + ['vrf', vrf_name]) + self.cli_set(base_path + ['source-interface', interface]) + + servers = ['time1.vyos.net', 'time2.vyos.net'] + for server in servers: + self.cli_set(base_path + ['server', server]) + + # eth0 does not belong to the VRF - commit must be rejected + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + # fix the invalid combination so chronyd is left running for tearDown + self.cli_delete(base_path + ['source-interface']) + self.cli_delete(base_path + ['vrf']) + self.cli_delete(['vrf', 'name', vrf_name]) + self.cli_commit() + def test_interface(self): interfaces = ['eth0'] for interface in interfaces: diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py index 3cb883ae1..adc161a9d 100755 --- a/src/conf_mode/service_ntp.py +++ b/src/conf_mode/service_ntp.py @@ -81,6 +81,20 @@ def verify(ntp): raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\ f'does not belong to this VRF!') + if 'source_interface' in ntp: + # If outgoing NTP client requests should be bound to a given + # interface (device), ensure it exists + source_interface = ntp['source_interface'] + verify_interface_exists(ntp, source_interface) + + # If we run in a VRF, our source interface must belong to this VRF, too + if 'vrf' in ntp: + tmp = get_interface_config(source_interface) + vrf_name = ntp['vrf'] + if 'master' not in tmp or tmp['master'] != vrf_name: + raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{source_interface}" '\ + f'does not belong to this VRF!') + if 'listen_address' in ntp: ipv4_addresses = 0 ipv6_addresses = 0 -- cgit v1.2.3