High Availability DNS and DHCP



I’ve been wanting to move DNS and DHCP away from my Synology NAS ever since I realised these services were preventing the ability to create a bond across the two Synology network interfaces to increase bandwidth. I chose to go with PowerDNS and Kea DHCP services and this became an opportunity to explore a high availability setup.

Along the way I learnt that for PowerDNS, the pdns, recursor and dns-dist services were all going to be needed.

In this post, the primary and secondary servers are at 10.0.0.111 and 10.0.0.112 respectively. These servers each run DNS and DHCP services. The network is assumed to be 10.0.0.0/8. The settings in this post give access to this full network. I have restricted settings which you may want to configure also.

Base Hosts

I’m using Alpine Linux on two Raspberry Pi 4s, each with 8GB RAM. One note about Alpine: it doesn’t use sudo. Alpine instead uses doas to execute commands with elevated privileges.

Install MariaDB

As a first step, install MariaDB. I run a MariaDB Galera cluster in the Talos Kubernetes cluster but didn’t want DNS/DHCP services to be dependent on Kubernetes being up and running so the first step was a vanilla mariadb install. The Alpine Linux wiki page is at: https://wiki.alpinelinux.org/wiki/MariaDB

apk add mariadb mariadb-client
mysql_install_db --user=mysql --datadir=/var/lib/mysql
reboot
# this next line may fail if mariadb is already running
rc-service mariadb start
# Configure mariadb with mysql_secure_installation
# Change the root password - use mariadb galera
# Switch to unix_socket authentication [Y/n] n
# Change the root password? [Y/n] y
# Remove anonymous users? [Y/n] y
# Disallow root login remotely? [Y/n] n
# Remove test database and access to it? [Y/n] y
# Reload privilege tables now? [Y/n] y
mysql_secure_installation
rc-update add mariadb default

PowerDNS

Before we install PowerDNS, we first want to create a database with the PowerDNS schema. As root, run mariadb to start a mariadb session.

Create the mariadb database and user

CREATE DATABASE powerdns;
create user 'powerdns'@'10.%' identified by 'powerdns';
grant all privileges on powerdns.* to 'powerdns'@'10.%';
flush privileges;
exit;

Create the database schema

from https://doc.powerdns.com/authoritative/backends/generic-mysql.html?highlight=mariadb

USE powerdns;
CREATE TABLE domains (
  id                    INT AUTO_INCREMENT,
  name                  VARCHAR(255) NOT NULL,
  master                VARCHAR(128) DEFAULT NULL,
  last_check            INT DEFAULT NULL,
  type                  VARCHAR(8) NOT NULL,
  notified_serial       INT UNSIGNED DEFAULT NULL,
  account               VARCHAR(40) CHARACTER SET 'utf8' DEFAULT NULL,
  options               VARCHAR(64000) DEFAULT NULL,
  catalog               VARCHAR(255) DEFAULT NULL,
  PRIMARY KEY (id)
) Engine=InnoDB CHARACTER SET 'latin1';

CREATE UNIQUE INDEX name_index ON domains(name);
CREATE INDEX catalog_idx ON domains(catalog);


CREATE TABLE records (
  id                    BIGINT AUTO_INCREMENT,
  domain_id             INT DEFAULT NULL,
  name                  VARCHAR(255) DEFAULT NULL,
  type                  VARCHAR(10) DEFAULT NULL,
  content               VARCHAR(64000) DEFAULT NULL,
  ttl                   INT DEFAULT NULL,
  prio                  INT DEFAULT NULL,
  disabled              TINYINT(1) DEFAULT 0,
  ordername             VARCHAR(255) BINARY DEFAULT NULL,
  auth                  TINYINT(1) DEFAULT 1,
  PRIMARY KEY (id)
) Engine=InnoDB CHARACTER SET 'latin1';

CREATE INDEX nametype_index ON records(name,type);
CREATE INDEX domain_id ON records(domain_id);
CREATE INDEX ordername ON records (ordername);


CREATE TABLE supermasters (
  ip                    VARCHAR(64) NOT NULL,
  nameserver            VARCHAR(255) NOT NULL,
  account               VARCHAR(40) CHARACTER SET 'utf8' NOT NULL,
  PRIMARY KEY (ip, nameserver)
) Engine=InnoDB CHARACTER SET 'latin1';


CREATE TABLE comments (
  id                    INT AUTO_INCREMENT,
  domain_id             INT NOT NULL,
  name                  VARCHAR(255) NOT NULL,
  type                  VARCHAR(10) NOT NULL,
  modified_at           INT NOT NULL,
  account               VARCHAR(40) CHARACTER SET 'utf8' DEFAULT NULL,
  comment               TEXT CHARACTER SET 'utf8' NOT NULL,
  PRIMARY KEY (id)
) Engine=InnoDB CHARACTER SET 'latin1';

CREATE INDEX comments_name_type_idx ON comments (name, type);
CREATE INDEX comments_order_idx ON comments (domain_id, modified_at);


CREATE TABLE domainmetadata (
  id                    INT AUTO_INCREMENT,
  domain_id             INT NOT NULL,
  kind                  VARCHAR(32),
  content               TEXT,
  PRIMARY KEY (id)
) Engine=InnoDB CHARACTER SET 'latin1';

CREATE INDEX domainmetadata_idx ON domainmetadata (domain_id, kind);


CREATE TABLE cryptokeys (
  id                    INT AUTO_INCREMENT,
  domain_id             INT NOT NULL,
  flags                 INT NOT NULL,
  active                BOOL,
  published             BOOL DEFAULT 1,
  content               TEXT,
  PRIMARY KEY(id)
) Engine=InnoDB CHARACTER SET 'latin1';

CREATE INDEX domainidindex ON cryptokeys(domain_id);


CREATE TABLE tsigkeys (
  id                    INT AUTO_INCREMENT,
  name                  VARCHAR(255),
  algorithm             VARCHAR(50),
  secret                VARCHAR(255),
  PRIMARY KEY (id)
) Engine=InnoDB CHARACTER SET 'latin1';

