Files
cloud-apache-container/scripts/render-shared-ols-config.sh
T
shadowdaoandClaude Opus 5 8790b027a9 fix(lsphp): stop SIGPIPE+pipefail reporting the parity extension as missing
`entrypoint-lsphp.sh` decided whether cac_path_parity was loaded with

    printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'

under `set -euo pipefail`. `grep -q` exits on its first match; printf is still
writing the remaining ~40 KB of `lsphp -i`, takes SIGPIPE, exits 141, and
pipefail prefers 141 over grep's 0. The branch therefore evaluated FALSE
*because the extension was present* — present early enough to stop the reader —
and every affected container fell back to the auto_prepend normaliser that a
customer's own .user.ini silently displaces, i.e. the exact failure the
extension exists to remove. Measured on whp02 against the published
cac-lsphp:php83: 5/5 runs status=141 with pipefail, 0 without.

The race is decided by pipe capacity, which is why it reproduced on whp02 and
not on other daemons: while the payload fits the pipe the writer never blocks
and always finishes first. Forced over the limit it is deterministic — 3x the
same `lsphp -i` (122100 bytes) gives 141 every time in the built image.

Fixed by reading with here-strings, which are not pipelines at all, so there is
no second exit status for pipefail to adopt. Same grep/awk patterns; plumbing
only. Same class fixed everywhere it existed under pipefail:

  * entrypoint-lsphp.sh      parity probe, and the SCAN_DIR awk probe
  * entrypoint-litespeed.sh  SCAN_DIR probe (a bare assignment: 141 there does
                             not degrade, `set -e` kills PID 1), and ols_running
  * entrypoint-shared-ols.sh ols_running
  * render-shared-ols-config.sh  site.meta parsing (`sed | head -1`): measured
                             141 at 6000 duplicate keys, which under `set -e`
                             aborts the whole render
  * fpm-parity-check.sh      the `php-fpm -m` pre-flight, whose whole job is to
                             stop a harness fault being blamed on the extension

Also: the fallback used to announce "cac_path_parity extension not loadable in
this image" for every reason the branch was reached, including its own plumbing
breaking — a false diagnosis that sends operators to rebuild a good image whose
build gate passed. Verdicts now carry the evidence they rest on, and a probe
that produced nothing is reported as a probe failure that establishes nothing
about the image. Fail-open posture is unchanged: no probe failure is fatal.

Adds scripts/tests/lsphp-info-probe.test.sh, which runs the shipped probes
(extracted verbatim, so they cannot drift from what runs in production) under
`set -euo pipefail` against a realistic ~40 KB phpinfo body, and statically
outlaws the shape repo-wide. Against trunk it fails, naming all 9 offending
lines. Wired into CI as a new Shell-Checks job, because no existing gate ever
executed the entrypoint's branch logic — the .phpt suite and the Dockerfile's
own `lsphp -i | grep -q` probe (which has no pipefail) were both green for the
release whose entrypoint declared that same extension missing.

Verified: PHP 8.3 --no-cache build green, 10/10 .phpt, 9/9 FPM harness; the
built image logs `path parity = extension` and reports `Rewriting => active`
with .from/.to populated; ext-removed and probe-broken variants each produce
their own honest message and still start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:23:56 -07:00

191 lines
8.7 KiB
Bash

