Three non-blocking findings from the review of 8790b02. The fix itself is
unchanged in intent; this makes the reasoning around it true, removes a
regression the fix introduced on the boot path, and stops the new test from
under-reporting.
F1 — the shipped comments explained the bug wrongly, and a wrong rule is what
the next maintainer reasons from. entrypoint-lsphp.sh and
render-shared-ols-config.sh both said the race is decided by PIPE CAPACITY:
"while the output fits the pipe the writer always wins; once it doesn't, SIGPIPE
is guaranteed." Both halves are refuted by measurement against a default
65536-byte pipe (F_GETPIPE_SZ):
41144 bytes -> 141 in 32/300 runs (11%) — well UNDER capacity
65012 bytes -> 141 in 25/30 runs — not certain even AT capacity
500 KB into a 1 MiB pipe -> 200/200 with 4096-byte writes, 0/200 with one
500 KB write
and strace caught printf dying having written 12086 of 40406 bytes into a
65536-byte pipe. The mechanism is a race on whether the reader closes before the
writer's final write() returns; capacity only modulates how many syscalls the
writer needs. What actually separated whp02 (5/5 failures) from a dev container
(10/10 clean) is the WRITER's syscall size: bash <= 5.2.15 writes ~37 KB at a
time, bash >= 5.2.21 writes 80-160 bytes. The fs.pipe-user-pages-soft aside was
also wrong: it clamps to two pages not one, needs one uid holding >1024 pipes,
and is skipped for CAP_SYS_RESOURCE.
Both blocks now state the rule that is actually true — any
`writer | early-exiting-reader` under pipefail is a latent 141; payload size is
not a safety argument; the only sound reasons a call site is safe are structural
(no pipefail, reader provably reads to EOF, or the status is discarded) — and
the same correction is applied to the three other comments that leaned on size
(`ols_running` x2, fpm-parity-check.sh's pre-flight). Nor is the reader's
implementation a defence: at 248 KB, mawk, gawk, `grep -q` and `head -1` all
gave 141 on 10/10, and these images already differ (mawk 1.3.4 vs gawk 5.2.1).
Comment-only; the test file's own section-4 output no longer contradicts the
prose next to it.
F2 — `<<<` added a writable-temp-dir precondition to the boot path. Above a
build-dependent size bash materialises a here-string as /tmp/sh-thd.XXXXXX
(measured switch: 65536 in this image's bash 5.2.21, and Debian's 5.2.15
switches between 4096 and 16384, where a ~40 KB `lsphp -i` WOULD spill). On a
bare assignment a temp file it cannot create is `set -e` killing PID 1 — the
exact failure this branch exists to remove, re-acquired from a different
direction and gated on which bash the base image ships. In cac-lsphp:f1f2f3
under `docker run --read-only`, same payload, same statement shape:
OLD (here-string) : bash: cannot create temp file for here-document
-> exit 1, script dead
NEW (pure bash) : REACHED NEXT STATEMENT, SCAN=[…/mods-available/], exit 0
So the boot-critical sites — the three probe helpers in entrypoint-lsphp.sh and
the SCAN_DIR extraction in entrypoint-litespeed.sh — now match with `[[ ]]` and
parameter expansion, which allocate nothing. The non-boot sites keep their
here-strings and say why at the call site: `ols_running` in both OLS entrypoints
(`lswsctrl status` is under 100 bytes, orders below any spill threshold) and
fpm-parity-check.sh's `php-fpm -m` pre-flight (~1 KB, in a harness that has
already written a docroot and a pool config).
Matching semantics are preserved, not approximated: the anchored whole-line
grep becomes a glob over a subject wrapped in newlines at BOTH ends (so first
and unterminated-last lines still match), and awk's `-F'=> ' {print $2; exit}`
becomes first-matching-line then the text between the FIRST and SECOND
separator. Section 6 of the test asserts that against the original grep/awk
patterns reading a FILE — 24 cases incl. trailing-space, prefix decoys, CRLF,
a second separator, an empty value, two candidate lines, glob metacharacters in
the body, and the full 40 KB fixture. Mutations verify the assertions bite:
dropping the trailing-newline wrap fails 3 cases, taking the whole rest of the
line fails "second separator", `##` instead of `#` fails "first of two wins",
dropping the `^` anchor on the banner fails "banner not at line start".
F3 — the structural scan missed shapes it implied it caught, and the extractor
was unbounded.
* `grep -l`/`-L`/`--quiet`/`--files-with-matches`, `-im1`-style clusters, a
bare `head` before `;`, and `sed q` / `sed 'q'` / `sed 2q` / `sed '$q'` were
all invisible. grep is now walked option by option the way grep reads them
(so `grep -eq foo` stays the pattern "q", not --quiet), and the sed test
reads the script with quote characters stripped but their contents kept.
Replaying the old regexes against the new fixtures: 10 shapes missed and 2
false positives (`sed s/a/q/`, `grep -eq foo`) — both now correct.
* new section 7 pins that coverage from both sides: 19 early-exit shapes must
be reported, 18 read-to-EOF / quoted / non-pipeline forms must not. Without
it the scan's regexes are unfalsified and can quietly stop matching, which
is precisely how `grep -l` and `sed q` stayed missing.
* the helper extraction is bounded. It buffers and emits nothing until it has
seen the END marker (exit 4 = BEGIN without END, exit 3 = no markers), so a
half-deleted pair is a marker error instead of a slurp. Measured on this
entrypoint with the END marker removed: the old extractor produced 301 lines
including `mkdir -p "$SCAN_DIR"` and three `rm -f "$SCAN_DIR/…"` — which the
harness then sourced and ran. It failed loudly last time only because `set
-u` happened to trip two statements in. The new one emits 0 bytes and says
what is wrong.
* the stated scope limits now include what remains: the reader list is an
enumeration, not a proof (nothing knows about `perl -ne … last`, `jq`,
`head -c`), and only the first word after a pipe is inspected.
Verified: PHP 8.3 `--no-cache` build exit 0, 10/10 .phpt; cac-lsphp boots and
logs `path parity = extension` with `Rewriting => active` and .from/.to
populated from the rendered ini; cac-litespeed boots, resolves SCAN_DIR and
writes 99-user-error-log.ini, OLS reports "running with PID", /healthz 200. The
FPM parity harness — never executed by the previous review because no cac-fpm
image existed locally — was built (Dockerfile.fpm, PHPVER=83), the extension
compiled inside it, and it reports 9/9 ALL PASS, exit 0. The new test exits 0
here and exits 1 against a `git archive 9343a56` export naming all 9 offending
lines. `bash -n` clean repo-wide; `shellcheck -S warning` clean on the CI set;
`-S style` is byte-identical to before this commit (5 pre-existing info-level
findings, 0 added — the earlier report's claim of `-S style` clean was wrong).
No `.c`/`.h` file touched and the C fail-open invariant grep is still empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
479 lines
27 KiB
Bash
479 lines
27 KiB
Bash
#!/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 <addr:port>`) and nothing else — no
|
|
## webserver. The shared-ols container connects to this over the docker
|
|
## network (extProcessor type lsapi, address <this-container>: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/<user>/<domain>/public_html. We create a symlink
|
|
## /mnt/users/<user>/<domain> -> /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/<user>/<safe-domain>/public_html
|
|
## (the shared-ols container's view). Point that at our real /home/$user mount so
|
|
## the path resolves. <safe-domain> 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" | <reader>`,
|
|
## 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<line>\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
|