CREATE UNIQUE INDEX namealgoindex ON tsigkeys(name, algorithm);

Install PowerDNS

Add the community repo

echo "http://dl-cdn.alpinelinux.org/alpine/v3.22/community" >> /etc/apk/repositories
cat /etc/apk/repositories
#/media/sda/apks
http://dl-cdn.alpinelinux.org/alpine/v3.22/main
http://dl-cdn.alpinelinux.org/alpine/v3.22/community

Install pdns:

apk update
apk add pdns pdns-backend-mariadb
rc-update add pdns

Configure PowerDNS

Create or update the PowerDNS configuration file at /etc/pdns/pdns.conf:

# api and primary settings
allow-axfr-ips=127.0.0.0/8,10.0.0.111,10.0.0.112
api=yes
api-key=your_secure_api_key # Use date | md5sum
local-port=5300
primary=yes
secondary=no

# web server settings
webserver=yes
webserver-address=0.0.0.0
webserver-allow-from=10.0.0.0/8
webserver-port=8081

setuid=pdns
setgid=pdns

# mariadb settings
launch=gmysql
gmysql-host=10.0.0.111
gmysql-dbname=powerdns
gmysql-user=powerdns
gmysql-password=zbqMSPpG6G

# dynamic dns
allow-dnsupdate-from=127.0.0.0/8,10.0.0.111,10.0.0.112
dnsupdate=yes

# logging
log-dns-queries=yes
log-dns-details=yes
loglevel=3

Unpacking this configuration, the first section sets up the server as a primary with primary=yes and secondary=no. The only difference in the configuration for the secondary server is to swap these parameters around so that primary=no and secondary=yes.

The parameter allow-axfr-ips will allow communication between the primary and secondary for DNS updates.

Finally, we set local-port=5300. DNS services usually run on port 53, but dns-dist will run here in this setup:

# api and primary settings
allow-axfr-ips=127.0.0.0/8,10.0.0.111,10.0.0.112
api=yes
api-key=create-a-secure-api-key
local-port=5300
primary=yes
secondary=no

Restart the service

rc-service pdns restart

Install dig

Install dig for test purposes

apk add --update bind-tools

Install PowerDNS Recursor

apk add pdns-recursor

Update /etc/pdns/recursor.conf

incoming:
  listen:
    - 0.0.0.0:5353
    - '[::]:5353'
  allow_from:
    - 10.0.0.0/8
  port: 53
recursor:
  daemon: true
  setgid: recursor
  setuid: recursor

It’s not immediately obvious from the pdns-recursor documentation, but it will not forward AXFR requests. We are going to need to use dns-dist for this.

Restart the PowerDNS recursor service:

rc-service pdns-recursor restart

Make sure PowerDNS and PowerDNS Resolver start automatically when Alpine Linux boots:

PowerDNS

su
# Enable the service to start at boot
rc-update add pdns default

# Start the service now (if not already running)
rc-service pdns start

# Check status
rc-service pdns status

PowerDNS Resolver

su
# Enable the service to start at boot
rc-update add pdns-recursor default

# Start the service now (if not already running)
rc-service pdns-recursor start

# Check status
rc-service pdns-recursor status

Verify services are enabled

# List all services enabled for the default runlevel
rc-update show default
                acpid | default
                crond | default
              mariadb | default
                 ntpd | default
                 pdns | default
        pdns-recursor | default
                 sshd | default

PowerAdmin

PowerAdmin provides a web front end to PowerDNS. There are a few documentation pages that were useful, for example: https://docs.poweradmin.org/installation/ , https://docs.poweradmin.org/getting-started/docker-demo/ and https://docs.poweradmin.org/installation/remote-setup-guide/ .

Create the mariadb database and user

CREATE USER 'poweradmin'@'10.%' IDENTIFIED BY 'poweradmin';
GRANT SELECT, INSERT, UPDATE, DELETE ON powerdns.* TO 'poweradmin'@'10.%';
FLUSH PRIVILEGES;

Clone the poweradmin repo and update the database settings:

git clone https://github.com/poweradmin/poweradmin.git
cd poweradmin
cp config/settings.defaults.php config/settings.php
nano config/settings.php

Edit the database connection settings:

'database' => [
    'host' => 'dns_server_ip',  // IP address of your PowerDNS server
    'port' => '3306',           // Database port (MySQL default: 3306, PostgreSQL: 5432)
    'user' => 'poweradmin',     // The database user created in step 1
    'password' => 'secure_password',
    'name' => 'powerdns',       // The PowerDNS database name
    'type' => 'mysql',          // mysql, pgsql, or sqlite
],

Create the poweradmin database structure

Apply the file sql/poweradmin-mysql-db-structure.sql to the database

https://github.com/poweradmin/poweradmin/blob/master/sql/poweradmin-mysql-db-structure.sql
use powerdns;
-- Adminer 4.8.1 MySQL 5.5.5-10.9.3-MariaDB-1:10.9.3+maria~ubu2204 dump

SET NAMES utf8;
SET time_zone = '+00:00';
SET foreign_key_checks = 0;
SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';

SET NAMES utf8mb4;

