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>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-21 09:46:22 -07:00
1 parent be1540f559
commit 83c2ee9c2a
13 files changed
+2021 -3

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
## lsphp-abi-gate.sh — may PHP extensions compiled against OLD_INC be loaded into
## a PHP built with NEW_INC? Used by lsphp-src-build.sh (emergency php.net build)
## before it keeps LiteSpeed's packaged PECL .so files (redis, imagick,
## memcached, igbinary, msgpack) next to a newer PHP patch release.
##
## WHY: PHP refuses a module whose ZEND_MODULE_API_NO / build-id differ (loud),
## but a changed struct layout under the SAME API number loads fine and corrupts
## memory (silent). Patch releases are not supposed to change layouts, yet the
## php2shell fix (8.x.y, 2026-10-21) did add fields — so this is measured, not
## assumed.
##
## HOW: compile one TU including the public PHP headers (each header in the list
## below that exists in BOTH trees) with -g against each tree, dump every struct/
## union with pahole (incl. anonymous typedef'd ones), and compare:
## * every pre-existing member keeps its offset and size (else FAIL)
## * a new member may only use bytes no old member used (else FAIL)
## (padding, or appended at the end)
## * a struct may change size only if it is *_globals — engine/module globals
## are singletons behind one exported symbol (executor_globals, ps_globals);
## no extension embeds or allocates them (else FAIL)
## A member that is renamed or retyped counts as removed: a false alarm that fails
## loud, never a silent pass.
##
## Usage: lsphp-abi-gate.sh OLD_INC NEW_INC WORKDIR (each *_INC contains php/)
## Env: ABI_MIN_MEMBERS (default 1000) — fewer members extracted from OLD_INC
## means the probe itself is broken; refuse rather than pass vacuously.
## Exit 0 = compatible (report on stdout), 1 = incompatible (report on stderr).
## Tested by scripts/tests/lsphp-abi-gate.test.sh.
set -euo pipefail
OLD_INC="${1:?usage: lsphp-abi-gate.sh OLD_INC NEW_INC WORKDIR}"
NEW_INC="${2:?usage: lsphp-abi-gate.sh OLD_INC NEW_INC WORKDIR}"
WORK="${3:?usage: lsphp-abi-gate.sh OLD_INC NEW_INC WORKDIR}"
MIN="${ABI_MIN_MEMBERS:-1000}"
mkdir -p "$WORK"
HDRS=(php.h php_ini.h SAPI.h ext/standard/info.h ext/standard/php_var.h
ext/standard/php_string.h ext/standard/basic_functions.h ext/standard/url.h
ext/standard/php_incomplete_class.h ext/standard/php_smart_string.h
ext/session/php_session.h ext/json/php_json.h ext/date/php_date.h
ext/hash/php_hash.h ext/random/php_random.h ext/spl/spl_exceptions.h
ext/pcre/php_pcre.h zend_exceptions.h zend_interfaces.h zend_smart_str.h
zend_closures.h zend_generators.h zend_observer.h zend_attributes.h
zend_enum.h zend_weakrefs.h zend_fibers.h zend_extensions.h)
: >"$WORK/abi.c"
for h in "${HDRS[@]}"; do
for t in "$OLD_INC/php" "$NEW_INC/php"; do
[ -e "$t/$h" ] || [ -e "$t/main/$h" ] || [ -e "$t/Zend/$h" ] || continue 2
done
printf '#include "%s"\n' "$h" >>"$WORK/abi.c"
done
[ -s "$WORK/abi.c" ] || { echo "ABI gate: none of the probe headers exist in both trees" >&2; exit 1; }
dump() { # $1 = include root, $2 = tag
local inc="$1/php" o="$WORK/abi-$2.o"
gcc -g -O0 -c -fno-eliminate-unused-debug-types \
-I"$inc" -I"$inc/main" -I"$inc/TSRM" -I"$inc/Zend" -I"$inc/ext" -I"$inc/ext/date/lib" \
-o "$o" "$WORK/abi.c"
## M <struct> <decl>#<n> <offset[:bit]> <size> and S <struct> <size>
## Named: "struct _zend_object {" ... "};" /* size: N ... */
## Anonymous: "typedef struct {" ... "} zend_fcall_info_cache;"
pahole -a -F dwarf "$o" | awk '
/^(typedef )?(struct|union) ([A-Za-z_0-9]+ )?\{/ {
depth = 1; nm = 0; sz = ""; delete seen
name = ($1 == "typedef") ? "" : $2; next }
depth == 0 { next }
depth == 1 && /^\}/ {
if (name == "") { n = $0; sub(/^\}[ \t]*/, "", n); sub(/;.*$/, "", n); name = n }
for (i = 1; i <= nm; i++) print "M", name, mem[i]
if (sz != "") print "S", name, sz
depth = 0; next }
depth == 1 && /\/\* size: [0-9]+/ { t = $0; sub(/^.*size: /, "", t); sub(/[^0-9].*$/, "", t); sz = t; next }
/\{[ \t]*$/ { depth++; next }
/^[ \t]+\}/ { depth-- }
/;[ \t]*\/\*[ \t]+[0-9]+(:[ \t]*[0-9]+)?[ \t]+[0-9]+[ \t]+\*\// {
d = $0; sub(/;.*$/, "", d); gsub(/[ \t]+/, " ", d); sub(/^ /, "", d); gsub(/ /, "_", d)
c = $0; sub(/^.*\/\*[ \t]+/, "", c); sub(/[ \t]+\*\/.*$/, "", c); gsub(/:[ \t]+/, ":", c)
k = split(c, w, /[ \t]+/)
seen[d]++
mem[++nm] = d "#" seen[d] " " w[1] " " w[k] }
' >"$WORK/abi-$2.txt"
}
dump "$OLD_INC" old
dump "$NEW_INC" new
NOLD=$(awk '$1 == "M"' "$WORK/abi-old.txt" | wc -l)
NSTRUCT=$(awk '$1 == "S"' "$WORK/abi-old.txt" | wc -l)
if [ "$NOLD" -lt "$MIN" ]; then
echo "ABI gate: only $NOLD struct members extracted from $OLD_INC (< $MIN) — the probe is broken; refusing to call that compatible." >&2
exit 1
fi
REPORT=$(awk '
FNR == NR { if ($1 == "M") { old[$2 " " $3] = $4 " " $5; ost[$2] = 1 } else if ($1 == "S") osz[$2] = $3; next }
$1 == "M" { new[$2 " " $3] = $4 " " $5; nst[$2] = 1 }
$1 == "S" { nsz[$2] = $3 }
END {
for (k in old) { split(k, p, " "); if (!(p[1] in nst)) continue
if (!(k in new)) print "BAD removed/renamed member " k " (was " old[k] ")"
else if (old[k] != new[k]) print "BAD moved member " k ": " old[k] " -> " new[k] }
for (k in new) { if (k in old) continue; split(k, p, " "); if (!(p[1] in ost)) continue
split(new[k], q, " "); s = q[1] + 0; e = s + q[2]
hit = ""
for (o in old) { split(o, r, " "); if (r[1] != p[1]) continue; split(old[o], z, " ")
if (s < z[1] + z[2] && z[1] + 0 < e) { hit = o; break } }
if (hit != "") print "BAD new member " k " at " new[k] " overlaps old " hit
else print "OK new member " k " at " new[k] " (padding/appended)" }
for (st in osz) { if (!(st in nsz) || osz[st] == nsz[st]) continue
if (st ~ /_globals$/) print "OK size " st " " osz[st] " -> " nsz[st] " (*_globals singleton)"
else print "BAD size " st " " osz[st] " -> " nsz[st] }
}' "$WORK/abi-old.txt" "$WORK/abi-new.txt" | sort)
if [[ $'\n'"$REPORT" == *$'\nBAD '* ]]; then
printf '%s\n' "$REPORT" >&2
echo "ABI gate: INCOMPATIBLE ($NOLD members / $NSTRUCT structs compared)" >&2
exit 1
fi
[ -z "$REPORT" ] || printf '%s\n' "$REPORT"
echo "ABI gate: compatible ($NOLD members / $NSTRUCT structs compared)"