Author SHA1 Message Date
shadowdaoandClaude Opus 5 11c02d94ec fix(shared-ols): unmapped Host gets 421, not a 200 that hides a dead site
The shared-OLS catch-all (`map _health *`) served html/index.html --
HTTP 200, 11 bytes, "shared-ols" -- to any Host no customer vhost claimed.
Three live customer sites (joshuaknapp.net, streamers.channel,
blog.anti-social.online) sat in exactly that state for ~2 months on whp01
and no monitor noticed, because every uptime check asks "is it 200?" and
it was. A tier-wide catch-all that answers 200 makes a missing vhost
indistinguishable from a working site.

An unmapped Host now gets 421 Misdirected Request with a short generic
body. 421 is semantically exact (the server cannot produce a response for
the requested authority) and, unlike 404, cannot be confused with a normal
answer from a real site.

The discriminator is the request path plus the client address, NOT the
Host -- the vhost is selected by the listener map, so by the time these
rules run the Host is no longer available to branch on:

  * `/healthz` from an internal client address (loopback, RFC1918) -> 200 "ok"
  * everything else, every path, every Host, both listeners -> 421

The 421 for `/` is UNCONDITIONAL: no header, source address or Host talks
this vhost into a 200 there, so the property the change exists to
guarantee does not rest on anything spoofable. The address gate only
hardens /healthz, and X-Forwarded-For cannot be used against it because
HAProxy replaces that header with the real client IP.

Health probes keep passing unchanged. Both forms were run against a
container carrying this change and both exit 0 with "ok":
  curl -fsSk https://127.0.0.1/healthz   (Dockerfile.shared-ols HEALTHCHECK)
  curl -sfk  https://localhost/healthz   (WHP setup-shared-ols.sh --health-cmd)
`docker inspect` reported healthy with failingStreak=0, on a container with
a customer site and on a zero-site container.

