A site moved from cac-fpm to cac-lsphp must see byte-identical
$_SERVER['DOCUMENT_ROOT'] and ['SCRIPT_FILENAME'] (/home/<user>/...).
The auto_prepend_file normaliser that did this was PHP_INI_PERDIR, so
any site with its own .user.ini auto_prepend_file silently displaced it
— the state 7 live shared_ols sites (Wordfence, cPanel imports) are
actually in. Hardening the hook was not an option either: making our
prepend win would have disabled those Wordfence WAFs.
Replace it with cac_path_parity, a small PHP extension that rewrites the
filesystem-path $_SERVER keys from RINIT. RINIT cannot be displaced by
.user.ini, and it occupies no userland hook, so the customer's own
auto_prepend_file stays the only prepend in play and keeps working. The
mapping lives in two PHP_INI_SYSTEM settings, which .user.ini (PERDIR /
USER only) and ini_set() cannot reach.
Mechanism is a path-component-bounded string prefix swap, not realpath():
byte-identical to cac-fpm by construction (realpath would resolve a
customer's own symlinked public_html to some third path), no syscall, and
no failure path. Every guard fails open and leaves $_SERVER untouched;
nothing here can warn, throw or 500 a site. Unconfigured it is fully
inert, so cac-fpm and cac-litespeed are unaffected.
Built in a separate Dockerfile stage keyed off the existing ARG PHPVER —
gcc/phpize/headers never reach the shipped image (verified absent; the
image grows ~155kB), and a base-image PHP bump recompiles with no human
step. A `lsphp -i | grep` assertion fails the build if the .so does not
load, so an image can never ship having silently lost parity.
The entrypoint selects the extension when present and removes any stale
prepend ini left by an older image; if the extension is somehow not
loadable it falls back to the old normaliser and logs a WARNING rather
than losing normalisation entirely. It also now logs the active parity
mode, and warns when lsphp reports no ini scan dir (previously silent).
Probe lsphp with `-i` only: it is the LSAPI SAPI, not the CLI, and
answers `-m`/`-r` by printing usage and exiting 0 — a `lsphp -m | grep`
check never matches and never errors, which is the exact class of silent
always-false assertion this change exists to remove.
Verified: 6 .phpt tests; tests/fpm-parity-check.sh proves under the FPM
SAPI that with a customer .user.ini auto_prepend_file present both keys
are still corrected AND the customer's prepend still runs, and that the
old mechanism does not; and in a real built cac-lsphp:php83 container
that SCRIPT_FILENAME is rewritten, the customer prepend still fires, and
another tenant's path is left untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
php-lsapi compiles .user.ini support in but leaves it DISABLED by default.
sapi/litespeed/lsapi_main.c has `static int parse_user_ini = 0;` and only
sets it when the process environment contains LSPHP_ENABLE_USER_INI=on.
WHP never set it, so lsphp never entered the user-ini chain at all.
The failure was silent: phpinfo() still reports user_ini.filename=.user.ini
and user_ini.cache_ttl=300, because those are core INI defaults that are
simply inert under this SAPI. Every other WHP PHP tier (cac, cac-fpm,
cac-litespeed) honors .user.ini, so shared_ols was quietly inconsistent.
Impact found in production (whp01/whp02/sdbees/TrueSelfCA, 29 sites):
- Per-site memory_limit / max_input_vars overrides were ignored. A Divi
site's max_input_vars stayed at the 2000 default while its .user.ini
asked for 20000.
- Wordfence's auto_prepend_file WAF never loaded on ANY shared_ols site.
11 sites had the plugin installed and reporting "Extended Protection"
enabled while the prepend was never executed.
Verified on a live sidecar (shadowdao.com, whp01) before this commit by
injecting the env var via whp.container_types.startup_env and recreating:
before: auto_prepend_file=/scripts/cac-lsphp-normalize.php (WAF absent)
after: auto_prepend_file=/home/shadowdao/public_html/wordfence-waf.php
wordfence-waf.php present in get_included_files()
class_exists('wfWAF') === true
The platform normalize prepend still chains in behind Wordfence's bootstrap,
so DOCUMENT_ROOT canonicalisation is not lost.
Set in two places on purpose: the Dockerfile ENV makes the value visible in
`docker inspect` and survives an entrypoint override, and the entrypoint
re-exports it with the same default because the runuser fallback exec path
resets the environment. Still overridable per-container
(LSPHP_ENABLE_USER_INI=off) as an escape hatch for a site whose legacy
cPanel-generated .user.ini has not been remediated yet.
NOTE: enabling this activates every previously-inert .user.ini at once.
Audit the fleet for stale cPanel directives before rolling this image —
session.save_path values under /var/cpanel/ that do not exist in the
container, memory_limit above the cgroup cap, and upload_max_filesize
values below the platform default were all found and remediated first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The symlink makes __FILE__/__DIR__/realpath/getcwd report /home/<user>/public_html
(WordPress/frameworks), but $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] are raw
env vars OLS sets to its /mnt/users view — apps that build/compare paths from
them would see /mnt/users. Added a tiny auto_prepend (cac-lsphp-normalize.php,
wired via a scan-dir ini) that realpath-canonicalises those two back to /home.
Customer sites have no auto_prepend by default, so no conflict.
Verified clean-room (committed image, fresh boot): DOCUMENT_ROOT and
SCRIPT_FILENAME both report /home/<user>/public_html through the shared OLS.
Now byte-for-byte 1:1 with cac-fpm/cac-litespeed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New slim per-site PHP backend that runs 'lsphp -b 0.0.0.0:9000' (detached
LSAPI) and nothing else — the LiteSpeed analogue of cac-fpm, sitting behind
a shared OpenLiteSpeed container. Built on the same litespeedtech prebuilt
base as cac-litespeed so the lsphp runtime/extensions are identical.
- Dockerfile.lsphp: base + lsphpNN-ldap parity, reuses shared lsphp-overrides.ini,
exposes only :9000, no webserver started (guaranteed by entrypoint, not by
stripping OLS binaries).
- entrypoint-lsphp.sh: same uid/user contract + /home/$user/logs layout +
ini drop-in mechanism as entrypoint-litespeed.sh; sizes PHP_LSAPI_CHILDREN
from container memory (detect-memory-lsphp.sh) with panel override precedence;
execs lsphp -b as the per-site user via setpriv (PID 1).
- detect-memory-lsphp.sh: LSAPI_CHILDREN sizing, no OLS daemon reserve.
- healthcheck-lsphp.sh: TCP :9000 + lsphp-alive (LSAPI isn't FastCGI).
- CI: Build-LSPHP-Images job, php81-85 matrix, OLS 1.8.4, cac-lsphp:phpNN.
Verified locally: builds php83+php85; sidecar runs lsphp as the per-site
user (uid 61045) as PID 1, healthcheck green, and a real shared OLS in front
serves PHP over LSAPI (HTTP 200, SAPI=litespeed) with identical docroot path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>