show dhcp server leases shows the MAC address of every DHCPv4 lease but not the
client identifier (DHCP option 61), even though Kea returns both. The DHCPv6 path
has no such gap — it reports the DUID for every lease.
The value is present right up until VyOS discards it. kea_get_leases() returns
the control socket's lease4-get-all payload untouched, and every v4 lease in it
carries client-id:
$ sudo python3 -c " from vyos.kea import kea_get_leases for l in kea_get_leases('4', None)[:2]: print(sorted(l.keys())) print(l['hw-address'], '->', l['client-id']) " ['client-id', 'cltt', 'fqdn-fwd', 'fqdn-rev', 'hostname', 'hw-address', 'ip-address', 'state', 'subnet-id', 'valid-lft'] 00:e0:4c:68:4e:0d -> 01:00:e0:4c:68:4e:0d ['client-id', 'cltt', 'fqdn-fwd', 'fqdn-rev', 'hostname', 'hw-address', 'ip-address', 'state', 'subnet-id', 'valid-lft'] 52:54:00:e3:be:41 -> ff:b5:5e:67:ff:00:02:00:00:ab:11:68:46:79:5a:ab:74:cd:25
kea_get_server_leases() in python/vyos/kea.py then builds the dict op-mode
returns, and copies the identifier for inet6 only:
data_lease['mac'] = lease.get('hw-address', '-') if inet == '4': data_lease['start'] = lease['start_time'].timestamp() # no client_id if inet == '6': data_lease['last_communication'] = lease['start_time'].timestamp() data_lease['duid'] = _format_hex_string(lease['duid'])
Because this is the function op-mode calls, the identifier is unreachable from
show dhcp server leases, from its --raw JSON, and from the HTTP API built on
top of it. sort_valid_inet6 already offers duid as a sort key; sort_valid_inet
has no counterpart.
Why this matters
DHCPv4 static mappings can be matched on the client identifier instead of the MAC:
set service dhcp-server shared-network-name LAN subnet 192.168.10.0/24 \
static-mapping example duid <client identifier>Hosts running systemd-networkd — the default on current Ubuntu, Fedora and others
— send an RFC 4361 identifier, ff:<IAID>:<DUID>, rather than the traditional
01:<MAC>. Kea matches a duid reservation against the DUID it extracts from
that identifier, with no host-reservation-identifiers configured:
host-reservation-identifiers in /run/kea/kea-dhcp4.conf : unset (Kea defaults apply)
reservation {"hostname": "ember-u26", "duid": "00:02:00:00:ab:11:68:46:79:5a:ab:74:cd:25", "ip-address": "192.168.10.92"}
host sends client-id ff:b5:5e:67:ff:00:02:00:00:ab:11:68:46:79:5a:ab:74:cd:25
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
resulting lease 192.168.10.92 the reserved addressSo the identifier a v4 reservation has to carry is a substring of a value the
operator cannot see anywhere in the CLI.
There is currently no supported way to read that value. It is not in the lease
output, and show dhcp server static-mappings only reports what was configured,
not what the host sends. The only route is sudo plus the Kea lease file or a
control socket query.
The consequence is a silent failure. A static mapping written with the wrong
identifier is accepted by the CLI, passes commit, and is rendered into
/run/kea/kea-dhcp4.conf as a valid reservation:
{"hostname": "ember-u26", "duid": "00:04:51:46:98:c8:e7:d8:46:f6:90:a7:a0:54:3b:d6:8f:5d", "ip-address": "192.168.10.92"}It then never matches, because that host sends
00:02:00:00:ab:11:68:46:79:5a:ab:74:cd:25 inside its client identifier. The host
carries on taking a dynamic address while show dhcp server static-mappings
reports it as reserved, and nothing in the CLI can show the operator why. Making
the identifier visible turns a silent mismatch into an obvious one.
A second, related case the column exposes: a host can hold leases under both
identifier styles at once — an initramfs or installer stack sending 01:<MAC> and
then networkd sending its RFC 4361 identifier — which is worth seeing before
choosing which one to pin on.
Proposed change
Copy the field in kea_get_server_leases() for inet 4, and surface it:
- python/vyos/kea.py — data_lease['client_id'] = lease.get('client-id', '-'), inside the existing if inet == '4':. Kea returns client-id already colon separated, so unlike the DHCPv6 DUID it must not go through _format_hex_string() — that would insert a second colon after every existing one. Clients that send no option 61 get -, matching how hostname and mac are handled two lines above.
- src/op_mode/dhcp.py — append a Client ID column to the inet table and add client_id to sort_valid_inet.
Appended rather than inserted beside the MAC: that matches where inet6 already
puts its DUID, and leaves every existing column in place.
Result:
IP Address MAC address State ... Hostname Origin Client ID 192.168.10.60 00:e0:4c:68:4e:0d active ... arya local 01:00:e0:4c:68:4e:0d 192.168.10.91 52:54:00:e3:be:31 active ... ember-u24 local ff:b5:5e:67:ff:00:02:00:00:ab:11:f8:af:84:82:a0:58:c1:5d 192.168.10.92 52:54:00:e3:be:41 active ... ember-u26 local ff:b5:5e:67:ff:00:02:00:00:ab:11:68:46:79:5a:ab:74:cd:25 192.168.10.93 52:54:00:e3:be:51 active ... ember-rocky10 local 01:52:54:00:e3:be:51
Compatibility
Additive. No configuration syntax changes, no new validation, and nothing in the
DHCP server's running behaviour is touched — the change is confined to how a
lease is reported. Every existing column keeps its position and meaning, and the
--raw JSON gains one key.
The only consumer that could notice is a script reading the last column of
show dhcp server leases positionally, which would now get the client identifier
instead of Origin. The column was appended specifically so that no existing
column moves.