Skip to content

Commit c284938

Browse files
authored
Merge pull request #4622 from MattKobayashi/T3680
T3680: protocols: add dhclient hooks for dhcp-interface static routes
2 parents d272b25 + c4632bb commit c284938

File tree

9 files changed

+248
-2
lines changed

9 files changed

+248
-2
lines changed

python/vyos/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
config_status = '/tmp/vyos-config-status'
6262
api_config_state = '/run/http-api-state'
6363
frr_debug_enable = '/tmp/vyos.frr.debug'
64+
static_route_dhcp_interfaces_path = '/tmp/static_dhcp_interfaces'
65+
vyos_configd_socket_path = 'ipc:///run/vyos-configd.sock'
6466

6567
cfg_group = 'vyattacfg'
6668

python/vyos/frrender.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@
3131
from vyos.configdict import get_dhcp_interfaces
3232
from vyos.configdict import get_pppoe_interfaces
3333
from vyos.defaults import frr_debug_enable
34+
from vyos.defaults import static_route_dhcp_interfaces_path
3435
from vyos.utils.dict import dict_search
3536
from vyos.utils.dict import dict_set_nested
37+
from vyos.utils.file import read_file
3638
from vyos.utils.file import write_file
3739
from vyos.utils.process import cmd
3840
from vyos.utils.process import rc_cmd
41+
from vyos.template import get_dhcp_router
3942
from vyos.template import render_to_string
4043
from vyos import ConfigError
4144

@@ -634,6 +637,7 @@ def dict_helper_nhrp_defaults(nhrp):
634637

635638
class FRRender:
636639
cached_config_dict = {}
640+
cached_dhcp_gateways = {}
637641
def __init__(self):
638642
self._frr_conf = '/run/frr/config/vyos.frr.conf'
639643

@@ -646,11 +650,20 @@ def generate(self, config_dict) -> None:
646650
tmp = type(config_dict)
647651
raise ValueError(f'Config must be of type "dict" and not "{tmp}"!')
648652

653+
dhcp_gateways = {
654+
interface: get_dhcp_router(interface)
655+
for interface in read_file(static_route_dhcp_interfaces_path, '').split()
656+
}
649657

650-
if self.cached_config_dict == config_dict:
658+
if (
659+
self.cached_config_dict == config_dict
660+
and self.cached_dhcp_gateways == dhcp_gateways
661+
):
651662
debug('FRR: NO CHANGES DETECTED')
652663
return False
664+
653665
self.cached_config_dict = config_dict
666+
self.cached_dhcp_gateways = dhcp_gateways
654667

655668
def inline_helper(config_dict) -> str:
656669
output = '!\n'

smoketest/scripts/cli/test_protocols_static.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,5 +651,112 @@ def test_06_dhcp_default_route_for_vrf(self):
651651
while process_named_running('dhclient', cmdline=interface, timeout=10):
652652
sleep(0.250)
653653

