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>
204 lines
9.6 KiB
Bash
204 lines
9.6 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
|
|
## Do not read a threshold into those two rows. There is no size below which
|
|
## this is safe: each run is a RACE on whether `head` closes the pipe before
|
|
## `sed`'s final write() returns, and the payload only decides how many write()
|
|
## syscalls sed has to lose. Measured against a default 65536-byte pipe
|
|
## (F_GETPIPE_SZ), 41144 bytes SIGPIPEd on 32 of 300 runs — 11%, well under
|
|
## capacity — and 65012 bytes still only on 25 of 30, so it is neither safe
|
|
## below capacity nor certain at it; strace caught a writer dying having put
|
|
## 12086 of 40406 bytes into a 65536-byte pipe. What actually separated a host
|
|
## that failed 5/5 from one that passed 10/10 was the WRITER's syscall size
|
|
## (bash <= 5.2.15 writes ~37 KB at a time, >= 5.2.21 writes 80-160 bytes), not
|
|
## the host's pipe capacity. (An earlier draft of this comment blamed
|
|
## fs.pipe-user-pages-soft; that limit clamps new pipes to two pages, not one,
|
|
## only past 1024 pipes for one uid, and never for CAP_SYS_RESOURCE.)
|
|
##
|
|
## So the rule this file follows is structural, not statistical: under pipefail,
|
|
## a writer piped into a reader that can stop early (`head`, `grep -q`/`-l`/`-m`,
|
|
## `sed q`, `awk ... exit`, `read`) is a latent 141 — full stop. A call site is
|
|
## only sound when the file does not set pipefail, or the reader provably runs
|
|
## to EOF, or the status is thrown away. "A site.meta would never be that big"
|
|
## was never one of those, least of all 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)"
|