From daffee2cbf001dab13799f5b2b69330162491214 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Sun, 7 Jan 2024 09:24:10 +0100 Subject: dhcp: T3316: Move options to separate node and extend scopes --- interface-definitions/include/dhcp/option-v4.xml.i | 257 ++++++++++++++++++++ .../include/version/dhcp-server-version.xml.i | 2 +- interface-definitions/service_dhcp-server.xml.in | 258 +-------------------- python/vyos/kea.py | 56 +++-- python/vyos/template.py | 15 +- smoketest/scripts/cli/test_service_dhcp-server.py | 132 ++++++++--- src/migration-scripts/dhcp-server/8-to-9 | 69 ++++++ 7 files changed, 480 insertions(+), 309 deletions(-) create mode 100644 interface-definitions/include/dhcp/option-v4.xml.i create mode 100755 src/migration-scripts/dhcp-server/8-to-9 diff --git a/interface-definitions/include/dhcp/option-v4.xml.i b/interface-definitions/include/dhcp/option-v4.xml.i new file mode 100644 index 000000000..bd6fc6043 --- /dev/null +++ b/interface-definitions/include/dhcp/option-v4.xml.i @@ -0,0 +1,257 @@ + + + + DHCP option + + + #include + #include + #include + #include + #include + + + Bootstrap file name + + [[:ascii:]]{1,253} + + + + + + Server from which the initial boot file is to be loaded + + ipv4 + Bootfile server IPv4 address + + + hostname + Bootfile server FQDN + + + + + + + + + + Bootstrap file size + + u32:1-16 + Bootstrap file size in 512 byte blocks + + + + + + + + + Specifies the clients subnet mask as per RFC 950. If unset, subnet declaration is used. + + u32:0-32 + DHCP client prefix length must be 0 to 32 + + + + + DHCP client prefix length must be 0 to 32 + + + + + IP address of default router + + ipv4 + Default router IPv4 address + + + + + + + + + Enable IP forwarding on client + + + + + + Disable IPv4 on IPv6 only hosts (RFC 8925) + + u32 + Seconds + + + + + Seconds must be between 0 and 4294967295 (49 days) + + + + + IP address of POP3 server + + ipv4 + POP3 server IPv4 address + + + + + + + + + + Address for DHCP server identifier + + ipv4 + DHCP server identifier IPv4 address + + + + + + + + + IP address of SMTP server + + ipv4 + SMTP server IPv4 address + + + + + + + + + + Classless static route destination subnet + + ipv4net + IPv4 address and prefix length + + + + + + + + + IP address of router to be used to reach the destination subnet + + ipv4 + IPv4 address of router + + + + + + + + + + + TFTP server name + + ipv4 + TFTP server IPv4 address + + + hostname + TFTP server FQDN + + + + + + + + + + Client subnet offset in seconds from Coordinated Universal Time (UTC) + + [-]N + Time offset (number, may be negative) + + + -?[0-9]+ + + Invalid time offset value + + + + + IP address of time server + + ipv4 + Time server IPv4 address + + + + + + + + + + Time zone to send to clients. Uses RFC4833 options 100 and 101 + + + + + + + + + + + Vendor Specific Options + + + + + Ubiquiti specific parameters + + + + + Address of UniFi controller + + ipv4 + IP address of UniFi controller + + + + + + + + + + + + + IP address for Windows Internet Name Service (WINS) server + + ipv4 + WINS server IPv4 address + + + + + + + + + + Web Proxy Autodiscovery (WPAD) URL + + + + + diff --git a/interface-definitions/include/version/dhcp-server-version.xml.i b/interface-definitions/include/version/dhcp-server-version.xml.i index cc84ea8b9..d83172e72 100644 --- a/interface-definitions/include/version/dhcp-server-version.xml.i +++ b/interface-definitions/include/version/dhcp-server-version.xml.i @@ -1,3 +1,3 @@ - + diff --git a/interface-definitions/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in index 8e13f9372..a5cee62d1 100644 --- a/interface-definitions/service_dhcp-server.xml.in +++ b/interface-definitions/service_dhcp-server.xml.in @@ -89,12 +89,9 @@ - #include - #include - #include + #include #include #include - #include DHCP subnet for shared network @@ -108,73 +105,9 @@ Invalid IPv4 subnet definition - - - Bootstrap file name - - [[:ascii:]]{1,253} - - - - - - Server from which the initial boot file is to be loaded - - ipv4 - Bootfile server IPv4 address - - - hostname - Bootfile server FQDN - - - - - - - - - - Bootstrap file size - - u32:1-16 - Bootstrap file size in 512 byte blocks - - - - - - - #include - - - Specifies the clients subnet mask as per RFC 950. If unset, subnet declaration is used. - - u32:0-32 - DHCP client prefix length must be 0 to 32 - - - - - DHCP client prefix length must be 0 to 32 - - - - - IP address of default router - - ipv4 - Default router IPv4 address - - - - - - - #include - #include + #include #include - #include + #include IP address to exclude from DHCP lease range @@ -188,12 +121,6 @@ - - - Enable IP forwarding on client - - - Lease timeout in seconds @@ -208,45 +135,6 @@ 86400 - #include - - - IP address of POP3 server - - ipv4 - POP3 server IPv4 address - - - - - - - - - - Address for DHCP server identifier - - ipv4 - DHCP server identifier IPv4 address - - - - - - - - - IP address of SMTP server - - ipv4 - SMTP server IPv4 address - - - - - - - DHCP lease range @@ -256,6 +144,7 @@ Invalid range name, may only be alphanumeric, dot and hyphen + #include First IP address for DHCP lease range @@ -291,6 +180,8 @@ Invalid static mapping hostname + #include + #include #include @@ -308,143 +199,6 @@ #include - - - Classless static route destination subnet - - ipv4net - IPv4 address and prefix length - - - - - - - - - IP address of router to be used to reach the destination subnet - - ipv4 - IPv4 address of router - - - - - - - - - - - Disable IPv4 on IPv6 only hosts (RFC 8925) - - u32 - Seconds - - - - - Seconds must be between 0 and 4294967295 (49 days) - - - - - TFTP server name - - ipv4 - TFTP server IPv4 address - - - hostname - TFTP server FQDN - - - - - - - - - - Client subnet offset in seconds from Coordinated Universal Time (UTC) - - [-]N - Time offset (number, may be negative) - - - -?[0-9]+ - - Invalid time offset value - - - - - IP address of time server - - ipv4 - Time server IPv4 address - - - - - - - - - - Time zone to send to clients. Uses RFC4833 options 100 and 101 - - - - - - - - - - - Vendor Specific Options - - - - - Ubiquiti specific parameters - - - - - Address of UniFi controller - - ipv4 - IP address of UniFi controller - - - - - - - - - - - - - IP address for Windows Internet Name Service (WINS) server - - ipv4 - WINS server IPv4 address - - - - - - - - - - Web Proxy Autodiscovery (WPAD) URL - - diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 819fe16a9..2ca73044b 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -92,17 +92,28 @@ def kea_parse_options(config): options.append({'name': 'pcode', 'data': tz_string}) options.append({'name': 'tcode', 'data': config['time_zone']}) + unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller') + if unifi_controller: + options.append({ + 'name': 'unifi-controller', + 'data': unifi_controller, + 'space': 'ubnt' + }) + return options def kea_parse_subnet(subnet, config): out = {'subnet': subnet} - options = kea_parse_options(config) + options = [] + + if 'option' in config: + out['option-data'] = kea_parse_options(config['option']) - if 'bootfile_name' in config: - out['boot-file-name'] = config['bootfile_name'] + if 'bootfile_name' in config['option']: + out['boot-file-name'] = config['option']['bootfile_name'] - if 'bootfile_server' in config: - out['next-server'] = config['bootfile_server'] + if 'bootfile_server' in config['option']: + out['next-server'] = config['option']['bootfile_server'] if 'lease' in config: out['valid-lifetime'] = int(config['lease']) @@ -112,7 +123,20 @@ def kea_parse_subnet(subnet, config): pools = [] for num, range_config in config['range'].items(): start, stop = range_config['start'], range_config['stop'] - pools.append({'pool': f'{start} - {stop}'}) + pool = { + 'pool': f'{start} - {stop}' + } + + if 'option' in range_config: + pool['option-data'] = kea_parse_options(range_config['option']) + + if 'bootfile_name' in range_config['option']: + pool['boot-file-name'] = range_config['option']['bootfile_name'] + + if 'bootfile_server' in range_config['option']: + pool['next-server'] = range_config['option']['bootfile_server'] + + pools.append(pool) out['pools'] = pools if 'static_mapping' in config: @@ -134,19 +158,17 @@ def kea_parse_subnet(subnet, config): if 'ip_address' in host_config: reservation['ip-address'] = host_config['ip_address'] - reservations.append(reservation) - out['reservations'] = reservations + if 'option' in host_config: + reservation['option-data'] = kea_parse_options(host_config['option']) - unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller') - if unifi_controller: - options.append({ - 'name': 'unifi-controller', - 'data': unifi_controller, - 'space': 'ubnt' - }) + if 'bootfile_name' in host_config['option']: + reservation['boot-file-name'] = host_config['option']['bootfile_name'] - if options: - out['option-data'] = options + if 'bootfile_server' in host_config['option']: + reservation['next-server'] = host_config['option']['bootfile_server'] + + reservations.append(reservation) + out['reservations'] = reservations return out diff --git a/python/vyos/template.py b/python/vyos/template.py index 29ea0889b..c0c09f690 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -842,15 +842,22 @@ def kea_shared_network_json(shared_networks): 'authoritative': ('authoritative' in config), 'subnet4': [] } - options = kea_parse_options(config) + + if 'option' in config: + network['option-data'] = kea_parse_options(config['option']) + + if 'bootfile_name' in config['option']: + network['boot-file-name'] = config['option']['bootfile_name'] + + if 'bootfile_server' in config['option']: + network['next-server'] = config['option']['bootfile_server'] if 'subnet' in config: for subnet, subnet_config in config['subnet'].items(): + if 'disable' in subnet_config: + continue network['subnet4'].append(kea_parse_subnet(subnet, subnet_config)) - if options: - network['option-data'] = options - out.append(network) return dumps(out, indent=4) diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index bf0c09965..99ac406cd 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -97,10 +97,10 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] # we use the first subnet IP address as default gateway - self.cli_set(pool + ['default-router', router]) - self.cli_set(pool + ['name-server', dns_1]) - self.cli_set(pool + ['name-server', dns_2]) - self.cli_set(pool + ['domain-name', domain_name]) + self.cli_set(pool + ['option', 'default-router', router]) + self.cli_set(pool + ['option', 'name-server', dns_1]) + self.cli_set(pool + ['option', 'name-server', dns_2]) + self.cli_set(pool + ['option', 'domain-name', domain_name]) # check validate() - No DHCP address range or active static-mapping set with self.assertRaises(ConfigSessionError): @@ -165,29 +165,26 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] # we use the first subnet IP address as default gateway - self.cli_set(pool + ['default-router', router]) - self.cli_set(pool + ['name-server', dns_1]) - self.cli_set(pool + ['name-server', dns_2]) - self.cli_set(pool + ['domain-name', domain_name]) - self.cli_set(pool + ['ip-forwarding']) - self.cli_set(pool + ['smtp-server', smtp_server]) - self.cli_set(pool + ['pop-server', smtp_server]) - self.cli_set(pool + ['time-server', time_server]) - self.cli_set(pool + ['tftp-server-name', tftp_server]) + self.cli_set(pool + ['option', 'default-router', router]) + self.cli_set(pool + ['option', 'name-server', dns_1]) + self.cli_set(pool + ['option', 'name-server', dns_2]) + self.cli_set(pool + ['option', 'domain-name', domain_name]) + self.cli_set(pool + ['option', 'ip-forwarding']) + self.cli_set(pool + ['option', 'smtp-server', smtp_server]) + self.cli_set(pool + ['option', 'pop-server', smtp_server]) + self.cli_set(pool + ['option', 'time-server', time_server]) + self.cli_set(pool + ['option', 'tftp-server-name', tftp_server]) for search in search_domains: - self.cli_set(pool + ['domain-search', search]) - self.cli_set(pool + ['bootfile-name', bootfile_name]) - self.cli_set(pool + ['bootfile-server', bootfile_server]) - self.cli_set(pool + ['wpad-url', wpad]) - self.cli_set(pool + ['server-identifier', server_identifier]) + self.cli_set(pool + ['option', 'domain-search', search]) + self.cli_set(pool + ['option', 'bootfile-name', bootfile_name]) + self.cli_set(pool + ['option', 'bootfile-server', bootfile_server]) + self.cli_set(pool + ['option', 'wpad-url', wpad]) + self.cli_set(pool + ['option', 'server-identifier', server_identifier]) - self.cli_set(pool + ['static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1']) - self.cli_set(pool + ['ipv6-only-preferred', ipv6_only_preferred]) - self.cli_set(pool + ['time-zone', 'Europe/London']) + self.cli_set(pool + ['option', 'static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1']) + self.cli_set(pool + ['option', 'ipv6-only-preferred', ipv6_only_preferred]) + self.cli_set(pool + ['option', 'time-zone', 'Europe/London']) - # check validate() - No DHCP address range or active static-mapping set - with self.assertRaises(ConfigSessionError): - self.cli_commit() self.cli_set(pool + ['range', '0', 'start', range_0_start]) self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) @@ -281,16 +278,81 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): # Check for running process self.assertTrue(process_named_running(PROCESS_NAME)) + def test_dhcp_single_pool_options_scoped(self): + shared_net_name = 'SMOKE-2' + + range_0_start = inc_ip(subnet, 10) + range_0_stop = inc_ip(subnet, 20) + + range_router = inc_ip(subnet, 5) + range_dns_1 = inc_ip(subnet, 6) + range_dns_2 = inc_ip(subnet, 7) + + shared_network = base_path + ['shared-network-name', shared_net_name] + pool = shared_network + ['subnet', subnet] + # we use the first subnet IP address as default gateway + self.cli_set(shared_network + ['option', 'default-router', router]) + self.cli_set(shared_network + ['option', 'name-server', dns_1]) + self.cli_set(shared_network + ['option', 'name-server', dns_2]) + self.cli_set(shared_network + ['option', 'domain-name', domain_name]) + + self.cli_set(pool + ['range', '0', 'start', range_0_start]) + self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) + self.cli_set(pool + ['range', '0', 'option', 'default-router', range_router]) + self.cli_set(pool + ['range', '0', 'option', 'name-server', range_dns_1]) + self.cli_set(pool + ['range', '0', 'option', 'name-server', range_dns_2]) + + # commit changes + self.cli_commit() + + config = read_file(KEA4_CONF) + obj = loads(config) + + self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'name', shared_net_name) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'subnet', subnet) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'valid-lifetime', 86400) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'max-valid-lifetime', 86400) + + # Verify shared-network options + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'option-data'], + {'name': 'domain-name', 'data': domain_name}) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'option-data'], + {'name': 'domain-name-servers', 'data': f'{dns_1}, {dns_2}'}) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'option-data'], + {'name': 'routers', 'data': router}) + + # Verify range options + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0, 'option-data'], + {'name': 'domain-name-servers', 'data': f'{range_dns_1}, {range_dns_2}'}) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0, 'option-data'], + {'name': 'routers', 'data': range_router}) + + # Verify pool + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools'], 'pool', f'{range_0_start} - {range_0_stop}') + + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) + def test_dhcp_single_pool_static_mapping(self): shared_net_name = 'SMOKE-2' domain_name = 'private' pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] # we use the first subnet IP address as default gateway - self.cli_set(pool + ['default-router', router]) - self.cli_set(pool + ['name-server', dns_1]) - self.cli_set(pool + ['name-server', dns_2]) - self.cli_set(pool + ['domain-name', domain_name]) + self.cli_set(pool + ['option', 'default-router', router]) + self.cli_set(pool + ['option', 'name-server', dns_1]) + self.cli_set(pool + ['option', 'name-server', dns_2]) + self.cli_set(pool + ['option', 'domain-name', domain_name]) # check validate() - No DHCP address range or active static-mapping set with self.assertRaises(ConfigSessionError): @@ -365,9 +427,9 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] # we use the first subnet IP address as default gateway - self.cli_set(pool + ['default-router', router]) - self.cli_set(pool + ['name-server', dns_1]) - self.cli_set(pool + ['domain-name', domain_name]) + self.cli_set(pool + ['option', 'default-router', router]) + self.cli_set(pool + ['option', 'name-server', dns_1]) + self.cli_set(pool + ['option', 'domain-name', domain_name]) self.cli_set(pool + ['lease', lease_time]) self.cli_set(pool + ['range', '0', 'start', range_0_start]) @@ -448,7 +510,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): range_0_stop = inc_ip(subnet, 20) pool = base_path + ['shared-network-name', 'EXCLUDE-TEST', 'subnet', subnet] - self.cli_set(pool + ['default-router', router]) + self.cli_set(pool + ['option', 'default-router', router]) self.cli_set(pool + ['exclude', router]) self.cli_set(pool + ['range', '0', 'start', range_0_start]) self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) @@ -490,7 +552,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): range_0_start_excl = inc_ip(exclude_addr, 1) pool = base_path + ['shared-network-name', 'EXCLUDE-TEST-2', 'subnet', subnet] - self.cli_set(pool + ['default-router', router]) + self.cli_set(pool + ['option', 'default-router', router]) self.cli_set(pool + ['exclude', exclude_addr]) self.cli_set(pool + ['range', '0', 'start', range_0_start]) self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) @@ -535,7 +597,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): range_0_stop = '10.0.250.255' pool = base_path + ['shared-network-name', 'RELAY', 'subnet', relay_subnet] - self.cli_set(pool + ['default-router', relay_router]) + self.cli_set(pool + ['option', 'default-router', relay_router]) self.cli_set(pool + ['range', '0', 'start', range_0_start]) self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) @@ -572,7 +634,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] # we use the first subnet IP address as default gateway - self.cli_set(pool + ['default-router', router]) + self.cli_set(pool + ['option', 'default-router', router]) # check validate() - No DHCP address range or active static-mapping set with self.assertRaises(ConfigSessionError): diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9 new file mode 100755 index 000000000..908420c18 --- /dev/null +++ b/src/migration-scripts/dhcp-server/8-to-9 @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 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 . + +# T3316: +# - Migrate dhcp options under new option node + +import sys +import re +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcp-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + sys.exit(0) + +option_nodes = ['bootfile-name', 'bootfile-server', 'bootfile-size', 'captive-portal', + 'client-prefix-length', 'default-router', 'domain-name', 'domain-search', + 'name-server', 'ip-forwarding', 'ipv6-only-preferred', 'ntp-server', + 'pop-server', 'server-identifier', 'smtp-server', 'static-route', + 'tftp-server-name', 'time-offset', 'time-server', 'time-zone', + 'vendor-option', 'wins-server', 'wpad-url'] + +for network in config.list_nodes(base): + for option in option_nodes: + if config.exists(base + [network, option]): + config.set(base + [network, 'option']) + config.copy(base + [network, option], base + [network, 'option', option]) + config.delete(base + [network, option]) + + if config.exists(base + [network, 'subnet']): + for subnet in config.list_nodes(base + [network, 'subnet']): + base_subnet = base + [network, 'subnet', subnet] + + for option in option_nodes: + if config.exists(base + [network, 'subnet', subnet, option]): + config.set(base + [network, 'subnet', subnet, 'option']) + config.copy(base + [network, 'subnet', subnet, option], base + [network, 'subnet', subnet, 'option', option]) + config.delete(base + [network, 'subnet', subnet, option]) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) -- cgit v1.2.3