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
This commit is contained in:
Monviech 2026-05-09 14:17:39 +02:00 committed by GitHub
parent 48da1ce7b9
commit 9b93f84c24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 94 additions and 47 deletions

View file

@ -31,8 +31,6 @@ namespace OPNsense\Kea\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\Core\Config;
use OPNsense\Kea\KeaDhcpv4;
use OPNsense\Kea\KeaDhcpv6;
use OPNsense\Base\UserException;
abstract class LeasesController extends ApiControllerBase
@ -50,7 +48,6 @@ abstract class LeasesController extends ApiControllerBase
$interfaces = [];
$leases = json_decode($backend->configdpRun($this->configd_fetch_leases), true) ?? [];
$ifconfig = json_decode($backend->configdRun('interface list ifconfig'), true);
$mac_db = json_decode($backend->configdRun('interface list macdb'), true) ?? [];
$ifmap = [];
@ -61,23 +58,6 @@ abstract class LeasesController extends ApiControllerBase
];
}
// Mark records as reserved based on hwaddr (IPv4) or duid/hwaddr (IPv6) match
$resv4 = [];
$resv6 = [];
foreach ((new KeaDhcpv4())->reservations->reservation->iterateItems() as $reservation) {
$resv4[strtolower($reservation->hw_address->getValue())] = 'hwaddr';
}
foreach ((new KeaDhcpv6())->reservations->reservation->iterateItems() as $reservation) {
// At least one of these is required in the model
if (!$reservation->duid->isEmpty()) {
$resv6[strtolower($reservation->duid->getValue())] = 'duid';
} elseif (!$reservation->hw_address->isEmpty()) {
$resv6[strtolower($reservation->hw_address->getValue())] = 'hwaddr';
}
}
if (!empty($leases) && isset($leases['records'])) {
$records = $leases['records'];
foreach ($records as &$record) {
@ -92,23 +72,6 @@ abstract class LeasesController extends ApiControllerBase
// Vendor
$mac = strtoupper(substr(str_replace(':', '', $record['hwaddr']), 0, 6));
$record['mac_info'] = isset($mac_db[$mac]) ? $mac_db[$mac] : '';
// Reservation
$record['is_reserved'] = '';
$addr = $record['address'] ?? '';
if (strpos($addr, ':') !== false) {
$duid = strtolower($record['duid'] ?? '');
$mac = strtolower($record['hwaddr'] ?? '');
if (isset($resv6[$duid])) {
$record['is_reserved'] = $resv6[$duid];
} elseif (isset($resv6[$mac])) {
$record['is_reserved'] = $resv6[$mac];
}
} else {
$mac = strtolower($record['hwaddr'] ?? '');
if (isset($resv4[$mac])) {
$record['is_reserved'] = $resv4[$mac];
}
}
}
} else {
$records = [];

View file

@ -87,10 +87,20 @@
return moment.unix(row[column.id]).local().format('YYYY-MM-DD HH:mm:ss');
},
"reservation": function (column, row) {
return row.is_reserved !== ''
return row.is_reserved.length > 0
? "{{ lang._('static') }}"
: "{{ lang._('dynamic') }}";
},
"state": function (column, row) {
const states = {
0: "{{ lang._('assigned') }}",
1: "{{ lang._('declined') }}",
2: "{{ lang._('expired reclaimed') }}",
3: "{{ lang._('released') }}",
4: "{{ lang._('registered') }}"
};
return states[row.state] || row.state;
},
"commands": function (column, row) {
const baseUrl = `/ui/kea/dhcp/v4#reservations`;
const searchUrl = `${baseUrl}&search=${encodeURIComponent(row.hwaddr || '')}`;
@ -103,7 +113,7 @@
let reservationBtn;
if (row.is_reserved !== '') {
if (row.is_reserved.length > 0) {
reservationBtn = $(`
<button type="button" class="btn btn-xs" data-toggle="tooltip"
title="{{ lang._('Find Reservation') }}">
@ -163,9 +173,11 @@
<th data-column-id="if_descr" data-type="string">{{ lang._('Interface') }}</th>
<th data-column-id="address" data-identifier="true" data-type="string" data-formatter="overflowformatter">{{ lang._('IP Address') }}</th>
<th data-column-id="hwaddr" data-type="string" data-formatter="macformatter" data-width="9em">{{ lang._('MAC Address') }}</th>
<th data-column-id="valid_lifetime" data-type="integer">{{ lang._('Lifetime') }}</th>
<th data-column-id="client_id" data-type="string" data-width="9em">{{ lang._('Client ID') }}</th>
<th data-column-id="valid_lifetime" data-width="6em" data-type="integer">{{ lang._('Lifetime') }}</th>
<th data-column-id="expire" data-type="string" data-formatter="timestamp">{{ lang._('Expire') }}</th>
<th data-column-id="hostname" data-type="string" data-formatter="overflowformatter">{{ lang._('Hostname') }}</th>
<th data-column-id="state" data-type="string" data-formatter="state" data-width="8em">{{ lang._('State') }}</th>
<th data-column-id="is_reserved" data-type="string" data-formatter="reservation" data-width="6em">{{ lang._('Lease Type') }}</th>
<th data-column-id="commands" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>

View file

@ -87,17 +87,27 @@
return moment.unix(row[column.id]).local().format('YYYY-MM-DD HH:mm:ss');
},
"reservation": function (column, row) {
return row.is_reserved !== ''
return row.is_reserved.length > 0
? "{{ lang._('static') }}"
: "{{ lang._('dynamic') }}";
},
"state": function (column, row) {
const states = {
0: "{{ lang._('assigned') }}",
1: "{{ lang._('declined') }}",
2: "{{ lang._('expired reclaimed') }}",
3: "{{ lang._('released') }}",
4: "{{ lang._('registered') }}"
};
return states[row.state] || row.state;
},
"commands": function (column, row) {
const baseUrl = `/ui/kea/dhcp/v6#reservations`;
let searchValue = '';
if (row.is_reserved === 'duid') {
if (row.is_reserved.includes('duid')) {
searchValue = row.duid || '';
} else if (row.is_reserved === 'hwaddr') {
} else if (row.is_reserved.includes('hwaddr')) {
searchValue = row.hwaddr || '';
}
@ -117,7 +127,7 @@
let reservationBtn;
if (row.is_reserved !== '') {
if (row.is_reserved.length > 0) {
reservationBtn = $(`
<button type="button" class="btn btn-xs" data-toggle="tooltip"
title="{{ lang._('Find Reservation') }}">
@ -181,9 +191,10 @@
<th data-column-id="duid" data-type="string" data-width="18em">{{ lang._('DUID') }}</th>
<th data-column-id="iaid" data-type="string" data-width="4em">{{ lang._('IAID') }}</th>
<th data-column-id="hwaddr" data-type="string" data-formatter="macformatter" data-width="9em">{{ lang._('MAC Address') }}</th>
<th data-column-id="valid_lifetime" data-type="integer">{{ lang._('Lifetime') }}</th>
<th data-column-id="valid_lifetime" data-width="6em" data-type="integer">{{ lang._('Lifetime') }}</th>
<th data-column-id="expire" data-type="string" data-formatter="timestamp">{{ lang._('Expire') }}</th>
<th data-column-id="hostname" data-type="string" data-formatter="overflowformatter">{{ lang._('Hostname') }}</th>
<th data-column-id="state" data-type="string" data-formatter="state" data-width="8em">{{ lang._('State') }}</th>
<th data-column-id="is_reserved" data-type="string" data-formatter="reservation" data-width="6em">{{ lang._('Lease Type') }}</th>
<th data-column-id="commands" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>

View file

@ -47,6 +47,58 @@ def build_ranges(proto):
return ranges
def get_reservation_keys(record, proto):
keys = []
subnet_id = record.get('subnet-id')
if subnet_id is not None:
hwaddr = record.get('hw-address', '').lower()
duid = record.get('duid', '').lower()
client_id = record.get('client-id', '').lower()
if hwaddr:
keys.append((subnet_id, 'hwaddr', hwaddr))
if proto == 'inet6' and duid:
keys.append((subnet_id, 'duid', duid))
if proto == 'inet' and client_id:
keys.append((subnet_id, 'client_id', client_id))
return keys
def build_reserved_matches(config, leases, proto, dhcp_key, config_key):
"""
Kea does not expose whether a lease came from a reservation, so we infer it
by comparing client identity within the same subnet-id. The subnet-id check
matters because the same MAC, DUID or client-id may have reservations on
one subnet, but due to client roaming currently exist on a different subnet as lease.
They should not be marked as reserved when they are not in the expected subnet-id scope.
"""
wanted = set()
matches = {}
for lease in leases:
wanted.update(get_reservation_keys(lease, proto))
if wanted:
for subnet in config.get('arguments', {}).get(dhcp_key, {}).get(config_key, []):
subnet_id = subnet.get('id')
if subnet_id is None:
continue
for reservation in subnet.get('reservations', []):
hwaddr = reservation.get('hw-address', '').lower()
duid = reservation.get('duid', '').lower()
client_id = reservation.get('client-id', '').lower()
if hwaddr and (subnet_id, 'hwaddr', hwaddr) in wanted:
matches[(subnet_id, 'hwaddr', hwaddr)] = 'hwaddr'
if proto == 'inet6' and duid and (subnet_id, 'duid', duid) in wanted:
matches[(subnet_id, 'duid', duid)] = 'duid'
if proto == 'inet' and client_id and (subnet_id, 'client_id', client_id) in wanted:
matches[(subnet_id, 'client_id', client_id)] = 'client_id'
return matches
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--proto', help='protocol to fetch (inet, inet6)', default='inet', choices=['inet', 'inet6'])
@ -69,11 +121,14 @@ if __name__ == '__main__':
]
leases = KeaCtrl.send_command(lease_cmd, {"subnets": subnets}, service)
leases = leases.get("arguments", {}).get("leases", [])
reserved_matches = build_reserved_matches(config, leases, inputargs.proto, dhcp_key, config_key)
records = []
for lease in leases.get("arguments", {}).get("leases", []):
for lease in leases:
address = lease.get("ip-address")
lease_if = None
is_reserved = []
if address:
for net, ifname in ranges.items():
@ -81,19 +136,25 @@ if __name__ == '__main__':
lease_if = ifname
break
for key in get_reservation_keys(lease, inputargs.proto):
if key in reserved_matches:
is_reserved.append(reserved_matches[key])
records.append({
"address": address,
"prefix_len": lease.get("prefix-len", 128),
"type": lease.get("type", ""),
"hwaddr": lease.get("hw-address", ""),
"duid": lease.get("duid", ""),
"client_id": lease.get("client-id", ""),
"iaid": lease.get("iaid", ""),
"valid_lifetime": lease.get("valid-lft", 0),
"expire": lease.get("cltt", 0) + lease.get("valid-lft", 0),
"hostname": lease.get("hostname", ""),
"state": lease.get("state", 0),
"if": lease_if,
"if_descr": "",
"is_reserved": ""
"is_reserved": is_reserved
})
if records: