Run PiHole directly on Asus-Merlin/DD-WRT Router

I am working on a script that would run PiHole directly on an asus-merlin router without needing the extra hardware of the pi.

an asus router with asus-merlin and entware installed

Here is an overview of what the script does

  • create a second virtual bridge device with it's own ip
  • install all necessary entware dependencies
  • configure dnsmasq to log queries
  • configure lighttpd and bind to the above bridge
  • checkout required pihole files
  • make necessary fixes to run entirely in /opt
  • lots of router specific tweeks and workarounds

Here’s the script…

# Pi-hole: A black hole for Internet advertisements
# (c) 2015, 2016 by Jacob Salmela
# Network-wide ad blocking via your Raspberry Pi
# Installs Pi-hole
# Pi-hole is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.

# is not installed.  It does not work because dnsmasq is in wierd places
# is not installed.  To update pihole and dashboard use this script.  
# is not installed

#Set this to an IP different than your router and not in your dhcp range

#Set this to your tz

    local pid=$1
    local delay="1s"
    local spinstr='/-\|'
    while [ "$(ps | awk '{print $1}' | grep "$pid")" ]; do
        local temp=${spinstr#?}
        printf " [%c]  " "$spinstr"
        local spinstr=${temp}${spinstr%"$temp"}
        sleep ${delay}
        printf "\b\b\b\b\b\b"
    printf "    \b\b\b\b"

installDependencies() {
    echo ":::"
    echo "::: Installing Dependencies"

        bc bash curl git git-http sed rsync perl coreutils-mktemp coreutils-truncate net-tools-hostname
        php5-fastcgi php5-mod-json php5-mod-openssl php5-mod-session
        lighttpd lighttpd-mod-fastcgi lighttpd-mod-access lighttpd-mod-accesslog lighttpd-mod-expire
        lighttpd-mod-compress lighttpd-mod-redirect lighttpd-mod-rewrite lighttpd-mod-setenv

    for i in "${PIHOLE_DEPS[@]}"; do
        opkg install "$i"

    echo "!!! done."

createBridge() {
echo ":::"
echo "::: Creating Bridge Interface"
touch "$FILE"
chmod +x "$FILE"
grep -q "$IPHOLE" "$FILE" || echo '

#Setup bridge for PiHole
ifconfig br0:1 '$IPHOLE' netmask up

' >> "$FILE"

source "$FILE"

echo "!!! done."

setupPhpTZ() {
    echo ":::"
    echo "::: Setup php.ini with correct TZ..."
    #TZ=$(cut -f1 -d "," /opt/etc/TZ)
    sed -i "s|.*date.timezone.*|date.timezone = $TZ|" /opt/etc/php.ini
    echo "!!! done."

setupDnsmasq() {
echo ":::"
echo "::: Creating dnsmasq configuration"

touch "$FILE"
grep -q "pihole" "$FILE" || echo '

# Set dnsmasq configs for PiHole
'  >> "$FILE"

service restart_dnsmasq >> /dev/null

echo "!!! done."

setupLighttpd() {
echo ":::"
echo "::: Creating lighttpd configuration"

#Setup cache dir for compress
mkdir -p /tmp/lighttpd/compress
sed -i 's|cache_dir|"/tmp/lighttpd"|g' /opt/etc/lighttpd/conf.d/30-compress.conf

touch "$FILE"
grep -q "pihole" "$FILE" || echo '

server.bind = "'$IPHOLE'"
server.error-handler-404	= "pihole/index.html"

accesslog.filename			= "/opt/var/log/lighttpd/access.log"
accesslog.format			= "%{%s}t|%V|%r|%s|%b"

fastcgi.server = (
  ".php" =>
    ( "localhost" =>
      ( "socket" => "/tmp/php-fcgi.sock",
        "bin-path" => "/opt/bin/php-fcgi",
        "max-procs" => 1,
        "bin-environment" =>
          ( "PHP_FCGI_CHILDREN" => "2",
             "PHP_FCGI_MAX_REQUESTS" => "1000"

# If the URL starts with /admin, it is the Web interface
$HTTP["url"] =~ "^/admin/" {
    # Create a response header for debugging using curl -I
    setenv.add-response-header = (
        "X-Pi-hole" => "The Pi-hole Web interface is working!",
        "X-Frame-Options" => "DENY"

# If the URL does not start with /admin, then it is a query for an ad domain
$HTTP["url"] =~ "^(?!/admin)/.*" {
    # Create a response header for debugging using curl -I
    setenv.add-response-header = ( "X-Pi-hole" => "A black hole for Internet advertisements." )
    # rewrite only js requests
    url.rewrite = ("(.*).js" => "pihole/index.js")

$HTTP["host"] =~ "||" {
	url.redirect = ( "^/published/(.*)" => "")

' >> "$FILE"

/opt/etc/init.d/S80lighttpd restart

echo "!!! done."


getGitFiles() {
    # Setup git repos for base files and web admin
    echo ":::"
    echo "::: Checking for existing base files..."
    if is_repo ${piholeFilesDir}; then
        make_repo ${piholeFilesDir} ${piholeGitUrl}
        update_repo ${piholeFilesDir}

    echo ":::"
    echo "::: Checking for existing web interface..."
    if is_repo ${webInterfaceDir}; then
        make_repo ${webInterfaceDir} ${webInterfaceGitUrl}
        update_repo ${webInterfaceDir}

is_repo() {
    # If the directory does not have a .git folder it is not a repo
    echo -n ":::    Checking $1 is a repo..."
        if [ -d "$1/.git" ]; then
            echo " OK!"
            return 1
    echo " not found!!"
    return 0

make_repo() {
    # Remove the non-repod interface and clone the interface
    echo -n ":::    Cloning $2 into $1..."
    rm -rf "$1"
    git clone -q "$2" "$1" > /dev/null & spinner $!
    echo " done!"

update_repo() {
    # Pull the latest commits
    echo -n ":::     Updating repo in $1..."
    cd "$1" || exit
    git pull -q > /dev/null & spinner $!
    echo " done!"

installScripts() {
    # Install the scripts from /opt/etc/.pihole to their various locations
    echo ":::"
    echo "::: Installing scripts to /opt/pihole..."
    mkdir -p /opt/pihole		

    cp /opt/etc/.pihole/pihole /opt/pihole/pihole
    cp /opt/etc/.pihole/ /opt/pihole/
    cp /opt/etc/.pihole/advanced/Scripts/ /opt/pihole/
    cp /opt/etc/.pihole/advanced/Scripts/ /opt/pihole/
    cp /opt/etc/.pihole/advanced/Scripts/ /opt/pihole/
    cp /opt/etc/.pihole/advanced/Scripts/ /opt/pihole/

    #make everything executable
    chmod +x /opt/pihole/*.sh

    #everything in /etc is actually in /opt/etc
    sed -i 's|/etc|/opt/etc|g' /opt/pihole/*

    #everything in /var is actually in /opt/var
    sed -i 's|/var|/opt/var|g' /opt/pihole/*

    #everything in /var/www/html is actually in /opt/share/www
    sed -i 's|/var/www/html|/opt/share/www|g' /opt/pihole/*

    #bash is in /opt/bin/bash
    sed -i 's|/bin/bash|/opt/bin/bash|g' /opt/pihole/*

    #in the script don't run gravity_reload function.  It doesn't work with our dnsmasq setup
    sed -i 's|^gravity_reload|s|^#|' /opt/pihole/
    #instead just restart dnsmasq
    echo 'service restart_dnsmasq' >>

    #remove functionality from pihole that does not work correctly
    sed -i 's|-ud, updateDashboard|s|^#|' /opt/pihole/pihole
    sed -i 's|-up, updatePihole|s|^#|' /opt/pihole/pihole
    sed -i 's|-s, setupLCD|s|^#|' /opt/pihole/pihole
    sed -i 's|-d, debug|s|^#|' /opt/pihole/pihole

    #create a working
    #instead of just wiping the log completely keep the last hour of data
    #then we will set a cron to run this every 5 minutes (should be fine, grep is plenty fast)
    echo '
    #!/usr/bin/env bash
    echo -n "::: Flushing hour old data from /var/log/pihole.log ..."
    grep "^$(date -d -1hour +'%b %d %H:%M:%S')" /opt/var/log/pihole.log > /opt/var/log/pihole.tmp
    mv /opt/var/log/pihole.tmp /opt/var/log/pihole.log
    echo "... done!"
    ' > /opt/pihole/

    #link pihole to something in our path
    ln -sf /opt/pihole/pihole /opt/usr/sbin/pihole

    echo "!!! done."

installAdmin() {
    echo ":::"
    echo -n "::: Installing Admin to /opt/share/www/admin..."

    mkdir -p /opt/share/www/admin
    rsync -a --exclude=".git*" /opt/etc/.pihole_admin/ /opt/share/www/admin/ > /dev/null & spinner $!

    #everything in /etc is actually in /opt/etc
    find /opt/share/www/admin/ -type f -exec sed -i -e 's|/etc|/opt/etc|g' {} \; > /dev/null & spinner $!

    #everything in /var is actually in /opt/var
    find /opt/share/www/admin/ -type f -exec sed -i -e 's|/var|/opt/var|g' {} \; > /dev/null & spinner $!

    #fix bug in script data.php
    sed -i '|function getAllQueries() {|a\\$status = "" ' /opt/share/www/admin/data.php

    #enable php debug
    echo 'error_reporting = -1\ndisplay_errors = On\nhtml_errors = On\n' >> /opt/share/www/admin/.user.ini

    echo "!!! done."    

installPiholeMov() {
    echo ":::"
    echo -n "::: Installing pi-hole movie..."
    curl -s -o /opt/pihole/ > /dev/null & spinner $!
    echo " done."

createPiholeIpFile() {
    echo ":::"
    echo "::: Create PiHole Ip file..."
    mkdir -p /opt/etc/pihole
    echo "$IPHOLE" > /opt/etc/pihole/piholeIP
    echo "!!! done."

createDummyHostnameFile() {
    echo ":::"
    echo "::: Create dummy Host file..."
    echo "pi.hole" > /opt/etc/hostname
    echo "!!! done."

createLogFile() {
    # Create logfiles if necessary
    echo ":::"
    echo -n "::: Creating log file and changing owner to nobody..."
    if [ ! -f /opt/var/log/pihole.log ]; then
        touch /opt/var/log/pihole.log
        chmod 640 /opt/var/log/pihole.log
        chown nobody:root /var/log/pihole.log
        echo " done!"
        echo " already exists!"

installPiholeWeb() {
    # Install the web interface
    echo ":::"
    echo "::: Installing pihole custom index page..."
    mkdir -p /opt/share/www/pihole
    cp /opt/etc/.pihole/advanced/index.* /opt/share/www/pihole/.
    echo "!!! done"

installCron() {
echo ":::"
echo "::: Installing Cron Jobs"

touch "$FILE"
chmod +x "$FILE"
grep -q "pihole" "$FILE" || echo '

# Pi-hole: Update the ad sources once a week on Sunday at 01:59
cru a UpdateGravity "59 1 * * 7 /opt/pihole/pihole updateGravity"

# Pi-hole: Flush the log every 5 minutes, keep an hours worth of data
# Log gets too big for our puny memory
cru a FlushLog "*/5 * * * * /opt/pihole/pihole flush"

' >> "$FILE"

echo "!!! done."

runGravity() {
    # Rub to build blacklists
    echo ":::"
    echo "::: Preparing to run to refresh hosts..."
    if ls /opt/etc/pihole/list* 1> /dev/null 2>&1; then
        echo "::: Cleaning up previous install (preserving whitelist/blacklist)"
        rm /opt/etc/pihole/list.*
    echo "::: Running"

installPiHole() {

    echo "::: View the web interface at http://pi.hole/admin or http://$IP/admin"

updatePihole() {

function helpFunc {
    echo "::: Install PiHole!"
    echo ":::"
    echo "::: Options:"
    echo ":::  -i, install"
    echo ":::  -u, update"
    exit 1

if [[ $# = 0 ]]; then

# Handle redirecting to specific functions based on arguments
case "$1" in
"-i" | "install" ) installPiHole;;
"-u" | "install" ) updatePiHole;;
*                ) helpFunc;;

Here are some notes on what works and what doesn’t

  • does not work because dnsmasq is in wierd places
  • the dns log is set to just age off stuff older than an hour instead of completely flushing the log. I have to keep the log at only an hour because these routers just can't handle the memory required to process huge logs. This might get better if they move the admin to use a db as has been discussed before.

Really interesting! It is just a pity that the scripts are not easily copyable… Can you maybe put them somewhere else, or maybe just upload the files??
I want to try it, but is hard to do so now.

It would be a bit better, and easier to possibly integrate, if this was on GitHub as a forked project from our main repo. Posting a 1000 line bash script isn’t quite what Discourse was set up to be. I did edit your post to make the script a bit more usable you need to use ``` to mark the end and beginning of multi line code drops.


I would also be interested in a nice solution for installing Pi-Hole on the router directly.
Still great work on the script, after some modifications I was able to install pi-hole on my Asus AC68U on current MerlinWRT.
However more problems occur:

  • a reboot of the router often results in total malfunction of the device (make backups before installing!)
  • no internet connection on clients in the network after installation (it might be a specific problem for me though due to special requirements from my ISP, didn’t debug it properly yet)
  • some features of Pi-Hole are missing and the setupvars.conf is not created by this script
  • don’t exist anymore
    I will happily upload my modified version of this script for others to investigate if there is any interest.

Original author of the above script. I also have an updated version with some fixes but it is annoying to chase around the changes being made to PiHole so I kind of just gave it up.

I’d think that a feature request is the better way to go about this. There are only a few changes to PiHole that would make the install script run directly on asus-merlin.

  • A new installation target with the opt package dependencies needed
  • A variable to globally control where everything is installed, /etc vs /opt/etc vs /vffs/opt/etc or whatever
  • The configuration of the bridge which is really just a trivial one liner.

Created the feature request. Should continue discussions there.
Link: PiHole directly on Routers (Tomato, MerlinWRT, DD-WRT, openWRT)

Edit: - added link to feature request

1 Like

Anyone have any know how of how to run this script directly in the shell?

I installed nano and saved the file to .sh but I get syntax errors. I turned off word wrap to think that maybe it’s truncating it but even after turning off word wrap (nano -w I still get syntax errors.

I have all the pre-requisites installed.

Edit: Below answer is for the Pi-hole script.

Short answer, you cant. Its written in bash shell and expects either a Debian or RedHat-like package manager. We don’t currently support the ash shell or busybox that is probably in that router. If you are getting syntax errors, then it’s probably the shell that the script is being run under. But even if you do install bash in the router, I don’t think you’d be successful in running the script, there’s just too many other dependencies.

Okay, that would make sense that the code isn’t being interpreted properly. I’m going to reply to Steve above to see what modified version he did.

Hey Steve,

I’d love the modified version that you used to get it working.

My apologies for some confusion, the Pi-hole installer is written for bash, the script on this page does look to be for plain sh shell interpretation, I might be able to help a little, what are the errors you are receiving?

Here is the final version of the script I was working on…

I doubt it even still works and I am no longer updating it but if you can read bash fairly well it is decently documented and contains all the steps needed to get pihole working on asus-merlin. is borked so let me try pastebin…

also If you hardcode the git versions in the installer to 6eedfb572e474e7a88f8dc6526ffee7ff8979559 it might actually run.

I tweeted Eric just the other day about he doubted it could happen.

Maybe it could on dual core ARM with at least 1Ghz and more ram than your a68u


There is an alternative solution (maybe pun):

It has the best designed CUI character user interface I have seen…

I think the 2 projects could cross pollinate.

The developer actually told me about Pi-hole :wink:

I thought it did as a good a job as a Pi-hole.

Btw, thank you to the developers !!!


I have a little problem with the script.
i am trying to install pihole on an asustor nas with your script but i have the message:
root@Stockage:/volume1/home/admin # bash line 12: $’\r’: command not found line 16: $’\r’: command not found line 19: $’\r’: command not found line 22: $’\r’: command not found line 25: $’\r’: command not found line 26: syntax error near unexpected token $'\r'' ' line 26:spinner()