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


* 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

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")

-- Enable the DNSdist web interface and API
webserver("127.0.0.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")



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")



* 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



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

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

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