Compare commits

...
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 af7fd193e5 fix(images): Remi origin over HTTPS first; fail builds older than php.net
Security review of #28: plain-http repo metadata could be replayed to keep a
build on old (validly signed) vulnerable PHP. The origin is now tried over
HTTPS first, with http only as a fallback (its HTTPS timed out/reset under the
2026-10-21 release load). CI resolves php.net's current release per branch
(over HTTPS) and the build fails if the installed PHP is older, so neither a
lagging mirror nor replayed metadata can ship a vulnerable PHP.

Tested on 192.168.1.193 (almalinux/10-base): rewritten baseurl installs PHP
8.5.12; the gate passes at 8.5.12 and fails at 8.5.99.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:29:31 -07:00
jknapp e8e3bfd490 Merge pull request 'fix(images): install Remi packages from the origin, not the lagging mirrors' (#28) from fix/remi-origin into trunk
Cloud Apache Container / Shell-Checks (push) Successful in 17s
Cloud Apache Container / Build-and-Push (80) (push) Failing after 4m54s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 6m6s
Cloud Apache Container / Build-and-Push (84) (push) Failing after 4m55s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 6m12s
Cloud Apache Container / Build-FPM-Images (74) (push) Failing after 6m3s
Cloud Apache Container / Build-FPM-Images (82) (push) Failing after 3m30s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 6m22s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 5m57s
Cloud Apache Container / Build-FPM-Images (83) (push) Failing after 4m22s
Cloud Apache Container / Build-FPM-Images (84) (push) Failing after 4m10s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 4m8s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 6m49s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 4m43s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 21m58s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m59s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 36s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 4m52s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 3m12s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 5m16s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 5m38s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 5m20s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 6m45s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 6m34s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 4m33s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 4m26s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 4m49s
2026-10-21 15:02:41 +00:00
shadowdaoandClaude Opus 5.5 c49f456336 fix(images): install Remi packages from the origin, not the lagging mirrors
During the php2shell release the Remi mirrors still served 8.2.34 hours after
8.2.35 was published, so cac/cac-fpm builds shipped vulnerable PHP. Enable the
repo files' baseurl (rpms.remirepo.net) and comment the mirrorlist. Verified on
the build server: a clean almalinux/10-base install now gets PHP 8.2.35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:02:38 -07:00
jknapp bcd737a9e2 Merge pull request 'ci: fail-fast false on every image matrix' (#27) from fix/ci-no-fail-fast into trunk
Cloud Apache Container / Shell-Checks (push) Successful in 42s
Cloud Apache Container / Build-and-Push (85) (push) Failing after 3m27s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 5m26s
Cloud Apache Container / Build-and-Push (80) (push) Failing after 13m6s
Cloud Apache Container / Build-FPM-Images (80) (push) Failing after 2m7s
Cloud Apache Container / Build-and-Push (84) (push) Failing after 11m45s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 15m27s
Cloud Apache Container / Build-FPM-Images (82) (push) Failing after 2m22s
Cloud Apache Container / Build-FPM-Images (85) (push) Failing after 1m19s
Cloud Apache Container / Build-FPM-Images (83) (push) Failing after 4m30s
Cloud Apache Container / Build-and-Push (83) (push) Failing after 18m2s
Cloud Apache Container / Build-FPM-Images (81) (push) Failing after 7m47s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 5m20s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 2m26s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 5m22s
Cloud Apache Container / Build-FPM-Images (84) (push) Failing after 8m36s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 2m11s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 36s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 6m5s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 4m5s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 6m14s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 6m21s
Cloud Apache Container / Build-and-Push (81) (push) Failing after 2m8s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 15m24s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 1m46s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 1m31s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 5m2s
2026-10-21 14:55:53 +00:00
shadowdaoandClaude Opus 5.5 955aec4573 ci: fail-fast false on every image matrix
One failing PHP version (runs 4645/4646: cac-fpm php80, cac php82) cancelled
every other cac/cac-fpm build, so no PHP security build of 8.3-8.5 was pushed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:55:49 -07:00
jknapp 998eb17f39 Merge pull request 'fix(php): bake php2shell ini mitigation into cac/cac-fpm/cac-lsphp/cac-litespeed' (#26) from fix/php2shell-ini-mitigation into trunk
Cloud Apache Container / Shell-Checks (push) Successful in 1m25s
Cloud Apache Container / Build-and-Push (74) (push) Canceled after 4m13s
Cloud Apache Container / Build-and-Push (80) (push) Canceled after 3m58s
Cloud Apache Container / Build-and-Push (83) (push) Canceled after 2m29s
Cloud Apache Container / Build-and-Push (84) (push) Canceled after 31s
Cloud Apache Container / Build-and-Push (85) (push) Canceled after 15s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 5m56s
Cloud Apache Container / Build-FPM-Images (80) (push) Failing after 5m45s
Cloud Apache Container / Build-FPM-Images (81) (push) Canceled after 5m31s
Cloud Apache Container / Build-FPM-Images (82) (push) Canceled after 5m16s
Cloud Apache Container / Build-FPM-Images (83) (push) Canceled after 5m0s
Cloud Apache Container / Build-FPM-Images (84) (push) Canceled after 3m56s
Cloud Apache Container / Build-FPM-Images (85) (push) Canceled after 2m52s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 1m27s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 2m35s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 5m53s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 46s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 5m28s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 6m32s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 6m19s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 6m36s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 4m54s
Cloud Apache Container / Build-and-Push (82) (push) Failing after 2m58s
Cloud Apache Container / Build-and-Push (81) (push) Canceled after 3m41s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 1m31s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m40s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 4m40s
2026-10-21 14:22:07 +00:00
shadowdaoandClaude Opus 5.5 f461d22aae fix(php): bake php2shell ini mitigation into cac/cac-fpm/cac-lsphp/cac-litespeed
CVE-2026-109618 / CVE-2026-109619 (GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp).
Adds configs/zz-php2shell-mitigation.ini (fatal_error_backtraces=0,
session.upload_progress.enabled=0, max_input_nesting_level=16) and installs it
into each image's PHP scan dir as zz-php2shell-mitigation.ini so it sorts after
99-prod-overrides.ini and the entrypoint-written 99-* fragments:

- Dockerfile / Dockerfile.fpm: COPY to /etc/php.d/ next to prod-php.ini.
- Dockerfile.lsphp / Dockerfile.litespeed: COPY to /etc/lsws-templates/ and
  cp into the computed lsphp $SCAN_DIR in the existing overrides RUN.

fatal_error_backtraces is unknown to (and silently ignored by) PHP < 8.5, so
one file serves every version. Remove once all images ship
PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:21:47 -07:00
jknapp cfde82ae44 Merge pull request 'fix(lsphp-apt-upgrade): no early-exit readers under pipefail' (#25) from fix/lsphp-upgrade-pipefail into trunk
Cloud Apache Container / Shell-Checks (push) Successful in 40s
Cloud Apache Container / Build-and-Push (74) (push) Failing after 1m4s
Cloud Apache Container / Build-and-Push (84) (push) Canceled after 0s
Cloud Apache Container / Build-and-Push (85) (push) Canceled after 0s
Cloud Apache Container / Build-and-Push (80) (push) Canceled after 56s
Cloud Apache Container / Build-and-Push (83) (push) Canceled after 10s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m54s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m40s
Cloud Apache Container / Build-FPM-Images (80) (push) Failing after 4m2s
Cloud Apache Container / Build-FPM-Images (81) (push) Canceled after 3m46s
Cloud Apache Container / Build-FPM-Images (83) (push) Canceled after 1m3s
Cloud Apache Container / Build-FPM-Images (84) (push) Canceled after 47s
Cloud Apache Container / Build-FPM-Images (85) (push) Canceled after 33s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 4m7s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 6m22s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m56s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 2m2s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 39s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 45s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 5m46s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 5m46s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 5m43s
Cloud Apache Container / Build-and-Push (81) (push) Canceled after 41s
Cloud Apache Container / Build-and-Push (82) (push) Canceled after 26s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 4m30s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 4m34s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 5m15s
2026-10-21 14:07:07 +00:00
shadowdaoandClaude Opus 5.5 132e591503 fix(lsphp-apt-upgrade): no early-exit readers under pipefail
apt-cache madison | grep -q and apt-cache policy | awk '...exit' can SIGPIPE
the writer under pipefail and report lsphp missing when it is there. Read
each output into a variable and match in the shell. Fixes the Shell-Checks
failure on trunk (lsphp-info-probe.test.sh structural scan).

Tested: lsphp-info-probe.test.sh 36/36, shellcheck clean, and the script run
in litespeedtech/openlitespeed:1.8.4-lsphp85 upgrades 8.5.0 -> 8.5.11 OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:07:05 -07:00
jknapp 642b97707d Merge pull request 'feat(images): upgrade lsphp from the LiteSpeed repo at build time; label PHP version' (#24) from feat/lsphp-upgrade-at-build into trunk
Cloud Apache Container / Shell-Checks (push) Failing after 12s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m55s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m47s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m45s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 5m50s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 5m55s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 1m52s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 5m37s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 5m53s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m39s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m43s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 6m32s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 2m6s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 5m30s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 5m33s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 6m41s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 3m1s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 34s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 3m8s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 5m9s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 5m25s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 6m5s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 6m6s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 6m29s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m27s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 5m24s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 4m58s
2026-10-21 13:08:15 +00:00
shadowdaoandClaude Opus 5.5 71ca114529 feat(images): upgrade lsphp from the LiteSpeed repo at build time; label PHP version
cac-litespeed and cac-lsphp start FROM litespeedtech/openlitespeed:1.8.4-lsphpNN,
which ships lsphp85 8.5.0 / lsphp84 8.4.15 (built 2025-12). lsphp only moved
forward as a side effect of installing lsphpNN-ldap. lsphp-apt-upgrade.sh now
sweeps every installed lsphpNN* package (and OS packages) to the repo's current
release on every build and fails the build if the repo is unreachable or the
result doesn't match what CI resolved from the repo index. CI passes a per-run
CACHE_BUST so the upgrade layer is never a cache hit, and images carry
org.whp.php-version / org.whp.lsphp-package-version labels.

Tested on .148 (2026-10-21): cac-lsphp php85/php84 and cac-litespeed php85 built
from this branch report the same PHP (8.5.11 / 8.4.26), identical module lists
and OLS 1.8.4 as the current registry images; with the hardening flags each
serves/runs PHP as the customer uid, restarts, and stops cleanly (exit 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:07:48 -07:00
jknapp 74e12407f8 Merge pull request 'fix(entrypoint): no recursive chmod, graceful stop, fpm error_log owner, symlink-safe root paths, dotfile deny' (#23) from fix/no-recursive-chmod-on-start into trunk
Cloud Apache Container / Shell-Checks (push) Successful in 12s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m58s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 2m36s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m51s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 3m16s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 9m14s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 8m24s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 8m51s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 8m43s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m44s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 59s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 3m16s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m26s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m13s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m57s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 31s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 3m34s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 4m10s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 9m24s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 9m38s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 9m38s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 5m13s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 8m48s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 8m45s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m22s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 3m11s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 3m38s
2026-10-21 00:01:29 +00:00
shadowdaoandClaude Opus 5.5 74648ba34b fix(security): deny config/VCS dotfiles over HTTP on standalone cac and cac-litespeed
Measured on .148: standalone cac served /.env with its contents, and
cac-litespeed served /.env even at mode 0640 (OLS runs as the customer uid
there). shared-httpd and shared-ols already deny these via WHP's drop-ins; the
standalone tiers never got them.

- cac: new configs/whp-security.conf in /etc/httpd/conf.d — the same named
  FilesMatch + DirectoryMatch list as WHP's shared-httpd-security.conf,
  re-rooted to /home.
- cac-litespeed: the same `context exp:` regex as WHP's shared-ols vhconf
  template, added to the vhost template.

Named list (.user.ini .env* .git .svn .hg .bzr .ht* .whp .site-builder), not a
blanket dotfile deny, so /.well-known/ stays reachable. HTTP only: PHP still
reads these files from disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:31:56 -07:00
shadowdaoandClaude Opus 5.5 86a88c87db fix(safe-paths): unlink planted symlinks as the customer, then fail closed
Root rm -f in drop_symlink could be redirected by a parent-directory swap to
delete arbitrary files as root. drop_symlink and the logs/ symlink sweep now
unlink AS THE CUSTOMER (can never delete anything the customer couldn't), and
drop_symlink still refuses to start if the path is a symlink afterwards; the
pre-daemon logs/ gate re-checks and fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:06:41 -07:00
shadowdaoandClaude Opus 5.5 02d93f4d1a fix(safe-paths): fail closed on planted symlinks before root daemons start
drop_symlink unlinks as root (rm on the final component never follows the
target and the customer cannot deny it) and refuses to start if the path is
still a symlink. user_touch refuses unless it ends up a regular file. New
require_safe_logs runs immediately before httpd / php-fpm start (they open
files under /home/$user/logs as root): logs/, logs/apache, logs/php-fpm must be
real directories and nothing under logs/ may be other than a regular file or
directory, else the entrypoint exits 1 without starting any daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:04:21 -07:00
shadowdaoandClaude Opus 5.5 94a73ea8ab fix(safe-paths): unlink symlinks and create files as the customer, never as root
drop_symlink and the logs/ symlink sweep now unlink AS THE CUSTOMER, and
user_touch no longer falls back to a root chown -h (a parent dir swapped for a
symlink between check and use could still redirect a root path operation).
A root-owned leftover error_log is fixed by the existing chown -R, which never
follows symlinks. Every operation these helpers perform is now safe even if the
customer races it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:52:01 -07:00
shadowdaoandClaude Opus 5.5 0ffccac4b8 fix(entrypoint): root never follows customer symlinks under /home/$user
/home/$user is the customer's bind mount; they can plant symlinks in it. The
entrypoints ran as root and wrote/chowned through customer paths, so e.g.
logs/php-fpm/error.log -> /etc/shadow (or a dangling crontab -> /etc/...)
handed the customer that file, i.e. container root. New scripts/safe-paths.sh,
used by entrypoint.sh and entrypoint-fpm.sh:

- secure_home: refuse to start if /home/$user is not a real dir; logs/ must be
  a real dir and every symlink under logs/ is removed and logged (apache and
  php-fpm open those files as root at start).
- dirs, the pool error_log and the default crontab are created AS THE CUSTOMER
  (setpriv, runuser fallback); a pre-existing root-owned error_log is fixed
  with chown -h (never dereferences).
- the user crontab is read as the customer and piped to `crontab -u $user -`
  (root reading it could install any root-readable file where the customer
  can `crontab -l` it). DEV-mode crontab/mysql_creds are written via as_user tee.
- the o+x directory pass (find -type d, no -L) runs as the customer, so a dir
  swapped for a symlink before chmod can only reach what the customer owns.
- chown -R is unchanged: GNU default -P lchown()s symlinks, never follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:49:18 -07:00
shadowdaoandClaude Opus 5.5 eaf616eb30 fix(entrypoint): graceful docker stop + cac-fpm error_log owner on first start
Graceful stop (cac, cac-fpm, shared-httpd, and form-sender which runs cac):
entrypoint.sh, entrypoint-fpm.sh and entrypoint-shared-httpd.sh end with bash
as PID 1 tailing logs. PID 1 only receives signals it has a handler for, so
docker stop's SIGTERM was dropped; docker waited the full 10s and SIGKILLed
apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT and
stop each daemon they started via the new scripts/stop-services.sh: httpd
graceful-stop (WINCH), php-fpm QUIT, crond TERM, each with a bounded wait and a
TERM/KILL fallback, budgets summing to < 10s, then exit 0. The log tail is
backgrounded + `wait`ed (and shared-httpd's 60s rescan sleep likewise) so the
trap runs immediately. Log streaming to docker logs is unchanged.

cac-fpm error_log: entrypoint-fpm.sh `touch`ed logs/php-fpm/error.log as root
AFTER its chown -R, so on a site's first start the pool (customer uid) could
not open it and PHP errors went out as FastCGI stderr to shared-httpd's log
until the next restart. The file is now created and chowned to the customer
right after the logs dir is made, before php-fpm starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:44:14 -07:00
shadowdaoandClaude Opus 5.5 14761597bb fix(entrypoint): stop chmod -R 755 on the customer docroot every start
entrypoint.sh and entrypoint-fpm.sh ran `chmod -R 755 /home/$user` on every
container start. /home/$user is the bind-mounted customer site directory, so
every file (wp-config.php included) became world-readable and executable, and
any tighter permissions the customer set were reverted on each restart.

The line dates from the initial commit (95b9397) and was copied into the FPM
entrypoint (c781678); neither gives a reason. The legitimate need is that
Apache (bundled httpd, or shared-httpd reading /mnt/users/...) runs as
`apache`, not the customer, and must be able to traverse directories to serve
static files.

Keep the recursive chown. Replace the recursive chmod with:
- chmod 755 on the docroot directory itself (matches entrypoint-litespeed.sh)
- o+x on directories that lack it (find -type d ! -perm -o=x)
Regular files are never touched: execute is never added, and wp-config.php or
any other customer-restricted file is never loosened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:33:29 -07:00
jknapp db19ee24c3 ci: add workflow_dispatch trigger for manual rebuilds
Cloud Apache Container / Shell-Checks (push) Successful in 33s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m32s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m32s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 3m13s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m31s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 3m32s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 1m23s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 1m25s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 32s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 43s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 4m28s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 4m9s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 1m19s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 1m41s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m51s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 3m47s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 33s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m37s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 7m42s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 6m47s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m21s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 6m19s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m15s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 2m36s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 1m26s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 4m1s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 6m3s
2026-09-28 03:15:17 +00:00
shadowdaoandClaude Sonnet 5 3841b18820 fix(shared-ols): enableCache 0 so headerless responses are never cached
Cloud Apache Container / Shell-Checks (push) Successful in 31s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 2m11s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 1m48s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 2m35s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 1m58s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 2m14s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 3m12s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 2m30s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m30s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 2m15s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m47s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 2m52s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m44s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 37s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 1m24s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 38s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m1s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 1m2s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 2m6s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 58s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m1s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 33s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 31s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 3m18s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m25s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 1m31s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 34s
LSCWP's Divi compat disables the plugin for ?et_fb= / ?et_pb_preview= /
?preview=true, so those responses carry no X-LiteSpeed-Cache-Control header.
With module-level enableCache 1, OLS cached them anyway (Divi Visual Builder
pages served from cache; the plugin's own QS exclusion never runs).

enableCache 0 caches only responses that opt in via an explicit
X-LiteSpeed-Cache-Control: public header. Measured on OLS 1.8.4: headerless
= miss,hit under 1 / not cached under 0; explicit public cached under both.
Matches the dedicated cac-litespeed tier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 06:55:02 -07:00
shadowdaoandClaude Opus 5 48b714447f chore: pin the shared-memory project key
Cloud Apache Container / Shell-Checks (push) Successful in 12s
Cloud Apache Container / Build-and-Push (74) (push) Successful in 1m31s
Cloud Apache Container / Build-and-Push (80) (push) Successful in 2m1s
Cloud Apache Container / Build-and-Push (83) (push) Successful in 3m27s
Cloud Apache Container / Build-and-Push (84) (push) Successful in 2m8s
Cloud Apache Container / Build-and-Push (85) (push) Successful in 1m28s
Cloud Apache Container / Build-FPM-Images (74) (push) Successful in 1m59s
Cloud Apache Container / Build-FPM-Images (80) (push) Successful in 1m46s
Cloud Apache Container / Build-FPM-Images (81) (push) Successful in 2m10s
Cloud Apache Container / Build-FPM-Images (82) (push) Successful in 1m26s
Cloud Apache Container / Build-FPM-Images (83) (push) Successful in 2m9s
Cloud Apache Container / Build-FPM-Images (84) (push) Successful in 6m24s
Cloud Apache Container / Build-FPM-Images (85) (push) Successful in 2m19s
Cloud Apache Container / Build-LiteSpeed-Images (81) (push) Successful in 1m14s
Cloud Apache Container / Build-LiteSpeed-Images (82) (push) Successful in 42s
Cloud Apache Container / Build-LiteSpeed-Images (83) (push) Successful in 34s
Cloud Apache Container / Build-LSPHP-Images (81) (push) Successful in 1m17s
Cloud Apache Container / Build-LSPHP-Images (82) (push) Successful in 2m33s
Cloud Apache Container / Build-LSPHP-Images (83) (push) Successful in 1m56s
Cloud Apache Container / Build-LSPHP-Images (84) (push) Successful in 59s
Cloud Apache Container / Build-LSPHP-Images (85) (push) Successful in 1m2s
Cloud Apache Container / Build-Shared-httpd (push) Successful in 29s
Cloud Apache Container / Build-Shared-OLS (push) Successful in 33s
Cloud Apache Container / Build-and-Push (81) (push) Successful in 2m28s
Cloud Apache Container / Build-and-Push (82) (push) Successful in 2m32s
Cloud Apache Container / Build-LiteSpeed-Images (84) (push) Successful in 35s
Cloud Apache Container / Build-LiteSpeed-Images (85) (push) Successful in 1m25s
Ties every collaborator's Claude Code to the same shared-memory project
automatically, instead of each machine inferring a key from the directory
name. Read by project.identify at session start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 08:09:04 -07:00
17 changed files with 725 additions and 64 deletions

No files matched your search

+114
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- trunk
workflow_dispatch:
jobs:
# Shell gate. Runs FIRST and costs seconds; the images below do not depend on
@@ -50,6 +51,7 @@ jobs:
scripts/ols-htaccess-watcher.sh \
scripts/create-vhost-litespeed.sh \
scripts/install-lscache-wp.sh \
scripts/lsphp-apt-upgrade.sh \
scripts/tune-mpm.sh \
scripts/tests/lsphp-info-probe.test.sh
@@ -59,6 +61,9 @@ jobs:
Build-and-Push:
runs-on: ubuntu-latest
strategy:
# One PHP version failing (e.g. an EOL stream) must not cancel the
# security builds of the others.
fail-fast: false
matrix:
phpver: [74, 80, 81, 82, 83, 84, 85]
steps:
@@ -71,6 +76,28 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Per-run cache-bust value. Each job's buildx builder is created fresh by
# setup-buildx-action (no cache-from/cache-to here), so today nothing is
# cached between runs — this keeps it that way if a cache is ever added:
# the package-upgrade layers consume CACHE_BUST and can never be reused.
- name: Cache-bust value
id: bust
run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT"
# The minimum PHP version this build must ship: php.net's current release for
# the branch (over HTTPS). The Dockerfile fails if Remi gave us anything older,
# so lagging mirrors or replayed repo metadata can't ship a vulnerable PHP.
- name: Resolve current PHP release from php.net
id: phpmin
run: |
set -euo pipefail
v="${{ matrix.phpver }}"; branch="${v:0:1}.${v:1}"
json=$(curl -fsS --retry 5 --max-time 60 "https://www.php.net/releases/index.php?json&max=1&version=${branch}")
min=$(printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(next(iter(d)) if isinstance(d,dict) and d else "")')
if [ -z "$min" ]; then echo "::error::no php.net release found for ${branch}"; exit 1; fi
echo "php.net current ${branch}: ${min}"
echo "min=${min}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea
uses: docker/login-action@v3
with:
@@ -85,6 +112,8 @@ jobs:
push: true
build-args: |
PHPVER=${{ matrix.phpver }}
CACHE_BUST=${{ steps.bust.outputs.value }}
EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }}
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac:latest' || '' }}
@@ -92,6 +121,9 @@ jobs:
Build-FPM-Images:
runs-on: ubuntu-latest
strategy:
# One PHP version failing (e.g. an EOL stream) must not cancel the
# security builds of the others.
fail-fast: false
matrix:
phpver: [74, 80, 81, 82, 83, 84, 85]
steps:
@@ -104,6 +136,28 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Per-run cache-bust value. Each job's buildx builder is created fresh by
# setup-buildx-action (no cache-from/cache-to here), so today nothing is
# cached between runs — this keeps it that way if a cache is ever added:
# the package-upgrade layers consume CACHE_BUST and can never be reused.
- name: Cache-bust value
id: bust
run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT"
# The minimum PHP version this build must ship: php.net's current release for
# the branch (over HTTPS). The Dockerfile fails if Remi gave us anything older,
# so lagging mirrors or replayed repo metadata can't ship a vulnerable PHP.
- name: Resolve current PHP release from php.net
id: phpmin
run: |
set -euo pipefail
v="${{ matrix.phpver }}"; branch="${v:0:1}.${v:1}"
json=$(curl -fsS --retry 5 --max-time 60 "https://www.php.net/releases/index.php?json&max=1&version=${branch}")
min=$(printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(next(iter(d)) if isinstance(d,dict) and d else "")')
if [ -z "$min" ]; then echo "::error::no php.net release found for ${branch}"; exit 1; fi
echo "php.net current ${branch}: ${min}"
echo "min=${min}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea
uses: docker/login-action@v3
with:
@@ -119,6 +173,8 @@ jobs:
push: true
build-args: |
PHPVER=${{ matrix.phpver }}
CACHE_BUST=${{ steps.bust.outputs.value }}
EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }}
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:latest' || '' }}
@@ -126,6 +182,9 @@ jobs:
Build-LiteSpeed-Images:
runs-on: ubuntu-latest
strategy:
# One PHP version failing (e.g. an EOL stream) must not cancel the
# security builds of the others.
fail-fast: false
matrix:
# PHP 7.4/8.0 deliberately excluded — the LiteSpeed prebuilt base
# images stop at older OLS releases for those PHP versions, and the
@@ -142,6 +201,29 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Per-run cache-bust value. Each job's buildx builder is created fresh by
# setup-buildx-action (no cache-from/cache-to here), so today nothing is
# cached between runs — this keeps it that way if a cache is ever added:
# the package-upgrade layers consume CACHE_BUST and can never be reused.
- name: Cache-bust value
id: bust
run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT"
# Ask the LiteSpeed apt repo (the same one the base image's apt uses) for the
# CURRENT lsphp version. The Dockerfile asserts the installed package equals
# this (build fails if the repo changed mid-build — just re-run) and writes
# it into the image labels org.whp.php-version / org.whp.lsphp-package-version.
- name: Resolve current lsphp${{ matrix.phpver }} from the LiteSpeed repo
id: lsphp
run: |
set -euo pipefail
idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages)
pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1)
if [ -z "$pkg" ]; then echo "::error::lsphp${{ matrix.phpver }} not found in the LiteSpeed repo index"; exit 1; fi
echo "lsphp${{ matrix.phpver }} in repo: $pkg (PHP ${pkg%%-*})"
echo "pkg=$pkg" >> "$GITHUB_OUTPUT"
echo "php=${pkg%%-*}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea
uses: docker/login-action@v3
with:
@@ -158,6 +240,9 @@ jobs:
build-args: |
PHPVER=${{ matrix.phpver }}
OLS_VERSION=1.8.4
CACHE_BUST=${{ steps.bust.outputs.value }}
EXPECT_LSPHP_PKG=${{ steps.lsphp.outputs.pkg }}
EXPECT_PHP_VERSION=${{ steps.lsphp.outputs.php }}
# 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
@@ -170,6 +255,9 @@ jobs:
Build-LSPHP-Images:
runs-on: ubuntu-latest
strategy:
# One PHP version failing (e.g. an EOL stream) must not cancel the
# security builds of the others.
fail-fast: false
matrix:
# Same PHP matrix as cac-litespeed (81–85): cac-lsphp is the detached
# backend for the shared-ols tier and shares the litespeed prebuilt
@@ -186,6 +274,29 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Per-run cache-bust value. Each job's buildx builder is created fresh by
# setup-buildx-action (no cache-from/cache-to here), so today nothing is
# cached between runs — this keeps it that way if a cache is ever added:
# the package-upgrade layers consume CACHE_BUST and can never be reused.
- name: Cache-bust value
id: bust
run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT"
# Ask the LiteSpeed apt repo (the same one the base image's apt uses) for the
# CURRENT lsphp version. The Dockerfile asserts the installed package equals
# this (build fails if the repo changed mid-build — just re-run) and writes
# it into the image labels org.whp.php-version / org.whp.lsphp-package-version.
- name: Resolve current lsphp${{ matrix.phpver }} from the LiteSpeed repo
id: lsphp
run: |
set -euo pipefail
idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages)
pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1)
if [ -z "$pkg" ]; then echo "::error::lsphp${{ matrix.phpver }} not found in the LiteSpeed repo index"; exit 1; fi
echo "lsphp${{ matrix.phpver }} in repo: $pkg (PHP ${pkg%%-*})"
echo "pkg=$pkg" >> "$GITHUB_OUTPUT"
echo "php=${pkg%%-*}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea
uses: docker/login-action@v3
with:
@@ -202,6 +313,9 @@ jobs:
build-args: |
PHPVER=${{ matrix.phpver }}
OLS_VERSION=1.8.4
CACHE_BUST=${{ steps.bust.outputs.value }}
EXPECT_LSPHP_PKG=${{ steps.lsphp.outputs.pkg }}
EXPECT_PHP_VERSION=${{ steps.lsphp.outputs.php }}
# OLS_VERSION pinned to 1.8.4 to match Build-LiteSpeed-Images — same
# prebuilt base, same lsphp binaries. Bump both together.
tags: |
+1
View File
@@ -0,0 +1 @@
cloud-hosting-platform/cloud-apache-container
+25
View File
@@ -1,10 +1,24 @@
FROM almalinux/10-base
ARG PHPVER=83
# Per-CI-run value: the first RUN below (dnf update) and everything after it can
# never be a layer-cache hit, so every build gets the repos' current packages.
# Local builds: --build-arg CACHE_BUST=$(date +%s) or --no-cache.
ARG CACHE_BUST=
# php.net's current release for this branch (CI); the build fails on older PHP.
ARG EXPECT_PHP_MIN=
# Install repos, update, install only needed packages, 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 && \
# Use Remi's origin, not the mirrorlist: during the 2026-10-21 PHP security
# release the mirrors lagged hours behind (still 8.2.34 when 8.2.35 was out),
# so builds shipped unpatched PHP. HTTPS first; plain http only as a fallback
# because the origin's HTTPS dropped/timed out under that day's load. A
# replayed (old) http index can't ship old PHP: packages stay GPG-checked and
# the EXPECT_PHP_MIN gate below fails the build on anything older than php.net.
sed -i -e 's|^#baseurl=http://\(.*\)$|baseurl=https://\1 http://\1|' -e 's/^mirrorlist=/#mirrorlist=/' /etc/yum.repos.d/remi*.repo && \
printf 'retries=10\ntimeout=60\n' >> /etc/dnf/dnf.conf && \
dnf update -y && \
dnf install -y httpd mod_ssl openssl wget procps cronie iproute postgresql-devel microdnf less git \
nano rsync unzip zip mariadb bind-utils jq patch nc tree dos2unix && \
@@ -23,6 +37,12 @@ RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \
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 && \
# CI passes php.net's current release for the branch; never ship older PHP.
if [ -n "$EXPECT_PHP_MIN" ]; then \
php -r 'exit(version_compare(PHP_VERSION, getenv("EXPECT_PHP_MIN"), ">=") ? 0 : 1);' || \
{ echo "FATAL: built PHP $(php -r 'echo PHP_VERSION;') is older than php.net $EXPECT_PHP_MIN"; exit 1; }; \
else echo "WARNING: no EXPECT_PHP_MIN (local build) - PHP version not gated"; fi && \
echo "PHP version report:" && php -v && rpm -q php-cli php-common php-fpm && \
rm -rf /tmp/*
# Download and install wp-cli (consider pinning version for reproducibility)
@@ -36,10 +56,15 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local
# Copy configs and web files
COPY ./configs/default-index.conf /etc/httpd/conf.d/
COPY ./configs/prod-php.ini /etc/php.ini
# php2shell mitigation (CVE-2026-109618/-109619). Named zz- so it sorts after
# every other scan-dir ini (incl. runtime-written 99-*) and wins. Remove once
# all images ship PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.
COPY ./configs/zz-php2shell-mitigation.ini /etc/php.d/zz-php2shell-mitigation.ini
COPY ./configs/phpinfo.php /var/www/html/
COPY ./configs/mariadb.repo /etc/yum.repos.d/
COPY ./configs/index.php /var/www/html/
COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
COPY ./configs/whp-security.conf /etc/httpd/conf.d/
# Set up cron job in a single layer
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
+24
View File
@@ -1,10 +1,24 @@
FROM almalinux/10-base
ARG PHPVER=83
# Per-CI-run value: the first RUN below (dnf update) and everything after it can
# never be a layer-cache hit, so every build gets the repos' current packages.
# Local builds: --build-arg CACHE_BUST=$(date +%s) or --no-cache.
ARG CACHE_BUST=
# php.net's current release for this branch (CI); the build fails on older PHP.
ARG EXPECT_PHP_MIN=
# 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 && \
# Use Remi's origin, not the mirrorlist: during the 2026-10-21 PHP security
# release the mirrors lagged hours behind (still 8.2.34 when 8.2.35 was out),
# so builds shipped unpatched PHP. HTTPS first; plain http only as a fallback
# because the origin's HTTPS dropped/timed out under that day's load. A
# replayed (old) http index can't ship old PHP: packages stay GPG-checked and
# the EXPECT_PHP_MIN gate below fails the build on anything older than php.net.
sed -i -e 's|^#baseurl=http://\(.*\)$|baseurl=https://\1 http://\1|' -e 's/^mirrorlist=/#mirrorlist=/' /etc/yum.repos.d/remi*.repo && \
printf 'retries=10\ntimeout=60\n' >> /etc/dnf/dnf.conf && \
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 && \
@@ -22,6 +36,12 @@ RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \
# Create needed dirs, install PHP, clean up (no SSL cert, no httpd)
RUN mkdir -p /run/php-fpm/ && \
/scripts/install-php$PHPVER.sh && \
# CI passes php.net's current release for the branch; never ship older PHP.
if [ -n "$EXPECT_PHP_MIN" ]; then \
php -r 'exit(version_compare(PHP_VERSION, getenv("EXPECT_PHP_MIN"), ">=") ? 0 : 1);' || \
{ echo "FATAL: built PHP $(php -r 'echo PHP_VERSION;') is older than php.net $EXPECT_PHP_MIN"; exit 1; }; \
else echo "WARNING: no EXPECT_PHP_MIN (local build) - PHP version not gated"; fi && \
echo "PHP version report:" && php -v && rpm -q php-cli php-common php-fpm && \
rm -rf /tmp/*
# Download and install wp-cli
@@ -34,6 +54,10 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local
# Copy configs (PHP only, no Apache configs)
COPY ./configs/prod-php.ini /etc/php.ini
# php2shell mitigation (CVE-2026-109618/-109619). Named zz- so it sorts after
# every other scan-dir ini (incl. runtime-written 99-*) and wins. Remove once
# all images ship PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.
COPY ./configs/zz-php2shell-mitigation.ini /etc/php.d/zz-php2shell-mitigation.ini
COPY ./configs/mariadb.repo /etc/yum.repos.d/
# Set up cron job for log rotation
+30 -2
View File
@@ -45,8 +45,23 @@ ENV PHPVER=${PHPVER}
## 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 \
##
## SECURITY UPDATES AT BUILD TIME: lsphp-apt-upgrade.sh also upgrades the OS and
## sweeps EVERY installed lsphp${PHPVER}* package to the LiteSpeed repo's current
## release, failing the build if the repo is unreachable — so a rebuild ships a
## PHP security fix as soon as LiteSpeed publishes the .deb, without waiting for
## a new litespeedtech/openlitespeed base tag. See the script header.
##
## CACHE_BUST: CI passes a per-run value so this layer can never be a cache hit
## (an ARG's first use in a RUN is part of that layer's cache key). Local builds:
## pass --build-arg CACHE_BUST=$(date +%s) or use --no-cache, or you may get the
## lsphp from your last build. EXPECT_* are resolved by CI from the repo index
## and asserted here; the same values become the image labels below.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN /usr/local/sbin/lsphp-apt-upgrade.sh "${PHPVER}" \
gettext-base sudo cron \
ca-certificates curl wget \
lsphp${PHPVER}-ldap && \
@@ -68,6 +83,10 @@ COPY ./scripts/entrypoint-litespeed.sh \
/scripts/
RUN chmod +x /scripts/*
COPY ./configs/litespeed/ /etc/lsws-templates/
## php2shell mitigation (CVE-2026-109618/-109619). Named zz- so it sorts after
## every other scan-dir ini (incl. runtime-written 99-*) and wins. Remove once
## all images ship PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.
COPY ./configs/zz-php2shell-mitigation.ini /etc/lsws-templates/zz-php2shell-mitigation.ini
## 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
@@ -77,6 +96,7 @@ RUN bash -c 'set -e; \
SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$SCAN_DIR"; \
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
cp /etc/lsws-templates/zz-php2shell-mitigation.ini "$SCAN_DIR/zz-php2shell-mitigation.ini"; \
echo "wrote overrides to $SCAN_DIR"'
## Disable the OLS WebAdmin port for customer-facing containers. Bind admin
@@ -87,6 +107,14 @@ RUN sed -i 's|^[[:space:]]*address[[:space:]]\+\*:| address 127.0
## Cron entry for log rotation (mirrors cac:phpXX).
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
## Inspect without running the image:
## docker buildx imagetools inspect <ref> --format '{{json .Image.Config.Labels}}'
## Values come from CI's repo-index lookup and were asserted against the
## installed packages by lsphp-apt-upgrade.sh above ("unverified" = local build).
LABEL org.whp.php-version="${EXPECT_PHP_VERSION:-unverified}" \
org.whp.lsphp-package-version="${EXPECT_LSPHP_PKG:-unverified}" \
org.whp.php-flavour="lsphp${PHPVER}"
EXPOSE 80 443
## Healthcheck: the entrypoint drops a static /healthz into the customer
+44 -8
View File
@@ -60,9 +60,20 @@ ARG PHPVER=83
## Toolchain layer, deliberately BEFORE the source COPY so editing the extension
## does not re-resolve the PHP version (which is what caused the skew above).
## Records the exact lsphp version the headers belong to; stage 2 pins to it.
##
## lsphp-apt-upgrade.sh (see its header) refuses to run on stale/unreachable
## repo lists and sweeps every lsphp${PHPVER}* package to the repo's current
## release. CACHE_BUST is per-CI-run so this layer — and therefore the recorded
## version stage 2 pins to — is never a cache hit; it is re-declared in stage 2.
## --no-os-upgrade: this stage is thrown away, OS updates would only cost time.
## EXPECT_* (CI's repo-index lookup) are asserted here too, so a mismatch fails
## before the compile rather than after it.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN set -e; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
/usr/local/sbin/lsphp-apt-upgrade.sh --no-os-upgrade "${PHPVER}" \
build-essential autoconf pkg-config \
lsphp${PHPVER}-dev; \
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
@@ -141,17 +152,29 @@ ENV PHPVER=${PHPVER}
## cache hit, new version => this layer re-runs and lands on the same one. The
## explicit `=$V` pins then make a mid-build repo roll a LOUD apt failure instead
## of a silent skew. Verified satisfiable on PHP 8.1/8.3/8.5 (2026-08-05).
##
## SECURITY UPDATES AT BUILD TIME: lsphp-apt-upgrade.sh upgrades the Ubuntu OS
## packages and every other lsphp${PHPVER}* package (extensions such as -msgpack
## are NOT dragged along by the -common pin) to the repos' current versions, and
## fails the build if the repo is unreachable. CACHE_BUST makes this layer a
## guaranteed cache miss per CI run; EXPECT_* are CI's repo-index lookup,
## asserted by the script and written into the image labels below.
ARG CACHE_BUST=
ARG EXPECT_LSPHP_PKG=
ARG EXPECT_PHP_VERSION=
COPY --from=ext-build /build-out/lsphp.version /etc/cac-lsphp-build.version
COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh
RUN set -e; \
V=$(cat /etc/cac-lsphp-build.version); \
apt-get update; \
if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
if ! /usr/local/sbin/lsphp-apt-upgrade.sh "${PHPVER}" \
ca-certificates \
lsphp${PHPVER}="$V" lsphp${PHPVER}-common="$V" lsphp${PHPVER}-ldap="$V"; then \
echo "FATAL: lsphp${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; \
echo "FATAL: lsphp-apt-upgrade.sh failed (reason above). If apt said" >&2; \
echo " \"Version '$V' ... was not found\" or refused a downgrade: $V is what" >&2; \
echo " cac_path_parity was compiled against, but the LiteSpeed repo (which" >&2; \
echo " keeps only the current release) now offers a newer one — either it" >&2; \
echo " published mid-build or ext-build was a stale cache hit. Re-run the" >&2; \
echo " build (CI) / rebuild with --no-cache (local)." >&2; \
exit 1; \
fi; \
apt-get clean; \
@@ -178,10 +201,15 @@ RUN chmod +x /scripts/entrypoint-lsphp.sh /scripts/detect-memory-lsphp.sh /scrip
## Apply production lsphp ini overrides into lsphp's scan dir (path varies by
## PHP minor version; ask lsphp directly — same idiom as Dockerfile.litespeed).
COPY ./configs/litespeed/lsphp-overrides.ini /etc/lsws-templates/lsphp-overrides.ini
## php2shell mitigation (CVE-2026-109618/-109619). Named zz- so it sorts after
## every other scan-dir ini (incl. runtime-written 99-*) and wins. Remove once
## all images ship PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.
COPY ./configs/zz-php2shell-mitigation.ini /etc/lsws-templates/zz-php2shell-mitigation.ini
RUN bash -c 'set -e; \
SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$SCAN_DIR"; \
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
cp /etc/lsws-templates/zz-php2shell-mitigation.ini "$SCAN_DIR/zz-php2shell-mitigation.ini"; \
echo "wrote overrides to $SCAN_DIR"'
## Install the cac_path_parity extension into lsphp's own extension_dir and load
@@ -218,6 +246,14 @@ RUN bash -c 'set -e; \
## default so the runuser exec path can't drop it.
ENV LSPHP_ENABLE_USER_INI=on
## Inspect without running the image:
## docker buildx imagetools inspect <ref> --format '{{json .Image.Config.Labels}}'
## Values come from CI's repo-index lookup and were asserted against the
## installed packages by lsphp-apt-upgrade.sh above ("unverified" = local build).
LABEL org.whp.php-version="${EXPECT_PHP_VERSION:-unverified}" \
org.whp.lsphp-package-version="${EXPECT_LSPHP_PKG:-unverified}" \
org.whp.php-flavour="lsphp${PHPVER}"
EXPOSE 9000
## TCP-connect + lsphp-alive check (LSAPI isn't FastCGI, so no cgi-fcgi ping).
+1
View File
@@ -13,6 +13,7 @@ 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/stop-services.sh /scripts/stop-services.sh
COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh
RUN chmod +x /scripts/*
+12
View File
@@ -72,6 +72,18 @@ virtualHostConfig {
addDefaultCharset off
}
## SECURITY: never serve config / VCS dotfiles over HTTP. Same named list as
## WHP's shared-ols vhconf template (first `context exp:` there) and
## shared-httpd's 000-whp-security.conf. Measured on .148: this tier served
## /.env even at mode 0640, because OLS runs as the customer uid here. An
## `exp:` regex context wins over the `context /` prefix context regardless
## of order (measured on OLS 1.8.4 for shared-ols), so a customer .htaccess
## cannot reopen it. Named, not blanket "^\.": /.well-known/ (ACME,
## security.txt, app-site-association) must stay reachable.
context exp:^/(.*/)?\.(user\.ini|env|git|svn|hg|bzr|ht|whp|site-builder) {
allowBrowse 0
}
rewrite {
enable 1
autoLoadHtaccess 1
+15 -3
View File
@@ -23,10 +23,22 @@ 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
## PUBLIC (anonymous) caching ONLY: checkPublicCache 1 lets OLS serve cacheable,
## non-logged-in responses that are EXPLICITLY marked by the LiteSpeed Cache WP
## plugin's X-LiteSpeed-Cache-Control headers (ignoreRespCacheCtrl=0 honors them).
##
## enableCache is 0 ON PURPOSE. At module scope enableCache 1 means "cache every
## cacheable-looking public response by default", INCLUDING ones that carry no
## cache header at all. That bit us on Divi: LSCWP's Divi compat calls
## litespeed_disable_all for ?et_fb= / ?et_pb_preview= / ?p=N&preview=true, which
## switches the plugin off for the request, so it sends NO header (and its own
## "Do Not Cache Query Strings" never runs) — and OLS stored the Visual Builder
## page under enableCache 1. With enableCache 0 only responses that opt in via
## X-LiteSpeed-Cache-Control: public are stored; a plugin-disabled request is
## simply not cached. Measured on OLS 1.8.4 (2026-09-21): headerless response =
## miss,hit under 1; not cached under 0; explicit `public` header cached under
## both. Matches the dedicated cac-litespeed tier (configs/litespeed/site-template.tpl).
##
## 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
@@ -45,7 +57,7 @@ module cache {
respCookieCache 1
ignoreReqCacheCtrl 0
ignoreRespCacheCtrl 0
enableCache 1
enableCache 0
enablePrivateCache 0
}
## ---- end shared-ols server append ----
+29
View File
@@ -0,0 +1,29 @@
## SECURITY: never serve config / VCS dotfiles over HTTP (standalone cac).
##
## Same named list as WHP's shared-httpd drop-in (web-files/configs/
## shared-httpd-security.conf, 000-whp-security.conf on every shared-httpd host),
## re-rooted from /mnt/users to /home because here the docroot is
## /home/<user>/public_html. Standalone cac never got that drop-in, and measured
## on .148 it served /.env with contents.
##
## <FilesMatch> matches the FILENAME resolved from the filesystem path, not the
## request URL, so it cannot be side-stepped by URL-encoding. httpd applies
## <Directory> sections (and .htaccess) first and <Files>/<FilesMatch> after
## them, so this deny wins over create-vhost.sh's `Require all granted`.
##
## A NAMED LIST, NOT A BLANKET "^\." DENY: /.well-known/ (ACME, security.txt,
## apple-app-site-association, assetlinks.json, payment-domain verification)
## must stay reachable. FilesMatch tests the final path component, and for
## /.well-known/acme-challenge/<token> that is <token>, not a dotfile.
##
## HTTP only: PHP still reads .user.ini and .env off the filesystem as before.
<Directory "/home">
<FilesMatch "^\.(user\.ini|env|git|svn|hg|bzr|ht|whp|site-builder)">
Require all denied
</FilesMatch>
</Directory>
## Directory form: reaches the files INSIDE .git/ etc. (e.g. /.git/config,
## whose own name is not a dotfile).
<DirectoryMatch "^/home/.*/\.(git|svn|hg|bzr|whp|site-builder)(/|$)">
Require all denied
</DirectoryMatch>
+5
View File
@@ -0,0 +1,5 @@
; php2shell mitigation (CVE-2026-109618 / CVE-2026-109619, GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp)
; Remove once all images ship PHP >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12.
fatal_error_backtraces = 0
session.upload_progress.enabled = 0
max_input_nesting_level = 16
+58 -24
View File
@@ -14,8 +14,18 @@ export FPM_LISTEN=${FPM_LISTEN:-9000}
adduser -u $uid $user
mkdir -p /home/$user/public_html
mkdir -p /home/$user/logs/php-fpm
# Root never follows customer symlinks under /home/$user: see safe-paths.sh.
source /scripts/safe-paths.sh
secure_home
user_mkdir /home/$user/public_html /home/$user/logs/php-fpm
# Create the pool's error_log (php_admin_value[error_log] in
# create-php-config.sh) AS THE CUSTOMER, before php-fpm starts. It used to be
# `touch`ed as root at the very end, after the chown -R, so on a site's first
# start the pool (customer uid) could not open it and PHP errors fell back to
# FastCGI stderr -> shared-httpd's error log until the next restart's chown -R
# fixed the owner. user_touch never writes or chowns through a symlink.
user_touch /home/$user/logs/php-fpm/error.log
ln -sf /home/$user/logs/php-fpm /var/log/php-fpm
@@ -25,13 +35,27 @@ echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_
/scripts/create-php-config.sh
mkdir -p /run/php-fpm/
# php-fpm opens its slowlog under /home/$user/logs as root: last fail-closed
# check immediately before it starts (safe-paths.sh).
require_safe_logs /home/$user/logs/php-fpm
/usr/sbin/php-fpm -y /etc/php-fpm.conf
# GNU chown -R defaults to -P: symlinks are lchown()ed, never followed.
chown -R $user:$user /home/$user
chmod -R 755 /home/$user
# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's
# bind-mounted site directory; a recursive 755 on every start made every file
# (wp-config.php included) world-readable AND executable, and reverted any
# tighter permissions the customer had set. Apache (shared-httpd, or the
# bundled httpd) runs as `apache`, not the customer, so it only needs to
# traverse directories to serve static files. Normalise that and nothing else:
# open the docroot itself, and add o+x to directories that lack it. Regular
# files are never touched, so execute is never added and wp-config.php is
# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only
# reach what the customer already owns.
open_dir_traversal
if [[ $environment == 'DEV' ]]; then
echo "Starting Dev Deployment (FPM-only mode)"
mkdir -p /home/$user/_db_backups
user_mkdir /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
@@ -49,13 +73,12 @@ if [[ $environment == 'DEV' ]]; then
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
# Written AS THE CUSTOMER (as_user tee), never by root through a customer path.
drop_symlink /home/$user/crontab
printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \
| as_user tee /home/$user/crontab >/dev/null
printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \
| as_user tee /home/$user/mysql_creds
fi
/usr/bin/memcached -d -u $user
fi
@@ -66,21 +89,32 @@ if [[ $environment == 'PROD' ]]; then
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
# Set up + load the user crontab, created and read as the customer
# (safe-paths.sh). Root reading/writing/chowning it followed symlinks.
load_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/*
# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it
# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped:
# docker waited the full 10s and SIGKILLed php-fpm mid-request (exit 137).
# php-fpm QUIT = graceful (workers finish their current request), then crond.
source /scripts/stop-services.sh
TAIL_PID=""
shutdown() {
trap - TERM INT
echo "entrypoint: received stop signal, shutting down"
stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 6 'php-fpm: master'
stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond'
[ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null
exit 0
}
trap shutdown TERM INT
# Tail PHP-FPM logs to docker logs. Backgrounded + `wait` (instead of a
# foreground tail) so bash can run the trap as soon as a signal arrives.
tail -f /home/$user/logs/php-fpm/* &
TAIL_PID=$!
wait "$TAIL_PID"
exit 0
+27 -4
View File
@@ -59,11 +59,30 @@ fi
# Start cron for log rotation
/usr/sbin/crond
# Tail Apache logs (becomes PID 1 process)
# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it
# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped:
# docker waited the full 10s and SIGKILLed apache mid-request (exit 137), for
# every site on the host at once. graceful-stop (WINCH) lets in-flight requests
# finish; after 7s it falls back to TERM. Timeouts sum to < 10s so this
# completes inside docker's default stop timeout.
source /scripts/stop-services.sh
TAIL_PID=""
SLEEP_PID=""
shutdown() {
trap - TERM INT
echo "entrypoint-shared-httpd: received stop signal, shutting down"
stop_service httpd /etc/httpd/run/httpd.pid WINCH 7 '^/usr/sbin/httpd'
stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond'
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
[ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null
exit 0
}
trap shutdown TERM INT
# Tail Apache logs to docker logs.
# 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
@@ -72,6 +91,10 @@ while true; do
fi
tail -f $LOG_FILES &
TAIL_PID=$!
# Re-check for new log files every 60 seconds
sleep 60
# Re-check for new log files every 60 seconds. Background + `wait` (not a
# foreground sleep) so a stop signal runs the trap immediately instead of
# after up to 60s.
sleep 60 &
SLEEP_PID=$!
wait "$SLEEP_PID"
done
+57 -23
View File
@@ -10,8 +10,11 @@ fi
adduser -u $uid $user
mkdir -p /home/$user/public_html
mkdir -p /home/$user/logs/{apache,php-fpm}
# Root never follows customer symlinks under /home/$user: see safe-paths.sh.
source /scripts/safe-paths.sh
secure_home
user_mkdir /home/$user/public_html /home/$user/logs/apache /home/$user/logs/php-fpm
mv /var/log/httpd /var/log/httpd.bak
@@ -34,14 +37,28 @@ if [ -f /etc/httpd/conf.d/ssl.conf ]; then
mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
fi
# httpd and php-fpm open their logs under /home/$user/logs as root: last
# fail-closed check immediately before they start (safe-paths.sh).
require_safe_logs /home/$user/logs/apache /home/$user/logs/php-fpm
/usr/sbin/httpd -k start
/usr/sbin/php-fpm -y /etc/php-fpm.conf
# GNU chown -R defaults to -P: symlinks are lchown()ed, never followed.
chown -R $user:$user /home/$user
chmod -R 755 /home/$user
# Do NOT `chmod -R 755` the docroot here. /home/$user is the customer's
# bind-mounted site directory; a recursive 755 on every start made every file
# (wp-config.php included) world-readable AND executable, and reverted any
# tighter permissions the customer had set. Apache (shared-httpd, or the
# bundled httpd) runs as `apache`, not the customer, so it only needs to
# traverse directories to serve static files. Normalise that and nothing else:
# open the docroot itself, and add o+x to directories that lack it. Regular
# files are never touched, so execute is never added and wp-config.php is
# never loosened. Runs as the customer (safe-paths.sh) so a symlink can only
# reach what the customer already owns.
open_dir_traversal
if [[ $environment == 'DEV' ]]; then
echo "Starting Dev Deployment"
mkdir -p /home/$user/_db_backups
user_mkdir /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..."
@@ -60,14 +77,13 @@ if [[ $environment == 'DEV' ]]; then
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;"
# Create user crontab with MySQL backup job
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
# Create user crontab with MySQL backup job. Written AS THE CUSTOMER
# (as_user tee), never by root through a customer path.
drop_symlink /home/$user/crontab
printf '%s\n' "# User crontab for $user" "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" \
| as_user tee /home/$user/crontab >/dev/null
printf '%s\n' "MySQL User: $mysql_user" "MySQL Password: $mysql_password" "MySQL Database: devdb_$mysql_db" \
| as_user tee /home/$user/mysql_creds
fi
/usr/bin/memcached -d -u $user
@@ -78,19 +94,37 @@ 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
# Set up + load the user crontab, created and read as the customer
# (safe-paths.sh). Root reading/writing/chowning it followed symlinks.
load_user_crontab
/usr/sbin/crond
tail -f /var/log/httpd/*
# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it
# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped:
# docker waited the full 10s and SIGKILLed apache and php-fpm mid-request
# (exit 137). Stop apache first (graceful-stop: finish in-flight requests,
# accept no new ones), then php-fpm (QUIT: workers finish their current
# request), then crond. Timeouts sum to < 10s so this completes inside
# docker's default stop timeout.
source /scripts/stop-services.sh
TAIL_PID=""
shutdown() {
trap - TERM INT
echo "entrypoint: received stop signal, shutting down"
stop_service httpd /etc/httpd/run/httpd.pid WINCH 4 '^/usr/sbin/httpd'
stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 3 'php-fpm: master'
stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond'
[ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null
exit 0
}
trap shutdown TERM INT
# Stream apache logs to docker logs. Backgrounded + `wait` (instead of a
# foreground tail) so bash can run the trap as soon as a signal arrives.
tail -f /var/log/httpd/* &
TAIL_PID=$!
wait "$TAIL_PID"
exit 0
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
## lsphp-apt-upgrade.sh — BUILD-TIME ONLY. Brings every installed lsphp${PHPVER}*
## package (and, by default, the Ubuntu OS packages) up to what the repos offer
## RIGHT NOW, then proves it.
##
## Why this exists: cac-litespeed / cac-lsphp start FROM the LiteSpeed prebuilt
## image litespeedtech/openlitespeed:<OLS>-lsphpNN, which LiteSpeed republishes
## rarely (the 1.8.4 tags were built 2025-12-16 and ship lsphp85 8.5.0 /
## lsphp84 8.4.15, measured 2026-10-21, while rpms.litespeedtech.com/debian
## offers 8.5.11 / 8.4.26). The base image already carries that apt repo, so a
## PHP security release reaches us the moment LiteSpeed publishes the .deb —
## provided every build actually asks for it. Before this script the lsphp core
## only moved forward as a SIDE EFFECT of `apt-get install lsphpNN-ldap` (whose
## exact-version dependency on lsphpNN-common drags the family along); lsphp
## extension packages without that coupling (e.g. lsphpNN-msgpack) and every
## Ubuntu package stayed at the base image's December versions.
##
## Fails LOUDLY — never ships the base image's PHP by accident:
## * `apt-get update --error-on=any`: plain `apt-get update` only WARNS when a
## repo is unreachable and carries on with no/old lists, exit 0.
## * the LiteSpeed repo must be the candidate source for lsphp${PHPVER}.
## * after upgrading, no lsphp${PHPVER}* package may remain upgradable.
## * if CI passed the version it resolved from the repo index (EXPECT_LSPHP_PKG,
## EXPECT_PHP_VERSION), the installed package / running PHP must match it;
## that same value becomes the image LABEL, so the label cannot lie.
##
## Usage: lsphp-apt-upgrade.sh [--no-os-upgrade] PHPVER [extra apt packages / pkg=ver pins...]
## Extra packages are installed in the same transaction set (after the OS
## upgrade, before the lsphp sweep). Does NOT clean apt lists — the caller
## decides (multi-stage builders want them; shipped layers delete them).
set -euo pipefail
OS_UPGRADE=1
if [ "${1:-}" = "--no-os-upgrade" ]; then OS_UPGRADE=0; shift; fi
PHPVER="${1:?usage: lsphp-apt-upgrade.sh [--no-os-upgrade] PHPVER [pkgs...]}"
shift
export DEBIAN_FRONTEND=noninteractive
APT=(apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
LSPHP="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"
PHPCLI="/usr/local/lsws/lsphp${PHPVER}/bin/php"
die() { echo "FATAL(lsphp-apt-upgrade): $*" >&2; exit 1; }
echo "lsphp-apt-upgrade: PHPVER=${PHPVER} os_upgrade=${OS_UPGRADE} cache_bust=${CACHE_BUST:-<none>}"
echo "lsphp-apt-upgrade: base image lsphp${PHPVER}=$(dpkg-query -W -f='${Version}' "lsphp${PHPVER}" 2>/dev/null || echo none)"
"${APT[@]}" update --error-on=any || die "apt-get update failed (repo unreachable?). Refusing to build with stale package lists."
# The candidate for lsphpNN must come from the LiteSpeed repo — not "none", and
# not merely whatever is already installed.
# Read each output into a variable and match in the shell: piping into an
# early-exit reader (grep -q, awk ... exit) under pipefail can SIGPIPE the writer
# and report "missing" for a package that is there (scripts/tests/lsphp-info-probe.test.sh).
MADISON=$(apt-cache madison "lsphp${PHPVER}")
if [[ "$MADISON" != *rpms.litespeedtech.com* ]]; then
apt-cache policy "lsphp${PHPVER}" >&2 || true
die "the LiteSpeed apt repo does not offer lsphp${PHPVER} (repo missing from sources, or its index is empty)."
fi
POLICY=$(apt-cache policy "lsphp${PHPVER}")
CAND=$(awk '/Candidate:/ {print $2; exit}' <<<"$POLICY")
echo "lsphp-apt-upgrade: repo candidate lsphp${PHPVER}=${CAND}"
if [ "$OS_UPGRADE" = 1 ]; then
# --with-new-pkgs: let an upgrade pull in a new dependency instead of being
# silently "kept back". Never removes packages (that would be dist-upgrade).
"${APT[@]}" upgrade --with-new-pkgs
fi
if [ $# -gt 0 ]; then
"${APT[@]}" install --no-install-recommends "$@"
fi
# Sweep EVERY installed lsphpNN* package (core + all extensions) to the candidate.
mapfile -t PKGS < <(dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' "lsphp${PHPVER}*" 2>/dev/null | awk '$1 == "ii" {print $2}')
[ "${#PKGS[@]}" -gt 0 ] || die "no lsphp${PHPVER}* packages installed — wrong base image?"
"${APT[@]}" install --only-upgrade --no-install-recommends "${PKGS[@]}"
LEFT=$(apt list --upgradable 2>/dev/null | grep "^lsphp${PHPVER}" || true)
[ -z "$LEFT" ] || die "lsphp packages still upgradable after the sweep:
$LEFT"
INST=$(dpkg-query -W -f='${Version}' "lsphp${PHPVER}")
RUNV=$("$PHPCLI" -r 'echo PHP_VERSION;') || die "lsphp${PHPVER} CLI does not run"
if [ -n "${EXPECT_LSPHP_PKG:-}" ] && [ "$INST" != "$EXPECT_LSPHP_PKG" ]; then
die "installed lsphp${PHPVER}=$INST but CI resolved $EXPECT_LSPHP_PKG from the repo index (repo changed mid-build? re-run)."
fi
if [ -n "${EXPECT_PHP_VERSION:-}" ] && [ "$RUNV" != "$EXPECT_PHP_VERSION" ]; then
die "lsphp${PHPVER} reports PHP $RUNV but the image will be labelled $EXPECT_PHP_VERSION."
fi
if [ -z "${EXPECT_PHP_VERSION:-}" ]; then
echo "lsphp-apt-upgrade: WARNING no EXPECT_PHP_VERSION build-arg — image label will read 'unverified' (fine for local builds; CI always sets it)." >&2
fi
printf '%s\n' "$RUNV" > /etc/cac-php-version
echo "lsphp-apt-upgrade: ===== version report ====="
dpkg-query -W -f=' ${Package} ${Version}\n' "lsphp${PHPVER}*"
echo " lsphp -v: $("$LSPHP" -v | head -n1)"
echo " OS packages still upgradable: $(apt list --upgradable 2>/dev/null | grep -vc '^Listing' || true)"
echo "lsphp-apt-upgrade: OK lsphp${PHPVER}=${INST} (PHP ${RUNV})"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# shellcheck disable=SC2154 # $uid / $user come from the container environment
## safe-paths.sh — sourced by entrypoint.sh and entrypoint-fpm.sh. Not executed directly.
##
## /home/$user is the customer's bind-mounted site directory: the customer can
## create anything in it, including symlinks, from SFTP/FTP, cron, or PHP. The
## entrypoints run as root, and root following a customer symlink is a
## privilege escalation: `touch`/`chown`/`>` on logs/php-fpm/error.log ->
## /etc/shadow hands the customer /etc/shadow (container root).
##
## Rules the entrypoints follow:
## * /home/$user itself is the bind-mount point and must be a real directory.
## * logs/ is WHP-owned and apache / php-fpm open files in it as root, so it
## must be a real directory and every symlink under it is removed + logged.
## * Files and dirs are CREATED as the customer (as_user), so a symlink or a
## swapped path can only reach what the customer could reach anyway.
## * Ownership changes use `chown -h` / `chown -R` (GNU default -P: never
## follows symlinks, lchown()s them), never a dereferencing chown.
## * Customer files are READ as the customer (crontab), never as root.
# as_user CMD... : run CMD as the customer uid/gid (no root, no caps).
as_user() {
if command -v setpriv >/dev/null 2>&1; then
setpriv --reuid="$uid" --regid="$uid" --init-groups -- "$@"
else
runuser -u "$user" -- "$@"
fi
}
# die MSG : refuse to start. Only the customer who planted the path is hurt,
# which is preferred to root following it.
die() {
echo "entrypoint: FATAL: $* - refusing to start" >&2
exit 1
}
# drop_symlink PATH : if PATH is a symlink, unlink it AS THE CUSTOMER (rm on
# the final component never touches the link target, and if a parent dir is
# swapped for a symlink mid-way the customer can only delete what they already
# could) and log it. Fails closed: still a symlink afterwards -> refuse to start.
drop_symlink() {
if [ -L "$1" ]; then
echo "entrypoint: SECURITY: $1 was a symlink to '$(readlink "$1")' - removing" >&2
as_user rm -f -- "$1"
[ -L "$1" ] && die "could not remove symlink $1"
fi
return 0
}
# secure_home : run once, right after adduser, before anything else touches
# /home/$user.
secure_home() {
local h="/home/$user"
if [ -L "$h" ] || [ ! -d "$h" ]; then
die "$h is not a real directory (symlink or missing)"
fi
# The mount point itself: owned by the customer so as_user can create in it.
chown -h "$user:$user" "$h"
drop_symlink "$h/logs"
# public_html may legitimately be a symlink (release-dir deploys); it is left
# alone and every root-side operation on it runs as the customer instead.
if [ -d "$h/logs" ]; then
# As the customer: find without -L never follows symlinks, -delete unlinks
# the link itself, and a raced parent swap can only reach what the customer
# could already delete. Errors are not fatal HERE: require_safe_logs
# re-checks immediately before the daemons start and fails closed.
as_user find "$h/logs" -xdev -type l \
-printf 'entrypoint: SECURITY: %p was a symlink to %l - removed\n' -delete >&2 || true
fi
}
# user_mkdir DIR... : create directories as the customer.
user_mkdir() {
as_user mkdir -p -- "$@"
}
# user_touch FILE : create FILE as the customer (a symlink there is dropped
# first, fail-closed). Root never touches/chowns it: if it already exists
# root-owned (left by an older image) the touch fails quietly and the
# entrypoint's chown -R, which never follows symlinks, fixes the owner. Must end
# up a regular file, or refuse to start.
user_touch() {
drop_symlink "$1"
as_user touch -- "$1" 2>/dev/null || true
[ -f "$1" ] && [ ! -L "$1" ] || die "$1 is not a regular file"
}
# require_safe_logs DIR... : fail-closed gate, run immediately before starting
# the daemons that open files under /home/$user/logs AS ROOT (httpd, php-fpm).
# Each DIR must be a real directory, and nothing under logs/ may be anything
# but a regular file or directory (no symlink, fifo, socket, device).
require_safe_logs() {
local d bad
for d in "/home/$user/logs" "$@"; do
[ -d "$d" ] && [ ! -L "$d" ] || die "$d is not a real directory"
done
bad=$(find "/home/$user/logs" -xdev ! -type f ! -type d -print -quit 2>&1)
[ -z "$bad" ] || die "unsafe entry under /home/$user/logs: $bad"
return 0
}
# load_user_crontab : install /home/$user/crontab, read AS THE CUSTOMER. Root
# reading it (`crontab -u $user /home/$user/crontab`) would follow a symlink to
# any root-readable file and install it where the customer can `crontab -l` it.
load_user_crontab() {
local f="/home/$user/crontab"
if [ ! -e "$f" ] && [ ! -L "$f" ]; then
as_user sh -c 'printf "%s\n" "# User crontab for $1" "# Add your cron jobs here" "# Example: */5 * * * * /home/$1/scripts/my-script.sh" > "$2"' sh "$user" "$f"
fi
# No temp file: root writing a predictable /tmp path is the same bug class.
local content
if content=$(as_user cat -- "$f" 2>/dev/null); then
printf '%s\n' "$content" | crontab -u "$user" -
else
echo "entrypoint: WARNING: $f not readable as $user - user crontab not loaded" >&2
fi
}
# open_dir_traversal : chmod 755 the home dir, then add o+x to every directory
# lacking it (apache/nginx run as their own user and must traverse). Run AS THE
# CUSTOMER: `find -type d` without -L skips symlinks, but chmod would follow a
# dir swapped for a symlink between find and chmod; as the customer that can
# only reach what the customer already owns.
open_dir_traversal() {
chmod 755 "/home/$user"
as_user find "/home/$user" -type d ! -perm -o=x -exec chmod o+x {} + 2>/dev/null || true
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
## stop-services.sh — sourced by the bash PID-1 entrypoints (entrypoint.sh,
## entrypoint-fpm.sh, entrypoint-shared-httpd.sh). Not executed directly.
##
## Why this exists: those entrypoints end with bash as PID 1 tailing logs.
## The kernel never delivers a signal to PID 1 unless PID 1 installed a handler
## for it, so `docker stop`'s SIGTERM was silently dropped. Docker waited the
## full stop timeout (10s) and then SIGKILLed the whole container, killing
## apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT
## and call stop_service for each daemon they started, then exit 0.
##
## Budget: docker's default stop timeout is 10s. Callers pass per-service
## timeouts that sum to < 10s so the graceful path always completes before
## docker gives up and SIGKILLs.
# A pid counts as alive if it exists and is not a zombie. bash (PID 1) reaps
# the daemons it adopted, but between exit and reap a zombie still answers
# `kill -0`, so check /proc state too.
_svc_alive() {
[ -n "$1" ] || return 1
kill -0 "$1" 2>/dev/null || return 1
! grep -qs '^State:[[:space:]]*Z' "/proc/$1/status"
}
# stop_service NAME PIDFILE GRACEFUL_SIGNAL GRACE_SECONDS [PGREP_PATTERN]
# Sends GRACEFUL_SIGNAL, waits up to GRACE_SECONDS for the process to exit,
# then sends TERM and waits 1s more, then KILL. Pid comes from PIDFILE, or
# from `pgrep -o -f PGREP_PATTERN` when the pidfile is missing/stale.
stop_service() {
local name=$1 pidfile=$2 sig=$3 grace=$4 pat=${5:-} pid="" i
[ -r "$pidfile" ] && pid=$(tr -dc '0-9' < "$pidfile")
if ! _svc_alive "$pid" && [ -n "$pat" ]; then
pid=$(pgrep -o -f "$pat" 2>/dev/null)
fi
if ! _svc_alive "$pid"; then
echo "entrypoint: $name not running"
return 0
fi
echo "entrypoint: stopping $name (pid $pid) with SIG$sig"
kill -s "$sig" "$pid" 2>/dev/null
for ((i = 0; i < grace * 10; i++)); do
_svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; }
sleep 0.1
done
echo "entrypoint: $name still running after ${grace}s, sending SIGTERM"
kill -TERM "$pid" 2>/dev/null
for ((i = 0; i < 10; i++)); do
_svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; }
sleep 0.1
done
echo "entrypoint: $name still running, sending SIGKILL"
kill -KILL "$pid" 2>/dev/null
return 0
}