654+
def test_07_dhcp_interface_static_routes(self):
655+
# Test static routes using dhcp-interface option
656+
# When running via vyos-build under the QEMU environment a local DHCP
657+
# server is available. This test verifies that static routes with
658+
# dhcp-interface are configured correctly.
659+
if not os.path.exists('/tmp/vyos.smoketests.hint'):
660+
self.skipTest('Not running under VyOS CI/CD QEMU environment!')
661+
662+
dhcp_interface = 'eth0'
663+
interface_path = ['interfaces', 'ethernet', dhcp_interface]
664+
665+
# Configure DHCP on the interface
666+
self.cli_set(interface_path + ['address', 'dhcp'])
667+
668+
# Commit configuration
669+
self.cli_commit()
670+
671+
# Wait for dhclient to receive IP address
672+
sleep(5)
673+
674+
# Configure static routes with dhcp-interface
675+
dhcp_routes = {
676+
'10.10.0.0/16': {
677+
'dhcp_interface': [dhcp_interface],
678+
},
679+
'192.168.100.0/24': {
680+
'dhcp_interface': [dhcp_interface],
681+
},
682+
}
683+
684+
# Configure the static routes
685+
for route, route_config in dhcp_routes.items():
686+
base = base_path + ['route', route]
687+
if 'dhcp_interface' in route_config:
688+
for dhcp_if in route_config['dhcp_interface']:
689+
self.cli_set(base + ['dhcp-interface', dhcp_if])
690+
691+
# Commit configuration
692+
self.cli_commit()
693+
694+
# Verify that the DHCP hook interface list file is created
695+
dhcp_hook_iflist = '/tmp/static_dhcp_interfaces'
696+
self.assertTrue(
697+
os.path.exists(dhcp_hook_iflist),
698+
'DHCP hook interface list file should be created',
699+
)
700+
701+
# Read the interface list file and verify it contains our interface
702+
with open(dhcp_hook_iflist, 'r') as f:
703+
interface_list = f.read().strip()
704+
self.assertIn(
705+
dhcp_interface,
706+
interface_list,
707+
f'Interface {dhcp_interface} should be in hook interface list',
708+
)
709+
710+
# Get the DHCP router for verification
711+
router = get_dhcp_router(dhcp_interface)
712+
self.assertIsNotNone(router, 'DHCP router should be available')
713+
714+
# Verify FRR configuration contains the static routes with DHCP router
715+
frrconfig = self.getFRRconfig('ip route')
716+
717+
for route in dhcp_routes.keys():
718+
expected_route = f'ip route {route} {router} {dhcp_interface}'
719+
self.assertIn(
720+
expected_route,
721+
frrconfig,
722+
f'Static route {route} with dhcp-interface should be in FRR config',
723+
)
724+
725+
# Test table-based routes with dhcp-interface
726+
table_id = '100'
727+
table_route = '10.20.0.0/16'
728+
table_base = base_path + ['table', table_id, 'route', table_route]
729+
self.cli_set(table_base + ['dhcp-interface', dhcp_interface])
730+
self.cli_commit()
731+
732+
# Verify table route in FRR config
733+
frrconfig = self.getFRRconfig('ip route')
734+
expected_table_route = (
735+
f'ip route {table_route} {router} {dhcp_interface} table {table_id}'
736+
)
737+
self.assertIn(
738+
expected_table_route,
739+
frrconfig,
740+
f'Table static route {table_route} with dhcp-interface should be in FRR config',
741+
)
742+
743+
# Clean up - remove DHCP configuration
744+
self.cli_delete(interface_path + ['address'])
745+
self.cli_commit()
746+
747+
# Wait for dhclient to stop
748+
while process_named_running('dhclient', cmdline=dhcp_interface, timeout=10):
749+
sleep(0.250)
750+
751+
# Verify that the hook interface list file is cleaned up when no dhcp-interface routes exist
752+
self.cli_delete(base_path)
753+
self.cli_commit()
754+
755+
# The interface list file should be removed when no dhcp-interface routes are configured
756+
self.assertFalse(
757+
os.path.exists(dhcp_hook_iflist),
758+
'DHCP hook interface list file should be removed when no dhcp-interface routes exist',
759+
)
760+
654761
if __name__ == '__main__':
655762
unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on())

src/conf_mode/protocols_static.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ipaddress import IPv4Network
1818
from sys import exit
1919
from sys import argv
20+
import os
2021

2122
from vyos.config import Config
2223
from vyos.configverify import has_frr_protocol_in_dict
@@ -25,12 +26,15 @@
2526
from vyos.frrender import FRRender
2627
from vyos.frrender import get_frrender_dict
2728
from vyos.utils.process import is_systemd_service_running
29+
from vyos.utils.file import write_file
2830
from vyos.template import render
2931
from vyos import ConfigError
3032
from vyos import airbag
33+
from vyos import defaults
3134
airbag.enable()
3235

3336
config_file = '/etc/iproute2/rt_tables.d/vyos-static.conf'
37+
DHCP_HOOK_IFLIST = defaults.static_route_dhcp_interfaces_path
3438

3539
def get_config(config=None):
3640
if config:
@@ -92,6 +96,22 @@ def generate(config_dict):
9296
# eqivalent of the C foo ? 'a' : 'b' statement
9397
static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static']
9498

