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.
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.
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
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;
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);
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
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 for test purposes
apk add --update bind-tools
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
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
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
# 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 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
],
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;
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:
---
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
For DNSSEC management and certain operations, Poweradmin requires access to the PowerDNS API:
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
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 ],
Forward Zone: example.com
Reverse Zones need to be divided up into zones of 24-bits (ie /24 reverse zones):
0.0.10.in-addr.arpa (10.0.0.0/24)1.0.10.in-addr.arpa (10.0.1.0/24)2.0.10.in-addr.arpa (10.0.2.0/24)3.0.10.in-addr.arpa (10.0.3.0/24)Note: no full stop at the end
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
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
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.
-- 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;
-- 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;
--- 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
);
-- 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;
--- Query to delete a reservation
DELETE FROM hosts
WHERE dhcp_identifier = UNHEX(REPLACE('aa:bb:cc:dd:ee:ff', ':', ''))
AND dhcp_identifier_type = 0;
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
#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';
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
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
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:
hostmaster.example.comns1.example.comns2.example.comThe 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
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:
AXFR requestsAXFR requests… yes!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
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
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:
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.
}
]
}
}
doas rc-service kea-dhcp4 restart
doas rc-service kea-dhcp-ddns restart
doas rc-service kea-ctrl-agent restart
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!