Files
cloud-apache-container/Dockerfile.lsphp
shadowdaoandClaude Opus 5.5 83c2ee9c2a feat(lsphp): emergency build path compiling lsphp from php.net source
For when a PHP security release is out and the LiteSpeed apt repo lags
(2026-10-21: php2shell CVE-2026-109618/-109619 fixed in 8.2.35/8.3.36/
8.4.27/8.5.12, LiteSpeed still on 8.2.34/8.3.35/8.4.26/8.5.11).

Dockerfile.lsphp / Dockerfile.litespeed gain an `lsphp-src` stage that is
idle unless PHP_SRC_VERSION is set; normal builds are unchanged (measured:
module list, file manifest, ldd, ini and dpkg identical to the published
images).

When set (lsphp-src-build.sh):
- tarball sha256 (php.net releases JSON) + OpenPGP signature against the
  committed php.net keyring, signer pinned per branch
- configure flags replayed from the packaged lsphp's own Configure Command
- LiteSpeed's php-litespeed-8.3 SAPI (what the packages actually compile,
  measured) instead of php-src's sapi/litespeed; sha256-pinned and checked
  against the packaged binary's LSAPI_* names
- packaged compiled-in ini defaults reproduced (session.save_path on 8.2/
  8.3) and gated (`php -n` diff)
- ABI gate (lsphp-abi-gate.sh, pahole) before reusing LiteSpeed's packaged
  PECL .so files; regression-tested in Shell-Checks

lsphp-src-overlay.sh swaps only php-src-owned files (decided by dpkg) and
fails the build unless extensions, versions, effective ini and feature
lines match the packaged lsphp under both CLI and litespeed SAPIs.

CI: lsphp-source-emergency.yaml (workflow_dispatch only). build-push.yaml
refuses to overwrite a newer php.net image with an older packaged one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:46:22 -07:00

316 lines
18 KiB
Docker

