diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2 index 031b141372..7302c2cade 100644 --- a/data/templates/dhcp-server/kea-dhcp4.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2 @@ -28,6 +28,9 @@ "persist": true, "name": "{{ lease_file }}" }, +{% if client_class is vyos_defined %} + "client-classes": {{ client_class | kea_client_class_json }}, +{% endif %} "option-def": [ { "name": "wpad-url", diff --git a/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i b/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i index 4dacb02af8..c52a6e0436 100644 --- a/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i +++ b/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i @@ -1,4 +1,49 @@ + + + Client class name + + #include + + Client class name may only contain letters, numbers, dots, underscores, and hyphens + + + #include + + + Match DHCP Option 82 (relay agent information) + + + + + Filters on the contents of the circuit-id sub option + + hex + Values that start with 0x are interpreted as raw hex. This must only be hexadecimal characters e.g. 0x1234567890ABCDEF + + + txt + Any other text string is interpreted as ASCII text + + + + + + Filters on the contents of the remote-id sub option + + hex + Values that start with 0x are interpreted as raw hex. This must only be hexadecimal characters e.g. 0x1234567890ABCDEF + + + txt + Any other text string is interpreted as ASCII text + + + + + + + #include @@ -229,6 +274,14 @@ #include #include #include + + + DHCP client class + + service dhcp-server client-class + + + Dynamically update Domain Name System (RFC4702) @@ -280,6 +333,14 @@ #include + + + DHCP client class + + service dhcp-server client-class + + + First IP address for DHCP lease range diff --git a/python/vyos/kea.py b/python/vyos/kea.py index f48cf293dc..323fded5d9 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -179,6 +179,9 @@ def kea_parse_subnet(subnet, config): if 'ping_check' in config: out['user-context']['enable-ping-check'] = True + if 'client_class' in config: + out['client-class'] = config['client_class'] + if 'range' in config: pools = [] for num, range_config in config['range'].items(): @@ -194,6 +197,9 @@ def kea_parse_subnet(subnet, config): if 'bootfile_server' in range_config['option']: pool['next-server'] = range_config['option']['bootfile_server'] + if 'client_class' in range_config: + pool['client-class'] = range_config['client_class'] + pools.append(pool) out['pools'] = pools @@ -677,3 +683,22 @@ def kea_get_server_leases(config, inet, vrf_name, pools=[], state=[], origin=Non data.pop(idx) return data + +def _build_relay_hex_condition(sub_option_index, value): + if value.startswith("0x"): + return f"relay4[{sub_option_index}].hex == {value}" + else: + return f"relay4[{sub_option_index}].hex == 0x{value.encode().hex().lower()}" + +def kea_build_client_class_test(config): + conditions = [] + + if "relay_agent_information" in config: + if "circuit_id" in config["relay_agent_information"]: + conditions.append(_build_relay_hex_condition(1, config["relay_agent_information"]["circuit_id"])) + if "remote_id" in config["relay_agent_information"]: + conditions.append(_build_relay_hex_condition(2, config["relay_agent_information"]["remote_id"])) + + test = " and ".join(conditions) + + return test diff --git a/python/vyos/template.py b/python/vyos/template.py index f384f752db..44278e963e 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -914,6 +914,25 @@ def kea_high_availability_json(config): return dumps(data) +@register_filter('kea_client_class_json') +def kea_client_class_json(client_classes): + from vyos.kea import kea_build_client_class_test + from json import dumps + out = [] + + for name, config in client_classes.items(): + if 'disable' in config: + continue + + client_class = { + 'name': name, + 'test': kea_build_client_class_test(config) + } + + out.append(client_class) + + return dumps(out, indent=4) + @register_filter('kea_dynamic_dns_update_main_json') def kea_dynamic_dns_update_main_json(config): from vyos.kea import kea_parse_ddns_settings diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index 9f66bdb78b..285b56dfeb 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -114,27 +114,7 @@ def test_dhcp_single_pool_range(self): range_1_start = inc_ip(subnet, 40) range_1_stop = inc_ip(subnet, 50) - self.cli_set(base_path + ['listen-interface', interface]) - - self.cli_set(base_path + ['shared-network-name', shared_net_name, 'ping-check']) - - pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] - self.cli_set(pool + ['subnet-id', '1']) - self.cli_set(pool + ['ignore-client-id']) - self.cli_set(pool + ['ping-check']) - # we use the first subnet IP address as default gateway - 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): - self.cli_commit() - 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', '1', 'start', range_1_start]) - self.cli_set(pool + ['range', '1', 'stop', range_1_stop]) + self.setup_single_pool_range(range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name) # commit changes self.cli_commit() @@ -211,6 +191,112 @@ def test_dhcp_single_pool_range(self): # Check for running process self.verify_service_running() + def setup_single_pool_range(self, range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name): + self.cli_set(base_path + ['listen-interface', interface]) + self.cli_set(base_path + ['shared-network-name', shared_net_name, 'ping-check']) + + pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] + + self.cli_set(pool + ['subnet-id', '1']) + self.cli_set(pool + ['ignore-client-id']) + self.cli_set(pool + ['ping-check']) + # we use the first subnet IP address as default gateway + 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): + self.cli_commit() + + 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', '1', 'start', range_1_start]) + self.cli_set(pool + ['range', '1', 'stop', range_1_stop]) + + def test_dhcp_client_class(self): + shared_net_name = 'SMOKE-1' + + range_0_start = inc_ip(subnet, 10) + range_0_stop = inc_ip(subnet, 20) + range_1_start = inc_ip(subnet, 40) + range_1_stop = inc_ip(subnet, 50) + + self.setup_single_pool_range(range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name) + + self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test']) + + # check validate() - Client class referenced that doesn't exist yet + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test']) + + self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'range', '0', 'client-class', 'test']) + + # check validate() - Client class referenced that doesn't exist yet + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test']) + + client_class = base_path + ['client-class', 'test'] + + # Test that invalid hex is rejected + self.cli_set(client_class + ['relay-agent-information', 'circuit-id', '0xHELLOWORLD']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(client_class + ['relay-agent-information', 'circuit-id']) + self.cli_set(client_class + ['relay-agent-information', 'remote-id', '0xHELLOWORLD']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(client_class + ['relay-agent-information', 'remote-id']) + + # Test string literals + self.cli_set(client_class + ['relay-agent-information', 'circuit-id', 'foo']) + self.cli_set(client_class + ['relay-agent-information', 'remote-id', 'bar']) + + self.cli_commit() + + self.check_client_class_in_config() + + self.cli_delete(client_class + ['relay-agent-information', 'circuit-id']) + self.cli_delete(client_class + ['relay-agent-information', 'remote-id']) + + # Test hex strings + self.cli_set(client_class + ['relay-agent-information', 'circuit-id', '0x666f6f']) + self.cli_set(client_class + ['relay-agent-information', 'remote-id', '0x626172']) + + self.cli_commit() + + self.check_client_class_in_config() + + def check_client_class_in_config(self): + config = read_file(KEA4_CONF) + obj = loads(config) + self.verify_config_value( + obj, ['Dhcp4', 'client-classes', 0], 'name', 'test' + ) + self.verify_config_value( + obj, ['Dhcp4', 'client-classes', 0], 'test', + 'relay4[1].hex == 0x666f6f and relay4[2].hex == 0x626172' + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0], 'client-class', + 'test' + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0], + 'client-class', 'test' + ) + # Check for running process + self.verify_service_running() + def test_dhcp_single_pool_options(self): shared_net_name = 'SMOKE-0815' diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index e4730f8a8e..144739372f 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import os +import re from sys import exit from sys import argv @@ -287,6 +288,12 @@ def verify(dhcp): f'DHCP static-route "{route}" requires router to be defined!' ) + # If a client class has been specified then it must exist + if 'client_class' in subnet_config: + client_class = subnet_config['client_class'] + if client_class not in dhcp.get('client_class', {}): + raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist') + # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] @@ -296,6 +303,12 @@ def verify(dhcp): f'DHCP range "{range}" start and stop address must be defined!' ) + # If a client class has been specified then it must exist + if 'client_class' in range_config: + client_class = range_config['client_class'] + if client_class not in dhcp.get('client_class', {}): + raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist') + # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): @@ -504,6 +517,26 @@ def verify(dhcp): if 'reverse_domain' in ddns: verify_ddns_domain_servers('Reverse', ddns['reverse_domain']) + if 'client_class' in dhcp: + # Check client class values are valid + for class_name, class_config in dhcp['client_class'].items(): + if 'relay_agent_information' in class_config: + relay_agent_information_config = class_config['relay_agent_information'] + # Compile a regex that will scan for valid inputs. Input can be + # either hex in the form 0x0123456789ABCDEF or a string that + # does *not* start with 0x. i.e. 0xHELLOWORLD is bad + pattern = re.compile(r'^(?:0x[0-9A-Fa-f]+|(?!0x).+)$') + + if 'circuit_id' in relay_agent_information_config: + circuit_id = relay_agent_information_config['circuit_id'] + if not pattern.match(circuit_id): + raise ConfigError(f'Invalid circuit-id "{circuit_id}" must be either text literal or hex string starting with 0x') + + if 'remote_id' in relay_agent_information_config: + remote_id = relay_agent_information_config['remote_id'] + if not pattern.match(remote_id): + raise ConfigError(f'Invalid remote-id "{remote_id}" must be either text literal or hex string starting with 0x') + return None