Commit Graph
9 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5 3047123f2b docs(lsphp): correct three comments that overstated what the code does
Review found all three describing behaviour the code does not have:

- The range bound does NOT prevent opcache's shared-memory startup failure.
  With 99-prod-overrides setting interned_strings_buffer=16, a
  memory_consumption of 8 or 16 is accepted here and still aborts opcache.
  Documented rather than raising the floor, which would forfeit the superset
  property.
- memory_consumption's 4096 ceiling is ours, not PHP's — PHP imposes no upper
  bound on that directive. Only the max_accelerated_files range is a vendor
  clamp. Also records that an out-of-range value resets to PHP's COMPILED
  default, discarding the image's own override.
- The stale-fragment rm -f is defensive, not a bug fix: changing these env vars
  requires a recreate, which starts from a fresh layer, so the scenario the
  comment described is not reachable via docker restart.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:08:09 -07:00
Claude 07378506a7 harden(cac-lsphp): close the two remaining unvetted ini emissions; stop MINFO lying
Three non-blocking findings from the re-review of this branch. No design change:
the extension's fail-open-absolute invariant is untouched (still zero error
emitters, every RINIT return is SUCCESS) and both RINIT guards stay pure
narrowing.

1. entrypoint-lsphp.sh: 99-user-opcache.ini was still emitted unquoted
   -------------------------------------------------------------------
   Twenty-five lines below the mapping fix, the opcache override block
   interpolated the raw env into an unquoted `echo` — the same injection class
   the mapping fix closed. Measured on this branch's image, before this commit:

     OPCACHE_MEMORY_MB=$'128\nprecision = 7\n; '
       -> 99-user-opcache.ini gained a `precision = 7` line
       -> lsphp -i reported  precision => 7 => 7

   WHP casts (int) and clamps 32-512 / 2000-32000 (site-pool-env.php), so this
   is not exploitable today — but "the panel validates it" is precisely the
   argument this branch already rejected for `domain`, and the panel is a
   different repo on a different release cadence. Both siblings in the block are
   now validated at the point of use (digits only, length-capped, range-checked)
   and emitted double-quoted. A rejected value is dropped with a WARNING and the
   image default applies; nothing here is ever fatal.

   The accepted ranges are PHP's own limits for these directives (>= 8 MB;
   [200, 1000000] files), deliberately a strict SUPERSET of the panel's clamps,
   so widening a panel clamp later cannot start silently rejecting real sites.

   The block now also removes a stale fragment when it has nothing valid to
   write: the container filesystem outlives `docker restart`, so without that an
   override that is later cleared — or rejected — would keep applying from the
   previous boot's file.

2. 99-user-error-log.ini was written from an unvetted $user
   --------------------------------------------------------
   It was emitted before the INI_TOKENS_OK branch. Contained in practice (a
   newline is inert inside the quotes, and a `${`-bearing user cannot exist
   because useradd would have failed under `set -euo pipefail`), but "this
   particular unvetted value happens to be contained" is the reasoning this
   branch rejected one screenful up. Now gated identically.

   Costs a rejected 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. Verified fleet-wide
   that no legitimate user reaches the branch (30 shared_ols sites, 4 hosts).

3. MINFO reported "active" for mappings RINIT ignores
   ---------------------------------------------------
   The absolute-path guard was added to RINIT and MINFO kept testing only "both
   values non-empty", so:

     from=mnt/users/bob/site.com   (relative -> INERT since the guard landed)
     lsphp -i  ->  Rewriting => active

   That row is what the post-deploy fleet canary greps to confirm parity is live,
   so the diagnostic would have masked exactly the failure the canary exists to
   find — and the C comment added by this branch documents it as the only runtime
   signal. RINIT and MINFO now share one predicate pair
   (cacpp_mapping_configured / cacpp_mapping_active) rather than two longhand
   copies, which is what drifted. MINFO now distinguishes "inactive (mapping not
   absolute)" from "inactive (unconfigured)" — different operational problems.

   Pure reporting change: the predicates are side-effect-free and cannot fail, so
   MINFO gains no error path.

Tests: two new .phpt cover both directions of the MINFO fix (009 relative
mapping must report inactive, 010 well-formed mapping must still report active),
so tightening it cannot overshoot into the opposite lie. The build gate's
EXPECTED count is derived from `ls tests/*.phpt`, so it picked them up: 10/10.

Non-vacuity, all five demonstrated by mutation:
  - opcache quoting reverted     -> injection lands, `precision => 7` observed
  - error-log gate removed       -> fragment written from the unvetted user
  - MINFO reverted to non-empty  -> 009 FAILS, build gate exits 1
  - MINFO forced always-inactive -> 010 FAILS, build gate exits 1
  - all restored                 -> 10/10, build exit 0