CREATE TABLE `log_users` (
                             `id` int(11) NOT NULL AUTO_INCREMENT,
                             `event` varchar(2048) NOT NULL,
                             `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
                             `priority` int(11) NOT NULL,
                             PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


CREATE TABLE `log_zones` (
                             `id` int(11) NOT NULL AUTO_INCREMENT,
                             `event` varchar(2048) NOT NULL,
                             `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
                             `priority` int(11) NOT NULL,
                             `zone_id` int(11) DEFAULT NULL,
                             PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `users` (
                         `id` int(11) NOT NULL AUTO_INCREMENT,
                         `username` varchar(64) NOT NULL,
                         `password` varchar(128) NOT NULL,
                         `fullname` varchar(255) NOT NULL,
                         `email` varchar(255) NOT NULL,
                         `description` varchar(1024) NOT NULL,
                         `perm_templ` int(11) NOT NULL,
                         `active` int(1) NOT NULL,
                         `use_ldap` int(1) NOT NULL,
                         PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `users` (`id`, `username`, `password`, `fullname`, `email`, `description`, `perm_templ`, `active`, `use_ldap`) VALUES
    (1,	'admin',	'$2y$12$10ei/WGJPcUY9Ea8/eVage9zBbxr0xxW82qJF/cfSyev/jX84WHQe',	'Administrator',	'admin@example.net',	'Administrator with full rights.',	1,	1,	0);

CREATE TABLE `login_attempts` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `user_id` int(11) NULL,
    `ip_address` varchar(45) NOT NULL,
    `timestamp` int(11) NOT NULL,
    `successful` tinyint(1) NOT NULL,
    PRIMARY KEY (`id`),
    KEY `idx_user_id` (`user_id`),
    KEY `idx_ip_address` (`ip_address`),
    KEY `idx_timestamp` (`timestamp`),
    CONSTRAINT `fk_login_attempts_users`
        FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
        ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


CREATE TABLE `migrations` (
                              `version` bigint(20) NOT NULL,
                              `migration_name` varchar(100) DEFAULT NULL,
                              `start_time` timestamp NULL DEFAULT NULL,
                              `end_time` timestamp NULL DEFAULT NULL,
                              `breakpoint` tinyint(1) NOT NULL DEFAULT '0',
                              PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


CREATE TABLE `perm_items` (
                              `id` int(11) NOT NULL AUTO_INCREMENT,
                              `name` varchar(64) NOT NULL,
                              `descr` varchar(1024) NOT NULL,
                              PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `perm_items` (`id`, `name`, `descr`) VALUES
                                                     (41,	'zone_master_add',	'User is allowed to add new master zones.'),
                                                     (42,	'zone_slave_add',	'User is allowed to add new slave zones.'),
                                                     (43,	'zone_content_view_own',	'User is allowed to see the content and meta data of zones he owns.'),
                                                     (44,	'zone_content_edit_own',	'User is allowed to edit the content of zones he owns.'),
                                                     (45,	'zone_meta_edit_own',	'User is allowed to edit the meta data of zones he owns.'),
                                                     (46,	'zone_content_view_others',	'User is allowed to see the content and meta data of zones he does not own.'),
                                                     (47,	'zone_content_edit_others',	'User is allowed to edit the content of zones he does not own.'),
                                                     (48,	'zone_meta_edit_others',	'User is allowed to edit the meta data of zones he does not own.'),
                                                     (49,	'search',	'User is allowed to perform searches.'),
                                                     (50,	'supermaster_view',	'User is allowed to view supermasters.'),
                                                     (51,	'supermaster_add',	'User is allowed to add new supermasters.'),
                                                     (52,	'supermaster_edit',	'User is allowed to edit supermasters.'),
                                                     (53,	'user_is_ueberuser',	'User has full access. God-like. Redeemer.'),
                                                     (54,	'user_view_others',	'User is allowed to see other users and their details.'),
                                                     (55,	'user_add_new',	'User is allowed to add new users.'),
                                                     (56,	'user_edit_own',	'User is allowed to edit their own details.'),
                                                     (57,	'user_edit_others',	'User is allowed to edit other users.'),
                                                     (58,	'user_passwd_edit_others',	'User is allowed to edit the password of other users.'),
                                                     (59,	'user_edit_templ_perm',	'User is allowed to change the permission template that is assigned to a user.'),
                                                     (60,	'templ_perm_add',	'User is allowed to add new permission templates.'),
                                                     (61,	'templ_perm_edit',	'User is allowed to edit existing permission templates.'),
                                                     (62,	'zone_content_edit_own_as_client',	'User is allowed to edit record, but not SOA and NS.'),
                                                     (63,	'zone_templ_add',	'User is allowed to add new zone templates.'),
                                                     (64,	'zone_templ_edit',	'User is allowed to edit existing zone templates.');

CREATE TABLE `perm_templ` (
                              `id` int(11) NOT NULL AUTO_INCREMENT,
                              `name` varchar(128) NOT NULL,
                              `descr` varchar(1024) NOT NULL,
                              PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `perm_templ` (`id`, `name`, `descr`) VALUES
    (1,	'Administrator',	'Administrator template with full rights.');

CREATE TABLE `perm_templ_items` (
                                    `id` int(11) NOT NULL AUTO_INCREMENT,
                                    `templ_id` int(11) NOT NULL,
                                    `perm_id` int(11) NOT NULL,
                                    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `perm_templ_items` (`id`, `templ_id`, `perm_id`) VALUES
    (1,	1,	53);

CREATE TABLE `records_zone_templ` (
                                      `domain_id` int(11) NOT NULL,
                                      `record_id` int(11) NOT NULL,
                                      `zone_templ_id` int(11) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `zones` (
                         `id` int(11) NOT NULL AUTO_INCREMENT,
                         `domain_id` int(11) NOT NULL,
                         `owner` int(11) NOT NULL,
                         `comment` varchar(1024) DEFAULT NULL,
                         `zone_templ_id` int(11) NOT NULL,
                         PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


CREATE TABLE `zone_templ` (
                              `id` int(11) NOT NULL AUTO_INCREMENT,
                              `name` varchar(128) NOT NULL,
                              `descr` varchar(1024) NOT NULL,
                              `owner` int(11) NOT NULL,
                              `created_by` int(11) DEFAULT NULL,
                              PRIMARY KEY (`id`),
                              CONSTRAINT `fk_zone_templ_users` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


CREATE TABLE `zone_templ_records` (
                                      `id` int(11) NOT NULL AUTO_INCREMENT,
                                      `zone_templ_id` int(11) NOT NULL,
                                      `name` varchar(255) NOT NULL,
                                      `type` varchar(6) NOT NULL,
                                      `content` varchar(2048) NOT NULL,
                                      `ttl` int(11) NOT NULL,
                                      `prio` int(11) NOT NULL,
                                      PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `api_keys` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `name` varchar(255) NOT NULL,
    `secret_key` varchar(255) NOT NULL,
    `created_by` int(11) DEFAULT NULL,
    `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `last_used_at` timestamp NULL DEFAULT NULL,
    `disabled` tinyint(1) NOT NULL DEFAULT '0',
    `expires_at` timestamp NULL DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_api_keys_secret_key` (`secret_key`),
    KEY `idx_api_keys_created_by` (`created_by`),
    KEY `idx_api_keys_disabled` (`disabled`),
    CONSTRAINT `fk_api_keys_users` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user_mfa` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `user_id` int(11) NOT NULL,
    `enabled` tinyint(1) NOT NULL DEFAULT 0,
    `secret` varchar(255) DEFAULT NULL,
    `recovery_codes` text DEFAULT NULL,
    `type` varchar(20) NOT NULL DEFAULT 'app',
    `last_used_at` datetime DEFAULT NULL,
    `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
    `verification_data` text DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_user_mfa_user_id` (`user_id`),
    KEY `idx_user_mfa_enabled` (`enabled`),
    CONSTRAINT `fk_user_mfa_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user_preferences` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `user_id` int(11) NOT NULL,
    `preference_key` varchar(100) NOT NULL,
    `preference_value` text DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_user_preferences_user_key` (`user_id`, `preference_key`),
    KEY `idx_user_preferences_user_id` (`user_id`),
    CONSTRAINT `fk_user_preferences_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `zone_template_sync` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `zone_id` int(11) NOT NULL,
    `zone_templ_id` int(11) NOT NULL,
    `last_synced` timestamp NULL DEFAULT NULL,
    `template_last_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `needs_sync` tinyint(1) NOT NULL DEFAULT 0,
    `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_zone_template_unique` (`zone_id`, `zone_templ_id`),
    KEY `idx_zone_templ_id` (`zone_templ_id`),
    KEY `idx_needs_sync` (`needs_sync`),
    CONSTRAINT `fk_zone_template_sync_zone` FOREIGN KEY (`zone_id`) REFERENCES `zones` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT `fk_zone_template_sync_templ` FOREIGN KEY (`zone_templ_id`) REFERENCES `zone_templ` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2022-09-29 19:08:10

CREATE TABLE `password_reset_tokens` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `email` varchar(255) NOT NULL,
    `token` varchar(64) NOT NULL,
    `expires_at` timestamp NOT NULL,
    `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `used` tinyint(1) NOT NULL DEFAULT 0,
    `ip_address` varchar(45) DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_token` (`token`),
    KEY `idx_email` (`email`),
    KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user_agreements` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `user_id` int(11) NOT NULL,
    `agreement_version` varchar(50) NOT NULL,
    `accepted_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `ip_address` varchar(45) DEFAULT NULL,
    `user_agent` text DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `unique_user_agreement` (`user_id`, `agreement_version`),
    KEY `idx_user_id` (`user_id`),
    KEY `idx_agreement_version` (`agreement_version`),
    CONSTRAINT `fk_user_agreements_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Test PowerAdmin with docker

To quickly test PowerAdmin, we can run in docker. Build and run the container:

# on den.agjei.xyz
cd docker/poweradmin
docker build --no-cache -t poweradmin .
docker run -d --name poweradmin -p 8080:80 -v ./config:/app/config poweradmin
docker logs poweradmin -f

Use the following credentials to log in:

Install PowerAdmin in Kubernetes

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: poweradmin
  namespace: dns
spec:
  selector:
    matchLabels:
      name: poweradmin
  template:
    metadata:
      labels:
        name: poweradmin
    spec:
      containers:
        - name: poweradmin
          image: harbor.example.com/example.com/poweradmin:v3.9.3
          ports:
            - name: poweradmin
              containerPort: 80
          volumeMounts:
            - name: poweradmin-config
              mountPath: /app/config/settings.php
              subPath: settings.php
      volumes:
        - name: poweradmin-config
          configMap:
            name: poweradmin-config
---
apiVersion: v1
kind: Service
metadata:
  name: poweradmin
  namespace: dns
spec:
  type: ClusterIP
  ports:
    - name: poweradmin
      port: 80
  selector:
    name: poweradmin
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: poweradmin
  namespace: dns
  annotations:
    external-dns.alpha.kubernetes.io/target: traefik.example.com
spec:
  entryPoints:
    - websecure
  routes:
  - match: Host(`dns.example.com`)
    kind: Rule
    services:
    - name: poweradmin
      port: 80

Configure PowerDNS API Access

For DNSSEC management and certain operations, Poweradmin requires access to the PowerDNS API:

  1. We enabled the API in the PowerDNS server. Be sure to change the webserver-allow-from CIDR to something more reasonable:

    api=yes
    api-key=your_secure_api_key # Use date | md5sum
    webserver=yes 
    webserver-address=0.0.0.0
    webserver-port=8081
    webserver-allow-from=10.0.0.0/8
    
  2. Configure Poweradmin to use the API by editing your settings.php:

    'pdns_api' => [
         'url' => 'http://10.0.0.111:8081',       // PowerDNS API URL
         'key' => 'your_secure_api_key',        // PowerDNS API key ],
    

Create the zones we need:

Forward Zone: example.com

Reverse Zones need to be divided up into zones of 24-bits (ie /24 reverse zones):

Note: no full stop at the end

Install Kea DHCP

We will use kea for DHCP IP addresses. Install

apk update
apk add kea kea-admin

Create the database:

CREATE DATABASE kea;
create user 'kea'@'10.%' identified by 'kea';
grant all privileges on kea.* to 'kea'@'10.%';
flush privileges;

Initialise the database:

kea-admin db-init mysql -h 10.0.0.111 -u kea -n kea -p

Update the config file:

nano /etc/kea/kea-dhcp4.conf

Check Kea is running when Alpine Linux boots:

su
# Enable the service to start at boot
rc-update add kea-dhcp4 default

# Start the service now (if not already running)
rc-service kea-dhcp4 start

# Check status
rc-service kea-dhcp4 status

Kea SQL queries

The kea database is not straight-forward to query. Here are a selection of queries that I’ve found useful. While there is a web app, Stork, available it seems overly complicated for my homelab network setup. As an alternative I wrote a tiny app, kea-ui, to provide lease and reservation information easily.

SQL: Show all current leases from Kea

-- Query to view leases
SELECT 
    UPPER(CONCAT(
        SUBSTR(HEX(hwaddr), 1, 2), ':',
        SUBSTR(HEX(hwaddr), 3, 2), ':',
        SUBSTR(HEX(hwaddr), 5, 2), ':',
        SUBSTR(HEX(hwaddr), 7, 2), ':',
        SUBSTR(HEX(hwaddr), 9, 2), ':',
        SUBSTR(HEX(hwaddr), 11, 2)
    )) as mac_address,
    INET_NTOA(address) as ip_address,
    hostname,
    expire as lease_expires,
    state as lease_state,
    subnet_id
FROM lease4
WHERE expire > UNIX_TIMESTAMP()  -- Only active (not expired) leases
ORDER BY address;

SQL: Show current reservations in Kea

-- Query to view reservations
SELECT 
	 host_id,
    UPPER(CONCAT(
        SUBSTR(HEX(dhcp_identifier), 1, 2), ':',
        SUBSTR(HEX(dhcp_identifier), 3, 2), ':',
        SUBSTR(HEX(dhcp_identifier), 5, 2), ':',
        SUBSTR(HEX(dhcp_identifier), 7, 2), ':',
        SUBSTR(HEX(dhcp_identifier), 9, 2), ':',
        SUBSTR(HEX(dhcp_identifier), 11, 2)
    )) as mac_address,
    INET_NTOA(ipv4_address) as ip_address,
    hostname
FROM hosts 
WHERE dhcp_identifier_type = 0
ORDER BY ipv4_address;

SQL: Add a reservation based on MAC address in Kea

--- Query to add a new reservation based on MAC address
INSERT INTO hosts (
    dhcp_identifier_type,
    dhcp_identifier,
    ipv4_address,
    hostname,
    dhcp4_subnet_id
) VALUES (
    0,
    UNHEX(REPLACE('1C:71:25:6F:11:FA', ':', '')),
    INET_ATON('10.19.1.201'),
    'gary-iphone',
    1
);

SQL: Join reservations and leases in Kea

--  Show current lease reservations (joins hosts with active leases)
SELECT 
    UPPER(CONCAT(
        SUBSTR(HEX(h.dhcp_identifier), 1, 2), ':',
        SUBSTR(HEX(h.dhcp_identifier), 3, 2), ':',
        SUBSTR(HEX(h.dhcp_identifier), 5, 2), ':',
        SUBSTR(HEX(h.dhcp_identifier), 7, 2), ':',
        SUBSTR(HEX(h.dhcp_identifier), 9, 2), ':',
        SUBSTR(HEX(h.dhcp_identifier), 11, 2)
    )) as reserved_mac,
    INET_NTOA(h.ipv4_address) as reserved_ip,
    h.hostname as reserved_hostname,
    CASE 
        WHEN l.address IS NOT NULL THEN 'ACTIVE LEASE'
        ELSE 'RESERVED ONLY'
    END as lease_status,
    INET_NTOA(l.address) as leased_ip,
    UPPER(HEX(l.hwaddr)) as leased_mac,
    l.expire as lease_expires
FROM hosts h
LEFT JOIN lease4 l ON h.ipv4_address = l.address
WHERE h.dhcp_identifier_type = 0
ORDER BY h.ipv4_address;

SQL: Delete a reservation

--- Query to delete a reservation
DELETE FROM hosts 
WHERE dhcp_identifier = UNHEX(REPLACE('aa:bb:cc:dd:ee:ff', ':', ''))
AND dhcp_identifier_type = 0;

Enable Dynamic DNS between Kea and PowerDNS

PowerDNS

From: https://carll.medium.com/get-up-and-running-on-with-your-own-dns-and-dhcp-server-from-scratch-powerdns-isc-dhcp-server-4b9d6185d275

The following two lines from /etc/pdns/pdns.conf enable dynamic dns:

# dynamic dns
allow-dnsupdate-from=127.0.0.0/8,10.0.0.111
dnsupdate=yes

TSIG is a shared secret between your DNS server and your DHCP server to allow DNS records to be updated. Further reader on TSIG can be found here.

Create a TSIG key for kea to use:

# Create a key called 'dhcp-key' using hmac-sha256
pdnsutil generate-tsig-key dhcp-key hmac-sha256
# Enable the key on your DNS zone
pdnsutil activate-tsig-key example.come dhcp-key primary
# To verify you can list your keys
pdnsutil list-tsig-keys
# Verify the key is enabled for your domain
pdnsutil show-zone example.com

Copy the TSIG key to the secondary:

#primary
SELECT name, algorithm, secret FROM tsigkeys WHERE name = 'dhcp-key';
#secondary
INSERT INTO tsigkeys (name, algorithm, secret)  VALUES ('dhcp-key', 'hmac-md5', 'YOUR_SECRET_HERE');

INSERT INTO domainmetadata (domain_id, kind, content) SELECT id, 'TSIG-ALLOW-AXFR', 'dhcp-key' FROM domains WHERE name = 'agjei.xyz';

Set up kea-dhcpdns

https://plon.medium.com/dynamic-dns-in-your-homelab-4b130c5b335d

Install kea-dhcp-ddns:

apk add kea-dhcp-ddns

Create /etc/kea/tsig-keys.json with the content:

"tsig-keys": [
    {
       "name": "kea-bind",
       "algorithm": "hmac-sha256",
       "secret": "tsgig-key-from-above" # add your key here
    }
],  

Create /etc/kea-dhcp-ddns.conf with the content:

{
"DhcpDdns":
{
  "ip-address": "127.0.0.1",
  "port": 53001,
  "control-socket": {
      "socket-type": "unix",
      "socket-name": "kea-ddns-ctrl-socket"
  },
  
  <?include "/etc/kea/tsig-keys.json"?>

  "forward-ddns" : {
      "ddns-domains" : [
          {
               "name": "example.com
               "key-name": "kea-powerdns",
               "dns-servers": [
                   { "ip-address": "10.0.0.111" }
               ]
          }
      ]
  },
  
  "reverse-ddns" : {
      "ddns-domains" : [
          {
               "name": "0.19.0.in-addr.arpa.",
               "key-name": "kea-powerdns",
               "dns-servers": [
                   { "ip-address": "10.0.0.111" }
               ]
          }
      ]
  },

  "loggers": [
    {
        "name": "kea-dhcp-ddns",
        "output_options": [
            {
                "output": "stdout",
                "pattern": "%-5p %m\n"
            }
        ],
        "severity": "INFO",
        "debuglevel": 0
    }
  ]
}
}

Restart services:

rc-service kea-dhcp-ddns restart
rc-service kea-dhcp4 restart
rc-service pdns-recursor restart

rc-update add kea-dhcp-ddns default

Debugging

While we are using OpenRC init system (rc-service) for normal running, it can be useful to run commands directly in a shell for debug purposes:

Call the kea-dhcp4 binary directly for debug logging

kea-dhcp4 -c /etc/kea/kea-dhcp4.conf

Same for kea-dhcp-ddns :

kea-dhcp-ddns -c /etc/kea/kea-dhcp-ddns.conf

And for pdns:

pdns_server --daemon=no --disable-syslog

Enable pdns high-availability

The config files already have the primary-secondary config but we also need to update the database:

On dns2:

use powerdns;
insert into domains (name, master, type) values ('example.com', '10.0.0.111', 'SLAVE');
select id from domains where name='example.com';
insert into domainmetadata (domain_id, kind, content) values (3, 'TSIG-ALLOW-AXFR', 'dhcp-key');

Create NS entries for:

The nameservers have to be set up correctly as NS domain records i.e. defining a NS and A record for each secondary. https://doc.powerdns.com/authoritative/modes-of-operation.html

Install dnsdist

At this point, I ran into trouble… The PowerDNS primary and secondary just would not talk to each other. I tried configuring the authorative server to forward requests and the recursor to forward requests… It turns out:

su
apk update
apk add dnsdist

the Alpine package does not install a service for dnsdist so create it:

doas nano /etc/init.d

Add these file contents:

#!/sbin/openrc-run
# dnsdist OpenRC init script for Alpine Linux

name="dnsdist"
description="DNS load balancer and traffic redirector"

command="/usr/bin/dnsdist"
command_args="--supervised --config /etc/dnsdist/dnsdist.conf"
command_background="yes"
pidfile="/run/${RC_SVCNAME}.pid"

required_files="/etc/dnsdist/dnsdist.conf"

depend() {
    need net
    after logger
    provide dns
}

start_pre() {
    # Test configuration before starting
    ebegin "Testing dnsdist configuration"
    ${command} --check-config --config /etc/dnsdist/dnsdist.conf
    eend $? "Configuration test failed"
}

start() {
    ebegin "Starting ${name}"
    start-stop-daemon --start \
        --pidfile "${pidfile}" \
        --make-pidfile \
        --background \
        --exec "${command}" \
        -- ${command_args}
    eend $?
}

stop() {
    ebegin "Stopping ${name}"
    start-stop-daemon --stop \
        --pidfile "${pidfile}" \
        --exec "${command}"
    eend $?
}

reload() {
    ebegin "Reloading ${name} configuration"
    if [ -f "${pidfile}" ]; then
        kill -HUP $(cat ${pidfile})
        eend $?
    else
        eend 1 "Service not running"
    fi
}

Create the dns-dist config file at /etc/dnsdist/dnsdist.conf:

-- dnsdist 1.9.0 Configuration
-- Define backend servers
-- This is the primary server configuration file. The secondary server should use address="10.0.0.112"
newServer({address="10.0.0.111:5300", name="authoritative", pool="standard"})
newServer({address="10.0.0.111:5353", name="recursor", pool="alt"})

-- Forward SOA queries to standard DNS port (53)
addAction(QTypeRule(DNSQType.SOA), PoolAction("standard"))

-- Forward AXFR queries to standard DNS port (53)
addAction(QTypeRule(DNSQType.AXFR), PoolAction("standard"))

-- Forward PTR queries to authoritative server
addAction(QTypeRule(DNSQType.PTR), PoolAction("standard"))

-- Use the authoritative server for our domain
addAction(SuffixMatchNodeRule("example.com"), PoolAction("standard"))

-- Route reverse DNS queries for our local subnets to authoritative server
addAction(SuffixMatchNodeRule("0.10.in-addr.arpa"), PoolAction("standard"))

-- Forward all other queries to alternative port (5353)
addAction(AllRule(), PoolAction("alt"))

-- Basic configuration
setLocal("0.0.0.0:53")
setACL({"0.0.0.0/0"})

-- Optional: Enable console for management
controlSocket("127.0.0.1:5199")

-- Set debug logging level
setVerboseHealthChecks(true)
setVerbose(true)

-- Enhanced logging with debug information
addAction(AllRule(), LogAction("/var/log/dnsdist.log", true))

-- Add response logging for debugging
addResponseAction(AllRule(), LogResponseAction("/var/log/dnsdist-responses.log", true))

Make it executable and install the service

su
chmod +x /etc/init.d/dnsdist
dnsdist --check-config --config /etc/dnsdist/dnsdist.conf
rc-update add dnsdist default
rc-service dnsdist start
rc-service dnsdist status

Voila! Now the PowerDNS primage and secondary can share messages. Test with:

# On primary server
pdns_control notify example.com

# On secondary server
pdns_control retrieve example.com

Enable kea high availability

From: https://kea.readthedocs.io/en/kea-2.0.1/arm/config-templates.html?highlight=standby

Install the control-server:

apk update
apk add kea-ctrl-agent
apk add kea-hook-lease-cmds
apk add kea-hook-ha

Create the kea configuration file

The master kea configuration file lives at /etc/kea/kea-dhcp4.conf :

{

"Dhcp4": {
    "interfaces-config": {
        "interfaces": [ "eth0" ]
    },
    "control-socket": {
        "socket-type": "unix",
        "socket-name": "kea-dhcp4-ctrl.sock"
    },
    "lease-database": {
        "type": "mysql",
        "name": "kea",
        "host": "10.0.0.111",
        "port": 3306,
        "user": "kea",
        "password": "kea",
        "lfc-interval": 3600
    },
    "hosts-database": {
        "type": "mysql",
        "name": "kea",
        "user": "kea",
        "password": "kea",
        "host": "10.0.0.111",
        "port": 3306
    },
    "expired-leases-processing": {
        "reclaim-timer-wait-time": 10,
        "flush-reclaimed-timer-wait-time": 25,
        "hold-reclaimed-time": 3600,
        "max-reclaim-leases": 100,
        "max-reclaim-time": 250,
        "unwarned-reclaim-cycles": 5
    },
    "renew-timer": 302400,
    "rebind-timer": 529200,
    "valid-lifetime": 604800,
    "option-data": [
        {
            "name": "domain-name-servers",
            "data": "10.0.0.111" // For DHCP clients, I actually use a different DNS service for blocking adverts
        },
        {
            "code": 15,
            "data": "example.com"
        },
        {
            "name": "domain-search",
            "data": "example.com"
        }
    ],
     "dhcp-ddns": {
        "enable-updates": true
     },
     "ddns-qualifying-suffix": "example.com",
     "ddns-override-client-update": true,
    "hooks-libraries": [
      {
        "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"
      },
      {
            "library": "/usr/lib/kea/hooks/libdhcp_ha.so",
            "parameters": {
                "high-availability": [ {
                    "this-server-name": "kea-server-1",
                    "mode": "hot-standby",
                    "heartbeat-delay": 10000,
                    "max-response-delay": 60000,
                    "max-ack-delay": 5000,
                    "max-unacked-clients": 5,
                    "sync-timeout": 60000,
                    "peers": [
                        {
                            "name": "kea-server-1",
                            "url": "http://10.0.0.111:8001/",
                            "role": "primary"
                        },
                        {
                            "name": "kea-server-2",
                            "url": "http://10.0.0.112:8001/",
                            "role": "standby"
                        }
                    ]
                } ]
            }
        }
    ],
    "subnet4": [
        {
            "id": 1,
            "subnet": "10.0.0.0/8",
            "pools": [ { "pool": "10.1.0.0 - 10.1.0.254" } ],
            "option-data": [
                {
                    "name": "routers",
                    "data": "10.0.0.1"
                }
            ],
            "reservations": [
            ]
        }
    ],
    "loggers": [
    {
        "name": "kea-dhcp4",
        "output-options": [
            {
                "output": "kea-dhcp4.log"
            }
        ],
        "severity": "DEBUG",
        "debuglevel": 0
    }
  ]
}
}

The default configuration file for the ctrl-agent lives at /etc/kea/kea-ctrl-agent.conf :

{
    "Control-agent":
    {
        "http-host": "10.0.0.111",
        "http-port": 8000,
        "control-sockets":
        {
            "dhcp4":
            {
                "comment": "socket to DHCP4 server",
                "socket-type": "unix",
                "socket-name": "kea-dhcp4-ctrl.sock"
            },
            "dhcp6":
            {
                "socket-type": "unix",
                "socket-name": "kea-dhcp6-ctrl.sock"
            },
            "d2":
            {
                "socket-type": "unix",
                "socket-name": "kea-dhcp-ddns-ctrl.sock",
                "user-context": { "in-use": false }
            }
        },
        "loggers": [
            {
                "name": "kea-ctrl-agent",
                "output_options": [
                    {
                        "output": "kea-ctrl-agent.log",
                        "flush": true,
                        "maxsize": 204800,
                        "maxver": 4,
                        "pattern": "%d{%y.%m.%d %H:%M:%S.%q} %-5p [%c/%i] %m\n"
                    }
                ],
                "severity": "DEBUG",
                "debuglevel": 9 // debug level only applies when severity is set to DEBUG.
            }
        ]
    }
}

There is a lot in these kea configuration files. As you’d expect, most of the configuration is for DHCP and there are many many options. The most important section in kea.conf for high-availability is the hooks-libraries section:

    "hooks-libraries": [
      {
        "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"
      },
      {
            "library": "/usr/lib/kea/hooks/libdhcp_ha.so",
            "parameters": {
                "high-availability": [ {
                    "this-server-name": "kea-server-1",
                    "mode": "hot-standby",
                    "heartbeat-delay": 10000,
                    "max-response-delay": 60000,
                    "max-ack-delay": 5000,
                    "max-unacked-clients": 5,
                    "sync-timeout": 60000,
                    "peers": [
                        {
                            "name": "kea-server-1",
                            "url": "http://10.0.0.111:8001/",
                            "role": "primary"
                        },
                        {
                            "name": "kea-server-2",
                            "url": "http://10.0.0.112:8001/",
                            "role": "standby"
                        }
                    ]
                } ]
            }
        }
    ],

Here we define two peers kea-server-1 and kea-server-2 that will communiate over port 8001. Note this needs to be different to the ctrl-agent port (which typically uses port 8000). We are also using hot-standby mode. Kea supports the following modes: load-balancing, hot-standby or passive-backup.

In the load-balancing configuration, one of the servers must be designated as “primary” and the other as “secondary.” Functionally, there is no difference between the two during normal operation. This distinction is required when the two servers are started at (nearly) the same time and have to synchronize their lease databases. The primary server synchronizes the database first. The secondary server waits for the primary server to complete the lease database synchronization before it starts the synchronization.

In the hot-standby configuration, one of the servers is designated as “primary” and the other as “standby.” However, during normal operation, the primary server is the only one that responds to DHCP requests. The standby server receives lease updates from the primary over the control channel; however, it does not respond to any DHCP queries as long as the primary is running or, more accurately, until the standby considers the primary to be offline. If the standby server detects the failure of the primary, it starts responding to all DHCP queries.

The last supported configuration is passive-backup. The passive-backup configuration is used in situations when an administrator wants to take advantage of the backup servers as an additional storage for leases without a need for running the fully blown failover setup. In this case, if the primary server fails, the DHCP service is lost and it requires that the administrator manually starts the primary to resume the DHCP service

The key differences between load-balancing mode and hot-standby are that:

Secondary server configuration

There are minimal differences between the primary and secondary kea configuration files. The hostnames are updated from 10.0.0.111 to 10.0.0.112 and that’s about it. One key difference in the secondary configuration for libdhcp_ha.so is that the peer-name is also changed from kea-server-1 to kea-server-2.

Posting the secondary configuration files here for completeness. I am thinking I should probably script these files from a common default as they are bound to get out of sync a some point….

/etc/kea/kea-dhcp4.conf:

{

"Dhcp4": {
    "interfaces-config": {
        "interfaces": [ "eth0" ]
    },
    "control-socket": {
        "socket-type": "unix",
        "socket-name": "kea-dhcp4-ctrl.sock"
    },
    "lease-database": {
        "type": "mysql",
        "name": "kea",
        "host": "10.0.0.112",
        "port": 3306,
        "user": "kea",
        "password": "kea",
        "lfc-interval": 3600
    },
    "hosts-database": {
        "type": "mysql",
        "name": "kea",
        "user": "kea",
        "password": "kea",
        "host": "10.0.0.112",
        "port": 3306
    },
    "expired-leases-processing": {
        "reclaim-timer-wait-time": 10,
        "flush-reclaimed-timer-wait-time": 25,
        "hold-reclaimed-time": 3600,
        "max-reclaim-leases": 100,
        "max-reclaim-time": 250,
        "unwarned-reclaim-cycles": 5
    },
    "renew-timer": 302400,
    "rebind-timer": 529200,
    "valid-lifetime": 604800,
    "option-data": [
        {
            "name": "domain-name-servers",
            "data": "10.0.0.111"
        },
        {
            "code": 15,
            "data": "example.com"
        },
        {
            "name": "domain-search",
            "data": "example.com"
        }
    ],
     "dhcp-ddns": {
        "enable-updates": true
     },
     "ddns-qualifying-suffix": "example.com",
     "ddns-override-client-update": true,
    "hooks-libraries": [
      {
        "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so"
      },
      {
            "library": "/usr/lib/kea/hooks/libdhcp_ha.so",
            "parameters": {
                "high-availability": [ {
                    "this-server-name": "kea-server-2",
                    "mode": "hot-standby",
                    "heartbeat-delay": 10000,
                    "max-response-delay": 60000,
                    "max-ack-delay": 5000,
                    "max-unacked-clients": 5,
                    "sync-timeout": 60000,
                    "peers": [
                        {
                            "name": "kea-server-1",
                            "url": "http://10.0.0.111:8001/",
                            "role": "primary"
                        },
                        {
                            "name": "kea-server-2",
                            "url": "http://10.0.0.112:8001/",
                            "role": "standby"
                        }
                    ]
                } ]
            }
        }
    ],
    "subnet4": [
        {
            "id": 1,
            "subnet": "10.0.0.0/8",
            "pools": [ { "pool": "10.1.0.0 - 10.1.0.254" } ],
            "option-data": [
                {
                    "name": "routers",
                    "data": "10.0.0.1"
                }
            ],
            "reservations": [
            ]
        }
    ],
    "loggers": [
    {
        "name": "kea-dhcp4",
        "output-options": [
            {
                "output": "kea-dhcp4.log"
            }
        ],
        "severity": "DEBUG",
        "debuglevel": 0
    }
  ]
}
}

/etc/kea/kea-ctrl-agent.conf:

{
    "Control-agent":
    {
        "http-host": "10.0.0.0",
        "http-port": 8000,
        "control-sockets":
        {
            "dhcp4":
            {
                "comment": "socket to DHCP4 server",
                "socket-type": "unix",
                "socket-name": "kea-dhcp4-ctrl.sock"
            },
            "dhcp6":
            {
                "socket-type": "unix",
                "socket-name": "kea-dhcp6-ctrl.sock"
            },
            "d2":
            {
                "socket-type": "unix",
                "socket-name": "kea-dhcp-ddns-ctrl.sock",
                "user-context": { "in-use": false }
            }
        },
        "loggers": [
            {
                "name": "kea-ctrl-agent",
                "output_options": [
                    {
                        "output": "kea-ctrl-agent.log",
                        "flush": true,
                        "maxsize": 204800,
                        "maxver": 4,
                        "pattern": "%d{%y.%m.%d %H:%M:%S.%q} %-5p [%c/%i] %m\n"
                    }
                ],
                "severity": "DEBUG",
                "debuglevel": 9 // debug level only applies when severity is set to DEBUG.
            }
        ]
    }
}

Restart the services

doas rc-service kea-dhcp4 restart
doas rc-service kea-dhcp-ddns restart
doas rc-service kea-ctrl-agent restart

Validate high-availability

If everything is working correctly, there should be a heart-beat message between the two hosts every ten seconds

INFO  [kea-dhcp4.commands/3885.548604914312] COMMAND_RECEIVED Received command 'ha-heartbeat'

If we temporarily stop the secondary DHCP service, this should also be reflected in the log:

WARN  [kea-dhcp4.ha-hooks/3885.548605057672] HA_HEARTBEAT_COMMUNICATIONS_FAILED kea-server-1: failed to send heartbeat to kea-server-2 (http://10.0.0.111:8001/): Connection refused

When the secondary service is restarted, the secondary will request the list of leases and then heartbeats continue

INFO  [kea-dhcp4.commands/3885.548604484232] COMMAND_RECEIVED Received command 'lease4-get-page'
INFO  [kea-dhcp4.commands/3885.548604914312] COMMAND_RECEIVED Received command 'ha-sync-complete-notify'
INFO  [kea-dhcp4.commands/3885.548604484232] COMMAND_RECEIVED Received command 'ha-heartbeat'

That’s it. If you’ve read this far, we now have high availability DNS and DHCP services available together with an authoratitive server for example.com, a recursive server and dynamic DNS updates based on DHCP leases.

Thanks for reading!