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

* Save and exit

chattr +i /etc/resolv.conf


apt update && apt upgrade -y

apt install -y sudo wget tar curl perl libwww-perl openssl apt-transport-https gpg



* 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-slave-server-domain: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


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 8083 (TCP) for API

Regarding 8083 port, (allow only to your master dns servers or production servers, also allow 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

nano /etc/powerdns/pdns.conf


* Make sure the below lines exist and uncommented.


# Listen only on localhost for queries from DNSdist
# local-address=127.0.0.1:53053, [::1]:53053
local-address=127.0.0.1:53053



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


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

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

-- 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="StrongWebLoginPassword"})




* 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-slave-server-domain.com

pdnsutil replace-rrset your-powerdns-slave-server-domain.com @ SOA "ns1.your-powerdns-slave-server-domain.com. webmaster.your-email-domain.com. 2025061601 10800 3600 604800 3600"

pdnsutil add-record your-powerdns-slave-server-domain.com @ NS 3600 ns1.your-powerdns-slave-server-domain.com.

pdnsutil add-record your-powerdns-slave-server-domain.com @ NS 3600 ns2.your-secondary-slave-server-domain.com.

pdnsutil add-record your-powerdns-slave-server-domain.com @ A 3600 1.2.3.4

pdnsutil add-record your-powerdns-slave-server-domain.com @ AAAA 3600 2600:1f10:4c55:e23d::1

pdnsutil add-record your-powerdns-slave-server-domain.com www A 3600 1.2.3.4

pdnsutil add-record your-powerdns-slave-server-domain.com www AAAA 3600 2600:1f10:4c55:e23d::1

pdnsutil add-record your-powerdns-slave-server-domain.com ns1 A 3600 1.2.3.4

pdnsutil add-record your-powerdns-slave-server-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-slave-server-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-slave-server-domain.com.

pdnsutil add-record 4.3.2.1.in-addr.arpa @ PTR 86400 ns1.your-powerdns-slave-server-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-slave-server-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-slave-server-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-slave-server-domain.com.






15) Configure LetsEncrypt SSL (PowerDNS dnsdist Server)


First, make sure the 80 and 443 port opened in firewall.

ssh root@PowerDNS

apt install certbot -y

certbot certonly --standalone -d ns1.your-powerdns-slave-server-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-slave-server-domain.com/privkey.pem

Certificate file: Separate file: /etc/letsencrypt/live/ns1.your-powerdns-slave-server-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-slave-server-domain.com dnsdist A 3600 1.2.3.4

pdnsutil add-record your-powerdns-slave-server-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-slave-server-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-slave-server-domain.com   is already  propagated, from an external DNS checker online (https://www.whatsmydns.net) 

certbot --nginx -d dnsdist.your-powerdns-slave-server-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 (DNSdist) API Reverse Proxy
# ==========================================================

# Server block for handling HTTPS traffic on port 443
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name dnsdist.your-powerdns-slave-server-domain.com;

    # SSL Configuration (managed by Certbot)
    ssl_certificate /etc/letsencrypt/live/dnsdist.your-powerdns-slave-server-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dnsdist.your-powerdns-slave-server-domain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Reverse Proxy Logic: This is the main part.
    # All requests to https://dnsdist.your-powerdns-slave-server-domain.com/ will be forwarded here.
    location / {
        # Forward the request to the local DNSdist API server
        proxy_pass http://127.0.0.1:8083;

        # Set headers to pass original client information to DNSdist
        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 for handling HTTP traffic on port 80
# This block redirects all HTTP traffic to HTTPS.
server {
    listen 80;
    listen [::]:80;
    server_name dnsdist.your-powerdns-slave-server-domain.com;

    # Redirect all other HTTP requests to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }

    # Certbot handles the .well-known path for renewals
    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-slave-server-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-slave-server-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)] Reloading dnsdist service." >> /var/log/dnsdist_cert_sync.log
    systemctl reload 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







* 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:


-- addTLSLocal("0.0.0.0:853", "/path/to/fullchain.pem", "/path/to/privkey.pem")
-- addTLSLocal("[::]:853", "/path/to/fullchain.pem", "/path/to/privkey.pem")



To:


addTLSLocal("0.0.0.0:853", "/etc/letsencrypt/live/dnsdist.your-powerdns-slave-server-domain.com/fullchain.pem", "/etc/letsencrypt/live/dnsdist.your-powerdns-slave-server-domain.com/privkey.pem")
addTLSLocal("[::]:853", "/etc/letsencrypt/live/dnsdist.your-powerdns-slave-server-domain.com/fullchain.pem", "/etc/letsencrypt/live/dnsdist.your-powerdns-slave-server-domain.com/privkey.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 8081

curl -v -H 'X-API-Key: YourVeryStrongAndSecretApiKey' https://dnsdist.your-powerdns-server-domain.com/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 



/////////////////////////////////////////////////////////////////////////////////////////////////////////


20) DirectAdmin Zone Transfer Setup

* ssh root @DirectAdmin server

* Open the TCP 443,8083 port (outgoing)

* Make sure that your DirectAdmin server has php-cli and php-curl available. If not, install it.


21) 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 (v5.9 - Two-Step PATCH)
//  This version uses a reliable two-step PATCH method for full sync.
// ===================================================================