2026-08-05 13:46:52 -07:00
shadowdaoandClaude Opus 5 690ff8738d fix(cac-lsphp): stop generating php.ini fragments from unquoted interpolation
The two generated ini drop-ins interpolated $user/$domain into an unquoted
heredoc. Measured against the pre-fix script in a real cac-lsphp:php83 container
with domain=$'evil.com\nprecision = 7\n; ':

  99-cac-path-parity.ini contained the injected line and lsphp reported
  `precision => 7 => 7` — an arbitrary ini directive supplied through the domain
  env var and applied to every request. The `from` value was silently truncated
  at the newline too, so the site also got a wrong (but "active") mapping.

Both values are panel-validated and both already feed `ln -sfn` and the
shared-ols vhost config, so this is defense-in-depth rather than a live hole. It
is worth closing anyway because the OTHER two hostile inputs the reviewer
measured — `$(...)` (ini parse error) and `"` (empty value) — leave the parity
extension INERT, which is precisely the silent failure this whole change set
exists to eliminate.

Two layers, neither of which can fatal a request:
  - values are emitted double-quoted via printf instead of heredoc
    interpolation. php.ini double-quoted values may span newlines, so a newline
    is data, not a new directive.
  - $user/$SAFE_DOMAIN are checked against [A-Za-z0-9._-]+ first, because
    quoting does NOT stop php.ini's own ${VAR} interpolation. A rejected value
    logs a WARNING, writes no mapping at all (not even the degraded
    auto_prepend fallback, which would not be right for such a site either) and
    reports `path parity = none (user/domain rejected)` on the startup line.

After: same container, same hostile domain — no 99-cac-path-parity.ini is
written, `precision => 14` (default), and the warning names the rejected values.
Happy path re-verified for domain=site.com and domain=*.site.com: mapping
written, `Rewriting => active`, from/to parse back byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:05:57 -07:00
shadowdaoandClaude Opus 5 da16faaff5 feat(cac-lsphp): guarantee $_SERVER path parity via a PHP extension
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>
2026-08-05 11:38:19 -07:00
Claude 433e74975c fix(cac-lsphp): enable .user.ini support (LSPHP_ENABLE_USER_INI)
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>
2026-08-02 15:23:05 -07:00
shadowdaoandClaude Opus 4.8 7552760ba0 fix(cac-lsphp): normalize $_SERVER DOCUMENT_ROOT/SCRIPT_FILENAME to /home
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>
2026-06-10 07:02:54 -07:00
shadowdaoandClaude Opus 4.8 fc65b68bd6 fix(cac-lsphp): mount docroot at /home/$user + symlink for true 1:1 compatibility
Customer concern: sites with /home/<user>/public_html baked into config or the
DB must keep working — a changed in-container docroot path would break WordPress
ABSPATH, hardcoded includes, cached absolute paths, etc., making the upgrade a
non-drop-in.

Fix: the sidecar now mounts the docroot at /home/$user (IDENTICAL to
cac-fpm/cac-litespeed) and the entrypoint symlinks /mnt/users/<user>/<domain> ->
/home/$user. OLS still serves from its bulk /mnt/users mount and sends lsphp
that path (no remap available), but the symlink resolves it to the real
/home/$user files AND PHP canonicalises it — so __FILE__/__DIR__/realpath/ABSPATH
all report /home/<user>/public_html.

Verified end-to-end through the shared OLS: a request reports
__FILE__=/home/homeuser/public_html/probe.php, ABSPATH=/home/homeuser/public_html/,
and stored /home paths resolve. True 1:1 drop-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:54:28 -07:00
shadowdaoandClaude Opus 4.8 e99b8cb2d1 fix(cac-lsphp): entrypoint operates on the /mnt/users docroot, not /home/$user
Code-review integration fixes:
- entrypoint-lsphp.sh: the shared-ols tier mounts the docroot at
  /mnt/users/<user>/<domain> (NOT /home/$user). Discover the mount via glob
  (one site per sidecar; wildcard-safe), create public_html + logs/php-fpm under
  it (so OLS docRoot exists), point lsphp error_log there, and chown just those
  dirs. Verified: sidecar creates public_html under the mount, runs as the
  per-site user, OLS serves PHP (SAPI=litespeed) end-to-end.
- shared-ols vhconf.tpl: per-vhost logs -> /usr/local/lsws/logs/<vhname>.* (the
  shared-ols container has no /home/<user>).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:42:31 -07:00
shadowdaoandClaude Opus 4.8 19092911a3 feat(cac-lsphp): detached lsphp (LSAPI) site image for the shared-ols tier
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>
2026-06-09 18:28:34 -07:00