Compare commits

..

4 Commits

Author SHA1 Message Date
RD WebDesign
ac78066657 Fix the github suggestion mess
Some checks are pending
CodeQL / Analyze (pull_request) Waiting to run
Test Supported Distributions / smoke-tests (pull_request) Waiting to run
Test Supported Distributions / distro-test (alpine_3_21) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (alpine_3_22) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (centos_10) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (centos_9) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (debian_11) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (debian_12) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (debian_13) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (fedora_40) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (fedora_41) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (fedora_42) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (ubuntu_20) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (ubuntu_22) (pull_request) Blocked by required conditions
Test Supported Distributions / distro-test (ubuntu_24) (pull_request) Blocked by required conditions
Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
2025-10-19 16:14:55 -03:00
RD WebDesign
f4a395cb06 Apply suggestion from @MichaIng
Do not handle HTTP code "000" separately.
Use curl error messages for every error, including unknown/unexpected HTTP codes or non-HTTP errors.

Co-authored-by: MichaIng <micha@dietpi.com>
Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
2025-10-19 14:48:54 -03:00
RD WebDesign
0cfc02cbab Apply suggestion from @MichaIng
Remove the STDERR mute from curl command.

The `-s` flag already mutes all curl errors, so STDERR would only contain something if curl itself (or a shared library) is damaged/missing.

Co-authored-by: MichaIng <micha@dietpi.com>
Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
2025-10-19 14:41:39 -03:00
RD WebDesign
4e191da1a0 Improve curl error message including exit code and error message
This commit replaces the 3 digits http_code returned by curl with the json
output. This output contains all returned values, including http_code,
exitcode and errormsg.

Using json format, the old http_error "000" string is formated as a number "0".

Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
2025-10-17 22:37:45 -03:00
42 changed files with 1164 additions and 890 deletions

View File

@@ -25,16 +25,16 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
# Initializes the CodeQL tools for scanning.
-
name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 #v4.32.6
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 #v4.30.8
with:
languages: 'python'
-
name: Autobuild
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 #v4.32.6
uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5 #v4.30.8
-
name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 #v4.32.6
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 #v4.30.8

View File

@@ -17,7 +17,7 @@ jobs:
issues: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f #v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
@@ -40,7 +40,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Remove 'stale' label
run: gh issue edit ${{ github.event.issue.number }} --remove-label ${{ env.stale_label }}
env:

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f #v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Do not automatically mark PR/issue as stale

View File

@@ -33,7 +33,7 @@ jobs:
name: Syncing branches
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Opening pull request
run: gh pr create -B development -H master --title 'Sync master back into development' --body 'Created by Github action' --label 'internal'
env:

View File

@@ -7,42 +7,53 @@ on:
permissions:
contents: read
env:
FORCE_COLOR: 1
PYTHONUNBUFFERED: 1
PYTHONUTF8: 1
jobs:
smoke-tests:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
fetch-depth: 0 # Differential ShellCheck requires full git history
- name: Check scripts in repository are executable
run: |
IFS=$'\n';
for f in $(find . -name '*.sh' -o -name '*.bats'); do if [[ ! -x $f ]]; then echo "$f is not executable" && FAIL=1; fi ;done
for f in $(find . -name '*.sh'); do if [[ ! -x $f ]]; then echo "$f is not executable" && FAIL=1; fi ;done
unset IFS;
# If FAIL is 1 then we fail.
[[ $FAIL == 1 ]] && exit 1 || echo "Scripts are executable!"
- name: Differential ShellCheck
if: github.event_name == 'pull_request'
uses: redhat-plumbers-in-action/differential-shellcheck@d965e66ec0b3b2f821f75c8eff9b12442d9a7d1e #v5.5.6
uses: redhat-plumbers-in-action/differential-shellcheck@0d9e5b29625f871e6a4215380486d6f1a7cb6cdd #v5.5.5
with:
severity: warning
display-engine: sarif-fmt
- name: Spell-Checking
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 #v2.2
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 #v2.1
with:
ignore_words_file: .codespellignore
- name: Get editorconfig-checker
uses: editorconfig-checker/action-editorconfig-checker@4b6cd6190d435e7e084fb35e36a096e98506f7b9 #v2.1.0
uses: editorconfig-checker/action-editorconfig-checker@1a41284d59c6fe7f1b21ddc4a2b36400a33dc1b4 # tag v2. is really out of date
- name: Run editorconfig-checker
run: editorconfig-checker
- name: Check python code formatting with black
uses: psf/black@af0ba72a73598c76189d6dd1b21d8532255d5942 #25.9.0
with:
src: "./test"
options: "--check --diff --color"
distro-test:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
@@ -63,14 +74,25 @@ jobs:
fedora_40,
fedora_41,
fedora_42,
fedora_43,
alpine_3_21,
alpine_3_22,
alpine_3_23,
]
env:
DISTRO: ${{matrix.distro}}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
- name: Run BATS test suite for ${{ matrix.distro }}
run: DISTRO=${{ matrix.distro }} bash test/run.sh
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c #v6.0.0
with:
python-version: "3.13"
- name: Install wheel
run: pip install wheel
- name: Install dependencies
run: pip install -r test/requirements.txt
- name: Test with tox
run: tox -c test/tox.${DISTRO}.ini

10
.gitignore vendored
View File

@@ -1,7 +1,15 @@
.DS_Store
*.pyc
*.swp
__pycache__
.cache
.pytest_cache
.tox
.eggs
*.egg-info
.idea/
*.iml
.vscode/
.venv/
.fleet/
test/libs/
.cache/

View File

