4160 words
21 minutes
Building a DNS Sinkhole: Complete Implementation Guide for Network Security
Anubhav Gain
2024-11-23
Table of Contents
Overview
A DNS sinkhole is a powerful network security mechanism that intercepts DNS queries for known malicious, unwanted, or tracking domains and returns false results, effectively blocking access to these domains at the DNS level. This guide covers multiple implementation approaches from simple to enterprise-grade solutions.
DNS Sinkhole Architecture
graph TB subgraph "Client Devices" A[Desktop] B[Mobile] C[IoT Device] end
subgraph "DNS Sinkhole" D[DNS Server] E[Blocklist Database] F[Whitelist] G[Query Logger] end
subgraph "Upstream DNS" H[Primary DNS] I[Secondary DNS] J[DoH/DoT Server] end
subgraph "Responses" K[Blocked Response<br/>0.0.0.0] L[Allowed Response<br/>Real IP] end
A --> D B --> D C --> D
D --> E D --> F D --> G
E -->|Blocked| K F -->|Allowed| H H --> L I --> L J --> L
style D fill:#4ecdc4,stroke:#087f5b,stroke-width:2px style E fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px style L fill:#51cf66,stroke:#2f9e44,stroke-width:2pxBasic DNS Sinkhole Implementation
Using Dnsmasq
#!/bin/bash# setup-dnsmasq-sinkhole.sh - Basic DNS sinkhole with dnsmasq
# Install dnsmasqinstall_dnsmasq() { if command -v apt-get &> /dev/null; then sudo apt-get update sudo apt-get install -y dnsmasq elif command -v yum &> /dev/null; then sudo yum install -y dnsmasq else echo "Unsupported package manager" exit 1 fi}
# Configure dnsmasqconfigure_dnsmasq() { # Backup original config sudo cp /etc/dnsmasq.conf /etc/dnsmasq.conf.backup
# Create sinkhole configuration cat << 'EOF' | sudo tee /etc/dnsmasq.conf# DNS Sinkhole Configuration
# Listen on all interfacesinterface=eth0bind-interfaces
# Never forward plain names (without a dot or domain part)domain-needed
# Never forward addresses in the non-routed address spacesbogus-priv
# Don't read /etc/resolv.confno-resolv
# Upstream DNS serversserver=8.8.8.8server=8.8.4.4server=1.1.1.1
# Cache sizecache-size=10000
# Log queries (optional)log-querieslog-facility=/var/log/dnsmasq.log
# Include blocklist configurationconf-dir=/etc/dnsmasq.d/,*.confEOF
# Create blocklist directory sudo mkdir -p /etc/dnsmasq.d/}
# Download and setup blocklistssetup_blocklists() { local blocklist_dir="/etc/dnsmasq.d"
# Create update script cat << 'EOF' | sudo tee /usr/local/bin/update-blocklists.sh#!/bin/bash
BLOCKLIST_DIR="/etc/dnsmasq.d"TEMP_DIR="/tmp/blocklists"
# Create temp directorymkdir -p "$TEMP_DIR"
# Blocklist sourcesdeclare -A BLOCKLISTS=( ["abuse"]="https://urlhaus.abuse.ch/downloads/hostfile/" ["someonewhocares"]="https://someonewhocares.org/hosts/zero/hosts" ["stevenblack"]="https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" ["adguard"]="https://raw.githubusercontent.com/AdguardTeam/AdguardSDNSFilter/master/Filters/filter.txt")
# Download and process blocklistsfor name in "${!BLOCKLISTS[@]}"; do url="${BLOCKLISTS[$name]}" echo "Downloading $name blocklist..."
wget -q -O "$TEMP_DIR/${name}.txt" "$url" || continue
# Convert to dnsmasq format grep -E "^(0\.0\.0\.0|127\.0\.0\.1)" "$TEMP_DIR/${name}.txt" | \ awk '{print "address=/" $2 "/0.0.0.0"}' | \ grep -v "localhost" | \ sort -u > "$BLOCKLIST_DIR/${name}.conf"done
# Combine unique entriescat "$BLOCKLIST_DIR"/*.conf | sort -u > "$BLOCKLIST_DIR/combined.conf.tmp"mv "$BLOCKLIST_DIR/combined.conf.tmp" "$BLOCKLIST_DIR/combined.conf"
# Remove individual files if using combinedrm -f "$BLOCKLIST_DIR"/{abuse,someonewhocares,stevenblack,adguard}.conf
# Add custom blockscat >> "$BLOCKLIST_DIR/combined.conf" << 'CUSTOM'# Custom blocked domainsaddress=/doubleclick.net/0.0.0.0address=/googleadservices.com/0.0.0.0address=/googlesyndication.com/0.0.0.0address=/google-analytics.com/0.0.0.0address=/facebook.com/pixel/0.0.0.0CUSTOM
# Restart dnsmasqsystemctl restart dnsmasq
# Clean uprm -rf "$TEMP_DIR"
echo "Blocklists updated: $(grep -c "address=" $BLOCKLIST_DIR/combined.conf) domains blocked"EOF
sudo chmod +x /usr/local/bin/update-blocklists.sh
# Run initial update sudo /usr/local/bin/update-blocklists.sh
# Setup cron job for daily updates echo "0 3 * * * root /usr/local/bin/update-blocklists.sh" | sudo tee /etc/cron.d/update-blocklists}
# Setup whitelistsetup_whitelist() { cat << 'EOF' | sudo tee /etc/dnsmasq.d/whitelist.conf# Whitelist - These domains will never be blockedserver=/github.com/#server=/stackoverflow.com/#server=/google.com/#server=/cloudflare.com/#EOF}
# Enable and start serviceenable_service() { sudo systemctl enable dnsmasq sudo systemctl restart dnsmasq
# Test configuration sudo dnsmasq --test}
# Main setupmain() { echo "Setting up DNS sinkhole with dnsmasq..." install_dnsmasq configure_dnsmasq setup_blocklists setup_whitelist enable_service echo "DNS sinkhole setup complete!"}
mainAdvanced Pi-hole Implementation
Automated Pi-hole Setup
#!/bin/bash# setup-pihole-advanced.sh - Advanced Pi-hole configuration
# Install Pi-hole with custom settingsinstall_pihole() { # Create configuration cat << 'EOF' > /tmp/pihole-setupVars.confWEBPASSWORD=changemePIHOLE_INTERFACE=eth0IPV4_ADDRESS=192.168.1.100/24IPV6_ADDRESS=QUERY_LOGGING=trueINSTALL_WEB_SERVER=trueINSTALL_WEB_INTERFACE=trueLIGHTTPD_ENABLED=trueBLOCKING_ENABLED=truePIHOLE_DNS_1=1.1.1.1PIHOLE_DNS_2=1.0.0.1DNS_FQDN_REQUIRED=trueDNS_BOGUS_PRIV=trueDNSSEC=trueTEMPERATUREUNIT=CWEBUIBOXEDLAYOUT=traditionalAPI_EXCLUDE_DOMAINS=API_EXCLUDE_CLIENTS=API_QUERY_LOG_SHOW=allAPI_PRIVACY_MODE=0EOF
# Run Pi-hole installer curl -sSL https://install.pi-hole.net | sudo bash /dev/stdin --unattended}
# Configure advanced blocklistsconfigure_blocklists() { # Add multiple blocklist sources cat << 'EOF' | sudo tee /etc/pihole/adlists.list# Malware listshttps://raw.githubusercontent.com/StevenBlack/hosts/master/hostshttps://someonewhocares.org/hosts/zero/hostshttps://raw.githubusercontent.com/crazy-max/WindowsSpyBlocker/master/data/hosts/spy.txthttps://urlhaus.abuse.ch/downloads/hostfile/
# Advertising listshttps://raw.githubusercontent.com/AdguardTeam/AdguardSDNSFilter/master/Filters/filter.txthttps://v.firebog.net/hosts/Easylist.txthttps://raw.githubusercontent.com/anudeepND/blacklist/master/adservers.txt
# Tracking & Telemetryhttps://v.firebog.net/hosts/Easyprivacy.txthttps://raw.githubusercontent.com/crazy-max/WindowsSpyBlocker/master/data/hosts/spy.txthttps://hostfiles.frogeye.fr/firstparty-trackers-hosts.txt
# Malicious listshttps://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txthttps://osint.digitalside.it/Threat-Intel/lists/latestdomains.txthttps://phishing.army/download/phishing_army_blocklist_extended.txt
# Suspicious listshttps://raw.githubusercontent.com/PolishFiltersTeam/KADhosts/master/KADhosts.txthttps://raw.githubusercontent.com/FadeMind/hosts.extras/master/add.Spam/hostsEOF
# Update gravity pihole -g}
# Setup regex filterssetup_regex_filters() { # Add regex patterns for advanced blocking cat << 'EOF' | sudo tee /etc/pihole/regex.list# Block all subdomains of tracking domains^(.+\.)?google-analytics\.com$^(.+\.)?googletagmanager\.com$^(.+\.)?doubleclick\.net$^(.+\.)?facebook\.com/tr^(.+\.)?amazon-adsystem\.com$
# Block cryptocurrency miners^(.+\.)?coinhive\.com$^(.+\.)?coin-hive\.com$^(.+\.)?crypto-loot\.com$
# Block specific tracking patterns^(.+\.)?metric\.^(.+\.)?telemetry\.^(.+\.)?analytics\.^(.+\.)?tracker\.^(.+\.)?tracking\.EOF
# Import regex to database sudo pihole --regex-add "^(.+\.)?google-analytics\.com$"}
# Configure DNS settingsconfigure_dns_settings() { # Setup DNS-over-HTTPS using cloudflared wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb sudo apt install ./cloudflared-linux-amd64.deb
# Configure cloudflared sudo useradd -s /usr/sbin/nologin -r -M cloudflared
cat << 'EOF' | sudo tee /etc/default/cloudflaredCLOUDFLARED_OPTS=--port 5053 --upstream https://1.1.1.1/dns-query --upstream https://1.0.0.1/dns-queryEOF
# Create systemd service cat << 'EOF' | sudo tee /etc/systemd/system/cloudflared.service[Unit]Description=cloudflared DNS over HTTPS proxyAfter=syslog.target network-online.target
[Service]Type=simpleUser=cloudflaredEnvironmentFile=/etc/default/cloudflaredExecStart=/usr/local/bin/cloudflared proxy-dns $CLOUDFLARED_OPTSRestart=on-failureRestartSec=10KillMode=process
[Install]WantedBy=multi-user.targetEOF
sudo systemctl enable cloudflared sudo systemctl start cloudflared
# Configure Pi-hole to use cloudflared echo "server=127.0.0.1#5053" | sudo tee /etc/dnsmasq.d/01-pihole.conf sudo systemctl restart pihole-FTL}
# Setup monitoring and loggingsetup_monitoring() { # Create monitoring script cat << 'EOF' | sudo tee /usr/local/bin/pihole-monitor.sh#!/bin/bash
# Monitor Pi-hole statisticsLOG_DIR="/var/log/pihole-monitor"mkdir -p "$LOG_DIR"
# Function to get Pi-hole statsget_stats() { local stats=$(pihole -c -j) echo "$stats" | jq -r '{ domains_blocked: .domains_being_blocked, dns_queries_today: .dns_queries_today, ads_blocked_today: .ads_blocked_today, ads_percentage_today: .ads_percentage_today, unique_clients: .unique_clients, status: .status }'}
# Log stats every 5 minuteswhile true; do timestamp=$(date '+%Y-%m-%d %H:%M:%S') stats=$(get_stats) echo "[$timestamp] $stats" >> "$LOG_DIR/stats.log"
# Alert if blocking percentage drops blocking_percentage=$(echo "$stats" | jq -r '.ads_percentage_today' | cut -d. -f1) if [[ $blocking_percentage -lt 10 ]]; then echo "[$timestamp] WARNING: Low blocking percentage: ${blocking_percentage}%" >> "$LOG_DIR/alerts.log" fi
sleep 300doneEOF
sudo chmod +x /usr/local/bin/pihole-monitor.sh
# Create systemd service cat << 'EOF' | sudo tee /etc/systemd/system/pihole-monitor.service[Unit]Description=Pi-hole Monitoring ServiceAfter=pihole-FTL.service
[Service]Type=simpleExecStart=/usr/local/bin/pihole-monitor.shRestart=alwaysRestartSec=10
[Install]WantedBy=multi-user.targetEOF
sudo systemctl enable pihole-monitor sudo systemctl start pihole-monitor}Bind9 DNS Sinkhole
Enterprise Bind9 Setup
#!/bin/bash# setup-bind9-sinkhole.sh - Enterprise-grade DNS sinkhole with Bind9
# Install BIND9install_bind9() { sudo apt-get update sudo apt-get install -y bind9 bind9utils bind9-doc dnsutils}
# Configure BIND9 as sinkholeconfigure_bind9() { # Backup original config sudo cp -r /etc/bind /etc/bind.backup
# Main configuration cat << 'EOF' | sudo tee /etc/bind/named.conf.optionsoptions { directory "/var/cache/bind";
// Forwarders for non-blocked queries forwarders { 1.1.1.1; 1.0.0.1; 8.8.8.8; 8.8.4.4; };
// Security settings dnssec-validation auto; auth-nxdomain no; listen-on-v6 { any; };
// Access control allow-query { localhost; 192.168.0.0/16; 172.16.0.0/12; 10.0.0.0/8; };
// Recursion for internal clients only recursion yes; allow-recursion { localhost; 192.168.0.0/16; 172.16.0.0/12; 10.0.0.0/8; };
// Rate limiting rate-limit { responses-per-second 10; window 5; };
// Query logging querylog yes;};
// Logging configurationlogging { channel default_log { file "/var/log/bind/default.log" versions 3 size 5m; severity dynamic; print-time yes; };
channel query_log { file "/var/log/bind/query.log" versions 3 size 10m; severity info; print-time yes; };
channel security_log { file "/var/log/bind/security.log" versions 3 size 5m; severity warning; print-time yes; };
category default { default_log; }; category queries { query_log; }; category security { security_log; };};EOF
# Create log directory sudo mkdir -p /var/log/bind sudo chown bind:bind /var/log/bind}
# Setup Response Policy Zones (RPZ)setup_rpz() { # RPZ configuration cat << 'EOF' | sudo tee -a /etc/bind/named.conf.options
// Response Policy Zone configurationresponse-policy { zone "malware.rpz" policy given; zone "ads.rpz" policy given; zone "tracking.rpz" policy given; zone "custom.rpz" policy given;};EOF
# Include RPZ zones cat << 'EOF' | sudo tee /etc/bind/named.conf.local// RPZ zones for DNS sinkhole
zone "malware.rpz" { type master; file "/etc/bind/zones/db.malware.rpz"; allow-query { none; };};
zone "ads.rpz" { type master; file "/etc/bind/zones/db.ads.rpz"; allow-query { none; };};
zone "tracking.rpz" { type master; file "/etc/bind/zones/db.tracking.rpz"; allow-query { none; };};
zone "custom.rpz" { type master; file "/etc/bind/zones/db.custom.rpz"; allow-query { none; };};
// Sinkhole zonezone "sinkhole" { type master; file "/etc/bind/zones/db.sinkhole";};EOF
# Create zone directory sudo mkdir -p /etc/bind/zones
# Create sinkhole zone file cat << 'EOF' | sudo tee /etc/bind/zones/db.sinkhole$TTL 86400@ IN SOA localhost. root.localhost. ( 2021010101 ; Serial 3600 ; Refresh 1800 ; Retry 604800 ; Expire 86400 ) ; Minimum TTL
IN NS localhost. IN A 0.0.0.0 IN AAAA ::* IN A 0.0.0.0* IN AAAA ::EOF
# Create RPZ zone template create_rpz_zone() { local zone_name=$1 cat << EOF | sudo tee "/etc/bind/zones/db.${zone_name}.rpz"\$TTL 300@ IN SOA localhost. root.localhost. ( $(date +%Y%m%d)01 ; Serial 3600 ; Refresh 1800 ; Retry 604800 ; Expire 300 ) ; Minimum TTL
IN NS localhost.
; Blocked domains will be added belowEOF }
# Create initial RPZ zones create_rpz_zone "malware" create_rpz_zone "ads" create_rpz_zone "tracking" create_rpz_zone "custom"}
# Blocklist updater for RPZcreate_rpz_updater() { cat << 'EOF' | sudo tee /usr/local/bin/update-rpz-blocklists.sh#!/bin/bash
# RPZ Blocklist UpdaterZONE_DIR="/etc/bind/zones"TEMP_DIR="/tmp/rpz-update"SERIAL=$(date +%Y%m%d%H)
mkdir -p "$TEMP_DIR"
# Function to convert hosts file to RPZ formathosts_to_rpz() { local input_file=$1 local output_file=$2
echo "; Generated from $input_file on $(date)" > "$output_file" echo "" >> "$output_file"
# Extract domains and convert to RPZ format grep -E "^(0\.0\.0\.0|127\.0\.0\.1)" "$input_file" | \ awk '{if ($2 && $2 != "localhost") print $2 " CNAME sinkhole."}' | \ sort -u >> "$output_file"
# Add wildcards for subdomains grep -E "^(0\.0\.0\.0|127\.0\.0\.1)" "$input_file" | \ awk '{if ($2 && $2 != "localhost") print "*." $2 " CNAME sinkhole."}' | \ sort -u >> "$output_file"}
# Update malware blocklistecho "Updating malware blocklist..."wget -q -O "$TEMP_DIR/malware-hosts.txt" \ "https://urlhaus.abuse.ch/downloads/hostfile/"hosts_to_rpz "$TEMP_DIR/malware-hosts.txt" "$TEMP_DIR/malware.rpz"
# Update ads blocklistecho "Updating ads blocklist..."wget -q -O "$TEMP_DIR/ads-hosts.txt" \ "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"hosts_to_rpz "$TEMP_DIR/ads-hosts.txt" "$TEMP_DIR/ads.rpz"
# Update tracking blocklistecho "Updating tracking blocklist..."wget -q -O "$TEMP_DIR/tracking-hosts.txt" \ "https://someonewhocares.org/hosts/zero/hosts"hosts_to_rpz "$TEMP_DIR/tracking-hosts.txt" "$TEMP_DIR/tracking.rpz"
# Function to update RPZ zoneupdate_rpz_zone() { local zone_name=$1 local new_data=$2 local zone_file="$ZONE_DIR/db.${zone_name}.rpz"
# Create new zone file with updated serial cat << ZONE > "$TEMP_DIR/${zone_name}.zone"\$TTL 300@ IN SOA localhost. root.localhost. ( $SERIAL ; Serial 3600 ; Refresh 1800 ; Retry 604800 ; Expire 300 ) ; Minimum TTL
IN NS localhost.
; Blocked domainsZONE
cat "$new_data" >> "$TEMP_DIR/${zone_name}.zone"
# Check zone syntax if named-checkzone "${zone_name}.rpz" "$TEMP_DIR/${zone_name}.zone" > /dev/null 2>&1; then sudo mv "$TEMP_DIR/${zone_name}.zone" "$zone_file" echo "Updated ${zone_name} RPZ: $(grep -c "CNAME" $zone_file) domains blocked" else echo "ERROR: Invalid zone file for ${zone_name}" fi}
# Update all zonesupdate_rpz_zone "malware" "$TEMP_DIR/malware.rpz"update_rpz_zone "ads" "$TEMP_DIR/ads.rpz"update_rpz_zone "tracking" "$TEMP_DIR/tracking.rpz"
# Reload BINDsudo rndc reload
# Cleanuprm -rf "$TEMP_DIR"
echo "RPZ blocklists updated successfully"EOF
sudo chmod +x /usr/local/bin/update-rpz-blocklists.sh
# Create cron job echo "0 2 * * * root /usr/local/bin/update-rpz-blocklists.sh > /var/log/rpz-update.log 2>&1" | \ sudo tee /etc/cron.d/update-rpz}
# Setup custom blocking rulessetup_custom_blocks() { cat << 'EOF' | sudo tee -a /etc/bind/zones/db.custom.rpz; Custom blocked domainsdoubleclick.net CNAME sinkhole.*.doubleclick.net CNAME sinkhole.googleadservices.com CNAME sinkhole.*.googleadservices.com CNAME sinkhole.google-analytics.com CNAME sinkhole.*.google-analytics.com CNAME sinkhole.facebook.com.pixel CNAME sinkhole.*.facebook.com.pixel CNAME sinkhole.
; Malware C&C servers (examples)evil-malware.com CNAME sinkhole.*.evil-malware.com CNAME sinkhole.bad-tracker.net CNAME sinkhole.*.bad-tracker.net CNAME sinkhole.EOF}
# Enable and start BINDenable_bind() { # Check configuration sudo named-checkconf
# Enable and start service sudo systemctl enable bind9 sudo systemctl restart bind9
# Verify it's working dig @localhost google.com}
# Main installationmain() { echo "Setting up BIND9 DNS sinkhole..." install_bind9 configure_bind9 setup_rpz create_rpz_updater setup_custom_blocks enable_bind
# Run initial update sudo /usr/local/bin/update-rpz-blocklists.sh
echo "BIND9 DNS sinkhole setup complete!"}
mainMonitoring and Analytics
DNS Query Analytics
#!/bin/bash# dns-analytics.sh - Analyze DNS sinkhole queries
# Parse and analyze DNS logsanalyze_dns_logs() { local log_file="/var/log/bind/query.log" local output_dir="/var/www/html/dns-stats"
mkdir -p "$output_dir"
# Extract query statistics echo "Analyzing DNS queries..."
# Top blocked domains grep "rpz QNAME" "$log_file" | \ awk '{print $8}' | \ sed 's/\.$//' | \ sort | uniq -c | sort -rn | \ head -50 > "$output_dir/top-blocked.txt"
# Query types distribution awk '$0 ~ /query:/ {print $9}' "$log_file" | \ sort | uniq -c | sort -rn > "$output_dir/query-types.txt"
# Client statistics awk '$0 ~ /query:/ {print $6}' "$log_file" | \ cut -d# -f1 | \ sort | uniq -c | sort -rn > "$output_dir/top-clients.txt"
# Hourly distribution awk '{print substr($1,12,2)}' "$log_file" | \ sort | uniq -c > "$output_dir/hourly-distribution.txt"}
# Generate HTML dashboardgenerate_dashboard() { local output_dir="/var/www/html/dns-stats"
cat << 'EOF' > "$output_dir/index.html"<!DOCTYPE html><html><head> <title>DNS Sinkhole Analytics</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <style> body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; } .container { max-width: 1200px; margin: 0 auto; } .card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .metric { display: inline-block; margin: 10px 20px; } .metric-value { font-size: 2em; font-weight: bold; color: #333; } .metric-label { color: #666; font-size: 0.9em; } table { width: 100%; border-collapse: collapse; } th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #ddd; } th { background-color: #f8f9fa; font-weight: bold; } .chart-container { position: relative; height: 300px; margin: 20px 0; } </style></head><body> <div class="container"> <h1>DNS Sinkhole Analytics Dashboard</h1>
<div class="card"> <h2>Overview</h2> <div class="metric"> <div class="metric-value" id="total-queries">0</div> <div class="metric-label">Total Queries</div> </div> <div class="metric"> <div class="metric-value" id="blocked-queries">0</div> <div class="metric-label">Blocked Queries</div> </div> <div class="metric"> <div class="metric-value" id="block-rate">0%</div> <div class="metric-label">Block Rate</div> </div> <div class="metric"> <div class="metric-value" id="unique-clients">0</div> <div class="metric-label">Unique Clients</div> </div> </div>
<div class="card"> <h2>Query Distribution</h2> <div class="chart-container"> <canvas id="queryChart"></canvas> </div> </div>
<div class="card"> <h2>Top Blocked Domains</h2> <table id="blocked-domains-table"> <thead> <tr> <th>Domain</th> <th>Count</th> <th>Category</th> </tr> </thead> <tbody></tbody> </table> </div>
<div class="card"> <h2>Client Activity</h2> <table id="client-table"> <thead> <tr> <th>Client IP</th> <th>Queries</th> <th>Blocked</th> <th>Block Rate</th> </tr> </thead> <tbody></tbody> </table> </div> </div>
<script> // Load and display analytics data async function loadAnalytics() { try { const response = await fetch('analytics-data.json'); const data = await response.json();
// Update metrics document.getElementById('total-queries').textContent = data.totalQueries.toLocaleString(); document.getElementById('blocked-queries').textContent = data.blockedQueries.toLocaleString(); document.getElementById('block-rate').textContent = data.blockRate + '%'; document.getElementById('unique-clients').textContent = data.uniqueClients;
// Create query distribution chart const ctx = document.getElementById('queryChart').getContext('2d'); new Chart(ctx, { type: 'line', data: { labels: data.hourlyLabels, datasets: [{ label: 'Total Queries', data: data.hourlyQueries, borderColor: 'rgb(75, 192, 192)', tension: 0.1 }, { label: 'Blocked Queries', data: data.hourlyBlocked, borderColor: 'rgb(255, 99, 132)', tension: 0.1 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } });
// Populate tables populateBlockedDomainsTable(data.topBlockedDomains); populateClientTable(data.topClients);
} catch (error) { console.error('Error loading analytics:', error); } }
function populateBlockedDomainsTable(domains) { const tbody = document.querySelector('#blocked-domains-table tbody'); domains.forEach(domain => { const row = tbody.insertRow(); row.insertCell(0).textContent = domain.name; row.insertCell(1).textContent = domain.count.toLocaleString(); row.insertCell(2).textContent = domain.category; }); }
function populateClientTable(clients) { const tbody = document.querySelector('#client-table tbody'); clients.forEach(client => { const row = tbody.insertRow(); row.insertCell(0).textContent = client.ip; row.insertCell(1).textContent = client.queries.toLocaleString(); row.insertCell(2).textContent = client.blocked.toLocaleString(); row.insertCell(3).textContent = client.blockRate + '%'; }); }
// Load data on page load loadAnalytics();
// Refresh every 5 minutes setInterval(loadAnalytics, 300000); </script></body></html>EOF}
# Generate JSON data for dashboardgenerate_analytics_json() { local output_dir="/var/www/html/dns-stats" local log_file="/var/log/bind/query.log"
python3 - "$log_file" "$output_dir" << 'EOF'import jsonimport reimport sysfrom collections import defaultdict, Counterfrom datetime import datetime
log_file = sys.argv[1]output_dir = sys.argv[2]
# Initialize counterstotal_queries = 0blocked_queries = 0hourly_queries = defaultdict(int)hourly_blocked = defaultdict(int)blocked_domains = Counter()client_stats = defaultdict(lambda: {'queries': 0, 'blocked': 0})
# Parse log filewith open(log_file, 'r') as f: for line in f: total_queries += 1
# Extract timestamp match = re.search(r'(\d{2}:\d{2}:\d{2})', line) if match: hour = match.group(1)[:2] hourly_queries[hour] += 1
# Check if blocked if 'rpz QNAME' in line: blocked_queries += 1 if match: hourly_blocked[hour] += 1
# Extract blocked domain domain_match = re.search(r'rpz QNAME.*?(\S+)\s+IN', line) if domain_match: domain = domain_match.group(1).rstrip('.') blocked_domains[domain] += 1
# Extract client IP client_match = re.search(r'client.*?(\d+\.\d+\.\d+\.\d+)', line) if client_match: client = client_match.group(1) client_stats[client]['queries'] += 1 if 'rpz QNAME' in line: client_stats[client]['blocked'] += 1
# Calculate metricsblock_rate = round((blocked_queries / total_queries * 100) if total_queries > 0 else 0, 2)unique_clients = len(client_stats)
# Prepare data for JSONanalytics_data = { 'totalQueries': total_queries, 'blockedQueries': blocked_queries, 'blockRate': block_rate, 'uniqueClients': unique_clients, 'hourlyLabels': [f'{h:02d}:00' for h in range(24)], 'hourlyQueries': [hourly_queries.get(f'{h:02d}', 0) for h in range(24)], 'hourlyBlocked': [hourly_blocked.get(f'{h:02d}', 0) for h in range(24)], 'topBlockedDomains': [ { 'name': domain, 'count': count, 'category': categorize_domain(domain) } for domain, count in blocked_domains.most_common(50) ], 'topClients': [ { 'ip': ip, 'queries': stats['queries'], 'blocked': stats['blocked'], 'blockRate': round((stats['blocked'] / stats['queries'] * 100) if stats['queries'] > 0 else 0, 2) } for ip, stats in sorted(client_stats.items(), key=lambda x: x[1]['queries'], reverse=True)[:20] ]}
def categorize_domain(domain): if 'google-analytics' in domain or 'googletagmanager' in domain: return 'Analytics' elif 'doubleclick' in domain or 'adsystem' in domain: return 'Advertising' elif 'facebook' in domain or 'fbcdn' in domain: return 'Social Media' elif 'malware' in domain or 'virus' in domain: return 'Malware' else: return 'Other'
# Write JSON filewith open(f'{output_dir}/analytics-data.json', 'w') as f: json.dump(analytics_data, f, indent=2)
print(f"Analytics data generated: {total_queries} queries analyzed")EOF}
# Create analytics cron jobsetup_analytics_cron() { cat << 'EOF' | sudo tee /usr/local/bin/update-dns-analytics.sh#!/bin/bash
# Update DNS analytics/usr/local/bin/dns-analytics.shEOF
sudo chmod +x /usr/local/bin/update-dns-analytics.sh
# Run every 15 minutes echo "*/15 * * * * root /usr/local/bin/update-dns-analytics.sh" | \ sudo tee /etc/cron.d/dns-analytics}Security Hardening
DNS Sinkhole Security
graph TD A[Security Measures] --> B[Access Control] A --> C[Query Validation] A --> D[Rate Limiting] A --> E[Monitoring]
B --> F[IP Whitelisting] B --> G[DNSSEC] B --> H[ACLs]
C --> I[Query Sanitization] C --> J[Regex Filtering] C --> K[Response Validation]
D --> L[Per-Client Limits] D --> M[Global Limits] D --> N[Blacklisting]
E --> O[Log Analysis] E --> P[Anomaly Detection] E --> Q[Alerting]
style A fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px style C fill:#74c0fc,stroke:#1971c2,stroke-width:2px style E fill:#4ecdc4,stroke:#087f5b,stroke-width:2pxSecurity Configuration
#!/bin/bash# secure-dns-sinkhole.sh - Security hardening for DNS sinkhole
# Configure firewall rulessetup_firewall() { # Allow DNS queries only from internal networks sudo ufw allow from 192.168.0.0/16 to any port 53 sudo ufw allow from 172.16.0.0/12 to any port 53 sudo ufw allow from 10.0.0.0/8 to any port 53
# Allow DNS over TLS/HTTPS sudo ufw allow 853/tcp # DoT sudo ufw allow 443/tcp # DoH
# Rate limiting sudo iptables -A INPUT -p udp --dport 53 -m recent --set --name DNS sudo iptables -A INPUT -p udp --dport 53 -m recent --update --seconds 1 --hitcount 10 --name DNS -j DROP
# Save rules sudo netfilter-persistent save}
# Setup DNSSECconfigure_dnssec() { # Generate DNSSEC keys cd /etc/bind/keys sudo dnssec-keygen -a RSASHA256 -b 2048 -n ZONE sinkhole sudo dnssec-keygen -a RSASHA256 -b 4096 -n ZONE -f KSK sinkhole
# Sign zones sudo dnssec-signzone -A -3 $(head -c 1000 /dev/random | sha1sum | cut -b 1-16) \ -N INCREMENT -o sinkhole -t /etc/bind/zones/db.sinkhole}
# Implement query logging and analysissetup_security_monitoring() { cat << 'EOF' | sudo tee /usr/local/bin/dns-security-monitor.sh#!/bin/bash
# DNS Security MonitorLOG_FILE="/var/log/bind/query.log"ALERT_LOG="/var/log/dns-security-alerts.log"
# ThresholdsQUERY_THRESHOLD=1000 # Queries per minute per clientNXDOMAIN_THRESHOLD=100 # NXDOMAIN responses per minute
# Monitor for anomaliestail -F "$LOG_FILE" | while read line; do # Extract client IP client=$(echo "$line" | grep -oE 'client[[:space:]]+[^#]+' | awk '{print $2}')
# Count queries per client query_count=$(grep -c "$client" "$LOG_FILE" | tail -n 1000)
if [[ $query_count -gt $QUERY_THRESHOLD ]]; then echo "[$(date)] ALERT: High query rate from $client: $query_count queries" >> "$ALERT_LOG"
# Temporarily block client sudo iptables -A INPUT -s "$client" -p udp --dport 53 -j DROP echo "[$(date)] Blocked $client for excessive queries" >> "$ALERT_LOG" fi
# Check for DNS tunneling attempts if echo "$line" | grep -qE "TXT.*[a-zA-Z0-9]{50,}"; then echo "[$(date)] ALERT: Possible DNS tunneling attempt: $line" >> "$ALERT_LOG" fi
# Check for cache poisoning attempts if echo "$line" | grep -qE "response.*REFUSED|FORMERR"; then echo "[$(date)] WARNING: Suspicious response: $line" >> "$ALERT_LOG" fidoneEOF
sudo chmod +x /usr/local/bin/dns-security-monitor.sh
# Create systemd service cat << 'EOF' | sudo tee /etc/systemd/system/dns-security-monitor.service[Unit]Description=DNS Security MonitorAfter=bind9.service
[Service]Type=simpleExecStart=/usr/local/bin/dns-security-monitor.shRestart=alwaysRestartSec=10
[Install]WantedBy=multi-user.targetEOF
sudo systemctl enable dns-security-monitor sudo systemctl start dns-security-monitor}Performance Optimization
Caching and Performance
#!/bin/bash# optimize-dns-performance.sh - Performance tuning for DNS sinkhole
# Optimize BIND performanceoptimize_bind_performance() { cat << 'EOF' | sudo tee -a /etc/bind/named.conf.options
// Performance tuningmax-cache-size 512M;max-cache-ttl 3600;max-ncache-ttl 300;
// Thread tuningrecursive-clients 10000;tcp-clients 1000;tcp-listen-queue 100;
// EDNS tuningedns-udp-size 4096;max-udp-size 4096;
// Prefetch popular recordsprefetch 2 9;
// Minimal responsesminimal-responses yes;EOF}
# Setup Redis caching layersetup_redis_cache() { # Install Redis sudo apt-get install -y redis-server redis-tools
# Configure Redis for DNS caching cat << 'EOF' | sudo tee -a /etc/redis/redis.conf# DNS Cache Configurationmaxmemory 1gbmaxmemory-policy allkeys-lrusave ""appendonly noEOF
# Create DNS cache interface cat << 'EOF' | sudo tee /usr/local/bin/dns-cache-layer.py#!/usr/bin/env python3
import redisimport socketimport structimport threadingimport time
class DNSCache: def __init__(self): self.redis = redis.Redis(host='localhost', port=6379, db=0) self.upstream_dns = '127.0.0.1' self.upstream_port = 5353 # BIND on different port
def cache_key(self, query): return f"dns:{query['name']}:{query['type']}"
def get_cached(self, query): key = self.cache_key(query) cached = self.redis.get(key) if cached: return cached return None
def set_cached(self, query, response, ttl=300): key = self.cache_key(query) self.redis.setex(key, ttl, response)
def handle_query(self, data, addr, sock): # Try cache first cached = self.get_cached(parse_dns_query(data)) if cached: sock.sendto(cached, addr) return
# Forward to upstream upstream_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) upstream_sock.sendto(data, (self.upstream_dns, self.upstream_port))
response, _ = upstream_sock.recvfrom(4096) upstream_sock.close()
# Cache and return response self.set_cached(parse_dns_query(data), response) sock.sendto(response, addr)
def run(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', 53))
while True: data, addr = sock.recvfrom(4096) threading.Thread(target=self.handle_query, args=(data, addr, sock)).start()
if __name__ == '__main__': cache = DNSCache() cache.run()EOF
sudo chmod +x /usr/local/bin/dns-cache-layer.py}Integration Examples
Docker Deployment
# docker-compose.yml - DNS Sinkhole Stackversion: "3.8"
services: pihole: container_name: pihole image: pihole/pihole:latest ports: - "53:53/tcp" - "53:53/udp" - "67:67/udp" - "80:80/tcp" environment: TZ: "America/New_York" WEBPASSWORD: "secure_password_here" PIHOLE_DNS_: "1.1.1.1;1.0.0.1" DNSSEC: "true" CONDITIONAL_FORWARDING: "true" CONDITIONAL_FORWARDING_IP: "192.168.1.1" CONDITIONAL_FORWARDING_DOMAIN: "local" volumes: - "./pihole/etc-pihole:/etc/pihole" - "./pihole/etc-dnsmasq.d:/etc/dnsmasq.d" cap_add: - NET_ADMIN restart: unless-stopped
unbound: container_name: unbound image: mvance/unbound:latest ports: - "5353:53/tcp" - "5353:53/udp" volumes: - "./unbound:/opt/unbound/etc/unbound" restart: unless-stopped
grafana: container_name: grafana image: grafana/grafana:latest ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana-storage:/var/lib/grafana - ./grafana/dashboards:/etc/grafana/provisioning/dashboards restart: unless-stopped
prometheus: container_name: prometheus image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-storage:/prometheus command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" restart: unless-stopped
volumes: grafana-storage: prometheus-storage:Kubernetes Deployment
# dns-sinkhole-k8s.yaml - Kubernetes deploymentapiVersion: v1kind: Namespacemetadata: name: dns-sinkhole
---apiVersion: v1kind: ConfigMapmetadata: name: pihole-config namespace: dns-sinkholedata: TZ: "UTC" PIHOLE_DNS_: "1.1.1.1;1.0.0.1" DNSSEC: "true"
---apiVersion: apps/v1kind: Deploymentmetadata: name: pihole namespace: dns-sinkholespec: replicas: 2 selector: matchLabels: app: pihole template: metadata: labels: app: pihole spec: containers: - name: pihole image: pihole/pihole:latest ports: - containerPort: 53 protocol: TCP - containerPort: 53 protocol: UDP - containerPort: 80 protocol: TCP envFrom: - configMapRef: name: pihole-config volumeMounts: - name: pihole-storage mountPath: /etc/pihole - name: dnsmasq-storage mountPath: /etc/dnsmasq.d volumes: - name: pihole-storage persistentVolumeClaim: claimName: pihole-pvc - name: dnsmasq-storage persistentVolumeClaim: claimName: dnsmasq-pvc
---apiVersion: v1kind: Servicemetadata: name: pihole-dns namespace: dns-sinkholespec: selector: app: pihole ports: - name: dns-tcp port: 53 protocol: TCP - name: dns-udp port: 53 protocol: UDP type: LoadBalancer
---apiVersion: v1kind: Servicemetadata: name: pihole-web namespace: dns-sinkholespec: selector: app: pihole ports: - name: http port: 80 protocol: TCP type: ClusterIPMaintenance and Updates
Automated Maintenance
#!/bin/bash# dns-sinkhole-maintenance.sh - Automated maintenance tasks
# Backup configuration and blocklistsbackup_sinkhole() { local backup_dir="/backup/dns-sinkhole/$(date +%Y%m%d)" mkdir -p "$backup_dir"
# Backup Pi-hole if command -v pihole &> /dev/null; then pihole -a -t "$backup_dir/pihole-teleporter.tar.gz" fi
# Backup BIND if [[ -d /etc/bind ]]; then tar -czf "$backup_dir/bind-config.tar.gz" /etc/bind/ fi
# Backup custom scripts tar -czf "$backup_dir/scripts.tar.gz" /usr/local/bin/*dns* /usr/local/bin/*block*
# Rotate old backups (keep last 30 days) find /backup/dns-sinkhole -type d -mtime +30 -exec rm -rf {} \;}
# Update all componentsupdate_sinkhole() { echo "Updating DNS sinkhole components..."
# Update system packages sudo apt-get update sudo apt-get upgrade -y
# Update Pi-hole if command -v pihole &> /dev/null; then pihole -up fi
# Update blocklists /usr/local/bin/update-blocklists.sh /usr/local/bin/update-rpz-blocklists.sh
# Restart services sudo systemctl restart bind9 || true sudo systemctl restart pihole-FTL || true sudo systemctl restart dnsmasq || true}
# Health checkhealth_check() { local status="healthy"
# Check DNS resolution if ! dig @localhost google.com +short &> /dev/null; then echo "ERROR: DNS resolution failed" status="unhealthy" fi
# Check if blocking is working if ! dig @localhost doubleclick.net +short | grep -q "0.0.0.0"; then echo "WARNING: Blocking might not be working" status="degraded" fi
# Check service status for service in bind9 pihole-FTL dnsmasq; do if systemctl is-active --quiet $service 2>/dev/null; then echo "$service: active" fi done
echo "Health check status: $status"}
# Main maintenance routinemain() { echo "Starting DNS sinkhole maintenance: $(date)"
backup_sinkhole update_sinkhole health_check
echo "Maintenance completed: $(date)"}
# Run with loggingmain 2>&1 | tee -a /var/log/dns-sinkhole-maintenance.logBest Practices
Implementation Strategy
graph TD A[Best Practices] --> B[Planning] A --> C[Implementation] A --> D[Operations] A --> E[Security]
B --> F[Network Assessment] B --> G[Requirements Analysis] B --> H[Tool Selection]
C --> I[Phased Rollout] C --> J[Testing] C --> K[Documentation]
D --> L[Monitoring] D --> M[Updates] D --> N[Backup]
E --> O[Access Control] E --> P[Audit Logging] E --> Q[Incident Response]
style A fill:#4ecdc4,stroke:#087f5b,stroke-width:2px style C fill:#74c0fc,stroke:#1971c2,stroke-width:2px style E fill:#ff6b6b,stroke:#c92a2a,stroke-width:2pxConclusion
A well-implemented DNS sinkhole provides:
- Malware Protection: Blocks communication with C&C servers
- Ad Blocking: Eliminates advertisements at the network level
- Privacy Enhancement: Prevents tracking and telemetry
- Bandwidth Savings: Reduces unnecessary network traffic
- Centralized Control: Single point for network-wide filtering
- Performance Improvement: Faster browsing through local caching
Key considerations for deployment:
- Choose the right solution for your environment (Pi-hole for home, BIND for enterprise)
- Maintain updated blocklists from multiple reputable sources
- Implement proper monitoring and alerting
- Plan for redundancy and high availability
- Regular maintenance and updates
- User education about false positives and whitelisting
The DNS sinkhole serves as a critical first line of defense in network security, complementing other security measures while improving the overall user experience.
Building a DNS Sinkhole: Complete Implementation Guide for Network Security
https://mranv.pages.dev/posts/dns-sinkhole-implementation-guide/