Skip to content

Commit 1737b16

Browse files
cblackburn-igldmbaturinnatali-rs1985
authored
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 326b5e7) * Update src/conf_mode/service_dhcp-server.py Co-authored-by: Nataliia S. <[email protected]> * Update src/conf_mode/service_dhcp-server.py Co-authored-by: Nataliia S. <[email protected]> * Update interface-definitions/include/dhcp/dhcp-server-common-config.xml.i Co-authored-by: Nataliia S. <[email protected]> --------- Co-authored-by: Daniil Baturin <[email protected]> Co-authored-by: Nataliia S. <[email protected]>
1 parent ecc4c85 commit 1737b16

File tree

6 files changed

+248
-21
lines changed

6 files changed

+248
-21
lines changed

data/templates/dhcp-server/kea-dhcp4.conf.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"persist": true,
2929
"name": "{{ lease_file }}"
3030
},
31+
{% if client_class is vyos_defined %}
32+
"client-classes": {{ client_class | kea_client_class_json }},
33+
{% endif %}
3134
"option-def": [
3235
{
3336
"name": "wpad-url",

interface-definitions/include/dhcp/dhcp-server-common-config.xml.i

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,49 @@
11
<!-- include start from dhcp/dhcp-server-common-config.xml.i -->
2+
<tagNode name="client-class">
3+
<properties>
4+
<help>Client class name</help>
5+
<constraint>
6+
#include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i>
7+
</constraint>
8+
<constraintErrorMessage>Client class name may only contain letters, numbers, dots, underscores, and hyphens</constraintErrorMessage>
9+
</properties>
10+
<children>
11+
#include <include/generic-disable-node.xml.i>
12+
<node name="relay-agent-information">
13+
<properties>
14+
<help>Match DHCP Option 82 (relay agent information)</help>
15+
</properties>
16+
<children>
17+
<leafNode name="circuit-id">
18+
<properties>
19+
<help>Filters on the contents of the circuit-id sub option</help>
20+
<valueHelp>
21+
<format>hex</format>
22+
<description>Values that start with 0x are interpreted as raw hex. This must only be hexadecimal characters e.g. 0x1234567890ABCDEF</description>
23+
</valueHelp>
24+
<valueHelp>
25+
<format>txt</format>
26+
<description>Any other text string is interpreted as ASCII text</description>
27+
</valueHelp>
28+
</properties>
29+
</leafNode>
30+
<leafNode name="remote-id">
31+
<properties>
32+
<help>Filters on the contents of the remote-id sub option</help>
33+
<valueHelp>
34+
<format>hex</format>
35+
<description>Values that start with 0x are interpreted as raw hex. This must only be hexadecimal characters e.g. 0x1234567890ABCDEF</description>
36+
</valueHelp>
37+
<valueHelp>
38+
<format>txt</format>
39+
<description>Any other text string is interpreted as ASCII text</description>
40+
</valueHelp>
41+
</properties>
42+
</leafNode>
43+
</children>
44+
</node>
45+
</children>
46+
</tagNode>
247
#include <include/generic-disable-node.xml.i>
348
<node name="dynamic-dns-update">
449
<properties>
@@ -229,6 +274,14 @@
229274
#include <include/dhcp/ping-check.xml.i>
230275
#include <include/generic-description.xml.i>
231276
#include <include/generic-disable-node.xml.i>
277+
<leafNode name="client-class">
278+
<properties>
279+
<help>DHCP client class</help>
280+
<completionHelp>
281+
<path>service dhcp-server client-class</path>
282+
</completionHelp>
283+
</properties>
284+
</leafNode>
232285
<node name="dynamic-dns-update">
233286
<properties>
234287
<help>Dynamically update Domain Name System (RFC4702)</help>
@@ -280,6 +333,14 @@
280333
</properties>
281334
<children>
282335
#include <include/dhcp/option-v4.xml.i>
336+
<leafNode name="client-class">
337+
<properties>
338+
<help>DHCP client class</help>
339+
<completionHelp>
340+
<path>service dhcp-server client-class</path>
341+
</completionHelp>
342+
</properties>
343+
</leafNode>
283344
<leafNode name="start">
284345
<properties>
285346
<help>First IP address for DHCP lease range</help>

python/vyos/kea.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ def kea_parse_subnet(subnet, config):
179179
if 'ping_check' in config:
180180
out['user-context']['enable-ping-check'] = True
181181

182+
if 'client_class' in config:
183+
out['client-class'] = config['client_class']
184+
182185
if 'range' in config:
183186
pools = []
184187
for num, range_config in config['range'].items():
@@ -194,6 +197,9 @@ def kea_parse_subnet(subnet, config):
194197
if 'bootfile_server' in range_config['option']:
195198
pool['next-server'] = range_config['option']['bootfile_server']
196199

200+
if 'client_class' in range_config:
201+
pool['client-class'] = range_config['client_class']
202+
197203
pools.append(pool)
198204
out['pools'] = pools
199205

@@ -677,3 +683,22 @@ def kea_get_server_leases(config, inet, vrf_name, pools=[], state=[], origin=Non
677683
data.pop(idx)
678684

679685
return data
686+
687+
def _build_relay_hex_condition(sub_option_index, value):
688+
if value.startswith("0x"):
689+
return f"relay4[{sub_option_index}].hex == {value}"
690+
else:
691+
return f"relay4[{sub_option_index}].hex == 0x{value.encode().hex().lower()}"
692+
693+
def kea_build_client_class_test(config):
694+
conditions = []
695+
696+
if "relay_agent_information" in config:
697+
if "circuit_id" in config["relay_agent_information"]:
698+
conditions.append(_build_relay_hex_condition(1, config["relay_agent_information"]["circuit_id"]))
699+
if "remote_id" in config["relay_agent_information"]:
700+
conditions.append(_build_relay_hex_condition(2, config["relay_agent_information"]["remote_id"]))
701+
702+
test = " and ".join(conditions)
703+
704+
return test

python/vyos/template.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,25 @@ def kea_high_availability_json(config):
914914

915915
return dumps(data)
916916

917+
@register_filter('kea_client_class_json')
918+
def kea_client_class_json(client_classes):
919+
from vyos.kea import kea_build_client_class_test
920+
from json import dumps
921+
out = []
922+
923+
for name, config in client_classes.items():
924+
if 'disable' in config:
925+
continue
926+
927+
client_class = {
928+
'name': name,
929+
'test': kea_build_client_class_test(config)
930+
}
931+
932+
out.append(client_class)
933+
934+
return dumps(out, indent=4)
935+
917936
@register_filter('kea_dynamic_dns_update_main_json')
918937
def kea_dynamic_dns_update_main_json(config):
919938
from vyos.kea import kea_parse_ddns_settings

smoketest/scripts/cli/test_service_dhcp-server.py

Lines changed: 107 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -114,27 +114,7 @@ def test_dhcp_single_pool_range(self):
114114
range_1_start = inc_ip(subnet, 40)
115115
range_1_stop = inc_ip(subnet, 50)
116116

117-
self.cli_set(base_path + ['listen-interface', interface])
118-
119-
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'ping-check'])
120-
121-
pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
122-
self.cli_set(pool + ['subnet-id', '1'])
123-
self.cli_set(pool + ['ignore-client-id'])
124-
self.cli_set(pool + ['ping-check'])
125-
# we use the first subnet IP address as default gateway
126-
self.cli_set(pool + ['option', 'default-router', router])
127-
self.cli_set(pool + ['option', 'name-server', dns_1])
128-
self.cli_set(pool + ['option', 'name-server', dns_2])
129-
self.cli_set(pool + ['option', 'domain-name', domain_name])
130-
131-
# check validate() - No DHCP address range or active static-mapping set
132-
with self.assertRaises(ConfigSessionError):
133-
self.cli_commit()
134-
self.cli_set(pool + ['range', '0', 'start', range_0_start])
135-
self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
136-
self.cli_set(pool + ['range', '1', 'start', range_1_start])
137-
self.cli_set(pool + ['range', '1', 'stop', range_1_stop])
117+
self.setup_single_pool_range(range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name)
138118

139119
# commit changes
140120
self.cli_commit()
@@ -211,6 +191,112 @@ def test_dhcp_single_pool_range(self):
211191
# Check for running process
212192
self.verify_service_running()
213193

194+
def setup_single_pool_range(self, range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name):
195+
self.cli_set(base_path + ['listen-interface', interface])
196+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'ping-check'])
197+
198+
pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
199+
200+
self.cli_set(pool + ['subnet-id', '1'])
201+
self.cli_set(pool + ['ignore-client-id'])
202+
self.cli_set(pool + ['ping-check'])
203+
# we use the first subnet IP address as default gateway
204+
self.cli_set(pool + ['option', 'default-router', router])
205+
self.cli_set(pool + ['option', 'name-server', dns_1])
206+
self.cli_set(pool + ['option', 'name-server', dns_2])
207+
self.cli_set(pool + ['option', 'domain-name', domain_name])
208+
209+
# check validate() - No DHCP address range or active static-mapping set
210+
with self.assertRaises(ConfigSessionError):
211+
self.cli_commit()
212+
213+
self.cli_set(pool + ['range', '0', 'start', range_0_start])
214+
self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
215+
self.cli_set(pool + ['range', '1', 'start', range_1_start])
216+
self.cli_set(pool + ['range', '1', 'stop', range_1_stop])
217+
218+
def test_dhcp_client_class(self):
219+
shared_net_name = 'SMOKE-1'
220+
221+
range_0_start = inc_ip(subnet, 10)
222+
range_0_stop = inc_ip(subnet, 20)
223+
range_1_start = inc_ip(subnet, 40)
224+
range_1_stop = inc_ip(subnet, 50)
225+
226+
self.setup_single_pool_range(range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name)
227+
228+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test'])
229+
230+
# check validate() - Client class referenced that doesn't exist yet
231+
with self.assertRaises(ConfigSessionError):
232+
self.cli_commit()
233+
234+
self.cli_delete(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test'])
235+
236+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'range', '0', 'client-class', 'test'])
237+
238+
# check validate() - Client class referenced that doesn't exist yet
239+
with self.assertRaises(ConfigSessionError):
240+
self.cli_commit()
241+
242+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test'])
243+
244+
client_class = base_path + ['client-class', 'test']
245+
246+
# Test that invalid hex is rejected
247+
self.cli_set(client_class + ['relay-agent-information', 'circuit-id', '0xHELLOWORLD'])
248+
249+
with self.assertRaises(ConfigSessionError):
250+
self.cli_commit()
251+
252+
self.cli_delete(client_class + ['relay-agent-information', 'circuit-id'])
253+
self.cli_set(client_class + ['relay-agent-information', 'remote-id', '0xHELLOWORLD'])
254+
255+
with self.assertRaises(ConfigSessionError):
256+
self.cli_commit()
257+
258+
self.cli_delete(client_class + ['relay-agent-information', 'remote-id'])
259+
260+
# Test string literals
261+
self.cli_set(client_class + ['relay-agent-information', 'circuit-id', 'foo'])
262+
self.cli_set(client_class + ['relay-agent-information', 'remote-id', 'bar'])
263+
264+
self.cli_commit()
265+
266+
self.check_client_class_in_config()
267+
268+
self.cli_delete(client_class + ['relay-agent-information', 'circuit-id'])
269+
self.cli_delete(client_class + ['relay-agent-information', 'remote-id'])
270+
271+
# Test hex strings
272+
self.cli_set(client_class + ['relay-agent-information', 'circuit-id', '0x666f6f'])
273+
self.cli_set(client_class + ['relay-agent-information', 'remote-id', '0x626172'])
274+
275+
self.cli_commit()
276+
277+
self.check_client_class_in_config()
278+
279+
def check_client_class_in_config(self):
280+
config = read_file(KEA4_CONF)
281+
obj = loads(config)
282+
self.verify_config_value(
283+
obj, ['Dhcp4', 'client-classes', 0], 'name', 'test'
284+
)
285+
self.verify_config_value(
286+
obj, ['Dhcp4', 'client-classes', 0], 'test',
287+
'relay4[1].hex == 0x666f6f and relay4[2].hex == 0x626172'
288+
)
289+
self.verify_config_value(
290+
obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0], 'client-class',
291+
'test'
292+
)
293+
self.verify_config_value(
294+
obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0],
295+
'client-class', 'test'
296+
)
297+
# Check for running process
298+
self.verify_service_running()
299+
214300
def test_dhcp_single_pool_options(self):
215301
shared_net_name = 'SMOKE-0815'
216302

