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

nano /etc/hosts

nano /etc/hostname

apt update && apt upgrade -y

apt install -y curl nano wget sudo perl libnet-ssleay-perl openssl libauthen-pam-perl libpam-runtime libio-pty-perl apt-show-versions python3 python-is-python3

reboot


2) Install MySQL

apt install mariadb-server -y

mysql_secure_installation

* Most of the questions press "y" and Enter, but set a mysql root password.


3) Database Creation

mysql -u root -p

* Provide your MySQL root password.

CREATE DATABASE powerdns;
CREATE USER 'pdns_user'@'localhost' IDENTIFIED BY 'YourStrongPasswordHere';
GRANT ALL PRIVILEGES ON powerdns.* TO 'pdns_user'@'localhost';
FLUSH PRIVILEGES;
USE powerdns;


4) 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 TABLE records (
  id BIGINT AUTO_INCREMENT,
  domain_id INT DEFAULT NULL,
  name VARCHAR(255) DEFAULT NULL,
  type VARCHAR(10) DEFAULT NULL,
  content TEXT 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;

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' NOT NULL,
  comment TEXT NOT NULL,
  PRIMARY KEY (id)
) Engine=InnoDB;

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 (modified_at);

CREATE TABLE domainmetadata (
  id INT AUTO_INCREMENT,
  domain_id INT NOT NULL,
  kind VARCHAR(32),
  content TEXT,
  PRIMARY KEY (id)
) Engine=InnoDB;

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 TINYINT(1),
  content TEXT,
  PRIMARY KEY(id)
) Engine=InnoDB;

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;

CREATE UNIQUE INDEX namealgoindex ON tsigkeys (name, algorithm);



5) Exit from MariaDB

EXIT;


6) Install PowerDNS

apt install pdns-server pdns-backend-mysql -y



7) Configure PowerDNS

nano /etc/powerdns/pdns.conf


* Make sure the below lines exist and uncommented.

# Default Configuration
daemon=yes
guardian=yes
setgid=pdns
setuid=pdns

# Listen from any address
local-address=0.0.0.0, ::
local-port=53

# API Enable
api=yes
api-key=YourVeryStrongAndSecretApiKey  # About 32 Alphanumeric capitalist without special characters
webserver=yes
webserver-address=0.0.0.0
webserver-port=8081
webserver-allow-from=0.0.0.0/0,::/0


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



9) 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=YourStrongPasswordHere


10) First Restart and then Enable  PowerDNS

systemctl restart pdns

systemctl enable pdns

systemctl status pdns


11) Configure Firewall (CSF or UFW) on this PowerDNS Server

Open: Port 53,853,953 (UDP/TCP)  for DNS

and

Restricted: Port 8081 (TCP) for API

Regarding 8081 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 server)

Additionally, port 22 tcp with restriction.


12) Test PowerDNS Server

* Query from any other external server terminal (outside powerdns server), this outside server must be trusted in PowerDNS server's firewall to port 8081

curl -v -H 'X-API-Key: YourVeryStrongAndSecretApiKey' http://your-powerdns-server-ip:8081/api/v1/servers/localhost/zones


Great going ! Your PowerDNS server is now ready to receive dns zones.


You can check the existing zone list in PowerDNS by the command:  pdnsutil list-all-zones 

Show a zone records by the command: pdnsutil show-zone example.com

A new zone can be created with the command: pdnsutil create-zone example.com

A zone can be edited by the command: pdnsutil edit-zone example.com

A zone can be delete by command: pdnsutil remove-zone example.com



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


13) DirectAdmin Zone Transfer Setup

* ssh root @DirectAdmin server

* Open the TCP 8081 port (outgoing)

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


14) 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 - Universal Edition)
//  Author: Frank Web Host
//  Purpose: Synchronizes BIND-style zone files from a source
//           server to a PowerDNS server via its API.
// ===================================================================

// --- CONFIGURATION ---
$pdns_api_url   = 'http://your-powerdns-server-ip:8081';        // 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_soa_contact = 'webmaster.your-slave-dns-server.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}");
        $payload = $this->parseZoneToPayload();
        $this->syncWithPowerDNS($payload);
        $this->log("Sync finished for domain: {$this->domain}");
    }

    private function parseZoneToPayload(): array {
        $rrsets = [];
        $lines = explode("\n", $this->zone_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, '$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'];
            $prio = 0;
            $records_array = [];

            switch ($type) {
                case 'MX':
                case 'SRV':
                    [$prio, $content_part] = explode(' ', $content_str, 2);
                    $record['prio'] = (int)$prio;
                    $records_array[] = ['content' => $content_part, '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;
                case 'A': case 'AAAA': case 'CNAME': case 'NS': case 'PTR':
                case 'DNSKEY': case 'DS': case 'SOA': case 'TLSA':
                    $records_array[] = ['content' => $content_str, '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 $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. Updating with PATCH.");
            $payload = ['rrsets' => $rrsets];
            $update_result = $this->callApi('PATCH', $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.");
            $soa_content = "{$this->config['ns_records'][0]} {$this->config['soa_contact']} " . date('Ymd') . "01";
            $payload = [
                'name'        => "{$this->domain}.",
                'kind'        => 'Native',
                'nameservers' => $this->config['ns_records'],
                'rrsets'      => array_merge([
                    ['name' => "{$this->domain}.", 'type' => 'SOA', 'ttl' => 3600, 'changetype' => 'REPLACE', 'records' => [['content' => $soa_content, 'disabled' => false]]]
                ], $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 ---
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 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



15) 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
#  This script will call the Php sync script to transfer dns
# ==========================================================

# Construct the full path to the zone file manually
ZONE_FILE_PATH="/etc/bind/${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
/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



16) (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.
# ==========================================================

# Path to the PHP worker script
PHP_SYNC_SCRIPT="/usr/local/directadmin/scripts/custom/pdns_sync.php"

# Directory containing the BIND zone files
ZONE_DIR="/etc/bind"

# Check if the PHP script exists and is executable
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 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)


17) Create DNS Zones for the Slave Server Own


* Check the existing zone list in PowerDNS by the command:  pdnsutil list-all-zones 
You can view a zone records by the command: pdnsutil show-zone example.com


pdnsutil create-zone your-powerdns-slave-server-domain.com

pdnsutil list-zone your-powerdns-slave-server-domain.com

pdnsutil edit-zone your-powerdns-slave-server-domain.com

* Edit records and save the file and exit


pdnsutil create-zone ns1.your-powerdns-slave-server-domain.com

pdnsutil list-zone ns1.your-powerdns-slave-server-domain.com

pdnsutil edit-zone ns1.your-powerdns-slave-server-domain.com

* Edit records and save the file and exit



18) GUI Setup for CSF Firewall, LetsEncrypt SSL, and File Manager


cd /tmp

wget http://prdownloads.sourceforge.net/webadmin/webmin-2.000-minimal.tar.gz

gunzip webmin-2.000-minimal.tar.gz

tar xf webmin-2.000-minimal.tar

cd webmin-2.000

./setup.sh /usr/local/webmin



* Login to the GUI at https://your-powerdns-slave-server-domain:10000



Post a Comment