@@ -169,7 +169,7 @@ initialize_debug() {
# Display that the debug process is beginning
log_write "${COL_PURPLE}*** [ INITIALIZING ]${COL_NC}"
# Timestamp the start of the log
log_write "${INFO} $(date "+%Y-%m-%d %H:%M:%S") debug log has been initialized."
log_write "${INFO} $(date "+%Y-%m-%d:%H:%M:%S") debug log has been initialized."
# Uptime of the system
# credits to https://stackoverflow.com/questions/28353409/bash-format-uptime-to-show-days-hours-minutes
system_uptime=$(uptime | awk -F'( |,|:)+' '{if ($7=="min") m=$6; else {if ($7~/^day/){if ($9=="min") {d=$6;m=$8} else {d=$6;h=$8;m=$9}} else {h=$6;m=$7}}} {print d+0,"days,",h+0,"hours,",m+0,"minutes"}')
@@ -375,6 +375,22 @@ check_firewalld() {
log_write "${CROSS} ${COL_RED} Allow Service: ${i}${COL_NC} (${FAQ_HARDWARE_REQUIREMENTS_FIREWALLD})"
fi
done
# check for custom FTL FirewallD zone
local firewalld_zones
firewalld_zones=$(firewall-cmd --get-zones)
if [[ "${firewalld_zones}" =~ "ftl" ]]; then
log_write "${TICK} ${COL_GREEN}FTL Custom Zone Detected${COL_NC}";
# check FTL custom zone interface: lo
local firewalld_ftl_zone_interfaces
firewalld_ftl_zone_interfaces=$(firewall-cmd --zone=ftl --list-interfaces)
if [[ "${firewalld_ftl_zone_interfaces}" =~ "lo" ]]; then
log_write "${TICK} ${COL_GREEN} Local Interface Detected${COL_NC}";
else
log_write "${CROSS} ${COL_RED} Local Interface Not Detected${COL_NC} (${FAQ_HARDWARE_REQUIREMENTS_FIREWALLD})"
fi
else
log_write "${CROSS} ${COL_RED}FTL Custom Zone Not Detected${COL_NC} (${FAQ_HARDWARE_REQUIREMENTS_FIREWALLD})"
fi
fi
else
log_write "${TICK} ${COL_GREEN}Firewalld service not detected${COL_NC}";
@@ -577,21 +593,18 @@ check_required_ports() {
# Add port 53
ports_configured+=("53")
local protocol_type port_number service_name
# Now that we have the values stored,
for i in "${!ports_in_use[@]}"; do
# loop through them and assign some local variables
read -r protocol_type port_number service_name <<< "$(
awk '{
p=$1; n=$5; s=$7
gsub(/users:\(\("/,"",s)
gsub(/".*/,"",s)
print p, n, s
}' <<< "${ports_in_use[$i]}"
)"
local service_name
service_name=$(echo "${ports_in_use[$i]}" | awk '{gsub(/users:\(\("/,"",$7);gsub(/".*/,"",$7);print $7}')
local protocol_type
protocol_type=$(echo "${ports_in_use[$i]}" | awk '{print $1}')
local port_number
port_number="$(echo "${ports_in_use[$i]}" | awk '{print $5}')" # | awk '{gsub(/^.*:/,"",$5);print $5}')
# Check if the right services are using the right ports
if [[ ${ports_configured[*]} =~ ${port_number##*:} ]]; then
if [[ ${ports_configured[*]} =~ $(echo "${port_number}" | rev | cut -d: -f1 | rev) ]]; then
compare_port_to_service_assigned "${ftl}" "${service_name}" "${protocol_type}:${port_number}"
else
# If it's not a default port that Pi-hole needs, just print it out for the user to see
@@ -709,7 +722,7 @@ dig_at() {
fi
# Check if Pi-hole can use itself to block a domain
if local_dig="$(dig +tries=1 +time=2 -"${protocol}" "${random_url}" @"${local_address}" "${record_type}" -p "$(get_ftl_conf_value "dns.port")")"; then
if local_dig="$(dig +tries=1 +time=2 -"${protocol}" "${random_url}" @"${local_address}" "${record_type}")"; then
# If it can, show success
if [[ "${local_dig}" == *"status: NOERROR"* ]]; then
local_dig="NOERROR"
@@ -803,27 +816,42 @@ ftl_full_status(){
make_array_from_file() {
local filename="${1}"
# If the file is a directory do nothing since it cannot be parsed
[[ -d "${filename}" ]] && return
# The second argument can put a limit on how many line should be read from the file
# Since some of the files are so large, this is helpful to limit the output
local limit=${2}
# A local iterator for testing if we are at the limit above
local i=0
# If the file is a directory
if [[ -d "${filename}" ]]; then
# do nothing since it cannot be parsed
:
else
# Otherwise, read the file line by line
while IFS= read -r line;do
# Otherwise, strip out comments and blank lines
new_line=$(echo "${line}" | sed -e 's/^\s*#.*$//' -e '/^$/d')
# If the line still has content (a non-zero value)
if [[ -n "${new_line}" ]]; then
# Process the file, strip out comments and blank lines
local processed
processed=$(sed -e 's/^\s*#.*$//' -e '/^$/d' "${filename}")
# If the string contains "### CHANGED", highlight this part in red
if [[ "${new_line}" == *"### CHANGED"* ]]; then
new_line="${new_line//### CHANGED/${COL_RED}### CHANGED${COL_NC}}"
fi
while IFS= read -r line; do
# If the string contains "### CHANGED", highlight this part in red
log_write " ${line//### CHANGED/${COL_RED}### CHANGED${COL_NC}}"
((i++))
# if the limit of lines we want to see is exceeded do nothing
[[ -n ${limit} && $i -eq ${limit} ]] && break
done <<< "$processed"
# Finally, write this line to the log
log_write " ${new_line}"
fi
# Increment the iterator +1
i=$((i+1))
# but if the limit of lines we want to see is exceeded
if [[ -z ${limit} ]]; then
# do nothing
:
elif [[ $i -eq ${limit} ]]; then
break
fi
done < "${filename}"
fi
}
parse_file() {
@@ -896,38 +924,38 @@ list_files_in_dir() {
fi
# Store the files found in an array
local files_found=("${dir_to_parse}"/*)
mapfile -t files_found < <(ls "${dir_to_parse}")
# For each file in the array,
for each_file in "${files_found[@]}"; do
if [[ -d "${each_file}" ]]; then
if [[ -d "${dir_to_parse}/${each_file}" ]]; then
# If it's a directory, do nothing
:
elif [[ "${each_file}" == "${PIHOLE_DEBUG_LOG}" ]] || \
[[ "${each_file}" == "${PIHOLE_RAW_BLOCKLIST_FILES}" ]] || \
[[ "${each_file}" == "${PIHOLE_INSTALL_LOG_FILE}" ]] || \
[[ "${each_file}" == "${PIHOLE_LOG}" ]] || \
[[ "${each_file}" == "${PIHOLE_LOG_GZIPS}" ]]; then
elif [[ "${dir_to_parse}/${each_file}" == "${PIHOLE_DEBUG_LOG}" ]] || \
[[ "${dir_to_parse}/${each_file}" == "${PIHOLE_RAW_BLOCKLIST_FILES}" ]] || \
[[ "${dir_to_parse}/${each_file}" == "${PIHOLE_INSTALL_LOG_FILE}" ]] || \
[[ "${dir_to_parse}/${each_file}" == "${PIHOLE_LOG}" ]] || \
[[ "${dir_to_parse}/${each_file}" == "${PIHOLE_LOG_GZIPS}" ]]; then
:
elif [[ "${dir_to_parse}" == "${DNSMASQ_D_DIRECTORY}" ]]; then
# in case of the dnsmasq directory include all files in the debug output
log_write "\\n${COL_GREEN}$(ls -lhd "${each_file}")${COL_NC}"
make_array_from_file "${each_file}"
log_write "\\n${COL_GREEN}$(ls -lhd "${dir_to_parse}"/"${each_file}")${COL_NC}"
make_array_from_file "${dir_to_parse}/${each_file}"
else
# Then, parse the file's content into an array so each line can be analyzed if need be
for i in "${!REQUIRED_FILES[@]}"; do
if [[ "${each_file}" == "${REQUIRED_FILES[$i]}" ]]; then
if [[ "${dir_to_parse}/${each_file}" == "${REQUIRED_FILES[$i]}" ]]; then
# display the filename
log_write "\\n${COL_GREEN}$(ls -lhd "${each_file}")${COL_NC}"
log_write "\\n${COL_GREEN}$(ls -lhd "${dir_to_parse}"/"${each_file}")${COL_NC}"
# Check if the file we want to view has a limit (because sometimes we just need a little bit of info from the file, not the entire thing)
case "${each_file}" in
case "${dir_to_parse}/${each_file}" in
# If it's Web server log, give the first and last 25 lines
"${PIHOLE_WEBSERVER_LOG}") head_tail_log "${each_file}" 25
"${PIHOLE_WEBSERVER_LOG}") head_tail_log "${dir_to_parse}/${each_file}" 25
;;
# Same for the FTL log
"${PIHOLE_FTL_LOG}") head_tail_log "${each_file}" 35
"${PIHOLE_FTL_LOG}") head_tail_log "${dir_to_parse}/${each_file}" 35
;;
# parse the file into an array in case we ever need to analyze it line-by-line
*) make_array_from_file "${each_file}";
*) make_array_from_file "${dir_to_parse}/${each_file}";
esac
else
# Otherwise, do nothing since it's not a file needed for Pi-hole so we don't care about it
@@ -963,7 +991,6 @@ head_tail_log() {
local filename="${1}"
# The number of lines to use for head and tail
local qty="${2}"
local filebasename="${filename##*/}"
local head_line
local tail_line
# Put the current Internal Field Separator into another variable so it can be restored later
@@ -972,14 +999,14 @@ head_tail_log() {
IFS=$'\r\n'
local log_head=()
mapfile -t log_head < <(head -n "${qty}" "${filename}")
log_write " ${COL_CYAN}-----head of ${filebasename}------${COL_NC}"
log_write " ${COL_CYAN}-----head of $(basename "${filename}")------${COL_NC}"
for head_line in "${log_head[@]}"; do
log_write " ${head_line}"
done
log_write ""
local log_tail=()
mapfile -t log_tail < <(tail -n "${qty}" "${filename}")
log_write " ${COL_CYAN}-----tail of ${filebasename}------${COL_NC}"
log_write " ${COL_CYAN}-----tail of $(basename "${filename}")------${COL_NC}"
for tail_line in "${log_tail[@]}"; do
log_write " ${tail_line}"
done
@@ -1006,24 +1033,6 @@ show_db_entries() {
)
for line in "${entries[@]}"; do
# Use gray color for "no". Normal color for "yes"
line=${line//--no---/${COL_GRAY} no ${COL_NC}}
line=${line//--yes--/ yes }
# Use red for "deny" and green for "allow"
if [ "$title" = "Domainlist" ]; then
line=${line//regex-deny/${COL_RED}regex-deny${COL_NC}}
line=${line//regex-allow/${COL_GREEN}regex-allow${COL_NC}}
line=${line//exact-deny/${COL_RED}exact-deny${COL_NC}}
line=${line//exact-allow/${COL_GREEN}exact-allow${COL_NC}}
fi
# Use red for "block" and green for "allow"
if [ "$title" = "Adlists" ]; then
line=${line//-BLOCK-/${COL_RED} Block ${COL_NC}}
line=${line//-ALLOW-/${COL_GREEN} Allow ${COL_NC}}
fi
log_write " ${line}"
done
@@ -1071,15 +1080,15 @@ check_dhcp_servers() {
}
show_groups() {
show_db_entries "Groups" "SELECT id,CASE enabled WHEN '0' THEN '--no---' WHEN '1' THEN '--yes--' ELSE enabled END enabled,name,datetime(date_added,'unixepoch','localtime') date_added,datetime(date_modified,'unixepoch','localtime') date_modified,description FROM \"group\"" "4 7 50 19 19 50"
show_db_entries "Groups" "SELECT id,CASE enabled WHEN '0' THEN ' 0' WHEN '1' THEN ' 1' ELSE enabled END enabled,name,datetime(date_added,'unixepoch','localtime') date_added,datetime(date_modified,'unixepoch','localtime') date_modified,description FROM \"group\"" "4 7 50 19 19 50"
}
show_adlists() {
show_db_entries "Adlists" "SELECT id,CASE enabled WHEN '0' THEN '--no---' WHEN '1' THEN '--yes--' ELSE enabled END enabled,GROUP_CONCAT(adlist_by_group.group_id) group_ids, CASE type WHEN '0' THEN '-BLOCK-' WHEN '1' THEN '-ALLOW-' ELSE type END type, address,datetime(date_added,'unixepoch','localtime') date_added,datetime(date_modified,'unixepoch','localtime') date_modified,comment FROM adlist LEFT JOIN adlist_by_group ON adlist.id = adlist_by_group.adlist_id GROUP BY id;" "5 7 12 7 100 19 19 50"
show_db_entries "Adlists" "SELECT id,CASE enabled WHEN '0' THEN ' 0' WHEN '1' THEN ' 1' ELSE enabled END enabled,GROUP_CONCAT(adlist_by_group.group_id) group_ids,address,datetime(date_added,'unixepoch','localtime') date_added,datetime(date_modified,'unixepoch','localtime') date_modified,comment FROM adlist LEFT JOIN adlist_by_group ON adlist.id = adlist_by_group.adlist_id GROUP BY id;" "5 7 12 100 19 19 50"
}
show_domainlist() {
show_db_entries "Domainlist" "SELECT id,CASE type WHEN '0' THEN 'exact-allow' WHEN '1' THEN 'exact-deny' WHEN '2' THEN 'regex-allow' WHEN '3' THEN 'regex-deny' ELSE type END type,CASE enabled WHEN '0' THEN '--no---' WHEN '1' THEN '--yes--' ELSE enabled END enabled,GROUP_CONCAT(domainlist_by_group.group_id) group_ids,domain,datetime(date_added,'unixepoch','localtime') date_added,datetime(date_modified,'unixepoch','localtime') date_modified,comment FROM domainlist LEFT JOIN domainlist_by_group ON domainlist.id = domainlist_by_group.domainlist_id GROUP BY id;" "5 11 7 12 90 19 19 50"
show_db_entries "Domainlist (0/1 = exact allow-/denylist, 2/3 = regex allow-/denylist)" "SELECT id,CASE type WHEN '0' THEN '0 ' WHEN '1' THEN ' 1 ' WHEN '2' THEN ' 2 ' WHEN '3' THEN ' 3' ELSE type END type,CASE enabled WHEN '0' THEN ' 0' WHEN '1' THEN ' 1' ELSE enabled END enabled,GROUP_CONCAT(domainlist_by_group.group_id) group_ids,domain,datetime(date_added,'unixepoch','localtime') date_added,datetime(date_modified,'unixepoch','localtime') date_modified,comment FROM domainlist LEFT JOIN domainlist_by_group ON domainlist.id = domainlist_by_group.domainlist_id GROUP BY id;" "5 4 7 12 100 19 19 50"
}
show_clients() {

View File

@@ -15,7 +15,7 @@ if [[ -f ${coltable} ]]; then
source ${coltable}
fi
PI_HOLE_SCRIPT_DIR="/opt/pihole"
readonly PI_HOLE_SCRIPT_DIR="/opt/pihole"
utilsfile="${PI_HOLE_SCRIPT_DIR}/utils.sh"
# shellcheck source=./advanced/Scripts/utils.sh
source "${utilsfile}"

View File

@@ -8,20 +8,12 @@ utilsfile="${PI_HOLE_SCRIPT_DIR}/utils.sh"
# Get file paths
FTL_PID_FILE="$(getFTLConfigValue files.pid)"
FTL_LOG_FILE="$(getFTLConfigValue files.log.ftl)"
PIHOLE_LOG_FILE="$(getFTLConfigValue files.log.dnsmasq)"
WEBSERVER_LOG_FILE="$(getFTLConfigValue files.log.webserver)"
FTL_PID_FILE="${FTL_PID_FILE:-/run/pihole-FTL.pid}"
FTL_LOG_FILE="${FTL_LOG_FILE:-/var/log/pihole/FTL.log}"
PIHOLE_LOG_FILE="${PIHOLE_LOG_FILE:-/var/log/pihole/pihole.log}"
WEBSERVER_LOG_FILE="${WEBSERVER_LOG_FILE:-/var/log/pihole/webserver.log}"
# Ensure that permissions are set so that pihole-FTL can edit all necessary files
mkdir -p /var/log/pihole
chown -R pihole:pihole /etc/pihole/ /var/log/pihole/
# allow all users read version file (and use pihole -v)
touch /etc/pihole/versions
chmod 0644 /etc/pihole/versions
# allow pihole to access subdirs in /etc/pihole (sets execution bit on dirs)
@@ -36,7 +28,7 @@ chown root:root /etc/pihole/logrotate
# Touch files to ensure they exist (create if non-existing, preserve if existing)
[ -f "${FTL_PID_FILE}" ] || install -D -m 644 -o pihole -g pihole /dev/null "${FTL_PID_FILE}"
[ -f "${FTL_LOG_FILE}" ] || install -m 640 -o pihole -g pihole /dev/null "${FTL_LOG_FILE}"
[ -f "${PIHOLE_LOG_FILE}" ] || install -m 640 -o pihole -g pihole /dev/null "${PIHOLE_LOG_FILE}"
[ -f "${WEBSERVER_LOG_FILE}" ] || install -m 640 -o pihole -g pihole /dev/null "${WEBSERVER_LOG_FILE}"
[ -f /var/log/pihole/FTL.log ] || install -m 640 -o pihole -g pihole /dev/null /var/log/pihole/FTL.log
[ -f /var/log/pihole/pihole.log ] || install -m 640 -o pihole -g pihole /dev/null /var/log/pihole/pihole.log
[ -f /var/log/pihole/webserver.log ] || install -m 640 -o pihole -g pihole /dev/null /var/log/pihole/webserver.log
[ -f /etc/pihole/dhcp.leases ] || install -m 644 -o pihole -g pihole /dev/null /etc/pihole/dhcp.leases

View File

@@ -13,7 +13,7 @@ extra_started_commands="reload"
respawn_max=5
respawn_period=60
capabilities="^CAP_NET_BIND_SERVICE,^CAP_NET_RAW,^CAP_NET_ADMIN,^CAP_SYS_NICE,^CAP_IPC_LOCK,^CAP_CHOWN,^CAP_SYS_TIME"
capabilities="^CAP_NET_BIND_SERVICE,CAP_NET_RAW,CAP_NET_ADMIN,CAP_SYS_NICE,CAP_IPC_LOCK,CAP_CHOWN,CAP_SYS_TIME"
depend() {
want net

View File

@@ -17,15 +17,15 @@ StartLimitIntervalSec=60s
[Service]
User=pihole
PermissionsStartOnly=true
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_NET_ADMIN CAP_SYS_NICE CAP_IPC_LOCK CAP_CHOWN CAP_SYS_TIME
# Run prestart with elevated permissions
ExecStartPre=+/opt/pihole/pihole-FTL-prestart.sh
ExecStartPre=/opt/pihole/pihole-FTL-prestart.sh
ExecStart=/usr/bin/pihole-FTL -f
Restart=on-failure
RestartSec=5s
ExecReload=/bin/kill -HUP $MAINPID
ExecStopPost=+/opt/pihole/pihole-FTL-poststop.sh
ExecStopPost=/opt/pihole/pihole-FTL-poststop.sh
# Use graceful shutdown with a reasonable timeout
TimeoutStopSec=60s

View File

@@ -116,11 +116,11 @@ c=70
PIHOLE_META_PACKAGE_CONTROL_APT=$(
cat <<EOM
Package: pihole-meta
Version: 0.6
Version: 0.5
Maintainer: Pi-hole team <adblock@pi-hole.net>
Architecture: all
Description: Pi-hole dependency meta package
Depends: awk,bash-completion,binutils,ca-certificates,cron|cron-daemon,curl,dialog,bind9-dnsutils|dnsutils,dns-root-data,git,grep,iproute2,iputils-ping,jq,libcap2,libcap2-bin,lshw,procps,psmisc,sudo,unzip
Depends: awk,bash-completion,binutils,ca-certificates,cron|cron-daemon,curl,dialog,dnsutils,dns-root-data,git,grep,iproute2,iputils-ping,jq,libcap2,libcap2-bin,lshw,procps,psmisc,sudo,unzip
Section: contrib/metapackages
Priority: optional
EOM
@@ -155,7 +155,7 @@ EOM
)
# List of required packages on APK based systems
PIHOLE_META_VERSION_APK=0.2
PIHOLE_META_VERSION_APK=0.1
PIHOLE_META_DEPS_APK=(
bash
bash-completion
@@ -181,6 +181,7 @@ PIHOLE_META_DEPS_APK=(
sudo
tzdata
unzip
wget
)
######## Undocumented Flags. Shhh ########
@@ -693,11 +694,10 @@ chooseInterface() {
status="OFF"
done
# Disable check for double quote here as we are passing a string with spaces
# shellcheck disable=SC2086
PIHOLE_INTERFACE=$(dialog --no-shadow --keep-tite --output-fd 1 \
--cancel-label "Exit" --ok-label "Select" \
--radiolist "Choose An Interface (press space to toggle selection)" \
${r} ${c} "${interfaceCount}" ${interfacesList})
${r} ${c} "${interfaceCount}" "${interfacesList}")
result=$?
case ${result} in
@@ -1251,6 +1251,10 @@ install_manpage() {
# if not present, create man8 directory
install -d -m 755 /usr/local/share/man/man8
fi
if [[ ! -d "/usr/local/share/man/man5" ]]; then
# if not present, create man5 directory
install -d -m 755 /usr/local/share/man/man5
fi
# Testing complete, copy the files & update the man db
install -D -m 644 -T ${PI_HOLE_LOCAL_REPO}/manpages/pihole.8 /usr/local/share/man/man8/pihole.8
@@ -1803,12 +1807,6 @@ clone_or_reset_repos() {
# If the user wants to repair/update,
if [[ "${repair}" == true ]]; then
printf " %b Resetting local repos\\n" "${INFO}"
# import getFTLConfigValue from utils.sh
source "/opt/pihole/utils.sh"
# Use the configured Web repo location on repair/update
webInterfaceDir=$(getFTLConfigValue "webserver.paths.webroot")$(getFTLConfigValue "webserver.paths.webhome")
# Reset the Core repo
resetRepo ${PI_HOLE_LOCAL_REPO} ||
{

View File

@@ -611,7 +611,7 @@ compareLists() {
# Download specified URL and perform checks on HTTP status and file content
gravity_DownloadBlocklistFromUrl() {
local url="${1}" adlistID="${2}" saveLocation="${3}" compression="${4}" gravity_type="${5}" domain="${6}"
local listCurlBuffer str httpCode success="" ip customUpstreamResolver=""
local listCurlBuffer str curlJson httpCode curlErrorMsg="" curlExitCode="" success="" ip customUpstreamResolver=""
local file_path permissions ip_addr port blocked=false download=true
# modifiedOptions is an array to store all the options used to check if the adlist has been changed upstream
local modifiedOptions=()
@@ -750,14 +750,19 @@ gravity_DownloadBlocklistFromUrl() {
# Check for allowed protocols
if [[ $url != "http"* && $url != "https"* && $url != "file"* && $url != "ftp"* && $url != "ftps"* && $url != "sftp"* ]]; then
echo -e "${OVER} ${CROSS} ${str} Invalid protocol specified. Ignoring list."
echo -e " Ensure your URL starts with a valid protocol like http:// , https:// or file:// ."
echo -e "Ensure your URL starts with a valid protocol like http:// , https:// or file:// ."
download=false
fi
if [[ "${download}" == true ]]; then
httpCode=$(curl --connect-timeout ${curl_connect_timeout} -s -L ${compression:+${compression}} ${customUpstreamResolver:+${customUpstreamResolver}} "${modifiedOptions[@]}" -w "%{http_code}" "${url}" -o "${listCurlBuffer}" 2>/dev/null)
curlJson=$(curl --connect-timeout ${curl_connect_timeout} -s -L ${compression:+${compression}} ${customUpstreamResolver:+${customUpstreamResolver}} "${modifiedOptions[@]}" -w "%{json}" "${url}" -o "${listCurlBuffer}")
fi
# Retrieve the HTTP code, exit code and error message returned by curl command
httpCode=$(echo "${curlJson}" | jq '.http_code')
curlErrorMsg=$(echo "${curlJson}" | jq '.errormsg')
curlExitCode=$(echo "${curlJson}" | jq '.exitcode')
case $url in
# Did we "download" a local file?
"file"*)
@@ -780,7 +785,6 @@ gravity_DownloadBlocklistFromUrl() {
echo -e "${OVER} ${TICK} ${str} No changes detected"
success=true
;;
"000") echo -e "${OVER} ${CROSS} ${str} Connection Refused" ;;
"403") echo -e "${OVER} ${CROSS} ${str} Forbidden" ;;
"404") echo -e "${OVER} ${CROSS} ${str} Not found" ;;
"408") echo -e "${OVER} ${CROSS} ${str} Time-out" ;;
@@ -789,7 +793,7 @@ gravity_DownloadBlocklistFromUrl() {
"504") echo -e "${OVER} ${CROSS} ${str} Connection Timed Out (Gateway)" ;;
"521") echo -e "${OVER} ${CROSS} ${str} Web Server Is Down (Cloudflare)" ;;
"522") echo -e "${OVER} ${CROSS} ${str} Connection Timed Out (Cloudflare)" ;;
*) echo -e "${OVER} ${CROSS} ${str} ${url} (${httpCode})" ;;
*) echo -e "${OVER} ${CROSS} ${str} Failure (exit_code=${COL_RED}${curlExitCode}${COL_NC} Msg: ${COL_CYAN}${curlErrorMsg}${COL_NC})" ;;
esac
;;
esac
@@ -947,7 +951,7 @@ database_recovery() {
else
echo -e "${OVER} ${CROSS} ${str} - the following errors happened:"
while IFS= read -r line; do echo " - $line"; done <<<"$result"
echo -e " ${CROSS} Recovery failed. Try \"pihole -g -r recreate\" instead."
echo -e " ${CROSS} Recovery failed. Try \"pihole -r recreate\" instead."
exit 1
fi
echo ""
@@ -1130,7 +1134,7 @@ fi
if [[ "${forceDelete:-}" == true ]]; then
str="Deleting existing list cache"
echo -ne " ${INFO} ${str}..."
echo -ne "${INFO} ${str}..."
rm "${listsCacheDir}/list.*" 2>/dev/null || true
echo -e "${OVER} ${TICK} ${str}"

0
test/__init__.py Normal file
View File

View File

@@ -1,18 +0,0 @@
FROM alpine:3.23
ENV GITDIR=/etc/.pihole
ENV SCRIPTDIR=/opt/pihole
RUN sed -i 's/#\(.*\/community\)/\1/' /etc/apk/repositories
RUN apk --no-cache add bash coreutils curl git jq openrc shadow
RUN mkdir -p $GITDIR $SCRIPTDIR /etc/pihole
ADD . $GITDIR
RUN cp $GITDIR/advanced/Scripts/*.sh $GITDIR/gravity.sh $GITDIR/pihole $GITDIR/automated\ install/*.sh $GITDIR/advanced/Scripts/COL_TABLE $SCRIPTDIR/
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$SCRIPTDIR
RUN true && \
chmod +x $SCRIPTDIR/*
ENV SKIP_INSTALL=true
#sed '/# Start the installer/Q' /opt/pihole/basic-install.sh > /opt/pihole/stub_basic-install.sh && \

View File

@@ -1,17 +0,0 @@
FROM fedora:43
RUN dnf install -y git initscripts
ENV GITDIR=/etc/.pihole
ENV SCRIPTDIR=/opt/pihole
RUN mkdir -p $GITDIR $SCRIPTDIR /etc/pihole
ADD . $GITDIR
RUN cp $GITDIR/advanced/Scripts/*.sh $GITDIR/gravity.sh $GITDIR/pihole $GITDIR/automated\ install/*.sh $GITDIR/advanced/Scripts/COL_TABLE $SCRIPTDIR/
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$SCRIPTDIR
RUN true && \
chmod +x $SCRIPTDIR/*
ENV SKIP_INSTALL=true
#sed '/# Start the installer/Q' /opt/pihole/basic-install.sh > /opt/pihole/stub_basic-install.sh && \

219
test/conftest.py Normal file
View File

@@ -0,0 +1,219 @@
import pytest
import testinfra
import testinfra.backend.docker
import subprocess
from textwrap import dedent
IMAGE = "pytest_pihole:test_container"
tick_box = "[✓]"
cross_box = "[✗]"
info_box = "[i]"
# Monkeypatch sh to bash, if they ever support non hard code /bin/sh this can go away
# https://github.com/pytest-dev/pytest-testinfra/blob/master/testinfra/backend/docker.py
def run_bash(self, command, *args, **kwargs):
cmd = self.get_command(command, *args)
if self.user is not None:
out = self.run_local(
"docker exec -u %s %s /bin/bash -c %s", self.user, self.name, cmd
)
else:
out = self.run_local("docker exec %s /bin/bash -c %s", self.name, cmd)
out.command = self.encode(cmd)
return out
testinfra.backend.docker.DockerBackend.run = run_bash
@pytest.fixture
def host():
# run a container
docker_id = (
subprocess.check_output(["docker", "run", "-t", "-d", "--cap-add=ALL", IMAGE])
.decode()
.strip()
)
# return a testinfra connection to the container
docker_host = testinfra.get_host("docker://" + docker_id)
yield docker_host
# at the end of the test suite, destroy the container
subprocess.check_call(["docker", "rm", "-f", docker_id])
# Helper functions
def mock_command(script, args, container):
"""
Allows for setup of commands we don't really want to have to run for real
in unit tests
"""
full_script_path = "/usr/local/bin/{}".format(script)
mock_script = dedent(
r"""\
#!/bin/bash -e
echo "\$0 \$@" >> /var/log/{script}
case "\$1" in""".format(
script=script
)
)
for k, v in args.items():
case = dedent(
"""
{arg})
echo {res}
exit {retcode}
;;""".format(
arg=k, res=v[0], retcode=v[1]
)
)
mock_script += case
mock_script += dedent(
"""
esac"""
)
container.run(
"""
cat <<EOF> {script}\n{content}\nEOF
chmod +x {script}
rm -f /var/log/{scriptlog}""".format(
script=full_script_path, content=mock_script, scriptlog=script
)
)
def mock_command_passthrough(script, args, container):
"""
Per other mock_command* functions, allows intercepting of commands we don't want to run for real
in unit tests, however also allows only specific arguments to be mocked. Anything not defined will
be passed through to the actual command.
Example use-case: mocking `git pull` but still allowing `git clone` to work as intended
"""
orig_script_path = container.check_output("command -v {}".format(script))
full_script_path = "/usr/local/bin/{}".format(script)
mock_script = dedent(
r"""\
#!/bin/bash -e
echo "\$0 \$@" >> /var/log/{script}
case "\$1" in""".format(
script=script
)
)
for k, v in args.items():
case = dedent(
"""
{arg})
echo {res}
exit {retcode}
;;""".format(
arg=k, res=v[0], retcode=v[1]
)
)
mock_script += case
mock_script += dedent(
r"""
*)
{orig_script_path} "\$@"
;;""".format(
orig_script_path=orig_script_path
)
)
mock_script += dedent(
"""
esac"""
)
container.run(
"""
cat <<EOF> {script}\n{content}\nEOF
chmod +x {script}
rm -f /var/log/{scriptlog}""".format(
script=full_script_path, content=mock_script, scriptlog=script
)
)
def mock_command_run(script, args, container):
"""
Allows for setup of commands we don't really want to have to run for real
in unit tests
"""
full_script_path = "/usr/local/bin/{}".format(script)
mock_script = dedent(
r"""\
#!/bin/bash -e
echo "\$0 \$@" >> /var/log/{script}
case "\$1 \$2" in""".format(
script=script
)
)
for k, v in args.items():
case = dedent(
"""
\"{arg}\")
echo {res}
exit {retcode}
;;""".format(
arg=k, res=v[0], retcode=v[1]
)
)
mock_script += case
mock_script += dedent(
"""
esac"""
)
container.run(
"""
cat <<EOF> {script}\n{content}\nEOF
chmod +x {script}
rm -f /var/log/{scriptlog}""".format(
script=full_script_path, content=mock_script, scriptlog=script
)
)
def mock_command_2(script, args, container):
"""
Allows for setup of commands we don't really want to have to run for real
in unit tests
"""
full_script_path = "/usr/local/bin/{}".format(script)
mock_script = dedent(
r"""\
#!/bin/bash -e
echo "\$0 \$@" >> /var/log/{script}
case "\$1 \$2" in""".format(
script=script
)
)
for k, v in args.items():
case = dedent(
"""
\"{arg}\")
echo \"{res}\"
exit {retcode}
;;""".format(
arg=k, res=v[0], retcode=v[1]
)
)
mock_script += case
mock_script += dedent(
"""
esac"""
)
container.run(
"""
cat <<EOF> {script}\n{content}\nEOF
chmod +x {script}
rm -f /var/log/{scriptlog}""".format(
script=full_script_path, content=mock_script, scriptlog=script
)
)
def run_script(Pihole, script):
result = Pihole.run(script)
assert result.rc == 0
return result

View File

@@ -1,106 +0,0 @@
#!/usr/bin/env bash
# Mock command helpers for BATS tests.
#
# These are the BATS equivalents of the mock_command* functions in conftest.py.
# Each function writes a bash case-statement script to /usr/local/bin/<name>
# inside the container, allowing tests to intercept command invocations.
#
# Usage:
# mock_command CONTAINER SCRIPT ARG1 OUTPUT1 RC1 [ARG2 OUTPUT2 RC2 ...]
# mock_command_2 CONTAINER SCRIPT ARG1 OUTPUT1 RC1 [ARG2 OUTPUT2 RC2 ...]
# mock_command_passthrough CONTAINER SCRIPT ARG1 OUTPUT1 RC1 [...]
#
# mock_command: matches on $1 (first argument); unquoted case pattern
# mock_command_2: matches on "$1 $2" (first two args joined); quoted pattern
# mock_command_passthrough: like mock_command but falls through to real binary
#
# Use '*' as ARG for a catch-all case (only works in mock_command and
# mock_command_passthrough; in mock_command_2 it matches the literal string '*').
#
# Content is transferred to the container via base64 to avoid quoting issues.
_write_mock_to_container() {
local container="$1" script_name="$2" script_content="$3"
# base64 alphabet is [A-Za-z0-9+/=] — safe to single-quote in the shell
local encoded
encoded=$(printf '%s' "$script_content" | base64 | tr -d '\n')
docker exec "$container" bash -c \
"printf '%s' '${encoded}' | base64 -d > /usr/local/bin/${script_name} && chmod +x /usr/local/bin/${script_name} && rm -f /var/log/${script_name}"
}
# mock_command — matches on $1
mock_command() {
local container="$1" script_name="$2"
shift 2
local script
script='#!/bin/bash -e'$'\n'
script+="echo \"\$0 \$@\" >> /var/log/${script_name}"$'\n'
script+='case "$1" in'$'\n'
while (( $# >= 3 )); do
local arg="$1" output="$2" rc="$3"
shift 3
script+=" ${arg})"$'\n'
script+=" echo ${output}"$'\n'
script+=" exit ${rc}"$'\n'
script+=" ;;"$'\n'
done
script+='esac'$'\n'
_write_mock_to_container "$container" "$script_name" "$script"
}
# mock_command_2 — matches on "$1 $2" (quoted pattern, quoted echo output)
mock_command_2() {
local container="$1" script_name="$2"
shift 2
local script
script='#!/bin/bash -e'$'\n'
script+="echo \"\$0 \$@\" >> /var/log/${script_name}"$'\n'
script+='case "$1 $2" in'$'\n'
while (( $# >= 3 )); do
local arg="$1" output="$2" rc="$3"
shift 3
script+=" \"${arg}\")"$'\n'
script+=" echo \"${output}\""$'\n'
script+=" exit ${rc}"$'\n'
script+=" ;;"$'\n'
done
script+='esac'$'\n'
_write_mock_to_container "$container" "$script_name" "$script"
}
# mock_command_passthrough — matches on $1; falls through to real binary for
# unmatched arguments
mock_command_passthrough() {
local container="$1" script_name="$2"
shift 2
# Find the real binary path before we shadow it
local orig_path
orig_path=$(docker exec "$container" bash -c "command -v ${script_name}")
local script
script='#!/bin/bash -e'$'\n'
script+="echo \"\$0 \$@\" >> /var/log/${script_name}"$'\n'
script+='case "$1" in'$'\n'
while (( $# >= 3 )); do
local arg="$1" output="$2" rc="$3"
shift 3
script+=" ${arg})"$'\n'
script+=" echo ${output}"$'\n'
script+=" exit ${rc}"$'\n'
script+=" ;;"$'\n'
done
script+=' *)'$'\n'
script+=" ${orig_path} \"\$@\""$'\n'
script+=' ;;'$'\n'
script+='esac'$'\n'
_write_mock_to_container "$container" "$script_name" "$script"
}

6
test/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
pyyaml == 6.0.3
pytest == 8.4.2
pytest-xdist == 3.8.0
pytest-testinfra == 10.2.2
tox == 4.31.0
pytest-clarity == 1.0.1

View File

@@ -1,91 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ---------------------------------------------------------------------------
# Distro selection
# ---------------------------------------------------------------------------
if [[ -z "${DISTRO:-}" ]]; then
echo "Error: DISTRO is required."
echo "Example: DISTRO=debian_12 bash test/run.sh"
echo ""
echo "Available distros:"
ls _*.Dockerfile | sed 's/^_//;s/\.Dockerfile$//' | sort
exit 1
fi
DOCKERFILE="_${DISTRO}.Dockerfile"
if [[ ! -f "$DOCKERFILE" ]]; then
echo "Error: Dockerfile not found: $DOCKERFILE"
exit 1
fi
# Determine distro family to select which test files to run.
# rhel: CentOS/Fedora — includes SELinux tests
# alpine: Alpine Linux
# debian: Debian/Ubuntu (default)
distro_family() {
case "$1" in
centos_* | fedora_*) echo "rhel" ;;
alpine_*) echo "alpine" ;;
*) echo "debian" ;;
esac
}
DISTRO_FAMILY=$(distro_family "$DISTRO")
# ---------------------------------------------------------------------------
# Build the test image
# ---------------------------------------------------------------------------
IMAGE_TAG="pihole_test:${DISTRO}"
docker buildx build \
--load \
--progress plain \
-f "$DOCKERFILE" \
-t "$IMAGE_TAG" \
../
# ---------------------------------------------------------------------------
# Install BATS and helper libraries (on-demand, not committed)
# ---------------------------------------------------------------------------
mkdir -p libs
if [[ ! -d libs/bats ]]; then
echo "Cloning bats-core..."
git clone --depth=1 --quiet https://github.com/bats-core/bats-core libs/bats
fi
if [[ ! -d libs/bats-support ]]; then
echo "Cloning bats-support..."
git clone --depth=1 --quiet https://github.com/bats-core/bats-support libs/bats-support
fi
if [[ ! -d libs/bats-assert ]]; then
echo "Cloning bats-assert..."
git clone --depth=1 --quiet https://github.com/bats-core/bats-assert libs/bats-assert
fi
BATS="${BATS:-libs/bats/bin/bats}"
# ---------------------------------------------------------------------------
# Run tests
# ---------------------------------------------------------------------------
export IMAGE_TAG DISTRO DISTRO_FAMILY
TEST_FILES=(
test_automated_install.bats
test_ftl.bats
test_network.bats
test_utils.bats
)
[[ "$DISTRO_FAMILY" == "rhel" ]] && TEST_FILES+=(test_selinux.bats)
# Use pretty output only when stdout is a real terminal; fall back to TAP in CI.
# Parallelise across files with --jobs when GNU parallel is available.
BATS_FLAGS=()
[[ -t 1 ]] && BATS_FLAGS+=("-p")
command -v parallel > /dev/null 2>&1 && BATS_FLAGS+=("--jobs" "$(nproc)")
"$BATS" "${BATS_FLAGS[@]}" "${TEST_FILES[@]}"

7
test/setup.py Normal file
View File

@@ -0,0 +1,7 @@
from setuptools import setup
setup(
py_modules=[],
setup_requires=["pytest-runner"],
tests_require=["pytest"],
)

View File

@@ -0,0 +1,516 @@
import pytest
from textwrap import dedent
import re
from .conftest import (
tick_box,
info_box,
cross_box,
mock_command,
mock_command_run,
mock_command_2,
mock_command_passthrough,
run_script,
)
FTL_BRANCH = "development"
def test_supported_package_manager(host):
"""
confirm installer exits when no supported package manager found
"""
# break supported package managers
host.run("rm -rf /usr/bin/apt-get")
host.run("rm -rf /usr/bin/rpm")
host.run("rm -rf /sbin/apk")
package_manager_detect = host.run(
"""
source /opt/pihole/basic-install.sh
package_manager_detect
"""
)
expected_stdout = cross_box + " No supported package manager found"
assert expected_stdout in package_manager_detect.stdout
# assert package_manager_detect.rc == 1
def test_selinux_not_detected(host):
"""
confirms installer continues when SELinux configuration file does not exist
"""
check_selinux = host.run(
"""
rm -f /etc/selinux/config
source /opt/pihole/basic-install.sh
checkSelinux
"""
)
expected_stdout = info_box + " SELinux not detected"
assert expected_stdout in check_selinux.stdout
assert check_selinux.rc == 0
def get_directories_recursive(host, directory):
if directory is None:
return directory
# returns all non-hidden subdirs of 'directory'
dirs_raw = host.run("find {} -type d -not -path '*/.*'".format(directory))
dirs = list(filter(bool, dirs_raw.stdout.splitlines()))
return dirs
def test_installPihole_fresh_install_readableFiles(host):
"""
confirms all necessary files are readable by pihole user
"""
# dialog returns Cancel for user prompt
mock_command("dialog", {"*": ("", "0")}, host)
# mock git pull
mock_command_passthrough("git", {"pull": ("", "0")}, host)
# mock systemctl to not start FTL
mock_command_2(
"systemctl",
{
"enable pihole-FTL": ("", "0"),
"restart pihole-FTL": ("", "0"),
"start pihole-FTL": ("", "0"),
"*": ('echo "systemctl call with $@"', "0"),
},
host,
)
mock_command_2(
"rc-service",
{
"rc-service pihole-FTL enable": ("", "0"),
"rc-service pihole-FTL restart": ("", "0"),
"rc-service pihole-FTL start": ("", "0"),
"*": ('echo "rc-service call with $@"', "0"),
},
host,
)
# try to install man
host.run("command -v apt-get > /dev/null && apt-get install -qq man")
host.run("command -v dnf > /dev/null && dnf install -y man")
host.run("command -v yum > /dev/null && yum install -y man")
host.run("command -v apk > /dev/null && apk add mandoc man-pages")
# Workaround to get FTLv6 installed until it reaches master branch
host.run('echo "' + FTL_BRANCH + '" > /etc/pihole/ftlbranch')
install = host.run(
"""
export TERM=xterm
export DEBIAN_FRONTEND=noninteractive
umask 0027
runUnattended=true
source /opt/pihole/basic-install.sh > /dev/null
runUnattended=true
main
/opt/pihole/pihole-FTL-prestart.sh
"""
)
assert 0 == install.rc
maninstalled = True
if (info_box + " man not installed") in install.stdout:
maninstalled = False
if (info_box + " man pages not installed") in install.stdout:
maninstalled = False
piholeuser = "pihole"
exit_status_success = 0
test_cmd = 'su -s /bin/bash -c "test -{0} {1}" -p {2}'
# check files in /etc/pihole for read, write and execute permission
check_etc = test_cmd.format("r", "/etc/pihole", piholeuser)
actual_rc = host.run(check_etc).rc
assert exit_status_success == actual_rc
check_etc = test_cmd.format("x", "/etc/pihole", piholeuser)
actual_rc = host.run(check_etc).rc
assert exit_status_success == actual_rc
# readable and writable dhcp.leases
check_leases = test_cmd.format("r", "/etc/pihole/dhcp.leases", piholeuser)
actual_rc = host.run(check_leases).rc
assert exit_status_success == actual_rc
check_leases = test_cmd.format("w", "/etc/pihole/dhcp.leases", piholeuser)
actual_rc = host.run(check_leases).rc
# readable install.log
check_install = test_cmd.format("r", "/etc/pihole/install.log", piholeuser)
actual_rc = host.run(check_install).rc
assert exit_status_success == actual_rc
# readable versions
check_localversion = test_cmd.format("r", "/etc/pihole/versions", piholeuser)
actual_rc = host.run(check_localversion).rc
assert exit_status_success == actual_rc
# readable macvendor.db
check_macvendor = test_cmd.format("r", "/etc/pihole/macvendor.db", piholeuser)
actual_rc = host.run(check_macvendor).rc
assert exit_status_success == actual_rc
# check readable and executable /etc/init.d/pihole-FTL
check_init = test_cmd.format("x", "/etc/init.d/pihole-FTL", piholeuser)
actual_rc = host.run(check_init).rc
assert exit_status_success == actual_rc
check_init = test_cmd.format("r", "/etc/init.d/pihole-FTL", piholeuser)
actual_rc = host.run(check_init).rc
assert exit_status_success == actual_rc
# check readable and executable manpages
if maninstalled is True:
check_man = test_cmd.format("x", "/usr/local/share/man", piholeuser)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
check_man = test_cmd.format("r", "/usr/local/share/man", piholeuser)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
check_man = test_cmd.format("x", "/usr/local/share/man/man8", piholeuser)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
check_man = test_cmd.format("r", "/usr/local/share/man/man8", piholeuser)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
check_man = test_cmd.format("x", "/usr/local/share/man/man5", piholeuser)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
check_man = test_cmd.format("r", "/usr/local/share/man/man5", piholeuser)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
check_man = test_cmd.format(
"r", "/usr/local/share/man/man8/pihole.8", piholeuser
)
actual_rc = host.run(check_man).rc
assert exit_status_success == actual_rc
# check not readable cron file
check_sudo = test_cmd.format("x", "/etc/cron.d/", piholeuser)
actual_rc = host.run(check_sudo).rc
assert exit_status_success == actual_rc
check_sudo = test_cmd.format("r", "/etc/cron.d/", piholeuser)
actual_rc = host.run(check_sudo).rc
assert exit_status_success == actual_rc
check_sudo = test_cmd.format("r", "/etc/cron.d/pihole", piholeuser)
actual_rc = host.run(check_sudo).rc
assert exit_status_success == actual_rc
directories = get_directories_recursive(host, "/etc/.pihole/")
for directory in directories:
check_pihole = test_cmd.format("r", directory, piholeuser)
actual_rc = host.run(check_pihole).rc
check_pihole = test_cmd.format("x", directory, piholeuser)
actual_rc = host.run(check_pihole).rc
findfiles = 'find "{}" -maxdepth 1 -type f -exec echo {{}} \\;;'
filelist = host.run(findfiles.format(directory))
files = list(filter(bool, filelist.stdout.splitlines()))
for file in files:
check_pihole = test_cmd.format("r", file, piholeuser)
actual_rc = host.run(check_pihole).rc
def test_update_package_cache_success_no_errors(host):
"""
confirms package cache was updated without any errors
"""
updateCache = host.run(
"""
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
"""
)
expected_stdout = tick_box + " Update local cache of available packages"
assert expected_stdout in updateCache.stdout
assert "error" not in updateCache.stdout.lower()
def test_update_package_cache_failure_no_errors(host):
"""
confirms package cache was not updated
"""
mock_command("apt-get", {"update": ("", "1")}, host)
updateCache = host.run(
"""
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
"""
)
expected_stdout = cross_box + " Update local cache of available packages"
assert expected_stdout in updateCache.stdout
assert "Error: Unable to update package cache." in updateCache.stdout
@pytest.mark.parametrize(
"arch,detected_string,supported",
[
("aarch64", "AArch64 (64 Bit ARM)", True),
("armv6", "ARMv6", True),
("armv7l", "ARMv7 (or newer)", True),
("armv7", "ARMv7 (or newer)", True),
("armv8a", "ARMv7 (or newer)", True),
("x86_64", "x86_64", True),
("riscv64", "riscv64", True),
("mips", "mips", False),
],
)
def test_FTL_detect_no_errors(host, arch, detected_string, supported):
"""
confirms only correct package is downloaded for FTL engine
"""
# mock uname to return passed platform
mock_command("uname", {"-m": (arch, "0")}, host)
# mock readelf to respond with passed CPU architecture
mock_command_2(
"readelf",
{
"-A /bin/sh": ("Tag_CPU_arch: " + arch, "0"),
"-A /usr/bin/sh": ("Tag_CPU_arch: " + arch, "0"),
"-A /usr/sbin/sh": ("Tag_CPU_arch: " + arch, "0"),
},
host,
)
host.run('echo "' + FTL_BRANCH + '" > /etc/pihole/ftlbranch')
detectPlatform = host.run(
"""
source /opt/pihole/basic-install.sh
create_pihole_user
funcOutput=$(get_binary_name)
binary="pihole-FTL${funcOutput##*pihole-FTL}"
theRest="${funcOutput%pihole-FTL*}"
FTLdetect "${binary}" "${theRest}"
"""
)
if supported:
expected_stdout = info_box + " FTL Checks..."
assert expected_stdout in detectPlatform.stdout
expected_stdout = tick_box + " Detected " + detected_string + " architecture"
assert expected_stdout in detectPlatform.stdout
expected_stdout = tick_box + " Downloading and Installing FTL"
assert expected_stdout in detectPlatform.stdout
else:
expected_stdout = (
"Not able to detect architecture (unknown: " + detected_string + ")"
)
assert expected_stdout in detectPlatform.stdout
def test_FTL_development_binary_installed_and_responsive_no_errors(host):
"""
confirms FTL development binary is copied and functional in installed location
"""
host.run('echo "' + FTL_BRANCH + '" > /etc/pihole/ftlbranch')
host.run(
"""
source /opt/pihole/basic-install.sh
create_pihole_user
funcOutput=$(get_binary_name)
binary="pihole-FTL${funcOutput##*pihole-FTL}"
theRest="${funcOutput%pihole-FTL*}"
FTLdetect "${binary}" "${theRest}"
"""
)
version_check = host.run(
"""
VERSION=$(pihole-FTL version)
echo ${VERSION:0:1}
"""
)
expected_stdout = "v"
assert expected_stdout in version_check.stdout
def test_IPv6_only_link_local(host):
"""
confirms IPv6 blocking is disabled for Link-local address
"""
# mock ip -6 address to return Link-local address
mock_command_2(
"ip",
{"-6 address": ("inet6 fe80::d210:52fa:fe00:7ad7/64 scope link", "0")},
host,
)
detectPlatform = host.run(
"""
source /opt/pihole/basic-install.sh
find_IPv6_information
"""
)
expected_stdout = "Unable to find IPv6 ULA/GUA address"
assert expected_stdout in detectPlatform.stdout
def test_IPv6_only_ULA(host):
"""
confirms IPv6 blocking is enabled for ULA addresses
"""
# mock ip -6 address to return ULA address
mock_command_2(
"ip",
{
"-6 address": (
"inet6 fda2:2001:5555:0:d210:52fa:fe00:7ad7/64 scope global",
"0",
)
},
host,
)
detectPlatform = host.run(
"""
source /opt/pihole/basic-install.sh
find_IPv6_information
"""
)
expected_stdout = "Found IPv6 ULA address"
assert expected_stdout in detectPlatform.stdout
def test_IPv6_only_GUA(host):
"""
confirms IPv6 blocking is enabled for GUA addresses
"""
# mock ip -6 address to return GUA address
mock_command_2(
"ip",
{
"-6 address": (
"inet6 2003:12:1e43:301:d210:52fa:fe00:7ad7/64 scope global",
"0",
)
},
host,
)
detectPlatform = host.run(
"""
source /opt/pihole/basic-install.sh
find_IPv6_information
"""
)
expected_stdout = "Found IPv6 GUA address"
assert expected_stdout in detectPlatform.stdout
def test_IPv6_GUA_ULA_test(host):
"""
confirms IPv6 blocking is enabled for GUA and ULA addresses
"""
# mock ip -6 address to return GUA and ULA addresses
mock_command_2(
"ip",
{
"-6 address": (
"inet6 2003:12:1e43:301:d210:52fa:fe00:7ad7/64 scope global\n"
"inet6 fda2:2001:5555:0:d210:52fa:fe00:7ad7/64 scope global",
"0",
)
},
host,
)
detectPlatform = host.run(
"""
source /opt/pihole/basic-install.sh
find_IPv6_information
"""
)
expected_stdout = "Found IPv6 ULA address"
assert expected_stdout in detectPlatform.stdout
def test_IPv6_ULA_GUA_test(host):
"""
confirms IPv6 blocking is enabled for GUA and ULA addresses
"""
# mock ip -6 address to return ULA and GUA addresses
mock_command_2(
"ip",
{
"-6 address": (
"inet6 fda2:2001:5555:0:d210:52fa:fe00:7ad7/64 scope global\n"
"inet6 2003:12:1e43:301:d210:52fa:fe00:7ad7/64 scope global",
"0",
)
},
host,
)
detectPlatform = host.run(
"""
source /opt/pihole/basic-install.sh
find_IPv6_information
"""
)
expected_stdout = "Found IPv6 ULA address"
assert expected_stdout in detectPlatform.stdout
def test_validate_ip(host):
"""
Tests valid_ip for various IP addresses
"""
def test_address(addr, success=True):
output = host.run(
"""
source /opt/pihole/basic-install.sh
valid_ip "{addr}"
""".format(
addr=addr
)
)
assert output.rc == 0 if success else 1
test_address("192.168.1.1")
test_address("127.0.0.1")
test_address("255.255.255.255")
test_address("255.255.255.256", False)
test_address("255.255.256.255", False)
test_address("255.256.255.255", False)
test_address("256.255.255.255", False)
test_address("1092.168.1.1", False)
test_address("not an IP", False)
test_address("8.8.8.8#", False)
test_address("8.8.8.8#0")
test_address("8.8.8.8#1")
test_address("8.8.8.8#42")
test_address("8.8.8.8#888")
test_address("8.8.8.8#1337")
test_address("8.8.8.8#65535")
test_address("8.8.8.8#65536", False)
test_address("8.8.8.8#-1", False)
test_address("00.0.0.0", False)
test_address("010.0.0.0", False)
test_address("001.0.0.0", False)
test_address("0.0.0.0#00", False)
test_address("0.0.0.0#01", False)
test_address("0.0.0.0#001", False)
test_address("0.0.0.0#0001", False)
test_address("0.0.0.0#00001", False)
def test_package_manager_has_pihole_deps(host):
"""Confirms OS is able to install the required packages for Pi-hole"""
mock_command("dialog", {"*": ("", "0")}, host)
output = host.run(
"""
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
build_dependency_package
install_dependent_packages
"""
)
assert "No package" not in output.stdout
assert output.rc == 0
def test_meta_package_uninstall(host):
"""Confirms OS is able to install and uninstall the Pi-hole meta package"""
mock_command("dialog", {"*": ("", "0")}, host)
install = host.run(
"""
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
build_dependency_package
install_dependent_packages
"""
)
assert install.rc == 0
uninstall = host.run(
"""
source /opt/pihole/uninstall.sh
removeMetaPackage
"""
)
assert uninstall.rc == 0

59
test/test_any_utils.py Normal file
View File

@@ -0,0 +1,59 @@
def test_key_val_replacement_works(host):
"""Confirms addOrEditKeyValPair either adds or replaces a key value pair in a given file"""
host.run(
"""
source /opt/pihole/utils.sh
addOrEditKeyValPair "./testoutput" "KEY_ONE" "value1"
addOrEditKeyValPair "./testoutput" "KEY_TWO" "value2"
addOrEditKeyValPair "./testoutput" "KEY_ONE" "value3"
addOrEditKeyValPair "./testoutput" "KEY_FOUR" "value4"
"""
)
output = host.run(
"""
cat ./testoutput
"""
)
expected_stdout = "KEY_ONE=value3\nKEY_TWO=value2\nKEY_FOUR=value4\n"
assert expected_stdout == output.stdout
def test_getFTLPID_default(host):
"""Confirms getFTLPID returns the default value if FTL is not running"""
output = host.run(
"""
source /opt/pihole/utils.sh
getFTLPID
"""
)
expected_stdout = "-1\n"
assert expected_stdout == output.stdout
def test_setFTLConfigValue_getFTLConfigValue(host):
"""
Confirms getFTLConfigValue works (also assumes setFTLConfigValue works)
Requires FTL to be installed, so we do that first
(taken from test_FTL_development_binary_installed_and_responsive_no_errors)
"""
host.run(
"""
source /opt/pihole/basic-install.sh
create_pihole_user
funcOutput=$(get_binary_name)
echo "development" > /etc/pihole/ftlbranch
binary="pihole-FTL${funcOutput##*pihole-FTL}"
theRest="${funcOutput%pihole-FTL*}"
FTLdetect "${binary}" "${theRest}"
"""
)
output = host.run(
"""
source /opt/pihole/utils.sh
setFTLConfigValue "dns.upstreams" '["9.9.9.9"]' > /dev/null
getFTLConfigValue "dns.upstreams"
"""
)
assert "[ 9.9.9.9 ]" in output.stdout

View File

@@ -1,188 +0,0 @@
#!/usr/bin/env bats
# Core installer tests — package manager, cache, fresh install, dependencies
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers/mocks'
TICK="[✓]"
CROSS="[✗]"
INFO="[i]"
FTL_BRANCH="development"
CID=""
setup() {
CID=$(docker run -d -t --cap-add=ALL "$IMAGE_TAG")
}
teardown() {
if [[ -n "$CID" ]]; then
docker rm -f "$CID" > /dev/null 2>&1 || true
fi
}
# ---------------------------------------------------------------------------
@test "installer exits when no supported package manager found" {
docker exec "$CID" bash -c "rm -rf /usr/bin/apt-get /usr/bin/rpm /sbin/apk"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
package_manager_detect
"
assert_output --partial "${CROSS} No supported package manager found"
}
@test "installer continues when SELinux config file does not exist" {
run docker exec "$CID" bash -c "
rm -f /etc/selinux/config
source /opt/pihole/basic-install.sh
checkSelinux
"
assert_output --partial "${INFO} SELinux not detected"
assert_success
}
@test "fresh install: all necessary files are readable by pihole user" {
mock_command "$CID" dialog "*" "" "0"
mock_command_passthrough "$CID" git "pull" "" "0"
mock_command_2 "$CID" systemctl \
"enable pihole-FTL" "" "0" \
"restart pihole-FTL" "" "0" \
"start pihole-FTL" "" "0"
mock_command_2 "$CID" rc-service \
"pihole-FTL enable" "" "0" \
"pihole-FTL restart" "" "0" \
"pihole-FTL start" "" "0"
# Install man pages if available (best-effort)
docker exec "$CID" bash -c "command -v apt-get > /dev/null && apt-get install -qq man" || true
docker exec "$CID" bash -c "command -v dnf > /dev/null && dnf install -y man" || true
docker exec "$CID" bash -c "command -v yum > /dev/null && yum install -y man" || true
docker exec "$CID" bash -c "command -v apk > /dev/null && apk add mandoc man-pages" || true
docker exec "$CID" bash -c "echo '${FTL_BRANCH}' > /etc/pihole/ftlbranch"
run docker exec "$CID" bash -c "
export TERM=xterm
export DEBIAN_FRONTEND=noninteractive
umask 0027
runUnattended=true
source /opt/pihole/basic-install.sh > /dev/null
runUnattended=true
main
/opt/pihole/pihole-FTL-prestart.sh
"
assert_success
# Detect whether man was installed
local maninstalled=true
if [[ "$output" == *"${INFO} man not installed"* ]] || [[ "$output" == *"${INFO} man pages not installed"* ]]; then
maninstalled=false
fi
local piholeuser="pihole"
_check_perm() { docker exec "$CID" bash -c "su -s /bin/bash -c 'test -${1} ${2}' -p ${piholeuser}"; }
# /etc/pihole
run _check_perm r /etc/pihole; assert_success
run _check_perm x /etc/pihole; assert_success
# /etc/pihole/dhcp.leases
run _check_perm r /etc/pihole/dhcp.leases; assert_success
# /etc/pihole/install.log
run _check_perm r /etc/pihole/install.log; assert_success
# /etc/pihole/versions
run _check_perm r /etc/pihole/versions; assert_success
# /etc/pihole/macvendor.db
run _check_perm r /etc/pihole/macvendor.db; assert_success
# /etc/init.d/pihole-FTL
run _check_perm x /etc/init.d/pihole-FTL; assert_success
run _check_perm r /etc/init.d/pihole-FTL; assert_success
# man pages (if installed)
if [[ "$maninstalled" == "true" ]]; then
run _check_perm x /usr/local/share/man; assert_success
run _check_perm r /usr/local/share/man; assert_success
run _check_perm x /usr/local/share/man/man8; assert_success
run _check_perm r /usr/local/share/man/man8; assert_success
run _check_perm r /usr/local/share/man/man8/pihole.8; assert_success
fi
# /etc/cron.d
run _check_perm x /etc/cron.d/; assert_success
run _check_perm r /etc/cron.d/; assert_success
run _check_perm r /etc/cron.d/pihole; assert_success
# All files and directories under /etc/.pihole/
local dirs
dirs=$(docker exec "$CID" bash -c "find /etc/.pihole/ -type d -not -path '*/.*'" 2>/dev/null || true)
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
run _check_perm r "$dir"; assert_success
run _check_perm x "$dir"; assert_success
local files
files=$(docker exec "$CID" bash -c "find '${dir}' -maxdepth 1 -type f -exec echo {} \\;" 2>/dev/null || true)
while IFS= read -r file; do
[[ -z "$file" ]] && continue
run _check_perm r "$file"; assert_success
done <<< "$files"
done <<< "$dirs"
}
@test "package cache update succeeds without errors" {
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
"
assert_output --partial "${TICK} Update local cache of available packages"
refute_output --partial "error"
}
@test "package cache update reports failure correctly" {
mock_command "$CID" apt-get "update" "" "1"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
"
assert_output --partial "${CROSS} Update local cache of available packages"
assert_output --partial "Error: Unable to update package cache."
}
@test "OS can install required Pi-hole dependency packages" {
mock_command "$CID" dialog "*" "" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
build_dependency_package
install_dependent_packages
"
refute_output --partial "No package"
assert_success
}
@test "OS can install and uninstall the Pi-hole meta package" {
mock_command "$CID" dialog "*" "" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
package_manager_detect
update_package_cache
build_dependency_package
install_dependent_packages
"
assert_success
run docker exec "$CID" bash -c "
source /opt/pihole/uninstall.sh
removeMetaPackage
"
assert_success
}

View File

@@ -0,0 +1,75 @@
from .conftest import (
tick_box,
cross_box,
mock_command,
)
def mock_selinux_config(state, host):
"""
Creates a mock SELinux config file with expected content
"""
# validate state string
valid_states = ["enforcing", "permissive", "disabled"]
assert state in valid_states
# getenforce returns the running state of SELinux
mock_command("getenforce", {"*": (state.capitalize(), "0")}, host)
# create mock configuration with desired content
host.run(
"""
mkdir /etc/selinux
echo "SELINUX={state}" > /etc/selinux/config
""".format(
state=state.lower()
)
)
def test_selinux_enforcing_exit(host):
"""
confirms installer prompts to exit when SELinux is Enforcing by default
"""
mock_selinux_config("enforcing", host)
check_selinux = host.run(
"""
source /opt/pihole/basic-install.sh
checkSelinux
"""
)
expected_stdout = cross_box + " Current SELinux: enforcing"
assert expected_stdout in check_selinux.stdout
expected_stdout = "SELinux Enforcing detected, exiting installer"
assert expected_stdout in check_selinux.stdout
assert check_selinux.rc == 1
def test_selinux_permissive(host):
"""
confirms installer continues when SELinux is Permissive
"""
mock_selinux_config("permissive", host)
check_selinux = host.run(
"""
source /opt/pihole/basic-install.sh
checkSelinux
"""
)
expected_stdout = tick_box + " Current SELinux: permissive"
assert expected_stdout in check_selinux.stdout
assert check_selinux.rc == 0
def test_selinux_disabled(host):
"""
confirms installer continues when SELinux is Disabled
"""
mock_selinux_config("disabled", host)
check_selinux = host.run(
"""
source /opt/pihole/basic-install.sh
checkSelinux
"""
)
expected_stdout = tick_box + " Current SELinux: disabled"
assert expected_stdout in check_selinux.stdout
assert check_selinux.rc == 0

View File

@@ -1,104 +0,0 @@
#!/usr/bin/env bats
# FTL architecture detection and binary installation tests
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers/mocks'
TICK="[✓]"
INFO="[i]"
FTL_BRANCH="development"
CID=""
setup() {
CID=$(docker run -d -t --cap-add=ALL "$IMAGE_TAG")
}
teardown() {
if [[ -n "$CID" ]]; then
docker rm -f "$CID" > /dev/null 2>&1 || true
fi
}
# ---------------------------------------------------------------------------
# FTL architecture detection — one @test per arch (replaces parametrize)
# ---------------------------------------------------------------------------
_test_ftl_arch() {
local arch="$1" detected_string="$2" supported="$3"
mock_command "$CID" uname "-m" "$arch" "0"
mock_command_2 "$CID" readelf \
"-A /bin/sh" "Tag_CPU_arch: ${arch}" "0" \
"-A /usr/bin/sh" "Tag_CPU_arch: ${arch}" "0" \
"-A /usr/sbin/sh" "Tag_CPU_arch: ${arch}" "0"
docker exec "$CID" bash -c "echo '${FTL_BRANCH}' > /etc/pihole/ftlbranch"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
create_pihole_user
funcOutput=\$(get_binary_name)
binary=\"pihole-FTL\${funcOutput##*pihole-FTL}\"
theRest=\"\${funcOutput%pihole-FTL*}\"
FTLdetect \"\${binary}\" \"\${theRest}\"
"
if [[ "$supported" == "true" ]]; then
assert_output --partial "${INFO} FTL Checks..."
assert_output --partial "${TICK} Detected ${detected_string} architecture"
assert_output --partial "${TICK} Downloading and Installing FTL"
else
assert_output --partial "Not able to detect architecture (unknown: ${detected_string})"
fi
}
@test "FTL detects aarch64 architecture" {
_test_ftl_arch "aarch64" "AArch64 (64 Bit ARM)" "true"
}
@test "FTL detects ARMv6 architecture" {
_test_ftl_arch "armv6" "ARMv6" "true"
}
@test "FTL detects ARMv7l architecture" {
_test_ftl_arch "armv7l" "ARMv7 (or newer)" "true"
}
@test "FTL detects ARMv7 architecture" {
_test_ftl_arch "armv7" "ARMv7 (or newer)" "true"
}
@test "FTL detects ARMv8a architecture" {
_test_ftl_arch "armv8a" "ARMv7 (or newer)" "true"
}
@test "FTL detects x86_64 architecture" {
_test_ftl_arch "x86_64" "x86_64" "true"
}
@test "FTL detects riscv64 architecture" {
_test_ftl_arch "riscv64" "riscv64" "true"
}
@test "FTL reports unsupported architecture" {
_test_ftl_arch "mips" "mips" "false"
}
@test "FTL development binary is installed and responsive" {
docker exec "$CID" bash -c "echo '${FTL_BRANCH}' > /etc/pihole/ftlbranch"
docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
create_pihole_user
funcOutput=\$(get_binary_name)
binary=\"pihole-FTL\${funcOutput##*pihole-FTL}\"
theRest=\"\${funcOutput%pihole-FTL*}\"
FTLdetect \"\${binary}\" \"\${theRest}\"
"
run docker exec "$CID" bash -c '
VERSION=$(pihole-FTL version)
echo "${VERSION:0:1}"
'
assert_output --partial "v"
}

View File

@@ -1,116 +0,0 @@
#!/usr/bin/env bats
# Network detection tests — IPv6 address detection and IP validation
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers/mocks'
CID=""
setup() {
CID=$(docker run -d -t --cap-add=ALL "$IMAGE_TAG")
}
teardown() {
if [[ -n "$CID" ]]; then
docker rm -f "$CID" > /dev/null 2>&1 || true
fi
}
# ---------------------------------------------------------------------------
# IPv6 detection
# ---------------------------------------------------------------------------
@test "IPv6 link-local only: blocking disabled" {
mock_command_2 "$CID" ip \
"-6 address" "inet6 fe80::d210:52fa:fe00:7ad7/64 scope link" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
find_IPv6_information
"
assert_output --partial "Unable to find IPv6 ULA/GUA address"
}
@test "IPv6 ULA only: blocking enabled" {
mock_command_2 "$CID" ip \
"-6 address" "inet6 fda2:2001:5555:0:d210:52fa:fe00:7ad7/64 scope global" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
find_IPv6_information
"
assert_output --partial "Found IPv6 ULA address"
}
@test "IPv6 GUA only: blocking enabled" {
mock_command_2 "$CID" ip \
"-6 address" "inet6 2003:12:1e43:301:d210:52fa:fe00:7ad7/64 scope global" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
find_IPv6_information
"
assert_output --partial "Found IPv6 GUA address"
}
@test "IPv6 GUA + ULA: ULA takes precedence" {
mock_command_2 "$CID" ip \
"-6 address" "inet6 2003:12:1e43:301:d210:52fa:fe00:7ad7/64 scope global
inet6 fda2:2001:5555:0:d210:52fa:fe00:7ad7/64 scope global" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
find_IPv6_information
"
assert_output --partial "Found IPv6 ULA address"
}
@test "IPv6 ULA + GUA: ULA takes precedence" {
mock_command_2 "$CID" ip \
"-6 address" "inet6 fda2:2001:5555:0:d210:52fa:fe00:7ad7/64 scope global
inet6 2003:12:1e43:301:d210:52fa:fe00:7ad7/64 scope global" "0"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
find_IPv6_information
"
assert_output --partial "Found IPv6 ULA address"
}
# ---------------------------------------------------------------------------
# IP address validation
# ---------------------------------------------------------------------------
@test "valid_ip accepts and rejects addresses correctly" {
_valid() {
run docker exec "$CID" bash -c "source /opt/pihole/basic-install.sh; valid_ip '${1}'"
assert_success
}
_invalid() {
run docker exec "$CID" bash -c "source /opt/pihole/basic-install.sh; valid_ip '${1}'"
assert_failure
}
_valid "192.168.1.1"
_valid "127.0.0.1"
_valid "255.255.255.255"
_invalid "255.255.255.256"
_invalid "255.255.256.255"
_invalid "255.256.255.255"
_invalid "256.255.255.255"
_invalid "1092.168.1.1"
_invalid "not an IP"
_invalid "8.8.8.8#"
_valid "8.8.8.8#0"
_valid "8.8.8.8#1"
_valid "8.8.8.8#42"
_valid "8.8.8.8#888"
_valid "8.8.8.8#1337"
_valid "8.8.8.8#65535"
_invalid "8.8.8.8#65536"
_invalid "8.8.8.8#-1"
_invalid "00.0.0.0"
_invalid "010.0.0.0"
_invalid "001.0.0.0"
_invalid "0.0.0.0#00"
_invalid "0.0.0.0#01"
_invalid "0.0.0.0#001"
_invalid "0.0.0.0#0001"
_invalid "0.0.0.0#00001"
}

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env bats
# Tests for SELinux handling in basic-install.sh.
# Translated from test_centos_fedora_common_support.py.
# Only runs on rhel family (CentOS/Fedora) — selected by run.sh.
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
load 'helpers/mocks'
TICK="[✓]"
CROSS="[✗]"
CID=""
setup() {
CID=$(docker run -d -t --cap-add=ALL "$IMAGE_TAG")
}
teardown() {
if [[ -n "$CID" ]]; then
docker rm -f "$CID" > /dev/null 2>&1 || true
fi
}
# ---------------------------------------------------------------------------
# Helper: write a mock SELinux config with the given state
# ---------------------------------------------------------------------------
_mock_selinux_config() {
local state="$1" # enforcing, permissive, or disabled
local capitalized
capitalized=$(echo "${state}" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')
mock_command "$CID" getenforce "*" "$capitalized" "0"
docker exec "$CID" bash -c "
mkdir -p /etc/selinux
echo 'SELINUX=${state}' > /etc/selinux/config
"
}
# ---------------------------------------------------------------------------
@test "SELinux enforcing: installer exits with error" {
_mock_selinux_config "enforcing"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
checkSelinux
"
assert_output --partial "${CROSS} Current SELinux: enforcing"
assert_output --partial "SELinux Enforcing detected, exiting installer"
assert_failure
}
@test "SELinux permissive: installer continues" {
_mock_selinux_config "permissive"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
checkSelinux
"
assert_output --partial "${TICK} Current SELinux: permissive"
assert_success
}
@test "SELinux disabled: installer continues" {
_mock_selinux_config "disabled"
run docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
checkSelinux
"
assert_output --partial "${TICK} Current SELinux: disabled"
assert_success
}

View File

@@ -1,60 +0,0 @@
#!/usr/bin/env bats
# Tests for utils.sh — translated from test_any_utils.py
load 'libs/bats-support/load'
load 'libs/bats-assert/load'
CID=""
setup() {
CID=$(docker run -d -t --cap-add=ALL "$IMAGE_TAG")
}
teardown() {
if [[ -n "$CID" ]]; then
docker rm -f "$CID" > /dev/null 2>&1 || true
fi
}
# ---------------------------------------------------------------------------
@test "addOrEditKeyValPair adds and replaces key-value pairs correctly" {
docker exec "$CID" bash -c "
source /opt/pihole/utils.sh
addOrEditKeyValPair './testoutput' 'KEY_ONE' 'value1'
addOrEditKeyValPair './testoutput' 'KEY_TWO' 'value2'
addOrEditKeyValPair './testoutput' 'KEY_ONE' 'value3'
addOrEditKeyValPair './testoutput' 'KEY_FOUR' 'value4'
"
run docker exec "$CID" bash -c "cat ./testoutput"
assert_output "KEY_ONE=value3
KEY_TWO=value2
KEY_FOUR=value4"
}
@test "getFTLPID returns -1 when FTL is not running" {
run docker exec "$CID" bash -c "
source /opt/pihole/utils.sh
getFTLPID
"
assert_output "-1"
}
@test "setFTLConfigValue and getFTLConfigValue round-trip" {
# FTL must be installed for this test
docker exec "$CID" bash -c "
source /opt/pihole/basic-install.sh
create_pihole_user
funcOutput=\$(get_binary_name)
echo 'development' > /etc/pihole/ftlbranch
binary=\"pihole-FTL\${funcOutput##*pihole-FTL}\"
theRest=\"\${funcOutput%pihole-FTL*}\"
FTLdetect \"\${binary}\" \"\${theRest}\"
"
run docker exec "$CID" bash -c "
source /opt/pihole/utils.sh
setFTLConfigValue 'dns.upstreams' '[\"9.9.9.9\"]' > /dev/null
getFTLConfigValue 'dns.upstreams'
"
assert_output --partial "[ 9.9.9.9 ]"
}

10
test/tox.alpine_3_21.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _alpine_3_21.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.alpine_3_22.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _alpine_3_22.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.centos_10.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _centos_10.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py ./test_centos_fedora_common_support.py

10
test/tox.centos_9.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _centos_9.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py ./test_centos_fedora_common_support.py

10
test/tox.debian_11.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _debian_11.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.debian_12.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _debian_12.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.debian_13.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _debian_13.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.fedora_40.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _fedora_40.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py ./test_centos_fedora_common_support.py

10
test/tox.fedora_41.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _fedora_41.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py ./test_centos_fedora_common_support.py

10
test/tox.fedora_42.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _fedora_42.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py ./test_centos_fedora_common_support.py

10
test/tox.ubuntu_20.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _ubuntu_20.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.ubuntu_22.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _ubuntu_22.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py

10
test/tox.ubuntu_24.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py3
[testenv:py3]
allowlist_externals = docker
deps = -rrequirements.txt
setenv =
COLUMNS=120
commands = docker buildx build --load --progress plain -f _ubuntu_24.Dockerfile -t pytest_pihole:test_container ../
pytest {posargs:-vv -n auto} ./test_any_automated_install.py ./test_any_utils.py