src/conf_mode/service_dhcp-server.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

1717
import os
18+
import re
1819

1920
from sys import exit
2021
from sys import argv
@@ -287,6 +288,12 @@ def verify(dhcp):
287288
f'DHCP static-route "{route}" requires router to be defined!'
288289
)
289290

291+
# If a client class has been specified then it must exist
292+
if 'client_class' in subnet_config:
293+
client_class = subnet_config['client_class']
294+
if client_class not in dhcp.get('client_class', {}):
295+
raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist')
296+
290297
# Check if DHCP address range is inside configured subnet declaration
291298
if 'range' in subnet_config:
292299
networks = []
@@ -296,6 +303,12 @@ def verify(dhcp):
296303
f'DHCP range "{range}" start and stop address must be defined!'
297304
)
298305

306+
# If a client class has been specified then it must exist
307+
if 'client_class' in range_config:
308+
client_class = range_config['client_class']
309+
if client_class not in dhcp.get('client_class', {}):
310+
raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist')
311+
299312
# Start/Stop address must be inside network
300313
for key in ['start', 'stop']:
301314
if ip_address(range_config[key]) not in ip_network(subnet):
@@ -504,6 +517,26 @@ def verify(dhcp):
504517
if 'reverse_domain' in ddns:
505518
verify_ddns_domain_servers('Reverse', ddns['reverse_domain'])
506519

520+
if 'client_class' in dhcp:
521+
# Check client class values are valid
522+
for class_name, class_config in dhcp['client_class'].items():
523+
if 'relay_agent_information' in class_config:
524+
relay_agent_information_config = class_config['relay_agent_information']
525+
# Compile a regex that will scan for valid inputs. Input can be
526+
# either hex in the form 0x0123456789ABCDEF or a string that
527+
# does *not* start with 0x. i.e. 0xHELLOWORLD is bad
528+
pattern = re.compile(r'^(?:0x[0-9A-Fa-f]+|(?!0x).+)$')
529+
530+
if 'circuit_id' in relay_agent_information_config:
531+
circuit_id = relay_agent_information_config['circuit_id']
532+
if not pattern.match(circuit_id):
533+
raise ConfigError(f'Invalid circuit-id "{circuit_id}" must be either text literal or hex string starting with 0x')
534+
535+
if 'remote_id' in relay_agent_information_config:
536+
remote_id = relay_agent_information_config['remote_id']
537+
if not pattern.match(remote_id):
538+
raise ConfigError(f'Invalid remote-id "{remote_id}" must be either text literal or hex string starting with 0x')
539+
507540
return None
508541

509542

0 commit comments

Comments
 (0)