Simple DNS Blackhole

By
Code · FreeBSD · Linux

Ads being sucked into a DNS blackhole

Tired of Annoying Ads and Privacy-Invading Trackers? Here’s How to Take Control

Are you frustrated with pop-up ads in your browser or ads cluttering news articles? Not to mention those pesky privacy-invading trackers? You’re not alone — but the good news is, you don’t have to put up with them.

First Protect Your Browser

The first step is to enable privacy settings in your browsers and install an ad-blocking extension like uBlock Origin. This will block ads in your browser, but that’s just one part of the solution. You can also take things a step further by setting up a DNS blackhole on your local network. This will send ads and trackers to a dead-end, keeping your devices protected across your entire home network.

A DNS blackhole works by redirecting requests for known ad-serving and tracker domains to a non-existent address, effectively blocking them. There are many options available, from free DIY solutions to paid DNS subscription services.

Add a DNS Blackhole to Your Network

If you’re running your own home network with a Unix-based server, you can easily integrate a DNS blackhole into a local BIND DNS service — and best of all, it’s completely free. If you’re already using BIND and know your way around it, you can use this script to manage BIND’s Response Policy Zone (RPZ) feature. RPZ is designed for DNS firewall/blocking purposes. The script should run on *BSD and Linux distros with proper pathnames configured. Out of the box, it has a FreeBSD default configuration.

The source of the blocked host list is provided by Steven Black on GitHub. See his page for full details.

Steps

Refer to the sample files below for these steps.

  1. Create a /usr/local/etc/dns-blackhole directory
  2. Copy dns-blackhole.sh into this new directory
  3. Create and configure your dns-blackhole.conf file here
Usage:
  dns-blackhole.sh [OPTIONS] <on|off|status|update>

Options:
  -c config_file    Specify an alternate config file (default: dns-blackhole.conf)
  -k                Keep temporary working files (skip cleanup after update)
  -q                Quiet mode: suppress progress messages
  -r                Restart 'named' instead of reloading the RPZ
  on|off            Turn the blackhole on or off
  status            Report blackhole and 'named' status
  update            Fetch new blocked hosts data and rebuild zone files
  1. Run dns-blackhole.sh update and make sure there are no errors before continuing
  2. Add this entry inside of the options block in BIND’s named.conf:
response-policy { zone "rpz"; };
  1. Add this to the end of named.conf:
include "/usr/local/etc/namedb/dns-blackhole.zone";
  1. Restart named (e.g., service named restart) and check for errors
  2. Enable the blackhole now with dns-blackhole.sh on
  3. Test to make sure it’s working (see Testing below)

Once you’re sure everything is working properly, add an entry in crontab or periodic to automate updates. Once per day is usually sufficient.


Files Added in Your namedb Directory

  • dns-blackhole.zone - included zone file
  • dns-blackhole-enabled.rpz - list of blocked hostnames
  • dns-blackhole-disabled.rpz - empty list for disabled mode
  • dns-blackhole.rpz - symlink (set by on and off)

dns-blackhole.sh

Copy this script to your dns_blackhole_dir directory.


#!/bin/sh
#
# dns-blackhole.sh - Manages a BIND DNS blackhole using Response Policy Zones (RPZ)
# https://www.morgandavis.net/post/simple-dns-blackhole/
#
# See below for options and usage details.

usage_and_exit() {
    cat >&2 <<EOF
Usage:
  $(basename "$0") [OPTIONS] <on|off|status|update>

Options:
  -c config_file    Specify an alternate config file (default: dns-blackhole.conf)
  -k                Keep temporary working files (skip cleanup after update)
  -q                Quiet mode: suppress progress messages
  -r                Restart 'named' instead of reloading the RPZ
  on|off            Turn the blackhole on or off
  status            Report blackhole and 'named' status
  update            Fetch new blocked hosts data and rebuild zone files

See also: https://www.morgandavis.net/post/simple-dns-blackhole/
EOF
    exit 1
}

#
# Support functions
#

error_exit() {
    echo "$@" >&2
    exit 1
}

msg() {
    if [ "$quiet" -eq 0 ]; then
        echo "$@"
    fi
}

missing_config() {
    error_exit "Missing config '$1'."
}

fetch_file() {
    url="$1"
    out="$2"
    if command -v fetch >/dev/null 2>&1; then
        fetch -q -m -T "$fetch_timeout" -o "$out" "$url"
    elif command -v curl >/dev/null 2>&1; then
        curl --silent --show-error --max-time "$fetch_timeout" --output "$out" "$url"
    else
        error_exit "Neither fetch nor curl found. Cannot fetch files."
    fi
}

make_zone() {
    printf '; [built by %s]\n\n' "$0"
    printf '$TTL    %d\n\n' 604800
    printf '@%*sIN%*sSOA%*slocalhost. root.localhost. (\n' 13 "" 5 "" 4 ""
    printf '%*s%d   ; Serial\n' 28 "" "$timestamp"
    printf '%*s%d   ; Refresh\n' 32 "" 604800
    printf '%*s%d   ; Retry\n' 33 "" 86400
    printf '%*s%d   ; Expire\n' 31 "" 2419200
    printf '%*s%d ) ; Minimum\n\n' 32 "" 604800
    printf '%*sIN%*sNS%*s%s.\n' 14 "" 5 "" 5 "" "$dns_server_hostname"
}