// --- CONFIGURATION ---
$pdns_api_url   = 'https:/dnsdist.your-powerdns-server-domain.com'; // URL of the PowerDNS API server.
$pdns_api_key   = 'YourVeryStrongAndSecretApiKey'; // The API key for PowerDNS.
$pdns_server_id = 'localhost'; // The server ID in PowerDNS, usually 'localhost'.

// Default values for creating NEW zones. These are ignored for existing zones.
$default_ns_records = ['ns1.your-slave-dns-server.com.', 'ns2.your-slave-dns-server.com.']; // Default nameservers
$default_soa_contact = 'webmaster.your-email-domain.com.'; // SOA contact (user@domain.com -> user.domain.com.)
// --- END CONFIGURATION ---


// --- SCRIPT LOGIC (Do not edit below this line) ---
final class PowerDNS_Sync {
    private array $config;
    private string $domain;
    private string $zone_content;

    public function __construct(array $config, string $domain, string $zone_file_path) {
        $this->config = $config;
        $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();
        $this->syncWithPowerDNS($rrsets);
        $this->log("Sync finished for domain: {$this->domain}");
    }

    private function parseZoneToRrsets(): array {
        $rrsets = [];
        $lines = explode("\n", $this->zone_content);
        $default_ttl = 3600;
        $in_multiline = false;
        $multiline_buffer = '';

        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 ($in_multiline) {
                $multiline_buffer .= ' ' . $line;
                if (strpos($line, ')') !== false) {
                    $line = trim(str_replace(')', '', $multiline_buffer));
                    $in_multiline = false;
                } else {
                    continue;
                }
            } else {
                if (strpos($line, '(') !== false) {
                    $in_multiline = true;
                    $multiline_buffer = trim(str_replace('(', '', $line));
                    if (strpos($line, ')') !== false) {
                       $line = trim(str_replace(')', '', $multiline_buffer));
                       $in_multiline = false;
                    } else {
                       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: $this->log("Skipping unsupported record type: {$type}"); 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 $new_rrsets): void {
        $url_zone = "{$this->config['api_url']}/api/v1/servers/{$this->config['server_id']}/zones/{$this->domain}.";
        $result = $this->callApi('GET', $url_zone);
        if ($result['code'] == 200) {
            $this->log("Zone exists. Performing two-step PATCH sync.");
            $zone_data = json_decode($result['body'], true);
            $existing_rrsets = $zone_data['rrsets'] ?? [];
            $delete_payload_rrsets = [];
            foreach ($existing_rrsets as $rrset) {
                if ($rrset['type'] !== 'SOA') {
                    $delete_payload_rrsets[] = ['name' => $rrset['name'], 'type' => $rrset['type'], 'changetype' => 'DELETE', 'records' => []];
                }
            }
            if (!empty($delete_payload_rrsets)) {
                $this->log("Step 1: Deleting old records.");
                $delete_result = $this->callApi('PATCH', $url_zone, ['rrsets' => $delete_payload_rrsets]);
                $this->log("Delete Response ({$delete_result['code']}): " . $delete_result['body']);
                if ($delete_result['code'] !== 204) {$this->log("Error during deletion step. Aborting."); return;}
            }
            $this->log("Step 2: Adding new records.");
            $add_result = $this->callApi('PATCH', $url_zone, ['rrsets' => $new_rrsets]);
            $this->log("Add Response ({$add_result['code']}): " . $add_result['body']);
        } elseif ($result['code'] == 404) {
            $this->log("Zone does not exist. Creating with POST.");
            $soa_content = "{$this->config['ns_records'][0]} {$this->config['soa_contact']} " . date('Ymd') . "01 3600 600 1209600 3600";
            $soa_rrset = ['name' => "{$this->domain}.", 'type' => 'SOA', 'ttl' => 3600, 'changetype' => 'REPLACE', 'records' => [['content' => $soa_content, 'disabled' => false]]];
            $payload = ['name' => "{$this->domain}.", 'kind' => 'Native', 'nameservers' => [], 'rrsets' => array_merge([$soa_rrset], $new_rrsets)];
            $create_result = $this->callApi('POST', "{$this->config['api_url']}/api/v1/servers/{$this->config['server_id']}/zones", $payload);
            $this->log("Create Response ({$create_result['code']}): " . $create_result['body']);
        } else {
            $this->log("An unexpected API error occurred ({$result['code']}): {$result['body']}");
        }
    }

    private function callApi(string $method, string $url, ?array $data = null): array {
        $ch = curl_init($url);
        $options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST  => $method, CURLOPT_HTTPHEADER     => ['X-API-Key: ' . $this->config['api_key'], 'Content-Type: application/json'], CURLOPT_CONNECTTIMEOUT => 10, 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 ({$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 {
    $config = ['api_url' => $pdns_api_url, 'api_key' => $pdns_api_key, 'server_id'   => $pdns_server_id, 'ns_records'  => $default_ns_records, 'soa_contact' => $default_soa_contact];
    $app = new PowerDNS_Sync($config, $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 755 /usr/local/directadmin/scripts/custom/pdns_sync.php



22) 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 755 /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/Record Deletion
#  This script sends a DELETE request to the PowerDNS API
#  when a zone is deleted in DirectAdmin.
# ==========================================================

# --- Configuration ---
PDNS_API_URL='https:/dnsdist.your-powerdns-server-domain.com' # Slave dns server api url
PDNS_API_KEY='YourVeryStrongAndSecretApiKey' # Your original api key from slave dns server
PDNS_SERVER_ID='localhost'
LOG_FILE='/tmp/pdns_delete.log'
# ---------------------

# Do not edit below this line

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

echo "[$(date)] Sending DELETE request for zone: ${domain}" >> "${LOG_FILE}"
curl -sS -X DELETE \
    -H "X-API-Key: ${PDNS_API_KEY}" \
    "${PDNS_API_URL}/api/v1/servers/${PDNS_SERVER_ID}/zones/${domain}." \
    >> "${LOG_FILE}" 2>&1

echo "" >> "${LOG_FILE}"

exit 0;


* Set file permission

chown diradmin:diradmin /usr/local/directadmin/scripts/custom/dns_delete_post.sh


chmod 755 /usr/local/directadmin/scripts/custom/dns_delete_post.sh



23) (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 755 bulk_sync.sh

./bulk_sync.sh

* Wait a minute, and then delete this temporary file

rm bulk_sync.sh


/////////////////////////////////////////////////////////////////////////////////////////////

Additional Configuration on PowerDNS Server (ssh root @PowerDNS)





24) Restrict the API Monitoring URL of PowerDNS


* We will not open the API monitoring page (https://dnsdist.your-powerdns-slave-server-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 reload 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 755 /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



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


apt install incron -y

echo "root" >> /etc/incron.allow


systemctl enable incron
systemctl start incron


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




WE HAVE DONE AN EXTRAORDINARY WORKS SO FAR, ENJOY !


Post a Comment