summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorViacheslav Hletenko <v.gletenko@vyos.io>2025-10-22 15:39:18 +0300
committerGitHub <noreply@github.com>2025-10-22 15:39:18 +0300
commitc284938df61b5e970dd13cde03e0693254e89211 (patch)
tree8339e1bbe531ac5caffb80938b48fdce4743cbba
parentd272b2509836403682c6c8ab4096ccf4be003bcb (diff)
parentc4632bb6ad6c055fb7fe5a5c055354c6895ba8e5 (diff)
downloadvyos-1x-c284938df61b5e970dd13cde03e0693254e89211.tar.gz
vyos-1x-c284938df61b5e970dd13cde03e0693254e89211.zip
Merge pull request #4622 from MattKobayashi/T3680
T3680: protocols: add dhclient hooks for dhcp-interface static routes
-rw-r--r--python/vyos/defaults.py2
-rw-r--r--python/vyos/frrender.py15
-rwxr-xr-xsmoketest/scripts/cli/test_protocols_static.py107
-rwxr-xr-xsrc/conf_mode/protocols_static.py20
-rwxr-xr-xsrc/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook33
-rwxr-xr-xsrc/etc/dhcp/dhclient-exit-hooks.d/97-run-user-hooks (renamed from src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks)0
-rwxr-xr-xsrc/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook39
-rwxr-xr-xsrc/helpers/vyos-request-configd-update.py31
-rwxr-xr-xsrc/services/vyos-configd3
9 files changed, 248 insertions, 2 deletions
diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py
index 43cde38e9..25d7fd50c 100644
--- a/python/vyos/defaults.py
+++ b/python/vyos/defaults.py
@@ -61,6 +61,8 @@ config_files = {
config_status = '/tmp/vyos-config-status'
api_config_state = '/run/http-api-state'
frr_debug_enable = '/tmp/vyos.frr.debug'
+static_route_dhcp_interfaces_path = '/tmp/static_dhcp_interfaces'
+vyos_configd_socket_path = 'ipc:///run/vyos-configd.sock'
cfg_group = 'vyattacfg'
diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py
index decec8aa2..91fd3667d 100644
--- a/python/vyos/frrender.py
+++ b/python/vyos/frrender.py
@@ -31,11 +31,14 @@ from vyos.config import config_dict_merge
from vyos.configdict import get_dhcp_interfaces
from vyos.configdict import get_pppoe_interfaces
from vyos.defaults import frr_debug_enable
+from vyos.defaults import static_route_dhcp_interfaces_path
from vyos.utils.dict import dict_search
from vyos.utils.dict import dict_set_nested
+from vyos.utils.file import read_file
from vyos.utils.file import write_file
from vyos.utils.process import cmd
from vyos.utils.process import rc_cmd
+from vyos.template import get_dhcp_router
from vyos.template import render_to_string
from vyos import ConfigError
@@ -634,6 +637,7 @@ def get_frrender_dict(conf: Config, argv=None) -> dict:
class FRRender:
cached_config_dict = {}
+ cached_dhcp_gateways = {}
def __init__(self):
self._frr_conf = '/run/frr/config/vyos.frr.conf'
@@ -646,11 +650,20 @@ class FRRender:
tmp = type(config_dict)
raise ValueError(f'Config must be of type "dict" and not "{tmp}"!')
+ dhcp_gateways = {
+ interface: get_dhcp_router(interface)
+ for interface in read_file(static_route_dhcp_interfaces_path, '').split()
+ }
- if self.cached_config_dict == config_dict:
+ if (
+ self.cached_config_dict == config_dict
+ and self.cached_dhcp_gateways == dhcp_gateways
+ ):
debug('FRR: NO CHANGES DETECTED')
return False
+
self.cached_config_dict = config_dict
+ self.cached_dhcp_gateways = dhcp_gateways
def inline_helper(config_dict) -> str:
output = '!\n'
diff --git a/smoketest/scripts/cli/test_protocols_static.py b/smoketest/scripts/cli/test_protocols_static.py
index 03115b7b8..77a45e333 100755
--- a/smoketest/scripts/cli/test_protocols_static.py
+++ b/smoketest/scripts/cli/test_protocols_static.py
@@ -651,5 +651,112 @@ class TestProtocolsStatic(VyOSUnitTestSHIM.TestCase):
while process_named_running('dhclient', cmdline=interface, timeout=10):
sleep(0.250)
+ def test_07_dhcp_interface_static_routes(self):
+ # Test static routes using dhcp-interface option
+ # When running via vyos-build under the QEMU environment a local DHCP
+ # server is available. This test verifies that static routes with
+ # dhcp-interface are configured correctly.
+ if not os.path.exists('/tmp/vyos.smoketests.hint'):
+ self.skipTest('Not running under VyOS CI/CD QEMU environment!')
+
+ dhcp_interface = 'eth0'
+ interface_path = ['interfaces', 'ethernet', dhcp_interface]
+
+ # Configure DHCP on the interface
+ self.cli_set(interface_path + ['address', 'dhcp'])
+
+ # Commit configuration
+ self.cli_commit()
+
+ # Wait for dhclient to receive IP address
+ sleep(5)
+
+ # Configure static routes with dhcp-interface
+ dhcp_routes = {
+ '10.10.0.0/16': {
+ 'dhcp_interface': [dhcp_interface],
+ },
+ '192.168.100.0/24': {
+ 'dhcp_interface': [dhcp_interface],
+ },
+ }
+
+ # Configure the static routes
+ for route, route_config in dhcp_routes.items():
+ base = base_path + ['route', route]
+ if 'dhcp_interface' in route_config:
+ for dhcp_if in route_config['dhcp_interface']:
+ self.cli_set(base + ['dhcp-interface', dhcp_if])
+
+ # Commit configuration
+ self.cli_commit()
+
+ # Verify that the DHCP hook interface list file is created
+ dhcp_hook_iflist = '/tmp/static_dhcp_interfaces'
+ self.assertTrue(
+ os.path.exists(dhcp_hook_iflist),
+ 'DHCP hook interface list file should be created',
+ )
+
+ # Read the interface list file and verify it contains our interface
+ with open(dhcp_hook_iflist, 'r') as f:
+ interface_list = f.read().strip()
+ self.assertIn(
+ dhcp_interface,
+ interface_list,
+ f'Interface {dhcp_interface} should be in hook interface list',
+ )
+
+ # Get the DHCP router for verification
+ router = get_dhcp_router(dhcp_interface)
+ self.assertIsNotNone(router, 'DHCP router should be available')
+
+ # Verify FRR configuration contains the static routes with DHCP router
+ frrconfig = self.getFRRconfig('ip route')
+
+ for route in dhcp_routes.keys():
+ expected_route = f'ip route {route} {router} {dhcp_interface}'
+ self.assertIn(
+ expected_route,
+ frrconfig,
+ f'Static route {route} with dhcp-interface should be in FRR config',
+ )
+
+ # Test table-based routes with dhcp-interface
+ table_id = '100'
+ table_route = '10.20.0.0/16'
+ table_base = base_path + ['table', table_id, 'route', table_route]
+ self.cli_set(table_base + ['dhcp-interface', dhcp_interface])
+ self.cli_commit()
+
+ # Verify table route in FRR config
+ frrconfig = self.getFRRconfig('ip route')
+ expected_table_route = (
+ f'ip route {table_route} {router} {dhcp_interface} table {table_id}'
+ )
+ self.assertIn(
+ expected_table_route,
+ frrconfig,
+ f'Table static route {table_route} with dhcp-interface should be in FRR config',
+ )
+
+ # Clean up - remove DHCP configuration
+ self.cli_delete(interface_path + ['address'])
+ self.cli_commit()
+
+ # Wait for dhclient to stop
+ while process_named_running('dhclient', cmdline=dhcp_interface, timeout=10):
+ sleep(0.250)
+
+ # Verify that the hook interface list file is cleaned up when no dhcp-interface routes exist
+ self.cli_delete(base_path)
+ self.cli_commit()
+
+ # The interface list file should be removed when no dhcp-interface routes are configured
+ self.assertFalse(
+ os.path.exists(dhcp_hook_iflist),
+ 'DHCP hook interface list file should be removed when no dhcp-interface routes exist',
+ )
+
if __name__ == '__main__':
unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on())
diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py
index 7c730ca81..6310be13d 100755
--- a/src/conf_mode/protocols_static.py
+++ b/src/conf_mode/protocols_static.py
@@ -17,6 +17,7 @@
from ipaddress import IPv4Network
from sys import exit
from sys import argv
+import os
from vyos.config import Config
from vyos.configverify import has_frr_protocol_in_dict
@@ -25,12 +26,15 @@ from vyos.configverify import verify_vrf
from vyos.frrender import FRRender
from vyos.frrender import get_frrender_dict
from vyos.utils.process import is_systemd_service_running
+from vyos.utils.file import write_file
from vyos.template import render
from vyos import ConfigError
from vyos import airbag
+from vyos import defaults
airbag.enable()
config_file = '/etc/iproute2/rt_tables.d/vyos-static.conf'
+DHCP_HOOK_IFLIST = defaults.static_route_dhcp_interfaces_path
def get_config(config=None):
if config:
@@ -92,6 +96,22 @@ def generate(config_dict):
# eqivalent of the C foo ? 'a' : 'b' statement
static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static']
+ # Collect interfaces that have DHCP configuration for DHCP hooks
+ dhcp_interfaces = set()
+
+ # Check for DHCP interfaces in route configurations
+ if 'route' in static:
+ for prefix, prefix_options in static['route'].items():
+ if 'dhcp_interface' in prefix_options:
+ for interface_name in prefix_options['dhcp_interface']:
+ dhcp_interfaces.add(interface_name)
+
+ # Write the interface list for DHCP hooks or clean up if empty
+ if dhcp_interfaces:
+ write_file(DHCP_HOOK_IFLIST, " ".join(dhcp_interfaces))
+ elif os.path.exists(DHCP_HOOK_IFLIST):
+ os.unlink(DHCP_HOOK_IFLIST)
+
# Put routing table names in /etc/iproute2/rt_tables
render(config_file, 'iproute2/static.conf.j2', static)
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook b/src/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook
new file mode 100755
index 000000000..439a8dd87
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook
@@ -0,0 +1,33 @@
+#!/bin/bash
+#
+# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# 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/>.
+
+DHCP_HOOK_IFLIST="/tmp/static_dhcp_interfaces"
+
+# Only run if there are static routes with dhcp-interface configured
+if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then
+ return 0
+fi
+
+# Handle interface state changes that require static route regeneration
+# - PREINIT: interface is about to be configured, cleanup old routes
+# - EXPIRE: lease has expired, remove routes
+# - FAIL: DHCP failed, remove routes
+# - RELEASE: lease released, remove routes
+# - STOP: dhclient stopped, remove routes
+if [ "$reason" == "PREINIT" ] || [ "$reason" == "EXPIRE" ] || [ "$reason" == "FAIL" ] || [ "$reason" == "RELEASE" ] || [ "$reason" == "STOP" ]; then
+ # Re-generate static routes config to remove routes that depend on this interface
+ sudo /usr/libexec/vyos/vyos-request-configd-update.py
+fi
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks b/src/etc/dhcp/dhclient-exit-hooks.d/97-run-user-hooks
index 910b586f0..910b586f0 100755
--- a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/97-run-user-hooks
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook
new file mode 100755
index 000000000..7038bf25e
--- /dev/null
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook
@@ -0,0 +1,39 @@
+#!/bin/bash
+#
+# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# 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/>.
+
+DHCP_HOOK_IFLIST="/tmp/static_dhcp_interfaces"
+
+# Only run if there are static routes with dhcp-interface configured
+if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then
+ return 0
+fi
+
+# Re-generate the config on the following events:
+# - BOUND: always re-generate
+# - RENEW: re-generate if the IP address changed
+# - REBIND: re-generate if the IP address changed
+# - EXPIRE: always re-generate (route should be removed)
+# - RELEASE: always re-generate (route should be removed)
+if [ "$reason" == "RENEW" ] || [ "$reason" == "REBIND" ]; then
+ if [ "$old_routers" == "$new_routers" ]; then
+ return 0
+ fi
+elif [ "$reason" != "BOUND" ] && [ "$reason" != "EXPIRE" ] && [ "$reason" != "RELEASE" ]; then
+ return 0
+fi
+
+# Re-generate the static routes config
+sudo /usr/libexec/vyos/vyos-request-configd-update.py
diff --git a/src/helpers/vyos-request-configd-update.py b/src/helpers/vyos-request-configd-update.py
new file mode 100755
index 000000000..55ed0eef9
--- /dev/null
+++ b/src/helpers/vyos-request-configd-update.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+
+import json
+import zmq
+
+from vyos.utils.commit import wait_for_commit_lock
+from vyos.defaults import vyos_configd_socket_path
+
+context = zmq.Context()
+
+request = {
+ 'type': 'node',
+ 'last': True,
+ 'data': '/usr/libexec/vyos/conf_mode/protocols_static.py',
+}
+request = json.dumps(request)
+
+print("Waiting for commit lock...")
+wait_for_commit_lock()
+
+print("Connecting to vyos-configd server...")
+socket = context.socket(zmq.REQ)
+socket.connect(vyos_configd_socket_path)
+
+print(f"Sending request {request}...")
+socket.send_string(request)
+
+message = socket.recv()
+print(f"Received reply {request} [ {message} ]")
+
+print("All done")
diff --git a/src/services/vyos-configd b/src/services/vyos-configd
index 1a207f876..2f060dc82 100755
--- a/src/services/vyos-configd
+++ b/src/services/vyos-configd
@@ -33,6 +33,7 @@ from enum import Enum
import zmq
from vyos.defaults import directories
+from vyos.defaults import vyos_configd_socket_path
from vyos.utils.boot import boot_configuration_complete
from vyos.configsource import ConfigSourceString
from vyos.configsource import ConfigSourceError
@@ -57,7 +58,7 @@ if debug:
else:
logger.setLevel(logging.INFO)
-SOCKET_PATH = 'ipc:///run/vyos-configd.sock'
+SOCKET_PATH = vyos_configd_socket_path
MAX_MSG_SIZE = 65535
PAD_MSG_SIZE = 6