diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2 index d08ca0eaa8..3562da3d26 100644 --- a/data/templates/dhcp-server/kea-dhcp4.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2 @@ -23,6 +23,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/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in index 78f1cea4e5..0ac7afdddf 100644 --- a/interface-definitions/service_dhcp-server.xml.in +++ b/interface-definitions/service_dhcp-server.xml.in @@ -9,6 +9,43 @@ 911 + + + Client class name + + #include + + Shared network name may only contain letters, numbers and, dots, underscores, and hyphens + + + #include + + + Match DHCP option 82 (relay agent information) + + + + + Filters on the contents of the circuit-id sub option. + + txt + Assumes ASCII text unless input starts with 0x in which case it is interpreted as raw hex + + + + + + Filters on the contents of the remote-id sub option. + + txt + Assumes ASCII text unless input starts with 0x in which case it is interpreted as raw hex + + + + + + + #include @@ -239,6 +276,14 @@ #include #include #include + + + DHCP client class + + service dhcp-server client-class + + + Dynamically update Domain Name System (RFC4702) @@ -290,6 +335,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 15c8564b04..0f0821ea15 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -169,6 +169,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(): @@ -184,6 +187,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 @@ -661,3 +667,25 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list data.pop(idx) return data + +def kea_build_client_class_test(config): + conditions = [] + + if "option82" in config: + if "circuit_id" in config["option82"]: + conditions.append("relay4[1].hex == 0x" + config["option82"]["circuit_id"].encode().hex().lower()) + if "remote_id" in config["option82"]: + conditions.append("relay4[2].hex == 0x" + config["option82"]["remote_id"].encode().hex().lower()) + + is_first = True + + test = "" + for condition in conditions: + if not is_first: + test += " and " + + is_first = False + + test += condition + + return test diff --git a/python/vyos/template.py b/python/vyos/template.py index 824d421361..8b0b09f784 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -910,6 +910,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 c59fc4efd8..2f93d38aba 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -113,27 +113,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() @@ -210,6 +190,84 @@ 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'] + self.cli_set(client_class + ['option82', 'circuit-id', 'foo']) + self.cli_set(client_class + ['option82', 'remote-id', 'bar']) + + self.cli_commit() + + 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 b404b17085..1c9b97e25a 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -231,6 +231,15 @@ 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: + raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist') + + if client_class not in dhcp['client_class'].keys(): + 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 = [] @@ -240,6 +249,15 @@ 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: + raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist') + + if client_class not in dhcp['client_class'].keys(): + 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):