You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pi-hole/automated install/

2785 lines
118 KiB

#!/usr/bin/env bash
# shellcheck disable=SC1090
# Pi-hole: A black hole for Internet advertisements
# (c) Pi-hole (
# Network-wide ad blocking via your own hardware.
# Installs and Updates Pi-hole
# This file is copyright under the latest version of the EUPL.
# Please see LICENSE file for your rights under this license.
# Install with this command (from your Linux machine):
# curl -sSL | bash
# -e option instructs bash to immediately exit if any command [1] has a non-zero exit status
# We do not want users to end up with a partially working install, so we exit the script
# instead of continuing the installation with something broken
set -e
# Append common folders to the PATH to ensure that all basic commands are available.
# When using "su" an incomplete PATH could be passed:
export PATH+=':/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
######## VARIABLES #########
# For better maintainability, we store as much information that can change in variables
# This allows us to make a change in one place that can propagate to all instances of the variable
# These variables should all be GLOBAL variables, written in CAPS
# Local variables will be in lowercase and will exist only within functions
# It's still a work in progress, so you may see some variance in this guideline until it is complete
# Dialog result codes
# dialog code values can be set by environment variables, we only override if
# the env var is not set or empty.
: "${DIALOG_OK:=0}"
: "${DIALOG_ESC:=255}"
# List of supported DNS servers
Google (ECS, DNSSEC);;;2001:4860:4860:0:0:0:0:8888;2001:4860:4860:0:0:0:0:8844
OpenDNS (ECS, DNSSEC);;;2620:119:35::35;2620:119:53::53
DNS.WATCH (DNSSEC);;;2001:1608:10:25:0:0:1c04:b12f;2001:1608:10:25:0:0:9249:d69b
Quad9 (filtered, DNSSEC);;;2620:fe::fe;2620:fe::9
Quad9 (unfiltered, no DNSSEC);;;2620:fe::10;2620:fe::fe:10
Quad9 (filtered, ECS, DNSSEC);;;2620:fe::11;2620:fe::fe:11
Cloudflare (DNSSEC);;;2606:4700:4700::1111;2606:4700:4700::1001
# Location for final installation log storage
# This is an important file as it contains information specific to the machine it's being installed on
# Pi-hole uses lighttpd as a Web server, and this is the config file for it
# This is a file used for the colorized output
# Root of the web server
# We clone (or update) two git repositories during the install. This helps to make sure that we always have the latest versions of the relevant files.
# AdminLTE is used to set up the Web admin interface.
# Pi-hole contains various setup scripts and files which are critical to the installation.
# Search for "PI_HOLE_LOCAL_REPO" in this file to see all such scripts.
# Two notable scripts are (used to generate the HOSTS file) and advanced/Scripts/ (used to install the Web admin interface)
# List of pihole scripts, stored in an array
PI_HOLE_FILES=(chronometer list piholeDebug piholeLogFlush setupLCD update version gravity uninstall webpage)
# This directory is where the Pi-hole scripts will be installed
if [ -z "$useUpdateVars" ]; then
9 years ago
# Pi-hole needs an IP address; to begin, these variables are empty since we don't know what the IP is until this script can run
# Give settings their default values. These may be changed by prompts later in the script.
if [ -z "${USER}" ]; then
USER="$(id -un)"
# dialog dimensions: Let dialog handle appropriate sizing.
######## Undocumented Flags. Shhh ########
# These are undocumented flags; some of which we can use when repairing an installation
# The runUnattended flag is one example of this
# Check arguments for the undocumented flags
for var in "$@"; do
case "$var" in
"--reconfigure" ) reconfigure=true;;
"--unattended" ) runUnattended=true;;
"--disable-install-webserver" ) INSTALL_WEB_SERVER=false;;
# If the color table file exists,
if [[ -f "${coltable}" ]]; then
# source it
source "${coltable}"
# Otherwise,
# Set these values so the installer can still run in color
COL_NC='\e[0m' # No Color
# shellcheck disable=SC2034
# A simple function that just echoes out our logo in ASCII format
# This lets users know that it is a Pi-hole, LLC product
show_ascii_berry() {
echo -e "
:cccclll:. ..,,
:ccccclll. ;ooodc
'ccll:;ll .oooodc
${COL_LIGHT_RED}.. ','.
......... .... .........
.......... ..........
.......... ..........
......... .... .........
is_command() {
# Checks to see if the given command (passed as a string argument) exists on the system.
# The function returns 0 (success) if the command exists, and 1 if it doesn't.
local check_command="$1"
command -v "${check_command}" >/dev/null 2>&1
os_check() {
if [ "$PIHOLE_SKIP_OS_CHECK" != true ]; then
# This function gets a list of supported OS versions from a TXT record at
# and determines whether or not the script is running on one of those systems
local remote_os_domain valid_os valid_version valid_response detected_os detected_version display_warning cmdResult digReturnCode response
detected_os=$(grep '^ID=' /etc/os-release | cut -d '=' -f2 | tr -d '"')
detected_version=$(grep VERSION_ID /etc/os-release | cut -d '=' -f2 | tr -d '"')
cmdResult="$(dig +short -t txt "${remote_os_domain}" 2>&1; echo $?)"
# Gets the return code of the previous command (last line)
if [ ! "${digReturnCode}" == "0" ]; then
# Dig returned 0 (success), so get the actual response, and loop through it to determine if the detected variables above are valid
# If the value of ${response} is a single 0, then this is the return code, not an actual response.
if [ "${response}" == 0 ]; then
IFS=" " read -r -a supportedOS < <(echo "${response}" | tr -d '"')
for distro_and_versions in "${supportedOS[@]}"
# If the distro part is a (case-insensitive) substring of the computer OS
if [[ "${detected_os^^}" =~ ${distro_part^^} ]]; then
IFS="," read -r -a supportedVer <<<"${versions_part}"
for version in "${supportedVer[@]}"
if [[ "${detected_version}" =~ $version ]]; then
if [ "$valid_os" = true ] && [ "$valid_version" = true ] && [ ! "$valid_response" = false ]; then
if [ "$display_warning" != false ]; then
if [ "$valid_response" = false ]; then
if [ "${digReturnCode}" -eq 0 ]; then
errStr="dig succeeded, but response was blank. Please contact support"
errStr="dig failed with return code ${digReturnCode}"
printf " %b %bRetrieval of supported OS list failed. %s. %b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${errStr}" "${COL_NC}"
printf " %bUnable to determine if the detected OS (%s %s) is supported%b\\n" "${COL_LIGHT_RED}" "${detected_os^}" "${detected_version}" "${COL_NC}"
printf " Possible causes for this include:\\n"
printf " - Firewall blocking certain DNS lookups from Pi-hole device\\n"
printf " - being blocked (required to obtain TXT record from containing supported operating systems)\\n"
printf " - Other internet connectivity issues\\n"
printf " %b %bUnsupported OS detected: %s %s%b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${detected_os^}" "${detected_version}" "${COL_NC}"
printf " If you are seeing this message and you do have a supported OS, please contact support.\\n"
printf "\\n"
printf " %b\\n" "${COL_LIGHT_GREEN}" "${COL_NC}"
printf "\\n"
printf " If you wish to attempt to continue anyway, you can try one of the following commands to skip this check:\\n"
printf "\\n"
printf " e.g: If you are seeing this message on a fresh install, you can run:\\n"
printf " %bcurl -sSL | sudo PIHOLE_SKIP_OS_CHECK=true bash%b\\n" "${COL_LIGHT_GREEN}" "${COL_NC}"
printf "\\n"
printf " If you are seeing this message after having run pihole -up:\\n"
printf " %bsudo PIHOLE_SKIP_OS_CHECK=true pihole -r%b\\n" "${COL_LIGHT_GREEN}" "${COL_NC}"
printf " (In this case, your previous run of pihole -up will have already updated the local repository)\\n"
printf "\\n"
printf " It is possible that the installation will still fail at this stage due to an unsupported configuration.\\n"
printf " If that is the case, you can feel free to ask the community on Discourse with the %bCommunity Help%b category:\\n" "${COL_LIGHT_RED}" "${COL_NC}"
printf " %b\\n" "${COL_LIGHT_GREEN}" "${COL_NC}"
printf "\\n"
exit 1
printf " %b %bSupported OS detected%b\\n" "${TICK}" "${COL_LIGHT_GREEN}" "${COL_NC}"
printf " %b %bPIHOLE_SKIP_OS_CHECK env variable set to true - installer will continue%b\\n" "${INFO}" "${COL_LIGHT_GREEN}" "${COL_NC}"
# This function waits for dpkg to unlock, which signals that the previous apt-get command has finished.
test_dpkg_lock() {
printf " %b Waiting for package manager to finish (up to 30 seconds)\\n" "${INFO}"
# fuser is a program to show which processes use the named files, sockets, or filesystems
# So while the lock is held,
while fuser /var/lib/dpkg/lock >/dev/null 2>&1
# we wait half a second,
sleep 0.5
# increase the iterator,
# exit if waiting for more then 30 seconds
if [[ $i -gt 60 ]]; then
printf " %b %bError: Could not verify package manager finished and released lock. %b\\n" "${CROSS}" "${COL_LIGHT_RED}" "${COL_NC}"
printf " Attempt to install packages manually and retry.\\n"
exit 1;
# and then report success once dpkg is unlocked.
return 0
# Compatibility
package_manager_detect() {
# TODO - pull common packages for both distributions out into a common variable, then add
# the distro-specific ones below.
# First check to see if apt-get is installed.
if is_command apt-get ; then
# Set some global variables here
# We don't set them earlier since the installed package manager might be rpm, so these values would be different
# A variable to store the command used to update the package cache
# The command we will use to actually install packages
PKG_INSTALL=("${PKG_MANAGER}" -qq --no-install-recommends install)
# grep -c will return 1 if there are no matches. This is an acceptable condition, so we OR TRUE to prevent set -e exiting the script.
PKG_COUNT="${PKG_MANAGER} -s -o Debug::NoLocking=true upgrade | grep -c ^Inst || true"
# Update package cache
update_package_cache || exit 1
# Check for and determine version number (major and minor) of current php install
local phpVer="php"
if is_command php ; then
phpVer="$(php <<< "<?php echo PHP_VERSION ?>")"
# Check if the first character of the string is numeric
if [[ ${phpVer:0:1} =~ [1-9] ]]; then
printf " %b Existing PHP installation detected : PHP version %s\\n" "${INFO}" "${phpVer}"
printf -v phpInsMajor "%d" "$(php <<< "<?php echo PHP_MAJOR_VERSION ?>")"
printf -v phpInsMinor "%d" "$(php <<< "<?php echo PHP_MINOR_VERSION ?>")"
printf " %b No valid PHP installation detected!\\n" "${CROSS}"
printf " %b PHP version : %s\\n" "${INFO}" "${phpVer}"
printf " %b Aborting installation.\\n" "${CROSS}"
exit 1
# Packages required to perform the os_check (stored as an array)
OS_CHECK_DEPS=(grep dnsutils)
# Packages required to run this install script (stored as an array)
INSTALLER_DEPS=(git iproute2 dialog ca-certificates)
# Packages required to run Pi-hole (stored as an array)
PIHOLE_DEPS=(cron curl iputils-ping psmisc sudo unzip idn2 libcap2-bin dns-root-data libcap2 netcat-openbsd procps jq)
# Packages required for the Web admin interface (stored as an array)
# It's useful to separate this from Pi-hole, since the two repos are also setup separately
PIHOLE_WEB_DEPS=(lighttpd "${phpVer}-common" "${phpVer}-cgi" "${phpVer}-sqlite3" "${phpVer}-xml" "${phpVer}-intl")
# Prior to PHP8.0, JSON functionality is provided as dedicated module, required by Pi-hole AdminLTE:
if [[ -z "${phpInsMajor}" || "${phpInsMajor}" -lt 8 ]]; then
# The Web server user,
# group,
# and config file
# If apt-get is not found, check for rpm.
elif is_command rpm ; then
# Then check if dnf or yum is the package manager
if is_command dnf ; then
# These variable names match the ones for apt-get. See above for an explanation of what they are for.
PKG_INSTALL=("${PKG_MANAGER}" install -y)
# CentOS package manager returns 100 when there are packages to update so we need to || true to prevent the script from exiting.
PKG_COUNT="${PKG_MANAGER} check-update | grep -E '(.i686|.x86|.noarch|.arm|.src)' | wc -l || true"
OS_CHECK_DEPS=(grep bind-utils)
INSTALLER_DEPS=(git dialog iproute newt procps-ng chkconfig ca-certificates)
PIHOLE_DEPS=(cronie curl findutils sudo unzip libidn2 psmisc libcap nmap-ncat jq)
PIHOLE_WEB_DEPS=(lighttpd lighttpd-fastcgi php-common php-cli php-pdo php-xml php-json php-intl)
# If the host OS is centos (or a derivative), epel is required for lighttpd
if ! grep -qiE 'fedora|fedberry' /etc/redhat-release; then
if rpm -qa | grep -qi 'epel'; then
printf " %b EPEL repository already installed\\n" "${TICK}"
# EPEL not already installed, add it based on the release version
RH_RELEASE=$(grep -oP '(?<= )[0-9]+(?=\.?)' /etc/redhat-release)
printf " %b Enabling EPEL package repository (\\n" "${INFO}"
printf " %b Installed %s\\n" "${TICK}" "${EPEL_PKG}"
# If neither apt-get or yum/dnf package managers were found
# we cannot install required packages
printf " %b No supported package manager found\\n" "${CROSS}"
# so exit the installer
# A function for checking if a directory is a git repository
is_repo() {
# Use a named, local variable instead of the vague $1, which is the first argument passed to this function
# These local variables should always be lowercase
local directory="${1}"
# A variable to store the return code
local rc
# If the first argument passed to this function is a directory,
if [[ -d "${directory}" ]]; then
# move into the directory
pushd "${directory}" &> /dev/null || return 1
# Use git to check if the directory is a repo
# git -C is not used here to support git versions older than 1.8.4
git status --short &> /dev/null || rc=$?
# If the command was not successful,
# Set a non-zero return code if directory does not exist
# Move back into the directory the user started in
popd &> /dev/null || return 1
# Return the code; if one is not set, return 0
return "${rc:-0}"
# A function to clone a repo
make_repo() {
# Set named variables for better readability
local directory="${1}"
local remoteRepo="${2}"
# The message to display when this function is running
str="Clone ${remoteRepo} into ${directory}"
# Display the message and use the color table to preface the message with an "info" indicator
printf " %b %s..." "${INFO}" "${str}"
# If the directory exists,
if [[ -d "${directory}" ]]; then
# Return with a 1 to exit the installer. We don't want to overwrite what could already be here in case it is not ours
str="Unable to clone ${remoteRepo} into ${directory} : Directory already exists"
printf "%b %b%s\\n" "${OVER}" "${CROSS}" "${str}"
return 1
# Clone the repo and return the return code from this command
git clone -q --depth 20 "${remoteRepo}" "${directory}" &> /dev/null || return $?
# Move into the directory that was passed as an argument
pushd "${directory}" &> /dev/null || return 1
# Check current branch. If it is master, then reset to the latest available tag.
# In case extra commits have been added after tagging/release (i.e in case of metadata updates/README.MD tweaks)
curBranch=$(git rev-parse --abbrev-ref HEAD)
if [[ "${curBranch}" == "master" ]]; then
# If we're calling make_repo() then it should always be master, we may not need to check.
git reset --hard "$(git describe --abbrev=0 --tags)" || return $?
# Show a colored message showing it's status
printf "%b %b %s\\n" "${OVER}" "${TICK}" "${str}"
# Data in the repositories is public anyway so we can make it readable by everyone (+r to keep executable permission if already set by git)
chmod -R a+rX "${directory}"
# Move back into the original directory
popd &> /dev/null || return 1
return 0
# We need to make sure the repos are up-to-date so we can effectively install Clean out the directory if it exists for git to clone into
update_repo() {
# Use named, local variables
# As you can see, these are the same variable names used in the last function,
# but since they are local, their scope does not go beyond this function
# This helps prevent the wrong value from being assigned if you were to set the variable as a GLOBAL one
local directory="${1}"
local curBranch
# A variable to store the message we want to display;
# Again, it's useful to store these in variables in case we need to reuse or change the message;
# we only need to make one change here
local str="Update repo in ${1}"
# Move into the directory that was passed as an argument
pushd "${directory}" &> /dev/null || return 1
# Let the user know what's happening
printf " %b %s..." "${INFO}" "${str}"
# Stash any local commits as they conflict with our working code
git stash --all --quiet &> /dev/null || true # Okay for stash failure
git clean --quiet --force -d || true # Okay for already clean directory
# Pull the latest commits
git pull --no-rebase --quiet &> /dev/null || return $?
# Check current branch. If it is master, then reset to the latest available tag.
# In case extra commits have been added after tagging/release (i.e in case of metadata updates/README.MD tweaks)
curBranch=$(git rev-parse --abbrev-ref HEAD)
if [[ "${curBranch}" == "master" ]]; then
git reset --hard "$(git describe --abbrev=0 --tags)" || return $?
# Show a completion message
printf "%b %b %s\\n" "${OVER}" "${TICK}" "${str}"
# Data in the repositories is public anyway so we can make it readable by everyone (+r to keep executable permission if already set by git)
chmod -R a+rX "${directory}"
# Move back into the original directory
popd &> /dev/null || return 1
return 0
# A function that combines the previous git functions to update or clone a repo
getGitFiles() {
# Setup named variables for the git repos
# We need the directory
local directory="${1}"
# as well as the repo URL
local remoteRepo="${2}"
# A local variable containing the message to be displayed
local str="Check for existing repository in ${1}"
# Show the message
printf " %b %s..." "${INFO}" "${str}"
# Check if the directory is a repository
if is_repo "${directory}"; then
# Show that we're checking it
printf "%b %b %s\\n" "${OVER}" "${TICK}" "${str}"
# Update the repo, returning an error message on failure
update_repo "${directory}" || { printf "\\n %b: Could not update local repository. Contact support.%b\\n" "${COL_LIGHT_RED}" "${COL_NC}"; exit 1; }
# If it's not a .git repo,
# Show an error
printf "%b %b %s\\n" "${OVER}" "${CROSS}" "${str}"
# Attempt to make the repository, showing an error on failure
make_repo "${directory}" "${remoteRepo}" || { printf "\\n %bError: Could not update local repository. Contact support.%b\\n" "${COL_LIGHT_RED}" "${COL_NC}"; exit 1; }
echo ""
# Success via one of the two branches, as the commands would exit if they failed.
return 0
# Reset a repo to get rid of any local changed
resetRepo() {
# Use named variables for arguments
local directory="${1}"
# Move into the directory
pushd "${directory}" &> /dev/null || return 1
# Store the message in a variable
str="Resetting repository within ${1}..."
# Show the message
printf " %b %s..." "${INFO}" "${str}"
# Use git to remove the local changes
git reset --hard &> /dev/null || return $?
# Data in the repositories is public anyway so we can make it readable by everyone (+r to keep executable permission if already set by git)
chmod -R a+rX "${directory}"
# And show the status
printf "%b %b %s\\n" "${OVER}" "${TICK}" "${str}"
# Return to where we came from
popd &> /dev/null || return 1
# Function succeeded, as "git reset" would have triggered a return earlier if it failed
return 0
find_IPv4_information() {
# Detects IPv4 address used for communication to WAN addresses.
# Accepts no arguments, returns no values.
# Named, local variables
local route
local IPv4bare
# Find IP used to route to outside world by checking the the route to Google's public DNS server
route=$(ip route get
# Get just the interface IPv4 address
# shellcheck disable=SC2059,SC2086
# disabled as we intentionally want to split on whitespace and have printf populate
# the variable with just the first field.
printf -v IPv4bare "$(printf ${route#*src })"
# Get the default gateway IPv4 address (the way to reach the Internet)
# shellcheck disable=SC2059,SC2086
printf -v IPv4gw "$(printf ${route#*via })"
if ! valid_ip "${IPv4bare}" ; then
# Append the CIDR notation to the IP address, if valid_ip fails this should return
IPV4_ADDRESS=$(ip -oneline -family inet address show | grep "${IPv4bare}/" | awk '{print $4}' | awk 'END {print}')
# Get available interfaces that are UP
get_available_interfaces() {
# There may be more than one so it's all stored in a variable
availableInterfaces=$(ip --oneline link show up | grep -v "lo" | awk '{print $2}' | cut -d':' -f1 | cut -d'@' -f1)
# A function for displaying the dialogs the user sees when first running the installer
welcomeDialogs() {
# Display the welcome dialog using an appropriately sized window via the calculation conducted earlier in the script
dialog --no-shadow --clear --keep-tite \
--backtitle "Welcome" \
--title "Pi-hole Automated Installer" \
--msgbox "\\n\\nThis installer will transform your device into a network-wide ad blocker!" \
"${r}" "${c}" \
--and-widget --clear \
--backtitle "Support Pi-hole" \
--title "Open Source Software" \
--msgbox "\\n\\nThe Pi-hole is free, but powered by your donations:" \
"${r}" "${c}" \