#!/usr/bin/env bash ## entrypoint-lsphp.sh — PID 1 for cac-lsphp:phpNN. ## ## The per-site PHP backend for the SHARED OpenLiteSpeed tier. Runs lsphp in ## DETACHED LSAPI mode (`lsphp -b `) and nothing else — no ## webserver. The shared-ols container connects to this over the docker ## network (extProcessor type lsapi, address :9000) exactly ## like the shared httpd connects to a cac-fpm container's php-fpm on :9000. ## ## Structurally identical to cac-fpm/cac-litespeed: same `uid`/`user` contract, ## the customer docroot mounted at /home/$user (so PHP sees /home/$user/public_html ## EXACTLY like the standalone tiers — true 1:1 drop-in for WordPress ABSPATH, ## config paths, and DB-stored absolute paths). The only difference is OLS lives ## in a separate container, so this PID 1 is lsphp itself. ## ## THE SYMLINK (see feedback_ols_lsapi_no_script_filename_remap): OLS has no ## ProxyFCGISetEnvIf-style remap — it hands lsphp exactly its vhost docRoot path. ## The shared-ols container serves from its bulk /docker/users->/mnt/users mount, ## so its docRoot (and the SCRIPT_FILENAME it sends us) is ## /mnt/users///public_html. We create a symlink ## /mnt/users// -> /home/$user so that path resolves to the real ## /home/$user/public_html files. PHP canonicalises the symlink, so ## __FILE__/__DIR__/realpath all report /home/$user/public_html (verified ## 2026-06-10) — the customer never sees the /mnt/users path. ## ## THE $_SERVER STRINGS: the symlink makes paths RESOLVE, but the raw strings OLS ## put in $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] still read /mnt/users. ## The cac_path_parity extension (baked into the image, configured per-site ## below) rewrites those two at request start, so a site moved from cac-fpm to ## cac-lsphp sees byte-identical values. It replaced an auto_prepend_file ## normaliser that any site's own .user.ini silently displaced — see ## ext/cac-path-parity/cac_path_parity.c. set -euo pipefail : "${PHPVER:=83}" : "${environment:=PROD}" export CONTAINER_ROLE="lsphp_only" export PHPVER environment ## ---- env validation (same contract as entrypoint-fpm / entrypoint-litespeed) ---- if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2 exit 1 fi : "${domain:=localhost}" export user domain LSPHP_BIN="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp" if [ ! -x "$LSPHP_BIN" ]; then echo "FATAL: lsphp binary not found at $LSPHP_BIN (PHPVER=$PHPVER)." >&2 exit 1 fi ## ---- user + directories (identical to entrypoint-litespeed.sh: docroot at ## /home/$user, the customer's bind-mounted domain dir) ---- if ! id -u "$user" >/dev/null 2>&1; then useradd -u "$uid" -m -s /bin/bash "$user" fi mkdir -p "/home/$user/public_html" "/home/$user/logs/php-fpm" ## ---- compatibility symlink for the OLS-sent path ---- ## OLS sends SCRIPT_FILENAME under /mnt/users///public_html ## (the shared-ols container's view). Point that at our real /home/$user mount so ## the path resolves. matches the on-disk convention: wildcard ## `*.foo.com` is stored as `wildcard.foo.com`. SAFE_DOMAIN="$domain" case "$domain" in \*.*) SAFE_DOMAIN="wildcard.${domain#\*.}" ;; esac ## Both of these get interpolated into generated php.ini fragments below. They ## are panel-validated and both already feed `ln -sfn` and the shared-ols vhost ## config, so a hostile value is not reachable today — this is the belt to that ## brace. A newline in $domain is an INI-DIRECTIVE INJECTION into the generated ## fragment (measured against the pre-fix script: domain=$'evil.com\nprecision = ## 7\n; ' put that directive in 99-cac-path-parity.ini and lsphp reported ## `precision => 7`); `$(...)` yields an ini parse error and `"` an empty value, ## and BOTH of those leave the ## path-parity extension INERT — the exact silent parity loss this whole change ## exists to eliminate. Quoting the emitted values (done below) neutralises ## newlines and quotes; it does NOT neutralise php.ini's own `${VAR}` ## interpolation, which is why the character class is checked as well. INI_TOKENS_OK=yes case "$user" in ''|*[!A-Za-z0-9._-]*) INI_TOKENS_OK=no ;; esac case "$SAFE_DOMAIN" in ''|*[!A-Za-z0-9._-]*) INI_TOKENS_OK=no ;; esac if [ "$INI_TOKENS_OK" != yes ]; then echo "WARNING: entrypoint-lsphp: user/domain contain characters outside [A-Za-z0-9._-] — refusing to generate php.ini fragments from them, so the \$_SERVER path-parity mapping and the per-site error_log are BOTH skipped (the extension stays inert, log_errors stays On from the image defaults and PHP logs to stderr i.e. \`docker logs\`; requests are unaffected). user=$(printf '%q' "$user") domain=$(printf '%q' "$domain")" >&2 fi ## The exact path prefix the shared-ols container serves this site from — the ## string OLS puts in SCRIPT_FILENAME/DOCUMENT_ROOT. Used twice: for the symlink ## that makes it RESOLVE, and for the cac_path_parity mapping that makes it READ ## like cac-fpm. Deriving both from one variable keeps them in lockstep. OLS_SITE_PATH="/mnt/users/$user/$SAFE_DOMAIN" mkdir -p "/mnt/users/$user" ln -sfn "/home/$user" "$OLS_SITE_PATH" ## ---- detached-lsphp pool sizing ---- # shellcheck source=/dev/null source /scripts/detect-memory-lsphp.sh ## LSAPI tuning (spec §5.1). PHP_LSAPI_CHILDREN MUST equal the shared-ols vhost ## maxConns — the WHP panel writes both from the single fpm_max_children value, ## so they can't drift. LSAPI_MAX_IDLE is THE RAM win: idle children exit, so an ## idle site's footprint collapses toward baseline (ondemand-like). export PHP_LSAPI_CHILDREN="${PHP_LSAPI_CHILDREN:-$LSAPI_CHILDREN}" export PHP_LSAPI_MAX_REQUESTS="${PHP_LSAPI_MAX_REQUESTS:-500}" export LSAPI_MAX_IDLE="${LSAPI_MAX_IDLE:-30}" export LSAPI_EXTRA_CHILDREN="${LSAPI_EXTRA_CHILDREN:-5}" export LSAPI_AVOID_FORK="${LSAPI_AVOID_FORK:-0}" LSPHP_BIND="${LSPHP_BIND:-0.0.0.0:9000}" ## ---- .user.ini support ---- ## 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 in PHP_MINIT_FUNCTION(litespeed) when the PROCESS ENV contains ## LSPHP_ENABLE_USER_INI=on. Without it, lsphp never enters the user-ini chain ## at all — and does so SILENTLY, because `user_ini.filename` / `user_ini.cache_ttl` ## still report their core defaults in phpinfo(). Every other WHP PHP tier ## (cac, cac-fpm, cac-litespeed) honors .user.ini, so leaving it off here made ## the shared-ols tier quietly inconsistent: customer memory_limit / ## max_input_vars overrides were ignored, and — the reason this was found — ## Wordfence's `auto_prepend_file` WAF never loaded on ANY shared-ols site. ## ## Exported here rather than relying solely on the Dockerfile ENV because the ## runuser fallback below resets the environment; an export survives all three ## exec paths. Still overridable per-container (set LSPHP_ENABLE_USER_INI=off in ## the site's env) as an escape hatch for a site whose legacy cPanel-generated ## .user.ini has not been remediated yet. export LSPHP_ENABLE_USER_INI="${LSPHP_ENABLE_USER_INI:-on}" echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP_LSAPI_CHILDREN=${PHP_LSAPI_CHILDREN} | LSAPI_MAX_IDLE=${LSAPI_MAX_IDLE} | PHPVER=${PHPVER} | bind=${LSPHP_BIND} | user_ini=${LSPHP_ENABLE_USER_INI}" ## Validate a numeric value destined for a generated php.ini fragment. ## Sets INI_NUM to the value when it is acceptable, and to "" (plus a WARNING) ## when it is not. Never fatal: a rejected override just leaves the image ## default in place, and the site serves either way. ## ## Digits-only is what closes the injection: no newline, quote, `$` or `{` can ## survive it, so neither an ini-directive injection nor php.ini's `${VAR}` ## interpolation is reachable regardless of what the caller sent. ## ## The range bound is a separate, weaker concern: it is a sanity check, NOT a ## guarantee that the value works. Measured — with `99-prod-overrides.ini` ## setting `opcache.interned_strings_buffer = 16`, a memory_consumption of 8 or ## 16 is ACCEPTED here and still aborts opcache at startup ("Insufficient shared ## memory for interned strings buffer"), loading no opcache at all. The floor is ## not raised to cover that because doing so would forfeit the superset property ## below; the panel clamps at 32, well clear of it. validate_ini_num() { local name="$1" val="$2" min="$3" max="$4" INI_NUM="" case "$val" in ''|*[!0-9]*) ;; *) ## Length-cap first: `[ -lt ]` on a 25-digit string is an arithmetic ## error, not a comparison. 7 digits covers every max below. if [ "${#val}" -le 7 ] && [ "$val" -ge "$min" ] && [ "$val" -le "$max" ]; then INI_NUM="$val" return 0 fi ;; esac echo "WARNING: entrypoint-lsphp: ${name}=$(printf '%q' "$val") is not a plain integer in ${min}-${max} — ignoring it; the image default from 99-prod-overrides.ini applies." >&2 return 0 } ## ---- per-site ini drop-ins (identical mechanism to entrypoint-litespeed.sh) ---- ## error_log → the same customer-visible path cac:phpNN / cac-litespeed use, so ## "where's my PHP error log?" is answered identically across all site types. ## Capture lsphp's own info once and read both answers out of it. Probe with ## `-i` ONLY: lsphp is the LSAPI SAPI, not the CLI — it accepts just ## -[b|c|n|h|i|q|s|v|?] and answers `-m`/`-r` by printing usage and exiting 0, so ## a `lsphp -m | grep` test never matches and never errors either. ## ## ---- CAC-TEST: probe helpers BEGIN ---- ## Everything between these two markers is extracted verbatim and executed by ## scripts/tests/lsphp-info-probe.test.sh — the markers are inert comments with ## no runtime effect, and they exist so the test exercises THE SHIPPED CODE ## rather than a copy of it that can drift away from it. Keep BOTH markers: the ## extractor refuses to emit anything unless it sees the END one, so a half- ## deleted pair is reported there as a marker error instead of silently ## sourcing the rest of this file. ## ## WHY THESE MATCH `$1` IN THE SHELL AND NEVER PIPE IT INTO A READER. ## ## What broke. Both probes used to be `printf '%s\n' "$LSPHP_INFO" | `, ## and both readers stop early — `grep -q` at its first match, `awk` at `exit`. ## When the reader closes the pipe with the writer still writing, the writer ## takes SIGPIPE and dies 141; `set -o pipefail` (line 34) adopts 141 as the ## PIPELINE's status; and the branch reads FALSE **because the thing it was ## looking for was present early enough to stop the reader**. Measured on whp02 ## against the published cac-lsphp:php83: 5/5 runs status=141 with pipefail, ## 0 without. ## ## THE RULE, and it is not about size. ANY `writer | early-exiting-reader` ## under pipefail is a latent 141. PAYLOAD SIZE IS NOT A SAFETY ARGUMENT. Each ## run is decided by a race — whether the reader's close lands before the ## writer's final write() returns — and the payload only sets how many write() ## syscalls the writer has to lose. Measured here against a default ## 65536-byte pipe (confirmed with F_GETPIPE_SZ): ## 41144 bytes -> 141 in 32/300 runs (11%) — well UNDER capacity ## 65012 bytes -> 141 in 25/30 runs — not 100% even AT capacity ## 500 KB into a 1 MiB pipe -> 200/200 SIGPIPE written 4096 bytes at a ## time, 0/200 written as one 500 KB write ## and strace caught printf dying having written 12086 of 40406 bytes into a ## 65536-byte pipe, i.e. losing with 53 KB of room to spare. The reason the same ## image failed 5/5 on whp02 and 10/10 clean in a dev container is the WRITER's ## syscall size: bash <= 5.2.15 pushes ~37 KB per write, bash >= 5.2.21 pushes ## 80-160 bytes, so the newer shell needs hundreds of chances to lose the race ## and the older one needs a couple. (An earlier draft of this comment blamed ## pipe capacity and fs.pipe-user-pages-soft. Both were wrong: that soft limit ## clamps new pipes to two pages rather than one, applies only once a single uid ## holds more than 1024 pipes, and is skipped entirely for CAP_SYS_RESOURCE.) ## ## The only SOUND reasons a call site is safe are structural: ## * the file does not set pipefail; or ## * the reader provably consumes to EOF (no `q`, `-q`, `-l`, `-m`, `exit`, ## `break`); or ## * the pipeline's status is discarded. ## The reader's implementation is not a defence either: at 248 KB, mawk, gawk, ## `grep -q` and `head -1` each returned 141 on 10/10, and these images have ## already drifted between mawk 1.3.4 (cac-lsphp) and gawk 5.2.1 ## (cac-litespeed:php83) — not a property this repo controls. ## ## WHY PURE-BASH MATCHING RATHER THAN A HERE-STRING. `<<<` does remove the ## pipeline, but it is not free: above a build-dependent size bash materialises ## the string as /tmp/sh-thd.XXXXXX, so it makes a writable temp dir a ## PRECONDITION OF BOOTING. Measured in this image (bash 5.2.21) the switch is ## at exactly 65536 bytes and `lsphp -i` is 39934, so the here-string form was ## not hitting disk here — but Debian's bash 5.2.15 switches somewhere between ## 4096 and 16384, where the same payload would. What that costs is not ## theoretical: ## docker run --read-only ... 'SCAN_DIR=$(awk ... <<<"$BIG")' ## -> bash: cannot create temp file for here-document: Read-only file ## system ... and the script is dead: exit 1, PID 1 gone. ## which is the exact boot failure this branch exists to remove, re-acquired ## from a different direction and gated on which bash the base image ships. ## `[[ ]]` and `${...}` allocate nothing and cannot fail that way. Where the ## subject is a couple of hundred bytes and provably cannot approach the ## threshold, a here-string is still fine — see `ols_running` in ## entrypoint-litespeed.sh, which says so at the call site. ## ## THE PATTERNS ARE THE OLD ONES RE-EXPRESSED, NOT APPROXIMATED. ## grep -q '^cac_path_parity support => enabled$' — an anchored whole-line ## match, so the subject is wrapped in a newline at BOTH ends and the glob ## matches \n\n; the wrapping is what keeps the first line and an ## unterminated last line matching exactly as grep matched them. ## grep -q '^PHP Version => ' — anchored at the start ## only, so only a leading newline is added. ## awk -F'=> ' '/^Scan this dir/ {print $2; exit}' — first matching line, ## then the text between the FIRST and SECOND '=> ' on it ($2), or empty if ## there is no separator. `${x#*'=> '}` then `${y%%'=> '*}` is that, exactly. ## scripts/tests/lsphp-info-probe.test.sh asserts this equivalence against the ## grep/awk originals over the edge cases (match on the first line, on the last ## line with no trailing newline, decoy substrings, a second separator, an empty ## value, a missing key), so "same answer as before" is checked, not asserted. ## ## Statuses are unchanged: a genuinely-absent extension is still a clean 1, and ## a genuinely-missing "Scan this dir" line is still empty output with status 0. lsphp_info_has_parity_ext() { [[ $'\n'"$1"$'\n' == *$'\ncac_path_parity support => enabled\n'* ]] } lsphp_info_scan_dir() { local rest line val rest=$'\n'"$1" [[ $rest == *$'\nScan this dir'* ]] || return 0 ## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`. rest=${rest#*$'\nScan this dir'} line="Scan this dir${rest%%$'\n'*}" val="" if [[ $line == *'=> '* ]]; then val=${line#*'=> '} val=${val%%'=> '*} fi printf '%s\n' "$val" } ## Did `lsphp -i` answer at all? Separates "the extension is not there" from ## "our probe produced nothing to look in", so neither gets reported as the ## other. Keyed on the phpinfo banner, which is line 2 of every `lsphp -i` ## (verified against lsphp83 8.3.32) and is not something LSPHP_INFO could ## contain from any other source. lsphp_info_is_usable() { [[ $'\n'"$1" == *$'\nPHP Version => '* ]] } ## ---- CAC-TEST: probe helpers END ---- PATH_PARITY_MODE="none" LSPHP_INFO=$("$LSPHP_BIN" -i 2>/dev/null || true) SCAN_DIR=$(lsphp_info_scan_dir "$LSPHP_INFO") ## `|| true` above is what keeps a broken probe survivable: fail-open is ## deliberate here and below — the site serves either way, only the $_SERVER ## strings differ. What the failure gets REPORTED as is handled at each of the ## two places it changes the outcome (the parity branch, and the no-scan-dir ## else at the bottom of this block). if [ -n "$SCAN_DIR" ]; then mkdir -p "$SCAN_DIR" ## Values emitted double-quoted via printf rather than interpolated into an ## unquoted heredoc — see the INI_TOKENS_OK note above for what that prevents. ## ## Gated on INI_TOKENS_OK for the same reason the mapping below is: this ## fragment interpolates $user into generated ini too. Leaving it ungated was ## an INCONSISTENCY, not a live hole — a newline is inert inside the quotes, ## and a `${`-bearing $user cannot exist because the useradd above would have ## failed under `set -euo pipefail`. But "this particular unvetted value ## happens to be contained" is the reasoning this branch already rejected one ## screenful up, so it is not the reasoning that guards this line either. ## ## Rejecting costs such a user nothing it needs: `log_errors = On` is already ## baked in by 99-prod-overrides.ini, so PHP still logs — to stderr, i.e. ## `docker logs`, which is MORE visible than a per-site file, not less. No ## legitimate user reaches this branch (verified fleet-wide: 30 shared_ols ## sites across 4 hosts, none rejected by the charset check). if [ "$INI_TOKENS_OK" = yes ]; then { echo '; rendered at container start by entrypoint-lsphp.sh' printf 'error_log = "%s"\n' "/home/$user/logs/php-fpm/error.log" echo 'log_errors = On' } > "$SCAN_DIR/99-user-error-log.ini" else ## The container filesystem survives `docker restart`, so a fragment an ## earlier boot wrote from a different env must not outlive the rejection. rm -f "$SCAN_DIR/99-user-error-log.ini" fi ## ---- $_SERVER path parity with cac-fpm ---- ## Point the cac_path_parity extension at THIS site's mapping. Same two ## values the compatibility symlink above is built from, so the rewrite and ## the symlink can never disagree. ## ## Both settings are PHP_INI_SYSTEM: a customer's .user.ini (PHP_INI_PERDIR / ## PHP_INI_USER only) cannot redirect or disable them, and the extension ## occupies no userland hook — so the customer's own auto_prepend_file (the ## Wordfence WAF on several live sites) keeps working untouched. That ## combination is why this is an extension: the previous auto_prepend_file ## normaliser was itself PHP_INI_PERDIR and any site with its own prepend ## silently displaced it, while making OUR prepend win would have disabled ## THEIRS. See ext/cac-path-parity/cac_path_parity.c. if [ "$INI_TOKENS_OK" != yes ]; then ## Already warned above. Write NOTHING: neither the mapping (we will not ## generate ini from an unvetted string) nor the auto_prepend fallback (which ## would not be correct for such a site either). The extension stays inert, ## the request path is unaffected. rm -f "$SCAN_DIR/99-cac-path-parity.ini" "$SCAN_DIR/99-cac-lsphp-normalize.ini" PATH_PARITY_MODE="none (user/domain rejected)" elif lsphp_info_has_parity_ext "$LSPHP_INFO"; then { echo '; rendered at container start by entrypoint-lsphp.sh' printf 'cac_path_parity.from = "%s"\n' "$OLS_SITE_PATH" printf 'cac_path_parity.to = "%s"\n' "/home/$user" } > "$SCAN_DIR/99-cac-path-parity.ini" ## Drop the pre-extension fallback if an older image left one here — the ## container filesystem survives a "docker restart", so an in-place upgrade ## must not keep a stale auto_prepend pointing at the old normaliser. rm -f "$SCAN_DIR/99-cac-lsphp-normalize.ini" PATH_PARITY_MODE="extension" else ## Degraded fallback for an image built before the extension existed (or one ## where it failed to load). Restores the old, .user.ini-defeatable ## behaviour rather than losing normalisation entirely — but say so loudly, ## because in this mode parity is NOT guaranteed. ## ## FAIL-OPEN, DELIBERATELY: a probe that cannot answer must never stop the ## container. The site serves either way; only the $_SERVER strings differ. cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF' ; rendered at container start by entrypoint-lsphp.sh (DEGRADED FALLBACK) auto_prepend_file = /scripts/cac-lsphp-normalize.php EOF ## ...but do not DIAGNOSE more than was established. The old wording said ## "extension not loadable in this image" for EVERY reason this branch is ## reached — including the probe breaking on its own, which is exactly what ## happened (see the SIGPIPE note on the helpers above): a false verdict ## that sent operators to rebuild an image whose extension was fine and ## whose build gate had passed. The claim now carries its evidence, and the ## evidence is real: reaching here at all means SCAN_DIR was parsed out of ## this same output, so `lsphp -i` did answer and its module list is ## authoritative. The case where it did NOT answer never gets here — it is ## caught and reported honestly at the `lsphp_info_is_usable` check above. PATH_PARITY_MODE="auto_prepend (DEGRADED)" echo "WARNING: entrypoint-lsphp: cac_path_parity extension not loadable in this image — '${LSPHP_BIN} -i' answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it — falling back to the auto_prepend normaliser, which a site's own .user.ini auto_prepend_file will silently displace. Rebuild/repull cac-lsphp:php${PHPVER}." >&2 fi ## Per-site opcache override (panel: Advanced Tuning → OpCache size); falls ## back to the baked lsphp-overrides.ini defaults when unset. ## ## SAME INJECTION CLASS AS THE MAPPING ABOVE, and it was left open when that ## one was closed. These two lines interpolated the raw env into an UNQUOTED ## `echo`, so (measured against the pre-fix script on this branch's image) ## OPCACHE_MEMORY_MB=$'128\nprecision = 7\n; ' put `precision = 7` into ## 99-user-opcache.ini and lsphp duly reported `precision => 7`. ## ## WHP does cast (int) and clamp these before setting the env ## (web-files/libs/site-pool-env.php: 32-512 MB, 2000-32000 files) — but ## "the panel validates it" is exactly the argument this branch rejected for ## `domain`, and the panel is a different repo on a different release cadence. ## Validate at the point of use, where the ini is actually generated. ## ## The accepted ranges below are deliberately a strict SUPERSET of the panel's ## clamps (32-512 and 2000-32000), so widening a panel clamp later can never ## start silently rejecting real sites here. ## ## Provenance, stated honestly: max_accelerated_files [200, 1000000] IS PHP's ## own clamp. For memory_consumption, 8 is PHP's documented floor but 4096 is ## OURS — PHP imposes no upper bound on that directive. It is a typo guard, not ## a vendor limit. An out-of-range value is not merely ignored: PHP resets the ## directive to its COMPILED default, discarding the image's own ## `99-prod-overrides` value, which is a further reason to reject rather than ## pass such a value through. OPCACHE_LINES=() if [ -n "${OPCACHE_MEMORY_MB:-}" ]; then validate_ini_num OPCACHE_MEMORY_MB "$OPCACHE_MEMORY_MB" 8 4096 if [ -n "$INI_NUM" ]; then OPCACHE_LINES+=("$(printf 'opcache.memory_consumption = "%s"' "$INI_NUM")") fi fi if [ -n "${OPCACHE_MAX_FILES:-}" ]; then validate_ini_num OPCACHE_MAX_FILES "$OPCACHE_MAX_FILES" 200 1000000 if [ -n "$INI_NUM" ]; then OPCACHE_LINES+=("$(printf 'opcache.max_accelerated_files = "%s"' "$INI_NUM")") fi fi if [ "${#OPCACHE_LINES[@]}" -gt 0 ]; then { echo "; rendered at container start by entrypoint-lsphp.sh" echo "; per-site override from WHP whp.sites.opcache_*_override" printf '%s\n' "${OPCACHE_LINES[@]}" } > "$SCAN_DIR/99-user-opcache.ini" else ## Nothing valid to say. Remove rather than leave whatever a previous boot ## wrote. Defensive only — do not read this as fixing a reachable bug: the ## writable layer does outlive a `docker restart`, but so does the ## environment, and changing these vars requires a RECREATE, which starts ## from a fresh layer with no stale fragment. Kept because it is free, and ## because it makes "no valid override" mean the same thing on every boot ## regardless of how the container got here. rm -f "$SCAN_DIR/99-user-opcache.ini" fi else ## No scan dir means none of the per-site ini drop-ins land — including the ## path-parity mapping. Previously this failed silently; it must not, because ## the tier's cac-fpm parity guarantee is one of the things lost. ## ## Two different things land here and they are not the same report. "lsphp ## reports no additional-ini scan dir" ASSERTS that lsphp answered us, which ## is false when the probe produced nothing at all — and that was the wrong ## half of the same mistake the parity branch above made: describing a probe ## that could not answer as a finding about the image. Say which one it was. if lsphp_info_is_usable "$LSPHP_INFO"; then echo "WARNING: entrypoint-lsphp: lsphp reports no additional-ini scan dir — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied." >&2 PATH_PARITY_MODE="none (no scan dir)" else echo "WARNING: entrypoint-lsphp: '${LSPHP_BIN} -i' produced no usable phpinfo output (${#LSPHP_INFO} bytes) — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied. This is a PROBE failure and establishes nothing about what the image contains; run '${LSPHP_BIN} -i' in this container before concluding anything about it." >&2 PATH_PARITY_MODE="none (lsphp -i unusable)" fi fi echo "entrypoint-lsphp: \$_SERVER path parity = ${PATH_PARITY_MODE} (${OLS_SITE_PATH} -> /home/${user})" ## ---- ownership ---- ## Ensure the dirs we created + the log file are customer-owned so lsphp (running ## as $user) can read code and write logs. Customer content is already ## customer-owned from the host side, so we don't recurse the whole (potentially ## large) tree on every boot. touch "/home/$user/logs/php-fpm/error.log" chown "$uid:$uid" "/home/$user" "/home/$user/public_html" "/home/$user/logs" "/home/$user/logs/php-fpm" "/home/$user/logs/php-fpm/error.log" 2>/dev/null || true ## ---- exec lsphp -b as the customer user (PID 1) ---- ## Bind port is unprivileged (9000), so no root port-bind step is needed — start ## directly as $user. Prefer setpriv (util-linux, on the Ubuntu base); fall back ## to runuser. exec so lsphp becomes PID 1 and receives Docker's signals ## directly (clean stop/restart, matches the php-fpm container's lifecycle). echo "entrypoint-lsphp: exec $LSPHP_BIN -b $LSPHP_BIND as $user (uid=$uid)" if command -v setpriv >/dev/null 2>&1; then exec setpriv --reuid "$uid" --regid "$uid" --init-groups "$LSPHP_BIN" -b "$LSPHP_BIND" elif command -v runuser >/dev/null 2>&1; then exec runuser -u "$user" -- "$LSPHP_BIN" -b "$LSPHP_BIND" else exec sudo -u "$user" -E "$LSPHP_BIN" -b "$LSPHP_BIND" fi