99+
# Collect interfaces that have DHCP configuration for DHCP hooks
100+
dhcp_interfaces = set()
101+
102+
# Check for DHCP interfaces in route configurations
103+
if 'route' in static:
104+
for prefix, prefix_options in static['route'].items():
105+
if 'dhcp_interface' in prefix_options:
106+
for interface_name in prefix_options['dhcp_interface']:
107+
dhcp_interfaces.add(interface_name)
108+
109+
# Write the interface list for DHCP hooks or clean up if empty
110+
if dhcp_interfaces:
111+
write_file(DHCP_HOOK_IFLIST, " ".join(dhcp_interfaces))
112+
elif os.path.exists(DHCP_HOOK_IFLIST):
113+
os.unlink(DHCP_HOOK_IFLIST)
114+
95115
# Put routing table names in /etc/iproute2/rt_tables
96116
render(config_file, 'iproute2/static.conf.j2', static)
97117

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
#
3+
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License version 2 or later as
7+
# published by the Free Software Foundation.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
DHCP_HOOK_IFLIST="/tmp/static_dhcp_interfaces"
18+
19+
# Only run if there are static routes with dhcp-interface configured
20+
if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then
21+
return 0
22+
fi
23+
24+
# Handle interface state changes that require static route regeneration
25+
# - PREINIT: interface is about to be configured, cleanup old routes
26+
# - EXPIRE: lease has expired, remove routes
27+
# - FAIL: DHCP failed, remove routes
28+
# - RELEASE: lease released, remove routes
29+
# - STOP: dhclient stopped, remove routes
30+
if [ "$reason" == "PREINIT" ] || [ "$reason" == "EXPIRE" ] || [ "$reason" == "FAIL" ] || [ "$reason" == "RELEASE" ] || [ "$reason" == "STOP" ]; then
31+
# Re-generate static routes config to remove routes that depend on this interface
32+
sudo /usr/libexec/vyos/vyos-request-configd-update.py
33+
fi
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/bash
2+
#
3+
# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License version 2 or later as
7+
# published by the Free Software Foundation.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
DHCP_HOOK_IFLIST="/tmp/static_dhcp_interfaces"
18+
19+
# Only run if there are static routes with dhcp-interface configured
20+
if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then
21+
return 0
22+
fi
23+
24+
# Re-generate the config on the following events:
25+
# - BOUND: always re-generate
26+
# - RENEW: re-generate if the IP address changed
27+
# - REBIND: re-generate if the IP address changed
28+
# - EXPIRE: always re-generate (route should be removed)
29+
# - RELEASE: always re-generate (route should be removed)
30+
if [ "$reason" == "RENEW" ] || [ "$reason" == "REBIND" ]; then
31+
if [ "$old_routers" == "$new_routers" ]; then
32+
return 0
33+
fi
34+
elif [ "$reason" != "BOUND" ] && [ "$reason" != "EXPIRE" ] && [ "$reason" != "RELEASE" ]; then
35+
return 0
36+
fi
37+
38+
# Re-generate the static routes config
39+
sudo /usr/libexec/vyos/vyos-request-configd-update.py
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import zmq
5+
6+
from vyos.utils.commit import wait_for_commit_lock
7+
from vyos.defaults import vyos_configd_socket_path
8+
9+
context = zmq.Context()
10+
11+
request = {
12+
'type': 'node',
13+
'last': True,
14+
'data': '/usr/libexec/vyos/conf_mode/protocols_static.py',
15+
}
16+
request = json.dumps(request)
17+
18+
print("Waiting for commit lock...")
19+
wait_for_commit_lock()
20+
21+
print("Connecting to vyos-configd server...")
22+
socket = context.socket(zmq.REQ)
23+
socket.connect(vyos_configd_socket_path)
24+
25+
print(f"Sending request {request}...")
26+
socket.send_string(request)
27+
28+
message = socket.recv()
29+
print(f"Received reply {request} [ {message} ]")
30+
31+
print("All done")

src/services/vyos-configd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ from enum import Enum
3333
import zmq
3434

3535
from vyos.defaults import directories
36+
from vyos.defaults import vyos_configd_socket_path
3637
from vyos.utils.boot import boot_configuration_complete
3738
from vyos.configsource import ConfigSourceString
3839
from vyos.configsource import ConfigSourceError
@@ -57,7 +58,7 @@ if debug:
5758
else:
5859
logger.setLevel(logging.INFO)
5960

60-
SOCKET_PATH = 'ipc:///run/vyos-configd.sock'
61+
SOCKET_PATH = vyos_configd_socket_path
6162
MAX_MSG_SIZE = 65535
6263
PAD_MSG_SIZE = 6
6364

0 commit comments

Comments
 (0)