WhyISC has retired isc-dhcp (end of life since 2022), and kea is its official successor. We will use it to serve DHCPv4 on a multi-subnet home gateway, with the host reservations kept in a MariaDB database (instead of the configuration file) and a REST interface for remote management.

See also
Build information

The MariaDB backend is a compile-time option, off by default (the official packages don’t include it), so ensure the following options:

net/kea
[x] MYSQL            MySQL database support
[ ] PGSQL            PostgreSQL database support

Installation

pkg install kea

keactrl starts every daemon enabled in /usr/local/etc/kea/keactrl.conf; as only DHCPv4 is wanted here, disable the others.

dhcp4=yes
dhcp6=no
dhcp_ddns=no
netconf=no
kea_enable="YES"    	      		# IP address allocation (DHCPd)

Since Kea 3.x there is no separate kea-ctrl-agent anymore: the REST interface is served directly by the daemons (see below). Similarly the database backends moved into hook libraries (libdhcp_mysql.so), which must be loaded explicitly.

Configuration

The configuration (/usr/local/etc/kea/kea-dhcp4.conf) is JSON, extended with comments (#, //, /* */) and file inclusion (<?include "file.json"?>) — the latter is handy to keep credentials out of a configuration file that is otherwise worth tracking in a repository.

{
"Dhcp4": {
    "interfaces-config": {
        // net0 carries an IP alias: pin the listener to ONE address
        "interfaces": [ "Interfaces to serve", "net1" ]
    },

    "lease-database": {
        "type": "memfile", "persist": true,
        "name": "/var/db/kea/kea-leases4.csv"
    },

    "valid-lifetime":     86400,	// 1 day
    "min-valid-lifetime":   600,	// 10 minutes
    "max-valid-lifetime": 604800,	// 1 week

    "authoritative": true,

    "option-data": [
        { "name": "domain-name",         "data": "Domain name" },
        { "name": "domain-name-servers", "data": "Name server" }
    ],

    "subnet4": [
        {
            "id": 1,			// stable id (kept mnemonic: third octet)
            "subnet": "192.168.1.0/24",
            "option-data": [
                { "name": "routers", "data": "192.168.1.254" }
            ],
            "pools": [
                // dynamic pool reserved to clients WITHOUT a host
                // reservation (the isc-dhcp "deny unknown-clients"
                // + pool "allow unknown-clients" pattern)
                { "pool": "192.168.1.200 - 192.168.1.249",
                  "client-classes": [ "UNKNOWN" ] }
            ]
        }
    ],

    "loggers": [
        {
            "name": "kea-dhcp4",
            "output-options": [ { "output": "syslog:local7" } ],
            "severity": "INFO"
        }
    ]
}
}

If an interface carries several IPv4 addresses (alias), Kea opens one broadcast socket per address and warns (DHCPSRV_MULTIPLE_RAW_SOCKETS_PER_IFACE) that some responses may be duplicated. Pin the listener with the iface/address notation as above.

There is no pool-level lease time (the isc-dhcp pool max-lease-time): attach a lifetime to a client class instead, here restricted to unknown clients on the LAN interface.

    "client-classes": [
        {
            "name": "lan-guest",
            "test": "not member('KNOWN') and pkt.iface == 'LAN interface'",
            "valid-lifetime": 3600
        }
    ],

The configuration can be checked without disturbing the running server, then applied with a reload:

kea-dhcp4 -t /usr/local/etc/kea/kea-dhcp4.conf
keactrl reload

Host reservations in MariaDB

Reservations can be kept in MariaDB instead of "reservations" entries in the configuration file: they are then queried live (a change is effective immediately, no reload), can carry a free-form comment, and are manageable remotely (SQL or REST). Create the database and initialize the schema with kea-admin:

CREATE DATABASE `kea-dhcp`;
CREATE USER 'kea'@'localhost' IDENTIFIED BY 'Password';
GRANT ALL ON `kea-dhcp`.* TO 'kea'@'localhost';
kea-admin db-init mysql -u root -p 'MariaDB root password' -n kea-dhcp -h localhost

When binary logging is enabled (replication), the schema’s triggers require the SUPER privilege: run kea-admin db-init with the MariaDB root account, not the kea user (which only needs table access at runtime).

Kea checks the schema version at startup and refuses to run on mismatch, but never upgrades it itself: after a kea package upgrade, run kea-admin db-upgrade mysql -n kea-dhcp … before restarting. The database now holds the only copy of the reservations, so add it to the backup set.

Then declare the backend, loading the MySQL hook library (Kea 3.x); the credentials are kept in a root-only file pulled in by <?include?>:

    "hooks-libraries": [
        { "library": "/usr/local/lib/kea/hooks/libdhcp_mysql.so" },
        { "library": "/usr/local/lib/kea/hooks/libdhcp_host_cmds.so" }
    ],
    "hosts-database": <?include "/usr/local/etc/kea/db.json"?>,
{
    "type": "mysql",
    "name": "kea-dhcp",
    "user": "kea",
    "password": "Password",
    "host": "localhost"
}

A reservation is a row in the hosts table; the user_context column holds free-form JSON, conventionally {"comment": …}. The dhcp4_subnet_id must match the subnet id in the configuration.

INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type,
                   dhcp4_subnet_id, ipv4_address, hostname, user_context)
VALUES (UNHEX('d83add38b6a0'), 0,	-- 0 = hw-address
        1, INET_ATON('192.168.1.4'),
        'brain', '{"comment": "RPI-4B (home automation)"}');

REST interface

The daemon serves its management API itself over HTTP (Kea 3.x replaced the former kea-ctrl-agent): declare an http control socket next to the default unix one. Kea refuses cleartext credentials in the configuration — user and password must each come from a file.

    "control-sockets": [
        { "socket-type": "unix",
          "socket-name": "/var/run/kea/kea4-ctrl-socket" },
        { "socket-type": "http",
          "socket-address": "Listen address",
          "socket-port": 8000,
          "authentication": <?include "/usr/local/etc/kea/http-auth.json"?> }
    ],
{
    "type": "basic",
    "realm": "kea-dhcp",
    "directory": "/usr/local/etc/kea",
    "clients": [ { "user-file": "api.user", "password-file": "api.pw" } ]
}

Commands are JSON over POST; for example querying a reservation through the host_cmds hook:

curl -s -u User:Password -X POST -H 'Content-Type: application/json' \
     -d '{"command": "reservation-get",
          "arguments": {"subnet-id": 1, "identifier-type": "hw-address",
                        "identifier": "d8:3a:dd:38:b6:a0"}}' \
     http://192.168.1.5:8000/

Remember the packet filter: with a default-deny ruleset the port stays unreachable from the network until a pass rule is added for the intended clients — which is a sane way to leave it until actually needed.

Migrating from isc-dhcp

The concepts map one to one; the main translations used here:

isc-dhcp (dhcpd.conf) Kea (kea-dhcp4.conf)
host { hardware ethernet; fixed-address; } reservation (file or database row)
deny unknown-clients + pool allow pool "client-classes": ["UNKNOWN"]
pool max-lease-time client class with valid-lifetime
class … match if substring(option vendor-class-identifier) "test": "substring(option[60].hex,0,9) == '…'"
next-server / filename "next-server" / "boot-file-name" (subnet, class or reservation)
log-facility local7; logger output "syslog:local7"

Leases are not worth migrating on a small network: reserved devices keep their addresses by definition, and with "authoritative": true unknown renewals are NAKed so dynamic clients immediately re-discover a fresh lease. The old server is simply stopped before the new one is started (both installed side by side), which also keeps the rollback trivial.