Skip to content

Commit 9b93f84

Browse files
authored
Services: Kea DHCPv4/6: Build reservation status from control socket output, so it matches the scope of individual subnet (#10276)
* Services: Kea DHCPv4/6: Build reservation status from control socket output, so it matches the scope of individual subnets as well. Add client-id since it's relevant for IPv4 leases as well in default configuration. We return an array now, change frontend detection if it's dynamic or static lease Missed a closing bracket Typo in client_id Remove unused imports in LeasesController Add comment to build_reserved_matches() to explain why the subnet-id logic exists now * Add state as well, helpful for troubleshooting * Add a state formatter to convert number status into their documented meaning * Some data-width micro management
1 parent 48da1ce commit 9b93f84

4 files changed

Lines changed: 94 additions & 47 deletions

File tree

src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/LeasesController.php

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@
3131
use OPNsense\Base\ApiControllerBase;
3232
use OPNsense\Core\Backend;
3333
use OPNsense\Core\Config;
34-
use OPNsense\Kea\KeaDhcpv4;
35-
use OPNsense\Kea\KeaDhcpv6;
3634
use OPNsense\Base\UserException;
3735

3836
abstract class LeasesController extends ApiControllerBase
@@ -50,7 +48,6 @@ public function searchAction()
5048
$interfaces = [];
5149

5250
$leases = json_decode($backend->configdpRun($this->configd_fetch_leases), true) ?? [];
53-
$ifconfig = json_decode($backend->configdRun('interface list ifconfig'), true);
5451
$mac_db = json_decode($backend->configdRun('interface list macdb'), true) ?? [];
5552

5653
$ifmap = [];
@@ -61,23 +58,6 @@ public function searchAction()
6158
];
6259
}
6360

