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
Post a Comment