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