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
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
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, [::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")
-- 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="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")
-- 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")
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
Post a Comment