2026-08-05 11:38:19 -07:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
## fpm-parity-check.sh — end-to-end proof under a REAL web SAPI.
|
|
|
|
|
##
|
|
|
|
|
## WHY NOT .phpt: the CLI SAPI overwrites DOCUMENT_ROOT / SCRIPT_FILENAME /
|
|
|
|
|
## PATH_TRANSLATED after importing the environment, and the cli-server SAPI does
|
|
|
|
|
## not process .user.ini at all — so neither can exercise the two things that
|
|
|
|
|
## actually matter here.
|
|
|
|
|
##
|
|
|
|
|
## WHY PHP-FPM: php-fpm takes DOCUMENT_ROOT and SCRIPT_FILENAME as caller-
|
|
|
|
|
## supplied FastCGI params and honours .user.ini — structurally the same shape as
|
|
|
|
|
## OpenLiteSpeed handing a detached lsphp its LSAPI params. It is the closest
|
|
|
|
|
## analogue available without an OLS runtime.
|
|
|
|
|
##
|
|
|
|
|
## Asserts:
|
|
|
|
|
## 1. CONTROL — no mapping => PHP reports the raw /mnt/users paths, i.e. the
|
|
|
|
|
## test reproduces the bug before claiming to fix it.
|
|
|
|
|
## 2. FIX — mapping => both keys read /home/<user>/... .
|
|
|
|
|
## 3. WORDFENCE — mapping AND a customer .user.ini auto_prepend_file (the state
|
|
|
|
|
## 7 live shared_ols sites are in): paths are STILL corrected
|
|
|
|
|
## AND the customer's prepend STILL runs. This is the case the
|
|
|
|
|
## old auto_prepend_file normaliser silently lost.
|
|
|
|
|
## 4. OLD — for the record: the previous auto_prepend mechanism, with the
|
|
|
|
|
## same customer .user.ini, does NOT run. This is the evidence
|
|
|
|
|
## that hardening the prepend hook could not have worked.
|
|
|
|
|
##
|
2026-08-05 13:06:10 -07:00
|
|
|
## Exit codes: 0 = all assertions passed, 1 = an assertion FAILED, 2 = the
|
|
|
|
|
## harness could not run (missing binary, php-fpm refused to start, .so would not
|
|
|
|
|
## load). 2 is deliberately distinct from 1: a startup problem previously
|
|
|
|
|
## surfaced as all nine assertions failing with an empty `got:`, which reads like
|
|
|
|
|
## nine parity bugs and is the opposite of the truth.
|
|
|
|
|
##
|
2026-08-05 11:38:19 -07:00
|
|
|
## Usage: ./fpm-parity-check.sh [ROOT] [PHP_FPM_BIN] [EXT_SO]
|
|
|
|
|
## ROOT defaults to /mnt/users (falls back to a temp dir if not creatable).
|
2026-08-05 13:06:10 -07:00
|
|
|
## PHP_FPM_BIN is auto-detected; every packaging of php-fpm this repo touches
|
|
|
|
|
## uses a different name (`php-fpm` in the official docker images,
|
|
|
|
|
## `php-fpm8.N` on Debian/Ubuntu, /usr/sbin/... unlinked from PATH), so a
|
|
|
|
|
## single hardcoded default is guaranteed to be wrong somewhere and its only
|
|
|
|
|
## symptom was a silent `SKIP`.
|
2026-08-05 11:38:19 -07:00
|
|
|
set -uo pipefail
|
|
|
|
|
|
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
2026-08-05 13:06:10 -07:00
|
|
|
|
|
|
|
|
find_fpm() {
|
|
|
|
|
local c
|
|
|
|
|
for c in php-fpm php-fpm8.5 php-fpm8.4 php-fpm8.3 php-fpm8.2 php-fpm8.1; do
|
|
|
|
|
if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi
|
|
|
|
|
done
|
|
|
|
|
for c in /usr/local/sbin/php-fpm /usr/sbin/php-fpm /usr/sbin/php-fpm8.*; do
|
|
|
|
|
if [ -x "$c" ]; then echo "$c"; return 0; fi
|
|
|
|
|
done
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 11:38:19 -07:00
|
|
|
ROOT="${1:-/mnt/users}"
|
2026-08-05 13:06:10 -07:00
|
|
|
FPM_BIN="${2:-$(find_fpm || true)}"
|
2026-08-05 11:38:19 -07:00
|
|
|
EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}"
|
|
|
|
|
PORT="${PORT:-9001}"
|
|
|
|
|
|
|
|
|
|
command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; }
|
2026-08-05 13:06:10 -07:00
|
|
|
[ -n "$FPM_BIN" ] && [ -x "$FPM_BIN" ] || { echo "SKIP: php-fpm not found (pass it as \$2)"; exit 0; }
|
2026-08-05 11:38:19 -07:00
|
|
|
[ -f "$EXT_SO" ] || { echo "SKIP: $EXT_SO not built (run phpize && ./configure && make)"; exit 0; }
|
|
|
|
|
|
2026-08-05 15:23:56 -07:00
|
|
|
## `${VAR%%$'\n'*}` rather than `| head -1`: same first line, no pipeline, so
|
|
|
|
|
## nothing here can be decided by a SIGPIPE race under the pipefail on line 39.
|
|
|
|
|
## This one only ever fed an echo, so it could not have misled anyone — it is
|
|
|
|
|
## changed so that "no pipefail script in this repo pipes into an early-exit
|
|
|
|
|
## reader" stays a rule with no exceptions to remember.
|
|
|
|
|
FPM_VERSION=$("$FPM_BIN" -n -v 2>/dev/null || true)
|
|
|
|
|
echo "php-fpm: $FPM_BIN (${FPM_VERSION%%$'\n'*})"
|
2026-08-05 13:06:10 -07:00
|
|
|
echo "extension: $EXT_SO"
|
|
|
|
|
|
|
|
|
|
## Pre-flight. If the .so will not load into THIS php-fpm (PHP API mismatch is
|
|
|
|
|
## the usual cause) every assertion below would fail identically and blame the
|
|
|
|
|
## extension's logic. Say what actually happened instead.
|
2026-08-05 15:23:56 -07:00
|
|
|
## Captured into a variable and matched with a here-string, not piped into
|
|
|
|
|
## `grep -qx`. `grep -q` exits on its first match, and with `set -o pipefail`
|
|
|
|
|
## (line 39) a writer still writing at that moment dies 141 and the pipeline
|
|
|
|
|
## reads FALSE — announcing "cannot load the extension" *because* the extension
|
|
|
|
|
## was listed. `php-fpm -m` is ~1 KB and loses that race only rarely, but this
|
|
|
|
|
## pre-flight exists precisely to stop a harness malfunction being reported as
|
|
|
|
|
## an extension fault, so it must not have one of its own. (The same construct
|
|
|
|
|
## on 40 KB of `lsphp -i` is what broke entrypoint-lsphp.sh in production.)
|
|
|
|
|
FPM_MODULES=$("$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>/dev/null || true)
|
|
|
|
|
if ! grep -qx 'cac_path_parity' <<<"$FPM_MODULES"; then
|
2026-08-05 13:06:10 -07:00
|
|
|
echo "HARNESS FAILURE: $FPM_BIN cannot load $EXT_SO" >&2
|
|
|
|
|
"$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>&1 | grep -i 'unable\|warning\|error' >&2
|
|
|
|
|
echo " The .so must be built against the same PHP as this php-fpm binary." >&2
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
|
2026-08-05 11:38:19 -07:00
|
|
|
mkdir -p "$ROOT" 2>/dev/null || ROOT="$(mktemp -d)/mnt/users"
|
|
|
|
|
USER_NAME=bob
|
|
|
|
|
SITE="$ROOT/$USER_NAME/site.com"
|
|
|
|
|
DOCROOT="$SITE/public_html"
|
|
|
|
|
HOME_PATH="/home/$USER_NAME"
|
|
|
|
|
TMP="$(mktemp -d)"
|
|
|
|
|
fail=0
|
|
|
|
|
|
2026-08-05 13:06:10 -07:00
|
|
|
## php-fpm REFUSES to start as root unless the pool names a non-root user/group,
|
|
|
|
|
## and the pool this script generates had neither — so as shipped it never got
|
|
|
|
|
## past startup in any root context (which is every container in this repo).
|
|
|
|
|
## Resolve a real unprivileged account rather than assuming www-data exists.
|
|
|
|
|
POOL_USER=""
|
|
|
|
|
POOL_GROUP=""
|
|
|
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
|
|
|
for u in www-data nobody daemon; do
|
|
|
|
|
if id -u "$u" >/dev/null 2>&1; then POOL_USER="$u"; break; fi
|
|
|
|
|
done
|
|
|
|
|
for g in www-data nogroup nobody daemon; do
|
|
|
|
|
if getent group "$g" >/dev/null 2>&1; then POOL_GROUP="$g"; break; fi
|
|
|
|
|
done
|
|
|
|
|
[ -n "$POOL_USER" ] && [ -n "$POOL_GROUP" ] || {
|
|
|
|
|
echo "HARNESS FAILURE: running as root but found no unprivileged user/group for the pool" >&2
|
|
|
|
|
exit 2
|
|
|
|
|
}
|
|
|
|
|
fi
|
|
|
|
|
|
2026-08-05 11:38:19 -07:00
|
|
|
mkdir -p "$DOCROOT" || { echo "cannot create $DOCROOT"; exit 1; }
|
2026-08-05 13:06:10 -07:00
|
|
|
## The pool worker is not root: it has to be able to read the fixtures under
|
|
|
|
|
## $TMP (mktemp -d is 0700) and walk down to $DOCROOT.
|
|
|
|
|
chmod 755 "$TMP"
|
2026-08-05 11:38:19 -07:00
|
|
|
trap 'rm -rf "$TMP"; rm -f "$DOCROOT/.user.ini"' EXIT
|
|
|
|
|
|
|
|
|
|
cat > "$DOCROOT/probe.php" <<'PHP'
|
|
|
|
|
<?php
|
|
|
|
|
echo "DOCUMENT_ROOT=" . $_SERVER['DOCUMENT_ROOT'] . "\n";
|
|
|
|
|
echo "SCRIPT_FILENAME=" . $_SERVER['SCRIPT_FILENAME'] . "\n";
|
|
|
|
|
echo "PREPEND_RAN=" . (defined('CUSTOMER_PREPEND_RAN') ? 'yes' : 'no') . "\n";
|
|
|
|
|
PHP
|
|
|
|
|
|
|
|
|
|
## Stand-in for the customer's wordfence-waf.php.
|
|
|
|
|
cat > "$SITE/customer-waf.php" <<'PHP'
|
|
|
|
|
<?php
|
|
|
|
|
define('CUSTOMER_PREPEND_RAN', 1);
|
|
|
|
|
PHP
|
|
|
|
|
|
|
|
|
|
## Stand-in for the OLD mechanism (scripts/cac-lsphp-normalize.php).
|
|
|
|
|
cat > "$TMP/old-normalize.php" <<'PHP'
|
|
|
|
|
<?php
|
|
|
|
|
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) {
|
|
|
|
|
if (!empty($_SERVER[$k]) && strncmp($_SERVER[$k], '/mnt/users/', 11) === 0) {
|
|
|
|
|
$r = realpath($_SERVER[$k]);
|
|
|
|
|
if ($r !== false) { $_SERVER[$k] = $r; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
PHP
|
|
|
|
|
|
2026-08-05 13:06:10 -07:00
|
|
|
{
|
|
|
|
|
echo "[global]"
|
|
|
|
|
echo "error_log = $TMP/fpm-error.log"
|
|
|
|
|
echo "daemonize = no"
|
|
|
|
|
echo "[www]"
|
|
|
|
|
echo "listen = 127.0.0.1:$PORT"
|
|
|
|
|
echo "pm = static"
|
|
|
|
|
echo "pm.max_children = 2"
|
|
|
|
|
## Only when we are root: php-fpm hard-errors on a root pool, and warns
|
|
|
|
|
## (harmlessly, but noisily) if a non-root master names a user at all.
|
|
|
|
|
if [ -n "$POOL_USER" ]; then
|
|
|
|
|
echo "user = $POOL_USER"
|
|
|
|
|
echo "group = $POOL_GROUP"
|
|
|
|
|
fi
|
|
|
|
|
} > "$TMP/fpm.conf"
|
2026-08-05 11:38:19 -07:00
|
|
|
|
2026-08-05 13:06:10 -07:00
|
|
|
## Returns non-zero when php-fpm never answered. Callers MUST distinguish that
|
|
|
|
|
## from an assertion failure — an unstarted php-fpm makes every expect() below
|
|
|
|
|
## fail with an empty `got:`, which looks like nine parity bugs.
|
2026-08-05 11:38:19 -07:00
|
|
|
run_case() {
|
2026-08-05 13:06:10 -07:00
|
|
|
: > "$TMP/fpm.out"
|
2026-08-05 11:38:19 -07:00
|
|
|
"$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \
|
|
|
|
|
>"$TMP/fpm.out" 2>&1 &
|
|
|
|
|
local pid=$! out=""
|
|
|
|
|
for _ in $(seq 1 40); do
|
|
|
|
|
sleep 0.15
|
2026-08-05 15:23:56 -07:00
|
|
|
## SC1007: `QUERY_STRING=` IS the intent — an empty FastCGI param in the
|
|
|
|
|
## per-command environment prefix, exactly as a webserver sends it for a
|
|
|
|
|
## URL with no query string. Not a truncated assignment.
|
|
|
|
|
# shellcheck disable=SC1007
|
2026-08-05 11:38:19 -07:00
|
|
|
out=$(SCRIPT_FILENAME="$DOCROOT/probe.php" DOCUMENT_ROOT="$DOCROOT" \
|
|
|
|
|
SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \
|
|
|
|
|
cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null)
|
|
|
|
|
[ -n "$out" ] && break
|
2026-08-05 13:06:10 -07:00
|
|
|
## Master already gone => it will never answer; stop waiting 6s for it.
|
|
|
|
|
kill -0 "$pid" 2>/dev/null || break
|
2026-08-05 11:38:19 -07:00
|
|
|
done
|
|
|
|
|
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
|
|
|
|
|
printf '%s' "$out"
|
2026-08-05 13:06:10 -07:00
|
|
|
[ -n "$out" ]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
die_startup() {
|
|
|
|
|
echo
|
|
|
|
|
echo "HARNESS FAILURE: php-fpm never answered for case '$1'." >&2
|
|
|
|
|
echo " This is a STARTUP/environment failure, NOT a parity assertion failure." >&2
|
|
|
|
|
echo " php-fpm: $FPM_BIN" >&2
|
|
|
|
|
echo " pool user/group: ${POOL_USER:-<none, master is not root>}/${POOL_GROUP:-}" >&2
|
|
|
|
|
echo " --- php-fpm output ---" >&2
|
|
|
|
|
sed 's/^/ /' "$TMP/fpm.out" >&2
|
|
|
|
|
echo " --- pool error_log ---" >&2
|
|
|
|
|
[ -s "$TMP/fpm-error.log" ] && sed 's/^/ /' "$TMP/fpm-error.log" >&2
|
|
|
|
|
echo " ----------------------" >&2
|
|
|
|
|
exit 2
|
2026-08-05 11:38:19 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect() {
|
|
|
|
|
local label="$1" got="$2" want="$3"
|
|
|
|
|
if [ "$got" = "$want" ]; then
|
|
|
|
|
echo " PASS $label"
|
|
|
|
|
else
|
|
|
|
|
echo " FAIL $label"
|
|
|
|
|
echo " want: $want"
|
|
|
|
|
echo " got: $got"
|
|
|
|
|
fail=1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
field() { printf '%s' "$1" | sed -n "s/^$2=//p"; }
|
|
|
|
|
|
|
|
|
|
EXT=( -d "extension=$EXT_SO" )
|
|
|
|
|
MAP=( -d "cac_path_parity.from=$SITE" -d "cac_path_parity.to=$HOME_PATH" )
|
|
|
|
|
USERINI_LINE="auto_prepend_file = $SITE/customer-waf.php"
|
|
|
|
|
|
|
|
|
|
echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) =="
|
|
|
|
|
rm -f "$DOCROOT/.user.ini"
|
2026-08-05 13:06:10 -07:00
|
|
|
out=$(run_case "${EXT[@]}") || die_startup "1. CONTROL"
|
2026-08-05 11:38:19 -07:00
|
|
|
expect "DOCUMENT_ROOT is the raw OLS path" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
|
|
|
|
|
expect "SCRIPT_FILENAME is the raw OLS path" "$(field "$out" SCRIPT_FILENAME)" "$DOCROOT/probe.php"
|
|
|
|
|
|
|
|
|
|
echo "== 2. FIX: mapping configured =="
|
2026-08-05 13:06:10 -07:00
|
|
|
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "2. FIX"
|
2026-08-05 11:38:19 -07:00
|
|
|
expect "DOCUMENT_ROOT == cac-fpm value" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
|
|
|
|
|
expect "SCRIPT_FILENAME == cac-fpm value" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
|
|
|
|
|
|
|
|
|
|
echo "== 3. WORDFENCE: customer .user.ini auto_prepend_file present =="
|
|
|
|
|
printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini"
|
2026-08-05 13:06:10 -07:00
|
|
|
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "3. WORDFENCE"
|
|
|
|
|
expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
|
2026-08-05 11:38:19 -07:00
|
|
|
expect "SCRIPT_FILENAME still corrected" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
|
|
|
|
|
expect "customer auto_prepend_file still ran" "$(field "$out" PREPEND_RAN)" "yes"
|
|
|
|
|
|
|
|
|
|
echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) =="
|
2026-08-05 13:06:10 -07:00
|
|
|
out=$(run_case -d "auto_prepend_file=$TMP/old-normalize.php") || die_startup "4. OLD MECHANISM"
|
2026-08-05 11:38:19 -07:00
|
|
|
expect "auto_prepend normaliser is displaced by the customer's .user.ini" \
|
|
|
|
|
"$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
|
|
|
|
|
expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes"
|
|
|
|
|
|
|
|
|
|
rm -f "$DOCROOT/.user.ini"
|
|
|
|
|
if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES"; fi
|
|
|
|
|
exit "$fail"
|