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):