Services: Kea DHCPv4: Add client-id to reservations (#10288)

* Services: Kea DHCPv4: Add client-id to reservations

* Should be client_id in the row

* Add client_id to dhcpv4 config generator

* client-id is stored differently in the running configuration and the lease endpoint, it must be normalized here so we can return a correct match in is_reserved

* Fix typo in client_id

* Use MAC address instead of Ether address in validation message, fix missing back reference in DHCPv6 reservation validation

* Update src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php

Co-authored-by: Franco Fichtner <franco@opnsense.org>

* Update src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php

Co-authored-by: Franco Fichtner <franco@opnsense.org>

---------

Co-authored-by: Franco Fichtner <franco@opnsense.org>
This commit is contained in:
Monviech 2026-05-11 13:01:05 +02:00 committed by GitHub
parent fe8c0f27ca
commit 8edd6eef67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 50 additions and 6 deletions

View file

@ -17,6 +17,12 @@
<type>text</type>
<help>MAC/Ether address of the client in question</help>
</field>
<field>
<id>reservation.client_id</id>
<label>Client ID</label>
<type>text</type>
<help>ID of the client in question</help>
</field>
<field>
<id>reservation.hostname</id>
<label>Hostname</label>

View file

@ -88,6 +88,11 @@ class KeaDhcpv4 extends BaseModel
if (!Util::isIPInCIDR($reservation->ip_address->getValue(), $subnet)) {
$messages->appendMessage(new Message(gettext("Address not in specified subnet"), $key . ".ip_address"));
}
if (!$reservation->client_id->isEmpty() && !$reservation->hw_address->isEmpty()) {
$messages->appendMessage(new Message(gettext("Either a client ID or a MAC address should be specified, but not both"), $key . ".hw_address"));
} elseif ($reservation->client_id->isEmpty() && $reservation->hw_address->isEmpty()) {
$messages->appendMessage(new Message(gettext("Either a client ID or a MAC address should be specified."), $key . ".hw_address"));
}
}
return $messages;
@ -198,6 +203,8 @@ class KeaDhcpv4 extends BaseModel
}
if (!$reservation->hw_address->isEmpty()) {
$res['hw-address'] = str_replace('-', ':', $reservation->hw_address->getValue());
} elseif (!$reservation->client_id->isEmpty()) {
$res['client-id'] = $reservation->client_id->getValue();
}
// Add DHCP option-data elements for reservations

View file

@ -246,6 +246,9 @@
<check001>
<reference>hw_address.check001</reference>
</check001>
<check002>
<reference>client_id.check001</reference>
</check002>
</Constraints>
<Required>Y</Required>
</subnet>
@ -260,7 +263,6 @@
</Constraints>
</ip_address>
<hw_address type="MacAddressField">
<Required>Y</Required>
<Constraints>
<check001>
<ValidationMessage>Duplicate entry exists.</ValidationMessage>
@ -272,6 +274,19 @@
</check001>
</Constraints>
</hw_address>
<client_id type="TextField">
<Mask>/^(?:[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2})+)$/</Mask>
<ValidationMessage>Value must be a colon-separated hexadecimal sequence (e.g., 01:02:f3).</ValidationMessage>
<Constraints>
<check001>
<ValidationMessage>Duplicate entry exists.</ValidationMessage>
<type>UniqueConstraint</type>
<addFields>
<field1>subnet</field1>
</addFields>
</check001>
</Constraints>
</client_id>
<hostname type="HostnameField">
<IsDNSName>Y</IsDNSName>
</hostname>

View file

@ -64,9 +64,9 @@ class KeaDhcpv6 extends BaseModel
$messages->appendMessage(new Message(gettext("Either an IP address or a Prefix should be specified."), $key . ".ip_address"));
}
if (!$reservation->duid->isEmpty() && !$reservation->hw_address->isEmpty()) {
$messages->appendMessage(new Message(gettext("Either a DUID or an Ether address should be specified, but not both"), $key . ".duid"));
$messages->appendMessage(new Message(gettext("Either a DUID or an MAC address should be specified, but not both"), $key . ".duid"));
} elseif ($reservation->duid->isEmpty() && $reservation->hw_address->isEmpty()) {
$messages->appendMessage(new Message(gettext("Either a DUID or an Ether address should be specified."), $key . ".duid"));
$messages->appendMessage(new Message(gettext("Either a DUID or an MAC address should be specified."), $key . ".duid"));
}
}
// validate changed subnets

View file

@ -228,6 +228,9 @@
<check001>
<reference>duid.check001</reference>
</check001>
<check002>
<reference>hw_address.check001</reference>
</check002>
</Constraints>
<Required>Y</Required>
</subnet>

View file

@ -184,6 +184,7 @@
if (params.has('hostname')) $('#reservation\\.hostname').val(params.get('hostname'));
if (params.has('ip_address')) $('#reservation\\.ip_address').val(params.get('ip_address'));
if (params.has('hw_address')) $('#reservation\\.hw_address').val(params.get('hw_address'));
if (params.has('client_id')) $('#reservation\\.client_id').val(params.get('client_id'));
history.replaceState(null, null, window.location.pathname + '#reservations');
});
$(this).find('.command-add').trigger('click');

View file

@ -103,12 +103,22 @@
},
"commands": function (column, row) {
const baseUrl = `/ui/kea/dhcp/v4#reservations`;
const searchUrl = `${baseUrl}&search=${encodeURIComponent(row.hwaddr || '')}`;
let searchValue = '';
if (row.is_reserved.includes('client_id')) {
searchValue = row.client_id || '';
} else if (row.is_reserved.includes('hwaddr')) {
searchValue = row.hwaddr || '';
}
const addUrlParams = {
ip_address: row.address || '',
hw_address: row.hwaddr || '',
client_id: row.client_id || '',
hostname: row.hostname || ''
};
const searchUrl = `${baseUrl}&search=${encodeURIComponent(searchValue)}`;
const addUrl = `${baseUrl}?${new URLSearchParams(addUrlParams)}`;
let reservationBtn;

View file

@ -52,9 +52,11 @@ def get_reservation_keys(record, proto):
subnet_id = record.get('subnet-id')
if subnet_id is not None:
# Reservation returns hw-address and duid like this: "01:48:90"...
hwaddr = record.get('hw-address', '').lower()
duid = record.get('duid', '').lower()
client_id = record.get('client-id', '').lower()
# ...but client_id like this: "014890"
client_id = record.get('client-id', '').replace(':', '').lower()
if hwaddr:
keys.append((subnet_id, 'hwaddr', hwaddr))
@ -88,7 +90,7 @@ def build_reserved_matches(config, leases, proto, dhcp_key, config_key):
for reservation in subnet.get('reservations', []):
hwaddr = reservation.get('hw-address', '').lower()
duid = reservation.get('duid', '').lower()
client_id = reservation.get('client-id', '').lower()
client_id = reservation.get('client-id', '').replace(':', '').lower()
if hwaddr and (subnet_id, 'hwaddr', hwaddr) in wanted:
matches[(subnet_id, 'hwaddr', hwaddr)] = 'hwaddr'