Measured on the lab VM against OLS 1.8.4 (the production base image):
  unmapped Host, `/`, :443 and :80   -> 421, 356 bytes, identical for every
                                        unmapped Host (no enumeration signal)
  unmapped Host, any deeper path     -> the same 421
  configured site, both names, :443/:80 -> 200, served normally
  litespeed -t                        -> 0 [ERROR] lines (warnings only, and
                                        only about the lab fixture's uid/gid)

Two OLS behaviours were measured rather than assumed, and both shaped the
implementation -- see the comment block in entrypoint-shared-ols.sh:
`context / { type redirect statusCode 421 }` silently degrades to a 302
with an unexpanded Location, and the `errorpage 421` body is fetched as a
fresh request through the same rewrite rules (so it needs a %{THE_REQUEST}
guard, since %{IS_SUBREQ} and %{ENV:REDIRECT_STATUS} are not populated).

The old index.html is removed, not just bypassed: if these rules ever
stopped applying, `context /` would fall back to the docRoot index, and
with no index.html that is a 403 -- wrong-but-loud, rather than a 200 that
is wrong-and-silent.

Known consumer to land alongside this: whp-monitoring's
probe_shared_ols_catchall() currently detects the catch-all by matching
`200` + body `shared-ols`, a signature this change deletes. It must also
accept 421, or the detector silently stops detecting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:44:01 -07:00
shadowdao ba9650ee45 Merge branch 'fix/ols-watcher-scope'
Cloud Apache Container / Shell-Checks (push) Successful in 10s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m18s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m6s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m16s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m6s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m19s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m14s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m45s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m26s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m28s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m37s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m25s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m16s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 35s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 35s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 34s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 40s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 35s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m7s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m20s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 1m7s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 1m4s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m1s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 35s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 29s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m11s
2026-08-13 21:39:37 -07:00
shadowdao 77af001af6 fix(ols): pin procps explicitly for pgrep dependency
entrypoint-shared-ols.sh's ols_running() liveness check now shells out to
pgrep, but procps was never in Dockerfile.shared-ols's apt-get install
list — pgrep works today only because Ubuntu 24.04's base image pulls
procps in transitively. If that stops being true, pgrep: command not
found -> exit 127 -> ols_running() false forever -> the crash-loop
breaker (MAX_STARTS/WINDOW) escalates to a hard exit 1 at boot. Make
the dependency explicit so it can't be pruned as unused.
2026-08-13 21:39:14 -07:00
shadowdaoandClaude Opus 5 cf6936e225 fix(ols): scope the htaccess watcher to docroots, stop lswsctrl status log spam
ols-htaccess-watcher.sh matched .htaccess by basename only, so ANY .htaccess
under a tenant (WordPress plugin guard files, not just the docroot OLS reads)
triggered a full graceful restart. Measured on whp01 over 24h: 63 restarts,
0 of them from a docroot .htaccess actually changing — all from Wordfence/W3TC/
WPForms/etc. self-healing files, mostly on tenants that aren't even on this
tier. Now matches the full path (%w%f) against */public_html/.htaccess, the
only .htaccess OLS ever reads, and logs which path triggered each restart.

entrypoint-shared-ols.sh's 3s supervisor poll called `lswsctrl status`, which
appends a line to lsrestart.log on every invocation. Measured on whp01:
1,819,286 status lines vs 2,429 real restarts in a 96 MB, never-rotated log.
ols_running() now checks the process table directly (pgrep -f 'lshttpd -
main', verified against the litespeedtech/openlitespeed base image) instead of
shelling out to a logging tool on a fixed timer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:29:48 -07:00
shadowdao b92725d2ec Merge branch 'fix/lsphp-keep-listen'
Cloud Apache Container / Shell-Checks (push) Successful in 9s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m39s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m28s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m27s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m27s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m29s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m27s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m28s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m29s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m29s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m26s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m31s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m27s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m24s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m27s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 37s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 35s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 35s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 33s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 35s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m1s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m3s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 1m1s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 57s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 57s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 34s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 31s
2026-08-13 15:03:49 -07:00
shadowdaoandClaude Opus 5 5e83c8db3b fix(lsphp): set LSAPI_KEEP_LISTEN=2 to stop idle-exit timing from following the leaked busy counter
lsphp's master keeps a `busy` worker counter in a MAP_SHARED page that drifts
negative over days of uptime (measured live on whp01: busy=-8 after 6.9 days
on arclightcourt.com-01 vs 0..9 on a healthy sibling). php-src's
sapi/litespeed/lsapilib.c derives each child's idle-exit grace period from
that counter (10 + busy*10, capped by LSAPI_MAX_IDLE) only inside
`if (s_keep_listener == 1)`; with busy=-8 that's -70s, so workers exit after
~1s idle instead of 10-30s, no worker ever lingers in accept(), the
"don't fork, one's already listening" guard never fires, and the master
forks for every connection -- confirmed hitting the max-children ceiling and
producing 503s under bot traffic (306 OLS-side ExtConn-timeout/503 errors on
the affected site vs 0 on an identical healthy sibling).

LSAPI_KEEP_LISTEN=2 skips the `== 1` branch entirely so idle-exit timing
falls back to LSAPI_MAX_IDLE (already 30 by default here) instead of the
leaked counter. is_enough_free_mem() sits above that branch, not inside it,
so the memory-pressure guard is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:47:08 -07:00
shadowdaoandClaude Opus 5 a325615690 Merge branch 'fix/lsphp-pipefail-sigpipe'
Cloud Apache Container / Shell-Checks (push) Successful in 9s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m27s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m52s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m23s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m24s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m28s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m41s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m39s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m22s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m20s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m28s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m25s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m22s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 32s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 32s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 33s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 34s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 32s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 58s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m1s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 1m0s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 59s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m1s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 37s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 31s
Fix nine 'writer | early-exiting-reader' pipelines that return 141 under
set -euo pipefail when the reader exits before the writer's final write().

The one that mattered: entrypoint-lsphp.sh reported the cac_path_parity
extension as 'not loadable' BECAUSE it was present, and fell back to the
degraded userland auto_prepend normaliser — the exact thing the extension
exists to eliminate. Measured on whp02 against the published image: 141 on
5/5 runs. It shipped past a green build gate, a 10/10 .phpt suite, a 9/9 FPM
harness and two review rounds, and was only caught by pulling the published
image and running it on a real host.

Two of the nine were bare command substitutions in entrypoints, where 141
under set -e kills PID 1 and the container never boots (entrypoint-lsphp.sh
SCAN_DIR, entrypoint-litespeed.sh — boot-critical for cac-litespeed).

Payload size is NOT the variable: 41144 bytes fails 32/300 well under a
65536-byte pipe capacity, while 500 KB into a 1 MiB pipe fails 200/200 with
small writes and 0/200 with one large write. It is a race on whether the
reader closes before the writer's last write() returns. The same image
failed 5/5 on whp02 and 0/10 in a dev container because bash <=5.2.15 writes
~37 KB per syscall while >=5.2.21 writes 80-160 bytes.

Boot-critical sites use pure-bash matching (no temp file); a regression test
extracts the shipped helpers verbatim and statically outlaws the shape
repo-wide, failing against the pre-fix tree with all 9 lines named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:04:13 -07:00
shadowdaoandClaude Opus 5 e28c3dcce4 fix(lsphp): correct the SIGPIPE explanation, drop the temp-dir boot precondition, close two scanner blind spots
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>
2026-08-05 16:03:08 -07:00
shadowdaoandClaude Opus 5 584099ff19 docs: record boot-posture and CI-gating decisions
Two judgement calls arising from the pipefail/SIGPIPE fix, reasoned through
and written up rather than decided silently:

1. Boot posture when 'lsphp -i' genuinely fails. Recommendation is fail-open
   plus a machine-detectable degraded marker and one probe retry, NOT
   fail-closed: a transient fork() failure during this fleet's backup windows
   produces the identical empty-LSPHP_INFO signature, so fail-closed would
   refuse to boot healthy sites under host pressure. Includes the caution
   that degraded must not map onto Docker 'unhealthy', or a watchdog would
   restart-loop a serving site over something restarts cannot fix.

   Corrects a premise the fix rested on: the two entrypoints were NOT already
   consistent — entrypoint-litespeed.sh fails open silently, with no warning.

2. Whether the new static check should gate releases. Recommendation is to
   hard-gate, but only after back-testing the job against trunk and recent
   tags to bound false positives.

Recommendations only; no implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:43:18 -07:00
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
shadowdaoandClaude Opus 5 9343a56ccf Merge branch 'fix/lsphp-parity-hardening'
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m35s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m6s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m23s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m23s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m24s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m26s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m30s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m29s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 4m40s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m25s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m7s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m28s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m25s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 33s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 32s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 32s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 34s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 33s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m1s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 59s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 1m2s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 1m0s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m4s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 33s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 30s
Hardening for the cac-path-parity PHP extension, which moves
cac-lsphp-normalize.php out of userland so the php-fpm -> ols/lsphp switch
is clean.

- pin the runtime PHP to the headers the extension was built against
  (the reverse, pinning -dev to the runtime, is unsatisfiable: the
  LiteSpeed repo carries only the current release)
- close an ini-injection hole in the entrypoint heredoc (quoting plus a
  charset check, since quoting alone does not stop php.ini ${VAR}
  interpolation)
- make the FPM parity harness actually runnable (it exited 0 while
  verifying nothing) and add the .phpt suite as a build gate
- guard to="/" and non-absolute mappings; both narrow what runs, neither
  adds an error path
- fix PHP_MINFO reporting 'active' for an inert mapping — that signal is
  what the post-deploy canary reads

Reviewed twice; RINIT verified byte-identical at the instruction level
across the predicate refactor. Fail-open invariant intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:09:53 -07:00
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 9761157a6b harden(cac-path-parity): make degenerate mappings inert instead of subtly wrong
Three loose ends from the review, none reachable from entrypoint-lsphp.sh today.
The rewrite semantics and the prefix-boundary logic are untouched; both new
guards only NARROW the set of configurations that do anything, and neither adds
an error path — fail-open is unchanged.

  - to="/" produced "//public_html": cacpp_trim() keeps a lone separator, and
    the tail already starts with one. Collapse the prefix when there is a tail,
    keep it when there is not (value == from exactly, where "/" is correct).
    A doubled leading slash is not the same string as the cac-fpm value, which
    is the entire point of the extension.
  - a non-absolute `from`/`to` was accepted and applied. Both are now required
    to start with '/', otherwise RINIT returns exactly as it does for an absent
    mapping: inert, no diagnostic, request proceeds.
  - a well-formed but WRONG mapping stays undetectable, and now the FAILURE
    MODES block says so explicitly rather than leaving it as an unlisted gap,
    along with why that is acceptable (the entrypoint derives from/to from the
    same two variables it builds the compatibility symlink from, so a wrong
    mapping means the symlink is wrong too and the site is already broken more
    loudly) and where the only runtime signal is (`lsphp -i`).

Two tests added, both non-vacuous — 007 rewrites without the absolute-path
guard, 008 returns "//public_html" without the collapse. 8/8 pass on PHP
8.1/8.3/8.5, and the FPM harness still reports 9/9 against the changed .so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:06:34 -07:00
shadowdaoandClaude Opus 5 61bfdcfaf9 test(cac-path-parity): run the .phpt suite as a build gate
Nothing executed ext/cac-path-parity/tests/ — not Dockerfile.lsphp, not
.gitea/workflows/build-push.yaml. The suite passed, but as shipped it was
documentation, not a gate.

`make test` now runs in the ext-build stage, against the same lsphp build the
.so ships next to. It costs ~1s per PHP version. The lsphp packages turn out to
include a real CLI binary (php-config --php-binary =>
/usr/local/lsws/lsphpNN/bin/phpN.N), so run-tests.php works with no extra
tooling.

Guarded twice, because `make test` fails silently by default:
  - if PHP_EXECUTABLE is missing, the Makefile prints "Cannot run tests without
    CLI sapi." and EXITS 0. Asserted rather than assumed.
  - a run that executes zero tests also exits 0, so the summary is checked
    against the number of .phpt files on disk, plus "Tests failed : 0".
    Same reasoning as the `lsphp -i` probe: an assertion that cannot fail is
    worse than no assertion.

Verified 8/8 on PHP 8.1/8.3/8.5. Mutation-tested both guards: breaking
001-rewrite.phpt's expectation fails the build ("FATAL: cac_path_parity .phpt
suite FAILED"); adding a test that always SKIPs makes run-tests.php still exit 0
but the build fails on "expected all 9 .phpt tests to run" (summary read
"Number of tests : 9  8").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:06:23 -07:00
shadowdaoandClaude Opus 5 fb4946641a fix(cac-path-parity): make the FPM proof harness actually runnable as shipped
The artifact cited as the web-SAPI evidence could not have been run as it stood.
Measured in an official php:8.3-fpm container with the extension built in place:

  - as shipped, no args:            "SKIP: php-fpm not found", exit 0.
    The default was `php-fpm8.3`, which matches neither the official images
    (`php-fpm`) nor this repo's images.
  - with the binary supplied by hand: 9 FAIL, every one with an empty `got:`.
    The generated pool had no user/group, so php-fpm refused to start as root
    ("please specify user and group other than root"). A startup failure was
    wearing the costume of nine parity bugs.

Changes:
  - auto-detect the binary (php-fpm, php-fpm8.N, /usr/local/sbin, /usr/sbin) and
    print which one was chosen plus its version;
  - pre-flight the extension with `php-fpm -m`, so a .so that will not load into
    THIS php-fpm reports as a harness failure naming the ABI mismatch rather
    than as nine wrong paths;
  - emit user/group in the pool when running as root, resolved from accounts
    that actually exist (www-data / nobody / daemon), and chmod the fixture tmpdir
    so the non-root worker can read it;
  - run_case() now returns non-zero when php-fpm never answered, and every call
    site routes that to die_startup(), which prints the php-fpm output and the
    pool error_log and exits 2 — an exit code deliberately distinct from 1
    (assertion failure).

After: 9/9 ALL PASS from a clean checkout with no arguments and no environment
fixing, running as root in php:8.3-fpm. Mutation-tested both new paths: a pool
user that does not exist reports "HARNESS FAILURE ... STARTUP/environment
failure" with the real php-fpm error and exit 2; an EXT_SO that is not a loadable
extension is caught by the pre-flight, also exit 2.

Also fixes doc drift: 001-rewrite.phpt pointed at tests/web-sapi-parity-check.sh,
which has never existed. The file it means is tests/fpm-parity-check.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:06:10 -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 06df1c410b fix(cac-lsphp): pin the shipped lsphp to the version the .so was built against
The comment claimed the extension was "built against THIS image's own lsphp so
the API/ABI always match". It was not, and could not be: `lsphp<NN>-dev` is
absent from the LiteSpeed apt repo at the version every prebuilt OLS base image
ships, because that repo carries only the current release. Measured on
OLS 1.8.4 (2026-08-05), base image vs repo candidate:

  lsphp81  8.1.33-5+noble  ->  8.1.34-1+noble
  lsphp83  8.3.28-1+noble  ->  8.3.32-1+noble
  lsphp85  8.5.0-3+noble   ->  8.5.8-1+noble

and the literal fix — `apt-get install lsphp<NN>-dev="$(dpkg-query lsphp<NN>)"` —
fails on all three with `E: Version '<base>' for 'lsphp<NN>-dev' was not found`
(apt exit 100). So installing -dev necessarily upgrades lsphp in the build stage;
the only satisfiable direction is to move the runtime to meet it.

The skew the reviewer measured (a .so built on 8.3.32 shipped beside an 8.3.30
runtime, lsphp83-common at 8.3.31) was not vendor randomness: BOTH stages resolve
"repo latest" independently and Docker caches them independently. `COPY ./ext`
sits at the top of the ext-build stage, so editing the extension invalidated that
stage's apt layer while stage 2's stayed cached — i.e. every extension edit
rebuilt the .so against fresh headers and shipped it next to a stale runtime.

Fixed by making them one system:
  - the toolchain layer moves ABOVE the source COPY, so editing the extension no
    longer re-resolves the PHP version;
  - it records the resolved version to /build-out/lsphp.version and asserts
    lsphp<NN> == lsphp<NN>-dev in that stage;
  - stage 2 COPYs that file in BEFORE its apt layer (so the version is part of
    that layer's cache key) and pins lsphp/-common/-ldap to it, then asserts the
    installed versions match. Unsatisfiable pin => loud apt failure with the
    remediation, never a silent fallback.

Verified: builds clean on PHP 8.1/8.3/8.5; the shipped php83 image now reports a
uniform lsphp83 family at 8.3.32 (the previous image shipped lsphp83 8.3.30 /
-common 8.3.31 / -ldap 8.3.30). Mutation-tested by recording a version the repo
no longer has: build fails at stage 2 rather than shipping the skew.

The `lsphp -i | grep` build assertion is kept but its comment now says what it
does and does not prove: it catches a .so that will not LOAD, never silent
struct-layout drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:05:36 -07:00
shadowdaoandClaude Opus 5 a3aa9f2b26 fix(lsphp): repair the comment my sed mangled
My previous commit used sed with | as both delimiter and literal, which
corrupted the comment line rather than changing -m to -i. The assertion
command itself was never touched and is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:41:28 -07:00
shadowdao 83522b00ef Merge branch 'feat/lsphp-server-path-parity'
Guarantees $_SERVER path parity between cac-fpm and cac-lsphp via a PHP
extension rewriting the filesystem-path keys from RINIT, configured by two
PHP_INI_SYSTEM entries a customer's .user.ini cannot reach.

The prepend it replaces was PHP_INI_PERDIR, so any site with its own
auto_prepend_file displaced it -- 7 live shared_ols sites are in that state.
Hardening the hook was self-defeating: PHP resolves ONE winning
auto_prepend_file after the .user.ini chain, so you cannot chain from the
losing side, and php_admin_value would make ours win by making the customer's
Wordfence WAF never run. An extension occupies no userland hook at all.

The symlink-farm alternative was rejected twice over: followSymLink in a
shared multi-tenant OLS is a cross-tenant read risk, and /home/<user> is
ambiguous in the single shared-ols container when one user has several sites.
2026-08-05 11:41:03 -07:00
shadowdaoandClaude Opus 5 15e304e0c3 docs(lsphp): the build assertion probes with -i, not -m
The comment describing the assertion still said 'lsphp -m | grep' while the
code correctly uses -i. That is the exact trap documented three lines below --
lsphp is the LSAPI SAPI and answers -m by printing usage and exiting 0, so an
-m based check never matches and never fails. Leaving the comment wrong would
invite someone to 'restore' it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:41:03 -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
jknapp 03b8f3f730 Merge pull request 'fix(cac-lsphp): enable .user.ini support (LSPHP_ENABLE_USER_INI)' (#21) from fix/lsphp-enable-user-ini into trunk
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m48s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m32s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m27s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 3m2s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m25s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 3m21s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m36s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m16s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m57s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m43s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m40s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 3m39s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m21s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 48s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 1m13s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 33s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 1m10s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 51s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 55s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 36s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 30s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 32s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m22s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 32s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 1m20s
2026-08-02 22:38:03 +00: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
jknapp cc72fda741 Merge pull request 'fix(shared-ols): never cache logged-in pages (disable tier private cache)' (#20) from fix/shared-ols-no-logged-in-cache into trunk
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m19s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m16s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m25s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m11s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m57s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m22s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 34s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 1m12s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 46s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 36s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m20s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m24s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 30s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 30s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 30s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 29s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 27s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 27s
Reviewed-on: #20
2026-06-27 04:04:49 +00:00
shadowdaoandClaude Opus 4.8 a865a13940 fix(shared-ols): never cache logged-in pages (disable tier private cache)
OLS tier had enablePrivateCache=1 + checkPrivateCache=1 at module scope on the
assumption that with no LiteSpeed Cache WP plugin nothing would be cached. In
practice OLS privately cached logged-in / cookie-bearing responses regardless of
the plugin, serving stale wp-admin pages for the full privateExpireInSeconds TTL
(observed: a WordPress 'automated update failed' nag persisting after the cause
was cleared).

Disable private caching at the tier (enablePrivateCache 0 + checkPrivateCache 0)
so logged-in pages are always served fresh. Public/anonymous caching is unchanged
(enableCache 1 + checkPublicCache 1), still honored from the plugin's
X-LiteSpeed-Cache-Control headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:58:23 -07:00
shadowdaoandClaude Opus 4.8 8dbfdf599a fix(shared-ols): useIpInProxyHeader 2->1 so real client IP reaches lsphp
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m26s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m24s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m20s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m21s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m20s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m19s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m20s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m22s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m19s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 56s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 34s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 30s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 27s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 25s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 26s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 26s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 28s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 26s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 25s
Mode 2 ("trusted IP only") extracts the real client IP from X-Forwarded-For
ONLY when the connecting peer is in a TRUSTED access-control list — which this
tier never configured (accessControl is `allow ALL`, no trusted designation).
So OLS kept HAProxy's container IP (172.18.0.34) as REMOTE_ADDR for EVERY
request across ALL tenants. WP security plugins (Wordfence etc.) then saw all
traffic as one IP; blocking it locked every site — and the admin — out.

HAProxy already sends X-Forwarded-For and is the ONLY peer that connects to
this tier (client-net, no host-published ports), and it OVERWRITES XFF with
%[src] (set-header), so spoofing is impossible. Mode 1 (always trust XFF) is
correct and safe here — it matches the working standalone configs/litespeed
config which has always used 1.

Verified on whp01: lsphp now receives the forwarded client IP end-to-end
(REMOTE_ADDR=<real-ip>, was 172.18.0.34). Live-hotpatched whp01+whp02 pending
this image rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:58:07 -07:00
jknapp 2e85f458d3 Merge pull request 'feat: OLS tier images — cac-lsphp (detached lsphp) + shared-ols' (#19) from feature/cac-lsphp-image into trunk
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m18s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m24s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m23s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m16s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m22s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m18s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m17s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m18s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m22s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m15s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m13s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 1m8s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 47s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 30s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 31s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m11s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m30s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 35s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 29s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 51s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 59s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 45s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 1m3s
Reviewed-on: #19
2026-06-10 16:56:38 +00:00
shadowdaoandClaude Opus 4.8 08f35032c5 fix(shared-ols): re-review hardening — bounded flock + stale-tmp sweep
Follow-up to the review fixes, from a second review pass:
- flock now uses -w 30 (bounded wait) so a hung render can't block the panel's
  docker-exec (and the site-save request) indefinitely; the dead-code timeout
  error path is now reachable.
- sweep stale .httpd_config.conf.tmp.* left by a prior SIGKILL (trap EXIT doesn't
  run on SIGKILL); safe under flock since each render uses a unique $$ suffix.
Verified: render still produces a valid config + serves; stale tmp is swept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:25:05 -07:00
shadowdaoandClaude Opus 4.8 6bb494c72f fix(shared-ols): review fixes — watcher starvation, atomic render, O(N) chown, safe meta parse
Addresses the local code-review on the OLS-tier images:
- [HIGH] ols-htaccess-watcher.sh: the debounce drain read ALL inotify events
  unfiltered, so on a busy multi-tenant server it never timed out and the
  restart was STARVED (rewrite changes silently never applied). Now coalesces
  with a hard DEBOUNCE-bounded window. Verified under continuous noise.
- [HIGH] render-shared-ols-config.sh: built httpd_config.conf in-place across
  several appends, so a concurrent OLS restart (watcher) or parallel render
  could read a half-written config and 503 the whole tier. Now flock-serialized,
  built in a temp file and atomically moved into place; refuses to publish empty.
- [MED] render + entrypoint: replaced recursive chown of the whole conf tree
  (O(N-sites) on every single-site change / boot) with a targeted chown of just
  the file written.
- [MED] render: parse site.meta with sed instead of sourcing it (do not execute
  panel-written data as shell).
- [cleanup] removed the unused configs/shared-ols/vhconf.tpl (the panel copy is
  the single source; the image never read it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:35:26 -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 19db8f170a feat(shared-ols): shared OpenLiteSpeed tier image (webserver-only, fronts cac-lsphp sidecars)
One OLS container fronting many tenants' detached cac-lsphp sidecars — the
OLS analogue of shared-httpd. Runs NO PHP locally; every site's PHP goes to
its own sidecar over LSAPI (extProcessor type lsapi, address <sidecar>:9000).

Key design fact (established by PoC): OLS has NO top-level 'include' directive,
so render-shared-ols-config.sh assembles httpd_config.conf from the panel's
per-site files (vhconf.conf + site.meta) at boot and on every change — the
'include' OLS lacks. Per-site detail uses the OLS-native configFile +
vhost-scoped extprocessor model. LSCache is module-level (a configFile-loaded
vhost rejects a bare cache{} block); the WP LiteSpeed plugin controls
cacheability via X-LiteSpeed-Cache-Control headers.

- Dockerfile.shared-ols: litespeed base + inotify-tools/envsubst/openssl,
  admin bound to loopback, :80/:443 self-signed, healthz HEALTHCHECK.
- entrypoint-shared-ols.sh: cert + health vhost + render + watcher, then
  daemon-mode OLS supervision (reused from cac-litespeed so self-restarts
  don't kill PID 1).
- render-shared-ols-config.sh: strip stock (incl local lsphp) + append base +
  per-site stanzas + listeners with all maps + catch-all health vhost.
- ols-htaccess-watcher.sh: inotify debounce+floor -> lswsctrl restart (spec 5.3).
- configs/shared-ols/{httpd_config_base,vhconf}.tpl.
- CI: Build-Shared-OLS job.

Verified locally end-to-end: zero-site boot healthy on :443; add site via the
panel contract -> Host-routed to the right sidecar (SAPI=litespeed); real
client IP + HTTPS behind X-Forwarded headers; LSCache miss->hit; .htaccess
change triggers graceful restart; unknown Host hits health catch-all (200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:22:14 -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
shadowdaoandClaude Opus 4.8 50202538e4 cac-litespeed: supervise OLS in daemon mode so self-restarts don't kill PID 1
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m24s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m25s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m21s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m15s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m15s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m33s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m19s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m24s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 30s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 31s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 31s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 32s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 28s
cac-litespeed containers were dying at random intervals and staying 503 until
manually restarted. Root-caused on whp02 (alsacorp, 2026-06-06): the LiteSpeed
Cache / QUIC.cloud integration refreshes the QUIC.cloud IP allowlist on a
schedule and, when it changes, sends SIGUSR1 → "request a graceful server
restart". The entrypoint ran `openlitespeed -n & wait "$OLS_PID"`, so when the
OLD main PID exited after the zero-downtime handoff, `wait` returned, PID 1
(bash) exited, and the whole container went down. The exit was clean (code 0),
so even a restart policy wouldn't reliably catch it — HAProxy just served 503
until someone ran `docker start`.

Replace the `-n` foreground+wait model with a daemon-mode supervisor: start OLS
via `lswsctrl start` (its native model, where it owns the SIGUSR1 handoff and
keeps listeners bound across generations) and have PID 1 follow `lswsctrl
status`. A graceful self-restart is now invisible here (verified zero-downtime);
PID 1 only relaunches on a genuine crash (no live main), with a 5-in-60s
crash-loop cap that bails out to Docker's restart policy / the site monitor.
SIGTERM still drains and exits cleanly for docker stop / recreate.

Verified on a scratch php85 container: survives `lswsctrl restart`, survives a
raw SIGUSR1 to the main (the exact QUIC.cloud path that used to kill it),
relaunches after `kill -9` of the main, and stops cleanly in ~6s on docker stop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:15:25 -07:00
shadowdaoandClaude Opus 4.8 2837d40f00 cac-litespeed: forward real client IP to logs and PHP behind HAProxy
Cloud Apache Container / Build-and-Push (74) (push) Successful in 4m47s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m18s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m17s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m20s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m16s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m6s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m14s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m16s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m19s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m13s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 35s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 45s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 1m9s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 30s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 31s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 26s
OLS had no equivalent of the Apache cac:phpNN mod_remoteip wiring
(configs/remote_ip.conf + RemoteIPInternalProxy), so every migrated
LiteSpeed site logged HAProxy's docker-bridge IP and handed that same
internal IP to lsphp as $_SERVER['REMOTE_ADDR']. That silently broke
traffic analytics, WP security plugins, brute-force detection, Coraza
source-IP correlation, geo, and rate-limiting.

Add server-level `useIpInProxyHeader 1` to the httpd_config append
fragment. OLS then rewrites the remote IP from X-Forwarded-For for both
logging and the LSAPI REMOTE_ADDR before PHP sees it. Value 1 mirrors the
Apache trust model (container is only reachable via HAProxy, never bound
publicly). Confirmed HAProxy customer backends are mode http with
`option forwardfor` and set X-Forwarded-For to the resolved real client IP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:51:25 -07:00
shadowdaoandClaude Opus 4.7 cfdaae116a tune(litespeed): bump opcache 32→64 MB / 4000→8000 files + add per-site override
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m37s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m42s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m50s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m51s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 3m18s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m21s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 3m49s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m0s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m44s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m30s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m48s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m40s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m58s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m15s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 29s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 26s
32M/4000 was too aggressive for heavy WP+Divi+WC sites: 3000+4000 unique
PHP files each blow through max_accelerated_files, causing constant
eviction + recompilation thrash. Manifested 2026-06-03 as ~40% sustained
CPU on alphaoneaminos and 5378 oom_kills/9h on brain-jar.

64M/8000 fits Divi + WC + WP core bytecode without eviction. N lsphp ×
64 MB ≈ 512 MiB shmem worst case — still under the per-instance setUIDMode
fan-out from the original 128M problem (which was 1+ GiB).

Per-site override (OPCACHE_MEMORY_MB / OPCACHE_MAX_FILES env vars) lets the
panel push down for low-traffic sites or up for outliers without rebuilding
the image. WHP panel UI ships in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 06:21:37 -07:00
shadowdaoandClaude Opus 4.7 87f154cdc8 refactor(litespeed): drop setUIDMode for shared lsphp + cut opcache 128→32M
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m19s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m35s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m16s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m29s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m2s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m22s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m30s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m14s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m6s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m20s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 3m20s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m19s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m41s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 43s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 1m16s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 56s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 2m2s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 51s
OLS runs as the customer user end-to-end (server-level user/group set by
create-vhost-litespeed.sh), so lsphp inherits that uid without per-request
suEXEC. Eliminates the per-httpd-worker lsphp instance fan-out — one shared
lsphp parent now serves all httpd workers via the shared socket.

Combined with opcache.memory_consumption 128→32M, brain-jar measured shmem
dropped from ~880 MiB → 32 MiB and memory.current from ~1.1 GiB → 67 MiB
at the 1.5 GiB cap. No new oom_kills since the change.

Safe because cac-litespeed is one-customer-per-container — the container
boundary is the privsep boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 20:06:56 -07:00
shadowdaoandClaude Opus 4.7 f463519998 tune(litespeed): bump LSPHP_WORKER_ESTIMATE_MB 115 → 130
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m33s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m24s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m8s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m23s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m21s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m23s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m14s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 3m26s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m22s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m1s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m28s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 1m30s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 39s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 1m12s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 30s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 30s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 1m30s
115 was set from idle-state per-worker memory. Active workers on
heavy WP/Divi grow to ~130-150 MB (shmem + anon + file), and the
115 formula gave brain-jar.com CHILDREN=8 at 1 GiB — which produced
142 OOM-kills overnight because there was zero headroom once page
renders started.

130 backs off slightly on the bigger sites:
  512 MiB:  3 workers  (unchanged)
  1 GiB:    7 workers  (was 8 — brain-jar's failure point)
  1.5 GiB:  11 workers (was 12)
  2 GiB:    15 workers (was 17)
  4 GiB:    30 workers (was 33)

Per-site FPM_MAX_CHILDREN override still wins for sites that need
tighter caps regardless of formula default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:54:28 -07:00
shadowdaoandClaude Opus 4.7 03cca745f7 feat(litespeed): wire up dynamic LSAPI tuning + idle reduction
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m18s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m14s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 3m21s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m18s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m11s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m22s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 4m22s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 3m46s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m21s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m15s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 3m29s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 31s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 31s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 30s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 32s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 31s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 1m33s
Two correctness fixes and a tuning improvement.

CORRECTNESS:
1. Strip the stock 'extProcessor lsphp' from httpd_config.conf before
   appending ours. Previously the stock block (hard-coded
   PHP_LSAPI_CHILDREN=10 regardless of container memory) always won
   because our APPEND fragment didn't include an extProcessor block.
   detect-memory-litespeed.sh was computing LSAPI_CHILDREN but never
   plumbing it anywhere — silent dead code.

2. Bump LSPHP_WORKER_ESTIMATE_MB from 96 → 115 per the 2026-06-02
   memory-sizing finding (vantagehealth OOM-spawn loop). Each lsphp
   carries ~115 MB shmem-rss accounted per worker. 115 MB matches the
   real per-worker baseline.

TUNING (idle reduction, the original ask):
- LSAPI_MAX_IDLE_CHILDREN=2  (was CHILDREN/2 = 5 default)
- LSAPI_MAX_IDLE=60s         (was 300s default)
- PHP_LSAPI_MAX_REQUESTS=500 (recycle workers, prevents bloat)
- memSoftLimit=1024M / memHardLimit=1500M per worker (RLIMIT_AS;
  catches runaway scripts at the worker level, cgroup still backstops
  the container)

Effective LSAPI_CHILDREN per container:
  2 GiB → ~17 (was 10 — brain-jar was saturating)
  1 GiB → ~8
  512 MiB → ~3 (cap-marginal per the memory note; bump container if
                site grows)

Dropped LSAPI_MEM_SOFT/HARD computation in detect-memory: AVAILABLE/CHILDREN
was conflating VSZ with RSS-budget arithmetic and would have killed
legitimate workers. The 1024/1500 hard-coded values in the template
comfortably fit typical Divi/WooCommerce VSZ (280-365 MB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 16:36:25 -07:00
shadowdaoandClaude Opus 4.7 d1c3cfadc0 feat(litespeed): make log paths drop-in compatible with cac:phpNN
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m35s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m20s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m18s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m13s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m21s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m19s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m14s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m25s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m26s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m15s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m15s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m58s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m27s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 30s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 29s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 33s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m27s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 24s
OLS now writes:
  access -> /home/$user/logs/apache/access_log
  error  -> /home/$user/logs/apache/error_log
  PHP    -> /home/$user/logs/php-fpm/error.log

Matches the cac:phpNN bundled image convention exactly, so existing WHP
log-gathering code (whp-traffic-aggregator.php, process-log-review.php)
works for migrated sites without any panel-side changes. Customer-facing
paths are stable across migrations — "where do I find my access log?"
gets the same answer regardless of image family.

Server-level OLS logs (/usr/local/lsws/logs/) are unchanged — those are
internal diagnostics, not customer-relevant.

PHP error_log is set via a runtime-rendered tiny ini in lsphp's scan dir
(can't be in the static lsphp-overrides.ini because the path is
per-customer).

Customers on the four whp01 migrations (alphaone, peptides, shadowdao,
brain-jar) need a container recreate after CI publishes the new tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 10:53:44 -07:00
shadowdaoandClaude Opus 4.7 80fa06592b perf(litespeed): defer mariadb-server + memcached install to DEV runtime
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m23s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m58s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m0s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m14s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m12s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m24s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m44s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m41s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 3m33s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m18s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m17s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m16s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 1m19s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 46s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 31s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 1m26s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 52s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 58s
Drops these from the build-time apt install in Dockerfile.litespeed; they
now install at entrypoint time only when environment=DEV, guarded by
'command -v mysqld' so container restarts skip the apt step.

Mirrors the cac:phpNN pattern. The mysql CLI client is already in the
litespeedtech/openlitespeed base, so wp-cli + DEV creds-bootstrap still work
without a build-time client install.

Measured (php83 / OLS 1.8.4):
  PROD image: 1.64 GB -> 1.20 GB (~440 MB savings)
  PROD first-200 boot: unchanged at ~1.5s
  DEV first boot:  ~51s (apt install cost — one-time per container)
  DEV second boot: ~6s (cache hit, same as PROD)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 08:26:19 -07:00
shadowdaoandClaude Opus 4.7 9e13571d61 Drop stale configs/litespeed/vhconf.tpl
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m48s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m35s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 3m38s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m30s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 3m15s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m20s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m49s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 3m52s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m27s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m32s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 3m0s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m33s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 53s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 52s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 2m59s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 58s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m56s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 26s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m56s
Leftover from v1 direct-virtualHost iteration. Superseded by site-template.tpl
when we switched to the vhTemplate + member pattern. Nothing references it
in scripts/ or configs/; was only included in the initial commit by oversight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 07:33:06 -07:00
shadowdaoandClaude Opus 4.7 55c28a0c11 Add cac-litespeed image family (OpenLiteSpeed, native LSAPI)
New paid-tier per-customer image built on litespeedtech/openlitespeed:1.8.4-lsphpNN.
Matrix: 8.1-8.5. Native LSAPI suexec to customer uid, server-level LSCache,
all WP/WooCommerce extensions (memcached, redis, imagick, mbstring, etc.) baked in.

Files:
- Dockerfile.litespeed (FROM prebuilt LiteSpeed base, layers wp-cli/composer/mariadb)
- configs/litespeed/{httpd_config,site-template,lsphp-overrides}.tpl
- scripts/{entrypoint,create-vhost,detect-memory}-litespeed.sh + install-lscache-wp.sh

CI: new Build-LiteSpeed-Images matrix job. OLS_VERSION pinned to 1.8.4 (only
release with prebuilt images for all 5 PHP versions on Docker Hub).

Spec: whp/docs/superpowers/specs/2026-06-01-cac-litespeed-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 07:32:47 -07:00
Claude Code 1756d496e5 detect-memory: raise PHP_WORKER_ESTIMATE_MB default 60→128
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m20s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m15s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m19s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m25s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m14s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m21s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m15s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m15s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 27s
The 60 MB worker estimate was optimistic for plugin-heavy WordPress
and WooCommerce stacks. Concrete measurement on alphaone 2026-06-01:

  Container memory : 1024 MiB (later 2048 MiB)
  Pool sized by formula : pm.max_children = (1024-100)/60 = 15
  Actual per-worker RSS : ~193 MB (anon+file+shmem from kernel OOM dumps)
  Worst-case peak       : 15 × 193 MB ≈ 2.9 GB

That math put traffic-burst peak demand well over the container cap,
producing 1,586 cumulative oom_kills across alphaone's two containers
over 18 days and intermittent fork-starvation for unrelated tenants
on the host.

128 MB is a more realistic baseline: closer to actual WP+Woo+page-
builder worker footprint, still conservative enough that lighter
sites continue to get reasonable concurrency. The matrix at common
container tiers:

  Tier (MiB)  | old children | new children | new peak demand
  256         | 2 (floored)  | 2 (floored)  | ~256 MB
  512         | 6            | 3            | ~384 MB
  768         | 11           | 5            | ~640 MB
  1024        | 15           | 7            | ~896 MB
  2048        | 15 (capped*) | 15           | ~1.9 GB
  (* old formula returned 32 at 2 GiB but production containers were
    booted at lower tiers and never recalculated; see whp01 audit.)

Existing containers keep their boot-time pm.max_children until they
are recreated — this change only affects new containers. Customers
or operators can override per-container via FPM_MAX_CHILDREN env.
2026-06-01 08:23:09 -07:00
shadowdaoandClaude Opus 4.7 d5d027c0ab chore(ci): trigger fresh build to verify older PHP tags repopulate
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m31s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m23s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m16s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m17s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m15s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m22s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m14s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 26s
The registry currently only carries cac:{latest,php84,php85} and
cac-fpm:{latest,php84,php85}, even though run #49's runner log shows
all 14 jobs (74,80,81,82,83,84,85 × cac, cac-fpm) successfully pushed
on 2026-04-02. The older manifests have since been deleted from the
registry — direct probe by digest returns 404, so it's not just an
orphaned-tag situation.

We do not believe there is an active cleanup process. This empty
commit triggers a fresh push so we can confirm the workflow is still
producing all 14 images and that the tags persist after build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 07:59:12 -07:00
shadowdaoandClaude Opus 4.6 28bb1055da Use proxy_block placeholder in vhost template for FPM load balancing
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m59s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m27s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m18s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m4s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m28s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m17s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m26s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m22s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m12s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m16s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m40s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 27s
Replaced hardcoded SetHandler + ProxyFCGISetEnvIf directives with a
~~proxy_block~~ placeholder. The shared_httpd_manager generates either
a direct SetHandler (single container) or a mod_proxy_balancer config
(multiple containers) depending on the site's container count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:03:11 -07:00
shadowdaoandClaude Opus 4.6 e9604b8721 Fix shared httpd log tailing for dynamically added vhosts
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m25s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m23s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m21s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m21s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m19s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m20s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m33s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m15s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m14s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m19s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 1m22s
The entrypoint used 'tail -f /var/log/httpd/*' which expands the glob
at startup. Log files created later (when new vhost configs are added)
were never tailed, so 'docker logs' showed nothing for sites added
after the container started.

Replaced with a loop that re-discovers log files every 60 seconds and
restarts tail to include new ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:22:10 -07:00
shadowdaoandClaude Opus 4.6 e81b0df5b8 Reduce idle PHP-FPM memory footprint
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m7s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m16s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m13s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m23s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 3m31s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m2s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m51s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m4s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m6s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m17s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 26s
Opcache:
- memory_consumption: 128MB → 64MB (most WordPress sites use <40MB)
- max_accelerated_files: 10000 → 4000 (sufficient for WordPress)
- revalidate_freq: 2s → 60s (reduce stat() calls in production)
- enable_cli: Off (don't cache scripts run from command line)

FPM workers:
- process_idle_timeout: 10s → 5s (faster worker teardown when idle)
- max_requests: 500 → 200 (recycle workers sooner to release leaked memory)

These changes primarily reduce the baseline memory of idle containers
where opcache was reserving 128MB even for small sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:58:42 -07:00
shadowdaoandClaude Opus 4.6 c65f533dcc Add HEIC/HEIF/AVIF support + fix MariaDB repo for AlmaLinux 10
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m6s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m23s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m55s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m39s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m35s
Cloud Apache Container / Build-and-Push (85) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (74) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-and-Push (84) (push) Has been cancelled
Added ImageMagick-heic package to both Dockerfile and Dockerfile.fpm.
This is a separate EPEL subpackage that provides HEIC, HEIF, and AVIF
format support via libheif. Without it, ImageMagick is installed but
cannot process iPhone photos and modern image formats.

Also fixed MariaDB repo URL: AlmaLinux 10 uses $releasever=10 but
MariaDB mirrors don't have an 'almalinux10' directory. Changed to
'rhel10' which is the supported path for EL10 derivatives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:48:58 -07:00
shadowdaoandClaude Opus 4.6 c6f1f42987 Final vhost template: SetHandler + ProxyFCGISetEnvIf for both paths
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m21s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m21s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m18s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m24s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m54s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m20s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m16s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m17s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m15s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 1m15s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m9s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m5s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 27s
Reverts from ProxyPassMatch back to SetHandler + ProxyFCGISetEnvIf.
ProxyPassMatch couldn't override DOCUMENT_ROOT (Apache sets it as a
CGI param after all directives run). SetHandler with unconditional
ProxyFCGISetEnvIf correctly overrides both:

- DOCUMENT_ROOT: set to /home/{user}/public_html (FPM path)
- SCRIPT_FILENAME: constructed from DOCUMENT_ROOT + SCRIPT_NAME

This fixes WordFence WAF and other plugins that use DOCUMENT_ROOT to
locate config/log files. Tested on live sites with WordPress pretty
URLs, wp-admin, static assets, and WordFence WAF optimization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:58:11 -07:00
shadowdaoandClaude Opus 4.6 e20f5620d7 Fix DOCUMENT_ROOT for PHP-FPM in shared httpd mode
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m19s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m5s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m9s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m11s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m12s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m14s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m18s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m14s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m51s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m27s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m0s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m12s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m6s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 1m13s
WordPress plugins like WordFence use $_SERVER['DOCUMENT_ROOT'] to locate
config/log files. With ProxyPassMatch, Apache sends its own mount path
(/mnt/users/...) as DOCUMENT_ROOT, which doesn't exist in the FPM
container.

ProxyFCGISetEnvIf can't override DOCUMENT_ROOT when using ProxyPassMatch
(Apache sets it after the directive evaluates). Instead, set it via the
FPM pool config's env[] directive which takes precedence.

create-php-config.sh now adds env[DOCUMENT_ROOT] = /home/$user/public_html
when in TCP listen mode (shared httpd), giving PHP the correct path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:04:53 -07:00
shadowdaoandClaude Opus 4.6 1490bde56e Switch shared vhost from SetHandler to ProxyPassMatch for PHP-FPM
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m7s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m59s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m3s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m26s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m21s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m51s
Cloud Apache Container / Build-FPM-Images (74) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-and-Push (85) (push) Has been cancelled
SetHandler + ProxyFCGISetEnvIf doesn't work for path remapping because
reqenv('SCRIPT_FILENAME') is empty when the directive evaluates with
the SetHandler approach.

ProxyPassMatch directly maps .php URLs to the FPM container's filesystem
path, bypassing the SCRIPT_FILENAME rewrite issue entirely:
  ^/(.*\.php(/.*)?)$ -> fcgi://fpm:9000/home/{user}/public_html/$1

Static assets (CSS, JS, images) bypass the proxy since they don't match
\.php and are served directly by Apache from the read-only mount.

Tested and confirmed working on live site with WordPress (including
pretty URLs via .htaccess mod_rewrite).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:50:54 -07:00
shadowdaoandClaude Opus 4.6 e5e055d198 Fix ProxyFCGISetEnvIf syntax for SCRIPT_FILENAME rewrite
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m1s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m25s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m18s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m17s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m46s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m18s
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
The previous expr= with s|...|...| substitution syntax doesn't exist
in Apache expressions — it silently failed, leaving SCRIPT_FILENAME
pointing to /mnt/users/ which PHP-FPM can't find.

Fixed to use regex match in the conditional with backreferences:
  reqenv('SCRIPT_FILENAME') =~ m#^/mnt/users/([^/]+)/([^/]+)/public_html(.*)#
  -> /home/$1/public_html$3

This is also generic (captures user from the path) so the template
no longer needs per-user placeholder substitution for this directive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:32:52 -07:00
shadowdaoandClaude Opus 4.6 c68b555a5f Fix PHP-FPM path mismatch in shared httpd vhost template
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m9s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m12s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m57s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m25s
Cloud Apache Container / Build-and-Push (84) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (85) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (74) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-and-Push (83) (push) Has been cancelled
The shared httpd serves files from /mnt/users/{user}/{domain}/public_html
but PHP-FPM containers have them at /home/{user}/public_html. When Apache
proxied PHP requests via fcgi, SCRIPT_FILENAME pointed to the Apache path
which doesn't exist inside the FPM container, causing "File not found".

Added ProxyFCGISetEnvIf to rewrite SCRIPT_FILENAME from the shared httpd
path to the FPM container path before proxying the request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:22:53 -07:00
shadowdaoandClaude Opus 4.6 7f7cb456f0 Add openssl to package installs for AlmaLinux 10
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m16s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m31s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m18s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 3m19s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m15s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m22s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m17s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m12s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m19s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m23s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 3m18s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m57s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 35s
AlmaLinux 10 base image does not include openssl by default (AL9 did).
Add it explicitly to all three Dockerfiles since it's needed for
self-signed cert generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:11:10 -07:00
shadowdaoandClaude Opus 4.6 dc6ce2bf12 Upgrade base image from AlmaLinux 9 to AlmaLinux 10
Cloud Apache Container / Build-and-Push (74) (push) Failing after 1m14s
Cloud Apache Container / Build-and-Push (80) (push) Failing after 1m46s
Cloud Apache Container / Build-and-Push (81) (push) Failing after 2m11s
Cloud Apache Container / Build-and-Push (82) (push) Failing after 1m7s
Cloud Apache Container / Build-and-Push (83) (push) Failing after 1m6s
Cloud Apache Container / Build-and-Push (84) (push) Failing after 1m53s
Cloud Apache Container / Build-and-Push (85) (push) Failing after 1m14s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m7s
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Bump all three Dockerfiles to almalinux/10-base with matching EPEL 10
and Remi 10 repository URLs. AlmaLinux 10.1 has been stable since Nov
2025. All PHP versions (7.4-8.5) confirmed available via Remi for EL10.

Also removes --allowerasing from shared-httpd Dockerfile since AL10
base does not ship curl-minimal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:55:26 -07:00
shadowdaoandClaude Opus 4.6 fc55752379 Fix curl-minimal conflict in shared-httpd Dockerfile
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m32s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 3m36s
Cloud Apache Container / Build-and-Push (82) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (83) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (84) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (85) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (74) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-and-Push (81) (push) Has been cancelled
The almalinux/9-base image ships curl-minimal which conflicts with the
full curl package. Add --allowerasing to allow dnf to replace it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:44:24 -07:00
shadowdaoandClaude Opus 4.6 367da7806c Fix ImageMagick install: use EPEL packages instead of upstream RPMs
Cloud Apache Container / Build-and-Push (80) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (81) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (82) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (83) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (84) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (85) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (74) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-and-Push (74) (push) Has been cancelled
The official ImageMagick 7.1.2-18 RPMs require GLIBC 2.38 which is not
available on AlmaLinux 9 (ships GLIBC 2.34). Switch to EPEL-provided
ImageMagick packages which are built for EL9 and guaranteed compatible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:44:03 -07:00
shadowdaoandClaude Opus 4.6 a5cb45a386 Install latest ImageMagick 7.1.2-18 from official RPMs
Cloud Apache Container / Build-and-Push (74) (push) Failing after 1m39s
Cloud Apache Container / Build-and-Push (80) (push) Failing after 1m11s
Cloud Apache Container / Build-and-Push (81) (push) Failing after 1m31s
Cloud Apache Container / Build-and-Push (82) (push) Failing after 54s
Cloud Apache Container / Build-and-Push (83) (push) Failing after 1m46s
Cloud Apache Container / Build-and-Push (84) (push) Failing after 1m47s
Cloud Apache Container / Build-and-Push (85) (push) Failing after 56s
Cloud Apache Container / Build-FPM-Images (74) (push) Failing after 1m42s
Cloud Apache Container / Build-FPM-Images (80) (push) Failing after 1m1s
Cloud Apache Container / Build-FPM-Images (81) (push) Failing after 55s
Cloud Apache Container / Build-FPM-Images (82) (push) Failing after 55s
Cloud Apache Container / Build-FPM-Images (83) (push) Failing after 59s
Cloud Apache Container / Build-FPM-Images (84) (push) Failing after 55s
Cloud Apache Container / Build-FPM-Images (85) (push) Failing after 57s
Cloud Apache Container / Build-Shared-httpd (push) Failing after 26s
Adds ImageMagick and ImageMagick-libs from the official CentOS x86_64
RPMs before PHP installation so php-pecl-imagick links against the
latest version. Applied to both Dockerfile (standalone) and
Dockerfile.fpm (shared httpd mode).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:15:15 -07:00
shadowdaoandClaude Opus 4.6 c78167871c Add shared httpd + PHP-FPM-only container architecture
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m22s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 3m14s
Cloud Apache Container / Build-and-Push (82) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (83) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (84) (push) Has been cancelled
Cloud Apache Container / Build-and-Push (85) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (74) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (80) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (81) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (82) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (83) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (84) (push) Has been cancelled
Cloud Apache Container / Build-FPM-Images (85) (push) Has been cancelled
Cloud Apache Container / Build-Shared-httpd (push) Has been cancelled
Cloud Apache Container / Build-and-Push (81) (push) Has been cancelled
Separate Apache and PHP-FPM into distinct container roles to reduce
per-customer memory overhead on shared servers. Adds three new images:
- Dockerfile.fpm: PHP-FPM only (no Apache), listens on TCP port 9000
- Dockerfile.shared-httpd: Apache only (no PHP), with SSL and proxy_fcgi
- Existing Dockerfile unchanged for standalone mode

Key changes:
- detect-memory.sh: CONTAINER_ROLE env var (combined/fpm_only/httpd_only)
  controls the memory budget split
- create-php-config.sh: FPM_LISTEN env var for TCP port vs Unix socket,
  added /fpm-ping and /fpm-status health endpoints
- New entrypoints for each container role
- tune-mpm.sh for hot-adjusting Apache MPM settings
- shared-vhost-template.tpl with proxy_fcgi and SSL on port 443
- CI/CD builds all three image types in parallel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:08:00 -07:00
shadowdaoandClaude Opus 4.6 87c4f2befc Optimize Apache & PHP-FPM memory for lower idle usage
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m31s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m54s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m51s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m52s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m39s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m58s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m51s
Switch PHP-FPM from pm=dynamic to pm=ondemand (zero idle workers),
auto-detect container memory via cgroups to calculate appropriate
limits, and generate Apache MPM config at runtime. All tuning values
are now overridable via environment variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:52:15 -08:00
shadowdao a153385d8f Adding support for PHP 8.5
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m12s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m46s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m47s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m44s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m47s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m46s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m47s
2026-02-08 07:57:04 -08:00
shadowdaoandClaude 468bc7b088 Move user crontab to persistent home directory
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m52s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m48s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m45s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m54s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m50s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m48s
- Created user-specific crontab file at /home/$user/crontab
- Crontab now persists through container restarts/refreshes
- Users can manage their own cron jobs by editing their crontab file
- Automatically loads user crontab on container start
- Updated DEV environment to use user crontab for MySQL backups

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-13 07:36:35 -07:00
shadowdaoandClaude 8b9708e351 Add essential development tools to container
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m31s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m55s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m58s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m52s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m48s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 3m24s
Added git, nano, rsync, unzip, zip, mariadb client, bind-utils, jq, patch, nc, tree, and dos2unix to provide developers with commonly needed tools for PHP development and debugging.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-13 07:19:25 -07:00
shadowdaoandClaude 92ed9885ec Remove php-ioncube-loader from PHP 8.1 to fix Composer installation
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m48s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m44s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m42s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m43s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m47s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m15s
The php-ioncube-loader package is incompatible with PHP 8.1 and was causing
a segmentation fault (exit code 139) when the Composer installer tried to
run PHP. This aligns PHP 8.1 with other PHP versions that already had
ioncube-loader removed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 16:41:33 -07:00
shadowdaoandClaude 844b21bd7c Add Composer to container for PHP dependency management
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m1s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m0s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m58s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m3s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m2s
Cloud Apache Container / Build-and-Push (81) (push) Failing after 1m28s
- Install Composer globally at /usr/local/bin/composer
- Available for all PHP versions and users
- Also includes previously added microdnf and less utilities

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 15:50:18 -07:00
shadowdaoandClaude 3d903b437f Fix PHP error log path to use correct user directory
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m45s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m39s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m38s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m39s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m42s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m50s
PHP error logs were incorrectly being written to /etc/httpd/logs/error_log
instead of the expected /home/$user/logs/php-fpm/ directory. Updated the
php_admin_value[error_log] setting to point to the proper location.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-31 10:21:15 -07:00
shadowdao 152dd413ef adding claude infor
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m12s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m37s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m54s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m23s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m8s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 3m21s
2025-07-28 07:29:03 -07:00
shadowdaoandClaude 617fdbcd21 Add PostgreSQL support for all PHP versions
- Added postgresql-devel package to Dockerfile for client libraries
- Added php-pgsql extension to all PHP versions (7.4, 8.0, 8.1, 8.2, 8.3, 8.4)
- Enables PHP applications to connect to PostgreSQL databases

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 07:27:39 -07:00
shadowdaoandClaude 154f42ae09 Optimize memory usage for Apache and PHP-FPM, remove ioncube-loader
Cloud Apache Container / Build-and-Push (74) (push) Successful in 3m7s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m42s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m37s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m39s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 3m3s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m40s
- Apache mpm_event: Reduced StartServers from 10 to 2, adjusted spare threads
  and worker limits for container environments
- PHP-FPM: Switched from static to dynamic process management with lower
  process counts (5 max children instead of 10)
- Removed php-ioncube-loader from PHP 8.0 installation
- Expected memory reduction: 60-70% in idle state while maintaining responsiveness

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 16:52:04 -07:00
shadowdao b5857d73c2 Fix issue where PHP Sessions were not working as expected
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m37s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 44s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 1m41s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 1m39s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 1m37s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m35s
2025-07-20 12:06:15 -07:00
shadowdao b1de7021a3 fix build issues
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m44s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m38s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 8m24s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 5m1s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 7m30s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 8m55s
2025-07-16 08:01:07 -07:00
shadowdao 9f8beb45b8 Switching builds to include PHP version to limit memory requirements on deploy.
Cloud Apache Container / Build-and-Push (74) (push) Failing after 56s
Cloud Apache Container / Build-and-Push (80) (push) Failing after 36s
Cloud Apache Container / Build-and-Push (81) (push) Failing after 56s
Cloud Apache Container / Build-and-Push (82) (push) Failing after 55s
Cloud Apache Container / Build-and-Push (83) (push) Failing after 40s
Cloud Apache Container / Build-and-Push (84) (push) Failing after 57s
improve build size and speed for images.
2025-07-16 07:55:03 -07:00
shadowdao 88f462eb04 Updated the README.md
Cloud Apache Container / Build-and-Push (push) Successful in 1m1s
Added a healthcheck to the container
adjusted Apache limits for memory consumption
switch to microdnf for improved memory usage
2025-07-16 05:56:33 -07:00
shadowdao e7b0bce666 Update MariaDB Version
Cloud Apache Container / Build-and-Push (push) Successful in 41s
2025-06-14 16:02:00 -07:00
shadowdao 5a097034c4 Update MariaDB Version
Cloud Apache Container / Build-and-Push (push) Successful in 44s
2025-03-16 11:12:46 -07:00
shadowdao a41157fad0 fix url
Cloud Apache Container / Build-and-Push (push) Successful in 39s
2025-03-16 10:58:25 -07:00
shadowdao 4fd7ee465a Adding support for PHP 8.4 and upgrading MariaDB to 11.4.5
Cloud Apache Container / Build-and-Push (push) Successful in 47s
2025-03-16 10:43:16 -07:00
shadowdao 8a7490ef98 forgot to add iproute
Cloud Apache Container / Build-and-Push (push) Successful in 1m32s
2024-12-17 21:52:38 -08:00
jknapp 9df776ef08 Merge pull request 'fix path to remote_ip.conf' (#18) from update-to-fix-ip-and-options-issue into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 39s
Reviewed-on: #18
2024-12-18 05:47:59 +00:00
shadowdao 7bab6d39fc fix path to remote_ip.conf 2024-12-17 21:47:32 -08:00
jknapp 9630408ca0 Merge pull request 'Added fix for issues found while setting up anhonesthost' (#17) from update-to-fix-ip-and-options-issue into trunk
Cloud Apache Container / Build-and-Push (push) Failing after 8s
Reviewed-on: #17
2024-12-18 05:44:51 +00:00
shadowdao 49c5438866 Added fix for issues found while setting up anhonesthost 2024-12-17 21:44:09 -08:00
jknapp 885deb5979 Merge pull request 'fix spacing on versions' (#16) from fix-readme into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 35s
Reviewed-on: #16
2024-10-16 01:50:31 +00:00
shadowdao 23253e9f37 fix spacing on versions 2024-10-15 18:50:11 -07:00
jknapp fde567d5f9 Merge pull request 'Fix/Update README.md' (#15) from fix-readme into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 35s
Reviewed-on: #15
2024-10-16 01:45:51 +00:00
shadowdao b2675abc30 Fix/Update README.md 2024-10-15 18:44:09 -07:00
jknapp aab89a7412 Merge pull request 'Update for log rotation and backups' (#14) from log-cleanup into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 1m22s
Reviewed-on: #14
2024-10-15 02:32:44 +00:00
shadowdao 527ba5cf58 Adding better backups and log rotation, and updating files around it 2024-10-14 19:30:51 -07:00
shadowdao bbd2de6792 Update for log rotation and backups 2024-10-14 12:15:11 -07:00
jknapp ed9ba0118b Update to reflect changes for user directory
Cloud Apache Container / Build-and-Push (push) Successful in 41s
2024-10-14 17:28:24 +00:00
jknapp 715b998404 Update README to reflect gitea address and adding logs
Cloud Apache Container / Build-and-Push (push) Successful in 1m22s
2024-10-14 17:25:10 +00:00
jknapp 7d988b338c Merge pull request 'Fixing script to add more time for startup and add backup crons for database' (#13) from fix-script into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 34s
Reviewed-on: #13
2024-10-02 20:22:22 +00:00
shadowdao b3e284a547 Fixing script to add more time for startup and add backup crons for database 2024-10-02 13:21:49 -07:00
jknapp 565482764d Merge pull request 'Update script to default to PHP 8.3 and have options' (#12) from add-php83 into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 32s
Reviewed-on: #12
2024-10-02 18:55:46 +00:00
shadowdao 3d3e353c66 Update script to default to PHP 8.3 and have options 2024-10-02 11:55:22 -07:00
jknapp 0373eb4ea8 Merge pull request 'Fix script host location' (#11) from add-php83 into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 33s
Reviewed-on: #11
2024-10-02 16:44:31 +00:00
shadowdao 36757fac8f fix docker command 2024-10-02 09:43:47 -07:00
shadowdao 0c8bdc4f04 Update local-dev script 2024-10-02 09:40:53 -07:00
jknapp f1ab086228 Merge pull request 'Adding PHP 8.3 and updating README.md for moving repos' (#10) from add-php83 into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 33s
Reviewed-on: #10
2024-10-02 16:02:24 +00:00
shadowdao 520af5b3a8 Adding PHP 8.3 and updating README.md for moving repos 2024-10-02 08:58:42 -07:00
jknapp 06a7cbc88d Merge pull request 'fix push target' (#9) from add-ci into trunk
Cloud Apache Container / Build-and-Push (push) Successful in 1m9s
Reviewed-on: #9
2024-10-01 21:20:09 +00:00
shadowdao b1ec63617a fix push target 2024-10-01 14:19:51 -07:00
jknapp 5ead6ed456 Merge pull request 'fix push target' (#8) from add-ci into trunk
Cloud Apache Container / Build-and-Push (push) Failing after 47s
Reviewed-on: #8
2024-10-01 21:17:55 +00:00
shadowdao b38b80e6fc fix push target 2024-10-01 14:09:12 -07:00
jknapp b53a4999bf Merge pull request 'fix push target' (#7) from add-ci into trunk
Cloud Apache Container / Build-and-Push (push) Failing after 36s
Reviewed-on: #7
2024-10-01 21:08:03 +00:00
shadowdao 49f2266974 fix push target 2024-10-01 14:07:22 -07:00
jknapp abb1da3a0f Merge pull request 'fix push target' (#6) from add-ci into trunk
Cloud Apache Container / Build-and-Push (push) Failing after 59s
Reviewed-on: #6
2024-10-01 21:04:09 +00:00
shadowdao ac5c70d26b fix push target 2024-10-01 14:03:29 -07:00
jknapp 1d4d440a88 Merge pull request 'fix branch' (#5) from add-ci into trunk
Cloud Apache Container / Build-and-Push (push) Failing after 1m5s
Reviewed-on: #5
2024-10-01 21:00:31 +00:00
shadowdao 5108689aa4 fix branch 2024-10-01 14:00:12 -07:00
jknapp 3d51a63ae4 Merge pull request 'First attempt at creating CI with Gitea Actions' (#4) from add-ci into trunk
Reviewed-on: #4
2024-10-01 20:57:37 +00:00
shadowdao 4ba4b7ae1e First attempt at creating CI with Gitea Actions 2024-10-01 13:57:01 -07:00
jknapp 07999c4252 Merge pull request 'update for prod run' (#3) from update-for-prod into trunk
Reviewed-on: #3
2024-08-13 01:21:13 +00:00
root 90841ada03 update for prod run 2024-08-12 21:20:00 -04:00
jknapp b2b3d284a6 Merge pull request 'Streamline WordPress Setup' (#2) from add-script into trunk
Reviewed-on: #2
2024-01-31 00:44:15 +00:00
shadowdao b6fe0d77fd update script to setup the config 2024-01-30 16:40:42 -08:00
shadowdao 2e912bc4ab update script 2024-01-30 14:01:46 -08:00
jknapp 6d966d388f Merge pull request 'Adding script to make it easier to create local development' (#1) from add-script into trunk
Reviewed-on: #1
2024-01-30 21:36:02 +00:00
jknapp da8e2fcb9c Update Readme to reflect correction
Fix for command
2023-12-10 21:38:28 +00:00
67 changed files with 5586 additions and 187 deletions
+39
View File
@@ -0,0 +1,39 @@
# Ignore version control
.git
.gitignore
# Ignore CI/CD and workflow files
.gitea/
.github/
.gitlab/
# Ignore local development files
*.swp
*.swo
*.bak
*.tmp
*.log
# Ignore OS and editor files
.DS_Store
Thumbs.db
.vscode/
.idea/
# Ignore test and documentation files
tests/
docs/
README*
# Ignore node and Python artifacts (if present)
node_modules/
__pycache__/
# Ignore build output
dist/
build/
# Ignore secrets and configs
*.env
.env.*
secrets/
+270
View File
@@ -0,0 +1,270 @@
name: Cloud Apache Container
run-name: ${{ gitea.actor }} pushed a change to trunk
on:
push:
branches:
- trunk
jobs:
# Shell gate. Runs FIRST and costs seconds; the images below do not depend on
# it (a red job here does not block a push that is otherwise fine), but it is
# the only place the ENTRYPOINT logic is executed at all. The .phpt suite and
# the Dockerfile's `lsphp -i` probe both test the extension, and both were
# green for the release whose entrypoint declared that same extension
# missing — see scripts/tests/lsphp-info-probe.test.sh for what went wrong
# and why it needed a test outside the image build to catch it.
Shell-Checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Runner images differ on whether they are root and whether sudo exists.
- name: Install shellcheck
run: |
if ! command -v shellcheck >/dev/null 2>&1; then
(apt-get update && apt-get install -y shellcheck) ||
(sudo apt-get update && sudo apt-get install -y shellcheck)
fi
shellcheck --version
- name: Syntax check every shell script
run: |
set -euo pipefail
find scripts ext -name '*.sh' -print0 | xargs -0 -n1 bash -n
# Deliberately NOT repo-wide. The older scripts (entrypoint.sh,
# entrypoint-fpm.sh, create-vhost.sh, create-php-config.sh,
# detect-memory.sh) carry pre-existing SC2154/SC2027 findings that predate
# this job; listing them here would make the gate red on arrival and
# therefore ignored. This is the set that is clean today — the scripts
# that run `set -o pipefail` plus the tests. Add files as they are fixed;
# do not add one that is not yet clean.
- name: shellcheck (warnings and above, on the clean set)
run: |
shellcheck -S warning \
scripts/entrypoint-lsphp.sh \
scripts/entrypoint-litespeed.sh \
scripts/entrypoint-shared-ols.sh \
scripts/render-shared-ols-config.sh \
scripts/ols-htaccess-watcher.sh \
scripts/create-vhost-litespeed.sh \
scripts/install-lscache-wp.sh \
scripts/tune-mpm.sh \
scripts/tests/lsphp-info-probe.test.sh
- name: lsphp probe regression test
run: ./scripts/tests/lsphp-info-probe.test.sh
Build-and-Push:
runs-on: ubuntu-latest
strategy:
matrix:
phpver: [74, 80, 81, 82, 83, 84, 85]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: repo.anhonesthost.net
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
push: true
build-args: |
PHPVER=${{ matrix.phpver }}
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac:latest' || '' }}
Build-FPM-Images:
runs-on: ubuntu-latest
strategy:
matrix:
phpver: [74, 80, 81, 82, 83, 84, 85]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: repo.anhonesthost.net
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and Push FPM Image
uses: docker/build-push-action@v6
with:
file: ./Dockerfile.fpm
platforms: linux/amd64
push: true
build-args: |
PHPVER=${{ matrix.phpver }}
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:latest' || '' }}
Build-LiteSpeed-Images:
runs-on: ubuntu-latest
strategy:
matrix:
# PHP 7.4/8.0 deliberately excluded — the LiteSpeed prebuilt base
# images stop at older OLS releases for those PHP versions, and the
# cac-litespeed tier is a paid premium offering: 8.1+ is the
# modernization story we're selling.
phpver: [81, 82, 83, 84, 85]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: repo.anhonesthost.net
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and Push LiteSpeed Image
uses: docker/build-push-action@v6
with:
file: ./Dockerfile.litespeed
platforms: linux/amd64
push: true
build-args: |
PHPVER=${{ matrix.phpver }}
OLS_VERSION=1.8.4
# OLS_VERSION pinned to 1.8.4 — only release with prebuilt images
# for every PHP version we ship (1.8.5 and 1.9.0 don't have an
# lsphp81 variant on Docker Hub). Bump alongside a local rebuild
# test when LiteSpeed publishes lsphp81 on a newer OLS release.
# See spec: docs/superpowers/specs/2026-06-01-cac-litespeed-design.md
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:latest' || '' }}
Build-LSPHP-Images:
runs-on: ubuntu-latest
strategy:
matrix:
# Same PHP matrix as cac-litespeed (8185): cac-lsphp is the detached
# backend for the shared-ols tier and shares the litespeed prebuilt
# base, which only ships lsphp for 8.1+. Keep this matrix in lockstep
# with Build-LiteSpeed-Images.
phpver: [81, 82, 83, 84, 85]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: repo.anhonesthost.net
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and Push lsphp Image
uses: docker/build-push-action@v6
with:
file: ./Dockerfile.lsphp
platforms: linux/amd64
push: true
build-args: |
PHPVER=${{ matrix.phpver }}
OLS_VERSION=1.8.4
# OLS_VERSION pinned to 1.8.4 to match Build-LiteSpeed-Images — same
# prebuilt base, same lsphp binaries. Bump both together.
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:latest' || '' }}
Build-Shared-httpd:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: repo.anhonesthost.net
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and Push Shared httpd Image
uses: docker/build-push-action@v6
with:
file: ./Dockerfile.shared-httpd
platforms: linux/amd64
push: true
tags: |
repo.anhonesthost.net/cloud-hosting-platform/shared-httpd:latest
Build-Shared-OLS:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: repo.anhonesthost.net
username: ${{ secrets.CI_USER }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and Push Shared OLS Image
uses: docker/build-push-action@v6
with:
file: ./Dockerfile.shared-ols
platforms: linux/amd64
push: true
# Single image (runs no PHP). PHPVER just selects the OLS base tag;
# pinned to 83 / OLS 1.8.4 to match the rest of the litespeed family.
build-args: |
PHPVER=83
OLS_VERSION=1.8.4
tags: |
repo.anhonesthost.net/cloud-hosting-platform/shared-ols:latest
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Cloud Apache Container (CAC) is a Docker-based PHP web hosting environment that supports multiple PHP versions (7.4 through 8.4) with Apache, designed for both local development and production deployment.
## Common Development Commands
### Local Development Setup
```bash
# Quick start with automated setup (creates helper scripts)
./local-dev.sh -n local-dev
# With specific PHP version
./local-dev.sh -n myproject -a 84 # PHP 8.4
# Helper scripts created by local-dev.sh:
./instance_start # Start container
./instance_stop # Stop container
./instance_logs # Tail Apache logs
./instance_db_info # Show MySQL credentials
```
### Building and Testing
```bash
# Build container locally
docker build -t cac:latest .
# Build with specific PHP version
docker build --build-arg PHP_VER=83 -t cac:php83 .
# Run container manually
docker run -d -p 80:80 -p 443:443 \
-e PHPVER=83 -e environment=DEV \
-e uid=$(id -u) -e user=$(whoami) -e domain=localhost \
-v"$(pwd)/user":/home/$(whoami) \
--name test-container cac:latest
```
### Server Deployment
- Production git directory: `/root/whp`
- After `git pull`, sync web files: `rsync -av web-files/ /docker/whp/web/`
## Architecture and Key Components
### Directory Structure
- `/scripts/` - Container setup scripts (entrypoint, PHP installers, vhost creation)
- `/config/` - Apache and PHP configuration files
- `/web-files/` - Default web content (ping endpoint)
- `/.gitea/workflows/` - CI/CD pipeline for multi-PHP version builds
### Container Behavior
1. **Entrypoint Flow** (`scripts/entrypoint.sh`):
- Creates user with specified UID
- Sets up directory structure
- Configures Apache vhost based on environment variables
- In DEV mode: starts MariaDB and Memcached
- Starts Apache and PHP-FPM
2. **Environment Modes**:
- **DEV** (`environment=DEV`): Local database, memcached, automatic backups
- **PROD** (default): Expects external database/cache services
3. **PHP Version Management**:
- Controlled via `PHPVER` environment variable (74, 80, 81, 82, 83, 84)
- Each version has dedicated install script in `/scripts/`
- PHP-FPM configuration dynamically created based on version
### Key Environment Variables
- `uid` (required): User ID for file permissions
- `user` (required): Username for container user
- `domain` (required): Primary domain for Apache vhost
- `serveralias`: Additional domains (comma-separated)
- `PHPVER`: PHP version to use (default: 83)
- `environment`: DEV or PROD mode
## Important Technical Details
1. **Health Check**: Available at `/ping` endpoint
2. **Logs Location**: `/home/$user/logs/apache/` and `/home/$user/logs/php-fpm/`
3. **Database Backups** (DEV mode): Every 15 minutes to `/home/$user/_db_backups/`
4. **Log Rotation**: Compress after 3 days, delete after 7 days
5. **SSL**: Self-signed certificate auto-generated, proper SSL configured
6. **WordPress**: WP-CLI pre-installed for WordPress development
+43 -16
View File
@@ -1,23 +1,50 @@
FROM almalinux/9-base FROM almalinux/10-base
ARG PHPVER=81 ARG PHPVER=83
#RUN dnf update -y && dnf upgrade -y
RUN dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm -y # Install repos, update, install only needed packages, clean up in one layer
RUN dnf install -y https://rpms.remirepo.net/enterprise/remi-release-9.rpm RUN dnf install -y \
#RUN dnf update -y && dnf upgrade -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm \
RUN dnf install -y httpd mod_ssl wget procps https://rpms.remirepo.net/enterprise/remi-release-10.rpm && \
RUN openssl req -newkey rsa:2048 -nodes -keyout /etc/pki/tls/private/localhost.key -x509 -days 3650 -subj "/CN=localhost" -out /etc/pki/tls/certs/localhost.crt dnf update -y && \
RUN mkdir /run/php-fpm/ dnf install -y httpd mod_ssl openssl wget procps cronie iproute postgresql-devel microdnf less git \
RUN mkdir /scripts nano rsync unzip zip mariadb bind-utils jq patch nc tree dos2unix && \
COPY ./scripts/* /scripts/ dnf clean all && \
rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
# Copy scripts into the image and set permissions
COPY ./scripts/ /scripts/
RUN chmod +x /scripts/* RUN chmod +x /scripts/*
#RUN /scripts/install-php$PHPVER.sh
RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar # Install ImageMagick from EPEL with HEIC/HEIF/AVIF support
RUN chmod +x wp-cli.phar RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \
RUN mv wp-cli.phar /usr/local/bin/wp dnf clean all
# Generate self-signed cert, create needed dirs, install PHP, clean up
RUN openssl req -newkey rsa:2048 -nodes -keyout /etc/pki/tls/private/localhost.key -x509 -days 3650 -subj "/CN=localhost" -out /etc/pki/tls/certs/localhost.crt && \
mkdir -p /run/php-fpm/ && \
/scripts/install-php$PHPVER.sh && \
rm -rf /tmp/*
# Download and install wp-cli (consider pinning version for reproducibility)
RUN curl -L -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
chmod +x /usr/local/bin/wp
# Download and install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
chmod +x /usr/local/bin/composer
# Copy configs and web files
COPY ./configs/default-index.conf /etc/httpd/conf.d/ COPY ./configs/default-index.conf /etc/httpd/conf.d/
COPY ./configs/prod-php.ini /etc/php.ini COPY ./configs/prod-php.ini /etc/php.ini
COPY ./configs/phpinfo.php /var/www/html/ COPY ./configs/phpinfo.php /var/www/html/
COPY ./configs/mariadb.repo /etc/yum.repos.d/ COPY ./configs/mariadb.repo /etc/yum.repos.d/
COPY ./configs/index.php /var/www/html/ COPY ./configs/index.php /var/www/html/
RUN yum clean all COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
# Set up cron job in a single layer
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost/ || exit 1
ENTRYPOINT [ "/scripts/entrypoint.sh" ] ENTRYPOINT [ "/scripts/entrypoint.sh" ]
+47
View File
@@ -0,0 +1,47 @@
FROM almalinux/10-base
ARG PHPVER=83
# Install repos, update, install only needed packages (no httpd/mod_ssl), clean up in one layer
RUN dnf install -y \
https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm \
https://rpms.remirepo.net/enterprise/remi-release-10.rpm && \
dnf update -y && \
dnf install -y openssl wget procps cronie iproute postgresql-devel microdnf less git \
nano rsync unzip zip mariadb bind-utils jq patch nc tree dos2unix fcgi && \
dnf clean all && \
rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
# Copy scripts into the image and set permissions
COPY ./scripts/ /scripts/
RUN chmod +x /scripts/*
# Install ImageMagick from EPEL with HEIC/HEIF/AVIF support
RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \
dnf clean all
# Create needed dirs, install PHP, clean up (no SSL cert, no httpd)
RUN mkdir -p /run/php-fpm/ && \
/scripts/install-php$PHPVER.sh && \
rm -rf /tmp/*
# Download and install wp-cli
RUN curl -L -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
chmod +x /usr/local/bin/wp
# Download and install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
chmod +x /usr/local/bin/composer
# Copy configs (PHP only, no Apache configs)
COPY ./configs/prod-php.ini /etc/php.ini
COPY ./configs/mariadb.repo /etc/yum.repos.d/
# Set up cron job for log rotation
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD SCRIPT_FILENAME=/fpm-ping SCRIPT_NAME=/fpm-ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 | grep -q pong || exit 1
ENTRYPOINT [ "/scripts/entrypoint-fpm.sh" ]
+97
View File
@@ -0,0 +1,97 @@
## cac-litespeed — OpenLiteSpeed customer container, LSAPI-native.
##
## Built on top of the LiteSpeed-maintained prebuilt image rather than
## installed-from-RPM on AlmaLinux 10. Rationale:
## - The EL10 RPM ships an empty /usr/local/lsws/cgid/ directory (the
## lscgid suexec helper is built by the upstream tarball install.sh,
## not packaged), which makes LSAPI unusable.
## - The prebuilt image is Ubuntu 24.04-based and includes lsphp +
## everything WP/WooCommerce needs out of the box (memcached, redis,
## imagick, mbstring, mysqlnd, intl, gd, soap, bcmath, gmp, sodium,
## opcache, ...) — saves us a dozen explicit installs and avoids the
## libonig.so.105 packaging bug entirely.
## - LiteSpeed Inc maintains it; OLS upgrades become a base-image bump.
##
## Tradeoff vs the rest of the CAC family: this image is Ubuntu-based,
## not AlmaLinux. The "cac" naming is now slightly misleading (it's no
## longer Cloud *Apache* Container, it's Cloud LiteSpeed Container) but
## the panel doesn't care and the customer-facing contract is identical.
## ARG before FROM is special — it can be used in the FROM line, but the
## value goes out of scope inside the image, so we re-declare ARG PHPVER
## after FROM for any RUN steps that need it.
ARG OLS_VERSION=1.8.5
ARG PHPVER=83
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
ARG PHPVER=83
ENV PHPVER=${PHPVER}
## Tooling we layer on top of the base:
## - gettext-base: envsubst for runtime template rendering
## - sudo: install-lscache-wp.sh runs wp-cli as the customer user
## - composer: not in the base image (wp-cli is)
## - cron: customer crontab support (mirror cac:phpXX behaviour)
## - lsphp83-ldap: not in base image, useful for some WP plugins
##
## NOTE: mariadb-server + memcached were previously installed here for
## DEV-mode parity but bloated the PROD image by ~500MB. They are now
## installed at runtime by entrypoint-litespeed.sh ONLY when
## environment=DEV, mirroring the cac:phpNN pattern. The mysql CLI
## client (used by the DEV creds-bootstrap and by wp-cli) is already
## present in the litespeedtech/openlitespeed base via the mysql-client
## package, so no client-side install is needed at build time.
##
## All apt cache is cleaned in the same layer to keep image size down.
## lsphp${PHPVER}-ldap is the only extra ext we add (everything else WP needs
## ships in the prebuilt base). lsphp84 + lsphp85 don't ship imap or pspell
## from LiteSpeed — customers needing imap should pin to 8.3 or earlier.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
gettext-base sudo cron \
ca-certificates curl wget \
lsphp${PHPVER}-ldap && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
## Composer (matches the wp-cli pattern in the existing CAC Dockerfile —
## phar download, no `php install` pipe since lsphp's CLI mode is fine).
RUN curl -fsSL -o /usr/local/bin/composer https://getcomposer.org/download/latest-stable/composer.phar && \
chmod +x /usr/local/bin/composer
## Our scripts + config templates layer in last (they change most often,
## keep them off the slow apt layer).
COPY ./scripts/entrypoint-litespeed.sh \
./scripts/create-vhost-litespeed.sh \
./scripts/detect-memory-litespeed.sh \
./scripts/install-lscache-wp.sh \
./scripts/log-rotate.sh \
/scripts/
RUN chmod +x /scripts/*
COPY ./configs/litespeed/ /etc/lsws-templates/
## Apply our production lsphp ini overrides. Ask lsphp for its scan dir
## directly (varies by PHP minor version: 8.3/8.4/8.5 each have their own
## /usr/local/lsws/lsphpNN/etc/php/8.M/mods-available/). Dockerfile RUN uses
## /bin/sh so we explicitly `bash -c` for safer scripting.
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"; \
echo "wrote overrides to $SCAN_DIR"'
## Disable the OLS WebAdmin port for customer-facing containers. Bind admin
## listener to loopback so it's unreachable even from the docker network.
RUN sed -i 's|^[[:space:]]*address[[:space:]]\+\*:| address 127.0.0.1:|' \
/usr/local/lsws/admin/conf/admin_config.conf 2>/dev/null || true
## Cron entry for log rotation (mirrors cac:phpXX).
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
EXPOSE 80 443
## Healthcheck: the entrypoint drops a static /healthz into the customer
## docroot at boot, so this passes even before any customer files exist.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsSk https://127.0.0.1/healthz || exit 1
ENTRYPOINT ["/scripts/entrypoint-litespeed.sh"]
+227
View File
@@ -0,0 +1,227 @@
## 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 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.
RUN set -e; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
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.
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).
COPY --from=ext-build /build-out/lsphp.version /etc/cac-lsphp-build.version
RUN set -e; \
V=$(cat /etc/cac-lsphp-build.version); \
apt-get update; \
if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
lsphp${PHPVER}="$V" lsphp${PHPVER}-common="$V" lsphp${PHPVER}-ldap="$V"; then \
echo "FATAL: lsphp${PHPVER} $V is what cac_path_parity was compiled against," >&2; \
echo " but the LiteSpeed repo no longer offers it (it keeps only the" >&2; \
echo " current release). The ext-build stage is almost certainly a stale" >&2; \
echo " cache hit — rebuild with --no-cache." >&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)"
## 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
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"; \
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
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"]
+40
View File
@@ -0,0 +1,40 @@
FROM almalinux/10-base
# Install Apache and minimal dependencies (no PHP at all)
RUN dnf install -y \
https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm && \
dnf update -y && \
dnf install -y httpd mod_ssl openssl iproute cronie procps curl && \
dnf clean all && \
rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
# Copy scripts and set permissions
COPY ./scripts/detect-memory.sh /scripts/detect-memory.sh
COPY ./scripts/create-apache-mpm-config.sh /scripts/create-apache-mpm-config.sh
COPY ./scripts/log-rotate.sh /scripts/log-rotate.sh
COPY ./scripts/entrypoint-shared-httpd.sh /scripts/entrypoint-shared-httpd.sh
COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh
RUN chmod +x /scripts/*
# Generate self-signed SSL cert (same as main CAC image)
RUN openssl req -newkey rsa:2048 -nodes \
-keyout /etc/pki/tls/private/localhost.key \
-x509 -days 3650 -subj "/CN=localhost" \
-out /etc/pki/tls/certs/localhost.crt
# Copy Apache configs
COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
COPY ./configs/default-index.conf /etc/httpd/conf.d/
# Create vhosts directory (will be volume-mounted from host)
RUN mkdir -p /etc/httpd/conf.d/vhosts
# Set up cron job for log rotation
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
EXPOSE 80 443
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -sfk https://localhost/ping || exit 1
ENTRYPOINT [ "/scripts/entrypoint-shared-httpd.sh" ]
+68
View File
@@ -0,0 +1,68 @@
## shared-ols — the shared OpenLiteSpeed webserver tier.
##
## One OLS container fronting MANY tenants' detached cac-lsphp sidecars — the
## OLS analogue of the shared-httpd container. Runs NO PHP locally: every site's
## PHP goes to its own cac-lsphp:phpNN sidecar over LSAPI (extProcessor type
## lsapi, address <sidecar>:9000). HAProxy stays the TLS/WAF/SNI edge and routes
## OLS-type hostnames here on :443.
##
## Built on the SAME litespeedtech prebuilt base as cac-litespeed / cac-lsphp so
## the OLS build + plumbing (lscgid, cgid socket — see feedback_ols_packaging_landmines)
## are the proven ones. The base is lsphp-tagged but we never run that lsphp;
## the tag just selects the OLS build. Pinned to lsphp83 / OLS 1.8.4.
##
## Config model (established by PoC 2026-06-10): OLS has NO top-level `include`,
## so render-shared-ols-config.sh assembles httpd_config.conf from the panel's
## per-site files at boot + on every change. See that script + the plan.
ARG OLS_VERSION=1.8.4
ARG PHPVER=83
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
## Tooling the shared tier needs on top of the base:
## - inotify-tools: the .htaccess watcher (spec 5.3)
## - gettext-base: envsubst for render-shared-ols-config.sh
## - openssl: self-signed cert for the :443 listener (HAProxy verifies none)
## - curl/ca-certificates: HEALTHCHECK
## - procps: provides pgrep, which entrypoint-shared-ols.sh's ols_running()
## liveness check depends on. Only transitively present via the base image
## today (Ubuntu 24.04 pulls it in) — pin it explicitly so it can't be
## pruned as "unused" and silently break the supervisor's crash detection.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
inotify-tools gettext-base openssl ca-certificates curl procps && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
## Snapshot the stock httpd_config.conf so render-shared-ols-config.sh always has
## a pristine base to strip-and-rebuild from (the base image keeps it at conf/).
RUN mkdir -p /usr/local/lsws/.conf && \
cp /usr/local/lsws/conf/httpd_config.conf /usr/local/lsws/.conf/httpd_config.conf
COPY ./scripts/entrypoint-shared-ols.sh \
./scripts/render-shared-ols-config.sh \
./scripts/ols-htaccess-watcher.sh \
/scripts/
RUN chmod +x /scripts/entrypoint-shared-ols.sh /scripts/render-shared-ols-config.sh /scripts/ols-htaccess-watcher.sh
COPY ./configs/shared-ols/ /etc/shared-ols-templates/
## Admin console unreachable from tenant/edge networks (spec 5.2): bind the
## WebAdmin listener to loopback. Same sed as Dockerfile.litespeed.
RUN sed -i 's|^[[:space:]]*address[[:space:]]\+\*:| address 127.0.0.1:|' \
/usr/local/lsws/admin/conf/admin_config.conf 2>/dev/null || true
EXPOSE 80 443
## Health: the entrypoint renders a catch-all _health vhost serving /healthz, so
## this passes from boot (zero customer sites) onward. Self-signed :443.
##
## MUST stay on /healthz, and must stay a LOOPBACK request. That vhost answers
## 421 for every other path/Host so an unmapped customer hostname can never look
## "up" to a monitor; /healthz answers 200 only for an internal client address
## (loopback here). Probing `/` instead would fail the healthcheck and restart
## the whole shared tier. WHP's setup-shared-ols.sh overrides this with the
## equivalent `curl -sfk https://localhost/healthz`; keep the two in step.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsSk https://127.0.0.1/healthz || exit 1
ENTRYPOINT ["/scripts/entrypoint-shared-ols.sh"]
+107 -44
View File
@@ -1,71 +1,134 @@
# Cloud Apache Container # # Cloud Apache Container
This is the base container for running PHP based applications. Select the PHP version environment variables. PHP Version Defaults to PHP 8.1
*__You mush have docker or compatable containerization software running.__* This is a base container for running PHP-based applications, supporting multiple PHP versions (7.4, 8.0, 8.1, 8.2, 8.3, 8.4). The default is PHP 8.3. The container is based on AlmaLinux 9 and uses Apache with mod_ssl. It is designed for both development and production use.
__You can pull this image locally by running:__ **You must have Docker or compatible containerization software running.**
```console ---
docker pull public.ecr.aws/s1f6k4w4/cac:latest
## What's New?
- **Optimized Image:** The Dockerfile has been refactored for smaller size, faster builds, and improved security. Unnecessary files and caches are removed during build.
- **Pre-built Images for Each PHP Version:** On every push, images for all supported PHP versions are built and pushed to the registry. You can pull the exact version you need (e.g., `cac:php74`, `cac:php84`, or `cac:latest`).
- **.dockerignore Added:** The build context is now minimized, making builds faster and more secure.
---
## Quick Start: Local Development with `local-dev.sh`
The easiest way to start a local development environment is with the provided `local-dev.sh` script. This script automates container setup, volume creation, log directories, and WordPress installation.
### Usage Example
```bash
./local-dev.sh -n local-dev
``` ```
__You can then run a development version of the server by running the following commands:__ **Flags:**
*Note this is an example, you can modify the command(s) to fit your needs.* - `-n` Name of the container (required)
- `-p` HTTP port (default: 80)
- `-s` HTTPS port (default: 443)
- `-r` Root path for files and database (default: current directory)
- `-a` PHP version (default: 8.3; options: 74, 80, 81, 82, 83, 84)
- `-v` Enable verbose mode
- `-h` Show help
```console The script will:
- Create a user directory and log folders
- Create a Docker volume for MySQL
- Start the container with the correct environment variables
- Generate helper scripts in your root path:
- `instance_start` Start the container
- `instance_stop` Stop the container
- `instance_logs` Tail Apache logs
- `instance_db_info` Show MySQL credentials
- Install WordPress in your web root
- Print MySQL credentials
---
## Manual Docker Usage
You can also run the container manually:
```bash
mkdir -p local-development/domain.tld mkdir -p local-development/domain.tld
cd local-development/domain.tld cd local-development/domain.tld
mkdir {web,db} mkdir user
docker run -it -p 80:80 -p 443:443 -e PHPVER=81 -e environment=DEV --mount type=bind,source="$(pwd)"/web,target=/home/myuser/public_html --mount type=bind,source="$(pwd)"/db,target=/var/lib/mysql -e uid=30001 -e user=myuser -e domain=domain.tld -e serveralias=www.domain.tld --name local-dev cac:latest mkdir -p user/logs/{apache,system}
docker run -d -it -p 80:80 -p 443:443 -e PHPVER=84 -e environment=DEV --mount type=bind,source="$(pwd)"/user,target=/home/myuser -v"$name-mysql":/var/lib/mysql -e uid=30001 -e user=myuser -e domain=localhost --name local-dev repo.anhonesthost.net/cloud-hosting-platform/cac:latest
``` ```
*This will start the processes needed to run sites locally.* ---
The first time you start the container, it will take some time as it is installing all the required software to run the dev instance. ## Accessing the Container
__If you need to get into the container you can run:__ ```bash
```console
docker exec -it local-dev /bin/bash docker exec -it local-dev /bin/bash
``` ```
__To install WordPress for your site__ ---
```console ## WordPress Installation
cat /var/lib/mysql/creds
If using `local-dev.sh`, WordPress is installed automatically. For manual setup:
```bash
cat /home/myuser/mysql_creds
su - myuser su - myuser
cd ~/public_html cd ~/public_html
wp core download wp core download
``` ```
You should be able to then go into your browser and go to https://localhost (accept the SSL warning if it appears) and follow the prompts to setup the site. Then visit https://localhost (accept the SSL warning) to complete setup.
The database credentials are shown in the /var/lib/mysql/creds file, which we had *cat* in the commands above. ---
### PHPVER ### ## Features
*74* - PHP 7.4
*80* - PHP 8.0
*81* - PHP 8.1
*82* - PHP 8.2
### Environment Variables ### - **Multiple PHP Versions:** 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 (set with `PHPVER` or `-a` flag)
__Required Tags__ - **Pre-built Images:** Pull the image for your desired PHP version directly from the registry. No need to build locally unless customizing.
*uid* - User ID for File Permissions - **Optimized Build:** Smaller, faster, and more secure images thanks to the improved Dockerfile and `.dockerignore`.
*user* - Username for File Permissions - **Automatic Database Setup:** MariaDB is started in DEV mode, credentials are auto-generated and stored in `/home/$user/mysql_creds`.
*domain* - Primary Domain for configuration - **Database Backups:** Cron job backs up the database every 15 minutes to `/home/$user/_db_backups`.
- **Log Management:** Log rotation compresses logs older than 3 days and deletes those older than 7 days.
- **Memcached:** Started automatically in DEV mode.
- **SSL:** Self-signed certificate enabled by default.
- **Default Web Content:** `/home/$user/public_html` is the web root. `/ping` endpoint and `phpinfo.php` are available for diagnostics.
- **Helper Scripts:** `instance_start`, `instance_stop`, `instance_logs`, `instance_db_info` (created by `local-dev.sh`).
__Optional Tags__ ---
*environment* - Set to DEV to start memcached and mysql locally for development purposes
*serveralias* - Set to allow alternative hostnames for a site.
*PHPVER* - Set to use a different version of PHP [refer to versions here.](#phpver)
### Helpful Notes ### ## Environment Variables
* On your first creation of a dev instance, you will be dumped to the logs output. Hit ```ctrl + c``` to exit the running process. **Required:**
* If you want to restart the instance again, run ```docker start {name-of-your-container}``` in the example, *name-of-your-cintainer* is *local-dev* - `uid` User ID for file permissions
* To stop a restarted instance, run ```docker stop {name-of-your-container}``` - `user` Username for file permissions
* To view log stream from container, run ```docker logs -f {name-of-your-container}``` - `domain` Primary domain for configuration
* To delete a container, run ```docker rm {name-of-your-container}``` *__Note:__ this does not delete the files in public_html or database, as those are store in your system*
* To view running containers, run ```docker ps``` **Optional:**
* To view all created containers, run ```docker ps --all`` - `environment` Set to `DEV` to start memcached and MySQL locally for development
* To view all container images downloaded on your system, run ```docker images``` - `serveralias` Comma-separated list of alternative hostnames
- `PHPVER` PHP version (see above)
---
## Helpful Notes
- To restart the instance: `./instance_start` or `docker start {container-name}`
- To stop: `./instance_stop` or `docker stop {container-name}`
- To view logs: `./instance_logs` or `docker logs -f {container-name}`
- To get DB credentials: `./instance_db_info` or `cat /home/$user/mysql_creds`
- To delete a container: `docker rm {container-name}` (does not delete user files or DB volume)
- To view running containers: `docker ps`
- To view all containers: `docker ps --all`
- To view images: `docker images`
---
## Troubleshooting
- The first run may take several minutes as dependencies are installed.
- If you need to change PHP version, stop and remove the container, then recreate with the desired version.
- For advanced configuration, see the scripts in the `scripts/` directory.
- The image is optimized for size and speed, but local development in DEV mode may install additional packages (MariaDB, memcached) at runtime using microdnf.
- The build context is minimized by the included `.dockerignore` file.
+1 -12
View File
@@ -1,13 +1,2 @@
DirectoryIndex index.html index.htm index.php DirectoryIndex index.html index.htm index.php
Alias "/ping" "/var/www/html" Alias "/ping" "/var/www/html"
<IfModule mpm_event_module>
StartServers 10
MinSpareThreads 25
MaxSpareThreads 75
ThreadLimit 64
ThreadsPerChild 25
MaxRequestWorkers 800
ServerLimit 32
MaxConnectionsPerChild 1500
</IfModule>
+111
View File
@@ -0,0 +1,111 @@
## OpenLiteSpeed APPEND fragment — added to the stock httpd_config.conf
## that ships with litespeedtech/openlitespeed. Keeping the stock config
## intact preserves all the cgid/lscgid plumbing (CGIRLimit defaults,
## fileAccessControl defaults, etc.) — when we tried writing a fully
## custom httpd_config.conf, lscgid never created its IPC socket and
## every PHP request 503'd. The upstream OLS docker template uses this
## append pattern too (see setup_docker.sh in litespeedtech/ols-dockerfiles).
##
## Rendered at container start by scripts/create-vhost-litespeed.sh via
## envsubst. Templated vars: $user $domain $vhost_map_aliases $PHPVER
## $LSAPI_CHILDREN (computed by detect-memory-litespeed.sh)
## --- real client IP behind HAProxy ---
## OLS equivalent of the Apache cac:phpNN mod_remoteip wiring
## (configs/remote_ip.conf + RemoteIPInternalProxy in entrypoint.sh). Without
## this, OLS records HAProxy's docker-bridge IP as the peer: every site's
## access_log and lsphp $_SERVER['REMOTE_ADDR'] collapse to one internal IP,
## silently breaking traffic analytics, WP security plugins, brute-force
## detection, Coraza source-IP correlation, geo, and rate-limiting.
## 1 = trust X-Forwarded-For (the container is only reachable via HAProxy;
## it is never bound to a public address). Mirrors the Apache side, which
## trusts the whole docker subnet via RemoteIPInternalProxy $docker_network.
## When enabled, OLS rewrites the remote IP for BOTH logging and the LSAPI
## REMOTE_ADDR before PHP sees it — so the default access_log format already
## records the real visitor; no LogFormat change needed.
useIpInProxyHeader 1
## --- our listeners (replace stock Default :8088) ---
listener HTTP {
address *:80
secure 0
map siteVH *
## NB: HTTPHTTPS redirect is in site-template.tpl's rewrite{} block,
## NOT here — OLS 1.8 listener-level rewrites are inert for vhTemplate
## members. Don't move it back to this listener.
}
listener HTTPS {
address *:443
secure 1
keyFile /usr/local/lsws/conf/cert/self.key
certFile /usr/local/lsws/conf/cert/self.crt
sslProtocol 24
enableSpdy 15
enableQuic 0
map siteVH *
}
## --- lsphp extProcessor (overrides the stock one which is hard-coded to
## PHP_LSAPI_CHILDREN=10 regardless of container memory).
##
## Sized dynamically by detect-memory-litespeed.sh based on the cgroup cap:
## 2 GiB container → LSAPI_CHILDREN ≈ 17 (was stuck at 10)
## 1 GiB container → LSAPI_CHILDREN ≈ 8
## 512 MiB → LSAPI_CHILDREN ≈ 3
##
## Idle-reduction knobs (the question that motivated this whole block):
## LSAPI_MAX_IDLE_CHILDREN=2 default was CHILDREN/2 (so 10/2=5)
## LSAPI_MAX_IDLE=60 default was 300 (5 min)
## Together: max 2 idle workers kept alive, anything idle >60s gets reaped.
## Trade-off: cold-start of an extra worker after idle reaping costs ~50-100ms
## on the first request to it. Worth it for shadowdao-sized low-traffic sites
## where the difference is "30 MB idle" vs "200 MB idle".
##
## memSoftLimit/memHardLimit: per-worker RLIMIT_AS catches a runaway PHP
## script before it hogs the whole pool's memory. Cgroup is still the host
## backstop (one-customer-per-container), but the per-worker cap protects
## the OTHER workers in the same pool from a bad-actor script. 1024M soft
## comfortably accommodates typical Divi/WooCommerce VSZ (~280-365 MB).
extProcessor lsphp {
type lsapi
address uds://tmp/lshttpd/lsphp.sock
maxConns ${LSAPI_CHILDREN}
env PHP_LSAPI_CHILDREN=${LSAPI_CHILDREN}
env LSAPI_MAX_IDLE_CHILDREN=2
env LSAPI_MAX_IDLE=60
env PHP_LSAPI_MAX_REQUESTS=500
env LSAPI_AVOID_FORK=200M
initTimeout 60
retryTimeout 0
persistConn 1
pcKeepAliveTimeout 30
respBuffer 0
autoStart 1
path /usr/local/lsws/lsphp${PHPVER}/bin/lsphp
backlog 100
instances 1
runOnStartUp 1
priority 0
memSoftLimit 1024M
memHardLimit 1500M
procSoftLimit 400
procHardLimit 500
}
## --- our vhost via vhTemplate (upstream's working pattern) ---
## The template file is /usr/local/lsws/conf/templates/site.conf — written
## by create-vhost-litespeed.sh at the same time as this fragment.
vhTemplate site {
templateFile conf/templates/site.conf
listeners HTTP, HTTPS
note cac-litespeed per-customer vhost
## vhDomain: customer's domain + serveralias list + `*` catchall so
## ip-only requests (e.g. HAProxy backend health check by container_name)
## still resolve. WHP/HAProxy filters hostnames upstream no risk to
## allowing the catchall here.
member siteVH {
vhDomain ${domain}${vhost_map_aliases}, *
}
}
+60
View File
@@ -0,0 +1,60 @@
; Production lsphp overrides — mirrors configs/prod-php.ini for the FPM
; image, adapted for LSAPI defaults. Dropped into /usr/local/lsws/lsphpNN/etc/php.d/
memory_limit = 256M
post_max_size = 384M
upload_max_filesize = 256M
max_input_vars = 2000
max_execution_time = 60
max_input_time = 120
expose_php = Off
short_open_tag = Off
display_errors = Off
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
zend.exception_ignore_args = On
session.save_handler = files
session.use_cookies = 1
session.use_only_cookies = 1
session.use_strict_mode = 0
session.gc_probability = 1
session.gc_divisor = 1000
session.gc_maxlifetime = 1440
opcache.enable = 1
; Sized to fit Divi + WooCommerce + WP core comfortably without eviction
; thrash. Per-instance because shmem is per-process-RSS on Linux cgroups
; (vs PHP-FPM's COW-shared model — one lsphp PARENT per httpd worker in
; OLS, each with its own opcache segment).
;
; Sizing history:
; 128 MB / 10000 files (original): blew 800+ MiB shmem under setUIDMode 2
; because that gave 8+ lsphp instances each at 128 MB → 1+ GiB shmem.
; 32 MB / 4000 files (2026-06-02): solved the shmem problem but caused
; opcache eviction thrash on Divi/WC sites (3000+4000 unique PHP files
; each); manifested as ~40% sustained CPU on alphaoneaminos and
; elevated OOM cycling on brain-jar (5378 oom_kills/9h on 2026-06-03).
; 64 MB / 8000 files (current): fits Divi+WC bytecode without eviction;
; N lsphp × 64 MB ≈ 512 MiB shmem worst case, still acceptable.
;
; Override per-site via OPCACHE_MEMORY_MB / OPCACHE_MAX_FILES env vars
; (panel: Advanced Tuning → OpCache size) for outliers.
opcache.memory_consumption = 64
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 8000
opcache.revalidate_freq = 60
opcache.enable_cli = Off
output_buffering = 4096
default_charset = "UTF-8"
file_uploads = On
max_file_uploads = 20
soap.wsdl_cache_enabled = 1
soap.wsdl_cache_dir = "/tmp"
soap.wsdl_cache_ttl = 86400
soap.wsdl_cache_limit = 5
+97
View File
@@ -0,0 +1,97 @@
## OLS vhTemplate for the per-customer vhost. Mirrors the structure of the
## upstream docker.conf template but with our paths and LSCache wiring.
## Templated vars (envsubst): $user
##
## $VH_NAME, $VH_ROOT, $DOC_ROOT, $SERVER_ROOT are OLS macros — they MUST
## stay literal in the output (not in the envsubst allow-list).
allowSymbolLink 1
enableScript 1
restrained 1
## No setUIDMode — OLS itself runs as ${user} (set at server level by
## create-vhost-litespeed.sh), so lsphp inherits that uid without needing
## suEXEC per request. This is the key to single-lsphp-instance topology:
## with setUIDMode 2, each httpd worker had to lscgid-spawn its own lsphp
## (= N opcache shmem segments). Without it, ONE persistent lsphp parent
## serves all httpd workers via the shared socket, and LSAPI children-mode
## actually works (1 parent + N children = 1 shmem segment).
##
## Safe because cac-litespeed is one-customer-per-container — the container
## boundary IS the privsep boundary.
vhRoot /home/${user}/public_html/
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhconf.conf
virtualHostConfig {
docRoot $VH_ROOT
## Drop-in log paths matching cac:phpNN (Apache+FPM bundled) so existing
## WHP log-gathering code (whp-traffic-aggregator.php, process-log-review.php,
## customer-facing log views) keeps working unchanged for migrated sites.
## Customer's "Apache access log" is just OLS's access log under the same
## filename. No `.log` suffix matches the bundled cac convention.
errorlog /home/${user}/logs/apache/error_log {
useServer 0
logLevel WARN
rollingSize 10M
keepDays 14
compressArchive 1
}
accesslog /home/${user}/logs/apache/access_log {
useServer 0
rollingSize 10M
keepDays 7
compressArchive 1
}
index {
useServer 0
indexFiles index.php, index.html
autoIndex 0
}
## LSCache plugin owns Cache-Control / Expires entirely server-level
## expires off so we don't double-emit headers.
expires {
enableExpires 0
}
accessControl {
allow *
}
context / {
location $DOC_ROOT/
allowBrowse 1
rewrite {
enable 1
inherit 0
autoLoadHtaccess 1
RewriteFile .htaccess
}
addDefaultCharset off
}
rewrite {
enable 1
autoLoadHtaccess 1
logLevel 0
## Force HTTPS — OLS 1.8 listener-level rewrites don't apply per-vhost,
## so the redirect lives here. The RewriteCond guards against an infinite
## loop (SERVER_PORT=80 means "this request came in on the HTTP listener,
## not HTTPS"). Per-customer .htaccess rules still apply (autoLoadHtaccess).
RewriteCond %{SERVER_PORT} 80
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [L,R=301]
}
## Per-vhost LSCache storage. The server-level `module cache` block in
## stock httpd_config.conf is already enabled (ls_enabled 1); the LSCWP
## plugin flips cache on/off per request via X-LiteSpeed-Cache-Control.
module cache {
storagePath /home/${user}/lscache
checkPrivateCache 1
checkPublicCache 1
enableCache 0
enablePrivateCache 0
}
}
+3 -3
View File
@@ -1,11 +1,11 @@
# MariaDB 10.11 CentOS repository list - created 2023-04-03 23:52 UTC # MariaDB 11.4.5 CentOS repository list - created 2023-04-03 23:52 UTC
# https://mariadb.org/download/ # https://mariadb.org/download/
[mariadb] [mariadb]
name = MariaDB name = MariaDB
# rpm.mariadb.org is a dynamic mirror if your preferred mirror goes offline. See https://mariadb.org/mirrorbits/ for details. # rpm.mariadb.org is a dynamic mirror if your preferred mirror goes offline. See https://mariadb.org/mirrorbits/ for details.
# baseurl = https://rpm.mariadb.org/10.11/centos/$releasever/$basearch # baseurl = https://rpm.mariadb.org/10.11/centos/$releasever/$basearch
baseurl = https://mirrors.xtom.com/mariadb/yum/10.11/centos/$releasever/$basearch baseurl = https://mirror.mariadb.org/yum/11.4/rhel$releasever-amd64
module_hotfixes = 1 module_hotfixes = 1
# gpgkey = https://rpm.mariadb.org/RPM-GPG-KEY-MariaDB # gpgkey = https://rpm.mariadb.org/RPM-GPG-KEY-MariaDB
gpgkey = https://mirrors.xtom.com/mariadb/yum/RPM-GPG-KEY-MariaDB gpgkey = https://mirrors.xtom.com/mariadb/yum/RPM-GPG-KEY-MariaDB
gpgcheck = 1 gpgcheck = 1
+10 -2
View File
@@ -1091,7 +1091,7 @@ session.save_handler = memcache
; RPM note : session directory must be owned by process owner ; RPM note : session directory must be owned by process owner
; for mod_php, see /etc/httpd/conf.d/php.conf ; for mod_php, see /etc/httpd/conf.d/php.conf
; for php-fpm, see /etc/php-fpm.d/*conf ; for php-fpm, see /etc/php-fpm.d/*conf
session.save_path = "tcp://localhost:11211" session.save_path = "tcp://memcache:11211"
; Whether to use strict session mode. ; Whether to use strict session mode.
; Strict session mode does not accept an uninitialized session ID, and ; Strict session mode does not accept an uninitialized session ID, and
@@ -1496,7 +1496,15 @@ ldap.max_links = -1
;dba.default_handler= ;dba.default_handler=
[opcache] [opcache]
; see /etc/php.d/10-opcache.ini ; Optimized for shared hosting — reduce idle memory footprint
; Default 128MB is excessive for most WordPress sites
opcache.memory_consumption = 64
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 4000
; Revalidate files every 60s in production (reduces stat() calls)
opcache.revalidate_freq = 60
; Don't waste memory on CLI scripts
opcache.enable_cli = Off
[curl] [curl]
; A default value for the CURLOPT_CAINFO option. This is required to be an ; A default value for the CURLOPT_CAINFO option. This is required to be an
+2
View File
@@ -0,0 +1,2 @@
RemoteIPHeader X-Forwarded-For
LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
+51
View File
@@ -0,0 +1,51 @@
## ---- shared-ols append (do not edit below) ----
## Server-level config for the SHARED OpenLiteSpeed tier. Appended to the
## stock httpd_config.conf AFTER render-shared-ols-config.sh strips the stock
## listeners, vhTemplate docker, AND the stock `extProcessor lsphp` +
## `scriptHandler` (so this server NEVER runs PHP locally — every site's PHP
## goes to its own detached cac-lsphp sidecar over LSAPI). Rendered with
## envsubst; only ${LSCACHE_ROOT} is substituted here.
serverName shared-ols
## Real client IP behind HAProxy. HAProxy sets X-Forwarded-For (the real
## client) and X-Forwarded-Proto. Mode 1 = always use X-Forwarded-For as the
## client IP. HAProxy is the ONLY thing that ever connects to this tier (it's on
## client-net with no host-published ports) and it OVERWRITES X-Forwarded-For
## with %[src] (set-header, not add-header), so a client can't spoof it — mode 1
## is safe here and matches the working standalone litespeed config.
## NOTE: mode 2 ("trusted IP only") does NOT mean "trust the proxy header" — it
## extracts the real IP ONLY when the connecting peer is in a TRUSTED access
## list, which this tier never configured. With mode 2 + no trusted IP, OLS kept
## HAProxy's container IP as REMOTE_ADDR for every request, so WP security
## plugins saw all tenants as one IP and blocking it locked everyone out.
useIpInProxyHeader 1
## LSCache enabled at MODULE scope for the whole tier (dedicated cache volume,
## ephemeral across rebuilds; OLS auto-keys a per-vhost subdir under storagePath).
## PUBLIC (anonymous) caching ONLY: enableCache 1 + checkPublicCache 1 let OLS
## serve cacheable, non-logged-in responses marked by the LiteSpeed Cache WP
## plugin's X-LiteSpeed-Cache-Control headers (ignoreRespCacheCtrl=0 honors them).
##
## PRIVATE caching is intentionally OFF (enablePrivateCache 0 + checkPrivateCache 0).
## Logged-in / cookie-bearing pages must NEVER be cached at the tier. We previously
## left enablePrivateCache=1 assuming "no plugin -> nothing cached," but that was
## WRONG: with private storage + reqCookieCache on, OLS privately cached logged-in
## responses regardless of plugin, serving stale wp-admin (e.g. a "failed update"
## nag that persisted for the full privateExpireInSeconds TTL). Keeping private
## cache off guarantees logged-in pages are always served fresh.
module cache {
storagePath ${LSCACHE_ROOT}
checkPrivateCache 0
checkPublicCache 1
maxCacheObjSize 10000000
maxStaleAge 200
qsCache 1
reqCookieCache 1
respCookieCache 1
ignoreReqCacheCtrl 0
ignoreRespCacheCtrl 0
enableCache 1
enablePrivateCache 0
}
## ---- end shared-ols server append ----
+37
View File
@@ -0,0 +1,37 @@
<Directory "/mnt/users/~~user~~/~~domain~~">
AllowOverride None
Require all granted
</Directory>
<Directory "/mnt/users/~~user~~/~~domain~~/public_html">
Options All MultiViews
AllowOverride All
Require all granted
</Directory>
<VirtualHost *:80>
ServerName "~~domain~~"
~~alias_block~~
DocumentRoot "/mnt/users/~~user~~/~~domain~~/public_html"
RewriteEngine on
RewriteCond %{SERVER_NAME} =~~domain~~
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName "~~domain~~"
~~alias_block~~
DocumentRoot "/mnt/users/~~user~~/~~domain~~/public_html"
SSLCertificateFile /etc/pki/tls/certs/localhost.crt
SSLCertificateKeyFile /etc/pki/tls/private/localhost.key
~~proxy_block~~
DirectoryIndex index.php index.html index.htm
ErrorLog "/var/log/httpd/~~domain~~-error.log"
CustomLog "/var/log/httpd/~~domain~~-access.log" combined
</VirtualHost>
</IfModule>
+36
View File
@@ -40,6 +40,42 @@
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost> </VirtualHost>
#
# When we also provide SSL we have to listen to the
# standard HTTPS port in addition.
#
Listen 443 https
##
## SSL Global Context
##
## All SSL configuration in this context applies both to
## the main server and all SSL-enabled virtual hosts.
##
# Pass Phrase Dialog:
# Configure the pass phrase gathering process.
# The filtering dialog program (`builtin' is a internal
# terminal dialog) has to provide the pass phrase on stdout.
SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
# Inter-Process Session Cache:
# Configure the SSL Session Cache: First the mechanism
# to use and second the expiring timeout (in seconds).
SSLSessionCache shmcb:/run/httpd/sslcache(512000)
SSLSessionCacheTimeout 300
#
# Use "SSLCryptoDevice" to enable any supported hardware
# accelerators. Use "openssl engine -v" to list supported
# engine names. NOTE: If you enable an accelerator and the
# server does not start, consult the error logs and ensure
# your accelerator is functioning properly.
#
SSLCryptoDevice builtin
<IfModule mod_ssl.c> <IfModule mod_ssl.c>
<VirtualHost _default_:443> <VirtualHost _default_:443>
ServerName "~~domain~~" ServerName "~~domain~~"
@@ -0,0 +1,291 @@
# Decision record: entrypoint boot posture on a failed `lsphp -i` probe, and CI gating for the pipeline-shape check
Date: 2026-08-05
Status: recommendations for sign-off; nothing here is implemented by this document.
Background: the SIGPIPE-under-pipefail fix (here-strings replacing `printf | awk` in
`scripts/entrypoint-lsphp.sh` and `scripts/entrypoint-litespeed.sh`) removed a boot-killing
141 and, as a side effect, changed what happens when `lsphp -i` *genuinely* produces
nothing. Two judgement calls were flagged for a human decision. This document is that
decision's working-out.
---
## Call 1 — boot posture when `lsphp -i` genuinely fails
### What actually happens today (post-fix, as-written in the repo)
The two container types are **not** currently symmetric, which matters for the decision:
- **`cac-lsphp`** (`scripts/entrypoint-lsphp.sh`): fail-open, but *not silent*. An empty
`SCAN_DIR` falls to an else-branch that distinguishes "lsphp answered but reported no
scan dir" from "`lsphp -i` produced no usable output at all" (`lsphp_info_is_usable`,
keyed on the `PHP Version =>` banner), prints a distinct `WARNING:` to stderr for each,
and every boot ends with a summary line
`entrypoint-lsphp: $_SERVER path parity = <MODE> (...)` where `<MODE>` becomes
`none (no scan dir)` or `none (lsphp -i unusable)`. So `docker logs` carries a
greppable verdict — but nothing on the platform consumes it.
- **`cac-litespeed`** (`scripts/entrypoint-litespeed.sh`, lines 81102): fail-open and
**completely silent**. `if [ -n "$SCAN_DIR" ] ... fi` with no else. An empty probe means
the per-site `error_log` ini and the opcache override ini are simply never written, and
nothing says so anywhere.
- **Shared tiers** (`entrypoint-shared-ols.sh`, `entrypoint-shared-httpd.sh`): no
`lsphp -i` probe exists in them at all. The question is moot there today, but the
blast-radius argument below still applies if one is ever added.
What degradation costs the customer, concretely:
| Lost fragment | cac-lsphp | cac-litespeed | Customer-visible symptom |
|---|---|---|---|
| `99-user-error-log.ini` | yes | yes | PHP errors go to stderr/`docker logs` instead of `/home/$user/logs/php-fpm/error.log`. Baked `log_errors = On` still applies, so errors are *not lost* — they are somewhere the customer can't see and support doesn't look first. Ticket shape: "my error log is empty." |
| `99-user-opcache.ini` | yes | yes | Panel-set opcache overrides silently revert to baked defaults (64 MB / 8000 files). A site tuned up for a big codebase gets cache thrash; hard to attribute. |
| `99-cac-path-parity.ini` | yes | n/a | Extension gets no mapping ⇒ `$_SERVER` path parity with cac-fpm is lost entirely in the "no output" case (the auto_prepend fallback is only written in the "extension missing from module list" branch, which requires the probe to have answered). Symptom: Wordfence/path-sensitive plugins misbehave after migration; extremely hard to diagnose from the outside. |
### The premise to examine first
The fail-open argument assumes "the site serves either way; only ini fragments differ."
That is true when `lsphp -i` answers but lacks a `Scan this dir` line (weird packaging,
still a working interpreter). It is **much weaker** when `lsphp -i` produces *nothing*:
the same `$LSPHP_BIN` that just failed to print phpinfo is what the entrypoint `exec`s
as PID 1 to serve requests. A binary that can't run `-i` because of a missing shared
library will not serve LSAPI either — fail-open in that case doesn't buy a serving
container, it buys a container that passes `docker ps`, then wedges or crash-loops
*after* the entrypoint's diagnostic moment has passed. So the two sub-cases deserve
different treatment, and conflating them is the main framing error in a flat
"fail-open vs fail-closed" question.
But there is a counterweight, and it is specific to this fleet: **`lsphp -i` can fail
transiently on a healthy image.** These hosts have documented fork-starvation episodes
(Committed_AS pinned against CommitLimit during backup windows; it has broken backups
before). A `fork()`/`execve` failure at boot time yields exactly "empty `LSPHP_INFO`"
through the `|| true`. Under fail-closed, a container (re)starting during a memory-pressure
window would refuse to boot **because of the host's state, not its own**, and its restart
policy would then add restart churn to a host already under pressure. The previous
incident has the same shape: the last time "the probe returned nothing useful" happened
in production, the image was *fine* — the probe itself was broken (the SIGPIPE bug), and
the fail-closed behavior converted a cosmetic probe defect into a fleet-wide
fails-to-boot on busy hosts only. Historically, probe-side failures have outnumbered
genuinely-corrupt-image failures **10 (or 20 counting fork starvation as observed
class)**. That base rate is the strongest single argument against pure fail-closed.
### Options
**A. Fail-open as fixed (status quo).**
- Buys: no false boot failures from probe-side defects or host memory pressure; matches
the extension's documented posture; sibling entrypoints consistent (once litespeed
gains the warning it currently lacks).
- Costs: "starts but serves degraded" is precisely the state this platform is worst at
noticing — the container watchdog's filter has historically been `exited|dead|created`
only, blind to running-but-wrong, and its post-start verify checks `State.Running`, so
it would even score a restart of a degraded container as success. Nothing alerts on the
stderr WARNINGs. Realistic outcome of a genuine failure under Option A: **weeks of
silent degradation**, surfaced eventually as a confusing customer ticket ("where are my
errors?" / plugin misbehavior), with the diagnostic clue buried in `docker logs` of a
container nobody had reason to inspect.
**B. Fail-closed (`exit 1`) whenever `lsphp -i` yields nothing usable.**
- Buys: loud, immediate, operator-visible failure; watchdog catches `exited`; the
diagnostic (the WARNING line) is the *last* thing in `docker logs`, exactly where an
on-call looks first. A genuinely broken image cannot masquerade as healthy for weeks.
- Costs: converts transient host-side failures (fork starvation) and any future
probe-side defect into customer downtime. On a bad-image push, every container that
restarts goes down *hard* — arguably correct (they weren't going to serve anyway), but
it also means a probe regression like the one just fixed would again be a mass boot
outage rather than a mass warning. The customer experience of B's false positive is
strictly worse than A's true positive: total outage vs. degraded-but-serving.
- Note: B is not "the old behavior restored" — the old behavior also failed to boot on
*healthy* images because the probe itself was the broken part. Anyone arguing "the old
crash-loop caught real problems" should first account for the fact that the only
production firing of that crash was a false positive.
**C. Fail-open + a machine-detectable degraded marker (middle).**
Boot and serve, but make the degraded state a *first-class, monitorable fact* rather than
a log line:
1. On any degraded branch, write a marker file (e.g. `/run/cac-degraded` containing the
mode string) in addition to the existing stderr WARNING, in **both** entrypoints —
and add the currently missing warning + summary line to `entrypoint-litespeed.sh` so
the two siblings really are consistent (today's "consistency" claim is only half true).
2. Retry the probe once or twice with a short sleep before accepting degradation. This
is nearly free and specifically absorbs the fork-starvation transient, which is the
most likely real-world cause of an empty probe on this fleet.
3. Surface the marker: either a periodic platform check (`docker exec ... test -f
/run/cac-degraded` across cac containers, or grepping the boot summary line
`path parity = none` from `docker logs --tail`) wired into existing monitoring/alerting,
or fold it into the health story. **Caution on the healthcheck route:** the
HEALTHCHECKs (all seven Dockerfiles have one) are liveness probes; making "degraded
config" report unhealthy is tempting but risky — if the watchdog ever learns to
restart unhealthy containers, a persistently-degraded-but-serving site would be
restart-looped for a condition a restart cannot fix. Keep degraded ≠ unhealthy;
alert on the marker through a channel that pages a human instead of triggering an
automated restart.
### Recommendation
**Option C, with one fail-closed carve-out considered and (narrowly) rejected.**
The carve-out I weighed: fail closed *only* in the `lsphp -i is unusable` sub-case (probe
empty), fail open in the `answered but no scan dir` sub-case — on the logic that an
interpreter that can't print phpinfo probably can't serve. I recommend against it, for
two reasons. First, the fork-starvation transient lands in exactly that sub-case and
would cause real, host-pressure-correlated boot failures of healthy sites; the retry in
C(2) mitigates but can't eliminate it. Second, if the binary truly cannot execute, the
final `exec "$LSPHP_BIN" -b ...` fails on its own a few lines later and PID 1 dies
anyway — fail-closed-at-the-probe mostly duplicates a crash the container will have
regardless, while adding a new false-positive class. The genuinely dangerous residue —
binary runs but is subtly broken — is better caught by the existing HEALTHCHECK
(TCP :9000 + `pgrep lsphp`), which *does* fail in that world; the platform gap is that
the watchdog ignores unhealthy, and that gap is worth closing on its own merits.
Per-type answer: same posture for `cac-lsphp` and `cac-litespeed` (their failure domains
are equivalent for this probe; the current behavioral gap between them is an oversight,
not a design). For **shared-tier** entrypoints the answer is *more* strongly fail-open
than for per-site containers, should a similar probe ever appear there: a fail-closed
shared tier takes down every site on the host, and the marginal diagnostic value of a
crash over a marker does not scale with the blast radius.
Why C over A, stated so it can be disagreed with: A and C serve the customer identically;
they differ only in whether the platform can *notice*. The honest failure-likelihood
ranking on this fleet is (1) transient probe failure under memory pressure, (2) probe
logic regression, (3) genuinely broken image — and only (3) is the case where B
outperforms. C handles (1) with the retry, (2) and (3) with the marker + alert, and its
worst case is "degraded for as long as the alerting channel takes to be read" instead of
A's "degraded for weeks" or B's "down because the host was busy." If you believe a
corrupt image push is *more* likely than a probe/host transient, B becomes defensible —
but the recent incident history says otherwise.
What a customer experiences, per option, when the failure is genuine: A/C — site up,
error log empty, opcache defaults, possible plugin path weirdness; C additionally gets it
noticed and fixed in hours. B — site down until an operator acts, but the operator is
paged immediately by existing machinery. Which failure gets silently tolerated for weeks:
A's, unambiguously. B's cannot be (crash-loops are loud); C's only if the marker alert is
never actually wired up — which is why C **is not done** until the alerting side ships,
and shipping the marker without the consumer would be A with extra steps.
Before committing, measure:
- Fleet scan: does `path parity = none` (or an empty scan dir) occur on any live
container today? (`docker logs` grep across cac-lsphp/litespeed containers.) If it
already fires, the fix priority changes from "posture" to "why".
- Frequency of boot-time fork failures during backup windows (correlate container start
timestamps with `MemAvailable` dips) — validates or kills the retry rationale.
- Whether the watchdog's unhealthy-blindness fix (in progress platform-side) will ever
auto-restart on unhealthy; that decides whether degraded may ever map to unhealthy.
---
## Call 2 — should the new CI check gate releases?
### What actually exists (`.gitea/workflows/build-push.yaml`)
A `Shell-Checks` job runs on every push to trunk: (1) `bash -n` over every script,
(2) `shellcheck -S warning` over an explicit allow-list of the pipefail-era scripts,
(3) `scripts/tests/lsphp-info-probe.test.sh`, whose section 5 is the structural scan
that outlaws early-exit-reader pipelines in any `pipefail`-enabled script under
`scripts/` and `ext/`. None of the six image build jobs has `needs: Shell-Checks`, and
the workflow's own comment says so: "a red job here does not block a push that is
otherwise fine." So today a red check produces a red run *annotation* while all seven
images publish anyway.
False-positive surface, honestly assessed:
- `bash -n` and the test's sections 14 are deterministic (the pre-fix pipeline is run
only informationally; no pass/fail depends on the race). Near-zero FP risk.
- `shellcheck` runs against a hand-curated clean set, so it goes red only when a listed
file regresses or a new shellcheck version tightens — low FP risk, nonzero.
- The structural scan is a line-oriented awk text scan with flat quote-stripping. Its
own header is candid: it can miss multi-line pipelines (false negatives) and could
misread exotic quoting (false positives). It requires the reader to be the first word
after `|` and special-cases `case a|b)`, `sed "s|x|y|"`, `||`. FP risk is real but
bounded — and an FP is a *visible* red on a specific line, trivially triaged.
### Options
**A. Leave it non-blocking (status quo).**
Buys: zero risk that a scanner quirk halts a release. Costs: this platform's own history
says non-blocking signals decay into wallpaper — one-shot installers drifted, the
watchdog's blind spot sat unnoticed, and this exact bug class shipped to production
through a green build gate and **two** code reviews. A check that exists precisely
because the build gate can't see entrypoint runtime, but that cannot stop a release,
protects nothing on the day it matters; it merely timestamps the moment the regression
was ignorable. The failure it would have caught kills PID 1 on busy production hosts
only — the single most expensive place to discover it.
**B. Hard gate now (add `needs: Shell-Checks` to all six build jobs).**
Buys: the regression class physically cannot ship again through this pipeline. Costs: an
unproven scanner acquires veto power over every release, including emergency ones. Worth
sizing that cost concretely rather than abstractly: releases here are operator-driven
pushes to trunk, not a high-frequency train; a false red costs the minutes needed to read
the offending line, either fix real sloppiness or adjust the scanner, and re-push. There
is no mechanism by which a red silently *delays* an unattended release for days, because
releases are attended. The scary version of "blocks all releases" mostly doesn't apply to
this repo's release model. The one genuinely bad scenario: an urgent security rebuild
blocked at 2 a.m. by a scanner FP — mitigable, since the operator can comment out one
`needs:` line in the same push, an escape hatch that is itself visible in the diff.
**C. Staged: soft-fail now, hard gate after N clean runs.**
Buys: evidence before enforcement; if the scanner has an FP mode, it is discovered while
red is cheap. Costs: N pushes of window during which the bug class can ship again, and a
follow-up task of exactly the kind this platform historically forgets (see: one-shot
installer drift). If staged, the flip must be a dated calendar/scheduler entry or a
tracked ticket, not an intention.
**D. Split the gate: hard-gate the deterministic steps now, soft-fail the scanner.**
Put `bash -n` + the probe regression test (sections 14) in a blocking job immediately —
they have effectively no FP surface and section 2's marker check alone prevents the
specific regression (reintroducing the inline pipelines) — and leave shellcheck + the
structural scan non-blocking until proven. Buys: immediate protection against the exact
bug that happened, zero new FP-block risk. Costs: two jobs to maintain; the structural
scan (the only piece that generalizes beyond this one probe) stays advisory.
### Recommendation
**B — make it a hard gate now**, with C's discipline applied only to future *additions*
to the check, and one prerequisite: run the full `Shell-Checks` job as-is against the
current trunk and the last ~5 release tags first (locally or via a manual dispatch) to
confirm it is green on everything already shipped. If that back-test is green, gate. If
it is not, fix or scope the scanner, then gate.
Reasoning, laid out to be disagreeable-with: the decision hinges on comparing the two
error costs *as they occur in this repo's actual release process*. A false red costs an
attended operator minutes, with a one-line escape hatch, and every FP found makes the
scanner better. A false green — or a true red that doesn't block — reproduces a
production incident whose failure mode is "customer containers refuse to boot, but only
on hosts busy enough to have collapsed pipe buffers," i.e. undetectable in dev by
construction. The asymmetry is roughly minutes-of-operator-time vs.
production-boot-outage, and the probability of FP red is bounded by a back-test we can
run *today* instead of estimating. Choosing C is defensible only if you weight "new check
might be flaky in ways a back-test can't reveal" highly — plausible for a check with
runtime/timing behavior, but sections 14 are deterministic and section 5 is a pure text
scan over files that are fully known at back-test time. Flakiness that a back-test can't
surface would have to come from the runner environment (shellcheck version drift is the
main one — worth pinning the shellcheck version when gating). D is the fallback if the
back-test turns up scanner noise that can't be resolved same-day.
Two obligations come with gating, or the gate rots:
1. The shellcheck allow-list must grow with the repo — a new `set -o pipefail` script
that never gets listed is invisibly exempt from step (2) (the structural scan, by
contrast, auto-discovers pipefail scripts). A one-line CI assertion that every
pipefail-enabled script appears in the shellcheck list would close that seam.
2. Green must not be oversold: the scanner's documented limits (single-line pipelines,
flat quote-stripping) mean a multi-line pipeline reintroduction passes it. The gate
raises the floor; it does not replace review. Record that limit where reviewers see
it (it already is, in the test header — keep it there).
Before committing, measure: the back-test above (mandatory); shellcheck version on the
Gitea runner vs. pinned (drift is the likeliest future FP source); and after gating,
track red-run causes for the first month — if more than ~1 in 10 reds is a scanner FP,
revisit with D.
---
## Framing notes (where the question as posed deserves push-back)
1. "Previously it failed loudly and the watchdog caught it" is only half the history:
the *only* production firing of the loud failure was a false positive (the probe's own
SIGPIPE), not a broken image. The old posture's loudness was never demonstrated on a
genuine failure; pricing it as a proven detection mechanism overstates its record.
2. The fail-open "consistency with the sibling" claim is currently aspirational:
`entrypoint-litespeed.sh` fails open *silently* while `entrypoint-lsphp.sh` fails open
loudly with mode strings. Whatever posture is chosen, making the siblings actually
consistent (warning + summary line + marker in both) is part of the work.
3. "Fail-open vs fail-closed" flattens two distinct sub-cases — probe empty vs. probe
answered without a scan dir — that have different likely causes and different serving
prognoses. The entrypoint already distinguishes them in its reporting; the decision
should too (and above, does).
+41
View File
@@ -0,0 +1,41 @@
# phpize / configure / make artifacts from building this extension locally.
# The shipped build happens inside Dockerfile.lsphp's ext-build stage, so
# nothing generated here is ever committed.
.deps
.libs/
Makefile
Makefile.fragments
Makefile.global
Makefile.objects
acinclude.m4
aclocal.m4
autom4te.cache/
build/
config.guess
config.h
config.h.in
config.log
config.nice
config.status
config.sub
configure
configure.ac
include/
install-sh
libtool
ltmain.sh
missing
mkinstalldirs
modules/
run-tests.php
*.lo
*.la
*.o
*.so
tests/*.php
tests/*.diff
tests/*.exp
tests/*.log
tests/*.out
tests/*.sh
!tests/fpm-parity-check.sh
+378
View File
@@ -0,0 +1,378 @@
/*
* cac_path_parity — $_SERVER filesystem-path parity for the shared-ols tier.
*
* WHAT PROBLEM THIS SOLVES
* ------------------------
* A site on the standalone tiers (cac / cac-fpm / cac-litespeed) sees:
*
* $_SERVER['DOCUMENT_ROOT'] = /home/<user>/public_html
* $_SERVER['SCRIPT_FILENAME'] = /home/<user>/public_html/index.php
*
* On the shared-ols tier the webserver is a SHARED OpenLiteSpeed container that
* serves every tenant out of one bulk `/docker/users -> /mnt/users:ro` mount, so
* its vhost docRoot is /mnt/users/<user>/<domain>/public_html. OLS has no
* ProxyFCGISetEnvIf-style remap (unlike shared-httpd -> cac-fpm): it hands lsphp
* exactly that path. The cac-lsphp sidecar symlinks /mnt/users/<user>/<domain>
* -> /home/<user>, so every file OPERATION resolves and PHP's own __FILE__ /
* __DIR__ / realpath() / getcwd() already report /home/<user>/... — but the RAW
* strings OLS put in $_SERVER still read /mnt/users. Moving a site from cac-fpm
* to cac-lsphp therefore changed two $_SERVER values, which is exactly the kind
* of difference that surfaces later as a broken plugin path or a mismatched
* absolute path stored in the database.
*
* WHY THIS IS AN EXTENSION AND NOT AN auto_prepend_file
* -----------------------------------------------------
* The first fix was an `auto_prepend_file` drop-in that realpath()'d the two
* keys. `auto_prepend_file` is PHP_INI_PERDIR, so ANY site with its own
* .user.ini auto_prepend_file silently displaces it and the normaliser never
* runs — the state 7 live shared_ols sites are actually in today (Wordfence and
* cPanel imports). PHP resolves a single winning value for auto_prepend_file
* after the .user.ini chain is parsed, so there is no way to "chain" from the
* losing side either.
*
* The obvious hardening — `php_admin_value auto_prepend_file` — is WORSE, not
* better: making our prepend un-overridable makes the customer's prepend
* un-runnable, which would disable those same 7 Wordfence WAFs. The two goals
* are irreconcilable as long as the mechanism IS the prepend hook.
*
* An extension sidesteps that entirely. RINIT runs before any userland code and
* cannot be displaced by .user.ini, and it consumes no userland hook — so the
* customer's auto_prepend_file remains the only prepend in play and keeps
* working untouched. Both constraints are satisfied at once.
*
* The mapping comes from two PHP_INI_SYSTEM entries. PHP_INI_SYSTEM is NOT
* settable from .user.ini (which honours only PHP_INI_PERDIR/PHP_INI_USER), nor
* from ini_set(), nor from .htaccess — so a customer cannot point the rewrite
* somewhere else or switch it off. The cac-lsphp entrypoint writes them from the
* same `user`/`domain` env the symlink is built from, so the two can't drift.
*
* WHY A STRING PREFIX SWAP AND NOT realpath()
* -------------------------------------------
* The old normaliser called realpath(), which worked only because the sidecar
* symlinks /mnt/users/<user>/<domain> -> /home/<user>. A plain prefix swap is
* better on every axis that matters here:
*
* - It is byte-identical to cac-fpm BY CONSTRUCTION. realpath() resolves ALL
* symlinks, so a customer who makes public_html itself a symlink would get
* some third path — cac-fpm reports the literal /home/<user>/public_html.
* - It cannot fail. realpath() returns false for a path that does not exist
* (and is constrained by open_basedir), leaving the value half-normalised.
* - It costs no syscall. realpath() is an lstat chain on every request.
*
* The prefix is this site's FULL mount path (/mnt/users/<user>/<domain>), not
* the bare bulk-mount root, and it only matches on a path-component boundary —
* so a value pointing at another tenant, or one that is already canonical, is
* never touched.
*
* FAILURE MODES — every one of them leaves $_SERVER untouched and lets the
* request proceed. Nothing here can warn, throw, or 500 a customer site:
* - mapping unset/empty (any tier that is not shared-ols) -> RINIT returns
* immediately, extension is inert.
* - either side of the mapping not an ABSOLUTE path -> inert. Nothing
* the entrypoint writes is anything else, and a relative prefix cannot
* usefully match a SAPI-supplied path, so a malformed mapping is treated
* exactly like an absent one.
* - $_SERVER absent or not an array -> return.
* - key absent from $_SERVER -> skip that key.
* - key present but not a string -> skip that key.
* - value shorter than the prefix / prefix mismatch -> skip that key.
* - value matches the prefix mid-component -> skip that key.
* There is no error path, no userland-visible diagnostic, and no dependency on
* the filesystem being readable.
*
* The one thing this CANNOT detect is a well-formed but WRONG mapping: it will
* confidently rewrite to a wrong path and say nothing. That is accepted by
* construction rather than overlooked — entrypoint-lsphp.sh derives from/to from
* the same two variables it builds the compatibility symlink from, so a wrong
* mapping means the symlink is wrong too and the site is already broken in a far
* louder way. The only runtime signal is `lsphp -i`, which prints
* "Rewriting => active" alongside the live from/to values.
*
* That row is what the post-deploy fleet canary greps, so MINFO's "active" test
* must stay a mirror of the conditions RINIT actually rewrites under — see
* PHP_MINFO_FUNCTION below, which shares cacpp_mapping_active() with RINIT
* precisely so the two cannot drift. A MINFO that reported "active" for a
* mapping RINIT treats as inert would mask exactly the failure the canary
* exists to catch.
*
* SCOPE / KNOWN LIMITS
* --------------------
* Only $_SERVER is rewritten. LSAPI also answers getenv('DOCUMENT_ROOT') from
* the request environment, and that still returns the /mnt/users string. That is
* a deliberate limit: touching the SAPI environment risks the request env lsphp
* itself reads. Real-world PHP (WordPress and its plugin ecosystem) reads
* $_SERVER, not getenv(), for these.
*
* The sidecar's compatibility symlink is still REQUIRED and is not replaced by
* this extension: it is what makes the path OLS sends actually resolve on disk.
* This extension only corrects the strings.
*/
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "php.h"
#include "php_ini.h"
#include "ext/standard/info.h"
#include "SAPI.h"
#include "zend_compile.h" /* zend_is_auto_global_str() */
#include "php_cac_path_parity.h"
ZEND_DECLARE_MODULE_GLOBALS(cac_path_parity)
#define CACPP_G(v) ZEND_MODULE_GLOBALS_ACCESSOR(cac_path_parity, v)
/*
* The $_SERVER keys that carry a FILESYSTEM path. URI-derived keys (PHP_SELF,
* SCRIPT_NAME, REQUEST_URI) are already identical across tiers and are left
* alone. PATH_TRANSLATED and CONTEXT_DOCUMENT_ROOT are usually absent under
* OLS; rewriting them is a no-op when they are, and correct when they are not.
*/
static const struct {
const char *name;
size_t len;
} cacpp_keys[] = {
{ ZEND_STRL("DOCUMENT_ROOT") },
{ ZEND_STRL("SCRIPT_FILENAME") },
{ ZEND_STRL("PATH_TRANSLATED") },
{ ZEND_STRL("CONTEXT_DOCUMENT_ROOT") },
};
/* clang-format off */
PHP_INI_BEGIN()
/*
* PHP_INI_SYSTEM is load-bearing: it is the reason a customer's .user.ini
* cannot reach these. Do not relax to PERDIR.
*/
STD_PHP_INI_ENTRY("cac_path_parity.from", "", PHP_INI_SYSTEM, OnUpdateString,
from, zend_cac_path_parity_globals, cac_path_parity_globals)
STD_PHP_INI_ENTRY("cac_path_parity.to", "", PHP_INI_SYSTEM, OnUpdateString,
to, zend_cac_path_parity_globals, cac_path_parity_globals)
PHP_INI_END()
/* clang-format on */
static PHP_GINIT_FUNCTION(cac_path_parity)
{
#if defined(COMPILE_DL_CAC_PATH_PARITY) && defined(ZTS)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
cac_path_parity_globals->from = NULL;
cac_path_parity_globals->to = NULL;
}
/*
* THE MAPPING PREDICATE — one definition, two callers.
*
* RINIT uses it to decide whether to rewrite; MINFO uses it to REPORT whether
* rewriting is live. Those two tests were written out longhand in two places
* and promptly drifted: the absolute-path guard was added to RINIT only, so
* `lsphp -i` went on printing "Rewriting => active" for a mapping RINIT had
* already decided to ignore. That row is the fleet canary's signal, so the lie
* masked precisely the failure the canary looks for. Keep them sharing this.
*
* Pure predicates over two NUL-terminated strings: no allocation, no side
* effect, no way to fail — MINFO gains no error path by calling them, and the
* fail-open invariant is untouched.
*/
static int cacpp_mapping_configured(const char *from, const char *to)
{
return from != NULL && *from != '\0' && to != NULL && *to != '\0';
}
static int cacpp_mapping_active(const char *from, const char *to)
{
return cacpp_mapping_configured(from, to) && *from == '/' && *to == '/';
}
/* Trailing slashes would defeat the component-boundary test below. */
static size_t cacpp_trim(const char *s, size_t len)
{
while (len > 1 && s[len - 1] == '/') {
len--;
}
return len;
}
static void cacpp_rewrite_key(zval *server, const char *key, size_t key_len,
const char *from, size_t from_len,
const char *to, size_t to_len)
{
zval *val = zend_hash_str_find(Z_ARRVAL_P(server), key, key_len);
if (val == NULL) {
return;
}
ZVAL_DEREF(val);
if (Z_TYPE_P(val) != IS_STRING) {
return;
}
const char *s = Z_STRVAL_P(val);
size_t len = Z_STRLEN_P(val);
if (len < from_len || memcmp(s, from, from_len) != 0) {
return;
}
/*
* Only replace on a PATH-COMPONENT boundary. Without this,
* from=/mnt/users/bob/site.com would also match a sibling directory
* /mnt/users/bob/site.com.bak and silently rewrite another site's path
* into this site's /home.
*/
if (len != from_len && s[from_len] != '/') {
return;
}
size_t tail_len = len - from_len;
/*
* to="/" is the one absolute prefix that survives cacpp_trim() as a bare
* separator, and the tail always starts with one — splicing both would give
* "//public_html". Drop it when there IS a tail; keep it when there is not
* (value == from exactly, where "/" is the correct answer). Unreachable from
* the entrypoint, which always writes to=/home/<user>.
*/
size_t eff_to_len = to_len;
if (tail_len > 0 && eff_to_len == 1 && to[0] == '/') {
eff_to_len = 0;
}
zend_string *out = zend_string_alloc(eff_to_len + tail_len, 0);
memcpy(ZSTR_VAL(out), to, eff_to_len);
memcpy(ZSTR_VAL(out) + eff_to_len, s + from_len, tail_len);
ZSTR_VAL(out)[eff_to_len + tail_len] = '\0';
zval nv;
ZVAL_STR(&nv, out);
/*
* Update the track_vars array IN PLACE. $_SERVER in the symbol table and
* PG(http_globals)[TRACK_VARS_SERVER] are two references to the SAME
* zend_array, which is why this is visible to userland. Do NOT
* SEPARATE_ARRAY() here: that would copy the array and leave the symbol
* table pointing at the original, i.e. silently do nothing. This is the
* same in-place pattern php_register_variable_ex() uses.
*/
zend_hash_str_update(Z_ARRVAL_P(server), key, key_len, &nv);
}
PHP_RINIT_FUNCTION(cac_path_parity)
{
#if defined(ZTS) && defined(COMPILE_DL_CAC_PATH_PARITY)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
const char *from = CACPP_G(from);
const char *to = CACPP_G(to);
/* Unconfigured (any tier that isn't shared-ols) => completely inert. */
if (!cacpp_mapping_configured(from, to)) {
return SUCCESS;
}
/*
* Both sides must be ABSOLUTE. The entrypoint only ever writes absolute
* paths; a relative prefix would be a typo or a mangled ini, and matching it
* against a SAPI-supplied path could only ever produce nonsense. Treat it
* like an absent mapping — inert, no diagnostic, request proceeds.
*/
if (!cacpp_mapping_active(from, to)) {
return SUCCESS;
}
/*
* With auto_globals_jit=On (the default) $_SERVER is not built yet at
* RINIT — php_hash_environment() only MARKED it for lazy creation. Reading
* PG(http_globals)[TRACK_VARS_SERVER] here without this call finds IS_UNDEF
* and the extension silently does nothing, which is precisely the failure
* the auto_prepend approach had. Forcing the auto-global now builds it (via
* the SAPI's register_server_variables) so there is something to rewrite,
* and the later userland access gets the corrected array.
*/
zend_is_auto_global_str(ZEND_STRL("_SERVER"));
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
if (Z_TYPE_P(server) != IS_ARRAY) {
return SUCCESS;
}
size_t from_len = cacpp_trim(from, strlen(from));
size_t to_len = cacpp_trim(to, strlen(to));
for (size_t i = 0; i < sizeof(cacpp_keys) / sizeof(cacpp_keys[0]); i++) {
cacpp_rewrite_key(server, cacpp_keys[i].name, cacpp_keys[i].len,
from, from_len, to, to_len);
}
return SUCCESS;
}
PHP_MINIT_FUNCTION(cac_path_parity)
{
REGISTER_INI_ENTRIES();
return SUCCESS;
}
PHP_MSHUTDOWN_FUNCTION(cac_path_parity)
{
UNREGISTER_INI_ENTRIES();
return SUCCESS;
}
PHP_MINFO_FUNCTION(cac_path_parity)
{
const char *from = CACPP_G(from);
const char *to = CACPP_G(to);
/*
* Report what RINIT would ACTUALLY do, by asking the same predicates RINIT
* asks — never a longhand copy of them (see cacpp_mapping_active above for
* what that cost last time). Three distinct answers, because "configured but
* ignored" is a different operational problem from "not configured" and the
* canary must be able to tell them apart.
*/
const char *state;
if (cacpp_mapping_active(from, to)) {
state = "active";
} else if (cacpp_mapping_configured(from, to)) {
state = "inactive (mapping not absolute)";
} else {
state = "inactive (unconfigured)";
}
php_info_print_table_start();
php_info_print_table_header(2, "cac_path_parity support", "enabled");
php_info_print_table_row(2, "Version", PHP_CAC_PATH_PARITY_VERSION);
/*
* The canary greps for this row: "active" proves the mapping is live — and,
* since the predicate is shared with RINIT, proves the request path agrees.
*/
php_info_print_table_row(2, "Rewriting", state);
php_info_print_table_end();
DISPLAY_INI_ENTRIES();
}
zend_module_entry cac_path_parity_module_entry = {
STANDARD_MODULE_HEADER,
"cac_path_parity",
NULL, /* no userland functions — by design */
PHP_MINIT(cac_path_parity),
PHP_MSHUTDOWN(cac_path_parity),
PHP_RINIT(cac_path_parity),
NULL, /* RSHUTDOWN */
PHP_MINFO(cac_path_parity),
PHP_CAC_PATH_PARITY_VERSION,
PHP_MODULE_GLOBALS(cac_path_parity),
PHP_GINIT(cac_path_parity),
NULL, /* GSHUTDOWN */
NULL, /* post-deactivate */
STANDARD_MODULE_PROPERTIES_EX
};
#ifdef COMPILE_DL_CAC_PATH_PARITY
#if defined(ZTS)
ZEND_TSRMLS_CACHE_DEFINE()
#endif
ZEND_GET_MODULE(cac_path_parity)
#endif
+15
View File
@@ -0,0 +1,15 @@
dnl config.m4 for the cac_path_parity extension.
dnl Built out-of-tree against the image's own lsphp:
dnl phpize && ./configure --with-php-config=/usr/local/lsws/lsphpNN/bin/php-config
dnl No external libraries, no optional features — pure core API.
PHP_ARG_ENABLE([cac_path_parity],
[whether to enable cac_path_parity support],
[AS_HELP_STRING([--enable-cac-path-parity],
[Enable cac_path_parity ($_SERVER path parity for the shared-ols tier)])],
[no])
if test "$PHP_CAC_PATH_PARITY" != "no"; then
AC_DEFINE(HAVE_CAC_PATH_PARITY, 1, [Have cac_path_parity support])
PHP_NEW_EXTENSION(cac_path_parity, cac_path_parity.c, $ext_shared)
fi
+24
View File
@@ -0,0 +1,24 @@
/*
* cac_path_parity — $_SERVER filesystem-path parity for the shared-ols tier.
*
* See cac_path_parity.c for the full rationale.
*/
#ifndef PHP_CAC_PATH_PARITY_H
#define PHP_CAC_PATH_PARITY_H
extern zend_module_entry cac_path_parity_module_entry;
#define phpext_cac_path_parity_ptr &cac_path_parity_module_entry
#define PHP_CAC_PATH_PARITY_VERSION "1.0.0"
#if defined(ZTS) && defined(COMPILE_DL_CAC_PATH_PARITY)
ZEND_TSRMLS_CACHE_EXTERN()
#endif
ZEND_BEGIN_MODULE_GLOBALS(cac_path_parity)
char *from;
char *to;
ZEND_END_MODULE_GLOBALS(cac_path_parity)
#endif /* PHP_CAC_PATH_PARITY_H */
@@ -0,0 +1,25 @@
--TEST--
cac_path_parity: rewrites the configured prefix on a filesystem $_SERVER key
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
HTTP_HOST=site.com
--FILE--
<?php
// NOTE: the CLI SAPI overwrites DOCUMENT_ROOT (to "") and SCRIPT_FILENAME /
// PATH_TRANSLATED (to the script path) AFTER the env import, so those three
// cannot be driven from --ENV-- here. They go through the identical code path
// as CONTEXT_DOCUMENT_ROOT (one loop over one key table); the real web-SAPI
// proof for them is tests/fpm-parity-check.sh.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
// Non-path vars must be untouched.
var_dump($_SERVER['HTTP_HOST']);
?>
--EXPECT--
string(21) "/home/bob/public_html"
string(8) "site.com"
@@ -0,0 +1,18 @@
--TEST--
cac_path_parity: a sibling dir sharing the prefix is NOT rewritten
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com.bak/public_html
--FILE--
<?php
// Replacement happens only on a path-COMPONENT boundary. Without that guard a
// neighbouring directory would be folded into this container's /home.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(39) "/mnt/users/bob/site.com.bak/public_html"
@@ -0,0 +1,17 @@
--TEST--
cac_path_parity: an exact prefix match (no trailing component) is rewritten
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com/
cac_path_parity.to=/home/bob/
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com
--FILE--
<?php
// Also covers trailing slashes in the configured values being tolerated.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(9) "/home/bob"
@@ -0,0 +1,16 @@
--TEST--
cac_path_parity: completely inert when no mapping is configured
--EXTENSIONS--
cac_path_parity
--INI--
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// cac-fpm / cac-litespeed never configure a mapping, so the extension must be
// a no-op there. This is the no-regression guarantee for the other tiers.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(35) "/mnt/users/bob/site.com/public_html"
@@ -0,0 +1,20 @@
--TEST--
cac_path_parity: works with auto_globals_jit=On (lazy $_SERVER, the default)
--EXTENSIONS--
cac_path_parity
--INI--
auto_globals_jit=1
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// With auto_globals_jit=On, $_SERVER does not exist yet when extension RINIT
// runs. The extension forces the auto-global so there is something to rewrite;
// drop that call and this test prints the /mnt/users path.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(21) "/home/bob/public_html"
@@ -0,0 +1,25 @@
--TEST--
cac_path_parity: mapping is PHP_INI_SYSTEM — userland cannot change it
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// PHP_INI_SYSTEM entries are not modifiable at runtime, and .user.ini honours
// only PHP_INI_PERDIR|PHP_INI_USER — so a customer cannot redirect or disable
// the rewrite the way they can displace an auto_prepend_file.
var_dump(ini_set('cac_path_parity.from', '/tmp'));
var_dump(ini_set('cac_path_parity.to', '/tmp'));
var_dump(ini_get('cac_path_parity.from'));
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
bool(false)
bool(false)
string(23) "/mnt/users/bob/site.com"
string(21) "/home/bob/public_html"
@@ -0,0 +1,20 @@
--TEST--
cac_path_parity: a non-absolute mapping is inert, not applied
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=mnt/users/bob/site.com/public_html
--FILE--
<?php
// The value here is deliberately relative TOO, so the prefix would match and be
// rewritten if the absolute-path guard in RINIT were removed. Nothing the
// entrypoint writes looks like this; the guard exists so a mangled ini degrades
// to "inert" rather than to "confidently wrong".
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(34) "mnt/users/bob/site.com/public_html"
@@ -0,0 +1,20 @@
--TEST--
cac_path_parity: to=/ does not produce a doubled separator
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// Degenerate mapping, unreachable from entrypoint-lsphp.sh (which always writes
// to=/home/<user>). Before the eff_to_len collapse this returned
// "//public_html". A path with a doubled leading slash is not the same string as
// the cac-fpm value, which is the entire point of this extension.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(12) "/public_html"
@@ -0,0 +1,30 @@
--TEST--
cac_path_parity: MINFO reports a non-absolute mapping as INACTIVE, not active
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=mnt/users/bob/site.com
cac_path_parity.to=/home/bob
--FILE--
<?php
// `lsphp -i | grep Rewriting` is the signal the post-deploy fleet canary uses to
// confirm parity is live on a host. When the absolute-path guard was added to
// RINIT, MINFO was left testing only "both values non-empty" — so this exact
// mapping (relative `from`, silently INERT since 007) still printed
// "Rewriting => active". A canary that reports healthy for a dead mapping hides
// precisely the failure it was deployed to find.
//
// MINFO and RINIT now share cacpp_mapping_active(); revert MINFO to the
// non-empty test and this prints "active".
ob_start();
phpinfo(INFO_MODULES);
$info = ob_get_clean();
// Also assert the row is unique, so the match below cannot be some other
// module's identically-named row.
var_dump(preg_match_all('/^Rewriting => (.+)$/m', $info, $m));
var_dump(rtrim($m[1][0]));
?>
--EXPECT--
int(1)
string(31) "inactive (mapping not absolute)"
@@ -0,0 +1,24 @@
--TEST--
cac_path_parity: MINFO reports a well-formed mapping as ACTIVE
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
--FILE--
<?php
// The other half of 009. Tightening MINFO must not overshoot into the opposite
// lie: a canary that reports "inactive" on a perfectly good mapping would page
// the fleet for nothing and, worse, train us to ignore the row. This mapping is
// the exact shape entrypoint-lsphp.sh writes (/mnt/users/<user>/<domain> ->
// /home/<user>), and 001 proves RINIT really does rewrite under it.
ob_start();
phpinfo(INFO_MODULES);
$info = ob_get_clean();
var_dump(preg_match_all('/^Rewriting => (.+)$/m', $info, $m));
var_dump(rtrim($m[1][0]));
?>
--EXPECT--
int(1)
string(6) "active"
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env bash
## fpm-parity-check.sh — end-to-end proof under a REAL web SAPI.
##
## WHY NOT .phpt: the CLI SAPI overwrites DOCUMENT_ROOT / SCRIPT_FILENAME /
## PATH_TRANSLATED after importing the environment, and the cli-server SAPI does
## not process .user.ini at all — so neither can exercise the two things that
## actually matter here.
##
## WHY PHP-FPM: php-fpm takes DOCUMENT_ROOT and SCRIPT_FILENAME as caller-
## supplied FastCGI params and honours .user.ini — structurally the same shape as
## OpenLiteSpeed handing a detached lsphp its LSAPI params. It is the closest
## analogue available without an OLS runtime.
##
## Asserts:
## 1. CONTROL — no mapping => PHP reports the raw /mnt/users paths, i.e. the
## test reproduces the bug before claiming to fix it.
## 2. FIX — mapping => both keys read /home/<user>/... .
## 3. WORDFENCE — mapping AND a customer .user.ini auto_prepend_file (the state
## 7 live shared_ols sites are in): paths are STILL corrected
## AND the customer's prepend STILL runs. This is the case the
## old auto_prepend_file normaliser silently lost.
## 4. OLD — for the record: the previous auto_prepend mechanism, with the
## same customer .user.ini, does NOT run. This is the evidence
## that hardening the prepend hook could not have worked.
##
## Exit codes: 0 = all assertions passed, 1 = an assertion FAILED, 2 = the
## harness could not run (missing binary, php-fpm refused to start, .so would not
## load). 2 is deliberately distinct from 1: a startup problem previously
## surfaced as all nine assertions failing with an empty `got:`, which reads like
## nine parity bugs and is the opposite of the truth.
##
## Usage: ./fpm-parity-check.sh [ROOT] [PHP_FPM_BIN] [EXT_SO]
## ROOT defaults to /mnt/users (falls back to a temp dir if not creatable).
## PHP_FPM_BIN is auto-detected; every packaging of php-fpm this repo touches
## uses a different name (`php-fpm` in the official docker images,
## `php-fpm8.N` on Debian/Ubuntu, /usr/sbin/... unlinked from PATH), so a
## single hardcoded default is guaranteed to be wrong somewhere and its only
## symptom was a silent `SKIP`.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
find_fpm() {
local c
for c in php-fpm php-fpm8.5 php-fpm8.4 php-fpm8.3 php-fpm8.2 php-fpm8.1; do
if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi
done
for c in /usr/local/sbin/php-fpm /usr/sbin/php-fpm /usr/sbin/php-fpm8.*; do
if [ -x "$c" ]; then echo "$c"; return 0; fi
done
return 1
}
ROOT="${1:-/mnt/users}"
FPM_BIN="${2:-$(find_fpm || true)}"
EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}"
PORT="${PORT:-9001}"
command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; }
[ -n "$FPM_BIN" ] && [ -x "$FPM_BIN" ] || { echo "SKIP: php-fpm not found (pass it as \$2)"; exit 0; }
[ -f "$EXT_SO" ] || { echo "SKIP: $EXT_SO not built (run phpize && ./configure && make)"; exit 0; }
## `${VAR%%$'\n'*}` rather than `| head -1`: same first line, no pipeline, so
## nothing here can be decided by a SIGPIPE race under the pipefail on line 39.
## This one only ever fed an echo, so it could not have misled anyone — it is
## changed so that "no pipefail script in this repo pipes into an early-exit
## reader" stays a rule with no exceptions to remember.
FPM_VERSION=$("$FPM_BIN" -n -v 2>/dev/null || true)
echo "php-fpm: $FPM_BIN (${FPM_VERSION%%$'\n'*})"
echo "extension: $EXT_SO"
## Pre-flight. If the .so will not load into THIS php-fpm (PHP API mismatch is
## the usual cause) every assertion below would fail identically and blame the
## extension's logic. Say what actually happened instead.
## Captured into a variable and matched with a here-string, not piped into
## `grep -qx`. `grep -q` exits on its first match, and with `set -o pipefail`
## (line 39) a writer still writing at that moment dies 141 and the pipeline
## reads FALSE — announcing "cannot load the extension" *because* the extension
## was listed. The reason to change it is structural, not that `php-fpm -m` is
## small: there is no payload size that makes this shape safe (41 KB SIGPIPEs
## about 11% of the time into a 64 KB pipe — see the note over the probe helpers
## in scripts/entrypoint-lsphp.sh), and this pre-flight exists precisely to stop
## a harness malfunction being reported as an extension fault, so it must not
## have one of its own. (The same construct on 40 KB of `lsphp -i` is what broke
## entrypoint-lsphp.sh in production.)
##
## A here-string, not the `[[ ]]` form those helpers use, on purpose: `<<<`
## spills to /tmp/sh-thd.XXXXXX above ~4-64 KB depending on the bash build, so
## it is a writable-temp-dir precondition, which is unacceptable on a boot path
## and irrelevant here — `php-fpm -m` is ~1 KB, and this harness has already
## created a docroot and a pool config by the time it runs.
FPM_MODULES=$("$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>/dev/null || true)
if ! grep -qx 'cac_path_parity' <<<"$FPM_MODULES"; then
echo "HARNESS FAILURE: $FPM_BIN cannot load $EXT_SO" >&2
"$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>&1 | grep -i 'unable\|warning\|error' >&2
echo " The .so must be built against the same PHP as this php-fpm binary." >&2
exit 2
fi
mkdir -p "$ROOT" 2>/dev/null || ROOT="$(mktemp -d)/mnt/users"
USER_NAME=bob
SITE="$ROOT/$USER_NAME/site.com"
DOCROOT="$SITE/public_html"
HOME_PATH="/home/$USER_NAME"
TMP="$(mktemp -d)"
fail=0
## php-fpm REFUSES to start as root unless the pool names a non-root user/group,
## and the pool this script generates had neither — so as shipped it never got
## past startup in any root context (which is every container in this repo).
## Resolve a real unprivileged account rather than assuming www-data exists.
POOL_USER=""
POOL_GROUP=""
if [ "$(id -u)" -eq 0 ]; then
for u in www-data nobody daemon; do
if id -u "$u" >/dev/null 2>&1; then POOL_USER="$u"; break; fi
done
for g in www-data nogroup nobody daemon; do
if getent group "$g" >/dev/null 2>&1; then POOL_GROUP="$g"; break; fi
done
[ -n "$POOL_USER" ] && [ -n "$POOL_GROUP" ] || {
echo "HARNESS FAILURE: running as root but found no unprivileged user/group for the pool" >&2
exit 2
}
fi
mkdir -p "$DOCROOT" || { echo "cannot create $DOCROOT"; exit 1; }
## The pool worker is not root: it has to be able to read the fixtures under
## $TMP (mktemp -d is 0700) and walk down to $DOCROOT.
chmod 755 "$TMP"
trap 'rm -rf "$TMP"; rm -f "$DOCROOT/.user.ini"' EXIT
cat > "$DOCROOT/probe.php" <<'PHP'
<?php
echo "DOCUMENT_ROOT=" . $_SERVER['DOCUMENT_ROOT'] . "\n";
echo "SCRIPT_FILENAME=" . $_SERVER['SCRIPT_FILENAME'] . "\n";
echo "PREPEND_RAN=" . (defined('CUSTOMER_PREPEND_RAN') ? 'yes' : 'no') . "\n";
PHP
## Stand-in for the customer's wordfence-waf.php.
cat > "$SITE/customer-waf.php" <<'PHP'
<?php
define('CUSTOMER_PREPEND_RAN', 1);
PHP
## Stand-in for the OLD mechanism (scripts/cac-lsphp-normalize.php).
cat > "$TMP/old-normalize.php" <<'PHP'
<?php
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) {
if (!empty($_SERVER[$k]) && strncmp($_SERVER[$k], '/mnt/users/', 11) === 0) {
$r = realpath($_SERVER[$k]);
if ($r !== false) { $_SERVER[$k] = $r; }
}
}
PHP
{
echo "[global]"
echo "error_log = $TMP/fpm-error.log"
echo "daemonize = no"
echo "[www]"
echo "listen = 127.0.0.1:$PORT"
echo "pm = static"
echo "pm.max_children = 2"
## Only when we are root: php-fpm hard-errors on a root pool, and warns
## (harmlessly, but noisily) if a non-root master names a user at all.
if [ -n "$POOL_USER" ]; then
echo "user = $POOL_USER"
echo "group = $POOL_GROUP"
fi
} > "$TMP/fpm.conf"
## Returns non-zero when php-fpm never answered. Callers MUST distinguish that
## from an assertion failure — an unstarted php-fpm makes every expect() below
## fail with an empty `got:`, which looks like nine parity bugs.
run_case() {
: > "$TMP/fpm.out"
"$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \
>"$TMP/fpm.out" 2>&1 &
local pid=$! out=""
for _ in $(seq 1 40); do
sleep 0.15
## SC1007: `QUERY_STRING=` IS the intent — an empty FastCGI param in the
## per-command environment prefix, exactly as a webserver sends it for a
## URL with no query string. Not a truncated assignment.
# shellcheck disable=SC1007
out=$(SCRIPT_FILENAME="$DOCROOT/probe.php" DOCUMENT_ROOT="$DOCROOT" \
SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \
cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null)
[ -n "$out" ] && break
## Master already gone => it will never answer; stop waiting 6s for it.
kill -0 "$pid" 2>/dev/null || break
done
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
printf '%s' "$out"
[ -n "$out" ]
}
die_startup() {
echo
echo "HARNESS FAILURE: php-fpm never answered for case '$1'." >&2
echo " This is a STARTUP/environment failure, NOT a parity assertion failure." >&2
echo " php-fpm: $FPM_BIN" >&2
echo " pool user/group: ${POOL_USER:-<none, master is not root>}/${POOL_GROUP:-}" >&2
echo " --- php-fpm output ---" >&2
sed 's/^/ /' "$TMP/fpm.out" >&2
echo " --- pool error_log ---" >&2
[ -s "$TMP/fpm-error.log" ] && sed 's/^/ /' "$TMP/fpm-error.log" >&2
echo " ----------------------" >&2
exit 2
}
expect() {
local label="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then
echo " PASS $label"
else
echo " FAIL $label"
echo " want: $want"
echo " got: $got"
fail=1
fi
}
field() { printf '%s' "$1" | sed -n "s/^$2=//p"; }
EXT=( -d "extension=$EXT_SO" )
MAP=( -d "cac_path_parity.from=$SITE" -d "cac_path_parity.to=$HOME_PATH" )
USERINI_LINE="auto_prepend_file = $SITE/customer-waf.php"
echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) =="
rm -f "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}") || die_startup "1. CONTROL"
expect "DOCUMENT_ROOT is the raw OLS path" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "SCRIPT_FILENAME is the raw OLS path" "$(field "$out" SCRIPT_FILENAME)" "$DOCROOT/probe.php"
echo "== 2. FIX: mapping configured =="
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "2. FIX"
expect "DOCUMENT_ROOT == cac-fpm value" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
expect "SCRIPT_FILENAME == cac-fpm value" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
echo "== 3. WORDFENCE: customer .user.ini auto_prepend_file present =="
printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "3. WORDFENCE"
expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
expect "SCRIPT_FILENAME still corrected" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
expect "customer auto_prepend_file still ran" "$(field "$out" PREPEND_RAN)" "yes"
echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) =="
out=$(run_case -d "auto_prepend_file=$TMP/old-normalize.php") || die_startup "4. OLD MECHANISM"
expect "auto_prepend normaliser is displaced by the customer's .user.ini" \
"$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes"
rm -f "$DOCROOT/.user.ini"
if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES"; fi
exit "$fail"
+19 -10
View File
@@ -5,18 +5,20 @@ https_port='443'
root_path="$(pwd)" root_path="$(pwd)"
verbose='false' verbose='false'
while getopts 'n:p:s:r:vh' flag; do while getopts 'n:p:s:r:a:vh' flag; do
case "${flag}" in case "${flag}" in
n) name="${OPTARG}" ;; n) name="${OPTARG}" ;;
p) http_port="${OPTARG}" ;; p) http_port="${OPTARG}" ;;
s) https_port="${OPTARG}" ;; s) https_port="${OPTARG}" ;;
r) root_path="${OPTARG}" ;; r) root_path="${OPTARG}" ;;
a) phpver="${OPTARG}" ;;
v) verbose='true' ;; v) verbose='true' ;;
h) echo "Variables" h) echo "Variables"
echo "-n = Name of Container, Required" echo "-n = Name of Container, Required"
echo "-p = Non-https Port Override, default 80" echo "-p = Non-https Port Override, default 80"
echo "-s = Https Port Override, default 443" echo "-s = Https Port Override, default 443"
echo "-r = Root Path for files and database, defaults to current working path" echo "-r = Root Path for files and database, defaults to current working path"
echo "-a = PHP App Version, Default to 8.3"
echo "-v = Enable Verbose Mode" echo "-v = Enable Verbose Mode"
exit 1 ;; exit 1 ;;
esac esac
@@ -34,16 +36,20 @@ if [ -z "$name" ]; then
echo "Name not set, please set it with -n" echo "Name not set, please set it with -n"
exit 1 exit 1
fi fi
if [ -z "$phpver" ]; then
phpver=83;
fi
echo "Building Docker Image..." echo "Building Docker Image..."
user=$(whoami) user=$(whoami)
uid=$(id -u) uid=$(id -u)
if [ ! -d "$root_path/db" ]; then if [ ! -d "$root_path/user" ]; then
mkdir -p "$root_path/db"; mkdir -p "$root_path/user";
mkdir -p "$root_path/user/logs/{apache,system}";
fi fi
if [ ! -d "$root_path/web" ]; then $check_docker volume create "$name-mysql"
mkdir -p "$root_path/web"; $check_docker run --pull=always -d -p "$http_port":80 -p "$https_port":443 -e PHPVER=$phpver -e environment=DEV --mount type=bind,source="$root_path"/user,target=/home/"$user" --mount type=bind,source="$(pwd)"/user/logs/apache,target=/etc/httpd/logs --mount type=bind,source="$(pwd)"/user/logs/system,target=/var/log -v"$name-mysql":/var/lib/mysql -e uid="$uid" -e user="$user" -e domain="$name-local.dev" --name "$name" repo.anhonesthost.net/cloud-hosting-platform/cac:latest
fi
$check_docker run -d -p "$http_port":80 -p "$https_port":443 -e PHPVER=82 -e environment=DEV --mount type=bind,source="$root_path"/web,target=/home/"$user"/public_html --mount type=bind,source="$root_path"/db,target=/var/lib/mysql -e uid="$uid" -e user="$user" -e domain="$name-local.dev" --name "$name" public.ecr.aws/s1f6k4w4/cac
echo "Creating management scripts in root directory..." echo "Creating management scripts in root directory..."
echo "#!/usr/bin/env bash" > "$root_path/instance_start" echo "#!/usr/bin/env bash" > "$root_path/instance_start"
echo "docker start $name" >> "$root_path/instance_start" echo "docker start $name" >> "$root_path/instance_start"
@@ -54,10 +60,13 @@ echo "docker exec $name bash -c 'tail -f /etc/httpd/logs/*'" >> "$root_path/inst
echo "#!/usr/bin/env bash" > "$root_path/instance_db_info" echo "#!/usr/bin/env bash" > "$root_path/instance_db_info"
echo "docker exec $name cat /var/lib/mysql/creds" >> "$root_path/instance_db_info" echo "docker exec $name cat /var/lib/mysql/creds" >> "$root_path/instance_db_info"
chmod +x $root_path/instance_* chmod +x $root_path/instance_*
echo "Waiting 120 seconds for setup to finish" echo "Waiting 160 seconds for setup to finish"
sleep 120; sleep 160;
echo "Installing WordPress..." echo "Installing WordPress..."
docker exec $name bash -c "cd /home/$(whoami)/public_html; wp core download; chown -R $(whoami) /home/$(whoami)/public_html" wpdbuser=$(docker exec $name cat /var/lib/mysql/creds |grep User| awk -F ": " {'print $2'})
wpdbpass=$(docker exec $name cat /var/lib/mysql/creds |grep Password| awk -F ": " {'print $2'})
wpdb=$(docker exec $name cat /var/lib/mysql/creds |grep Database| awk -F ": " {'print $2'})
docker exec $name bash -c "cd /home/$(whoami)/public_html; wp core download; wp config create --dbname=$wpdb --dbuser=$wpdbuser --dbpass=$wpdbpass ; chown -R $(whoami):$(whoami) /home/$(whoami)/public_html;"
echo "Local Development Instance Created, to stop run ./instance_stop from within the base directory" echo "Local Development Instance Created, to stop run ./instance_stop from within the base directory"
echo "MySQL DB Credentials" echo "MySQL DB Credentials"
docker exec $name cat /var/lib/mysql/creds docker exec $name cat /var/lib/mysql/creds
-70
View File
@@ -1,70 +0,0 @@
---
resources:
- name: cac
type: git
source:
uri: https://repo.anhonesthost.net/cloud-hosting-platform/cloud-apache-container.git
branch: trunk
- name: build-cac-74
type: docker-image
source:
repository: registry.dnspegasus.net/cac
tag: 74
- name: build-cac-80
type: docker-image
source:
repository: registry.dnspegasus.net/cac
tag: 80
- name: build-cac-81
type: docker-image
source:
repository: registry.dnspegasus.net/cac
tag: 81
- name: build-cac-82
type: docker-image
source:
repository: registry.dnspegasus.net/cac
tag: 82
jobs:
- name: publish-cac-74
plan:
- get: cac
trigger: true
- put: build-cac-74
params:
build: cac
build_args:
PHPVER: 74
- name: publish-cac-80
plan:
- get: cac
trigger: true
- put: build-cac-80
params:
build: cac
build_args:
PHPVER: 80
- name: publish-cac-81
plan:
- get: cac
trigger: true
- put: build-cac-81
params:
build: cac
build_args:
PHPVER: 81
- name: publish-cac-82
plan:
- get: cac
trigger: true
- put: build-cac-82
params:
build: cac
build_args:
PHPVER: 82
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* cac-lsphp $_SERVER path normaliser (auto_prepend) — DEGRADED FALLBACK ONLY.
*
* SUPERSEDED by the cac_path_parity PHP extension (ext/cac-path-parity/), which
* does this from RINIT where a customer's .user.ini cannot displace it. The
* entrypoint only wires this file up when that extension is not loadable in the
* running image, and logs a WARNING when it does. Do not extend this script —
* fix the extension instead.
*
* It is kept because the flaw documented at the bottom of this docblock is
* exactly why the extension exists, and because an image where the extension
* failed to load should degrade to the old behaviour rather than to nothing.
*
* The shared-ols container serves from its bulk /docker/users->/mnt/users mount,
* so OLS sends lsphp $_SERVER['DOCUMENT_ROOT'] / ['SCRIPT_FILENAME'] under
* /mnt/users/<user>/<domain>/... . The sidecar symlinks that back to the real
* /home/<user> mount, so file operations resolve and PHP's own __FILE__/__DIR__/
* realpath()/getcwd() already report /home/<user>/public_html. But the RAW env
* strings OLS set still read /mnt/users, which would leak to the (uncommon) apps
* that build or compare paths from $_SERVER['DOCUMENT_ROOT'].
*
* Canonicalise those two via realpath() so cac-lsphp is byte-for-byte 1:1 with
* cac-fpm/cac-litespeed (where DOCUMENT_ROOT is natively /home/<user>/public_html).
* Cheap (two realpath calls, cached by realpath_cache) and side-effect-free.
*
* Customer sites have no auto_prepend by default, so this is the only prepend in
* play. If a site sets its own auto_prepend_file via .user.ini it overrides this
* (theirs wins) — acceptable: paths still resolve via the symlink, only the raw
* string differs.
*/
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $__cl_key) {
if (!empty($_SERVER[$__cl_key]) && strncmp($_SERVER[$__cl_key], '/mnt/users/', 11) === 0) {
$__cl_real = realpath($_SERVER[$__cl_key]);
if ($__cl_real !== false) {
$_SERVER[$__cl_key] = $__cl_real;
}
}
}
unset($__cl_key, $__cl_real);
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Generate Apache MPM event tuning config at runtime using detect-memory.sh values.
cat <<EOF > /etc/httpd/conf.d/mpm-tuning.conf
<IfModule mpm_event_module>
StartServers ${APACHE_START_SERVERS}
MinSpareThreads ${APACHE_MIN_SPARE_THREADS}
MaxSpareThreads ${APACHE_MAX_SPARE_THREADS}
ThreadLimit 64
ThreadsPerChild 25
MaxRequestWorkers ${APACHE_MAX_REQUEST_WORKERS}
ServerLimit ${APACHE_SERVER_LIMIT}
MaxConnectionsPerChild ${APACHE_MAX_CONNECTIONS_PER_CHILD}
</IfModule>
EOF
exit 0
+39 -9
View File
@@ -2,27 +2,57 @@
rm /etc/php-fpm.d/www.conf rm /etc/php-fpm.d/www.conf
FPM_LISTEN=${FPM_LISTEN:-/run/php-fpm/www.sock}
# Determine listen directive and ownership based on socket vs TCP
if echo "$FPM_LISTEN" | grep -q '/'; then
# Unix socket mode (standalone — Apache and FPM in same container)
listen_directive="$FPM_LISTEN"
listen_owner_block="listen.owner = apache
listen.group = apache"
env_block=""
else
# TCP port mode (shared httpd — FPM in separate container)
listen_directive="0.0.0.0:${FPM_LISTEN}"
listen_owner_block=""
# Override DOCUMENT_ROOT so PHP plugins (e.g., WordFence) that use
# $_SERVER['DOCUMENT_ROOT'] find files at the FPM container's path,
# not the shared httpd's /mnt/users/ mount path.
env_block="env[DOCUMENT_ROOT] = /home/$user/public_html"
fi
cat <<EOF > /etc/php-fpm.d/$user.conf cat <<EOF > /etc/php-fpm.d/$user.conf
[$user] [$user]
user = $user user = $user
group = $user group = $user
listen = /run/php-fpm/www.sock listen = ${listen_directive}
listen.owner = apache ${listen_owner_block}
listen.group = apache
pm = static pm = ${PHP_FPM_PM}
pm.max_children = 10 pm.max_children = ${PHP_FPM_MAX_CHILDREN}
pm.max_requests = 150 pm.max_requests = ${PHP_FPM_MAX_REQUESTS}
pm.process_idle_timeout = ${PHP_FPM_PROCESS_IDLE_TIMEOUT}
slowlog = /etc/httpd/logs/error_log ; Settings used when pm = dynamic (fallback if user overrides FPM_PM)
pm.start_servers = ${PHP_FPM_START_SERVERS}
pm.min_spare_servers = ${PHP_FPM_MIN_SPARE}
pm.max_spare_servers = ${PHP_FPM_MAX_SPARE}
; Health check endpoints
ping.path = /fpm-ping
ping.response = pong
pm.status_path = /fpm-status
slowlog = /home/$user/logs/php-fpm/slowlog
request_slowlog_timeout = 3s request_slowlog_timeout = 3s
php_admin_value[error_log] = /etc/httpd/logs/error_log php_admin_value[error_log] = /home/$user/logs/php-fpm/error.log
php_admin_flag[log_errors] = on php_admin_flag[log_errors] = on
php_value[soap.wsdl_cache_dir] = /var/lib/php/wsdlcache php_value[soap.wsdl_cache_dir] = /var/lib/php/wsdlcache
${env_block}
EOF EOF
exit 0 exit 0
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
## create-vhost-litespeed.sh — sets up OLS config for one customer site.
##
## Approach: keep the stock LiteSpeed-shipped httpd_config.conf VERBATIM
## (it has all the cgid/lscgid plumbing that lscgid needs to actually
## create its IPC socket), and just APPEND our listeners + vhTemplate.
## The custom vhost template lives at conf/templates/site.conf and points
## at /home/${user}/public_html. envsubst renders our user/domain into
## both files at container start.
##
## Expects in env: user, domain, serveralias (optional).
set -euo pipefail
TPL_DIR=${TPL_DIR:-/etc/lsws-templates}
LSWS_CONF=/usr/local/lsws/conf
## Ensure the conf dir has stock config to append to. On first boot with
## a fresh image this is a no-op (image ships with conf/ populated). With
## a future volume mount of conf/, the upstream entrypoint pattern would
## copy from .conf/* — keep parity:
if [ -z "$(ls -A -- "$LSWS_CONF/" 2>/dev/null)" ]; then
cp -R /usr/local/lsws/.conf/* "$LSWS_CONF/"
fi
## Build the serveralias suffix for vhDomain. Empty for none, else
## ",alias1,alias2" prepended to the comma list.
vhost_map_aliases=""
if [ -n "${serveralias:-}" ]; then
for alias in $(echo "$serveralias" | tr ',' ' '); do
[ -z "$alias" ] && continue
vhost_map_aliases="${vhost_map_aliases},${alias}"
done
fi
export vhost_map_aliases user domain
## --- prep the stock httpd_config.conf before appending ours ---
## Stock ships with `listener HTTP {*:80}`, `listener HTTPS {*:443}`, and
## a `vhTemplate docker` mapped to /var/www/vhosts/$VH_NAME/html — these
## conflict with our ports and would shadow our siteVH vhost. Strip them
## and the demo `virtualHost Example`, but KEEP `listener Default` (it's
## bound to 8088 — harmless internally, removing risks unrelated breakage).
## Always restart from a stock copy so re-runs are idempotent (otherwise
## a second sed pass on already-stripped config corrupts it).
cp /usr/local/lsws/.conf/httpd_config.conf "$LSWS_CONF/httpd_config.conf"
## Strip the stock blocks we replace. Use awk: easier than sed range-deletes
## to skip a NAMED block of arbitrary length terminated by a top-level `}`.
## extProcessor lsphp is stripped because the stock one hard-codes
## PHP_LSAPI_CHILDREN=10 regardless of container size — our appended
## extProcessor scales it from detect-memory-litespeed.sh.
awk '
BEGIN { skip = 0 }
/^listener HTTP \{/ || /^listener HTTPS \{/ || /^vhTemplate docker \{/ || /^extProcessor lsphp\{/ || /^extProcessor lsphp \{/ { skip = 1; next }
skip && /^\}/ { skip = 0; next }
!skip { print }
' "$LSWS_CONF/httpd_config.conf" > "$LSWS_CONF/httpd_config.conf.new"
mv "$LSWS_CONF/httpd_config.conf.new" "$LSWS_CONF/httpd_config.conf"
## Server-level user/group → customer. Without this, OLS runs as nobody and
## either can't read customer files (no setUIDMode) or has to lscgid-spawn a
## per-uid lsphp for every httpd worker (the setUIDMode 2 pathway). With OLS
## itself running as ${user}, a single shared lsphp parent serves all httpd
## workers, LSAPI children-mode actually engages, and shmem stops fanning out.
## OLS still starts as root (PID 1 binds 80/443) then drops privs after bind.
sed -i \
-e "s|^user[[:space:]].*|user ${user}|" \
-e "s|^group[[:space:]].*|group ${user}|" \
"$LSWS_CONF/httpd_config.conf"
## --- append our listeners + vhTemplate ---
SENTINEL="## ---- cac-litespeed append (do not edit below) ----"
{
echo ""
echo "$SENTINEL"
envsubst '${user} ${domain} ${vhost_map_aliases} ${PHPVER} ${LSAPI_CHILDREN}' < "$TPL_DIR/httpd_config.tpl"
} >> "$LSWS_CONF/httpd_config.conf"
## --- write our vhost template to /usr/local/lsws/conf/templates/site.conf ---
envsubst '${user}' < "$TPL_DIR/site-template.tpl" \
> "$LSWS_CONF/templates/site.conf"
## --- per-vhost config file the vhTemplate will reference ---
## OLS creates conf/vhosts/$VH_NAME/ at template-instantiation time, but
## we pre-create it to satisfy the configFile path and write a minimal
## vhconf.conf (empty body — all real config is inline in the template's
## virtualHostConfig{} block).
mkdir -p "$LSWS_CONF/vhosts/siteVH"
echo "## auto-generated; real vhost config is in templates/site.conf" \
> "$LSWS_CONF/vhosts/siteVH/vhconf.conf"
## Permissions: OLS reads conf/ as lsadm. Don't break that.
chown -R lsadm:nogroup "$LSWS_CONF" 2>/dev/null || true
+6
View File
@@ -34,6 +34,12 @@ cat <<EOF > /etc/httpd/conf.d/$domain.conf
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost> </VirtualHost>
Listen 443 https
SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
SSLSessionCache shmcb:/run/httpd/sslcache(512000)
SSLSessionCacheTimeout 300
SSLCryptoDevice builtin
<IfModule mod_ssl.c> <IfModule mod_ssl.c>
<VirtualHost _default_:443> <VirtualHost _default_:443>
ServerName "$domain" ServerName "$domain"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
## detect-memory-litespeed.sh — sibling to detect-memory.sh.
## Computes LSAPI_CHILDREN + extprocessor memSoftLimit/memHardLimit from
## container memory cap. Sourced by entrypoint-litespeed.sh.
## ---- container memory detection (mirrors detect-memory.sh) ----
CONTAINER_MEMORY_BYTES=""
if [ -f /sys/fs/cgroup/memory.max ]; then
val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
if [ "$val" != "max" ] && [ -n "$val" ]; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
if [ -n "$mem_kb" ]; then
CONTAINER_MEMORY_BYTES=$((mem_kb * 1024))
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ]; then
CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024))
fi
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
## ---- budget split (LSAPI workers get the lion's share) ----
OS_RESERVE_MB=50
OLS_RESERVE_MB=40 # OpenLiteSpeed daemon footprint
DEV_OVERHEAD_MB=0
if [ "${environment:-PROD}" = "DEV" ]; then
DEV_OVERHEAD_MB=125
fi
AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - OLS_RESERVE_MB - DEV_OVERHEAD_MB))
if [ "$AVAILABLE_MB" -lt 60 ]; then
AVAILABLE_MB=60
fi
## ---- LSAPI children (analogous to PHP_FPM_MAX_CHILDREN) ----
## Per the 2026-06-02 cac-litespeed memory-sizing finding (vantagehealth
## OOM-spawn loop at 512 MB cap): each lsphp worker carries ~115 MB
## shmem-rss + ~25 MB anon + ~10 MB file ≈ 130-150 MB real cgroup cost
## per worker on heavy WP workloads. shmem is RSS-accounted per worker
## (vs cac-fpm's COW-shared fork model) so the cost is real per cgroup,
## not just per process.
##
## 115 (the previous default) was set from idle-state measurements and
## ran brain-jar.com into 142 OOM-kills at 1 GiB on 2026-06-02 night —
## the formula computed CHILDREN=8, which left zero headroom once Divi
## page renders started growing worker anon. Bumped to 130 to track the
## active per-worker cost; gives slightly fewer workers but real headroom.
##
## Sub-512 MB containers remain unsafe for dynamic WP on OLS — the floor
## of 2 workers still applies but it'll be cap-marginal. Per-site override
## via FPM_MAX_CHILDREN env var (panel edit-site UI) overrides this for
## sites where the default isn't right for their workload.
LSPHP_WORKER_ESTIMATE_MB=${LSPHP_WORKER_ESTIMATE_MB:-130}
calc_lsapi_children=$((AVAILABLE_MB / LSPHP_WORKER_ESTIMATE_MB))
if [ "$calc_lsapi_children" -lt 2 ]; then
calc_lsapi_children=2
fi
if [ "$calc_lsapi_children" -gt 50 ]; then
calc_lsapi_children=50
fi
## Per-site override knobs — site-pool-env.php still passes FPM_MAX_CHILDREN
## for backward compat, so prefer LSAPI_CHILDREN if set, else FPM_MAX_CHILDREN,
## else the calculated value.
LSAPI_CHILDREN=${LSAPI_CHILDREN:-${FPM_MAX_CHILDREN:-$calc_lsapi_children}}
## Per-worker mem limits (RLIMIT_AS) live in httpd_config.tpl now as
## hard-coded 1024M soft / 1500M hard — those values comfortably fit
## typical Divi/WooCommerce VSZ (~280-365 MB) while still catching a
## true runaway script. Cgroup remains the real backstop. The earlier
## AVAILABLE/CHILDREN formula was killing legitimate workers because
## it conflated VSZ (RLIMIT_AS) with RSS-budget arithmetic.
export CONTAINER_MEMORY_MB LSAPI_CHILDREN
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
## detect-memory-lsphp.sh — sibling of detect-memory-litespeed.sh for the
## cac-lsphp DETACHED sidecar (lsphp -b, no local webserver).
##
## Computes PHP_LSAPI_CHILDREN from the container memory cap. Identical worker
## arithmetic to detect-memory-litespeed.sh, with ONE difference: there is no
## OpenLiteSpeed daemon in this container (OLS runs in the shared-ols tier), so
## the ~40 MB OLS_RESERVE is dropped — every MB above the OS reserve goes to
## lsphp workers. Sourced by entrypoint-lsphp.sh.
## ---- container memory detection (mirrors detect-memory-litespeed.sh) ----
CONTAINER_MEMORY_BYTES=""
if [ -f /sys/fs/cgroup/memory.max ]; then
val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
if [ "$val" != "max" ] && [ -n "$val" ]; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
if [ -n "$mem_kb" ]; then
CONTAINER_MEMORY_BYTES=$((mem_kb * 1024))
fi
fi
if [ -z "$CONTAINER_MEMORY_BYTES" ]; then
CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024))
fi
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
## ---- budget split (all non-OS memory is the lsphp workers' to use) ----
OS_RESERVE_MB=50
DEV_OVERHEAD_MB=0
if [ "${environment:-PROD}" = "DEV" ]; then
DEV_OVERHEAD_MB=125
fi
AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - DEV_OVERHEAD_MB))
if [ "$AVAILABLE_MB" -lt 60 ]; then
AVAILABLE_MB=60
fi
## ---- LSAPI children ----
## Same ~130 MB/worker estimate as cac-litespeed (see detect-memory-litespeed.sh
## for the vantagehealth/brain-jar OOM history that set this). Detached lsphp
## has the SAME per-worker shmem-RSS profile as in-container lsphp — splitting
## the webserver out doesn't change lsphp's memory model, only removes the OLS
## daemon footprint from the budget.
LSPHP_WORKER_ESTIMATE_MB=${LSPHP_WORKER_ESTIMATE_MB:-130}
calc_lsapi_children=$((AVAILABLE_MB / LSPHP_WORKER_ESTIMATE_MB))
if [ "$calc_lsapi_children" -lt 2 ]; then
calc_lsapi_children=2
fi
if [ "$calc_lsapi_children" -gt 50 ]; then
calc_lsapi_children=50
fi
## Per-site override precedence — the WHP panel (site-pool-env.php) passes the
## customer's override as LSAPI_CHILDREN and/or FPM_MAX_CHILDREN; either wins
## over the calculated default. entrypoint-lsphp.sh exports the result as
## PHP_LSAPI_CHILDREN (the name lsphp -b reads).
LSAPI_CHILDREN=${LSAPI_CHILDREN:-${FPM_MAX_CHILDREN:-$calc_lsapi_children}}
export CONTAINER_MEMORY_MB LSAPI_CHILDREN
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# detect-memory.sh — Detect container memory and calculate tuning parameters.
# Must be sourced (not executed) so variables are available to the caller.
# --- Memory detection (cgroups v2 → v1 → /proc/meminfo → fallback) ---
CONTAINER_MEMORY_BYTES=""
# cgroups v2
if [ -f /sys/fs/cgroup/memory.max ]; then
val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
if [ "$val" != "max" ] && [ -n "$val" ]; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
# cgroups v1
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
# Values near page-aligned max (like 9223372036854771712) mean "no limit"
if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then
CONTAINER_MEMORY_BYTES=$val
fi
fi
# /proc/meminfo (host memory — used when no cgroup limit is set)
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
if [ -n "$mem_kb" ]; then
CONTAINER_MEMORY_BYTES=$((mem_kb * 1024))
fi
fi
# Fallback
if [ -z "$CONTAINER_MEMORY_BYTES" ]; then
CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024))
fi
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
# --- Budget calculation ---
CONTAINER_ROLE=${CONTAINER_ROLE:-combined} # combined | fpm_only | httpd_only
OS_RESERVE_MB=50
FIXED_PROCESS_MB=50
DEV_OVERHEAD_MB=0
if [ "$environment" = "DEV" ]; then
DEV_OVERHEAD_MB=125
fi
AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - FIXED_PROCESS_MB - DEV_OVERHEAD_MB))
if [ "$AVAILABLE_MB" -lt 60 ]; then
AVAILABLE_MB=60
fi
case "$CONTAINER_ROLE" in
fpm_only)
PHP_BUDGET_MB=$AVAILABLE_MB
APACHE_BUDGET_MB=0
;;
httpd_only)
PHP_BUDGET_MB=0
APACHE_BUDGET_MB=$AVAILABLE_MB
;;
*)
PHP_BUDGET_MB=$((AVAILABLE_MB * 80 / 100))
APACHE_BUDGET_MB=$((AVAILABLE_MB * 20 / 100))
;;
esac
# --- PHP-FPM parameters (skipped for httpd_only) ---
if [ "$CONTAINER_ROLE" != "httpd_only" ]; then
# PHP_WORKER_ESTIMATE_MB sizes the divisor for pm.max_children. The
# previous default of 60 was optimistic for modern Woo/Elementor stacks:
# the alphaone 2026-06-01 incident measured ~193 MB resident per worker
# against the 60 MB assumption, and 15 calculated children put peak
# demand (15 * 193 = 2.9 GB) over the 1-2 GiB container cap. 128 lands
# closer to plugin-heavy WP reality while remaining conservative for
# leaner sites. Customers can still override via the FPM_MAX_CHILDREN
# env var on the container if a different shape is justified.
PHP_WORKER_ESTIMATE_MB=${PHP_WORKER_ESTIMATE_MB:-128}
calc_max_children=$((PHP_BUDGET_MB / PHP_WORKER_ESTIMATE_MB))
# Floor at 2, cap at 50
if [ "$calc_max_children" -lt 2 ]; then
calc_max_children=2
fi
if [ "$calc_max_children" -gt 50 ]; then
calc_max_children=50
fi
PHP_FPM_PM=${FPM_PM:-ondemand}
PHP_FPM_MAX_CHILDREN=${FPM_MAX_CHILDREN:-$calc_max_children}
PHP_FPM_PROCESS_IDLE_TIMEOUT=${FPM_PROCESS_IDLE_TIMEOUT:-5s}
PHP_FPM_MAX_REQUESTS=${FPM_MAX_REQUESTS:-200}
# Dynamic mode fallbacks (used if user overrides FPM_PM=dynamic)
PHP_FPM_START_SERVERS=${FPM_START_SERVERS:-2}
PHP_FPM_MIN_SPARE=${FPM_MIN_SPARE_SERVERS:-1}
PHP_FPM_MAX_SPARE=${FPM_MAX_SPARE_SERVERS:-3}
fi
# --- Apache MPM parameters (skipped for fpm_only) ---
if [ "$CONTAINER_ROLE" != "fpm_only" ]; then
# ServerLimit: roughly 1 process per ~25 workers, floor 2, cap 16
calc_server_limit=$((APACHE_BUDGET_MB / 30))
if [ "$calc_server_limit" -lt 2 ]; then
calc_server_limit=2
fi
if [ "$calc_server_limit" -gt 16 ]; then
calc_server_limit=16
fi
# MaxRequestWorkers: ServerLimit * ThreadsPerChild (25)
calc_max_request_workers=$((calc_server_limit * 25))
if [ "$calc_max_request_workers" -gt 400 ]; then
calc_max_request_workers=400
fi
# StartServers: 1 for ≤1GB, 2 for larger
calc_start_servers=1
if [ "$CONTAINER_MEMORY_MB" -gt 1024 ]; then
calc_start_servers=2
fi
APACHE_START_SERVERS=${APACHE_START_SERVERS:-$calc_start_servers}
APACHE_SERVER_LIMIT=${APACHE_SERVER_LIMIT:-$calc_server_limit}
APACHE_MAX_REQUEST_WORKERS=${APACHE_MAX_REQUEST_WORKERS:-$calc_max_request_workers}
APACHE_MIN_SPARE_THREADS=${APACHE_MIN_SPARE_THREADS:-5}
APACHE_MAX_SPARE_THREADS=${APACHE_MAX_SPARE_THREADS:-15}
APACHE_MAX_CONNECTIONS_PER_CHILD=${APACHE_MAX_CONNECTIONS_PER_CHILD:-3000}
fi
# --- Export all variables ---
export CONTAINER_ROLE CONTAINER_MEMORY_MB
if [ "$CONTAINER_ROLE" != "httpd_only" ]; then
export PHP_FPM_PM PHP_FPM_MAX_CHILDREN PHP_FPM_PROCESS_IDLE_TIMEOUT PHP_FPM_MAX_REQUESTS
export PHP_FPM_START_SERVERS PHP_FPM_MIN_SPARE PHP_FPM_MAX_SPARE
fi
if [ "$CONTAINER_ROLE" != "fpm_only" ]; then
export APACHE_START_SERVERS APACHE_SERVER_LIMIT APACHE_MAX_REQUEST_WORKERS
export APACHE_MIN_SPARE_THREADS APACHE_MAX_SPARE_THREADS APACHE_MAX_CONNECTIONS_PER_CHILD
fi
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
if [ -z "$PHPVER" ]; then
PHPVER="83";
fi
if [ -z "$environment" ]; then
environment="PROD"
fi
# Default to FPM-only role
export CONTAINER_ROLE="fpm_only"
export FPM_LISTEN=${FPM_LISTEN:-9000}
adduser -u $uid $user
mkdir -p /home/$user/public_html
mkdir -p /home/$user/logs/php-fpm
ln -sf /home/$user/logs/php-fpm /var/log/php-fpm
source /scripts/detect-memory.sh
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_children=${PHP_FPM_MAX_CHILDREN} | Listen=${FPM_LISTEN}"
/scripts/create-php-config.sh
mkdir -p /run/php-fpm/
/usr/sbin/php-fpm -y /etc/php-fpm.conf
chown -R $user:$user /home/$user
chmod -R 755 /home/$user
if [[ $environment == 'DEV' ]]; then
echo "Starting Dev Deployment (FPM-only mode)"
mkdir -p /home/$user/_db_backups
if ! command -v microdnf &> /dev/null; then
echo "microdnf not found, installing with dnf..."
dnf install -y microdnf && dnf clean all
fi
microdnf install -y MariaDB-server MariaDB-client memcached
sed -r -i 's/session.save_path="memcache:11211/session.save_path="localhost:11211/' /etc/php.ini
nohup mysqld -umysql &
if [ ! -f /home/$user/mysql_creds ]; then
echo "Give MySQL a chance to finish starting..."
sleep 10
mysql_user=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')
mysql_password=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 18 ; echo '')
mysql_db=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 6 ; echo '')
mysql -e "CREATE DATABASE devdb_"$mysql_db";"
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
mysql -e "FLUSH PRIVILEGES;"
echo "# User crontab for $user" > /home/$user/crontab
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
chown $user:$user /home/$user/crontab
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
cat /home/$user/mysql_creds
fi
/usr/bin/memcached -d -u $user
fi
if [[ $environment == 'PROD' ]]; then
if [ -f /etc/php.d/50-memcached.ini ]; then
sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
fi
fi
# Set up user crontab
if [ ! -f /home/$user/crontab ]; then
echo "# User crontab for $user" > /home/$user/crontab
echo "# Add your cron jobs here" >> /home/$user/crontab
echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
chown $user:$user /home/$user/crontab
fi
# Load user crontab
crontab -u $user /home/$user/crontab
/usr/sbin/crond
# Tail PHP-FPM logs (becomes PID 1 process)
touch /home/$user/logs/php-fpm/error.log
tail -f /home/$user/logs/php-fpm/*
exit 0
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env bash
## entrypoint-litespeed.sh — PID 1 for cac-litespeed:phpNN.
## Built on litespeedtech/openlitespeed:1.8.x-lsphp83 prebuilt base. Native
## LSAPI (no FPM proxy), one customer per container.
##
## Process supervision: starts OLS via `openlitespeed -n` (no-daemon +
## crash-guard, per OLS source: lshttpdmain.cpp). SIGTERM is forwarded.
## crond runs in the background for customer crontabs; OLS itself is the
## process we wait on (if OLS dies, the container exits and Docker
## restarts it per its restart policy).
set -euo pipefail
: "${PHPVER:=83}"
: "${environment:=PROD}"
: "${LSCACHE_AUTOINSTALL:=1}"
export CONTAINER_ROLE="litespeed_only"
export PHPVER environment LSCACHE_AUTOINSTALL
## ---- env validation ----
if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then
echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2
exit 1
fi
: "${domain:=localhost}"
export user domain
## ---- user + directories ----
if ! id -u "$user" >/dev/null 2>&1; then
## Ubuntu's useradd; mirror what the AL10 entrypoints do with adduser
useradd -u "$uid" -m -s /bin/bash "$user"
fi
mkdir -p "/home/$user/public_html"
## Log dirs mirror cac:phpNN exactly — apache/ for web server access+error,
## php-fpm/ for PHP errors. OLS isn't Apache and lsphp isn't php-fpm, but
## the customer-facing paths stay identical so log-gathering, analytics,
## and the customer's "where do I find my access log?" mental model all
## just work without per-image-family special cases.
mkdir -p "/home/$user/logs/apache" "/home/$user/logs/php-fpm"
mkdir -p "/home/$user/lscache"
mkdir -p /tmp/lshttpd/swap
chmod 1777 /tmp/lshttpd
## ---- memory + lsphp pool sizing ----
# shellcheck source=/dev/null
source /scripts/detect-memory-litespeed.sh
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | LSAPI_CHILDREN=${LSAPI_CHILDREN} | PHPVER=${PHPVER}"
## ---- self-signed cert (idempotent) ----
mkdir -p /usr/local/lsws/conf/cert
if [ ! -f /usr/local/lsws/conf/cert/self.crt ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /usr/local/lsws/conf/cert/self.key \
-out /usr/local/lsws/conf/cert/self.crt \
-subj "/CN=${domain}" 2>/dev/null
fi
## ---- render httpd_config + vhconf from templates ----
/scripts/create-vhost-litespeed.sh
## ---- point PHP error_log at the same customer-visible path that
## cac:phpNN uses for php-fpm errors. Drop-in compat: customer code that
## was tailing /home/$user/logs/php-fpm/error.log on the old image will
## see lsphp's PHP errors in the exact same file on the new image.
## Rendered as a tiny ini in lsphp's scan dir; PHP merges it after the
## production-tuning overrides at startup.
## Captured, then matched in the shell. As a single pipeline this was
## `lsphp -i | awk '…{print;exit}'`: awk stops at the "Scan this dir" line,
## which is near the top of the output, so lsphp can still be writing when awk
## closes the pipe. lsphp then dies 141, `set -o pipefail` (line 12) makes that
## the pipeline's status, and because this is a BARE ASSIGNMENT `set -e` KILLS
## PID 1 — the container never starts. (Its twin in entrypoint-lsphp.sh chose a
## degraded fallback instead; this one just exits.) That is a race on whether
## the reader closes before the writer's last write() returns, not a function of
## how big the output is: see the long note over the probe helpers in
## entrypoint-lsphp.sh for the measurements. The rule is simply that no
## `writer | early-exiting-reader` belongs in a pipefail script.
##
## A here-string would remove the pipeline, but bash spills a here-string to
## /tmp/sh-thd.XXXXXX above a build-dependent size (65536 for the bash 5.2.21 in
## this image, between 4096 and 16384 for Debian's 5.2.15) — and on this line,
## a bare assignment, a temp file it cannot create is again `set -e` killing
## PID 1: `docker run --read-only` reproduces exactly that. So the extraction is
## done with parameter expansion, which allocates nothing.
##
## Same answer as the awk it replaces: first line starting "Scan this dir", then
## the text between the FIRST and SECOND '=> ' on it (awk's $2 under -F'=> '),
## empty if the line carries no separator, empty if there is no such line.
## `|| true` on the capture keeps a genuinely failing lsphp as an empty
## SCAN_DIR — which the `-n` test below already handles — not a boot failure.
LSPHP_INFO=$(/usr/local/lsws/lsphp"${PHPVER}"/bin/lsphp -i 2>/dev/null || true)
SCAN_DIR=""
## The leading newline is what makes a match on LINE 1 behave like every other
## line, exactly as awk's `^` anchor does.
scan_rest=$'\n'"$LSPHP_INFO"
if [[ $scan_rest == *$'\nScan this dir'* ]]; then
## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`.
scan_rest=${scan_rest#*$'\nScan this dir'}
scan_line="Scan this dir${scan_rest%%$'\n'*}"
if [[ $scan_line == *'=> '* ]]; then
SCAN_DIR=${scan_line#*'=> '}
SCAN_DIR=${SCAN_DIR%%'=> '*}
fi
unset scan_line
fi
unset scan_rest
if [ -n "$SCAN_DIR" ]; then
cat > "$SCAN_DIR/99-user-error-log.ini" <<EOF
; rendered at container start by entrypoint-litespeed.sh
error_log = /home/${user}/logs/php-fpm/error.log
log_errors = On
EOF
## Per-site opcache override (panel: Advanced Tuning → OpCache size).
## Falls back to the global lsphp-overrides.ini values (64 MB / 8000 files)
## when the env vars aren't set. Numeric range/sanity is enforced in the
## WHP panel before the env var lands here.
if [ -n "${OPCACHE_MEMORY_MB:-}" ] || [ -n "${OPCACHE_MAX_FILES:-}" ]; then
{
echo "; rendered at container start by entrypoint-litespeed.sh"
echo "; per-site override from WHP whp.sites.opcache_*_override"
[ -n "${OPCACHE_MEMORY_MB:-}" ] && echo "opcache.memory_consumption = ${OPCACHE_MEMORY_MB}"
[ -n "${OPCACHE_MAX_FILES:-}" ] && echo "opcache.max_accelerated_files = ${OPCACHE_MAX_FILES}"
} > "$SCAN_DIR/99-user-opcache.ini"
fi
fi
## ---- ownership: OLS runs as $user end-to-end (server-level user set by
## create-vhost-litespeed.sh, no setUIDMode). So OLS runtime dirs need to
## be customer-owned for log writes, swap files, lsphp socket creation.
## Master still starts as root for port binding, then drops privs to $user.
chown -R "$user:$user" /usr/local/lsws/logs /usr/local/lsws/conf/cert /tmp/lshttpd 2>/dev/null || true
chown -R "$user:$user" "/home/$user"
chmod 755 "/home/$user"
## ---- drop healthz so docker HEALTHCHECK passes before customer files
## Always rewrite as customer; suexec lsphp will read it as that uid too.
sudo -u "$user" sh -c "echo ok > /home/$user/public_html/healthz"
## ---- DEV: local mariadb + memcached for parity with cac entrypoints ----
if [ "$environment" = "DEV" ]; then
echo "Starting Dev Deployment (litespeed)"
mkdir -p "/home/$user/_db_backups"
## mariadb-server + memcached are NOT baked into the image (saves ~500MB
## on PROD pulls). Install them at runtime, but only once per container —
## the command -v guard means a restart of an already-bootstrapped
## container skips the apt step and DEV boot stays ~1.5s like PROD.
## First-boot in DEV adds ~30-60s for the apt install; acceptable
## tradeoff per the design spec.
if ! command -v mysqld >/dev/null 2>&1; then
echo "DEV first boot: installing mariadb-server + memcached..."
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
mariadb-server memcached
apt-get clean
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
fi
mkdir -p /run/mysqld && chown mysql:mysql /run/mysqld
nohup mysqld --user=mysql &>/dev/null &
if [ ! -f "/home/$user/mysql_creds" ]; then
sleep 10
mysql_user=$(openssl rand -hex 7)
mysql_password=$(openssl rand -hex 12)
mysql_db="devdb_$(openssl rand -hex 3)"
mysql -e "CREATE DATABASE $mysql_db;"
mysql -e "CREATE USER '$mysql_user'@'localhost' IDENTIFIED BY '$mysql_password';"
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '$mysql_user'@'localhost' WITH GRANT OPTION;"
mysql -e "FLUSH PRIVILEGES;"
{
echo "MySQL User: $mysql_user"
echo "MySQL Password: $mysql_password"
echo "MySQL Database: $mysql_db"
} > "/home/$user/mysql_creds"
cat "/home/$user/mysql_creds"
fi
/usr/bin/memcached -d -u "$user"
fi
## ---- user crontab ----
if [ ! -f "/home/$user/crontab" ]; then
{
echo "# User crontab for $user"
echo "# Add your cron jobs here"
} > "/home/$user/crontab"
chown "$user:$user" "/home/$user/crontab"
fi
crontab -u "$user" "/home/$user/crontab"
service cron start >/dev/null 2>&1 || /usr/sbin/cron
## ---- LSCache plugin (background, non-fatal) ----
( /scripts/install-lscache-wp.sh "$user" >>/var/log/lscache-install.log 2>&1 || true ) &
## Stream OLS + customer logs to PID-1 stdout so `docker logs` works. Started
## once, before the supervisor loop — it follows the files across OLS restarts.
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
touch "/home/$user/logs/apache/error_log" "/home/$user/logs/apache/access_log"
touch "/home/$user/logs/php-fpm/error.log"
chown "$user:$user" "/home/$user/logs/apache/error_log" \
"/home/$user/logs/apache/access_log" \
"/home/$user/logs/php-fpm/error.log"
tail -F /usr/local/lsws/logs/error.log \
/usr/local/lsws/logs/access.log \
"/home/$user/logs/apache/error_log" \
"/home/$user/logs/apache/access_log" \
"/home/$user/logs/php-fpm/error.log" 2>/dev/null &
## ---- supervise OLS in DAEMON mode (NOT `openlitespeed -n` + wait) ----
## OLS performs INTERNAL graceful self-restarts: the LiteSpeed Cache /
## QUIC.cloud integration refreshes the QUIC.cloud IP allowlist on a schedule
## and, when it changes, sends SIGUSR1 → "request a graceful server restart".
## In `-n` foreground mode the OLD main PID exits after the zero-downtime
## handoff; a bare `wait` on that PID lets bash (PID 1) exit and tears the whole
## container down. Worse, that exit is *clean*, so `RestartPolicy` doesn't
## reliably catch it — the container just stops and HAProxy serves 503 until
## someone manually starts it. (Root-caused on whp02 alsacorp, 2026-06-06.)
##
## Daemon mode is OLS's native model: it owns the SIGUSR1 handoff, keeps the
## listeners bound across generations, and rewrites lshttpd.pid to the new main.
## PID 1 just FOLLOWS the pidfile — a graceful self-restart is invisible here
## (zero downtime), and we only ever relaunch on a genuine crash (no live main).
STOP_REQUESTED=0
term_handler() {
STOP_REQUESTED=1
/usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true
}
trap term_handler TERM INT
## Authoritative, path-independent liveness check: `lswsctrl status` prints
## "litespeed is running with PID N." when up (and "...is not running" when
## down). We match the running message specifically — a bare grep for "running"
## would also match "not running". (This image keeps the pidfile under
## /tmp/lshttpd, not logs/, so we never hard-code a pidfile path.)
##
## Read into a variable and match with a here-string rather than piping into
## `grep -qi`: `grep -q` closes the pipe on its first match, and under the
## `set -o pipefail` at the top of this file a writer that is still writing when
## that happens dies 141 and the pipeline reports FALSE — i.e. "OLS is down"
## precisely because the "running" line matched, which here means a spurious
## relaunch of a healthy OLS, five of which trip the crash-loop cap and exit
## PID 1. (Same defect that shipped in entrypoint-lsphp.sh's cac_path_parity
## probe.) The reason to change it is STRUCTURAL — a pipefail script must not
## pipe into an early-exit reader, whatever the payload — because "lswsctrl
## prints one short line so it always wins" is a size argument, and size
## arguments about this race are wrong: see the measurements over the probe
## helpers in entrypoint-lsphp.sh, where 41 KB SIGPIPEd 11% of the time into a
## 64 KB pipe. A non-zero `lswsctrl` still means "not running", exactly as
## pipefail made it mean before.
##
## A here-string is the right shape HERE, where those helpers use `[[ ]]`: the
## reason to avoid `<<<` there is that bash spills a large here-string to
## /tmp/sh-thd.XXXXXX and so makes a writable temp dir a boot precondition. The
## threshold is 65536 bytes in this image's bash 5.2.21 and no lower than 4096
## in any bash this repo has met; `lswsctrl status` prints well under 100 bytes
## and cannot approach it, so no temp file is ever created and the case-
## insensitive match stays a plain `grep -i` instead of a hand-rolled glob.
ols_running() {
local st
st=$(/usr/local/lsws/bin/lswsctrl status 2>/dev/null) || return 1
grep -qi 'running with pid' <<<"$st"
}
## Crash-loop cap: if OLS can't stay up, bail out so Docker's restart policy and
## the site-health monitor escalate instead of us hot-looping forever.
MAX_STARTS=5
WINDOW=60
starts=""
start_ols() {
/usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true
## wait up to 10s for the daemon to report running
for _ in $(seq 1 20); do
ols_running && return 0
sleep 0.5
done
return 1
}
if ! start_ols; then
echo "entrypoint: OLS failed to start (not running after 10s)." >&2
exit 1
fi
echo "entrypoint: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)"
while true; do
if ols_running; then
sleep 3
continue
fi
## Not running this instant. This is EITHER a clean shutdown OR the brief
## handoff window of a graceful self-restart (status momentarily reports down
## while the new main takes over). Grace, then re-check before judging.
sleep 2
if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then
continue
fi
if [ "$STOP_REQUESTED" -eq 1 ]; then
echo "entrypoint: SIGTERM received, OLS stopped — exiting."
exit 0
fi
## Genuine crash: not running and no shutdown requested. Relaunch, capped.
now=$(date +%s)
starts="$starts $now"
pruned=""
for t in $starts; do
[ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t"
done
starts="$pruned"
n=$(echo $starts | wc -w)
echo "entrypoint: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2
if [ "$n" -ge "$MAX_STARTS" ]; then
echo "entrypoint: OLS crash-looping ($n starts in ${WINDOW}s) — bailing out for Docker restart policy / monitor." >&2
exit 1
fi
start_ols || true
done
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env bash
## entrypoint-lsphp.sh — PID 1 for cac-lsphp:phpNN.
##
## The per-site PHP backend for the SHARED OpenLiteSpeed tier. Runs lsphp in
## DETACHED LSAPI mode (`lsphp -b <addr:port>`) and nothing else — no
## webserver. The shared-ols container connects to this over the docker
## network (extProcessor type lsapi, address <this-container>:9000) exactly
## like the shared httpd connects to a cac-fpm container's php-fpm on :9000.
##
## Structurally identical to cac-fpm/cac-litespeed: same `uid`/`user` contract,
## the customer docroot mounted at /home/$user (so PHP sees /home/$user/public_html
## EXACTLY like the standalone tiers — true 1:1 drop-in for WordPress ABSPATH,
## config paths, and DB-stored absolute paths). The only difference is OLS lives
## in a separate container, so this PID 1 is lsphp itself.
##
## THE SYMLINK (see feedback_ols_lsapi_no_script_filename_remap): OLS has no
## ProxyFCGISetEnvIf-style remap — it hands lsphp exactly its vhost docRoot path.
## The shared-ols container serves from its bulk /docker/users->/mnt/users mount,
## so its docRoot (and the SCRIPT_FILENAME it sends us) is
## /mnt/users/<user>/<domain>/public_html. We create a symlink
## /mnt/users/<user>/<domain> -> /home/$user so that path resolves to the real
## /home/$user/public_html files. PHP canonicalises the symlink, so
## __FILE__/__DIR__/realpath all report /home/$user/public_html (verified
## 2026-06-10) — the customer never sees the /mnt/users path.
##
## THE $_SERVER STRINGS: the symlink makes paths RESOLVE, but the raw strings OLS
## put in $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] still read /mnt/users.
## The cac_path_parity extension (baked into the image, configured per-site
## below) rewrites those two at request start, so a site moved from cac-fpm to
## cac-lsphp sees byte-identical values. It replaced an auto_prepend_file
## normaliser that any site's own .user.ini silently displaced — see
## ext/cac-path-parity/cac_path_parity.c.
set -euo pipefail
: "${PHPVER:=83}"
: "${environment:=PROD}"
export CONTAINER_ROLE="lsphp_only"
export PHPVER environment
## ---- env validation (same contract as entrypoint-fpm / entrypoint-litespeed) ----
if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then
echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2
exit 1
fi
: "${domain:=localhost}"
export user domain
LSPHP_BIN="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"
if [ ! -x "$LSPHP_BIN" ]; then
echo "FATAL: lsphp binary not found at $LSPHP_BIN (PHPVER=$PHPVER)." >&2
exit 1
fi
## ---- user + directories (identical to entrypoint-litespeed.sh: docroot at
## /home/$user, the customer's bind-mounted domain dir) ----
if ! id -u "$user" >/dev/null 2>&1; then
useradd -u "$uid" -m -s /bin/bash "$user"
fi
mkdir -p "/home/$user/public_html" "/home/$user/logs/php-fpm"
## ---- compatibility symlink for the OLS-sent path ----
## OLS sends SCRIPT_FILENAME under /mnt/users/<user>/<safe-domain>/public_html
## (the shared-ols container's view). Point that at our real /home/$user mount so
## the path resolves. <safe-domain> matches the on-disk convention: wildcard
## `*.foo.com` is stored as `wildcard.foo.com`.
SAFE_DOMAIN="$domain"
case "$domain" in
\*.*) SAFE_DOMAIN="wildcard.${domain#\*.}" ;;
esac
## Both of these get interpolated into generated php.ini fragments below. They
## are panel-validated and both already feed `ln -sfn` and the shared-ols vhost
## config, so a hostile value is not reachable today — this is the belt to that
## brace. A newline in $domain is an INI-DIRECTIVE INJECTION into the generated
## fragment (measured against the pre-fix script: domain=$'evil.com\nprecision =
## 7\n; ' put that directive in 99-cac-path-parity.ini and lsphp reported
## `precision => 7`); `$(...)` yields an ini parse error and `"` an empty value,
## and BOTH of those leave the
## path-parity extension INERT — the exact silent parity loss this whole change
## exists to eliminate. Quoting the emitted values (done below) neutralises
## newlines and quotes; it does NOT neutralise php.ini's own `${VAR}`
## interpolation, which is why the character class is checked as well.
INI_TOKENS_OK=yes
case "$user" in ''|*[!A-Za-z0-9._-]*) INI_TOKENS_OK=no ;; esac
case "$SAFE_DOMAIN" in ''|*[!A-Za-z0-9._-]*) INI_TOKENS_OK=no ;; esac
if [ "$INI_TOKENS_OK" != yes ]; then
echo "WARNING: entrypoint-lsphp: user/domain contain characters outside [A-Za-z0-9._-] — refusing to generate php.ini fragments from them, so the \$_SERVER path-parity mapping and the per-site error_log are BOTH skipped (the extension stays inert, log_errors stays On from the image defaults and PHP logs to stderr i.e. \`docker logs\`; requests are unaffected). user=$(printf '%q' "$user") domain=$(printf '%q' "$domain")" >&2
fi
## The exact path prefix the shared-ols container serves this site from — the
## string OLS puts in SCRIPT_FILENAME/DOCUMENT_ROOT. Used twice: for the symlink
## that makes it RESOLVE, and for the cac_path_parity mapping that makes it READ
## like cac-fpm. Deriving both from one variable keeps them in lockstep.
OLS_SITE_PATH="/mnt/users/$user/$SAFE_DOMAIN"
mkdir -p "/mnt/users/$user"
ln -sfn "/home/$user" "$OLS_SITE_PATH"
## ---- detached-lsphp pool sizing ----
# shellcheck source=/dev/null
source /scripts/detect-memory-lsphp.sh
## LSAPI tuning (spec §5.1). PHP_LSAPI_CHILDREN MUST equal the shared-ols vhost
## maxConns — the WHP panel writes both from the single fpm_max_children value,
## so they can't drift. LSAPI_MAX_IDLE is THE RAM win: idle children exit, so an
## idle site's footprint collapses toward baseline (ondemand-like).
export PHP_LSAPI_CHILDREN="${PHP_LSAPI_CHILDREN:-$LSAPI_CHILDREN}"
export PHP_LSAPI_MAX_REQUESTS="${PHP_LSAPI_MAX_REQUESTS:-500}"
export LSAPI_MAX_IDLE="${LSAPI_MAX_IDLE:-30}"
export LSAPI_EXTRA_CHILDREN="${LSAPI_EXTRA_CHILDREN:-5}"
export LSAPI_AVOID_FORK="${LSAPI_AVOID_FORK:-0}"
## LSAPI_KEEP_LISTEN=2 works around a leak in lsphp's own bookkeeping — not a
## setting we're tuning for taste. The master keeps a `busy` worker counter in
## a MAP_SHARED page it shares with its children; measured live on whp01,
## that counter drifts NEGATIVE over days of uptime (arclightcourt.com-01 was
## at busy=-8 after 6.9 days; a healthy sibling sat at 0..9). php-src
## sapi/litespeed/lsapilib.c computes each child's idle-exit grace period as
## `10 + busy*10` seconds (capped by LSAPI_MAX_IDLE) INSIDE
## `if (s_keep_listener == 1)` — with busy=-8 that's `wait_time = -70`, so
## workers exit after ~1s idle instead of 10-30s. No worker then lingers in
## accept(), so the master's "an idle worker is already accepting, don't
## fork" guard never fires and it forks for every single connection —
## observed slamming the hard child ceiling under bot traffic
## (`Reached max children process limit`) and, on rejection, leaving the
## pending connection to rot in the kernel backlog as a 503. Confirmed
## asymmetry: the affected site logged 306 OLS-side `ExtConn timed out` /
## deadlock / `oops! 503` errors where an identically-configured healthy
## sibling logged 0. Restarting the container resets the counter to 0 (it's
## initialised at master start) but it drifts negative again over about a
## week — a reset, not a cure. LSAPI_KEEP_LISTEN=2 skips the `== 1` branch
## entirely, so idle-exit timing is never derived from the leaked counter and
## instead falls straight back to LSAPI_MAX_IDLE above. The is_enough_free_mem()
## memory guard sits immediately above that branch in lsapilib.c and is NOT
## part of it, so it still applies at =2 — this does not trade away the
## memory-pressure protection LSAPI_MAX_IDLE exists for. Still overridable
## (e.g. back to 1) per-container as an escape hatch.
export LSAPI_KEEP_LISTEN="${LSAPI_KEEP_LISTEN:-2}"
LSPHP_BIND="${LSPHP_BIND:-0.0.0.0:9000}"
## ---- .user.ini support ----
## 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 in PHP_MINIT_FUNCTION(litespeed) when the PROCESS ENV contains
## LSPHP_ENABLE_USER_INI=on. Without it, lsphp never enters the user-ini chain
## at all — and does so SILENTLY, because `user_ini.filename` / `user_ini.cache_ttl`
## still report their core defaults in phpinfo(). Every other WHP PHP tier
## (cac, cac-fpm, cac-litespeed) honors .user.ini, so leaving it off here made
## the shared-ols tier quietly inconsistent: customer memory_limit /
## max_input_vars overrides were ignored, and — the reason this was found —
## Wordfence's `auto_prepend_file` WAF never loaded on ANY shared-ols site.
##
## Exported here rather than relying solely on the Dockerfile ENV because the
## runuser fallback below resets the environment; an export survives all three
## exec paths. Still overridable per-container (set LSPHP_ENABLE_USER_INI=off in
## the site's env) as an escape hatch for a site whose legacy cPanel-generated
## .user.ini has not been remediated yet.
export LSPHP_ENABLE_USER_INI="${LSPHP_ENABLE_USER_INI:-on}"
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP_LSAPI_CHILDREN=${PHP_LSAPI_CHILDREN} | LSAPI_MAX_IDLE=${LSAPI_MAX_IDLE} | LSAPI_KEEP_LISTEN=${LSAPI_KEEP_LISTEN} | PHPVER=${PHPVER} | bind=${LSPHP_BIND} | user_ini=${LSPHP_ENABLE_USER_INI}"
## Validate a numeric value destined for a generated php.ini fragment.
## Sets INI_NUM to the value when it is acceptable, and to "" (plus a WARNING)
## when it is not. Never fatal: a rejected override just leaves the image
## default in place, and the site serves either way.
##
## Digits-only is what closes the injection: no newline, quote, `$` or `{` can
## survive it, so neither an ini-directive injection nor php.ini's `${VAR}`
## interpolation is reachable regardless of what the caller sent.
##
## The range bound is a separate, weaker concern: it is a sanity check, NOT a
## guarantee that the value works. Measured — with `99-prod-overrides.ini`
## setting `opcache.interned_strings_buffer = 16`, a memory_consumption of 8 or
## 16 is ACCEPTED here and still aborts opcache at startup ("Insufficient shared
## memory for interned strings buffer"), loading no opcache at all. The floor is
## not raised to cover that because doing so would forfeit the superset property
## below; the panel clamps at 32, well clear of it.
validate_ini_num() {
local name="$1" val="$2" min="$3" max="$4"
INI_NUM=""
case "$val" in
''|*[!0-9]*) ;;
*)
## Length-cap first: `[ -lt ]` on a 25-digit string is an arithmetic
## error, not a comparison. 7 digits covers every max below.
if [ "${#val}" -le 7 ] && [ "$val" -ge "$min" ] && [ "$val" -le "$max" ]; then
INI_NUM="$val"
return 0
fi
;;
esac
echo "WARNING: entrypoint-lsphp: ${name}=$(printf '%q' "$val") is not a plain integer in ${min}-${max} — ignoring it; the image default from 99-prod-overrides.ini applies." >&2
return 0
}
## ---- per-site ini drop-ins (identical mechanism to entrypoint-litespeed.sh) ----
## error_log → the same customer-visible path cac:phpNN / cac-litespeed use, so
## "where's my PHP error log?" is answered identically across all site types.
## Capture lsphp's own info once and read both answers out of it. Probe with
## `-i` ONLY: lsphp is the LSAPI SAPI, not the CLI — it accepts just
## -[b|c|n|h|i|q|s|v|?] and answers `-m`/`-r` by printing usage and exiting 0, so
## a `lsphp -m | grep` test never matches and never errors either.
##
## ---- CAC-TEST: probe helpers BEGIN ----
## Everything between these two markers is extracted verbatim and executed by
## scripts/tests/lsphp-info-probe.test.sh — the markers are inert comments with
## no runtime effect, and they exist so the test exercises THE SHIPPED CODE
## rather than a copy of it that can drift away from it. Keep BOTH markers: the
## extractor refuses to emit anything unless it sees the END one, so a half-
## deleted pair is reported there as a marker error instead of silently
## sourcing the rest of this file.
##
## WHY THESE MATCH `$1` IN THE SHELL AND NEVER PIPE IT INTO A READER.
##
## What broke. Both probes used to be `printf '%s\n' "$LSPHP_INFO" | <reader>`,
## and both readers stop early — `grep -q` at its first match, `awk` at `exit`.
## When the reader closes the pipe with the writer still writing, the writer
## takes SIGPIPE and dies 141; `set -o pipefail` (line 34) adopts 141 as the
## PIPELINE's status; and the branch reads FALSE **because the thing it was
## looking for was present early enough to stop the reader**. Measured on whp02
## against the published cac-lsphp:php83: 5/5 runs status=141 with pipefail,
## 0 without.
##
## THE RULE, and it is not about size. ANY `writer | early-exiting-reader`
## under pipefail is a latent 141. PAYLOAD SIZE IS NOT A SAFETY ARGUMENT. Each
## run is decided by a race — whether the reader's close lands before the
## writer's final write() returns — and the payload only sets how many write()
## syscalls the writer has to lose. Measured here against a default
## 65536-byte pipe (confirmed with F_GETPIPE_SZ):
## 41144 bytes -> 141 in 32/300 runs (11%) — well UNDER capacity
## 65012 bytes -> 141 in 25/30 runs — not 100% even AT capacity
## 500 KB into a 1 MiB pipe -> 200/200 SIGPIPE written 4096 bytes at a
## time, 0/200 written as one 500 KB write
## and strace caught printf dying having written 12086 of 40406 bytes into a
## 65536-byte pipe, i.e. losing with 53 KB of room to spare. The reason the same
## image failed 5/5 on whp02 and 10/10 clean in a dev container is the WRITER's
## syscall size: bash <= 5.2.15 pushes ~37 KB per write, bash >= 5.2.21 pushes
## 80-160 bytes, so the newer shell needs hundreds of chances to lose the race
## and the older one needs a couple. (An earlier draft of this comment blamed
## pipe capacity and fs.pipe-user-pages-soft. Both were wrong: that soft limit
## clamps new pipes to two pages rather than one, applies only once a single uid
## holds more than 1024 pipes, and is skipped entirely for CAP_SYS_RESOURCE.)
##
## The only SOUND reasons a call site is safe are structural:
## * the file does not set pipefail; or
## * the reader provably consumes to EOF (no `q`, `-q`, `-l`, `-m`, `exit`,
## `break`); or
## * the pipeline's status is discarded.
## The reader's implementation is not a defence either: at 248 KB, mawk, gawk,
## `grep -q` and `head -1` each returned 141 on 10/10, and these images have
## already drifted between mawk 1.3.4 (cac-lsphp) and gawk 5.2.1
## (cac-litespeed:php83) — not a property this repo controls.
##
## WHY PURE-BASH MATCHING RATHER THAN A HERE-STRING. `<<<` does remove the
## pipeline, but it is not free: above a build-dependent size bash materialises
## the string as /tmp/sh-thd.XXXXXX, so it makes a writable temp dir a
## PRECONDITION OF BOOTING. Measured in this image (bash 5.2.21) the switch is
## at exactly 65536 bytes and `lsphp -i` is 39934, so the here-string form was
## not hitting disk here — but Debian's bash 5.2.15 switches somewhere between
## 4096 and 16384, where the same payload would. What that costs is not
## theoretical:
## docker run --read-only ... 'SCAN_DIR=$(awk ... <<<"$BIG")'
## -> bash: cannot create temp file for here-document: Read-only file
## system ... and the script is dead: exit 1, PID 1 gone.
## which is the exact boot failure this branch exists to remove, re-acquired
## from a different direction and gated on which bash the base image ships.
## `[[ ]]` and `${...}` allocate nothing and cannot fail that way. Where the
## subject is a couple of hundred bytes and provably cannot approach the
## threshold, a here-string is still fine — see `ols_running` in
## entrypoint-litespeed.sh, which says so at the call site.
##
## THE PATTERNS ARE THE OLD ONES RE-EXPRESSED, NOT APPROXIMATED.
## grep -q '^cac_path_parity support => enabled$' — an anchored whole-line
## match, so the subject is wrapped in a newline at BOTH ends and the glob
## matches \n<line>\n; the wrapping is what keeps the first line and an
## unterminated last line matching exactly as grep matched them.
## grep -q '^PHP Version => ' — anchored at the start
## only, so only a leading newline is added.
## awk -F'=> ' '/^Scan this dir/ {print $2; exit}' — first matching line,
## then the text between the FIRST and SECOND '=> ' on it ($2), or empty if
## there is no separator. `${x#*'=> '}` then `${y%%'=> '*}` is that, exactly.
## scripts/tests/lsphp-info-probe.test.sh asserts this equivalence against the
## grep/awk originals over the edge cases (match on the first line, on the last
## line with no trailing newline, decoy substrings, a second separator, an empty
## value, a missing key), so "same answer as before" is checked, not asserted.
##
## Statuses are unchanged: a genuinely-absent extension is still a clean 1, and
## a genuinely-missing "Scan this dir" line is still empty output with status 0.
lsphp_info_has_parity_ext() {
[[ $'\n'"$1"$'\n' == *$'\ncac_path_parity support => enabled\n'* ]]
}
lsphp_info_scan_dir() {
local rest line val
rest=$'\n'"$1"
[[ $rest == *$'\nScan this dir'* ]] || return 0
## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`.
rest=${rest#*$'\nScan this dir'}
line="Scan this dir${rest%%$'\n'*}"
val=""
if [[ $line == *'=> '* ]]; then
val=${line#*'=> '}
val=${val%%'=> '*}
fi
printf '%s\n' "$val"
}
## Did `lsphp -i` answer at all? Separates "the extension is not there" from
## "our probe produced nothing to look in", so neither gets reported as the
## other. Keyed on the phpinfo banner, which is line 2 of every `lsphp -i`
## (verified against lsphp83 8.3.32) and is not something LSPHP_INFO could
## contain from any other source.
lsphp_info_is_usable() {
[[ $'\n'"$1" == *$'\nPHP Version => '* ]]
}
## ---- CAC-TEST: probe helpers END ----
PATH_PARITY_MODE="none"
LSPHP_INFO=$("$LSPHP_BIN" -i 2>/dev/null || true)
SCAN_DIR=$(lsphp_info_scan_dir "$LSPHP_INFO")
## `|| true` above is what keeps a broken probe survivable: fail-open is
## deliberate here and below — the site serves either way, only the $_SERVER
## strings differ. What the failure gets REPORTED as is handled at each of the
## two places it changes the outcome (the parity branch, and the no-scan-dir
## else at the bottom of this block).
if [ -n "$SCAN_DIR" ]; then
mkdir -p "$SCAN_DIR"
## Values emitted double-quoted via printf rather than interpolated into an
## unquoted heredoc — see the INI_TOKENS_OK note above for what that prevents.
##
## Gated on INI_TOKENS_OK for the same reason the mapping below is: this
## fragment interpolates $user into generated ini too. Leaving it ungated was
## an INCONSISTENCY, not a live hole — a newline is inert inside the quotes,
## and a `${`-bearing $user cannot exist because the useradd above would have
## failed under `set -euo pipefail`. But "this particular unvetted value
## happens to be contained" is the reasoning this branch already rejected one
## screenful up, so it is not the reasoning that guards this line either.
##
## Rejecting costs such a 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. No
## legitimate user reaches this branch (verified fleet-wide: 30 shared_ols
## sites across 4 hosts, none rejected by the charset check).
if [ "$INI_TOKENS_OK" = yes ]; then
{
echo '; rendered at container start by entrypoint-lsphp.sh'
printf 'error_log = "%s"\n' "/home/$user/logs/php-fpm/error.log"
echo 'log_errors = On'
} > "$SCAN_DIR/99-user-error-log.ini"
else
## The container filesystem survives `docker restart`, so a fragment an
## earlier boot wrote from a different env must not outlive the rejection.
rm -f "$SCAN_DIR/99-user-error-log.ini"
fi
## ---- $_SERVER path parity with cac-fpm ----
## Point the cac_path_parity extension at THIS site's mapping. Same two
## values the compatibility symlink above is built from, so the rewrite and
## the symlink can never disagree.
##
## Both settings are PHP_INI_SYSTEM: a customer's .user.ini (PHP_INI_PERDIR /
## PHP_INI_USER only) cannot redirect or disable them, and the extension
## occupies no userland hook — so the customer's own auto_prepend_file (the
## Wordfence WAF on several live sites) keeps working untouched. That
## combination is why this is an extension: the previous auto_prepend_file
## normaliser was itself PHP_INI_PERDIR and any site with its own prepend
## silently displaced it, while making OUR prepend win would have disabled
## THEIRS. See ext/cac-path-parity/cac_path_parity.c.
if [ "$INI_TOKENS_OK" != yes ]; then
## Already warned above. Write NOTHING: neither the mapping (we will not
## generate ini from an unvetted string) nor the auto_prepend fallback (which
## would not be correct for such a site either). The extension stays inert,
## the request path is unaffected.
rm -f "$SCAN_DIR/99-cac-path-parity.ini" "$SCAN_DIR/99-cac-lsphp-normalize.ini"
PATH_PARITY_MODE="none (user/domain rejected)"
elif lsphp_info_has_parity_ext "$LSPHP_INFO"; then
{
echo '; rendered at container start by entrypoint-lsphp.sh'
printf 'cac_path_parity.from = "%s"\n' "$OLS_SITE_PATH"
printf 'cac_path_parity.to = "%s"\n' "/home/$user"
} > "$SCAN_DIR/99-cac-path-parity.ini"
## Drop the pre-extension fallback if an older image left one here — the
## container filesystem survives a "docker restart", so an in-place upgrade
## must not keep a stale auto_prepend pointing at the old normaliser.
rm -f "$SCAN_DIR/99-cac-lsphp-normalize.ini"
PATH_PARITY_MODE="extension"
else
## Degraded fallback for an image built before the extension existed (or one
## where it failed to load). Restores the old, .user.ini-defeatable
## behaviour rather than losing normalisation entirely — but say so loudly,
## because in this mode parity is NOT guaranteed.
##
## FAIL-OPEN, DELIBERATELY: a probe that cannot answer must never stop the
## container. The site serves either way; only the $_SERVER strings differ.
cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF'
; rendered at container start by entrypoint-lsphp.sh (DEGRADED FALLBACK)
auto_prepend_file = /scripts/cac-lsphp-normalize.php
EOF
## ...but do not DIAGNOSE more than was established. The old wording said
## "extension not loadable in this image" for EVERY reason this branch is
## reached — including the probe breaking on its own, which is exactly what
## happened (see the SIGPIPE note on the helpers above): a false verdict
## that sent operators to rebuild an image whose extension was fine and
## whose build gate had passed. The claim now carries its evidence, and the
## evidence is real: reaching here at all means SCAN_DIR was parsed out of
## this same output, so `lsphp -i` did answer and its module list is
## authoritative. The case where it did NOT answer never gets here — it is
## caught and reported honestly at the `lsphp_info_is_usable` check above.
PATH_PARITY_MODE="auto_prepend (DEGRADED)"
echo "WARNING: entrypoint-lsphp: cac_path_parity extension not loadable in this image — '${LSPHP_BIN} -i' answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it — falling back to the auto_prepend normaliser, which a site's own .user.ini auto_prepend_file will silently displace. Rebuild/repull cac-lsphp:php${PHPVER}." >&2
fi
## Per-site opcache override (panel: Advanced Tuning → OpCache size); falls
## back to the baked lsphp-overrides.ini defaults when unset.
##
## SAME INJECTION CLASS AS THE MAPPING ABOVE, and it was left open when that
## one was closed. These two lines interpolated the raw env into an UNQUOTED
## `echo`, so (measured against the pre-fix script on this branch's image)
## OPCACHE_MEMORY_MB=$'128\nprecision = 7\n; ' put `precision = 7` into
## 99-user-opcache.ini and lsphp duly reported `precision => 7`.
##
## WHP does cast (int) and clamp these before setting the env
## (web-files/libs/site-pool-env.php: 32-512 MB, 2000-32000 files) — but
## "the panel validates it" is exactly the argument this branch rejected for
## `domain`, and the panel is a different repo on a different release cadence.
## Validate at the point of use, where the ini is actually generated.
##
## The accepted ranges below are deliberately a strict SUPERSET of the panel's
## clamps (32-512 and 2000-32000), so widening a panel clamp later can never
## start silently rejecting real sites here.
##
## Provenance, stated honestly: max_accelerated_files [200, 1000000] IS PHP's
## own clamp. For memory_consumption, 8 is PHP's documented floor but 4096 is
## OURS — PHP imposes no upper bound on that directive. It is a typo guard, not
## a vendor limit. An out-of-range value is not merely ignored: PHP resets the
## directive to its COMPILED default, discarding the image's own
## `99-prod-overrides` value, which is a further reason to reject rather than
## pass such a value through.
OPCACHE_LINES=()
if [ -n "${OPCACHE_MEMORY_MB:-}" ]; then
validate_ini_num OPCACHE_MEMORY_MB "$OPCACHE_MEMORY_MB" 8 4096
if [ -n "$INI_NUM" ]; then
OPCACHE_LINES+=("$(printf 'opcache.memory_consumption = "%s"' "$INI_NUM")")
fi
fi
if [ -n "${OPCACHE_MAX_FILES:-}" ]; then
validate_ini_num OPCACHE_MAX_FILES "$OPCACHE_MAX_FILES" 200 1000000
if [ -n "$INI_NUM" ]; then
OPCACHE_LINES+=("$(printf 'opcache.max_accelerated_files = "%s"' "$INI_NUM")")
fi
fi
if [ "${#OPCACHE_LINES[@]}" -gt 0 ]; then
{
echo "; rendered at container start by entrypoint-lsphp.sh"
echo "; per-site override from WHP whp.sites.opcache_*_override"
printf '%s\n' "${OPCACHE_LINES[@]}"
} > "$SCAN_DIR/99-user-opcache.ini"
else
## Nothing valid to say. Remove rather than leave whatever a previous boot
## wrote. Defensive only — do not read this as fixing a reachable bug: the
## writable layer does outlive a `docker restart`, but so does the
## environment, and changing these vars requires a RECREATE, which starts
## from a fresh layer with no stale fragment. Kept because it is free, and
## because it makes "no valid override" mean the same thing on every boot
## regardless of how the container got here.
rm -f "$SCAN_DIR/99-user-opcache.ini"
fi
else
## No scan dir means none of the per-site ini drop-ins land — including the
## path-parity mapping. Previously this failed silently; it must not, because
## the tier's cac-fpm parity guarantee is one of the things lost.
##
## Two different things land here and they are not the same report. "lsphp
## reports no additional-ini scan dir" ASSERTS that lsphp answered us, which
## is false when the probe produced nothing at all — and that was the wrong
## half of the same mistake the parity branch above made: describing a probe
## that could not answer as a finding about the image. Say which one it was.
if lsphp_info_is_usable "$LSPHP_INFO"; then
echo "WARNING: entrypoint-lsphp: lsphp reports no additional-ini scan dir — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied." >&2
PATH_PARITY_MODE="none (no scan dir)"
else
echo "WARNING: entrypoint-lsphp: '${LSPHP_BIN} -i' produced no usable phpinfo output (${#LSPHP_INFO} bytes) — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied. This is a PROBE failure and establishes nothing about what the image contains; run '${LSPHP_BIN} -i' in this container before concluding anything about it." >&2
PATH_PARITY_MODE="none (lsphp -i unusable)"
fi
fi
echo "entrypoint-lsphp: \$_SERVER path parity = ${PATH_PARITY_MODE} (${OLS_SITE_PATH} -> /home/${user})"
## ---- ownership ----
## Ensure the dirs we created + the log file are customer-owned so lsphp (running
## as $user) can read code and write logs. Customer content is already
## customer-owned from the host side, so we don't recurse the whole (potentially
## large) tree on every boot.
touch "/home/$user/logs/php-fpm/error.log"
chown "$uid:$uid" "/home/$user" "/home/$user/public_html" "/home/$user/logs" "/home/$user/logs/php-fpm" "/home/$user/logs/php-fpm/error.log" 2>/dev/null || true
## ---- exec lsphp -b as the customer user (PID 1) ----
## Bind port is unprivileged (9000), so no root port-bind step is needed — start
## directly as $user. Prefer setpriv (util-linux, on the Ubuntu base); fall back
## to runuser. exec so lsphp becomes PID 1 and receives Docker's signals
## directly (clean stop/restart, matches the php-fpm container's lifecycle).
echo "entrypoint-lsphp: exec $LSPHP_BIN -b $LSPHP_BIND as $user (uid=$uid)"
if command -v setpriv >/dev/null 2>&1; then
exec setpriv --reuid "$uid" --regid "$uid" --init-groups "$LSPHP_BIN" -b "$LSPHP_BIND"
elif command -v runuser >/dev/null 2>&1; then
exec runuser -u "$user" -- "$LSPHP_BIN" -b "$LSPHP_BIND"
else
exec sudo -u "$user" -E "$LSPHP_BIN" -b "$LSPHP_BIND"
fi
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
export CONTAINER_ROLE="httpd_only"
if [ -z "$environment" ]; then
environment="PROD"
fi
# Generate self-signed SSL cert if not already present
if [ ! -f /etc/pki/tls/certs/localhost.crt ]; then
openssl req -newkey rsa:2048 -nodes \
-keyout /etc/pki/tls/private/localhost.key \
-x509 -days 3650 -subj "/CN=localhost" \
-out /etc/pki/tls/certs/localhost.crt
fi
# Create log directory
mkdir -p /var/log/httpd
# Remove default configs that conflict
rm -f /etc/httpd/conf.d/userdir.conf
# Configure RemoteIP for Docker network
docker_network=$(ip addr show | grep eth0 | grep inet | awk -F " " '{print $2}')
if [ -n "$docker_network" ]; then
echo "RemoteIPInternalProxy $docker_network" >> /etc/httpd/conf.d/remoteip.conf
fi
# Detect memory and calculate Apache MPM tuning
source /scripts/detect-memory.sh
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | Apache workers=${APACHE_MAX_REQUEST_WORKERS} | Role=${CONTAINER_ROLE}"
# Generate MPM tuning config
/scripts/create-apache-mpm-config.sh
# Write SSL global config (matches standalone CAC behavior)
cat <<'EOF' > /etc/httpd/conf.d/ssl-global.conf
Listen 443 https
SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
SSLSessionCache shmcb:/run/httpd/sslcache(512000)
SSLSessionCacheTimeout 300
SSLCryptoDevice builtin
EOF
# Disable the default ssl.conf if present (we use per-vhost SSL)
if [ -f /etc/httpd/conf.d/ssl.conf ]; then
mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
fi
# Ensure vhosts directory exists and is included
mkdir -p /etc/httpd/conf.d/vhosts
if ! grep -q 'IncludeOptional conf.d/vhosts/' /etc/httpd/conf/httpd.conf; then
echo 'IncludeOptional conf.d/vhosts/*.conf' >> /etc/httpd/conf/httpd.conf
fi
# Start Apache
/usr/sbin/httpd -k start
# Start cron for log rotation
/usr/sbin/crond
# Tail Apache logs (becomes PID 1 process)
# Use a loop to pick up new log files as vhosts are added.
# tail -f only watches files that exist at start time.
touch /var/log/httpd/error_log
TAIL_PID=""
while true; do
LOG_FILES=$(find /var/log/httpd/ -name '*.log' -o -name '*_log' 2>/dev/null | sort)
if [ -n "$TAIL_PID" ]; then
kill "$TAIL_PID" 2>/dev/null
wait "$TAIL_PID" 2>/dev/null
fi
tail -f $LOG_FILES &
TAIL_PID=$!
# Re-check for new log files every 60 seconds
sleep 60
done
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
## entrypoint-shared-ols.sh — PID 1 for the shared-ols tier.
##
## One OpenLiteSpeed container fronting MANY tenants' detached cac-lsphp
## sidecars (the OLS analogue of the shared-httpd container). Webserver ONLY —
## it runs NO PHP locally (render-shared-ols-config.sh strips the stock local
## lsphp; every site's PHP goes to its own sidecar over LSAPI). HAProxy stays
## the TLS/WAF/SNI edge and routes OLS-type hostnames here on :443.
##
## Reuses cac-litespeed's hard-won DAEMON-MODE supervision (NOT `openlitespeed
## -n` + wait): OLS self-restarts on QUIC.cloud IP refresh would otherwise exit
## PID 1 cleanly and tear the container down. See entrypoint-litespeed.sh and
## feedback_ols_quiccloud_restart_kills_container.
set -euo pipefail
: "${environment:=PROD}"
export CONTAINER_ROLE="shared_ols"
LSWS_CONF=/usr/local/lsws/conf
CERT_DIR="$LSWS_CONF/cert"
HEALTH_DIR=/usr/local/lsws/shared-ols-health
export SITES_ROOT="${SITES_ROOT:-$LSWS_CONF/shared-sites}"
export LSCACHE_ROOT="${LSCACHE_ROOT:-/var/lscache}"
export CERT_FILE="$CERT_DIR/shared-ols.crt"
export KEY_FILE="$CERT_DIR/shared-ols.key"
mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT" "$CERT_DIR" "$HEALTH_DIR/html"
## ---- self-signed cert for the :443 listener (HAProxy verifies none) ----
if [ ! -f "$CERT_FILE" ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$KEY_FILE" -out "$CERT_FILE" -subj "/CN=shared-ols" 2>/dev/null
fi
## ---- health vhost (catch-all) ----
## This vhost is mapped `map _health *` by render-shared-ols-config.sh, so it
## answers EVERY Host that no customer vhost claims. It exists so the server is
## valid with zero customer sites and so local/edge health probes get a 200.
##
## IT MUST NOT ANSWER 200 FOR AN UNMAPPED CUSTOMER HOST.
## It used to serve html/index.html ("shared-ols", 11 bytes) with HTTP 200 to
## anything that fell through. Measured 2026-08: three live customer sites
## (their vhost had silently stopped being rendered) served that 200 for ~2
## months and no monitor noticed, because every uptime check asks "is it 200?"
## and the answer was yes. A hostname this server cannot serve now gets
## 421 Misdirected Request -- semantically exact (RFC 7540 s9.1.2: the server is
## not able to produce a response for the combination of scheme and authority in
## the request URI) and unambiguous to monitoring in a way 404 is not, since a
## 404 is a perfectly normal answer from a real, working site.
##
## THE DISCRIMINATOR: request path /healthz AND an INTERNAL client address.
## * Path alone is not enough -- anyone can request /healthz.
## * REMOTE_ADDR is the half an outside caller cannot choose, BECAUSE of
## `useIpInProxyHeader 1` in httpd_config_base.tpl: OLS resolves the client
## IP from X-Forwarded-For, and HAProxy -- the only thing that can reach
## this tier, which has no host-published ports and sits on client-net --
## SETS (not appends) that header:
## `http-request set-header X-Forwarded-For %[var(txn.real_ip)]` in
## haproxy-manager-base/templates/hap_backend.tpl, which DISCARDS whatever
## the client sent. So a request arriving from outside carries the real
## public client IP. Verified on the lab: `-H 'X-Forwarded-For: 8.8.8.8'`
## on /healthz returns 421.
## * MEASURED LIMIT OF THE IP GATE, stated plainly rather than assumed away:
## OLS takes the FIRST element of a multi-value X-Forwarded-For as
## REMOTE_ADDR. `X-Forwarded-For: 10.0.0.1, 8.8.8.8` returns 200 on /healthz
## here, and anchoring the pattern ^...$ does NOT change that (tested both
## ways) -- because by the time the rule sees REMOTE_ADDR it is already the
## single token `10.0.0.1`. The anchors are kept because they are correct
## and free, not because they close that hole. What closes it is HAProxy:
## `http-request set-header X-Forwarded-For %[var(txn.real_ip)]` REPLACES
## whatever the client sent with one value.
## * AND THE GATE IS NOT LOAD-BEARING ANYWAY. It only guards /healthz. `/`,
## and every other path, is 421 UNCONDITIONALLY -- no header, source
## address or Host can talk this vhost into a 200 there. So even a total
## bypass of the IP gate buys an attacker a 3-byte `ok` on /healthz, never
## a "the site is up" answer on the URL a monitor actually requests. That
## is the property this change exists to guarantee, and it does not rest on
## anything spoofable.
## * The probes that MUST keep passing all originate inside: the Docker
## HEALTHCHECK (`curl -sfk https://127.0.0.1/healthz` in Dockerfile.shared-ols,
## overridden by WHP's setup-shared-ols.sh to `https://localhost/healthz`)
## connects over loopback and sends no X-Forwarded-For, so REMOTE_ADDR falls
## back to the peer, 127.0.0.1. An edge/host probe of the container IP comes
## from the docker gateway (172.16/12), also allowed.
##
## `/` is 421 for EVERY client, internal ones included -- there is deliberately
## no "internal clients still get the old 200 page" escape hatch, because that
## is exactly the response that hid the outage. Anything probing this tier for
## liveness must ask for /healthz.
##
## WHY REWRITE AND NOT A REDIRECT CONTEXT: `context / { type redirect
## statusCode 421 }` was measured on this image (OLS 1.8.4) and does NOT work --
## 421 is not in OLS's accepted status-code list, so it silently degrades to a
## 302 with a literal, unexpanded `Location: $DOC_ROOT/?`. A rewrite `[R=421,L]`
## does emit a real 421.
##
## WHY THE THE_REQUEST GUARD ON THE ERROR PAGE: a bare [R=421] has no body, and
## a bare 421 with no explanation is a support ticket. `errorpage 421` supplies
## the body, but OLS fetches that URL as a fresh internal request that runs
## through these same rules -- without an exception it is itself 421'd and the
## body comes back empty (measured: content-length 0). %{IS_SUBREQ} and
## %{ENV:REDIRECT_STATUS} are NOT populated by OLS's rewrite engine (both
## measured, both no-ops), but %{THE_REQUEST} keeps the ORIGINAL request line
## across the internal fetch. So: serve misdirected.html when the client did not
## itself ask for it, which lets the error page render while a direct external
## GET /misdirected.html still gets 421 -- no path on this catch-all answers 200
## to an outside caller.
##
## The body is deliberately generic: no branding, no customer names, nothing
## that reveals which hostnames this server does serve. Every unmapped Host and
## every path gets the byte-identical 421, so the response cannot be used to
## enumerate configured vs unconfigured hostnames.
cat > "$HEALTH_DIR/vhconf.conf" <<'EOF'
docRoot $VH_ROOT/html
enableScript 0
errorpage 421 {
url /misdirected.html
}
rewrite {
enable 1
rules <<<END_rules
RewriteCond %{THE_REQUEST} !\s/+misdirected\.html
RewriteRule ^/?misdirected\.html$ - [L]
RewriteCond %{REMOTE_ADDR} ^(127\.0\.0\.1|::1|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+)$
RewriteRule ^/?healthz$ - [L]
RewriteRule .* - [R=421,L]
END_rules
}
context / {
allowBrowse 1
location $DOC_ROOT/
}
EOF
printf 'ok\n' > "$HEALTH_DIR/html/healthz"
cat > "$HEALTH_DIR/html/misdirected.html" <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>421 Misdirected Request</title></head>
<body>
<h1>421 Misdirected Request</h1>
<p>This hostname is not configured on this server.</p>
<p>If you own this domain, check that its DNS points to the correct server and
that the site is active in your hosting control panel.</p>
</body>
</html>
EOF
## The old catch-all index.html ("shared-ols") is gone on purpose, and actively
## removed so an in-place upgrade of a long-lived container cannot leave it
## behind. If these rewrite rules were ever to stop applying, `context /` would
## fall back to serving the docRoot index -- with no index.html that is a 403,
## which is wrong-but-loud, instead of a 200 that is wrong-and-silent.
rm -f "$HEALTH_DIR/html/index.html"
## ---- ownership: OLS reads conf/ as lsadm. chown the base conf dir + health dir
## NON-recursively (the per-site files under conf/shared-sites are written by the
## panel and are world-readable; a recursive chown here would be O(N-sites) on
## every container (re)start, delaying first-listen after a crash). The render
## script chowns the httpd_config.conf it produces. ----
chown lsadm:nogroup "$LSWS_CONF" "$HEALTH_DIR" "$HEALTH_DIR/html" 2>/dev/null || true
chown lsadm:nogroup "$HEALTH_DIR/vhconf.conf" "$HEALTH_DIR/html/healthz" "$HEALTH_DIR/html/misdirected.html" 2>/dev/null || true
## ---- assemble httpd_config.conf from the panel's per-site files ----
/scripts/render-shared-ols-config.sh
## ---- stream OLS logs to PID-1 stdout (follows across restarts) ----
mkdir -p /usr/local/lsws/logs
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
tail -F /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log 2>/dev/null &
## ---- .htaccess watcher (required; spec 5.3). Background; the panel monitors
## that it stays alive (its death silently stops rewrite changes applying). ----
/scripts/ols-htaccess-watcher.sh &
WATCHER_PID=$!
## ---- supervise OLS in DAEMON mode (verbatim model from entrypoint-litespeed.sh) ----
STOP_REQUESTED=0
term_handler() {
STOP_REQUESTED=1
kill "$WATCHER_PID" 2>/dev/null || true
/usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true
}
trap term_handler TERM INT
## NOT `lswsctrl status` (unlike the otherwise-identical function in
## entrypoint-litespeed.sh). `lswsctrl` appends a timestamped line to
## logs/lsrestart.log on EVERY invocation it makes, including `status` — and
## this loop polls every 3s forever. Measured on whp01: lsrestart.log is 96 MB,
## holding 1,819,286 `status` lines against 2,429 real `restart` lines; at one
## poll per 3s that's ~63 days of continuous polling, which is exactly the
## file's age, and it isn't rotated on any host (whp01/whp02/sdbees all growing
## at ~1.5 MB/day). So: check liveness directly instead of shelling out to a
## tool whose logging is a side effect we don't want on a fixed timer.
##
## Verified (docker run litespeedtech/openlitespeed:1.8.4-lsphp83, the exact
## base this image is built FROM — see Dockerfile.shared-ols): the running main
## process shows in `ps` as `openlitespeed (lshttpd - main)`, one PID, always
## present while OLS is up and absent the instant it is killed (checked via
## `ps aux` immediately after `kill -9` on the main PID). `pgrep -f` matches
## against the full command line, and no other process on this image's `ps`
## output contains that string, so this cannot cross-match an unrelated
## process. It also cannot self-match: pgrep excludes its own PID by default,
## and the invoking process here is bash executing this script file, whose own
## argv never contains the pattern text (only the *source lines* of this script
## do, which `pgrep -f` never sees).
##
## Deliberately NOT the pidfile (/tmp/lshttpd/lshttpd.pid, confirmed present in
## the same probe): pidfiles are known to go stale across a crash (verified —
## after `kill -9` the file still held the dead PID), and treating a stale PID
## as "alive" if the kernel ever reuses that number is a false positive this
## supervisor cannot afford (see below). `pgrep -f` reads the live process
## table, so there is no staleness window to reason about.
##
## Conservative on both failure directions, which matters because this is a
## supervisor predicate, not a metric: a false negative makes start_ols() run
## `lswsctrl start` against an already-running OLS — verified against the same
## probe base image, that is NOT a no-op, it sends SIGUSR1 to the live main
## process, i.e. the same graceful self-restart QUIC.cloud IP refreshes trigger
## (see entrypoint-litespeed.sh's note on that handoff) — a brief, zero-
## downtime blip at worst. A false positive is worse: it leaves a genuinely
## dead OLS un-revived until some later poll happens to notice. So if this
## predicate is ever in doubt it should err toward reporting "not running", not
## "running".
ols_running() {
pgrep -f 'lshttpd - main' >/dev/null 2>&1
}
MAX_STARTS=5
WINDOW=60
starts=""
start_ols() {
/usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true
for _ in $(seq 1 20); do
ols_running && return 0
sleep 0.5
done
return 1
}
if ! start_ols; then
echo "entrypoint-shared-ols: OLS failed to start (not running after 10s)." >&2
exit 1
fi
echo "entrypoint-shared-ols: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)"
while true; do
if ols_running; then
sleep 3
continue
fi
sleep 2
if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then
continue
fi
if [ "$STOP_REQUESTED" -eq 1 ]; then
echo "entrypoint-shared-ols: SIGTERM received, OLS stopped — exiting."
exit 0
fi
now=$(date +%s)
starts="$starts $now"
pruned=""
for t in $starts; do
[ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t"
done
starts="$pruned"
n=$(echo $starts | wc -w)
echo "entrypoint-shared-ols: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2
if [ "$n" -ge "$MAX_STARTS" ]; then
echo "entrypoint-shared-ols: OLS crash-looping — bailing for Docker restart policy / monitor." >&2
exit 1
fi
start_ols || true
done
+63 -13
View File
@@ -1,29 +1,56 @@
#!/bin/bash #!/usr/bin/env bash
if [ -z "$PHPVER" ]; then if [ -z "$PHPVER" ]; then
PHPVER="81"; PHPVER="83";
fi
if [ -z "$environment" ]; then
environment="PROD"
fi fi
adduser -u $uid $user adduser -u $uid $user
mkdir -p /home/$user/public_html mkdir -p /home/$user/public_html
mkdir -p /home/$user/logs/{apache,php-fpm}
chown -R $user:$user /home/$user mv /var/log/httpd /var/log/httpd.bak
chmod -R 755 /home/$user
/scripts/install-php$PHPVER.sh ln -s /home/$user/logs/apache /var/log/httpd
ln -s /home/$user/logs/php-fpm /var/log/php-fpm
rm -f /etc/httpd/conf.d/userdir.conf
docker_network=$(ip addr show |grep eth0 |grep inet |awk -F " " {'print $2'})
echo "RemoteIPInternalProxy $docker_network" >> /etc/httpd/conf.d/remoteip.conf
# /scripts/install-php$PHPVER.sh
source /scripts/detect-memory.sh
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_children=${PHP_FPM_MAX_CHILDREN} | Apache workers=${APACHE_MAX_REQUEST_WORKERS}"
/scripts/create-vhost.sh /scripts/create-vhost.sh
/scripts/create-php-config.sh /scripts/create-php-config.sh
/scripts/create-apache-mpm-config.sh
if [ -f /etc/httpd/conf.d/ssl.conf ]; then
mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
fi
/usr/sbin/httpd -k start /usr/sbin/httpd -k start
/usr/sbin/php-fpm -y /etc/php-fpm.conf /usr/sbin/php-fpm -y /etc/php-fpm.conf
chown -R $user:$user /home/$user
chmod -R 755 /home/$user
if [[ $environment == 'DEV' ]]; then if [[ $environment == 'DEV' ]]; then
echo "Starting Dev Deployment" echo "Starting Dev Deployment"
dnf install -y MariaDB-server MariaDB-client memcached mkdir -p /home/$user/_db_backups
# Ensure microdnf is available for installing MariaDB and memcached in DEV mode
if ! command -v microdnf &> /dev/null; then
echo "microdnf not found, installing with dnf..."
dnf install -y microdnf && dnf clean all
fi
microdnf install -y MariaDB-server MariaDB-client memcached
sed -r -i 's/session.save_path="memcache:11211/session.save_path="localhost:11211/' /etc/php.ini
nohup mysqld -umysql & nohup mysqld -umysql &
if [ ! -f /var/lib/mysql/creds ]; then if [ ! -f /home/$user/mysql_creds ]; then
echo "Give MySQL a chance to finish starting..." echo "Give MySQL a chance to finish starting..."
sleep 10 sleep 10
mysql_user=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '') mysql_user=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')
@@ -33,14 +60,37 @@ if [[ $environment == 'DEV' ]]; then
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';" mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;" mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
mysql -e "FLUSH PRIVILEGES;" mysql -e "FLUSH PRIVILEGES;"
echo "MySQL User: "$mysql_user > /var/lib/mysql/creds # Create user crontab with MySQL backup job
echo "MySQL Password: "$mysql_password >> /var/lib/mysql/creds echo "# User crontab for $user" > /home/$user/crontab
echo "MySQL Database: devdb_"$mysql_db >> /var/lib/mysql/creds echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
cat /var/lib/mysql/creds chown $user:$user /home/$user/crontab
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
cat /home/$user/mysql_creds
fi fi
/usr/bin/memcached -d -u $user /usr/bin/memcached -d -u $user
fi fi
tail -f /etc/httpd/logs/*
if [[ $environment == 'PROD' ]]; then
sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
fi
# Set up user crontab
if [ ! -f /home/$user/crontab ]; then
echo "# User crontab for $user" > /home/$user/crontab
echo "# Add your cron jobs here" >> /home/$user/crontab
echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
chown $user:$user /home/$user/crontab
fi
# Load user crontab
crontab -u $user /home/$user/crontab
/usr/sbin/crond
tail -f /var/log/httpd/*
exit 0 exit 0
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
## healthcheck-lsphp.sh — liveness for the detached-lsphp sidecar.
##
## LSAPI is not FastCGI, so the cac-fpm `cgi-fcgi ... | grep pong` ping doesn't
## apply here. Minimum viable check (spec §5.1 fallback): the LSAPI listener is
## accepting TCP connections on :9000 AND at least one lsphp process is alive.
## A bound-but-wedged listener with no lsphp would fail the pgrep; a crashed
## listener fails the connect.
PORT="${LSPHP_HEALTH_PORT:-9000}"
# bash /dev/tcp connect test (bash is present on the litespeedtech base).
exec 3<>"/dev/tcp/127.0.0.1/${PORT}" 2>/dev/null || exit 1
exec 3>&- 3<&-
pgrep -x lsphp >/dev/null 2>&1 || exit 1
exit 0
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
## install-lscache-wp.sh — auto-install the official litespeed-cache plugin
## on first boot if WP is detected and plugin not already managed.
## Idempotent: re-runs are no-ops. Honors LSCACHE_AUTOINSTALL=0 escape hatch.
##
## Args: $1 = $user (customer system user)
set -euo pipefail
user="${1:?usage: install-lscache-wp.sh <user>}"
home="/home/${user}"
if [ "${LSCACHE_AUTOINSTALL:-1}" = "0" ]; then
echo "[lscache] LSCACHE_AUTOINSTALL=0 — skipping plugin install."
exit 0
fi
if [ ! -f "$home/public_html/wp-config.php" ]; then
echo "[lscache] No wp-config.php in $home/public_html — skipping (not a WP site)."
exit 0
fi
## With setUIDMode 2, lsphp runs as the customer, and customer owns their
## home tree — wp-cli also runs as the customer, files end up correctly owned.
if ! command -v wp >/dev/null 2>&1; then
echo "[lscache] wp-cli not on PATH — skipping (image build issue, not fatal)."
exit 0
fi
if sudo -u "$user" -- wp --path="$home/public_html" plugin is-installed litespeed-cache 2>/dev/null; then
echo "[lscache] litespeed-cache already installed — leaving customer's settings alone."
exit 0
fi
echo "[lscache] Installing litespeed-cache plugin for $user"
sudo -u "$user" -- wp --path="$home/public_html" plugin install litespeed-cache --activate \
|| { echo "[lscache] plugin install failed (network? wp-cli? perms?) — non-fatal."; exit 0; }
echo "[lscache] Done."
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/usr/bin/env bash
dnf module enable php:remi-7.4 -y dnf module enable php:remi-7.4 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-xmlrpc \ dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \ php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd libzip php-cli php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd php-pgsql libzip php-cli
exit 0 exit 0
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/usr/bin/env bash
dnf module enable php:remi-8.0 -y dnf module enable php:remi-8.0 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \ dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \ php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd libzip php-cli php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
exit 0 exit 0
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/usr/bin/env bash
dnf module enable php:remi-8.1 -y dnf module enable php:remi-8.1 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \ dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \ php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd libzip php-cli php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
exit 0 exit 0
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/usr/bin/env bash
dnf module enable php:remi-8.2 -y dnf module enable php:remi-8.2 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \ dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \ php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-intl php-gd libzip php-cli php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
exit 0 exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
dnf module enable php:remi-8.3 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
dnf module enable php:remi-8.4 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
dnf module enable php:remi-8.5 -y
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
exit 0
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Set the log directory
LOG_DIR="/var/log/httpd"
# Get current date
DATE=$(date +%Y%m%d)
# Rotate access log
if [ -f "$LOG_DIR/access_log" ]; then
cp "$LOG_DIR/access_log" "$LOG_DIR/access_log.$DATE"
cat /dev/null > "$LOG_DIR/access_log"
fi
# Rotate error log
if [ -f "$LOG_DIR/error_log" ]; then
cp "$LOG_DIR/error_log" "$LOG_DIR/error_log.$DATE"
cat /dev/null > "$LOG_DIR/error_log"
fi
# Compress logs older than 3 days
find "$LOG_DIR" -name "*.log.*" -type f -mtime +3 -exec gzip {} \;
# Delete logs older than 7 days
find "$LOG_DIR" -name "*.log.*" -type f -mtime +7 -delete
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
user=$1
mysql_db=$2
dt=$(date +%y%m%d-%T)
if [ ! -d /home/$user/_db_backups ]; then
mkdir -p /home/$user/_db_backups
fi
/usr/bin/mysqldump $mysql_db > /home/$user/_db_backups/$mysql_db.$dt.sql
chown -R $user:$user /home/$user/_db_backups
/usr/bin/find /home/$user/_db_backups/ -type f -mmin +360 -delete
exit 0
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
## ols-htaccess-watcher.sh — graceful-restart the shared OLS when any tenant's
## .htaccess changes. OLS reads .htaccess (RewriteFile) at (re)start, NOT per
## request, so without this a WordPress permalink/LiteSpeed-Cache change would
## silently not take effect. Required by spec 5.3.
##
## Watches all docroots for .htaccess writes, COALESCES bursts (a WP plugin save
## touches the file several times) within a debounce window, and RATE-LIMITS to
## a floor (one restart per FLOOR seconds) so many tenants saving at once can't
## trigger a restart storm. Debounce/floor are env-tunable (panel discloses the
## resulting "~60s" window to customers).
##
## Failure of THIS process is the silent-ticket failure mode (spec 7): if it
## dies, tenants' rewrite changes stop applying with no error. The entrypoint
## runs it and the panel monitors it (check-ols-htaccess-watcher.php).
set -uo pipefail
## WATCH_ROOT is deliberately left as the host-wide /mnt/users, not narrowed to
## the shared-OLS tenant set, even though that set IS derivable in-container
## (render-shared-ols-config.sh's $SITES_ROOT/*/site.meta VHROOT= is exactly
## that list). Narrowing it would mean handing inotifywait a fixed argv list of
## VHROOT dirs at process start — and inotifywait cannot be told to watch a NEW
## directory once running. The panel provisions sites onto this container live,
## between renders; a site added after the watcher started would then sit
## outside every watch until the next container restart, i.e. exactly the
## silent-failure mode (spec 7) this script exists to prevent, now for brand
## new tenants instead of none. Doing this safely needs a reload path (SIGHUP
## re-exec off the current site.meta list, coordinated with
## render-shared-ols-config.sh) that does not exist yet and is its own change.
## So: WATCH_ROOT stays broad, and correctness comes entirely from the path
## match below, which is sufficient on its own.
WATCH_ROOT="${OLS_WATCH_ROOT:-/mnt/users}"
DEBOUNCE="${OLS_HTACCESS_DEBOUNCE:-15}" # coalesce window (s)
FLOOR="${OLS_HTACCESS_FLOOR:-60}" # min seconds between restarts
LSWSCTRL=/usr/local/lsws/bin/lswsctrl
last_restart=0
log() { echo "ols-htaccess-watcher: $*" >&2; }
do_restart() {
path="$1"
now=$(date +%s)
if [ $((now - last_restart)) -lt "$FLOOR" ]; then
log "within ${FLOOR}s floor — coalescing, skipping restart ($path)"
return
fi
if "$LSWSCTRL" restart >/dev/null 2>&1; then
last_restart=$now
log "graceful restart issued — $path changed"
else
log "WARNING: lswsctrl restart failed ($path)"
fi
}
if ! command -v inotifywait >/dev/null 2>&1; then
log "FATAL: inotifywait not installed (inotify-tools)"; exit 1
fi
mkdir -p "$WATCH_ROOT"
log "watching $WATCH_ROOT for docroot (public_html) .htaccess changes (debounce=${DEBOUNCE}s floor=${FLOOR}s)"
## -m monitor, -r recursive. We filter in the read loop rather than --include
## so this works on older inotify-tools too. modify/create/delete/move all
## matter (delete of .htaccess also changes rewrite behavior).
##
## --format '%w%f' (full path), NOT '%f' (basename only). OLS reads .htaccess
## (RewriteFile) only from a vhost's DOCROOT — VHROOT, i.e.
## /mnt/users/<user>/<domain>/public_html (see render-shared-ols-config.sh /
## entrypoint-lsphp.sh) — never anything below it. A basename-only match fires
## for ANY .htaccess anywhere under a tenant, at any depth, and WordPress
## plugins write plenty of those that OLS never opens: measured on whp01 over
## 24h, this watcher fired 63 restarts, of which the docroot .htaccess actually
## changed in 0. All 28 distinct files behind those 63 were plugin guard files
## — Wordfence self-healing waf/views/vendor/tmp/models/lib/.htaccess, W3 Total
## Cache writing one per cached URL under wp-content/cache/page_enhanced/, plus
## WPForms/Gravity Forms/UpdraftPlus/Groundhogg/WP Staging upload guards — and
## most of those tenants are on the shared Apache tier (cac-fpm), not this OLS
## tier at all, so their cache churn was restarting the OLS serving 15 unrelated
## tenants for no reason. Matching the full path down to /public_html/.htaccess
## is what actually ties a change to something OLS will reread.
inotifywait -m -r -e modify,create,delete,move "$WATCH_ROOT" --format '%w%f' 2>/dev/null |
while read -r path; do
case "$path" in
*/public_html/.htaccess) ;;
*) continue ;;
esac
## A tenant DOCROOT .htaccess changed. Coalesce the save-burst, then restart ONCE.
##
## The coalesce is HARD-BOUNDED to DEBOUNCE seconds: a previous version blocked
## on `read -t DEBOUNCE` which, on a busy multi-tenant server, never timed out
## (unrelated file writes under $WATCH_ROOT kept resetting it) — so the restart
## was starved and rewrite changes silently never applied. Here we read further
## events only until the deadline OR ~2s of total quiet, whichever comes first,
## so continuous activity can delay us by at most DEBOUNCE. do_restart's FLOOR
## then rate-limits across consecutive bursts.
deadline=$(( $(date +%s) + DEBOUNCE ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if read -r -t 2 _; then
continue # more activity — keep coalescing toward the deadline
else
break # ~2s of total quiet — the burst has settled
fi
done
do_restart "$path"
done
+220
View File
@@ -0,0 +1,220 @@
#!/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. Exact-domain maps above win over this '*'.
##
## THIS MAP IS WHY AN UNMAPPED HOST GETS AN ANSWER AT ALL. Anything the loop
## above did not emit a `map` for -- a customer domain whose site dir went
## missing, a stale DNS record, a scanner probing by IP -- lands here. It used
## to answer 200 with an 11-byte "shared-ols" body, which is how three live
## customer sites stayed silently broken for ~2 months: every uptime monitor
## asks "is it 200?" and it was.
##
## The health vhost (its vhconf.conf is written by entrypoint-shared-ols.sh,
## which carries the full rationale) now answers 421 Misdirected Request with a
## short generic body for any Host it cannot serve, and keeps 200 ONLY for
## GET /healthz from an internal client address -- the Docker HEALTHCHECK and
## edge liveness probes. Do NOT reintroduce a 200 here for `/`: probe /healthz.
##
## The listener `map` itself is unchanged, deliberately. Dropping the catch-all
## instead would make OLS answer an unmapped Host from whichever vhost it
## considers first, which is worse: an unmapped Host would be served SOMEONE
## ELSE'S SITE. ---
{
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)"
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env bash
## lsphp-info-probe.test.sh — regression test for the SIGPIPE-under-pipefail
## class of bug that shipped in cac-lsphp (trunk 9343a56) and silently turned
## $_SERVER path parity off on every shared-ols site whose host lost the race.
##
## THE BUG. entrypoint-lsphp.sh asked "is cac_path_parity loaded?" with
##
## printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'
##
## under `set -euo pipefail`. `grep -q` exits the instant it matches; printf is
## still pushing the rest of `lsphp -i` into the pipe, takes SIGPIPE, and exits
## 141; pipefail prefers that over grep's 0. So the test read FALSE **because
## the extension was present** — present early enough in the output to stop the
## reader — and the container fell back to the degraded auto_prepend normaliser
## the extension exists to replace, while telling the operator the extension was
## "not loadable in this image".
##
## THE RULE THIS FILE ENFORCES, stated so nobody re-derives a wrong one: ANY
## `writer | early-exiting-reader` under pipefail is a latent 141. PAYLOAD SIZE
## IS NOT A SAFETY ARGUMENT — each run is a race on whether the reader's close
## lands before the writer's final write() returns, and size only sets how many
## write() syscalls the writer must survive. Measured against a default
## 65536-byte pipe: 41144 bytes SIGPIPEd on 32/300 runs (11%, well UNDER
## capacity) and 65012 bytes on 25/30 (not certain even AT capacity); 500 KB
## into a 1 MiB pipe was 200/200 when written 4096 bytes at a time and 0/200 as
## a single write. A call site is sound only for a STRUCTURAL reason: the file
## does not set pipefail, or the reader provably consumes to EOF (no `q`, `-q`,
## `-l`, `-m`, `exit`, `break`), or the pipeline's status is discarded. Section 4
## below prints this race happening at ~41 KB; if the numbers there ever read as
## "small payloads are fine", the numbers are right and the reading is wrong.
##
## WHY THE EXISTING SUITE DID NOT CATCH IT. The .phpt suite and
## fpm-parity-check.sh both test the EXTENSION; nothing executed the
## ENTRYPOINT's branch logic, and the Dockerfile's own `lsphp -i | grep -q`
## build gate runs under `bash -c 'set -e'` with NO pipefail, so it reported the
## extension present in the very image whose entrypoint declared it missing.
##
## WHAT THIS ASSERTS.
## 1. behaviour — the SHIPPED probe helpers (extracted verbatim from
## entrypoint-lsphp.sh, never copied, so they cannot drift) return the
## right answer AND a clean exit status under `set -euo pipefail` with a
## realistic ~40 KB phpinfo body.
## 2. structure — no script in this repo that enables pipefail pipes into a
## reader that can exit before its writer finishes. This is the check that
## fails deterministically against trunk; assertion 1's *old* form is a
## RACE (measured 141 on 3 of 5 runs here, 5 of 5 on whp02, and 0 of 5
## under a different Docker daemon), so no behavioural assertion about the
## broken code could be trusted to fail on every machine. Section 4 runs
## the old form anyway and prints what it did, for the record.
## 3. equivalence — the helpers' pure-bash matching answers exactly what the
## grep/awk patterns they replaced answer, checked case by case against
## those same patterns reading a FILE (a file, so the reference itself
## cannot SIGPIPE). Section 6.
## 4. the scan's own coverage — every reader shape section 5 claims to catch
## is caught, and a matched set of safe shapes is NOT flagged. Section 7.
## Without this the scan's regexes are unfalsified and can quietly stop
## matching; `grep -l` and `sed q` were both missed until section 7 existed.
##
## SCOPE LIMITS, stated rather than hidden. The structural scan is a text scan,
## so it under-reports in known ways:
## - it reads one line at a time. The repo's only multi-line pipeline
## (ols-htaccess-watcher.sh's `inotifywait … |` / `while read`) is invisible
## to it and was reviewed by hand: that reader loops until EOF, i.e. until
## the writer has already gone, so it cannot produce this failure.
## - its quote stripping is flat, so a pipe nested inside a command
## substitution inside a quoted string (`echo "x ($(a | head -1))"`) is read
## as quoted text and skipped.
## - the reader list is an ENUMERATION, not a proof. It knows `grep`
## (-q/-l/-L/-m and their long forms), `head`, `read`, `awk … exit` and
## `sed` with a q/Q command; it does not know an early exit hidden in
## `perl -ne '… last'`, `python -c`, `jq`, `head -c`, or any project-local
## program that stops reading. A reader not on the list is not thereby safe.
## - it only inspects the FIRST word after a pipe, so `foo | LC_ALL=C grep -q`
## or `foo | { grep -q x; }` reads as an unknown reader and is skipped.
## It is a guard against reintroducing the shape, not a proof of its absence.
##
## Usage: scripts/tests/lsphp-info-probe.test.sh [REPO_ROOT]
## Exit: 0 all assertions passed, 1 an assertion FAILED, 2 the test could not run.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${1:-$(cd "$HERE/../.." && pwd)}"
ENTRYPOINT="$ROOT/scripts/entrypoint-lsphp.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
pass=0; fail=0
ok() { pass=$((pass+1)); echo " ok — $1"; }
bad() { fail=$((fail+1)); echo " FAIL — $1" >&2; }
die() { echo "HARNESS FAILURE: $*" >&2; exit 2; }
[ -f "$ENTRYPOINT" ] || die "no entrypoint at $ENTRYPOINT (pass REPO_ROOT as \$1)"
## ---------------------------------------------------------------------------
## 1. Extract the real probe helpers. Not a copy: whatever the shipped
## entrypoint says between the markers is what runs below, so a future edit
## that reintroduces a pipeline is tested, not narrated.
##
## A missing block is an assertion FAILURE, not a harness error, and it does
## not stop the run: against a pre-fix checkout the probes are still inline
## pipelines, and section 5 below is what names them. Bailing out here would
## have replaced that report with "could not run".
##
## BOUNDED ON PURPOSE. The extraction buffers and emits NOTHING until it has
## seen the END marker, so deleting or mistyping that one line is a marker
## error (exit 4) rather than a slurp of every line after BEGIN into a file
## this harness then `source`s. That is not hypothetical: it happened during
## development and only failed loudly by luck — `set -u` tripped over an
## unbound variable two statements into the entrypoint's real boot code. An
## extractor whose failure mode is "execute arbitrary parts of the program
## under test" is not a safe thing to leave lying around, however careful the
## markers are today.
## exit 0 = both markers, block on stdout
## exit 3 = no BEGIN marker at all (pre-fix checkout, or block removed)
## exit 4 = BEGIN seen, END missing — refuse to emit, refuse to source
## ---------------------------------------------------------------------------
HAVE_HELPERS=yes
XRC=0
awk '
/^## ---- CAC-TEST: probe helpers BEGIN ----$/ { f = 1 }
f { buf = buf $0 ORS }
f && /^## ---- CAC-TEST: probe helpers END ----$/ { printf "%s", buf; found = 1; exit 0 }
END { if (found) exit 0; else if (f) exit 4; else exit 3 }
' "$ENTRYPOINT" > "$TMP/helpers.sh" || XRC=$?
case "$XRC" in
0)
for fn in lsphp_info_has_parity_ext lsphp_info_scan_dir lsphp_info_is_usable; do
if ! grep -q "^${fn}()" "$TMP/helpers.sh"; then
HAVE_HELPERS=no
bad "the probe-helper block in ${ENTRYPOINT#"$ROOT"/} defines no ${fn}()"
fi
done
;;
4)
HAVE_HELPERS=no
: > "$TMP/helpers.sh"
bad "${ENTRYPOINT#"$ROOT"/} has a 'CAC-TEST: probe helpers BEGIN' marker with no matching END marker. Nothing was extracted — an unterminated block would otherwise have pulled the whole rest of the entrypoint into a file this test sources and runs."
;;
3)
HAVE_HELPERS=no
bad "no probe-helper markers in ${ENTRYPOINT#"$ROOT"/} — either they were removed, or this is a pre-fix checkout where the probes are still inline pipelines (trunk 9343a56). Section 5 says which lines."
;;
*)
HAVE_HELPERS=no
bad "extracting the probe-helper block from ${ENTRYPOINT#"$ROOT"/} failed (awk exit $XRC)"
;;
esac
## ---------------------------------------------------------------------------
## 2. Fixtures. Shaped like real `lsphp -i`: the two lines the probes look for
## sit near the TOP (that is what lets a reader stop early), with tens of KB
## of body after them. A fixture whose marker is near the end could not
## SIGPIPE at all and would make this whole test vacuous, so both properties
## are asserted before anything else runs.
## ---------------------------------------------------------------------------
SCAN_PATH='/usr/local/lsws/lsphp83/etc/php/8.3/mods-available'
make_fixture() { # $1=outfile $2=yes|no (include the cac_path_parity line)
{
printf 'phpinfo()\nPHP Version => 8.3.27\n\n'
printf 'System => Linux 6ecb4e0a1c1f 5.15.0 #1 SMP x86_64\n'
printf 'Server API => LiteSpeed V8.3\n'
printf 'Configuration File (php.ini) Path => /usr/local/lsws/lsphp83/etc/php/8.3/litespeed\n'
printf 'Scan this dir for additional .ini files => %s\n' "$SCAN_PATH"
printf 'PHP API => 20230831\nDebug Build => no\nThread Safety => disabled\n\n'
printf 'bcmath\n\nBCMath support => enabled\n\n'
printf 'calendar\n\nCalendar support => enabled\n\n'
[ "$2" = yes ] && printf 'cac_path_parity\n\ncac_path_parity support => enabled\nRewriting => active\n\n'
printf 'Core\n\nPHP Version => 8.3.27\n\n'
## ~40 KB of directive rows, exactly the shape phpinfo() prints them in.
local i=0
while [ "$i" -lt 700 ]; do
printf 'some.directive_%03d => local_value_%03d => master_value_%03d\n' "$i" "$i" "$i"
i=$((i+1))
done
} > "$1"
}
make_fixture "$TMP/info-present.txt" yes
make_fixture "$TMP/info-absent.txt" no
: > "$TMP/info-empty.txt"
echo "== fixture sanity =="
FIX_BYTES=$(wc -c < "$TMP/info-present.txt")
MATCH_OFF=$(grep -b -m1 '^cac_path_parity support => enabled$' "$TMP/info-present.txt" | cut -d: -f1)
if [ "$FIX_BYTES" -ge 32768 ]; then
ok "fixture is ${FIX_BYTES} bytes (realistic 'lsphp -i' is ~40 KB)"
else
bad "fixture is only ${FIX_BYTES} bytes — too small to reproduce the failure"
fi
if [ "$MATCH_OFF" -lt $((FIX_BYTES / 4)) ]; then
ok "match sits at byte ${MATCH_OFF} of ${FIX_BYTES} — most of the body is still unwritten when a reader could stop"
else
bad "match at byte ${MATCH_OFF} of ${FIX_BYTES} leaves too small a tail; the test would be vacuous"
fi
## ---------------------------------------------------------------------------
## 3. Behaviour, under the REAL option set (`set -euo pipefail`, as line 34 of
## the entrypoint sets it). Each case runs in its own bash process so the
## options and the exit status are the genuine article, not something this
## harness simulated.
## ---------------------------------------------------------------------------
cat > "$TMP/runner.sh" <<'RUNNER'
#!/usr/bin/env bash
set -euo pipefail
# shellcheck disable=SC1091
source "$1" # the extracted probe helpers
LSPHP_INFO=$(cat "$2")
case "$3" in
has_ext) lsphp_info_has_parity_ext "$LSPHP_INFO" ;;
scan_dir) lsphp_info_scan_dir "$LSPHP_INFO" ;;
usable) lsphp_info_is_usable "$LSPHP_INFO" ;;
legacy) printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$' ;;
*) echo "unknown case $3" >&2; exit 99 ;;
esac
RUNNER
## NOT `out=$(run …)`: command substitution runs the function in a subshell,
## where an exit status assigned to RC would be thrown away with it. Run in this
## shell, capture stdout through a file, and RC is the real thing.
RC=0; out=""
run() { # $1=fixture $2=case -> sets RC and out
RC=0
bash "$TMP/runner.sh" "$TMP/helpers.sh" "$1" "$2" >"$TMP/out" 2>&1 || RC=$?
out=$(cat "$TMP/out")
}
echo "== the shipped probes under set -euo pipefail =="
if [ "$HAVE_HELPERS" = no ]; then
echo " (skipped — no probe helpers to run; see the failure above)"
fi
if [ "$HAVE_HELPERS" = yes ]; then
run "$TMP/info-present.txt" has_ext
if [ "$RC" -eq 0 ]; then
ok "extension present => branch TAKEN (exit 0)"
elif [ "$RC" -eq 141 ]; then
bad "exit 141 (SIGPIPE): the probe is a pipeline again — this is the original bug"
else
bad "extension present => exit $RC (expected 0). Output: $out"
fi
run "$TMP/info-absent.txt" has_ext
if [ "$RC" -eq 1 ]; then
ok "extension genuinely absent => branch NOT taken (exit 1, a clean 'no')"
else
bad "extension absent => exit $RC (expected 1). Output: $out"
fi
run "$TMP/info-present.txt" scan_dir
if [ "$RC" -eq 0 ] && [ "$out" = "$SCAN_PATH" ]; then
ok "scan-dir probe returns '$out' (exit 0)"
elif [ "$RC" -eq 141 ]; then
bad "scan-dir probe exit 141 (SIGPIPE) — as a bare assignment under set -e that KILLS PID 1"
else
bad "scan-dir probe => exit $RC, got '$out', expected '$SCAN_PATH'"
fi
run "$TMP/info-present.txt" usable
if [ "$RC" -eq 0 ]; then ok "usability probe: real phpinfo body => usable (exit 0)"
else bad "usability probe on real body => exit $RC (expected 0)"; fi
run "$TMP/info-empty.txt" usable
if [ "$RC" -eq 1 ]; then ok "usability probe: empty body => NOT usable (exit 1)"
else bad "usability probe on empty body => exit $RC (expected 1)"; fi
## The two failure causes must be reported as the different things they are.
## A probe that cannot answer has established nothing about the image, and the
## old message asserted the opposite of that for every reason it fired.
## SC2016: these patterns are the entrypoint's literal text, `$` and all.
# shellcheck disable=SC2016
if grep -q 'if lsphp_info_is_usable "$LSPHP_INFO"; then' "$ENTRYPOINT" &&
grep -q 'This is a PROBE failure and establishes nothing' "$ENTRYPOINT"; then
ok "a probe that produced nothing is reported as OUR failure, not as a verdict on the image"
else
bad "entrypoint no longer reports an unusable 'lsphp -i' as a probe failure"
fi
# shellcheck disable=SC2016
if grep -q 'not loadable in this image' "$ENTRYPOINT" &&
grep -q 'answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it' "$ENTRYPOINT"; then
ok "the 'extension not loadable' verdict now ships the evidence it rests on"
else
bad "the 'extension not loadable' message no longer states what it observed"
fi
fi # HAVE_HELPERS
## ---------------------------------------------------------------------------
## 4. The old form, for the record. NOT asserted: it is a race, and asserting a
## race would make this suite flap on whichever machine happens to win it.
## ---------------------------------------------------------------------------
echo "== the pre-fix pipeline form on the same input (informational) =="
if [ "$HAVE_HELPERS" = no ]; then
echo " (skipped — the runner needs the helper block to source)"
fi
legacy_rcs=""
if [ "$HAVE_HELPERS" = yes ]; then
for _ in 1 2 3 4 5 6 7 8 9 10; do
run "$TMP/info-present.txt" legacy
legacy_rcs="$legacy_rcs $RC"
done
echo " 10 runs of 'printf | grep -q' under pipefail:${legacy_rcs}"
echo " (0 = happened to finish writing, 141 = writer SIGPIPEd and pipefail reported the"
echo " extension MISSING because it was present. Either value is expected here.)"
fi
## ---------------------------------------------------------------------------
## 5. Structural scan — the deterministic guard, and the part of this file that
## fails against trunk on EVERY machine. Any script that turns on pipefail
## and pipes into a reader that can stop early has this bug latent in it
## whether or not today's buffer sizes expose it, so the shape is what gets
## outlawed, not the symptom.
##
## It is a text scan, so it is honest about its limits: it strips quoted
## spans and comments (that is what keeps `case a|b)`, `sed "s|x|y|"` and
## `echo "a | b"` from being reported), it requires the reader to be the
## FIRST word after a pipe, and it reads one line at a time. This file is
## skipped because section 4 runs the broken form on purpose.
## ---------------------------------------------------------------------------
echo "== structural scan: early-exit readers in pipefail scripts =="
mapfile -t PIPEFAIL_FILES < <(grep -rl --include='*.sh' -E '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*[[:space:]]*o?[[:space:]]*pipefail|^[[:space:]]*set[[:space:]]+-o[[:space:]]+pipefail' "$ROOT/scripts" "$ROOT/ext" 2>/dev/null | sort)
[ "${#PIPEFAIL_FILES[@]}" -gt 0 ] || die "found no pipefail-enabled scripts under $ROOT — the scan would be vacuous"
cat > "$TMP/scan.awk" <<'AWKPROG'
# Does this `grep …` invocation stop reading before EOF? -q/-l/-L/-m do; -c,
# -i, -v, -o, -n and the rest read the whole input and are none of our business.
# Walked option by option rather than pattern-matched in one go, because the
# letters have to be read the way grep reads them: a cluster like -im1 stops
# early, -e/-f/-A/-B/-C/-d/-D swallow the rest of their token as an ARGUMENT
# (so `grep -eq` is the pattern "q", not --quiet), and the first non-option word
# is the pattern, after which nothing is a flag any more.
function grep_stops_early(s, a, k, j, t, c, m) {
if (s !~ /^[[:space:]]*grep([[:space:];&)]|$)/) return 0
sub(/^[[:space:]]*grep([[:space:]]+|$)/, "", s)
m = split(s, a, /[[:space:]]+/)
for (k = 1; k <= m; k++) {
t = a[k]
if (t == "") continue
if (t == "--") return 0
if (t ~ /^--/) {
if (t ~ /^--(quiet|silent|max-count|files-with-match|files-without-match)/) return 1
continue
}
if (t !~ /^-/) return 0 # the pattern; options are over
sub(/^-/, "", t)
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (c == "q" || c == "l" || c == "L" || c == "m") return 1
if (c ~ /[efABCdD]/) break # rest of the token is its argument
}
}
return 0
}
{
raw = $0
l = raw
gsub(/\\"/, "X", l); gsub(/\\'/, "X", l) # escaped quotes are not delimiters
while (match(l, /'[^']*'/)) l = substr(l, 1, RSTART-1) "SQ" substr(l, RSTART+RLENGTH)
while (match(l, /"[^"]*"/)) l = substr(l, 1, RSTART-1) "DQ" substr(l, RSTART+RLENGTH)
if (l ~ /^[[:space:]]*#/) next # whole-line comment
sub(/[[:space:]]#.*/, "", l) # trailing comment
# `u` = the line with quote CHARACTERS dropped but their contents KEPT, used
# only to read a command's script argument. `l` cannot serve for that: it
# replaces a whole quoted span with a placeholder, so `sed 'q'` and
# `awk '{exit}'` lose the very token that makes them early-exit readers.
u = raw
gsub(/["']/, "", u)
sub(/[[:space:]]#.*/, "", u)
gsub(/\|\|/, " ", l) # || is not a pipeline
if (l !~ /\|/) next
n = split(l, seg, "|")
for (i = 2; i <= n; i++) {
r = seg[i]
if (grep_stops_early(r)) { print NR ": " raw; next }
# The `[[:space:];&)]|$` tail rather than a bare `[[:space:]]|$`: a reader
# can be the last word of a compound command (`… | head; }`), which the
# whitespace-only form silently skipped.
if (r ~ /^[[:space:]]*head([[:space:];&)]|$)/) { print NR ": " raw; next }
if (r ~ /^[[:space:]]*(while[[:space:]]+)?read([[:space:];&)]|$)/) { print NR ": " raw; next }
if (r ~ /^[[:space:]]*awk([[:space:];&)]|$)/ && u ~ /exit/) { print NR ": " raw; next }
# sed: a q/Q command anywhere in the script — `/re/q`, `2q`, `$q`, `1p;q`,
# `{…;q}`, and the bare `sed q` / `sed 'q'` that the earlier pattern missed
# (it required a `;`, `{` or `/` in front of the q, which a lone script has
# none of). Anchored on what may PRECEDE the q and what may FOLLOW it, so a
# q inside a replacement — `sed s/a/q/` — is not read as the command (which
# is what the trailing `/` exclusion buys: a command q is never followed by
# another delimiter, a replacement q always is).
if (r ~ /^[[:space:]]*sed([[:space:];&)]|$)/ &&
(u ~ /[;{\/][[:space:]]*[qQ]([^[:alnum:]\/]|$)/ ||
u ~ /[[:space:]]([0-9]+|\$)?[qQ]([[:space:];}]|$)/)) { print NR ": " raw; next }
}
}
AWKPROG
offenders=0
for f in "${PIPEFAIL_FILES[@]}"; do
[ "$(basename "$f")" = "$(basename "${BASH_SOURCE[0]}")" ] && continue
while IFS= read -r hit; do
offenders=$((offenders+1))
echo " FAIL — ${f#"$ROOT"/}:${hit}" >&2
done < <(awk -f "$TMP/scan.awk" "$f")
done
if [ "$offenders" -eq 0 ]; then
ok "${#PIPEFAIL_FILES[@]} pipefail-enabled scripts, no pipeline whose reader can outrun its writer"
else
bad "$offenders pipeline(s) above pipe into an early-exit reader under pipefail."
echo " Read the value into a variable and match it in the shell (see" >&2
echo " lsphp_info_has_parity_ext in scripts/entrypoint-lsphp.sh), or give the" >&2
echo " reader the file directly. Neither is a pipeline, so there is no second" >&2
echo " exit status for pipefail to prefer. A here-string also works, but it" >&2
echo " spills to a temp file above a build-dependent size, so it is not the" >&2
echo " right shape on a boot path." >&2
fi
## ---------------------------------------------------------------------------
## 6. Equivalence. The helpers answer with `[[ ]]` and `${…}` what they used to
## answer with grep and awk, and "same patterns, different plumbing" is a
## claim that has to be checked rather than asserted — an anchored line match
## re-expressed as a glob is exactly where an off-by-one lives.
##
## The reference runs the ORIGINAL grep/awk patterns over a FILE, so the
## reference itself cannot SIGPIPE and cannot be accused of the bug it is
## refereeing. The file is written the way the old pipeline fed them,
## `printf '%s\n' "$SUBJECT"`, so the comparison is against the pre-fix
## behaviour byte for byte and not against a tidier reading of it.
## ---------------------------------------------------------------------------
echo "== pure-bash matching vs the grep/awk patterns it replaced =="
if [ "$HAVE_HELPERS" = no ]; then
echo " (skipped — no probe helpers to compare)"
else
# shellcheck disable=SC1091
source "$TMP/helpers.sh"
NL=$'\n'
eq_fail=0
eq_case() { # $1 = label, $2 = subject
local label="$1" subj="$2" f="$TMP/eq.txt"
local ref_has ref_use new_has new_use ref_scan new_scan
printf '%s\n' "$subj" > "$f"
ref_has=0; grep -q '^cac_path_parity support => enabled$' "$f" || ref_has=$?
new_has=0; lsphp_info_has_parity_ext "$subj" || new_has=$?
ref_use=0; grep -q '^PHP Version => ' "$f" || ref_use=$?
new_use=0; lsphp_info_is_usable "$subj" || new_use=$?
ref_scan=$(awk -F'=> ' '/^Scan this dir/ {print $2; exit}' "$f")
new_scan=$(lsphp_info_scan_dir "$subj")
if [ "$ref_has" = "$new_has" ] && [ "$ref_use" = "$new_use" ] && [ "$ref_scan" = "$new_scan" ]; then
ok "equivalent on: $label"
else
eq_fail=$((eq_fail+1))
bad "NOT equivalent on: $label — has_ext grep=$ref_has bash=$new_has; usable grep=$ref_use bash=$new_use; scan_dir awk='$ref_scan' bash='$new_scan'"
fi
}
SD='Scan this dir for additional .ini files'
PARITY='cac_path_parity support => enabled'
eq_case "empty subject" ""
eq_case "match on the first line" "${PARITY}${NL}tail line"
eq_case "match on the last line" "head line${NL}${PARITY}"
eq_case "match is the only line" "${PARITY}"
eq_case "match sandwiched" "a${NL}${PARITY}${NL}b"
eq_case "not at line start (decoy)" "x ${PARITY}${NL}b"
eq_case "trailing space defeats the \$" "${PARITY} ${NL}b"
eq_case "prefix-only line (decoy)" "cac_path_parity support => enabled but no${NL}b"
eq_case "genuinely absent" "a${NL}b${NL}c"
eq_case "embedded blank lines" "a${NL}${NL}${PARITY}${NL}${NL}b"
eq_case "banner first, scan dir second" "PHP Version => 8.3.27${NL}${SD} => /a/b"
eq_case "scan dir on the first line" "${SD} => /a/b${NL}PHP Version => 8.3.27"
eq_case "scan dir on the last line" "PHP Version => 8.3.27${NL}${SD} => /a/b"
eq_case "scan dir, second separator" "${SD} => /a => /b${NL}x"
eq_case "scan dir, empty value" "${SD} => ${NL}x"
eq_case "scan dir, no separator" "Scan this dir is broken${NL}x"
eq_case "scan dir, first of two wins" "${SD} => /first${NL}${SD} => /second"
eq_case "scan dir, value has spaces" "${SD} => /a b/c ${NL}x"
eq_case "scan-dir line is a prefix" "Scan this directory => /a/b${NL}x"
eq_case "banner not at line start" "x PHP Version => 8.3.27${NL}b"
eq_case "banner without trailing space" "PHP Version =>${NL}b"
eq_case "CR-terminated lines" $'PHP Version => 8.3.27\r'"${NL}${PARITY}"$'\r'
eq_case "glob metacharacters in body" "*${NL}?${NL}[a-z]${NL}${PARITY}${NL}]["
eq_case "realistic 40 KB body" "$(cat "$TMP/info-present.txt")"
[ "$eq_fail" -eq 0 ] || echo " (an inequivalence here means the shipped probe now answers something the old grep/awk did not)" >&2
fi
## ---------------------------------------------------------------------------
## 7. The scan's own coverage. Section 5 only proves something if its regexes
## actually match the shapes it claims to outlaw — an unfalsified scanner
## reports "clean" just as loudly when it has stopped matching anything.
## `grep -l foo` and `sed q` were both silently missed until this section
## existed; both are asserted below, alongside the safe forms that must NOT
## be reported, because a scanner that flags everything is no better.
## ---------------------------------------------------------------------------
echo "== the structural scan catches what it claims to =="
cat > "$TMP/scan-bad.sh" <<'BADSH'
set -euo pipefail
a() { producer | grep -q needle; }
b() { producer | grep -qx needle; }
c() { producer | grep -m1 needle; }
d() { producer | grep -im1 needle; }
e() { producer | grep -l foo; }
f() { producer | grep -L foo; }
g() { producer | grep --quiet foo; }
h() { producer | grep --files-with-matches foo; }
i() { producer | head -1; }
j() { producer | head; }
k() { producer | read -r x; }
l() { producer | while read -r x; do :; done; }
m() { producer | awk '/x/ {print; exit}'; }
n() { producer | sed q; }
o() { producer | sed 'q'; }
p() { producer | sed 2q; }
q() { producer | sed -n '1p;q'; }
r() { producer | sed -n '/x/{p;q}'; }
s() { producer | sed '$q'; }
BADSH
cat > "$TMP/scan-good.sh" <<'GOODSH'
set -euo pipefail
a() { producer | grep -c needle; }
b() { producer | grep -i needle; }
c() { producer | grep -v needle; }
d() { producer | grep -o needle; }
e() { producer | awk '{print $1}'; }
f() { producer | sed -n 's/^K=//p'; }
g() { producer | sed 's/a/q/'; }
h() { producer | sed -e 'y/abc/xqz/'; }
i() { producer | wc -l; }
j() { producer | sort -u; }
k() { producer | tail -1; }
l() { case $x in a|b) : ;; esac; }
m() { echo "a | head -1"; }
n() { echo 'x | grep -q y'; }
o() { grep -q needle <<<"$1"; }
p() { grep -q needle "$file"; }
# q() { producer | grep -q commented-out; }
r() { producer | grep -eq foo; }
GOODSH
missed=""; falsely=""; hits_bad=""
while IFS= read -r line; do
fn=${line#*: }; fn=${fn%%(*}
hits_bad="$hits_bad $fn"
done < <(awk -f "$TMP/scan.awk" "$TMP/scan-bad.sh")
for want in a b c d e f g h i j k l m n o p q r s; do
case " ${hits_bad:-} " in *" $want "*) ;; *) missed="$missed $want" ;; esac
done
mapfile -t good_hits < <(awk -f "$TMP/scan.awk" "$TMP/scan-good.sh")
if [ -z "$missed" ]; then
ok "all 19 early-exit reader shapes are reported (incl. grep -l/-L/--quiet and bare 'sed q')"
else
bad "the scan misses these shapes in scan-bad.sh:$missed"
awk -f "$TMP/scan.awk" "$TMP/scan-bad.sh" >&2
fi
if [ "${#good_hits[@]}" -eq 0 ]; then
ok "18 read-to-EOF / quoted / non-pipeline forms are not reported"
else
falsely=$(printf '%s; ' "${good_hits[@]}")
bad "the scan false-positives on: $falsely"
fi
echo
echo "passed: $pass failed: $fail"
[ "$fail" -eq 0 ] || exit 1
exit 0
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Hot-adjust Apache MPM Event settings and graceful reload.
# Usage: tune-mpm.sh [--max-workers N] [--server-limit N] [--start-servers N]
# [--min-spare-threads N] [--max-spare-threads N]
# [--max-connections-per-child N]
set -euo pipefail
# Read current values from the config as defaults
CONFIG_FILE="/etc/httpd/conf.d/mpm-tuning.conf"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: $CONFIG_FILE not found. Run detect-memory.sh first."
exit 1
fi
# Parse current values from config
current_start=$(grep -oP 'StartServers\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "1")
current_min_spare=$(grep -oP 'MinSpareThreads\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "5")
current_max_spare=$(grep -oP 'MaxSpareThreads\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "15")
current_max_workers=$(grep -oP 'MaxRequestWorkers\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "50")
current_server_limit=$(grep -oP 'ServerLimit\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "2")
current_max_conn=$(grep -oP 'MaxConnectionsPerChild\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "3000")
# Parse arguments
START_SERVERS=$current_start
MIN_SPARE_THREADS=$current_min_spare
MAX_SPARE_THREADS=$current_max_spare
MAX_REQUEST_WORKERS=$current_max_workers
SERVER_LIMIT=$current_server_limit
MAX_CONNECTIONS_PER_CHILD=$current_max_conn
while [[ $# -gt 0 ]]; do
case $1 in
--max-workers) MAX_REQUEST_WORKERS="$2"; shift 2 ;;
--server-limit) SERVER_LIMIT="$2"; shift 2 ;;
--start-servers) START_SERVERS="$2"; shift 2 ;;
--min-spare-threads) MIN_SPARE_THREADS="$2"; shift 2 ;;
--max-spare-threads) MAX_SPARE_THREADS="$2"; shift 2 ;;
--max-connections-per-child) MAX_CONNECTIONS_PER_CHILD="$2"; shift 2 ;;
--help|-h)
echo "Usage: $0 [--max-workers N] [--server-limit N] [--start-servers N]"
echo " [--min-spare-threads N] [--max-spare-threads N]"
echo " [--max-connections-per-child N]"
echo ""
echo "Current values:"
echo " StartServers: $current_start"
echo " MinSpareThreads: $current_min_spare"
echo " MaxSpareThreads: $current_max_spare"
echo " MaxRequestWorkers: $current_max_workers"
echo " ServerLimit: $current_server_limit"
echo " MaxConnectionsPerChild: $current_max_conn"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# Write updated config
cat <<EOF > "$CONFIG_FILE"
<IfModule mpm_event_module>
StartServers ${START_SERVERS}
MinSpareThreads ${MIN_SPARE_THREADS}
MaxSpareThreads ${MAX_SPARE_THREADS}
ThreadLimit 64
ThreadsPerChild 25
MaxRequestWorkers ${MAX_REQUEST_WORKERS}
ServerLimit ${SERVER_LIMIT}
MaxConnectionsPerChild ${MAX_CONNECTIONS_PER_CHILD}
</IfModule>
EOF
echo "MPM config updated:"
echo " StartServers=$START_SERVERS ServerLimit=$SERVER_LIMIT MaxRequestWorkers=$MAX_REQUEST_WORKERS"
echo " MinSpareThreads=$MIN_SPARE_THREADS MaxSpareThreads=$MAX_SPARE_THREADS MaxConnectionsPerChild=$MAX_CONNECTIONS_PER_CHILD"
# Graceful reload
/usr/sbin/httpd -k graceful
echo "Apache graceful reload triggered."