Private Nameserver Setup - PowerDNS Authoritative Server - Own DNS Server - Accept DNS Transfer From Any Control Panel
We will create our own private DNS server to accept DNS transfer from popular hosting control panels.
This project is tested with PowerDNS on Debian 12
1) root @Debian12
apt-get install nano
nano /etc/hosts
nano /etc/hostname
systemctl stop systemd-resolved
systemctl disable systemd-resolved
rm /etc/resolv.conf
nano /etc/resolv.conf
# Using Google's and Cloudflare's public DNS servers nameserver 8.8.8.8 nameserver 1.1.1.1 nameserver 2001:4860:4860::8888 nameserver 2606:4700:4700::1111
* Save and exit
chattr +i /etc/resolv.conf
apt update && apt upgrade -y
* Install some necessary tools or dependencies first to successfully complete our project
apt install -y sudo wget tar curl perl libwww-perl openssl certbot apt-transport-https incron gpg
echo "root" >> /etc/incron.allow
systemctl enable incron
systemctl start incron
* Check if IPv6 is disabled, enable it only if your server has an ipv6 public ip
nano /etc/sysctl.conf
* Create SWAP if needed
fallocate -l 16G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
nano /etc/fstab
# Add this line at the end:
/swapfile swap swap defaults 0 0
# Save and exit
nano /etc/sysctl.conf
# Add or modify this line:
vm.swappiness = 10
# Save and exit
reboot
IF YOU HAVE EXTERNAL FIREWALL, PLEASE OPEN ALL PORT ON THERE !
BECAUSE, WE MUST USE CSF TO DO SOME AUTOMATION FOR IP TRUST
2) GUI Setup for CSF Firewall, LetsEncrypt SSL, and get a File Manager
curl -o webmin-setup-repo.sh https://raw.githubusercontent.com/webmin/webmin/master/webmin-setup-repo.sh
sh webmin-setup-repo.sh
apt-get install --install-recommends webmin
* Login to the GUI at http://your-powerdns-server-ip.com:10000
Change Webmin default port 10000 to a different one (between 49152-65535) for security best practice.
Here I'm using 51000, you can use a different one
Here I'm using 51000, you can use a different one
* We will configure CSF first, and File Manager already integrated
systemctl stop ufw
systemctl disable ufw
systemctl stop firewalld
systemctl disable firewalld
apt-get remove --auto-remove nftables
apt-get purge nftables
apt-get install iptables
cd /usr/src
rm -fv csf.tgz
wget https://download.configserver.com/csf.tgz
tar -xzf csf.tgz
cd csf
sh install.sh
cd
Webmin > Webmin Configuration > Webmin Modules > From local file > /usr/local/csf/csfwebmin.tgz > Install Module
* Configure CSF and allow your production servers
* We will configure SSL after our own dns-slave-server's dns entry resolve
apt install certbot -y
3) Configure Firewall (CSF or UFW) on this PowerDNS Server
Open: Port 53,853 (UDP/TCP) for DNS
Port 80,443 (TCP) for SSL automation and secure access
Port 51000 (TCP) for Webmin access
Port 80,443 (TCP) for SSL automation and secure access
Port 51000 (TCP) for Webmin access
and
Restricted: Port 8081,8083 (TCP) for PowerDNS
Regarding 8081 port, allow only to your master dns servers or production servers for api connection, also allow 8083 for your own network's dedicated IP to view and monitor the PowerDNS frontend DNSdist server.
Additionally, port 22 tcp with restriction.
Outgoing: All TCP/UDP outgoing for 0:65535
4) Install MySQL
apt install mariadb-server -y
mysql_secure_installation
* Most of the questions press "Y" and Enter, but set a mysql root password.
5) Database Creation
mysql -u root -p
* Provide your MySQL root password.
CREATE DATABASE powerdns;
CREATE USER 'pdns_user'@'localhost' IDENTIFIED BY 'YourDatabasePasswordHere';
GRANT ALL PRIVILEGES ON powerdns.* TO 'pdns_user'@'localhost';
FLUSH PRIVILEGES;
USE powerdns;
6) Create Database Tables
* Copy the SQL Codes Below (paste it to your terminal and press Enter):
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_domain_id_idx ON comments (domain_id);
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);
7) Exit from MariaDB
EXIT;
8) Install PowerDNS
nano /etc/apt/sources.list.d/pdns.list
* Insert the below line in the file
deb [signed-by=/etc/apt/keyrings/auth-49-pub.asc] http://repo.powerdns.com/debian bookworm-auth-49 main
* Save and exit the file
nano /etc/apt/preferences.d/auth-49
* Put this into the file
Package: auth* Pin: origin repo.powerdns.com Pin-Priority: 600
* Save and exit
* Run the command:
install -d /etc/apt/keyrings; curl https://repo.powerdns.com/FD380FBB-pub.asc | sudo tee /etc/apt/keyrings/auth-49-pub.asc
* Now, update and install
apt-get update
apt install pdns-server pdns-backend-mysql -y
9) Configure PowerDNS
Create the API Key hash password first, keep saved the plain text version of the api key in a safe place, we have to use the plain text version when api call from production server, and the hash version will be use here in pdns.conf file.
Run the command:
pdnsutil hash-password
The command will not give you anything, but waiting for your plain text api key password, write your plain text api password and press enter.
eSd4sPkynwnk2SlavGRerverNS7hVduq (example plain text password)
Then the pdnsutil will give you a long and very strong hash password that looks like below:
$scrypt$ln=10,p=1,r=8$h+qA1ZdnvZmbmBj2ynQZQA==$oYpE9bQE7Ugwa2tEBHyqce0V0+Y5ZU20kEGzWAJ6pV0=
Then you are ready to set your pdns configuration file.
nano /etc/powerdns/pdns.conf
* Make sure the below lines exist and uncommented.
# Enable api to transfer dns zones from your production servers to this dns server
api=yes
# Hashed api key is recommended here, you will call api by your plain text key later
api-key=$scrypt$ln=10,p=1,r=8$h+qA1ZdnvZmbmBj2ynQZQA==$oYpE9bQE7Ugwa2tEBHyqce0V0+Y5ZU20kEGzWAJ6pV0=
# The default ksk algorithm for enabling DNSSEC when running pdnsutil secure-zone
default-ksk-algorithm=ecdsa256
# The default zsk algorithm for enabling DNSSEC when running pdnsutil secure-zone
default-zsk-algorithm=ecdsa256
# We will not transfer SOA from our production server, so pdns will manage it
default-soa-content=ns1.your-powerdns-domain.com. webmaster.your-email-domain.com. 0 10800 3600 604800 3600
# Listen only on localhost for queries from DNSdist
# local-address=127.0.0.1:53053, [::1]:53053
local-address=127.0.0.1:53053
# version-string, protect your nameserver from attacker
version-string=hidden
# Enable webserver for zone records transfer via api
webserver=yes
# NginX will be a proxy to connect with this webserver
webserver-address=127.0.0.1
# Only allow localhost, as the nginx will stand on frontend and handle api request
# webserver-allow-from=127.0.0.1,::1
webserver-allow-from=127.0.0.1
# This is PowerDNS web interface port, the DNSdist port will be different
webserver-port=8081
10) Avoid Any Other Config Files for PowerDNS Config
* Find any other conf file in /etc/powerdns/pdns.d/ directory (like bind.conf).
ls -l /etc/powerdns/pdns.d/
* If any other conf file exist, remove them.
rm /etc/powerdns/pdns.d/bind.conf
11) Database Connection
nano /etc/powerdns/pdns.d/gmysql.conf
* Enter the below lines, then save and exit.
launch=gmysql
gmysql-host=127.0.0.1
gmysql-dbname=powerdns
gmysql-user=pdns_user
gmysql-password=YourDatabasePasswordHere
gmysql-dnssec=yes
12) First Restart and then Enable PowerDNS
systemctl restart pdns
systemctl enable pdns
systemctl status pdns
13) Install DNSdist
PowerDNS Authoritative Server will work in Backend
DNSdist module will work on Frontend as a GateKeeper and handle all traffic
nano /etc/apt/sources.list.d/pdns.list
* Insert the below line at the end of the file
deb [signed-by=/etc/apt/keyrings/dnsdist-19-pub.asc] http://repo.powerdns.com/debian bookworm-dnsdist-19 main
* Save and exit the file
nano /etc/apt/preferences.d/dnsdist-19
* Put this into the file
Package: dnsdist* Pin: origin repo.powerdns.com Pin-Priority: 600
* Save and exit
* Run the command:
install -d /etc/apt/keyrings; curl https://repo.powerdns.com/FD380FBB-pub.asc | sudo tee /etc/apt/keyrings/dnsdist-19-pub.asc
* Now, update and install
apt-get update
apt install dnsdist -y
* Configure DNSdist
Before configure, we will create another one hash password for DNSdist web interface security
Run the command:
pdnsutil hash-password
wHv3j6cPksQb (example plain text password, enter it)
Then the pdnsutil will give you a long and very strong hash password that looks like below:
$scrypt$ln=10,p=1,r=8$X8qO4EAclFY47vfMfUvALw==$AZv89GIAoiGgtCoVVXQfO0V4AYdYbnL8ysilAjT1b6k=
Then you are ready to set your dnsdist configuration file.
nano /etc/dnsdist/dnsdist.conf
* Put this into the file
-- Define the backend PowerDNS server
newServer({address="127.0.0.1:53053", name="pdns-backend-ipv4"})
-- newServer({address="[::1]:53053", name="pdns-backend-ipv6"})
-- Allow DNS queries from ANYWHERE
addACL('0.0.0.0/0')
-- addACL('::/0')
-- Listen on public interfaces for incoming queries
addLocal("0.0.0.0:53")
-- addLocal("[::]:53")
-- Optional: Add DoT support later after we get SSL cert from LetsEncrypt
-- addTLSLocal("0.0.0.0:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- addTLSLocal("[::]:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- addTLSLocal("[::]:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- Enable the DNSdist web interface and API
webserver("127.0.0.1:8083")
-- webserver("[::1]:8083")
-- webserver("[::1]:8083")
setWebserverConfig({password="$scrypt$ln=10,p=1,r=8$X8qO4EAclFY47vfMfUvALw==$AZv89GIAoiGgtCoVVXQfO0V4AYdYbnL8ysilAjT1b6k=", acl={"127.0.0.0/8", "::1/128"}})
* Save and exit the file
systemctl restart dnsdist
systemctl enable dnsdist
systemctl status dnsdist
14) Create DNS Zones for the Slave Server Own
!!! Remember !!! Never create a subdomain zone or hostname zone, in any status, that is wrong !!!
!! Main domain zone works for subdomain always !! Create A record for subdomain, but not NS record !!
* Check the existing zone list in PowerDNS by the command: pdnsutil list-all-zones
You can view a zone records by the command: pdnsutil list-zone example.com
pdnsutil create-zone your-powerdns-domain.com
pdnsutil replace-rrset your-powerdns-domain.com @ SOA "ns1.your-powerdns-domain.com. webmaster.your-email-domain.com. 2025061601 10800 3600 604800 3600"
pdnsutil add-record your-powerdns-domain.com @ NS 3600 ns1.your-powerdns-domain.com.
pdnsutil add-record your-powerdns-domain.com @ NS 3600 ns2.your-secondary-powerdns-domain.com.
pdnsutil add-record your-powerdns-domain.com @ A 3600 1.2.3.4
pdnsutil add-record your-powerdns-domain.com @ AAAA 3600 2600:1f10:4c55:e23d::1
pdnsutil add-record your-powerdns-domain.com www A 3600 1.2.3.4
pdnsutil add-record your-powerdns-domain.com www AAAA 3600 2600:1f10:4c55:e23d::1
pdnsutil add-record your-powerdns-domain.com ns1 A 3600 1.2.3.4
pdnsutil add-record your-powerdns-domain.com ns1 AAAA 3600 2600:1f10:4c55:e23d::1
* Creating PTR record (reverse-ip-address.in-addr.arpa)
pdnsutil create-zone 4.3.2.1.in-addr.arpa
pdnsutil replace-rrset 4.3.2.1.in-addr.arpa @ SOA "ns1.your-powerdns-domain.com. webmaster.your-email-domain.com. 2025061601 10800 3600 604800 3600"
pdnsutil add-record 4.3.2.1.in-addr.arpa @ NS 86400 ns1.your-powerdns-domain.com.
pdnsutil add-record 4.3.2.1.in-addr.arpa @ PTR 86400 ns1.your-powerdns-domain.com.
* Creating IPv6 PTR (example ipv6: 2600:1f10:4c55:e23d::1/64)
* So, your full 128-bit address is: 2600:1f10:4c55:e23d:0000:0000:0000:0001
* Your reverse ip6 will be: 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.3.2.e.5.5.c.4.0.1.f.1.0.0.6.2
pdnsutil create-zone 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.3.2.e.5.5.c.4.0.1.f.1.0.0.6.2.ip6.arpa
pdnsutil replace-rrset 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.3.2.e.5.5.c.4.0.1.f.1.0.0.6.2.ip6.arpa @ SOA "ns1.your-powerdns-domain.com. webmaster.your-email-domain.com. 2025061601 10800 3600 604800 3600"
pdnsutil add-record 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.3.2.e.5.5.c.4.0.1.f.1.0.0.6.2.ip6.arpa @ NS 86400 ns1.your-powerdns-domain.com.
pdnsutil add-record 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.3.2.e.5.5.c.4.0.1.f.1.0.0.6.2.ip6.arpa @ PTR 86400 ns1.your-powerdns-domain.com.
15) Configure LetsEncrypt SSL (PowerDNS dnsdist Server)
First, make sure the 80 and 443 port opened in firewall.
ssh root@PowerDNS
certbot certonly --standalone -d ns1.your-powerdns-domain.com
* Accept terms, provide your valid email address webmaster@youremaildomain.com
Login to Webmin > Webmin > Webmin Configuration > SSL Encryption
Private key file: /etc/letsencrypt/live/ns1.your-powerdns-domain.com/privkey.pem
Certificate file: Separate file: /etc/letsencrypt/live/ns1.your-powerdns-domain.com/fullchain.pem
Click "Save" and wait a minute.
Logout from Webmin, and login again, see the SSL is ok.
* Now, we will secure the API connection port 8083
mkdir -p /var/www/html
apt install nginx -y
* Add DNS records to create a subdomain
pdnsutil add-record your-powerdns-domain.com dnsdist A 3600 1.2.3.4
pdnsutil add-record your-powerdns-domain.com dnsdist AAAA 3600 2600:1f10:4c55:e23d::1
16) Configure NginX
nano /etc/nginx/sites-available/dnsdist
* Copy/paste the below script to the dnsdist file
server {
listen 80;
listen [::]:80;
server_name dnsdist.your-powerdns-domain.com;
# Certbot validation path
location /.well-known/acme-challenge/ {
root /var/www/html;
allow all;
}
# Temporary message to all traffic
location / {
return 403 "SSL Certificate setup in progress.";
}
}
* Save and exit the file, then enable the website
ln -s /etc/nginx/sites-available/dnsdist /etc/nginx/sites-enabled/
* Test the nginx configuration if its ok or not
nginx -t
* If successful, restart nginx
systemctl restart nginx
17) SSL for NginX Secure Site
apt install python3-certbot-nginx -y
* This step, please first check the A record of dnsdist.your-powerdns-domain.com is already propagated, from an external DNS checker online (https://www.whatsmydns.net)
certbot --nginx -d dnsdist.your-powerdns-domain.com
* Certbot installation of a LetsEncrypt certificate should be success
* Now remove the http only script
rm /etc/nginx/sites-available/dnsdist
* And add the https enabled script to the file
nano /etc/nginx/sites-available/dnsdist
# ===================================================================
# Nginx Configuration for PowerDNS API Reverse Proxy and (DNSdist) Monitoring UI
# - Port 443 -> DNSdist Web UI (for monitoring) proxy for 8083
# - Port 8082 -> PowerDNS API (for DNS sync) proxy for 8081
# ===================================================================
# Server Block 1: PowerDNS Authoritative API on Port 8082
# Handles requests to https://dnsdist.your-powerdns-domain.com:8082
server {
listen 8082 ssl http2;
listen [::]:8082 ssl http2;
server_name dnsdist.your-powerdns-domain.com;
# --- Security: IP Whitelist ---
# This file is automatically generated from csf.allow by a script. We will enable it later.
# include /etc/nginx/csf_whitelist.conf;
# deny all; # Deny all IPs not in the whitelist
# --- SSL Configuration (managed by Certbot) ---
ssl_certificate /etc/letsencrypt/live/dnsdist.your-powerdns-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dnsdist.your-powerdns-domain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# --- Reverse Proxy to PowerDNS Authoritative API ---
location / {
# IMPORTANT: Ensure this port matches your 'webserver-port' in pdns.conf
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# --- Server Block 2: DNSdist Web UI on Port 443 ---
# Handles requests to https://dnsdist.your-powerdns-domain.com
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name dnsdist.your-powerdns-domain.com;
# --- Security: IP Whitelist ---
# include /etc/nginx/csf_whitelist.conf;
# deny all;
# --- SSL Configuration (using the same certificate) ---
ssl_certificate /etc/letsencrypt/live/dnsdist.your-powerdns-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dnsdist.your-powerdns-domain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# --- Reverse Proxy to DNSdist Web Interface ---
location / {
# IMPORTANT: Ensure this port matches your 'webserver' port in dnsdist.conf
proxy_pass http://127.0.0.1:8083;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# --- Server Block 3: HTTP to HTTPS Redirect on Port 80 ---
# This block redirects all insecure HTTP traffic to the secure monitoring UI
server {
listen 80;
listen [::]:80;
server_name dnsdist.your-powerdns-domain.com;
# Redirect all other HTTP requests to the main HTTPS site (port 443)
location / {
return 301 https://$host$request_uri;
}
# Certbot handles renewals using this location. Do not remove.
location /.well-known/acme-challenge/ {
root /var/www/html;
allow all;
}
}
* Save and exit the file, then restart nginx
systemctl restart nginx
Now your API connection is SSL secured, visit the secured URL http(s)
https://dnsdist.your-powerdns-domain.com
18) Enable DoT Service
ssh root@PowerDNS
* Enable DNS-over-TLS (DoT) Service
Automation for SSL file copy and paste to DNSdist directory with proper permission
mkdir -p /etc/dnsdist/ssl
chown -R _dnsdist:_dnsdist /etc/dnsdist/ssl
chmod 750 /etc/dnsdist/ssl
nano /usr/local/sbin/sync_dnsdist_certs.sh
Put the below script to the file
#!/bin/bash
# ===================================================================
# Certbot Renewal Hook to copy certificates for DNSdist
# ===================================================================
set -e # Exit immediately if a command exits with a non-zero status.
CERT_NAME="dnsdist.your-powerdns-domain.com" # <<<--- Cert common name (domain name the cert for)
LETSENCRYPT_DIR="/etc/letsencrypt/live/${CERT_NAME}"
DNSDIST_SSL_DIR="/etc/dnsdist/ssl"
echo "[$(date)] Certificate sync hook triggered." >> /var/log/dnsdist_cert_sync.log
# Copy the certificate files
cp "${LETSENCRYPT_DIR}/fullchain.pem" "${DNSDIST_SSL_DIR}/cert.pem"
cp "${LETSENCRYPT_DIR}/privkey.pem" "${DNSDIST_SSL_DIR}/key.pem"
# Set the correct ownership and permissions
chown _dnsdist:_dnsdist "${DNSDIST_SSL_DIR}/cert.pem"
chown _dnsdist:_dnsdist "${DNSDIST_SSL_DIR}/key.pem"
chmod 600 "${DNSDIST_SSL_DIR}/key.pem"
chmod 644 "${DNSDIST_SSL_DIR}/cert.pem"
# Reload dnsdist to use the new certificate
if systemctl is-active --quiet dnsdist; then
echo "[$(date)] Restarting dnsdist service to apply new certificate." >> /var/log/dnsdist_cert_sync.log
systemctl restart dnsdist
fi
echo "[$(date)] Certificate sync complete." >> /var/log/dnsdist_cert_sync.log
* Save and exit the file
chmod 750 /usr/local/sbin/sync_dnsdist_certs.sh
incrontab -e
Put this line at the end of the file
/etc/letsencrypt/live/ IN_CREATE,IN_MODIFY /usr/local/sbin/sync_dnsdist_certs.sh
* Save and exit
* Run the bash script for the first time and only onetime, next time incron will run this script when needed
/usr/local/sbin/sync_dnsdist_certs.sh
* Open dnsdist.conf file and copy the below script and paste anywhere or the alphabetically section placement
nano /etc/dnsdist/dnsdist.conf
Comment out the:
-- Optional: Add DoT support later after we get SSL cert from LetsEncrypt
-- addTLSLocal("0.0.0.0:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- addTLSLocal("[::]:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- addTLSLocal("[::]:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
To:
-- Optional: Add DoT support later after we get SSL cert from LetsEncrypt
addTLSLocal("0.0.0.0:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- addTLSLocal("[::]:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
-- addTLSLocal("[::]:853", "/etc/dnsdist/ssl/cert.pem", "/etc/dnsdist/ssl/key.pem")
* Save and exit the file
systemctl restart dnsdist
19) Test PowerDNS (DNSdist) Server
* Query from any other external server terminal (outside powerdns server), this outside server's ip address must be trusted in PowerDNS server's firewall to port 8082
curl -v -H 'X-API-Key: YourPlainTextApiKey' https://dnsdist.your-powerdns-domain.com:8082/api/v1/servers/localhost/zones
Great going ! Your PowerDNS server is now ready to receive dns zones.
Here is the necessary PowerDNS Utility command to manage zones
You can check the existing zone list in PowerDNS by the command: pdnsutil list-all-zones
To view a zone records by the command: pdnsutil list-zone example.com
A new zone create can be done with the command: pdnsutil create-zone example.com
A zone edit can be done by the command: pdnsutil edit-zone example.com
A zone can be delete by command: pdnsutil delete-zone example.com
Additional Configuration on PowerDNS Server (ssh root @PowerDNS)
20) Restrict the API Monitoring URL of PowerDNS
* We will not open the API monitoring page (https://dnsdist.your-powerdns-domain.com) to the public (port 443,8083), but we will allow the whitelisted IPs only (csf.allow)
ssh root@PowerDNS
nano /usr/local/sbin/generate_nginx_whitelist.sh
Copy/paste the below script and insert into the file
#!/bin/bash
# ===================================================================
# CSF Allow to Nginx Whitelist Generator
# This script reads IPs from csf.allow and creates a config
# file for Nginx to include.
# ===================================================================
# Source and Destination files
CSF_ALLOW_FILE="/etc/csf/csf.allow"
NGINX_WHITELIST_FILE="/etc/nginx/csf_whitelist.conf"
# Check if the source file exists
if [ ! -f "${CSF_ALLOW_FILE}" ]; then
echo "CSF allow file not found at ${CSF_ALLOW_FILE}. Exiting."
exit 1
fi
# Create a temporary file to build the new list
TMP_FILE=$(mktemp)
# Read the csf.allow file, extract valid IPs, and format for Nginx
# This regex handles IPv4 and IPv6, and ignores comments and ports.
grep -E -o "^([0-9]{1,3}\.){3}[0-9]{1,3}|^[0-9a-fA-F:]+" "${CSF_ALLOW_FILE}" | while read IP; do
echo "allow ${IP};" >> "${TMP_FILE}"
done
# Compare the new list with the old one to see if a reload is needed
if ! cmp -s "${TMP_FILE}" "${NGINX_WHITELIST_FILE}"; then
echo "Whitelist has changed. Updating and reloading Nginx."
# Move the new file into place and reload Nginx
mv "${TMP_FILE}" "${NGINX_WHITELIST_FILE}"
# Check Nginx config before reloading to prevent errors
if nginx -t > /dev/null 2>&1; then
systemctl restart nginx
else
echo "Nginx config test failed. Reload aborted."
# Optional: Restore backup if you have one
fi
else
# No changes, do nothing.
rm "${TMP_FILE}"
fi
exit 0
* Set the file permission
chmod 750 /usr/local/sbin/generate_nginx_whitelist.sh
* Run the bash script for the first time and only onetime, next time incron will run this script when needed
/usr/local/sbin/generate_nginx_whitelist.sh
* Include the whitelist file to nginx configuration in "Location" section, just before the proxy_pass line
nano /etc/nginx/sites-available/dnsdist
* Uncomment and enable this feature, this option has two time placed in the file
location / {
# ========================================================
# Include the IP whitelist file and deny all others
# ========================================================
include /etc/nginx/csf_whitelist.conf;
deny all;
# ========================================================
# Forward the request to the local DNSdist API server
proxy_pass http://127.0.0.1:8083;
* Now we will update the new ip address whitelist entry in CSF by the INCRON program
incrontab -e
Insert the below line, and save the file
/etc/csf/csf.allow IN_MODIFY,IN_CREATE /usr/local/sbin/generate_nginx_whitelist.sh
* Restart nginx finally
systemctl restart nginx
///////////////////////////////////////////////////////
BEFORE ZONE TRANSFER FROM PRODUCTION SERVER,
FIRST CREATE YOUR ALL NAMESERVER ZONES IN BOTH NAMESERVER
FIRST CREATE YOUR ALL NAMESERVER ZONES IN BOTH NAMESERVER
21) Optional: DNSSEC for Nameserver domain
pdnsutil add-zone-key your-powerdns-domain.com ksk active
* Keep saved the key's ID from the command output
pdnsutil add-zone-key your-powerdns-domain.com zsk active
* Again, keep saved the key's ID from the command output
pdnsutil rectify-zone your-powerdns-domain.com
* Harden security: NSEC3
* Create a hex string (salt)
openssl rand -hex 8
* Keep save the hex string from command output
pdnsutil set-nsec3 your-powerdns-domain.com '1 0 10 YourHexString'
* Rectify again
pdnsutil rectify-zone your-powerdns-domain.com
* Show zone records for all Key's and their ID's
pdnsutil show-zone your-powerdns-domain.com
* Find out DS record
Example output: DS record: your-powerdns-domain.com. IN DS 54321 13 2 1234ABCD...EFGH;
Here, 54321 is your Key Tag, 13 is the Algorithm, 2 is the Digest Type, 1234ABCD...EFGH is the Digest / Public Key.
* Copy the DS record and enter it to your Domain Registrar's control panel to DNSSEC settings.
Things to do if you have other servers:
* If you have multi master PowerDNS server, copy the same private keys and NSEC3 parameter to the other servers. Here is the step..
Command to primary server:
mysql -u pdns_user -p powerdns -e "SELECT id FROM domains WHERE name='your-powerdns-domain.com';"
* Suppose, the domain ID is "1", keep note of this domain id
mysqldump -u pdns_user -p powerdns cryptokeys --where="domain_id=1" > /tmp/your-powerdns-domain_dnssec.sql
mysqldump -u pdns_user -p powerdns domainmetadata --where="domain_id=1" >> /tmp/your-powerdns-domain_dnssec.sql
* Then login to primary server's Webmin, go to Tools > File Manager, and browse /tmp/ folder, then open the your-powerdns-domain_dnssec.sql file with Editor
* Find the INSERT INTO `cryptokeys` VALUES lines, may be two lines there, copy that two lines
Example:
INSERT INTO `cryptokeys` VALUES
(1,1,257,1,1,'Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\nPrivateKey: rgY2F9ziU82HsIHvQuHBV5mgrwXRmUzEL/FkjvxtcYw=\n'),
(2,1,256,1,1,'Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\nPrivateKey: KCSCcQJ1eEDODBMNrfOWqPWHsnQOmZqQQR6bdacn9lg=\n');
* Find the INSERT INTO `domainmetadata` VALUES line, may be one line there, copy that line
Example:
INSERT INTO `domainmetadata` VALUES
(81,1,'NSEC3PARAM','1 0 10 ff12f68333db340d');
* !!! Now is the time to replace the correct domain id found in your other PowerDNS server !!!
Look, the three lines we got from the primary server's exported SQL file had the VALUES line stated as like 1,1,257,1,1,'Private-key....
Here the second number is the domain id
Here the second number is the domain id
The tables structure is (id, domain_id, flags, active, ...), so the second number is domain id
Now, find out the domain id from your other server, domain name is same but domain id will be different in other servers
Command to other server:
mysql -u pdns_user -p powerdns -e "SELECT id FROM domains WHERE name='your-powerdns-domain.com';"
* Now replace the domain id on that three lines, delete primary server's domain id, and replace other server's domain id there, remember that the second number is the domain id
mysql -u pdns_user -p powerdns -e "SELECT id FROM domains WHERE name='your-powerdns-domain.com';"
* Now replace the domain id on that three lines, delete primary server's domain id, and replace other server's domain id there, remember that the second number is the domain id
Suppose, we got the domain id in other server is 84
* Insert the lines in to the other server by the following command
Other server command:
mysql -u root -p
USE powerdns;
* You can keep the first number id as NULL, it will be auto fill the correct id, must use the NULL
INSERT INTO `cryptokeys` VALUES
(NULL, 84, 257, 1, 1, '...key content...'),
(NULL, 84, 256, 1, 1, '...key content...');
INSERT INTO `domainmetadata` VALUES (NULL, 84, 'NSEC3PARAM', '1 0 10 ff12f68333db340d');
EXIT;
* Then rectify to sign
Other server command:
pdnsutil rectify-zone your-powerdns-domain.com
systemctl restart pdns
/////////////////////////////////////////////////////////////////////////////////////////////////////////
22) DirectAdmin Zone Transfer Setup
* ssh root @DirectAdmin server
* Open the TCP 443,8082 port (outgoing)
* Make sure that your DirectAdmin server has php-cli and php-curl available. If not, install it.
23) DNS Sync Script
* Create a Php file in DirectAdmin for DNS Transfer Sync, by the code below.
nano /usr/local/directadmin/scripts/custom/pdns_sync.php
#!/usr/local/bin/php
<?php
// ===================================================================
// DirectAdmin to PowerDNS API Sync Script (v6.0 - Definitive HA)
// Handles Multi-Master Sync, Multi-line records, and Full Mirroring.
// ===================================================================
// --- CONFIGURATION ---
$powerdns_servers = [
[
'id' => 'ns1_pdns',
'api_url' => 'https://dnsdist.your-powerdns-domain.com:8082',
'api_key' => 'Your_NS1_PlainText_ApiKey_Here'
],
// [
// 'id' => 'ns2_pdns',
// 'api_url' => 'https://dnsdist.your-secondary-powerdns-domain.com:8082',
// 'api_key' => 'Your_NS2_PlainText_ApiKey_Here'
// ],
];
$pdns_server_id = 'localhost';
$default_soa_content_template = "ns1.your-powerdns-domain.com. webmaster.your-email-domain.com. %s 3600 600 1209600 3600";
// --- END CONFIGURATION ---
// --- SCRIPT LOGIC (Do not edit below this line) ---
final class PowerDNS_Multi_Sync {
private array $servers_config; private string $domain;
private string $zone_content; private string $pdns_server_id;
private string $soa_template;
public function __construct(array $servers_config, string $pdns_server_id, string $soa_template, string $domain, string $zone_file_path) {
$this->servers_config = $servers_config;
$this->pdns_server_id = $pdns_server_id;
$this->soa_template = $soa_template;
$this->domain = $domain;
$this->zone_content = file_get_contents($zone_file_path);
if ($this->zone_content === false) { $this->log("FATAL: Could not read zone file: {$zone_file_path}"); exit(1); }
}
public function run(): void {
$this->log("Starting sync for domain: {$this->domain}");
$rrsets = $this->parseZoneToRrsets();
foreach($this->servers_config as $server) {
$this->log("--- Processing Server: {$server['id']} ---");
$this->syncWithPowerDNS($server, $rrsets);
$this->log("--- Finished Server: {$server['id']} ---");
}
$this->log("Full sync process finished for domain: {$this->domain}");
}
private function parseZoneToRrsets(): array {
$processed_content = preg_replace_callback( '/\([\s\S]*?\)/', function ($matches) {
return ' ' . str_replace(["\r", "\n"], ' ', trim($matches[0], '()'));
}, $this->zone_content);
$rrsets = []; $lines = explode("\n", $processed_content); $default_ttl = 3600;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || $line[0] === ';') continue;
if (preg_match('/^\$TTL\s+(\d+)/i', $line, $matches)) { $default_ttl = (int)$matches[1]; continue; }
if (strpos($line, ' IN SOA ') !== false || strpos($line, '$ORIGIN') !== false) { continue; }
if (!preg_match('/^(\S+)\s+(?:(\d+)\s+)?(?:IN\s+)?(\S+)\s+(.*)$/i', $line, $matches)) continue;
list(, $name, $ttl, $type, $content_str) = $matches;
$ttl = empty($ttl) ? $default_ttl : (int)$ttl;
if ($name === '@') $name = $this->domain . '.';
else if (substr($name, -1) !== '.') $name .= '.' . $this->domain . '.';
$record = ['name' => $name, 'type' => $type, 'ttl' => $ttl, 'changetype' => 'REPLACE'];
$records_array = [];
switch ($type) {
case 'A': case 'AAAA': case 'CNAME': case 'NS': case 'PTR': case 'DNSKEY': case 'DS': case 'TLSA': case 'MX': case 'SRV':
$records_array[] = ['content' => $content_str, 'disabled' => false]; break;
case 'CAA':
[$flag, $tag, $value] = explode(' ', $content_str, 3);
$records_array[] = ['content' => "$flag $tag " . trim($value, '"'), 'disabled' => false]; break;
case 'TXT':
preg_match_all('/"(.*?)"/', $content_str, $txt_matches);
$records_array[] = ['content' => '"' . implode('', $txt_matches[1]) . '"', 'disabled' => false]; break;
default: continue 2;
}
$record['records'] = $records_array;
$key = $record['name'] . '|' . $record['type'];
if (!isset($rrsets[$key])) { $rrsets[$key] = $record; } else { $rrsets[$key]['records'] = array_merge($rrsets[$key]['records'], $record['records']); }
}
return array_values($rrsets);
}
private function syncWithPowerDNS(array $server, array $rrsets): void {
$soa_content = sprintf($this->soa_template, date('Ymd') . "01");
$soa_rrset = ['name' => "{$this->domain}.", 'type' => 'SOA', 'ttl' => 3600, 'changetype' => 'REPLACE', 'records' => [['content' => $soa_content, 'disabled' => false]]];
$final_rrsets = array_merge([$soa_rrset], $rrsets);
$url_zone = "{$server['api_url']}/api/v1/servers/{$this->pdns_server_id}/zones/{$this->domain}.";
$result = $this->callApi($server, 'GET', $url_zone);
$payload = ['name' => "{$this->domain}.", 'kind' => 'Native', 'nameservers' => [], 'rrsets' => $final_rrsets];
if ($result['code'] == 200) {
$this->log("Zone exists. Replacing all records with PUT.");
$update_result = $this->callApi($server, 'PUT', $url_zone, $payload);
$this->log("Update Response ({$update_result['code']}): " . $update_result['body']);
} elseif ($result['code'] == 404) {
$this->log("Zone does not exist. Creating with POST.");
$create_result = $this->callApi($server, 'POST', "{$server['api_url']}/api/v1/servers/{$this->pdns_server_id}/zones", $payload);
$this->log("Create Response ({$create_result['code']}): " . $create_result['body']);
} else {
$this->log("An unexpected API error for {$server['id']} ({$result['code']}): {$result['body']}");
}
}
private function callApi(array $server, string $method, string $url, ?array $data = null): array {
$ch = curl_init($url);
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => ['X-API-Key: ' . $server['api_key'], 'Content-Type: application/json'], CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 30];
if ($data) {$options[CURLOPT_POSTFIELDS] = json_encode($data);}
curl_setopt_array($ch, $options);
$response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {$this->log("cURL Error for server {$server['id']} ({$method} {$url}): " . curl_error($ch));}
curl_close($ch);
return ['code' => $http_code, 'body' => $response];
}
private function log(string $message): void {
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
}
}
// --- Execution ---
$options = getopt("", ["domain:", "file:"]);
if (!isset($options['domain']) || !isset($options['file'])) {die("Usage: php " . basename(__FILE__) . " --domain <domain_name> --file <zone_file_path>\n");}
try {
$app = new PowerDNS_Multi_Sync($powerdns_servers, $pdns_server_id, $default_soa_content_template, $options['domain'], $options['file']);
$app->run();
} catch (Exception $e) {echo "An uncaught exception occurred: " . $e->getMessage() . "\n"; exit(1);}
* Save and exit the file.
chown diradmin:diradmin /usr/local/directadmin/scripts/custom/pdns_sync.php
chmod 750 /usr/local/directadmin/scripts/custom/pdns_sync.php
24) DNS Transfer Hook Script
nano /usr/local/directadmin/scripts/custom/dns_write_post.sh
* Paste the below script to the file, then save and exit.
#!/bin/bash
# ==========================================================
# DirectAdmin Hook for Zone/Record Add/Update
# This script sends a POST request to the PowerDNS API
# when a zone is created or updated in DirectAdmin.
# ==========================================================
# Determine the correct BIND data directory
if [ -d "/etc/bind" ]; then
ZONE_DIR="/etc/bind"
elif [ -d "/var/named" ]; then
ZONE_DIR="/var/named"
else
echo "[$(date)] Error: Could not find BIND data directory (/etc/bind or /var/named)." >> /tmp/pdns_sync.log
exit 1
fi
# Construct the full path to the zone file
ZONE_FILE_PATH="${ZONE_DIR}/${DOMAIN}.db"
# Check if the domain variable exists and the zone file is readable
if [ -z "${DOMAIN}" ] || [ ! -r "${ZONE_FILE_PATH}" ]; then
# Log an error if something is wrong and exit
echo "[$(date)] Error: DOMAIN variable not set or zone file not readable: ${ZONE_FILE_PATH}" >> /tmp/pdns_sync.log
exit 1
fi
# Call the PHP sync script to send dns transfer signal
/usr/local/bin/php /usr/local/directadmin/scripts/custom/pdns_sync.php --domain="${DOMAIN}" --file="${ZONE_FILE_PATH}" >> /tmp/pdns_sync.log 2>&1
exit 0;
chown diradmin:diradmin /usr/local/directadmin/scripts/custom/dns_write_post.sh
chmod 750 /usr/local/directadmin/scripts/custom/dns_write_post.sh
nano /usr/local/directadmin/scripts/custom/dns_delete_post.sh
* Paste the below script to the file, then save and exit
#!/bin/bash
# ==========================================================
# DirectAdmin Hook for Zone Deletion (Dual Master Version)
# This script sends a DELETE request to multiple PowerDNS
# servers when a zone is deleted in DirectAdmin.
# ==========================================================
# --- Configuration ---
# Add all your PowerDNS master server APIs to this array.
# Format: "API_URL|API_KEY"
PDNS_SERVERS=(
"https://dnsdist.your-powerdns-domain.com:8082|PlainTextApiKeyFor_ns1_ServerHere"
"https://dnsdist.your-secondary-powerdns-domain.com:8082|PlainTextApiKeyFor_ns2_ServerHere"
)
PDNS_SERVER_ID='localhost'
LOG_FILE='/tmp/pdns_delete.log'
# ---------------------
# --- Script Logic (Do not edit below this line) ---
# Use the lowercase '$domain' variable provided by DirectAdmin for this hook.
echo "[$(date)] Zone delete hook triggered for domain: ${domain}" >> "${LOG_FILE}"
if [ -z "${domain}" ]; then
echo "[$(date)] Error: 'domain' variable not set by DirectAdmin. Aborting." >> "${LOG_FILE}"
exit 1
fi
# Loop through all configured PowerDNS servers
for server_info in "${PDNS_SERVERS[@]}"; do
# Split the string into URL and Key
IFS='|' read -r PDNS_API_URL PDNS_API_KEY <<< "$server_info"
echo "[$(date)] Sending DELETE request for zone '${domain}' to server ${PDNS_API_URL}" >> "${LOG_FILE}"
# Send the DELETE request using curl
curl_response=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "X-API-Key: ${PDNS_API_KEY}" \
"${PDNS_API_URL}/api/v1/servers/${PDNS_SERVER_ID}/zones/${domain}.")
echo "[$(date)] Received HTTP response code: ${curl_response} from ${PDNS_API_URL}" >> "${LOG_FILE}"
done
echo "[$(date)] Delete process finished for domain: ${domain}" >> "${LOG_FILE}"
echo "" >> "${LOG_FILE}" # Add a blank line for readability
exit 0;
* Set file permission
chown diradmin:diradmin /usr/local/directadmin/scripts/custom/dns_delete_post.sh
chmod 750 /usr/local/directadmin/scripts/custom/dns_delete_post.sh
25) (Optional) Transfer existing zones
cd /tmp
nano bulk_sync.sh
* Paste the below script
#!/bin/bash
# ===================================================================
# Bulk DNS Zone Sync Script for DirectAdmin to PowerDNS
# This script iterates through all BIND zone files and
# calls the PHP sync script for each domain.
# ===================================================================
# --- Configuration ---
PHP_SYNC_SCRIPT="/usr/local/directadmin/scripts/custom/pdns_sync.php"
# --- End Configuration ---
# Determine the correct BIND data directory
if [ -d "/etc/bind" ]; then
ZONE_DIR="/etc/bind"
elif [ -d "/var/named" ]; then
ZONE_DIR="/var/named"
else
echo "Error: Could not find BIND data directory (/etc/bind or /var/named). Aborting."
exit 1
fi
echo "Using zone directory: ${ZONE_DIR}"
# Check if required files and commands exist
if [ ! -x "$(command -v php)" ]; then
echo "Error: PHP CLI is not installed or not in PATH."
exit 1
fi
if [ ! -f "${PHP_SYNC_SCRIPT}" ]; then
echo "Error: Sync script not found at ${PHP_SYNC_SCRIPT}"
exit 1
fi
# Loop through all .db files in the determined zone directory
for zone_file in ${ZONE_DIR}/*.db; do
if [ -f "$zone_file" ]; then
DOMAIN=$(basename "$zone_file" .db)
echo "--- Syncing ${DOMAIN} ---"
php "${PHP_SYNC_SCRIPT}" --domain="${DOMAIN}" --file="${zone_file}"
# A small delay to avoid overwhelming the API
sleep 1
fi
done
echo "--- Bulk sync process complete. ---"
* Save and exit the file
chmod 750 bulk_sync.sh
./bulk_sync.sh
* Wait a minute, and then delete this temporary file
rm bulk_sync.sh
/////////////////////////////////////////////////////////////////////////////////////////////
WE HAVE DONE AN EXTRAORDINARY WORKS SO FAR, ENJOY !
Post a Comment
Post a Comment