64-
// Mark records as reserved based on hwaddr (IPv4) or duid/hwaddr (IPv6) match
65-
$resv4 = [];
66-
$resv6 = [];
67-
68-
foreach ((new KeaDhcpv4())->reservations->reservation->iterateItems() as $reservation) {
69-
$resv4[strtolower($reservation->hw_address->getValue())] = 'hwaddr';
70-
}
71-
72-
foreach ((new KeaDhcpv6())->reservations->reservation->iterateItems() as $reservation) {
73-
// At least one of these is required in the model
74-
if (!$reservation->duid->isEmpty()) {
75-
$resv6[strtolower($reservation->duid->getValue())] = 'duid';
76-
} elseif (!$reservation->hw_address->isEmpty()) {
77-
$resv6[strtolower($reservation->hw_address->getValue())] = 'hwaddr';
78-
}
79-
}
80-
8161
if (!empty($leases) && isset($leases['records'])) {
8262
$records = $leases['records'];
8363
foreach ($records as &$record) {
@@ -92,23 +72,6 @@ public function searchAction()
9272
// Vendor
9373
$mac = strtoupper(substr(str_replace(':', '', $record['hwaddr']), 0, 6));
9474
$record['mac_info'] = isset($mac_db[$mac]) ? $mac_db[$mac] : '';
95-
// Reservation
96-
$record['is_reserved'] = '';
97-
$addr = $record['address'] ?? '';
98-
if (strpos($addr, ':') !== false) {
99-
$duid = strtolower($record['duid'] ?? '');
100-
$mac = strtolower($record['hwaddr'] ?? '');
101-
if (isset($resv6[$duid])) {
102-
$record['is_reserved'] = $resv6[$duid];
103-
} elseif (isset($resv6[$mac])) {
104-
$record['is_reserved'] = $resv6[$mac];
105-
}
106-
} else {
107-
$mac = strtolower($record['hwaddr'] ?? '');
108-
if (isset($resv4[$mac])) {
109-
$record['is_reserved'] = $resv4[$mac];
110-
}
111-
}
11275
}
11376
} else {
11477
$records = [];

src/opnsense/mvc/app/views/OPNsense/Kea/leases4.volt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,20 @@
8787
return moment.unix(row[column.id]).local().format('YYYY-MM-DD HH:mm:ss');
8888
},
8989
"reservation": function (column, row) {
90-
return row.is_reserved !== ''
90+
return row.is_reserved.length > 0
9191
? "{{ lang._('static') }}"
9292
: "{{ lang._('dynamic') }}";
9393
},
94+
"state": function (column, row) {
95+
const states = {
96+
0: "{{ lang._('assigned') }}",
97+
1: "{{ lang._('declined') }}",
98+
2: "{{ lang._('expired reclaimed') }}",
99+
3: "{{ lang._('released') }}",
100+
4: "{{ lang._('registered') }}"
101+
};
102+
return states[row.state] || row.state;
103+
},
94104
"commands": function (column, row) {
95105
const baseUrl = `/ui/kea/dhcp/v4#reservations`;
96106
const searchUrl = `${baseUrl}&search=${encodeURIComponent(row.hwaddr || '')}`;
@@ -103,7 +113,7 @@
103113

104114
let reservationBtn;
105115

106-
if (row.is_reserved !== '') {
116+
if (row.is_reserved.length > 0) {
107117
reservationBtn = $(`
108118
<button type="button" class="btn btn-xs" data-toggle="tooltip"
109119
title="{{ lang._('Find Reservation') }}">
@@ -163,9 +173,11 @@
163173
<th data-column-id="if_descr" data-type="string">{{ lang._('Interface') }}</th>
164174
<th data-column-id="address" data-identifier="true" data-type="string" data-formatter="overflowformatter">{{ lang._('IP Address') }}</th>
165175
<th data-column-id="hwaddr" data-type="string" data-formatter="macformatter" data-width="9em">{{ lang._('MAC Address') }}</th>
166-
<th data-column-id="valid_lifetime" data-type="integer">{{ lang._('Lifetime') }}</th>
176+
<th data-column-id="client_id" data-type="string" data-width="9em">{{ lang._('Client ID') }}</th>
177+
<th data-column-id="valid_lifetime" data-width="6em" data-type="integer">{{ lang._('Lifetime') }}</th>
167178
<th data-column-id="expire" data-type="string" data-formatter="timestamp">{{ lang._('Expire') }}</th>
168179
<th data-column-id="hostname" data-type="string" data-formatter="overflowformatter">{{ lang._('Hostname') }}</th>
180+
<th data-column-id="state" data-type="string" data-formatter="state" data-width="8em">{{ lang._('State') }}</th>
169181
<th data-column-id="is_reserved" data-type="string" data-formatter="reservation" data-width="6em">{{ lang._('Lease Type') }}</th>
170182
<th data-column-id="commands" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
171183
</tr>

src/opnsense/mvc/app/views/OPNsense/Kea/leases6.volt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,27 @@
8787
return moment.unix(row[column.id]).local().format('YYYY-MM-DD HH:mm:ss');
8888
},
8989
"reservation": function (column, row) {
90-
return row.is_reserved !== ''
90+
return row.is_reserved.length > 0
9191
? "{{ lang._('static') }}"
9292
: "{{ lang._('dynamic') }}";
9393
},
94+
"state": function (column, row) {
95+
const states = {
96+
0: "{{ lang._('assigned') }}",
97+
1: "{{ lang._('declined') }}",
98+
2: "{{ lang._('expired reclaimed') }}",
99+
3: "{{ lang._('released') }}",
100+
4: "{{ lang._('registered') }}"
101+
};
102+
return states[row.state] || row.state;
103+
},
94104
"commands": function (column, row) {
95105
const baseUrl = `/ui/kea/dhcp/v6#reservations`;
96106
let searchValue = '';
97107

98-
if (row.is_reserved === 'duid') {
108+
if (row.is_reserved.includes('duid')) {
99109
searchValue = row.duid || '';
100-
} else if (row.is_reserved === 'hwaddr') {
110+
} else if (row.is_reserved.includes('hwaddr')) {
101111
searchValue = row.hwaddr || '';
102112
}
103113

@@ -117,7 +127,7 @@
117127

118128
let reservationBtn;
119129

120-
if (row.is_reserved !== '') {
130+
if (row.is_reserved.length > 0) {
121131
reservationBtn = $(`
122132
<button type="button" class="btn btn-xs" data-toggle="tooltip"
123133
title="{{ lang._('Find Reservation') }}">
@@ -181,9 +191,10 @@
181191
<th data-column-id="duid" data-type="string" data-width="18em">{{ lang._('DUID') }}</th>
182192
<th data-column-id="iaid" data-type="string" data-width="4em">{{ lang._('IAID') }}</th>
183193
<th data-column-id="hwaddr" data-type="string" data-formatter="macformatter" data-width="9em">{{ lang._('MAC Address') }}</th>
184-
<th data-column-id="valid_lifetime" data-type="integer">{{ lang._('Lifetime') }}</th>
194+
<th data-column-id="valid_lifetime" data-width="6em" data-type="integer">{{ lang._('Lifetime') }}</th>
185195
<th data-column-id="expire" data-type="string" data-formatter="timestamp">{{ lang._('Expire') }}</th>
186196
<th data-column-id="hostname" data-type="string" data-formatter="overflowformatter">{{ lang._('Hostname') }}</th>
197+
<th data-column-id="state" data-type="string" data-formatter="state" data-width="8em">{{ lang._('State') }}</th>
187198
<th data-column-id="is_reserved" data-type="string" data-formatter="reservation" data-width="6em">{{ lang._('Lease Type') }}</th>
188199
<th data-column-id="commands" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
189200
</tr>

src/opnsense/scripts/kea/get_kea_leases.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,58 @@ def build_ranges(proto):
4747

4848
return ranges
4949

50+
def get_reservation_keys(record, proto):
51+
keys = []
52+
subnet_id = record.get('subnet-id')
53+
54+
if subnet_id is not None:
55+
hwaddr = record.get('hw-address', '').lower()
56+
duid = record.get('duid', '').lower()
57+
client_id = record.get('client-id', '').lower()
58+
59+
if hwaddr:
60+
keys.append((subnet_id, 'hwaddr', hwaddr))
61+
if proto == 'inet6' and duid:
62+
keys.append((subnet_id, 'duid', duid))
63+
if proto == 'inet' and client_id:
64+
keys.append((subnet_id, 'client_id', client_id))
65+
66+
return keys
67+
68+
def build_reserved_matches(config, leases, proto, dhcp_key, config_key):
69+
"""
70+
Kea does not expose whether a lease came from a reservation, so we infer it
71+
by comparing client identity within the same subnet-id. The subnet-id check
72+
matters because the same MAC, DUID or client-id may have reservations on
73+
one subnet, but due to client roaming currently exist on a different subnet as lease.
74+
They should not be marked as reserved when they are not in the expected subnet-id scope.
75+
"""
76+
wanted = set()
77+
matches = {}
78+
79+
for lease in leases:
80+
wanted.update(get_reservation_keys(lease, proto))
81+
82+
if wanted:
83+
for subnet in config.get('arguments', {}).get(dhcp_key, {}).get(config_key, []):
84+
subnet_id = subnet.get('id')
85+
if subnet_id is None:
86+
continue
87+
88+
for reservation in subnet.get('reservations', []):
89+
hwaddr = reservation.get('hw-address', '').lower()
90+
duid = reservation.get('duid', '').lower()
91+
client_id = reservation.get('client-id', '').lower()
92+
93+
if hwaddr and (subnet_id, 'hwaddr', hwaddr) in wanted:
94+
matches[(subnet_id, 'hwaddr', hwaddr)] = 'hwaddr'
95+
if proto == 'inet6' and duid and (subnet_id, 'duid', duid) in wanted:
96+
matches[(subnet_id, 'duid', duid)] = 'duid'
97+
if proto == 'inet' and client_id and (subnet_id, 'client_id', client_id) in wanted:
98+
matches[(subnet_id, 'client_id', client_id)] = 'client_id'
99+
100+
return matches
101+
50102
if __name__ == '__main__':
51103
parser = argparse.ArgumentParser()
52104
parser.add_argument('--proto', help='protocol to fetch (inet, inet6)', default='inet', choices=['inet', 'inet6'])
@@ -69,31 +121,40 @@ def build_ranges(proto):
69121
]
70122

71123
leases = KeaCtrl.send_command(lease_cmd, {"subnets": subnets}, service)
124+
leases = leases.get("arguments", {}).get("leases", [])
125+
reserved_matches = build_reserved_matches(config, leases, inputargs.proto, dhcp_key, config_key)
72126

73127
records = []
74-
for lease in leases.get("arguments", {}).get("leases", []):
128+
for lease in leases:
75129
address = lease.get("ip-address")
76130
lease_if = None
131+
is_reserved = []
77132

78133
if address:
79134
for net, ifname in ranges.items():
80135
if net.overlaps(ipaddress.ip_network(address)):
81136
lease_if = ifname
82137
break
83138

139+
for key in get_reservation_keys(lease, inputargs.proto):
140+
if key in reserved_matches:
141+
is_reserved.append(reserved_matches[key])
142+
84143
records.append({
85144
"address": address,
86145
"prefix_len": lease.get("prefix-len", 128),
87146
"type": lease.get("type", ""),
88147
"hwaddr": lease.get("hw-address", ""),
89148
"duid": lease.get("duid", ""),
149+
"client_id": lease.get("client-id", ""),
90150
"iaid": lease.get("iaid", ""),
91151
"valid_lifetime": lease.get("valid-lft", 0),
92152
"expire": lease.get("cltt", 0) + lease.get("valid-lft", 0),
93153
"hostname": lease.get("hostname", ""),
154+
"state": lease.get("state", 0),
94155
"if": lease_if,
95156
"if_descr": "",
96-
"is_reserved": ""
157+
"is_reserved": is_reserved
97158
})
98159

99160
if records:

0 commit comments

Comments
 (0)