Introduction
This guide provides a detailed walk-through for setting up a PowerDNS master-slave configuration on Debian 12, including installation, configuration, and implementation of automatic zone management and import scripts. This setup is ideal for managing multiple DNS zones with automatic replication between servers and efficient zone imports.
Pre-requisites
- SSH Access: Ensure passwordless SSH access from your master server (ns1) to your slave server (ns2). This is crucial for automated zone transfers and management. You can achieve this by setting up SSH keys.
- Network Connectivity: Verify that both servers (ns1 and ns2) can communicate with each other on the required ports (primarily port 53 for DNS and port 8081 for the API, if enabled). Use tools like ping and telnet or nc to test connectivity.
- Accurate Server IP Addresses: Have the correct IP addresses for both ns1 and ns2 readily available, as you'll need these for configuration.
Part 1: Installing PowerDNS
On both Master (ns1) and Slave (ns2) servers:
-
Update your system:
sudo apt update && sudo apt upgrade -y
-
Install PowerDNS and the MySQL backend:
sudo apt install pdns-server pdns-backend-mysql mariadb-server -y
-
Secure your MySQL installation:
sudo mysql_secure_installation
-
Create a database and user for PowerDNS:
sudo mysql -u root -p
In the MySQL prompt:
CREATE DATABASE pdns; CREATE USER 'pdns'@'localhost' IDENTIFIED BY 'your_secure_password'; GRANT ALL PRIVILEGES ON pdns.* TO 'pdns'@'localhost'; FLUSH PRIVILEGES; EXIT;
-
Import the PowerDNS schema:
sudo mysql -u root -p pdns < /usr/share/doc/pdns-backend-mysql/schema.mysql.sql
Part 2: Configuring PowerDNS
On the Master server (ns1):
-
Edit the PowerDNS configuration file:
sudo nano /etc/powerdns/pdns.conf
-
Add or modify these lines:
launch=gmysql gmysql-host=localhost gmysql-user=pdns gmysql-dbname=pdns gmysql-password=your_secure_password api=yes api-key=generate_a_secure_api_key webserver=yes webserver-port=8081 webserver-address=0.0.0.0 webserver-allow-from=127.0.0.1,ANOTHER_IP,ANOTHER_IP allow-axfr-ips=IP_OF_SLAVE_SERVER master=yes slave=no also-notify=IP_OF_SLAVE_SERVER
-
Here's how you might generate a secure API key:
openssl rand -base64 30
On the Slave server (ns2):
-
Edit the PowerDNS configuration file:
sudo nano /etc/powerdns/pdns.conf
-
Add or modify these lines:
launch=gmysql gmysql-host=localhost gmysql-user=pdns gmysql-dbname=pdns gmysql-password=your_secure_password slave=yes superslave=yes
-
Add the master server as a supermaster:
sudo mysql -u pdns -p pdns
In the MySQL prompt:
INSERT INTO supermasters (ip, nameserver, account) VALUES ('IP_OF_MASTER_SERVER', 'ns1.yourdomain.com', 'admin'); EXIT;
Part 3: Firewall Configuration
On both servers, configure the firewall to allow only necessary traffic from trusted sources. We will allow port 53 from anywhere since setup is for public Authoritative Nameservers:
# Allow DNS traffic
sudo ufw allow 53/tcp
sudo ufw allow 53/udp
# Allow API traffic on port 8081 (restricted to specific IPs)
# Replace 192.0.2.1 with the actual IP address that needs access
sudo ufw allow from 192.0.2.1 to any port 8081 proto tcp
# If multiple IPs need access, add each one separately
# sudo ufw allow from 10.0.0.1 to any port 8081 proto tcp
# Allow SSH access only from specific jumphost/trusted IPs
# Replace 198.51.100.5 with your jumphost/management server IP
sudo ufw allow from 198.51.100.5 to any port 22 proto tcp
# Deny all other SSH connections
sudo ufw deny 22/tcp
# Enable the firewall
sudo ufw enable
# Verify the rules
sudo ufw status verbose
Part 4: Starting PowerDNS
On both servers:
sudo systemctl start pdns
sudo systemctl enable pdns
Part 5: Testing the Setup
-
On the master server, create a test zone:
sudo pdnsutil create-zone example.com ns1.example.com sudo pdnsutil add-record example.com www A 192.0.2.1
-
Notify the slave:
sudo pdns_control notify example.com
-
On the slave, check if the zone transferred:
sudo pdnsutil list-zone example.com
Part 6: Implementing the Zone Management Script
-
Install jq on the master server:
sudo apt install jq -y
-
Create the script file:
sudo nano /usr/local/bin/pdns_zone_monitor.sh
-
Paste the following content:
#!/bin/bash # Configuration API_KEY="API_KEY" API_URL="http://IP_OF_MASTER_SERVER:8081" # Replace with your master server's IP NS1="ns1.example.com" NS2="ns2.example.com" LOG_FILE="/var/log/pdns_zone_monitor.log" STATE_FILE="/var/log/pdns_processed_zones.txt" # Function to log messages log_message() { echo "$(date): $1" >> $LOG_FILE } # Function to fix SOA and NS records for a zone fix_zone_records() { local zone=$1 local serial=$(date +%Y%m%d%H) # Update SOA and NS records pdnsutil set-meta "$zone" SOA-EDIT-API INCREMENT-WEEKS pdnsutil set-kind "$zone" MASTER pdnsutil add-record "$zone" @ SOA "$NS1 hostmaster.$zone $serial 10800 3600 604800 3600" pdnsutil add-record "$zone" @ NS "$NS1" pdnsutil add-record "$zone" @ NS "$NS2" # Increase serial and notify slaves pdnsutil increase-serial "$zone" pdns_control notify "$zone" log_message "Fixed records for zone $zone" } # Function to remove zone from ns2 remove_zone_from_ns2() { local zone=$1 ssh root@ns2 "pdnsutil delete-zone $zone" log_message "Removed zone $zone from ns2" } # Function to clean ns2 by removing zones not present on ns1 clean_ns2() { log_message "Starting cleanup process on ns2" local primary_zones=$(pdnsutil list-all-zones | awk '{print $1}') local secondary_zones=$(ssh root@ns2 "pdnsutil list-all-zones" | awk '{print $1}') log_message "Primary zones on ns1: $primary_zones" log_message "Secondary zones on ns2: $secondary_zones" # Ensure the zones are compared correctly primary_zones_array=($primary_zones) secondary_zones_array=($secondary_zones) for zone in "${secondary_zones_array[@]}"; do if [[ ! " ${primary_zones_array[*]} " =~ " $zone " ]]; then log_message "Cleaning up zone $zone from ns2" remove_zone_from_ns2 "$zone" fi done } # Load processed zones if [ -f "$STATE_FILE" ]; then mapfile -t processed_zones < "$STATE_FILE" else touch "$STATE_FILE" processed_zones=() fi # Get list of current zones current_zones=$(curl -s -H "X-API-Key: $API_KEY" "$API_URL/api/v1/servers/localhost/zones" | jq -r '.[].name') # Convert current_zones to an array current_zones_array=($current_zones) # Main loop while true; do # Get new list of zones new_zones=$(curl -s -H "X-API-Key: $API_KEY" "$API_URL/api/v1/servers/localhost/zones" | jq -r '.[].name') # Convert new_zones to an array new_zones_array=($new_zones) # Check for new zones and process them for zone in "${new_zones_array[@]}"; do if [[ ! " ${current_zones_array[*]} " =~ " $zone " ]] && [[ ! " ${processed_zones[*]} " =~ " $zone " ]]; then log_message "New zone detected: $zone" fix_zone_records "$zone" echo "$zone" >> "$STATE_FILE" processed_zones+=("$zone") fi done # Check for removed zones and process them for zone in "${current_zones_array[@]}"; do if [[ ! " ${new_zones_array[*]} " =~ " $zone " ]]; then log_message "Zone removed: $zone" remove_zone_from_ns2 "$zone" processed_zones=("${processed_zones[@]/$zone}") sed -i "\|$zone|d" "$STATE_FILE" fi done # Clean ns2 to remove any zones not present on ns1 clean_ns2 # Update current zones list current_zones_array=("${new_zones_array[@]}") # Wait for 1 minute before checking again sleep 60 done
-
Make the script executable:
sudo chmod +x /usr/local/bin/pdns_zone_monitor.sh
-
Create a systemd service file:
sudo nano /etc/systemd/system/pdns-zone-monitor.service
-
Add the following content:
[Unit] Description=PowerDNS Zone Monitor After=pdns.service [Service] ExecStart=/usr/local/bin/pdns_zone_monitor.sh Restart=always User=root [Install] WantedBy=multi-user.target
-
Enable and start the service:
sudo systemctl daemon-reload sudo systemctl enable pdns-zone-monitor sudo systemctl start pdns-zone-monitor
Part 7: Implementing the Zone Import Script
-
Create the PowerDNS importer script:
sudo nano /usr/local/bin/powerdns_importer.py
-
Paste the following content:
#!/usr/bin/env python3 import os import sys import subprocess import logging # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def run_command(command): try: result = subprocess.run(command, capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError as e: logging.error(f"Error running command {' '.join(command)}. Error: {e.stderr.strip()}") return None def import_zone(zone_file): with open(zone_file, 'r') as f: lines = f.readlines() zone_name = None ttl = "3600" for line in lines: line = line.strip() if line.startswith('$ORIGIN'): zone_name = line.split()[1].rstrip('.') break if not zone_name: logging.error(f"Could not determine zone name from {zone_file}") return False try: # Delete the zone if it exists run_command(['pdnsutil', 'delete-zone', zone_name]) logging.info(f"Deleted existing zone: {zone_name}") except: logging.warning(f"Zone {zone_name} did not exist, continuing with creation") # Create the zone run_command(['pdnsutil', 'create-zone', zone_name]) logging.info(f"Created new zone: {zone_name}") for line in lines: line = line.strip() if not line or line.startswith('$ORIGIN'): continue if line.startswith('$TTL'): ttl = line.split()[1] continue parts = line.split(maxsplit=5) if len(parts) < 5: continue name, record_ttl, _, record_type, content = parts[:5] # Handle domain name correctly if name == zone_name or name == "@": name = "@" else: name = name.rstrip('.') if name.endswith(zone_name): name = name[:-len(zone_name)-1] # Remove zone name from the end if not name: name = "@" if record_type == 'MX': if len(parts) == 6: priority, mx = parts[4], parts[5] else: priority, mx = "10", parts[4] content = f"{priority} {mx}" elif record_type == 'TXT': content = f'"{" ".join(parts[4:])}"' elif record_type in ['CNAME', 'NS', 'MX'] and not content.endswith('.'): content = f"{content}." result = run_command(['pdnsutil', 'add-record', zone_name, name, record_type, record_ttl, content]) if result is not None: logging.info(f"Added {record_type} record: {name} {record_ttl} IN {record_type} {content}") else: logging.error(f"Failed to add record: {line}") # Rectify the zone run_command(['pdnsutil', 'rectify-zone', zone_name]) logging.info(f"Rectified zone: {zone_name}") return True def sync_to_ns2(zone_name): try: run_command(['pdns_control', 'notify', zone_name]) logging.info(f"Triggered notification for {zone_name} to ns2") return True except Exception as e: logging.error(f"Error syncing {zone_name} to ns2: {str(e)}") return False def main(): if len(sys.argv) < 2: print("Usage: python powerdns_importer.py ") sys.exit(1) input_directory = sys.argv[1] if not os.path.isdir(input_directory): logging.error(f"Error: {input_directory} is not a valid directory") sys.exit(1) for filename in os.listdir(input_directory): if filename.endswith('.txt'): zone_file = os.path.join(input_directory, filename) logging.info(f"Processing {zone_file}") if import_zone(zone_file): sync_to_ns2(os.path.splitext(filename)[0]) if __name__ == "__main__": main()
-
Make the script executable:
sudo chmod +x /usr/local/bin/powerdns_importer.py
The Zone Import script works with CloudFlare exported Zones, below is a script to export all zones from CloudFlare (Don't forget to provide your API key):
#!/usr/bin/env python3
import requests
import os
import sys
# Set your Cloudflare API credentials
api_token = 'YOUR_CLOUDFLARE_API_KEY'
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
# Cloudflare API endpoints
base_url = 'https://api.cloudflare.com/client/v4'
zones_endpoint = f'{base_url}/zones'
def fetch_paginated_results(url, params=None):
all_results = []
while url:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
data = response.json()
all_results.extend(data['result'])
info = data['result_info']
if info['page'] < info['total_pages']:
params = {'page': info['page'] + 1, 'per_page': info['per_page']}
else:
break
else:
print(f"Failed to fetch data: {response.status_code} - {response.text}")
return None
return all_results
def fetch_zones():
return fetch_paginated_results(zones_endpoint)
def fetch_zone_records(zone_id):
records_endpoint = f'{base_url}/zones/{zone_id}/dns_records'
return fetch_paginated_results(records_endpoint)
def export_zone_to_file(zone_name, records):
filename = f'{zone_name}.txt'
with open(filename, 'w') as file:
file.write(f"$ORIGIN {zone_name}.\n")
file.write("$TTL 3600\n\n")
for record in records:
name = record['name'].replace(f".{zone_name}", "") or "@"
file.write(f"{name}\t{record['ttl']}\tIN\t{record['type']}\t{record['content']}\n")
print(f"Exported {zone_name} to {filename}")
def main():
if api_token == 'YOUR_CLOUDFLARE_API_TOKEN':
print("Please set your Cloudflare API token in the script.")
sys.exit(1)
zones = fetch_zones()
if not zones:
print("No zones found or failed to fetch zones.")
return
os.makedirs('cloudflare_zones', exist_ok=True)
os.chdir('cloudflare_zones')
for zone in zones:
zone_id = zone['id']
zone_name = zone['name']
print(f"Fetching records for zone: {zone_name}")
records = fetch_zone_records(zone_id)
if records:
export_zone_to_file(zone_name, records)
if __name__ == "__main__":
main()
Part 8: Implementing the Slave Update Script
-
Create the update all slaves script:
sudo nano /usr/local/bin/update_all_slaves.py
-
Paste the following content:
#!/usr/bin/env python3 import subprocess import logging # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def run_command(command): try: result = subprocess.run(command, capture_output=True, text=True, check=True) return result.stdout.strip() except subprocess.CalledProcessError as e: logging.error(f"Error running command {' '.join(command)}. Error: {e.stderr.strip()}") return None def update_all_slaves(): try: # Get list of all zones zones_output = run_command(['pdnsutil', 'list-all-zones']) if zones_output is None: logging.error("Failed to get list of zones") return False zones = zones_output.split('\n') # Notify each zone for zone in zones: result = run_command(['pdns_control', 'notify', zone]) if result is not None: logging.info(f"Triggered notification for {zone} to slaves") else: logging.warning(f"Failed to notify for zone: {zone}") logging.info("Completed notifications for all zones") return True except Exception as e: logging.error(f"Error updating all slaves: {str(e)}") return False if __name__ == "__main__": if update_all_slaves(): logging.info("Successfully updated all slave servers") else: logging.error("Failed to update all slave servers")
-
Make the script executable:
sudo chmod +x /usr/local/bin/update_all_slaves.py
Part 9: Using the Scripts
- To monitor and manage zones automatically: The
pdns_zone_monitor.sh
script will run as a service, continuously monitoring for new zones and managing them. -
To import zones from text files:
python3 /usr/local/bin/powerdns_importer.py /path/to/zone/files/directory
-
To update all slave servers:
python3 /usr/local/bin/update_all_slaves.py
Part 10: Monitoring and Maintenance
-
Check the status of PowerDNS:
sudo systemctl status pdns
-
Monitor the zone management script:
sudo journalctl -u pdns-zone-monitor -f
-
View the script's log:
tail -f /var/log/pdns_zone_monitor.log
-
Regularly backup your PowerDNS database:
sudo mysqldump pdns > pdns_backup_$(date +%Y%m%d).sql
Part 11: Zone Backups
import subprocess
import os
from datetime import datetime
def run_pdnsutil(command):
return subprocess.run(['pdnsutil'] + command.split(),
capture_output=True,
text=True).stdout.strip()
# Get the current date and time
current_date = datetime.now().strftime("%Y-%m-%d")
# Get all zones
zones = run_pdnsutil('list-all-zones').split('\n')
# Create output directory
output_dir = f'/root/backups/output_{current_date}'
os.makedirs(output_dir, exist_ok=True)
# Backup each zone
for zone in zones:
print(f"Backing up zone {zone}")
content = run_pdnsutil(f'list-zone {zone}')
with open(os.path.join(output_dir, f'{zone}.txt'), 'w') as f:
f.write(content)
print(f"Backup complete. Files saved in {output_dir}.")
crontab -e
0 4 * * * /usr/bin/python3 /root/scripts/zone_backups.py