summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcblackburn-igl <chris.blackburn@infinitivegroup.co.uk>2025-10-28 15:38:43 +0000
committerGitHub <noreply@github.com>2025-10-28 15:38:43 +0000
commit1737b16dd87778358266222e2ef061eb15d63019 (patch)
tree0e127460ae080ad35a06b4e719d261ab3f7a1805
parentecc4c85b799047f51e4a32ae5b0590017a6ce374 (diff)
downloadvyos-1x-1737b16dd87778358266222e2ef061eb15d63019.tar.gz
vyos-1x-1737b16dd87778358266222e2ef061eb15d63019.zip
dhcp-server: T3936: Added support for DHCP Option 82 (#4665)
* dhcp-server: T3936: Added support for DHCP Option 82 This commit adds support in both the CLI and the underlying code for DHCP Option 82 to be used to filter/route DHCP address assignments. The primary use case for this is to support enterprise switches which can "tag" DHCP requests with physical real world informaiton such as which switch first saw the request and which port it originated from (known in this context as remote-id and circuit-id). Once client-classes have been defined they can be assigned to subnets or ranges so that only certain addresses get assigned to specific requests. There is also a corresponding documentation update which pairs with this code change. (cherry picked from commit 326b5e713cb363a2b9f69e2204c4ee2ccd9939bb) * Update src/conf_mode/service_dhcp-server.py Co-authored-by: Nataliia S. <81954790+natali-rs1985@users.noreply.github.com> * Update src/conf_mode/service_dhcp-server.py Co-authored-by: Nataliia S. <81954790+natali-rs1985@users.noreply.github.com> * Update interface-definitions/include/dhcp/dhcp-server-common-config.xml.i Co-authored-by: Nataliia S. <81954790+natali-rs1985@users.noreply.github.com> --------- Co-authored-by: Daniil Baturin <daniil@baturin.org> Co-authored-by: Nataliia S. <81954790+natali-rs1985@users.noreply.github.com>
-rw-r--r--data/templates/dhcp-server/kea-dhcp4.conf.j23
-rw-r--r--interface-definitions/include/dhcp/dhcp-server-common-config.xml.i61
-rw-r--r--python/vyos/kea.py25
-rwxr-xr-xpython/vyos/template.py19
-rwxr-xr-xsmoketest/scripts/cli/test_service_dhcp-server.py128
-rwxr-xr-xsrc/conf_mode/service_dhcp-server.py33
6 files changed, 248 insertions, 21 deletions
diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2
index 031b14137..7302c2cad 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 4dacb02af..c52a6e043 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 @@
<!-- include start from dhcp/dhcp-server-common-config.xml.i -->
+<tagNode name="client-class">
+ <properties>
+ <help>Client class name</help>
+ <constraint>
+ #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i>
+ </constraint>
+ <constraintErrorMessage>Client class name may only contain letters, numbers, dots, underscores, and hyphens</constraintErrorMessage>
+ </properties>
+ <children>
+ #include <include/generic-disable-node.xml.i>
+ <node name="relay-agent-information">
+ <properties>
+ <help>Match DHCP Option 82 (relay agent information)</help>
+ </properties>
+ <children>
+ <leafNode name="circuit-id">
+ <properties>
+ <help>Filters on the contents of the circuit-id sub option</help>
+ <valueHelp>
+ <format>hex</format>
+ <description>Values that start with 0x are interpreted as raw hex. This must only be hexadecimal characters e.g. 0x1234567890ABCDEF</description>
+ </valueHelp>
+ <valueHelp>
+ <format>txt</format>
+ <description>Any other text string is interpreted as ASCII text</description>
+ </valueHelp>
+ </properties>
+ </leafNode>
+ <leafNode name="remote-id">
+ <properties>
+ <help>Filters on the contents of the remote-id sub option</help>
+ <valueHelp>
+ <format>hex</format>
+ <description>Values that start with 0x are interpreted as raw hex. This must only be hexadecimal characters e.g. 0x1234567890ABCDEF</description>
+ </valueHelp>
+ <valueHelp>
+ <format>txt</format>
+ <description>Any other text string is interpreted as ASCII text</description>
+ </valueHelp>
+ </properties>
+ </leafNode>
+ </children>
+ </node>
+ </children>
+</tagNode>
#include <include/generic-disable-node.xml.i>
<node name="dynamic-dns-update">
<properties>
@@ -229,6 +274,14 @@
#include <include/dhcp/ping-check.xml.i>
#include <include/generic-description.xml.i>
#include <include/generic-disable-node.xml.i>
+ <leafNode name="client-class">
+ <properties>
+ <help>DHCP client class</help>
+ <completionHelp>
+ <path>service dhcp-server client-class</path>
+ </completionHelp>
+ </properties>
+ </leafNode>
<node name="dynamic-dns-update">
<properties>
<help>Dynamically update Domain Name System (RFC4702)</help>
@@ -280,6 +333,14 @@
</properties>
<children>
#include <include/dhcp/option-v4.xml.i>
+ <leafNode name="client-class">
+ <properties>
+ <help>DHCP client class</help>
+ <completionHelp>
+ <path>service dhcp-server client-class</path>
+ </completionHelp>
+ </properties>
+ </leafNode>
<leafNode name="start">
<properties>
<help>First IP address for DHCP lease range</help>
diff --git a/python/vyos/kea.py b/python/vyos/kea.py
index f48cf293d..323fded5d 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 f384f752d..44278e963 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 9f66bdb78..285b56dfe 100755
--- a/smoketest/scripts/cli/test_service_dhcp-server.py
+++ b/smoketest/scripts/cli/test_service_dhcp-server.py
@@ -114,27 +114,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
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 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
# 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 e4730f8a8..144739372 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 <http://www.gnu.org/licenses/>.
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