From 7416a299010b87825a55e613fffb12a0bd3d8437 Mon Sep 17 00:00:00 2001 From: Jakub Zelenka Date: Thu, 8 Oct 2026 19:56:33 +0200 Subject: [PATCH] Add DNS testing setup using BIND 9 This creates a test framework for testing PHP DNS functions that were previously completely untested. It provides scripts for starting a BIND 9 server that serves locally defined zone files, which can be used for creating specific test scenarios without the need to rely on online data. The PHP DNS functions use libresolv which reads /etc/resolv.conf, so the resolv-setup.sh script replaces that file with one pointing to the local BIND server (keeping the previous nameservers as a fallback) instead of configuring systemd-resolved, which re-applies DHCP servers and routes queries per link and therefore did not work reliably in CI. BIND forwards all other queries to the original upstream nameservers so the rest of the system keeps resolving. This is all used in the GitHub Actions Linux jobs. --- .github/actions/apt-x64/action.yml | 3 + .github/actions/setup-x64/action.yml | 4 + ext/standard/tests/dns/.gitignore | 6 ++ ext/standard/tests/dns/README.md | 41 +++++++++ ext/standard/tests/dns/bind-start.sh | 84 +++++++++++++++++++ ext/standard/tests/dns/bind-stop.sh | 23 +++++ ext/standard/tests/dns/checkdnsrr_basic.phpt | 22 +++++ ext/standard/tests/dns/common.sh | 49 +++++++++++ .../tests/dns/dns_get_record_basic.phpt | 27 ++++++ ext/standard/tests/dns/dns_get_record_mx.phpt | 29 +++++++ .../tests/dns/dns_get_record_nonexistent.phpt | 16 ++++ .../tests/dns/dns_get_record_txt.phpt | 32 +++++++ ext/standard/tests/dns/getmxrr_basic.phpt | 27 ++++++ ext/standard/tests/dns/named.conf.in | 29 +++++++ ext/standard/tests/dns/resolv-reset.sh | 23 +++++ ext/standard/tests/dns/resolv-setup.sh | 33 ++++++++ ext/standard/tests/dns/skipif.inc | 14 ++++ .../dns/zones/basic.dnstest.php.net.zone | 16 ++++ 18 files changed, 478 insertions(+) create mode 100644 ext/standard/tests/dns/.gitignore create mode 100644 ext/standard/tests/dns/README.md create mode 100755 ext/standard/tests/dns/bind-start.sh create mode 100755 ext/standard/tests/dns/bind-stop.sh create mode 100644 ext/standard/tests/dns/checkdnsrr_basic.phpt create mode 100755 ext/standard/tests/dns/common.sh create mode 100644 ext/standard/tests/dns/dns_get_record_basic.phpt create mode 100644 ext/standard/tests/dns/dns_get_record_mx.phpt create mode 100644 ext/standard/tests/dns/dns_get_record_nonexistent.phpt create mode 100644 ext/standard/tests/dns/dns_get_record_txt.phpt create mode 100644 ext/standard/tests/dns/getmxrr_basic.phpt create mode 100644 ext/standard/tests/dns/named.conf.in create mode 100755 ext/standard/tests/dns/resolv-reset.sh create mode 100755 ext/standard/tests/dns/resolv-setup.sh create mode 100644 ext/standard/tests/dns/skipif.inc create mode 100644 ext/standard/tests/dns/zones/basic.dnstest.php.net.zone diff --git a/.github/actions/apt-x64/action.yml b/.github/actions/apt-x64/action.yml index 3e654ef9fdc4..71b239920737 100644 --- a/.github/actions/apt-x64/action.yml +++ b/.github/actions/apt-x64/action.yml @@ -33,6 +33,9 @@ runs: ldap-utils \ openssl \ slapd \ + bind9 \ + bind9utils \ + bind9-dnsutils \ language-pack-de \ libgmp-dev \ libicu-dev \ diff --git a/.github/actions/setup-x64/action.yml b/.github/actions/setup-x64/action.yml index 3ee5cd3f28f2..021a63279eaa 100644 --- a/.github/actions/setup-x64/action.yml +++ b/.github/actions/setup-x64/action.yml @@ -6,6 +6,10 @@ runs: run: | set -x + sudo systemctl stop named + sudo ./ext/standard/tests/dns/bind-start.sh + sudo ./ext/standard/tests/dns/resolv-setup.sh + sudo service slapd start docker exec sql1 /opt/mssql-tools18/bin/sqlcmd -S 127.0.0.1 -U SA -C -P "" -Q "create login pdo_test with password='password', check_policy=off; create user pdo_test for login pdo_test; grant alter, control to pdo_test;" docker exec sql1 /opt/mssql-tools18/bin/sqlcmd -S 127.0.0.1 -U SA -C -P "" -Q "create login odbc_test with password='password', check_policy=off; create user odbc_test for login odbc_test; grant alter, control, delete to odbc_test;" diff --git a/ext/standard/tests/dns/.gitignore b/ext/standard/tests/dns/.gitignore new file mode 100644 index 000000000000..84fd15724d32 --- /dev/null +++ b/ext/standard/tests/dns/.gitignore @@ -0,0 +1,6 @@ +!*.sh +named.conf +named.pid +named.log +managed-keys.* +*.jnl diff --git a/ext/standard/tests/dns/README.md b/ext/standard/tests/dns/README.md new file mode 100644 index 000000000000..6c28e63f51d4 --- /dev/null +++ b/ext/standard/tests/dns/README.md @@ -0,0 +1,41 @@ +# DNS tests + +These tests run the PHP DNS functions against a local BIND 9 server serving +the zones from `zones/`. They are skipped unless the server is running and +`/etc/resolv.conf` points to it. + +Requirements on Debian / Ubuntu: + +```sh +sudo apt-get install bind9 bind9utils bind9-dnsutils +``` + +Nothing else may listen on `127.0.0.1:53` (the system `named` service started +by the package install needs to be stopped). systemd-resolved listens on +`127.0.0.53` and is not a problem. + +Running the tests: + +```sh +sudo ext/standard/tests/dns/bind-start.sh +sudo ext/standard/tests/dns/resolv-setup.sh + +sapi/cli/php run-tests.php ext/standard/tests/dns + +sudo ext/standard/tests/dns/resolv-reset.sh +sudo ext/standard/tests/dns/bind-stop.sh +``` + +`bind-start.sh` generates `named.conf` from `named.conf.in` and starts `named` +on `127.0.0.1:53` as the owner of this directory. Other queries are forwarded +to the nameservers the host uses so everything else keeps resolving. The +server logs to `named.log`. + +`resolv-setup.sh` saves `/etc/resolv.conf` and replaces it with one using +`127.0.0.1` with the previous nameservers as a fallback. The PHP DNS +functions use libresolv which reads this file directly, so this is the only +setting that matters. systemd-resolved is not used on purpose as it re-applies +DHCP servers and routes queries per interface. + +To add records, edit `zones/basic.dnstest.php.net.zone` and bump the serial, +or add a new zone file and a `zone` block to `named.conf.in`. diff --git a/ext/standard/tests/dns/bind-start.sh b/ext/standard/tests/dns/bind-start.sh new file mode 100755 index 000000000000..10d59087a4a0 --- /dev/null +++ b/ext/standard/tests/dns/bind-start.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Starts BIND serving the zones from zones/ on 127.0.0.1:53, forwarding +# everything else to the nameservers the host uses. Use -f to run it in +# the foreground. + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +command -v named >/dev/null || fail "named not found, install BIND 9 (bind9 bind9utils bind9-dnsutils)" + +if pid="$(running_pid)"; then + echo "BIND is already running with PID $pid" + exit 0 +fi + +# Installing bind9 on Debian/Ubuntu starts the system named on port 53 +if command -v ss >/dev/null; then + listener="$(ss -H -uln "sport = :$DNS_PORT" | awk -v a="$DNS_ADDRESS:$DNS_PORT" -v p="$DNS_PORT" \ + '$4 == a || $4 == "0.0.0.0:" p || $4 == "[::]:" p || $4 == "*:" p { print $4; exit }')" + [[ -z "$listener" ]] || fail "$listener is already in use, stop the service using it first (e.g. systemctl stop named)" +fi + +forwarders="" +while read -r ns; do + forwarders+="$ns; " +done < <(upstream_nameservers) +if [[ -n "$forwarders" ]]; then + forwarders="forwarders { $forwarders}; forward first;" +fi + +sed -e "s|@TEST_DIR@|$TEST_DIR|g" \ + -e "s|@PID_FILE@|$PID_FILE|g" \ + -e "s|@LOG_FILE@|$LOG_FILE|g" \ + -e "s|@ADDRESS@|$DNS_ADDRESS|g" \ + -e "s|@PORT@|$DNS_PORT|g" \ + -e "s|@FORWARDERS@|$forwarders|" \ + "$TEST_DIR/named.conf.in" > "$NAMED_CONF" +chmod 644 "$NAMED_CONF" + +if command -v named-checkconf >/dev/null; then + named-checkconf -z "$NAMED_CONF" >/dev/null +fi + +# Drop privileges to the owner of this directory so named can write here +args=(-c "$NAMED_CONF") +owner="" +if [[ "$(id -u)" -eq 0 ]]; then + owner="$(stat -c '%U' "$TEST_DIR")" + if [[ "$owner" != "root" ]]; then + args+=(-u "$owner") + fi +elif [[ "$DNS_PORT" -lt 1024 ]]; then + fail "must run as root to listen on port $DNS_PORT" +fi + +# The distribution profile only allows named to read /etc/bind and /var/{cache,lib}/bind +if can_manage_apparmor; then + apparmor_parser -R "$AA_PROFILE" 2>/dev/null || true +fi + +rm -f "${PID_FILE:?}" "${ZONES_DIR:?}"/*.jnl +: > "$LOG_FILE" +[[ -z "$owner" ]] || chown "$owner" "$LOG_FILE" + +if [[ "${1:-}" == "-f" ]]; then + exec named "${args[@]}" -g +fi + +named "${args[@]}" + +for _ in $(seq 1 40); do + if pid="$(running_pid)"; then + if ! command -v dig >/dev/null \ + || [[ "$(dig "@$DNS_ADDRESS" -p "$DNS_PORT" +short +time=1 +tries=1 www.basic.dnstest.php.net A)" == "192.0.2.1" ]]; then + echo "BIND started with PID $pid on $DNS_ADDRESS:$DNS_PORT" + exit 0 + fi + fi + sleep 0.25 +done + +cat "$LOG_FILE" >&2 +fail "BIND did not start, see $LOG_FILE" diff --git a/ext/standard/tests/dns/bind-stop.sh b/ext/standard/tests/dns/bind-stop.sh new file mode 100755 index 000000000000..ca04de4195ca --- /dev/null +++ b/ext/standard/tests/dns/bind-stop.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stops BIND started by bind-start.sh + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +if pid="$(running_pid)"; then + kill "$pid" + for _ in $(seq 1 40); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.25 + done + echo "BIND with PID $pid stopped" +else + echo "BIND is not running" +fi + +rm -f "${PID_FILE:?}" "${NAMED_CONF:?}" "${ZONES_DIR:?}"/*.jnl + +if can_manage_apparmor; then + apparmor_parser -r "$AA_PROFILE" 2>/dev/null || true +fi diff --git a/ext/standard/tests/dns/checkdnsrr_basic.phpt b/ext/standard/tests/dns/checkdnsrr_basic.phpt new file mode 100644 index 000000000000..06f6f2a806ad --- /dev/null +++ b/ext/standard/tests/dns/checkdnsrr_basic.phpt @@ -0,0 +1,22 @@ +--TEST-- +checkdnsrr() and dns_check_record() basic usage +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(false) +bool(false) diff --git a/ext/standard/tests/dns/common.sh b/ext/standard/tests/dns/common.sh new file mode 100755 index 000000000000..32b6080ac942 --- /dev/null +++ b/ext/standard/tests/dns/common.sh @@ -0,0 +1,49 @@ +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ZONES_DIR="$TEST_DIR/zones" +NAMED_CONF="$TEST_DIR/named.conf" +PID_FILE="$TEST_DIR/named.pid" +LOG_FILE="$TEST_DIR/named.log" + +DNS_ADDRESS="${PHP_DNS_TEST_ADDRESS:-127.0.0.1}" +DNS_PORT="${PHP_DNS_TEST_PORT:-53}" + +RESOLV_CONF="/etc/resolv.conf" +RESOLV_CONF_BACKUP="/etc/resolv.conf.php-dns-test.orig" + +AA_PROFILE="/etc/apparmor.d/usr.sbin.named" + +fail() { + echo "$*" >&2 + exit 1 +} + +resolv_nameservers() { + [[ -r "$1" ]] && sed -nE 's/^[[:space:]]*nameserver[[:space:]]+([^[:space:]#]+).*/\1/p' "$1" + return 0 +} + +# Nameservers the host uses, skipping loopback (the systemd-resolved stub or our own server) +upstream_nameservers() { + local f ns found=0 + for f in "$RESOLV_CONF_BACKUP" /run/systemd/resolve/resolv.conf "$RESOLV_CONF"; do + while read -r ns; do + case "$ns" in + 127.*|::1) ;; + *) echo "$ns"; found=1 ;; + esac + done < <(resolv_nameservers "$f") + [[ $found -eq 1 ]] && return 0 + done +} + +running_pid() { + local pid + pid="$(cat "$PID_FILE" 2>/dev/null)" || return 1 + [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null || return 1 + echo "$pid" +} + +can_manage_apparmor() { + [[ -f "$AA_PROFILE" && -d /sys/kernel/security/apparmor && "$(id -u)" -eq 0 ]] \ + && command -v apparmor_parser >/dev/null +} diff --git a/ext/standard/tests/dns/dns_get_record_basic.phpt b/ext/standard/tests/dns/dns_get_record_basic.phpt new file mode 100644 index 000000000000..5babd5d601b9 --- /dev/null +++ b/ext/standard/tests/dns/dns_get_record_basic.phpt @@ -0,0 +1,27 @@ +--TEST-- +dns_get_record() basic usage with A record +--SKIPIF-- + +--FILE-- + +--EXPECTF-- +array(1) { + [0]=> + array(5) { + ["host"]=> + string(25) "www.basic.dnstest.php.net" + ["class"]=> + string(2) "IN" + ["ttl"]=> + int(%d) + ["type"]=> + string(1) "A" + ["ip"]=> + string(9) "192.0.2.1" + } +} diff --git a/ext/standard/tests/dns/dns_get_record_mx.phpt b/ext/standard/tests/dns/dns_get_record_mx.phpt new file mode 100644 index 000000000000..fe320acf9130 --- /dev/null +++ b/ext/standard/tests/dns/dns_get_record_mx.phpt @@ -0,0 +1,29 @@ +--TEST-- +dns_get_record() with MX record +--SKIPIF-- + +--FILE-- + +--EXPECTF-- +array(1) { + [0]=> + array(6) { + ["host"]=> + string(21) "basic.dnstest.php.net" + ["class"]=> + string(2) "IN" + ["ttl"]=> + int(%d) + ["type"]=> + string(2) "MX" + ["pri"]=> + int(10) + ["target"]=> + string(25) "mx1.basic.dnstest.php.net" + } +} diff --git a/ext/standard/tests/dns/dns_get_record_nonexistent.phpt b/ext/standard/tests/dns/dns_get_record_nonexistent.phpt new file mode 100644 index 000000000000..f5b2d934b584 --- /dev/null +++ b/ext/standard/tests/dns/dns_get_record_nonexistent.phpt @@ -0,0 +1,16 @@ +--TEST-- +dns_get_record() with a name that does not exist +--SKIPIF-- + +--FILE-- + +--EXPECT-- +array(0) { +} +array(0) { +} diff --git a/ext/standard/tests/dns/dns_get_record_txt.phpt b/ext/standard/tests/dns/dns_get_record_txt.phpt new file mode 100644 index 000000000000..6fa2bea865a6 --- /dev/null +++ b/ext/standard/tests/dns/dns_get_record_txt.phpt @@ -0,0 +1,32 @@ +--TEST-- +dns_get_record() with TXT record +--SKIPIF-- + +--FILE-- + +--EXPECTF-- +array(1) { + [0]=> + array(6) { + ["host"]=> + string(26) "txt1.basic.dnstest.php.net" + ["class"]=> + string(2) "IN" + ["ttl"]=> + int(%d) + ["type"]=> + string(3) "TXT" + ["txt"]=> + string(25) "This is a test TXT record" + ["entries"]=> + array(1) { + [0]=> + string(25) "This is a test TXT record" + } + } +} diff --git a/ext/standard/tests/dns/getmxrr_basic.phpt b/ext/standard/tests/dns/getmxrr_basic.phpt new file mode 100644 index 000000000000..d77ec5ad5598 --- /dev/null +++ b/ext/standard/tests/dns/getmxrr_basic.phpt @@ -0,0 +1,27 @@ +--TEST-- +getmxrr() basic usage +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +array(1) { + [0]=> + string(25) "mx1.basic.dnstest.php.net" +} +array(1) { + [0]=> + int(10) +} +bool(false) +array(0) { +} +array(0) { +} diff --git a/ext/standard/tests/dns/named.conf.in b/ext/standard/tests/dns/named.conf.in new file mode 100644 index 000000000000..e446431ff307 --- /dev/null +++ b/ext/standard/tests/dns/named.conf.in @@ -0,0 +1,29 @@ +options { + directory "@TEST_DIR@"; + pid-file "@PID_FILE@"; + listen-on port @PORT@ { @ADDRESS@; }; + listen-on-v6 { none; }; + allow-query { any; }; + recursion yes; + allow-recursion { localhost; }; + dnssec-validation no; + @FORWARDERS@ +}; + +controls { }; + +logging { + channel file { + file "@LOG_FILE@"; + severity info; + print-time yes; + print-category yes; + }; + category default { file; }; + category queries { file; }; +}; + +zone "basic.dnstest.php.net" { + type primary; + file "zones/basic.dnstest.php.net.zone"; +}; diff --git a/ext/standard/tests/dns/resolv-reset.sh b/ext/standard/tests/dns/resolv-reset.sh new file mode 100755 index 000000000000..5a4be74648b9 --- /dev/null +++ b/ext/standard/tests/dns/resolv-reset.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Restores /etc/resolv.conf saved by resolv-setup.sh + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +[[ "$(id -u)" -eq 0 ]] || fail "must run as root" + +if [[ ! -e "$RESOLV_CONF_BACKUP" && ! -L "$RESOLV_CONF_BACKUP" ]]; then + echo "$RESOLV_CONF_BACKUP not found, nothing to restore" + exit 0 +fi + +if [[ -L "$RESOLV_CONF_BACKUP" ]]; then + rm -f "$RESOLV_CONF" + mv "$RESOLV_CONF_BACKUP" "$RESOLV_CONF" +else + cat "$RESOLV_CONF_BACKUP" > "$RESOLV_CONF" + rm -f "$RESOLV_CONF_BACKUP" +fi + +echo "$RESOLV_CONF restored" diff --git a/ext/standard/tests/dns/resolv-setup.sh b/ext/standard/tests/dns/resolv-setup.sh new file mode 100755 index 000000000000..e72357459a54 --- /dev/null +++ b/ext/standard/tests/dns/resolv-setup.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Points /etc/resolv.conf to the BIND started by bind-start.sh. The PHP DNS +# functions use libresolv which reads this file directly, so it is replaced +# instead of configuring systemd-resolved or NetworkManager. The current +# nameservers are kept as a fallback and resolv-reset.sh restores the file. + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +[[ "$(id -u)" -eq 0 ]] || fail "must run as root" +[[ "$DNS_PORT" == "53" ]] || fail "resolv.conf cannot use port $DNS_PORT" + +if [[ -e "$RESOLV_CONF_BACKUP" || -L "$RESOLV_CONF_BACKUP" ]]; then + echo "$RESOLV_CONF_BACKUP exists, already set up" + exit 0 +fi + +content="nameserver $DNS_ADDRESS"$'\n' +while read -r ns; do + [[ "$ns" == "$DNS_ADDRESS" ]] || content+="nameserver $ns"$'\n' +done < <(resolv_nameservers "$RESOLV_CONF" | head -n 2) + +# In a container /etc/resolv.conf is a bind mount that can only be written in place +cp -P "$RESOLV_CONF" "$RESOLV_CONF_BACKUP" +if [[ -L "$RESOLV_CONF" ]]; then + rm -f "$RESOLV_CONF" +fi +printf '%s' "$content" > "$RESOLV_CONF" +chmod 644 "$RESOLV_CONF" + +echo "$RESOLV_CONF now contains:" +cat "$RESOLV_CONF" diff --git a/ext/standard/tests/dns/skipif.inc b/ext/standard/tests/dns/skipif.inc new file mode 100644 index 000000000000..a919cdf938f6 --- /dev/null +++ b/ext/standard/tests/dns/skipif.inc @@ -0,0 +1,14 @@ +