Skip to content

Commit 59b9ceb

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. Co-Authored-By: Daniil Baturin <[email protected]>
1 parent 6fa4978 commit 59b9ceb

File tree

6 files changed

+209
-32
lines changed

6 files changed

+209
-32
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@
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. Assumes ASCII text unless input starts with 0x in which case it is interpreted as raw hex</help>
30+
</properties>
31+
</leafNode>
32+
<leafNode name="remote-id">
33+
<properties>
34+
<help>Filters on the contents of the remote-id sub option. Assumes ASCII text unless input starts with 0x in which case it is interpreted as raw hex</help>
35+
</properties>
36+
</leafNode>
37+
</children>
38+
</node>
39+
</children>
40+
</tagNode>
1241
#include <include/generic-disable-node.xml.i>
1342
<node name="dynamic-dns-update">
1443
<properties>
@@ -239,6 +268,14 @@
239268
#include <include/dhcp/ping-check.xml.i>
240269
#include <include/generic-description.xml.i>
241270
#include <include/generic-disable-node.xml.i>
271+
<leafNode name="client-class">
272+
<properties>
273+
<help>DHCP client class</help>
274+
<completionHelp>
275+
<path>service dhcp-server client-class</path>
276+
</completionHelp>
277+
</properties>
278+
</leafNode>
242279
<node name="dynamic-dns-update">
243280
<properties>
244281
<help>Dynamically update Domain Name System (RFC4702)</help>
@@ -290,6 +327,14 @@
290327
</properties>
291328
<children>
292329
#include <include/dhcp/option-v4.xml.i>
330+
<leafNode name="client-class">
331+
<properties>
332+
<help>DHCP client class</help>
333+
<completionHelp>
334+
<path>service dhcp-server client-class</path>
335+
</completionHelp>
336+
</properties>
337+
</leafNode>
293338
<leafNode name="start">
294339
<properties>
295340
<help>First IP address for DHCP lease range</help>

python/vyos/kea.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from datetime import datetime
2121
from datetime import timezone
22+
from operator import truediv
2223

2324
from vyos import ConfigError
2425
from vyos.template import is_ipv6
@@ -169,6 +170,9 @@ def kea_parse_subnet(subnet, config):
169170
if 'ping_check' in config:
170171
out['user-context']['enable-ping-check'] = True
171172

173+
if 'client_class' in config:
174+
out['client-class'] = config['client_class']
175+
172176
if 'range' in config:
173177
pools = []
174178
for num, range_config in config['range'].items():
@@ -184,6 +188,9 @@ def kea_parse_subnet(subnet, config):
184188
if 'bootfile_server' in range_config['option']:
185189
pool['next-server'] = range_config['option']['bootfile_server']
186190

191+
if 'client_class' in range_config:
192+
pool['client-class'] = range_config['client_class']
193+
187194
pools.append(pool)
188195
out['pools'] = pools
189196

@@ -354,6 +361,7 @@ def kea6_parse_subnet(subnet, config):
354361

355362
return out
356363

364+
357365
def kea_parse_tsig_algo(algo_spec):
358366
translate = {
359367
'md5': 'HMAC-MD5',
@@ -367,9 +375,11 @@ def kea_parse_tsig_algo(algo_spec):
367375
raise ConfigError(f'Unsupported TSIG algorithm: {algo_spec}')
368376
return translate[algo_spec]
369377

378+
370379
def kea_parse_enable_disable(value):
371380
return True if value == 'enable' else False
372381

382+
373383
def kea_parse_ddns_settings(config):
374384
data = {}
375385

@@ -403,6 +413,7 @@ def kea_parse_ddns_settings(config):
403413

404414
return data
405415

416+
406417
def _ctrl_socket_command(inet, command, args=None):
407418
path = kea_ctrl_socket.format(inet=inet)
408419

@@ -440,13 +451,13 @@ def kea_get_leases(inet):
440451

441452

442453
def kea_add_lease(
443-
inet,
444-
ip_address,
445-
host_name=None,
446-
mac_address=None,
447-
iaid=None,
448-
duid=None,
449-
subnet_id=None,
454+
inet,
455+
ip_address,
456+
host_name=None,
457+
mac_address=None,
458+
iaid=None,
459+
duid=None,
460+
subnet_id=None,
450461
):
451462
args = {'ip-address': ip_address}
452463

@@ -642,10 +653,10 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list
642653

643654
# Do not add old leases
644655
if (
645-
data_lease['remaining'] != ''
646-
and data_lease['pool'] in pools
647-
and data_lease['state'] != 'free'
648-
and (not state or state == 'all' or data_lease['state'] in state)
656+
data_lease['remaining'] != ''
657+
and data_lease['pool'] in pools
658+
and data_lease['state'] != 'free'
659+
and (not state or state == 'all' or data_lease['state'] in state)
649660
):
650661
data.append(data_lease)
651662

@@ -661,3 +672,26 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list
661672
data.pop(idx)
662673

663674
return data
675+
676+
677+
def kea_build_client_class_test(config):
678+
conditions = []
679+
680+
if "option82" in config:
681+
if "circuit_id" in config["option82"]:
682+
conditions.append("relay4[1].hex == 0x" + config["option82"]["circuit_id"].encode().hex().lower())
683+
if "remote_id" in config["option82"]:
684+
conditions.append("relay4[2].hex == 0x" + config["option82"]["remote_id"].encode().hex().lower())
685+
686+
is_first = True
687+
688+
test = ""
689+
for condition in conditions:
690+
if not is_first:
691+
test += " and "
692+
693+
is_first = False
694+
695+
test += condition
696+
697+
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)