command_named() {
    cmd="$1"
    if command -v service >/dev/null 2>&1; then
        service named "$cmd"
    elif command -v systemctl >/dev/null 2>&1; then
        systemctl "$cmd" named
    else
        error_exit "Cannot $cmd named service."
    fi
}

get_symlink_target() {
    link="$named_zone_files_dir/$switch_symlink"
    if [ -L "$link" ]; then
        basename "$(readlink "$link" 2>/dev/null || echo '')"
    else
        echo ''
    fi
}

#
# Update zone data
#
do_update() {
    # Initialize empty files if they don't exist
    for f in blocked_hosts allowed_hosts; do [ -f "$f" ] || touch "$f"; done

    # Create temporary working directory if it doesn't exist
    [ ! -d "$tmp_dir" ] && mkdir -p "$tmp_dir"

    msg "Fetching master host list..."
    master_list="$tmp_dir/master_hosts_list"
    attempt=0
    while [ "$attempt" -lt "$max_attempts" ]; do
        if [ "$attempt" -gt 0 ]; then
            echo "Fetch failed. Retrying in $retry_seconds seconds..." >&2
            sleep "$retry_seconds"
        fi

        if fetch_file "$master_host_list_url" "$master_list"; then
            break
        fi

        attempt=$((attempt + 1))
    done

    if [ "$attempt" -ge "$max_attempts" ]; then
        error_exit "Failed after $attempt/$max_attempts attempts."
    fi

    msg "Optimizing ..."
    cat "$master_list" "$dns_blackhole_dir/blocked_hosts" |
        sed -e 's/^[[:space:]]*//' |
        grep -v '^#' |
        awk '{print $2}' |
        grep -Ev '^(0\.0\.0\.0|localhost)$' |
        grep -v '^$' |
        sort -u >"$tmp_dir/optimized_hosts"

    msg "Excluding allowed hosts..."
    sort "$dns_blackhole_dir/allowed_hosts" |
        comm -23 "$tmp_dir/optimized_hosts" - >"$tmp_dir/blocked_hosts"

    timestamp=$(date +%s)

    msg "Installing enabled/disabled RPZ zone files..."
    make_zone >"$tmp_dir/$disabled_rpz"
    make_zone >"$tmp_dir/$enabled_rpz"
    sed 's/.*/& CNAME ./' "$tmp_dir/blocked_hosts" >>"$tmp_dir/$enabled_rpz"
    cp "$tmp_dir/$enabled_rpz" "$tmp_dir/$disabled_rpz" "$named_zone_files_dir/"
    chmod 644 "$named_zone_files_dir/$enabled_rpz"
    chmod 644 "$named_zone_files_dir/$disabled_rpz"

    if [ ! -f "$named_includes_dir/$included_zone" ]; then
        msg "Building included zone file..."
        {
            echo 'zone "rpz" {'
            echo '    type master;'
            echo '    file "'$switch_symlink'";'
            echo '};'
        } >"$named_includes_dir/$included_zone"
    fi

    if [ "$keep_temp" -eq 0 ]; then
        msg "Cleaning up..."
        rm "$tmp_dir"/* && rmdir "$tmp_dir"
    fi

    if [ "$(get_symlink_target)" = "" ]; then
        # Create the symlink; defaulting to off
        switch_blackhole "off"
    else
        show_status
    fi
}

#
# Update RPZ zone serial
#
update_serial() {
    timestamp=$(date +%s)
    zone_file=$(get_symlink_target)

    # Set sed in-place flag (GNU is -i alone; BSD is -i '')
    in_place=$(sed --version >/dev/null 2>&1 && echo -i || echo "-i ''")

    eval sed "$in_place" "'s/^\([[:space:]]*\)[0-9]\{1,\}\([[:space:]]*; Serial\)/\1'$timestamp'\2/'" "$named_zone_files_dir/$zone_file"
}

#
# Switch DNS blackhole state
#
switch_blackhole() {
    state="$1"
    tgt=$enabled_rpz
    if [ ! -f "$named_zone_files_dir/$tgt" ]; then
        error_exit "Not ready. Perform an update first."
    fi
    [ "$state" = off ] && tgt="$disabled_rpz"
    [ "$(get_symlink_target)" = "$tgt" ] && {
        msg "DNS blackhole already $state."
        exit 0
    }
    ln -sf "$tgt" "$named_zone_files_dir/$switch_symlink"
    msg "DNS blackhole switched $state."
}

#
# Show status
#
show_status() {
    msg "DNS blackhole is $([ "$(get_symlink_target)" = "$enabled_rpz" ] && echo "on" || echo "off")."
}

#
# Main
#

set -eu

config_file="dns-blackhole.conf"
included_zone="dns-blackhole.zone"
switch_symlink="dns-blackhole.rpz"
enabled_rpz="dns-blackhole-enabled.rpz"
disabled_rpz="dns-blackhole-disabled.rpz"

# Change to the directory where this script resides (resolve symlinks if possible)
if command -v realpath >/dev/null 2>&1; then
    SCRIPT_DIR=$(dirname "$(realpath "$0")")
elif readlink -f "$0" >/dev/null 2>&1; then
    SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
else
    SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
fi
cd "$SCRIPT_DIR"

quiet=0
keep_temp=0
refresh="rndc reload rpz"

while getopts "c:kqr" opt; do
    case "$opt" in
    c) config_file=$OPTARG ;;
    k) keep_temp=1 ;;
    q) quiet=1 ;;
    r) refresh="command_named restart" ;;
    *)
        usage_and_exit
        ;;
    esac
done

shift $((OPTIND - 1))

# Validate operation argument
option="${1:-}"
case "$option" in
on | off | status | update) ;;
*)
    usage_and_exit
    ;;
esac

[ -f "$config_file" ] || {
    error_exit "Config file '$config_file' not found."
}

# shellcheck source=/dev/null
. "$config_file"

[ -n "$dns_blackhole_dir" ] || missing_config "dns_blackhole_dir"
[ -n "$named_includes_dir" ] || missing_config "named_includes_dir"
[ -n "$named_zone_files_dir" ] || missing_config "named_zone_files_dir"
[ -n "$tmp_dir" ] || missing_config "tmp_dir"
[ -n "$dns_server_hostname" ] || missing_config "dns_server_hostname"
[ -n "$fetch_timeout" ] || missing_config "fetch_timeout"
[ -n "$retry_seconds" ] || missing_config "retry_seconds"
[ -n "$max_attempts" ] || missing_config "max_attempts"
[ -n "$master_host_list_url" ] || missing_config "master_host_list_url"

for d in "$dns_blackhole_dir" "$named_includes_dir" "$named_zone_files_dir"; do
    [ -d "$d" ] || error_exit "Directory '$d' missing."
done

case "$option" in
status)
    show_status
    command_named "status"
    exit 0
    ;;
update)
    do_update
    ;;
on | off)
    switch_blackhole "$option"
    update_serial
    ;;
esac

if [ $quiet -eq 1 ]; then
    $refresh >/dev/null
else
    $refresh
fi

Sample dns-blackhole.conf

Create this in your dns_blackhole_dir directory.

#
# dns-blackhole.conf.dist
#

# Directory in which config and custom host files reside
dns_blackhole_dir="/usr/local/etc/dns-blackhole"

# Path to your BIND namedb directory where included files go
named_includes_dir="/usr/local/etc/namedb"

# Path to your BIND namedb directory where zone data files go
named_zone_files_dir="/usr/local/etc/namedb"

# Temporary directory in which to fetch and build zone files
tmp_dir="/var/tmp/dns-blackhole"

# The fully qualified hostname of your nameserver
dns_server_hostname="localhost"

# Seconds before fetch times out
fetch_timeout="15"

# Seconds to wait between fetch retries
retry_seconds="10"

# Maximum number of fetch attempts before giving up
max_attempts="3"

# Master host list URL
# See https://github.com/StevenBlack/hosts for full details
master_host_list_url="https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"

allowed_hosts

#
# allowed_hosts.dist
#
# Add hostnames, one per line, to omit from the DNS blackhole.

blocked_hosts

#
# blocked_hosts.dist
#
# Add host entries, one per line, to add to the DNS blackhole.
# Format is similar to /etc/hosts with 0.0.0.0 addresses:
#
# 0.0.0.0 example.com

Sample Output

# ./dns-blackhole.sh update
Fetching master host list...
Optimizing ...
Excluding allowed hosts...
Installing enabled/disabled RPZ zone files...
Cleaning up...
DNS blackhole is on.
zone reload queued

# ./dns-blackhole.sh on
DNS blackhole switched on.
zone reload queued

# ./dns-blackhole.sh status
DNS blackhole is on.
named is running as pid 39227.

Sample Crontab Entry

#
#minute hour    mday    month   wday    command
#

15      4       *       *       *       /usr/local/etc/dns-blackhole/dns-blackhole.sh -q update 2>&1 | mail -s "Update DNS blackhole zone" root

Testing

Simple test to see if it is working using one of the hostnames in the dns-blackhole-enabled.rpz file.

# host 00fun.com
Host 00fun.com not found: 3(NXDOMAIN)

Yay! It’s blocked when using the local DNS resolver.

To test the opposite function (no blackhole DNS), you can switch the blackhole off and try the lookup again. Or keep it enabled but use an external nameserver like 1.1.1.1 or 8.8.8.8:

# host 00fun.com 1.1.1.1
Using domain server:
Name: 1.1.1.1
Address: 1.1.1.1#53
Aliases:

00fun.com has address 74.53.201.226
00fun.com mail is handled by 1 mx1.comspec.com.

This proves that it would otherwise resolve with the DNS blackhole disabled.

Forcing a Restart

Note that rndc reload rpz (the default refresh command used after any changes) can sometimes delay the correct lookup results. If you want to ensure immediate results, include the -r flag to restart named completely.

Explore