Setting Up PowerDNS Master-Slave Setup with Auto Zone Management and Import

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:

  1. Update your system:

    sudo apt update && sudo apt upgrade -y
  2. Install PowerDNS and the MySQL backend:

    sudo apt install pdns-server pdns-backend-mysql mariadb-server -y
  3. Secure your MySQL installation:

    sudo mysql_secure_installation
  4. 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;
  5. 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):

  1. Edit the PowerDNS configuration file:

    sudo nano /etc/powerdns/pdns.conf
  2. 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
  3. Here's how you might generate a secure API key:

    openssl rand -base64 30

On the Slave server (ns2):

  1. Edit the PowerDNS configuration file:

    sudo nano /etc/powerdns/pdns.conf
  2. 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
  3. 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

  1. 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
  2. Notify the slave:

    sudo pdns_control notify example.com
  3. On the slave, check if the zone transferred:

    sudo pdnsutil list-zone example.com

Part 6: Implementing the Zone Management Script

  1. Install jq on the master server:

    sudo apt install jq -y
  2. Create the script file:

    sudo nano /usr/local/bin/pdns_zone_monitor.sh
  3. 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
  4. Make the script executable:

    sudo chmod +x /usr/local/bin/pdns_zone_monitor.sh
  5. Create a systemd service file:

    sudo nano /etc/systemd/system/pdns-zone-monitor.service
  6. 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
  7. 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

  1. Create the PowerDNS importer script:

    sudo nano /usr/local/bin/powerdns_importer.py
  2. 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()
  3. 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

  1. Create the update all slaves script:

    sudo nano /usr/local/bin/update_all_slaves.py
  2. 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")
  3. Make the script executable:

    sudo chmod +x /usr/local/bin/update_all_slaves.py

Part 9: Using the Scripts

  1. 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.
  2. To import zones from text files:

    python3 /usr/local/bin/powerdns_importer.py /path/to/zone/files/directory
  3. To update all slave servers:

    python3 /usr/local/bin/update_all_slaves.py

Part 10: Monitoring and Maintenance

  1. Check the status of PowerDNS:

    sudo systemctl status pdns
  2. Monitor the zone management script:

    sudo journalctl -u pdns-zone-monitor -f
  3. View the script's log:

    tail -f /var/log/pdns_zone_monitor.log
  4. 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
  • 31 Users Found This Useful
Was this answer helpful?

Related Articles

How to protect your .htaccess file?

For security purposes, we recommend you to prevent access to your .htaccess file from...

ArkHost Default Nameservers

Hello there, and welcome to ArkHost! If you're new to our platform or just considering...

ArkHost Default Nameservers

Hello there, and welcome to ArkHost! If you're new to our platform or just considering...

How long does a domain name transfer usually take?

A domain transfer may take up to 14 days to be completed. It is difficult to provide an exact...

How to Transfer Your Domain to ArkHost

Transferring your domain to ArkHost involves several important steps. Follow this guide to ensure...