Skip to content

Commit 326b5e7

Browse files
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.
1 parent 69cd484 commit 326b5e7

File tree

6 files changed

+200
-21
lines changed

6 files changed

+200
-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
@@ -23,6 +23,9 @@
2323
"persist": true,
2424
"name": "{{ lease_file }}"
2525
},
26+
{% if client_class is vyos_defined %}
27+
"client-classes": {{ client_class | kea_client_class_json }},
28+
{% endif %}
2629
"option-def": [
2730
{
2831
"name": "wpad-url",

interface-definitions/service_dhcp-server.xml.in

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@
99
<priority>911</priority>
1010
</properties>
1111
<children>
12+
<tagNode name="client-class">
13+
<properties>
14+
<help>Client class name</help>
15+
<constraint>
16+
#include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i>
17+
</constraint>
18+
<constraintErrorMessage>Shared network name may only contain letters, numbers and, dots, underscores, and hyphens</constraintErrorMessage>
19+
</properties>
20+
<children>
21+
#include <include/generic-disable-node.xml.i>
22+
<node name="option82">
23+
<properties>
24+
<help>Match DHCP option 82 (relay agent information)</help>
25+
</properties>
26+
<children>
27+
<leafNode name="circuit-id">
28+
<properties>
29+
<help>Filters on the contents of the circuit-id sub option.</help>
30+
<valueHelp>
31+
<format>txt</format>
32+
<description>Assumes ASCII text unless input starts with 0x in which case it is interpreted as raw hex</description>
33+
</valueHelp>
34+
</properties>
35+
</leafNode>
36+
<leafNode name="remote-id">
37+
<properties>
38+
<help>Filters on the contents of the remote-id sub option.</help>
39+
<valueHelp>
40+
<format>txt</format>
41+
<description>Assumes ASCII text unless input starts with 0x in which case it is interpreted as raw hex</description>
42+
</valueHelp>
43+
</properties>
44+
</leafNode>
45+
</children>
46+
</node>
47+
</children>
48+
</tagNode>
1249
#include <include/generic-disable-node.xml.i>
1350
<node name="dynamic-dns-update">
1451
<properties>
@@ -239,6 +276,14 @@
239276
#include <include/dhcp/ping-check.xml.i>
240277
#include <include/generic-description.xml.i>
241278
#include <include/generic-disable-node.xml.i>
279+
<leafNode name="client-class">
280+
<properties>
281+
<help>DHCP client class</help>
282+
<completionHelp>
283+
<path>service dhcp-server client-class</path>
284+
</completionHelp>
285+
</properties>
286+
</leafNode>
242287
<node name="dynamic-dns-update">
243288
<properties>
244289
<help>Dynamically update Domain Name System (RFC4702)</help>
@@ -290,6 +335,14 @@
290335
</properties>
291336
<children>
292337
#include <include/dhcp/option-v4.xml.i>
338+
<leafNode name="client-class">
339+
<properties>
340+
<help>DHCP client class</help>
341+
<completionHelp>
342+
<path>service dhcp-server client-class</path>
343+
</completionHelp>
344+
</properties>
345+
</leafNode>
293346
<leafNode name="start">
294347
<properties>
295348
<help>First IP address for DHCP lease range</help>

python/vyos/kea.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ def kea_parse_subnet(subnet, config):
169169
if 'ping_check' in config:
170170
out['user-context']['enable-ping-check'] = True
171171

172+
if 'client_class' in config:
173+
out['client-class'] = config['client_class']
174+
172175
if 'range' in config:
173176
pools = []
174177
for num, range_config in config['range'].items():
@@ -184,6 +187,9 @@ def kea_parse_subnet(subnet, config):
184187
if 'bootfile_server' in range_config['option']:
185188
pool['next-server'] = range_config['option']['bootfile_server']
186189

190+
if 'client_class' in range_config:
191+
pool['client-class'] = range_config['client_class']
192+
187193
pools.append(pool)
188194
out['pools'] = pools
189195

@@ -661,3 +667,25 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list
661667
data.pop(idx)
662668

663669
return data
670+
671+
def kea_build_client_class_test(config):
672+
conditions = []
673+
674+
if "option82" in config:
675+
if "circuit_id" in config["option82"]:
676+
conditions.append("relay4[1].hex == 0x" + config["option82"]["circuit_id"].encode().hex().lower())
677+
if "remote_id" in config["option82"]:
678+
conditions.append("relay4[2].hex == 0x" + config["option82"]["remote_id"].encode().hex().lower())
679+
680+
is_first = True
681+
682+
test = ""
683+
for condition in conditions:
684+
if not is_first:
685+
test += " and "
686+
687+
is_first = False
688+
689+
test += condition
690+
691+
return test

python/vyos/template.py

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

911911
return dumps(data)
912912

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

smoketest/scripts/cli/test_service_dhcp-server.py

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

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

138118
# commit changes
139119
self.cli_commit()
@@ -210,6 +190,84 @@ def test_dhcp_single_pool_range(self):
210190
# Check for running process
211191
self.verify_service_running()
212192

193+
def setup_single_pool_range(self, range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name):
194+
self.cli_set(base_path + ['listen-interface', interface])
195+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'ping-check'])
196+
197+
pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
198+
199+
self.cli_set(pool + ['subnet-id', '1'])
200+
self.cli_set(pool + ['ignore-client-id'])
201+
self.cli_set(pool + ['ping-check'])
202+
# we use the first subnet IP address as default gateway
203+
self.cli_set(pool + ['option', 'default-router', router])
204+
self.cli_set(pool + ['option', 'name-server', dns_1])
205+
self.cli_set(pool + ['option', 'name-server', dns_2])
206+
self.cli_set(pool + ['option', 'domain-name', domain_name])
207+
208+
# check validate() - No DHCP address range or active static-mapping set
209+
with self.assertRaises(ConfigSessionError):
210+
self.cli_commit()
211+
212+
self.cli_set(pool + ['range', '0', 'start', range_0_start])
213+
self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
214+
self.cli_set(pool + ['range', '1', 'start', range_1_start])
215+
self.cli_set(pool + ['range', '1', 'stop', range_1_stop])
216+
217+
def test_dhcp_client_class(self):
218+
shared_net_name = 'SMOKE-1'
219+
220+
range_0_start = inc_ip(subnet, 10)
221+
range_0_stop = inc_ip(subnet, 20)
222+
range_1_start = inc_ip(subnet, 40)
223+
range_1_stop = inc_ip(subnet, 50)
224+
225+
self.setup_single_pool_range(range_0_start, range_0_stop, range_1_start, range_1_stop, shared_net_name)
226+
227+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test'])
228+
229+
# check validate() - Client class referenced that doesn't exist yet
230+
with self.assertRaises(ConfigSessionError):
231+
self.cli_commit()
232+
233+
self.cli_delete(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test'])
234+
235+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'range', '0', 'client-class', 'test'])
236+
237+
# check validate() - Client class referenced that doesn't exist yet
238+
with self.assertRaises(ConfigSessionError):
239+
self.cli_commit()
240+
241+
self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'client-class', 'test'])
242+
243+
client_class = base_path + ['client-class', 'test']
244+
self.cli_set(client_class + ['option82', 'circuit-id', 'foo'])
245+
self.cli_set(client_class + ['option82', 'remote-id', 'bar'])
246+
247+
self.cli_commit()
248+
249+
config = read_file(KEA4_CONF)
250+
obj = loads(config)
251+
252+
self.verify_config_value(
253+
obj, ['Dhcp4', 'client-classes', 0], 'name', 'test'
254+
)
255+
256+
self.verify_config_value(
257+
obj, ['Dhcp4', 'client-classes', 0], 'test', 'relay4[1].hex == 0x666f6f and relay4[2].hex == 0x626172'
258+
)
259+
260+
self.verify_config_value(
261+
obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0], 'client-class', 'test'
262+
)
263+
264+
self.verify_config_value(
265+
obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0], 'client-class', 'test'
266+
)
267+
268+
# Check for running process
269+
self.verify_service_running()
270+
213271
def test_dhcp_single_pool_options(self):
214272
shared_net_name = 'SMOKE-0815'
215273

src/conf_mode/service_dhcp-server.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,15 @@ def verify(dhcp):
231231
f'DHCP static-route "{route}" requires router to be defined!'
232232
)
233233

234+
# If a client class has been specified then it must exist
235+
if 'client_class' in subnet_config:
236+
client_class = subnet_config['client_class']
237+
if 'client_class' not in dhcp:
238+
raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist')
239+
240+
if client_class not in dhcp['client_class'].keys():
241+
raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist')
242+
234243
# Check if DHCP address range is inside configured subnet declaration
235244
if 'range' in subnet_config:
236245
networks = []
@@ -240,6 +249,15 @@ def verify(dhcp):
240249
f'DHCP range "{range}" start and stop address must be defined!'
241250
)
242251

252+
# If a client class has been specified then it must exist
253+
if 'client_class' in range_config:
254+
client_class = range_config['client_class']
255+
if 'client_class' not in dhcp:
256+
raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist')
257+
258+
if client_class not in dhcp['client_class'].keys():
259+
raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist')
260+
243261
# Start/Stop address must be inside network
244262
for key in ['start', 'stop']:
245263
if ip_address(range_config[key]) not in ip_network(subnet):

0 commit comments

Comments
 (0)