#!/usr/bin/env bash
## render-shared-ols-config.sh — assemble httpd_config.conf for the shared-ols
## tier from the per-site files the WHP panel drops into $SITES_ROOT.
##
## WHY THIS EXISTS: OpenLiteSpeed has NO top-level `include` directive (unlike
## Apache's IncludeOptional that shared-httpd relies on). So we cannot just drop
## per-vhost files in a dir and have OLS pick them up — the listener `map` lines
## and the vhost stanzas must live IN httpd_config.conf. This script is the
## "include" OLS lacks: it concatenates the panel's per-site pieces into one
## valid httpd_config.conf, then the caller issues `lswsctrl restart`.
## (Empirically established 2026-06-10 — see the OLS-tier PoC.)
##
## Per-site contract — the panel writes, for each site, a directory:
## $SITES_ROOT/<vhname>/vhconf.conf (rendered by the WHP panel from its own
## web-files/configs/shared-ols-vhconf-template.tpl
## — the single source of truth for vhost detail)
## $SITES_ROOT/<vhname>/site.meta (VHNAME=, VHROOT=, DOMAINS=a.com,www.a.com)
## This script turns each into a `virtualhost {configFile}` stanza + a listener
## `map` line. A site dir missing either file is skipped (logged).
##
## Idempotent: always rebuilds from the stock config, so re-runs never compound.
set -euo pipefail
LSWS_CONF=/usr/local/lsws/conf
TPL_DIR=${TPL_DIR:-/etc/shared-ols-templates}
SITES_ROOT=${SITES_ROOT:-$LSWS_CONF/shared-sites}
LSCACHE_ROOT=${LSCACHE_ROOT:-/var/lscache}
CERT_FILE=${CERT_FILE:-$LSWS_CONF/cert/shared-ols.crt}
KEY_FILE=${KEY_FILE:-$LSWS_CONF/cert/shared-ols.key}
export LSCACHE_ROOT
OUT="$LSWS_CONF/httpd_config.conf"
TMP="$LSWS_CONF/.httpd_config.conf.tmp.$$"
STOCK="/usr/local/lsws/.conf/httpd_config.conf"
mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT"
## --- SERIALIZE concurrent renders + write ATOMICALLY ---
## The panel can fire two renders at once (parallel provisioning), and the
## in-container .htaccess watcher issues `lswsctrl restart` independently. If OLS
## (re)reads httpd_config.conf while it's half-written, it fails to parse and the
## whole tier 503s. So: (1) flock so only one render runs at a time; (2) build
## into $TMP and atomically `mv` into place at the end, so any concurrent OLS
## restart always sees a COMPLETE config (the old one until the instant of mv).
exec 9>"$LSWS_CONF/.render.lock"
## Bounded wait (-w): if a previous render is hung, fail after 30s rather than
## blocking the panel's `docker exec` call (and thus the site-save request)
## indefinitely. The caller re-tries on the next change.
flock -w 30 9 || { echo "render-shared-ols: could not acquire render lock within 30s" >&2; exit 1; }
trap 'rm -f "$TMP"' EXIT
## Sweep any stale temp configs left by a prior SIGKILL (trap EXIT doesn't run on
## SIGKILL); each render uses a unique $$ suffix so this never races a live render.
rm -f "$LSWS_CONF"/.httpd_config.conf.tmp.* 2>/dev/null || true
## From here on, build into $TMP (not $OUT).
## --- 1. start from a pristine stock config (idempotent) ---
if [ ! -f "$STOCK" ]; then
## Some image builds keep the only copy at conf/; snapshot it once so future
## renders have a clean base to strip.
mkdir -p "$(dirname "$STOCK")"
cp "$OUT" "$STOCK"
fi
## --- 2. strip stock blocks that conflict or would run PHP LOCALLY ---
## extProcessor lsphp (autoStart 1, uds) + the server scriptHandler are removed
## so this server NEVER executes PHP itself — all PHP goes to remote sidecars.
## listener HTTP/HTTPS + vhTemplate docker are removed (we add our own).
awk '
/^listener HTTP \{/ { skip=1; next }
/^listener HTTPS \{/ { skip=1; next }
/^vhTemplate docker ?\{/ { skip=1; next }
/^extProcessor lsphp ?\{/{ skip=1; next }
/^scriptHandler ?\{/ { skip=1; next }
skip && /^\}/ { skip=0; next }
!skip { print }
' "$STOCK" > "$TMP"
## --- 3. append our server-level base (real-IP, cache module, no local PHP) ---
{
echo ""
envsubst '${LSCACHE_ROOT}' < "$TPL_DIR/httpd_config_base.tpl"
} >> "$TMP"
## --- 4. emit per-site vhost stanzas + collect listener map lines ---
##
## First value of KEY= in a site.meta, as plain data. This replaces
## `sed -n 's/^KEY=//p' "$meta" | head -1`, which was a pipeline whose reader
## (`head -1`) exits after one line while the writer (`sed`) may still be
## flushing: the writer then dies 141, and `set -euo pipefail` (line 22) makes
## the whole ASSIGNMENT fail, which aborts this script mid-render. A truncated
## httpd_config.conf is never written (the render is atomic), but the effect is
## that a site the panel just provisioned silently never appears in the config
## and every subsequent render fails the same way.
##
## Measured in this image, `sed -n 's/^DOMAINS=//p' | head -1`:
## 400 matching lines (~6 KB of sed output) -> 0 0 0 0 0
## 6000 matching lines (~90 KB of sed output) -> 141 141 141
## The threshold is the PIPE CAPACITY, not "is the file small": while the
## writer's whole output fits, it never blocks and always finishes first;
## once it does not, the reader's early exit is a guaranteed SIGPIPE. Linux
## gives a pipe 64 KiB by default but drops NEW pipes to a single page once a
## user passes fs.pipe-user-pages-soft, which is the state a busy host gets
## into — and the reason a 40 KB probe failed 5/5 on whp02 and 0/10 here.
## So "a site.meta would never be that big" is not a bound worth resting on
## for panel-written input we do not validate.
##
## awk reads the FILE directly and stops at the first hit: no pipeline, so
## nothing for pipefail to adopt. Same semantics as before, verified against
## the old form on duplicate keys, decoy keys (`notVHNAME=`), empty values and
## missing keys: first match wins, the rest of the line is the value, verbatim.
meta_value() {
awk -v k="$1" 'index($0, k "=") == 1 { print substr($0, length(k) + 2); exit }' "$2"
}
maps=""
site_count=0
for meta in "$SITES_ROOT"/*/site.meta; do
[ -e "$meta" ] || continue
sdir=$(dirname "$meta")
## EXTRACT from site.meta — do NOT `source` it. The panel writes these values
## (derived from DB domains), so they should be safe, but sourcing paneldata as
## shell would execute any metacharacters as root in this container if a value
## ever slipped validation. meta_value treats them as plain data.
VHNAME=$(meta_value VHNAME "$meta")
VHROOT=$(meta_value VHROOT "$meta")
DOMAINS=$(meta_value DOMAINS "$meta")
if [ -z "$VHNAME" ] || [ -z "$VHROOT" ] || [ -z "$DOMAINS" ] || [ ! -f "$sdir/vhconf.conf" ]; then
echo "render-shared-ols: skipping $sdir (incomplete: VHNAME/VHROOT/DOMAINS/vhconf.conf)" >&2
continue
fi
{
echo ""
echo "virtualhost ${VHNAME} {"
echo " vhRoot ${VHROOT}"
echo " configFile ${sdir}/vhconf.conf"
echo " allowSymbolLink 1"
echo " enableScript 1"
echo " restrained 1"
echo "}"
} >> "$TMP"
maps="${maps} map ${VHNAME} ${DOMAINS}"$'\n'
site_count=$((site_count + 1))
done
## --- 5. ALWAYS add a health vhost mapped to the catch-all so the server is
## valid with zero customer sites and HAProxy health checks (which hit by IP /
## unknown Host) get a 200. Exact-domain maps above win over this '*'. ---
{
echo ""
echo "virtualhost _health {"
echo " vhRoot /usr/local/lsws/shared-ols-health"
echo " configFile /usr/local/lsws/shared-ols-health/vhconf.conf"
echo " allowSymbolLink 1"
echo " enableScript 0"
echo "}"
} >> "$TMP"
maps="${maps} map _health *"$'\n'
## --- 6. listeners (HTTP :80 + HTTPS :443 self-signed) carrying ALL maps.
## HAProxy terminates real TLS and connects to this tier on :443 ssl verify
## none (same as shared-httpd), so :443 needs a cert — self-signed is fine. ---
{
echo ""
echo "listener shared_http {"
echo " address *:80"
echo " secure 0"
printf '%s' "$maps"
echo "}"
echo ""
echo "listener shared_https {"
echo " address *:443"
echo " secure 1"
echo " keyFile ${KEY_FILE}"
echo " certFile ${CERT_FILE}"
printf '%s' "$maps"
echo "}"
} >> "$TMP"
## --- 7. publish atomically. Validate the temp parses as non-empty, then mv into
## place (rename is atomic on the same filesystem) so a concurrent OLS restart
## never sees a half-written config. chown only the file we wrote — NOT a
## recursive chown of the whole conf tree (that was O(N-sites) on every single
## change; the per-site files are world-readable and owned correctly already). ---
if [ ! -s "$TMP" ]; then
echo "render-shared-ols: refusing to publish empty config" >&2
exit 1
fi
chown lsadm:nogroup "$TMP" 2>/dev/null || true
mv -f "$TMP" "$OUT"
echo "render-shared-ols: wrote $OUT ($site_count customer vhost(s) + health)"