## cac-lsphp — per-site DETACHED lsphp (LSAPI) backend for the shared-ols tier.
##
## The LiteSpeed analogue of cac-fpm: a slim, single-tenant PHP backend that
## runs `lsphp -b 0.0.0.0:9000` (detached LSAPI mode) and NOTHING ELSE — no
## webserver. The shared OpenLiteSpeed container (shared-ols) sits in front and
## reaches this over the docker network via an extProcessor of type lsapi,
## address <this-container>:9000 — structurally identical to how shared-httpd
## reaches a cac-fpm container's php-fpm on :9000.
##
## Built on the SAME LiteSpeed prebuilt base as cac-litespeed so the lsphp
## binary + extension set are byte-for-byte the runtime customers already get
## on the litespeed tier (memcached, redis, imagick, mbstring, mysqlnd, intl,
## gd, soap, bcmath, gmp, sodium, opcache, ... + lsphpNN-ldap added below).
## We do NOT strip the bundled OpenLiteSpeed binaries: the "no webserver"
## guarantee comes from the ENTRYPOINT (it only ever execs lsphp), and deleting
## OLS files from the upstream image risks breaking lsphp's shared libs for no
## real benefit. Only :9000 is EXPOSEd, and OLS is never started.
##
## See the design spec + PoC: whp docs/superpowers/plans/2026-06-09-ols-lsphp-tier.md
## and the LSAPI path-parity finding (feedback_ols_lsapi_no_script_filename_remap).
ARG OLS_VERSION=1.8.4
ARG PHPVER=83
## ---- stage 0 (EMERGENCY ONLY): lsphp compiled from php.net source -----------
## Idle unless PHP_SRC_VERSION is set: lsphp-src-build.sh then exits at once and
## /lsphp-src-out stays empty, so every normal build is unchanged (the overlay
## steps below are no-ops). Set PHP_SRC_VERSION + PHP_SRC_SHA256 + LSPHP_SOURCE=php.net
## ONLY when a PHP security release is out and the LiteSpeed apt repo still lags
## — CI: .gitea/workflows/lsphp-source-emergency.yaml; runbook:
## docs/lsphp-source-emergency.md. The tarball is sha256- AND OpenPGP-verified
## against keys/php-src/; configure flags are replayed from the packaged lsphp.
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} AS lsphp-src
ARG PHPVER=83
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
ARG PHP_SRC_VERSION=
ARG PHP_SRC_SHA256=
RUN --mount=type=bind,source=scripts,target=/mnt/scripts \
--mount=type=bind,source=keys/php-src,target=/usr/local/share/php-src-keys \
set -e; \
if [ -n "$PHP_SRC_VERSION" ]; then \
install -m 0755 /mnt/scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh; \
fi; \
PHPVER="${PHPVER}" bash /mnt/scripts/lsphp-src-build.sh
## ---- stage 1: build the cac_path_parity extension --------------------------
## $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] parity with cac-fpm, enforced
## from RINIT so a customer's .user.ini cannot displace it — see
## ext/cac-path-parity/cac_path_parity.c for why this is an extension and not an
## auto_prepend_file.
##
## WHICH lsphp THE .so IS BUILT AGAINST — read this before touching the apt lines.
## `lsphp${PHPVER}-dev` is NOT available at the version the base image ships:
## the LiteSpeed apt repo carries only the CURRENT release, and every prebuilt
## OLS base image is behind it (measured 2026-08-05 on OLS 1.8.4:
## lsphp81 8.1.33 base / 8.1.34 repo, lsphp83 8.3.28 / 8.3.32,
## lsphp85 8.5.0 / 8.5.8).
## Pinning -dev to the base version fails on all three with
## `E: Version '<base>' for 'lsphp<NN>-dev' was not found`.
##
## So installing -dev necessarily UPGRADES lsphp in this stage. The parity we can
## have — and the one this file now guarantees — is the other direction: the
## shipped runtime is pinned to whatever version this stage compiled against.
## That version is recorded here and consumed by stage 2, so the two apt layers
## are cache-locked to each other. Without this, `COPY ./ext` invalidating only
## THIS stage while stage 2's apt layer stayed cached produced a real, repeatable
## skew (reviewer measured a .so built on 8.3.32 shipped next to an 8.3.30
## runtime, with lsphp83-common at 8.3.31 — the vendor family is not always
## uniformly versioned either).
##
## Benign in practice (PHP holds ABI stable across a patch series) but it is the
## riskier direction — headers NEWER than the runtime — and the runtime assertion
## in stage 2 catches only load failure, never silent struct-layout drift.
##
## Separate stage on purpose: the compiler + headers (~400MB) stay out of the
## shipped image, which gains only the ~40KB .so. Costs ~1-2 min of CI per PHP
## version; both stages share the same base layer, so no extra pull.
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} AS ext-build
ARG PHPVER=83
## Toolchain layer, deliberately BEFORE the source COPY so editing the extension
## does not re-resolve the PHP version (which is what caused the skew above).
## Records the exact lsphp version the headers belong to; stage 2 pins to it.
##
## lsphp-apt-upgrade.sh (see its header) refuses to run on stale/unreachable
## repo lists and sweeps every lsphp${PHPVER}* package to the repo's current
## release. CACHE_BUST is per-CI-run so this layer — and therefore the recorded
## version stage 2 pins to — is never a cache hit; it is re-declared in stage 2.
## --no-os-upgrade: this stage is thrown away, OS updates would only cost time.
## EXPECT_* (CI's repo-index lookup) are asserted here too, so a mismatch fails
## before the compile rather than after it.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN set -e; \
/usr/local/sbin/lsphp-apt-upgrade.sh --no-os-upgrade "${PHPVER}" \
build-essential autoconf pkg-config \
lsphp${PHPVER}-dev; \
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
DEVV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}-dev); \
if [ "$RTV" != "$DEVV" ]; then \
echo "FATAL: lsphp${PHPVER}=$RTV but lsphp${PHPVER}-dev=$DEVV — the headers" >&2; \
echo " do not belong to the PHP in this stage. Refusing to build." >&2; \
exit 1; \
fi; \
mkdir -p /build-out; \
printf '%s' "$RTV" > /build-out/lsphp.version; \
echo "cac_path_parity will be compiled against lsphp${PHPVER} $RTV"
## Build, then RUN THE .phpt SUITE as a build gate. Before this, ext/…/tests/
## existed but nothing ever executed it — neither this Dockerfile nor
## .gitea/workflows/build-push.yaml — so six green tests were documentation. It
## costs ~1s per PHP version and it runs against the SAME lsphp build the .so
## will ship next to.
##
## Two guards around `make test`, because its default failure mode is silence:
## - if PHP_EXECUTABLE is missing the Makefile prints "Cannot run tests without
## CLI sapi." and EXITS 0. The lsphp packages do ship a real CLI
## (php-config --php-binary => .../bin/phpN.N), but assert it rather than
## trusting it.
## - a run that executes ZERO tests also exits 0, so assert the summary shows
## every .phpt in the directory both ran and passed. Same reasoning as the
## `lsphp -i` probe below: an assertion that cannot fail is worse than none.
## EMERGENCY PATH: swap the packaged lsphp + its -dev files for the php.net build
## (no-op when stage 0 was idle), so cac_path_parity below compiles against, and
## its .phpt suite runs on, the PHP this image will actually ship.
ARG PHP_SRC_VERSION=
ARG LSPHP_SOURCE=litespeed-apt
RUN --mount=type=bind,from=lsphp-src,source=/lsphp-src-out,target=/lsphp-src-out,rw \
--mount=type=bind,source=scripts,target=/mnt/scripts \
install -m 0644 /mnt/scripts/lsphp-src-probe.php /usr/local/share/lsphp-src-probe.php && \
bash /mnt/scripts/lsphp-src-overlay.sh dev "${PHPVER}" && \
rm -f /usr/local/share/lsphp-src-probe.php
COPY ./ext/cac-path-parity /usr/src/cac-path-parity
RUN set -e; \
cd /usr/src/cac-path-parity; \
/usr/local/lsws/lsphp${PHPVER}/bin/phpize; \
./configure --enable-cac-path-parity \
--with-php-config=/usr/local/lsws/lsphp${PHPVER}/bin/php-config; \
make -j"$(nproc)"; \
PHP_BIN=$(/usr/local/lsws/lsphp${PHPVER}/bin/php-config --php-binary); \
if [ ! -x "$PHP_BIN" ]; then \
echo "FATAL: no CLI php at '$PHP_BIN' — \`make test\` would print" >&2; \
echo " 'Cannot run tests without CLI sapi.' and exit 0." >&2; \
exit 1; \
fi; \
EXPECTED=$(ls tests/*.phpt | wc -l); \
if [ "$EXPECTED" -lt 1 ]; then echo "FATAL: no .phpt tests found" >&2; exit 1; fi; \
if ! NO_INTERACTION=1 REPORT_EXIT_STATUS=1 make test >/tmp/make-test.log 2>&1; then \
cat /tmp/make-test.log >&2; \
echo "FATAL: cac_path_parity .phpt suite FAILED — not shipping this .so." >&2; \
exit 1; \
fi; \
cat /tmp/make-test.log; \
if ! grep -Eq "^Number of tests : +${EXPECTED} +${EXPECTED} *$" /tmp/make-test.log; then \
echo "FATAL: expected all ${EXPECTED} .phpt tests to run; the summary above disagrees." >&2; \
exit 1; \
fi; \
if ! grep -Eq "^Tests failed +: +0 " /tmp/make-test.log; then \
echo "FATAL: run-tests.php reported failures." >&2; \
exit 1; \
fi; \
echo "cac_path_parity: ${EXPECTED}/${EXPECTED} .phpt tests passed"; \
cp modules/cac_path_parity.so /build-out/
## ---- stage 2: the shipped sidecar image ------------------------------------
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
ARG PHPVER=83
ENV PHPVER=${PHPVER}
## Match the cac-litespeed extension surface exactly: the only ext the prebuilt
## base lacks is lsphpNN-ldap. setpriv (util-linux) is already on the Ubuntu
## base; we add nothing else the sidecar doesn't need. All apt cache cleaned in
## the same layer to keep the image small.
##
## VERSION LOCKSTEP: `apt-get install lsphpNN-ldap` pulls lsphpNN-common forward,
## which drags the whole lsphpNN family to the repo's current release — the same
## upgrade the ext-build stage gets. Left implicit, the two stages resolve that
## independently and Docker caches them independently, so they drift apart (see
## the long comment on stage 1). Copying stage 1's recorded version in BEFORE
## this layer makes the version part of this layer's cache key: same version =>
## cache hit, new version => this layer re-runs and lands on the same one. The
## explicit `=$V` pins then make a mid-build repo roll a LOUD apt failure instead
## of a silent skew. Verified satisfiable on PHP 8.1/8.3/8.5 (2026-08-05).
##
## SECURITY UPDATES AT BUILD TIME: lsphp-apt-upgrade.sh upgrades the Ubuntu OS
## packages and every other lsphp${PHPVER}* package (extensions such as -msgpack
## are NOT dragged along by the -common pin) to the repos' current versions, and
## fails the build if the repo is unreachable. CACHE_BUST makes this layer a
## guaranteed cache miss per CI run; EXPECT_* are CI's repo-index lookup,
## asserted by the script and written into the image labels below.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY --from=ext-build /build-out/lsphp.version /etc/cac-lsphp-build.version
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN set -e; \
V=$(cat /etc/cac-lsphp-build.version); \
if ! /usr/local/sbin/lsphp-apt-upgrade.sh "${PHPVER}" \
ca-certificates \
lsphp${PHPVER}="$V" lsphp${PHPVER}-common="$V" lsphp${PHPVER}-ldap="$V"; then \
echo "FATAL: lsphp-apt-upgrade.sh failed (reason above). If apt said" >&2; \
echo " \"Version '$V' ... was not found\" or refused a downgrade: $V is what" >&2; \
echo " cac_path_parity was compiled against, but the LiteSpeed repo (which" >&2; \
echo " keeps only the current release) now offers a newer one — either it" >&2; \
echo " published mid-build or ext-build was a stale cache hit. Re-run the" >&2; \
echo " build (CI) / rebuild with --no-cache (local)." >&2; \
exit 1; \
fi; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
CMV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}-common); \
if [ "$RTV" != "$V" ] || [ "$CMV" != "$V" ]; then \
echo "FATAL: cac_path_parity.so was compiled against lsphp${PHPVER} $V but this" >&2; \
echo " image would ship lsphp${PHPVER}=$RTV / -common=$CMV." >&2; \
echo " Rebuild with --no-cache so both stages resolve the same release." >&2; \
exit 1; \
fi; \
echo "runtime lsphp${PHPVER} pinned to $V (the version cac_path_parity was built against)"
## EMERGENCY PATH: overlay the php.net lsphp (no-op on normal builds) and prove
## it has the same extension/feature surface as the packaged one just installed
## — see scripts/lsphp-src-overlay.sh. Bind mounts, so neither the staged build
## nor the scripts add a layer to the image.
ARG PHP_SRC_VERSION=
ARG LSPHP_SOURCE=litespeed-apt
RUN --mount=type=bind,from=lsphp-src,source=/lsphp-src-out,target=/lsphp-src-out,rw \
--mount=type=bind,source=scripts,target=/mnt/scripts \
install -m 0644 /mnt/scripts/lsphp-src-probe.php /usr/local/share/lsphp-src-probe.php && \
bash /mnt/scripts/lsphp-src-overlay.sh runtime "${PHPVER}" && \
rm -f /usr/local/share/lsphp-src-probe.php
## Scripts + the SHARED production lsphp ini (reused verbatim from the litespeed
## image — same runtime, same tuning). Scripts layer last (they change most).
COPY ./scripts/entrypoint-lsphp.sh \
./scripts/detect-memory-lsphp.sh \
./scripts/healthcheck-lsphp.sh \
./scripts/cac-lsphp-normalize.php \
/scripts/
RUN chmod +x /scripts/entrypoint-lsphp.sh /scripts/detect-memory-lsphp.sh /scripts/healthcheck-lsphp.sh
## Apply production lsphp ini overrides into lsphp's scan dir (path varies by
## PHP minor version; ask lsphp directly — same idiom as Dockerfile.litespeed).
COPY ./configs/litespeed/lsphp-overrides.ini /etc/lsws-templates/lsphp-overrides.ini
## php2shell mitigation (CVE-2026-109618/-109619). Named zz- so it sorts after
## every other scan-dir ini (incl. runtime-written 99-*) and wins. Remove once
## all images ship PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.
COPY ./configs/zz-php2shell-mitigation.ini /etc/lsws-templates/zz-php2shell-mitigation.ini
RUN bash -c 'set -e; \
SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$SCAN_DIR"; \
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
cp /etc/lsws-templates/zz-php2shell-mitigation.ini "$SCAN_DIR/zz-php2shell-mitigation.ini"; \
echo "wrote overrides to $SCAN_DIR"'
## Install the cac_path_parity extension into lsphp's own extension_dir and load
## it unconditionally. It is INERT until the entrypoint writes the per-site
## cac_path_parity.from/.to mapping, so it is safe in any context (including
## wp-cli runs, where $_SERVER carries no filesystem paths).
##
## The trailing `lsphp -i | grep` is a BUILD-TIME ASSERTION: if the .so fails to
## load (ABI drift after a base-image PHP bump, bad build) the image build fails
## here rather than shipping a sidecar that silently lost path parity. Note its
## limit: it proves the .so LOADS, not that it was built against these exact
## structs — silent layout drift would sail straight through. The version lockstep
## above is what actually removes that possibility; this stays as the backstop.
## NOTE: probe lsphp with `-i` ONLY. The lsphp binary is the LSAPI SAPI, not the
## CLI — it accepts just -[b|c|n|h|i|q|s|v|?] and answers anything else (`-m`,
## `-r`) by printing its usage text and exiting 0. A `lsphp -m | grep` check
## therefore never matches AND never fails, which is exactly the kind of silent
## always-false assertion this whole change exists to eliminate.
COPY --from=ext-build /build-out/cac_path_parity.so /tmp/cac_path_parity.so
RUN bash -c 'set -e; \
LSPHP="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"; \
EXT_DIR=$("$LSPHP" -i 2>/dev/null | awk -F" => " "/^extension_dir/ {print \$2; exit}"); \
SCAN_DIR=$("$LSPHP" -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$EXT_DIR" "$SCAN_DIR"; \
mv /tmp/cac_path_parity.so "$EXT_DIR/"; \
printf "; installed by Dockerfile.lsphp\nextension=cac_path_parity.so\n" \
> "$SCAN_DIR/00-cac-path-parity.ini"; \
"$LSPHP" -i 2>/dev/null | grep -q "^cac_path_parity support => enabled$"; \
echo "cac_path_parity installed into $EXT_DIR and verified loadable"'
## php-lsapi gates .user.ini parsing behind this env var (see entrypoint-lsphp.sh
## for the full explanation). Set here so the value is visible in `docker inspect`
## and survives an entrypoint override; the entrypoint re-exports it with the same
## default so the runuser exec path can't drop it.
ENV LSPHP_ENABLE_USER_INI=on
## Inspect without running the image:
## docker buildx imagetools inspect <ref> --format '{{json .Image.Config.Labels}}'
## Values come from CI's repo-index lookup and were asserted against the
## installed packages by lsphp-apt-upgrade.sh above ("unverified" = local build).
## Emergency (php.net) builds: org.whp.php-version is the php.net version the
## overlay step asserted; org.whp.lsphp-package-version still names the LiteSpeed
## packages underneath (their ini/PECL files are what the image keeps);
## org.whp.lsphp-source says which one is running. /etc/cac-lsphp-source has the
## tarball sha256 + signer.
LABEL org.whp.php-version="${PHP_SRC_VERSION:-${EXPECT_PHP_VERSION:-unverified}}" \
org.whp.lsphp-package-version="${EXPECT_LSPHP_PKG:-unverified}" \
org.whp.lsphp-source="${LSPHP_SOURCE}" \
org.whp.php-flavour="lsphp${PHPVER}"
EXPOSE 9000
## TCP-connect + lsphp-alive check (LSAPI isn't FastCGI, so no cgi-fcgi ping).
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD /scripts/healthcheck-lsphp.sh
ENTRYPOINT ["/scripts/entrypoint-lsphp.sh"]