From 19092911a39896c1a6ce6d0edac6622547849c66 Mon Sep 17 00:00:00 2001 From: jknapp Date: Tue, 9 Jun 2026 18:28:34 -0700 Subject: [PATCH 1/7] feat(cac-lsphp): detached lsphp (LSAPI) site image for the shared-ols tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/build-push.yaml | 41 ++++++++++++ Dockerfile.lsphp | 62 ++++++++++++++++++ scripts/detect-memory-lsphp.sh | 75 ++++++++++++++++++++++ scripts/entrypoint-lsphp.sh | 106 +++++++++++++++++++++++++++++++ scripts/healthcheck-lsphp.sh | 17 +++++ 5 files changed, 301 insertions(+) create mode 100644 Dockerfile.lsphp create mode 100644 scripts/detect-memory-lsphp.sh create mode 100644 scripts/entrypoint-lsphp.sh create mode 100644 scripts/healthcheck-lsphp.sh diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index e2a708c..4a85576 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -117,6 +117,47 @@ jobs: 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 (81–85): 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: diff --git a/Dockerfile.lsphp b/Dockerfile.lsphp new file mode 100644 index 0000000..af1599e --- /dev/null +++ b/Dockerfile.lsphp @@ -0,0 +1,62 @@ +## 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 :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 +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. +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + lsphp${PHPVER}-ldap && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +## 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/ +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"' + +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"] diff --git a/scripts/detect-memory-lsphp.sh b/scripts/detect-memory-lsphp.sh new file mode 100644 index 0000000..535e9f4 --- /dev/null +++ b/scripts/detect-memory-lsphp.sh @@ -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 diff --git a/scripts/entrypoint-lsphp.sh b/scripts/entrypoint-lsphp.sh new file mode 100644 index 0000000..f9e7168 --- /dev/null +++ b/scripts/entrypoint-lsphp.sh @@ -0,0 +1,106 @@ +#!/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 `) and nothing else — no +## webserver. The shared-ols container connects to this over the docker +## network (extProcessor type lsapi, address :9000) exactly +## like the shared httpd connects to a cac-fpm container's php-fpm on :9000. +## +## Structurally this is the LiteSpeed analogue of entrypoint-fpm.sh: same +## `uid`/`user` contract, same /home/$user/{public_html,logs} layout, same +## per-site ini drop-in mechanism as entrypoint-litespeed.sh. The only +## difference from cac-litespeed is that OLS lives elsewhere, so this PID 1 is +## lsphp itself rather than a webserver supervisor. +## +## IMPORTANT (see whp memory feedback_ols_lsapi_no_script_filename_remap): +## OLS hands lsphp exactly its vhost docRoot path, so the shared-ols vhost +## docRoot and THIS container's docroot mount MUST be the same absolute path. +## The panel mounts the site at /mnt/users// in BOTH containers; +## this entrypoint does not assume any particular path — it just runs lsphp, +## which opens whatever absolute SCRIPT_FILENAME the webserver sends. + +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 +export user + +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 (mirror entrypoint-litespeed.sh paths) ---- +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" + +## ---- 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}" +LSPHP_BIND="${LSPHP_BIND:-0.0.0.0:9000}" + +echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP_LSAPI_CHILDREN=${PHP_LSAPI_CHILDREN} | LSAPI_MAX_IDLE=${LSAPI_MAX_IDLE} | PHPVER=${PHPVER} | bind=${LSPHP_BIND}" + +## ---- 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. +SCAN_DIR=$("$LSPHP_BIN" -i 2>/dev/null | awk -F'=> ' '/^Scan this dir/ {print $2; exit}') +if [ -n "$SCAN_DIR" ]; then + mkdir -p "$SCAN_DIR" + cat > "$SCAN_DIR/99-user-error-log.ini" < "$SCAN_DIR/99-user-opcache.ini" + fi +fi + +## ---- ownership ---- +touch "/home/$user/logs/php-fpm/error.log" +chown -R "$user:$user" "/home/$user" +chmod 755 "/home/$user" + +## ---- 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 diff --git a/scripts/healthcheck-lsphp.sh b/scripts/healthcheck-lsphp.sh new file mode 100644 index 0000000..59c3c10 --- /dev/null +++ b/scripts/healthcheck-lsphp.sh @@ -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 From 19db8f170a3447e096d989984032a7366a3d63db Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 10 Jun 2026 01:22:14 -0700 Subject: [PATCH 2/7] feat(shared-ols): shared OpenLiteSpeed tier image (webserver-only, fronts cac-lsphp sidecars) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 :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) --- .gitea/workflows/build-push.yaml | 33 ++++++ Dockerfile.shared-ols | 57 +++++++++++ configs/shared-ols/httpd_config_base.tpl | 38 +++++++ configs/shared-ols/vhconf.tpl | 70 +++++++++++++ scripts/entrypoint-shared-ols.sh | 120 ++++++++++++++++++++++ scripts/ols-htaccess-watcher.sh | 58 +++++++++++ scripts/render-shared-ols-config.sh | 125 +++++++++++++++++++++++ 7 files changed, 501 insertions(+) create mode 100644 Dockerfile.shared-ols create mode 100644 configs/shared-ols/httpd_config_base.tpl create mode 100644 configs/shared-ols/vhconf.tpl create mode 100644 scripts/entrypoint-shared-ols.sh create mode 100644 scripts/ols-htaccess-watcher.sh create mode 100644 scripts/render-shared-ols-config.sh diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 4a85576..b290d1a 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -185,3 +185,36 @@ jobs: 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 diff --git a/Dockerfile.shared-ols b/Dockerfile.shared-ols new file mode 100644 index 0000000..074497d --- /dev/null +++ b/Dockerfile.shared-ols @@ -0,0 +1,57 @@ +## 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 :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 +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + inotify-tools gettext-base openssl ca-certificates curl && \ + 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. +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"] diff --git a/configs/shared-ols/httpd_config_base.tpl b/configs/shared-ols/httpd_config_base.tpl new file mode 100644 index 0000000..3cb41ec --- /dev/null +++ b/configs/shared-ols/httpd_config_base.tpl @@ -0,0 +1,38 @@ +## ---- 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 2 = trust the proxy header. HAProxy is +## the only thing that ever connects to this tier (it's not publicly exposed), +## so trusting the header from the docker-network peer is safe — same trust +## model as the shared httpd's RemoteIPInternalProxy. +useIpInProxyHeader 2 + +## LSCache enabled at MODULE scope for the whole tier (dedicated cache volume, +## ephemeral across rebuilds; OLS auto-keys a per-vhost subdir under storagePath). +## enableCache/enablePrivateCache ON here means the cache module is ACTIVE, but a +## response is only cached if it's marked cacheable — the LiteSpeed Cache WP +## plugin sets X-LiteSpeed-Cache-Control headers, and checkPublic/PrivateCache + +## ignoreRespCacheCtrl=0 make OLS honor them. No plugin → nothing cached (safe). +module cache { + storagePath ${LSCACHE_ROOT} + checkPrivateCache 1 + checkPublicCache 1 + maxCacheObjSize 10000000 + maxStaleAge 200 + qsCache 1 + reqCookieCache 1 + respCookieCache 1 + ignoreReqCacheCtrl 0 + ignoreRespCacheCtrl 0 + enableCache 1 + enablePrivateCache 1 +} +## ---- end shared-ols server append ---- diff --git a/configs/shared-ols/vhconf.tpl b/configs/shared-ols/vhconf.tpl new file mode 100644 index 0000000..8add16c --- /dev/null +++ b/configs/shared-ols/vhconf.tpl @@ -0,0 +1,70 @@ +## Per-site OLS vhost detail — rendered by the WHP panel (shared_ols_manager) +## to $SITES_ROOT//vhconf.conf and referenced from the vhost stanza's +## `configFile` in httpd_config.conf. ~~PLACEHOLDERS~~ are filled by the panel +## (matches the shared-vhost-template.tpl convention). One directive per line — +## OLS PlainConf does NOT accept ';' separators. +## +## CRITICAL (feedback_ols_lsapi_no_script_filename_remap): docRoot here MUST be +## the SAME absolute path the cac-lsphp sidecar has mounted, because OLS hands +## lsphp exactly docRoot+URI as SCRIPT_FILENAME and lsphp opens it. Both are +## /mnt/users///public_html. The panel asserts this parity. + +docRoot ~~DOCROOT~~ +enableScript 1 + +## Remote detached lsphp over LSAPI/TCP. address = the site's sidecar container +## on the docker network. autoStart 0 = OLS NEVER spawns it (it's a separate +## container). maxConns MUST equal the sidecar's PHP_LSAPI_CHILDREN — the panel +## writes both from the single fpm_max_children value so they can't drift. +## NO `env` lines: detached lsphp owns its env in the sidecar (spec 5.2). +## NOTE on `path`: required syntactically but UNUSED for a remote autoStart-0 +## processor (OLS never spawns it). Point it at a path that always exists in the +## shared-ols image (the stock fcgi-bin/lsphp), NOT a version-specific +## /usr/local/lsws/lsphpNN — the shared-ols image carries only one lsphp build, +## while sites may run any PHP version on their sidecar. The sidecar owns the +## real PHP runtime/version. +extprocessor ~~VHNAME~~_lsphp { + type lsapi + address ~~SIDECAR~~:9000 + maxConns ~~MAXCONNS~~ + autoStart 0 + path /usr/local/lsws/fcgi-bin/lsphp + initTimeout 60 + retryTimeout 0 + respBuffer 0 + persistConn 1 +} + +scripthandler { + add lsapi:~~VHNAME~~_lsphp php +} + +## context / drives static serving + .htaccess. RewriteFile .htaccess is OLS's +## autoLoadHtaccess equivalent — re-read on graceful restart (the watcher +## triggers that within the documented window). +context / { + allowBrowse 1 + location $DOC_ROOT/ + rewrite { + enable 1 + RewriteFile .htaccess + } + addDefaultCharset off +} + +## LSCache is enabled at MODULE scope (httpd_config_base.tpl) and honored per +## response via the LiteSpeed Cache WP plugin's X-LiteSpeed-Cache-Control +## headers — a `configFile`-loaded vhost in OLS 1.8.4 does NOT accept a bare +## `cache {}` block (verified 2026-06-10), so there is intentionally no per-vhost +## cache block here. OLS stores each vhost's cache in its own subdir under the +## module storagePath automatically (per-vhost isolation, spec 5.2). + +errorlog ~~LOG_DIR~~/error_log { + logLevel WARN + rollingSize 50M + keepDays 7 +} +accesslog ~~LOG_DIR~~/access_log { + rollingSize 50M + keepDays 7 +} diff --git a/scripts/entrypoint-shared-ols.sh b/scripts/entrypoint-shared-ols.sh new file mode 100644 index 0000000..7fc116a --- /dev/null +++ b/scripts/entrypoint-shared-ols.sh @@ -0,0 +1,120 @@ +#!/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): valid server with zero customer sites + +## answers HAProxy health checks that hit by IP / unknown Host with a 200 ---- +cat > "$HEALTH_DIR/vhconf.conf" <<'EOF' +docRoot $VH_ROOT/html +enableScript 0 +context / { + allowBrowse 1 + location $DOC_ROOT/ +} +EOF +printf 'ok\n' > "$HEALTH_DIR/html/healthz" +printf 'shared-ols\n' > "$HEALTH_DIR/html/index.html" + +## ---- assemble httpd_config.conf from the panel's per-site files ---- +/scripts/render-shared-ols-config.sh + +chown -R lsadm:nogroup "$LSWS_CONF" "$HEALTH_DIR" 2>/dev/null || true + +## ---- 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 + +ols_running() { /usr/local/lsws/bin/lswsctrl status 2>/dev/null | grep -qi 'running with pid'; } + +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 diff --git a/scripts/ols-htaccess-watcher.sh b/scripts/ols-htaccess-watcher.sh new file mode 100644 index 0000000..38e132c --- /dev/null +++ b/scripts/ols-htaccess-watcher.sh @@ -0,0 +1,58 @@ +#!/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="${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() { + now=$(date +%s) + if [ $((now - last_restart)) -lt "$FLOOR" ]; then + log "within ${FLOOR}s floor — coalescing, skipping restart" + return + fi + if "$LSWSCTRL" restart >/dev/null 2>&1; then + last_restart=$now + log "graceful restart issued (.htaccess change)" + else + log "WARNING: lswsctrl restart failed" + 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 .htaccess changes (debounce=${DEBOUNCE}s floor=${FLOOR}s)" + +## -m monitor, -r recursive. We filter to .htaccess 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). +inotifywait -m -r -e modify,create,delete,move "$WATCH_ROOT" --format '%f' 2>/dev/null | +while read -r fname; do + case "$fname" in + .htaccess) ;; + *) continue ;; + esac + ## Drain further events for DEBOUNCE seconds (coalesce the burst), then act. + while read -r -t "$DEBOUNCE" _; do :; done + do_restart +done diff --git a/scripts/render-shared-ols-config.sh b/scripts/render-shared-ols-config.sh new file mode 100644 index 0000000..eae7da7 --- /dev/null +++ b/scripts/render-shared-ols-config.sh @@ -0,0 +1,125 @@ +#!/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//vhconf.conf (rendered from configs/shared-ols/vhconf.tpl) +## $SITES_ROOT//site.meta (shell: 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" +STOCK="/usr/local/lsws/.conf/httpd_config.conf" + +mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT" + +## --- 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" > "$OUT" + +## --- 3. append our server-level base (real-IP, cache module, no local PHP) --- +{ + echo "" + envsubst '${LSCACHE_ROOT}' < "$TPL_DIR/httpd_config_base.tpl" +} >> "$OUT" + +## --- 4. emit per-site vhost stanzas + collect listener map lines --- +maps="" +site_count=0 +for meta in "$SITES_ROOT"/*/site.meta; do + [ -e "$meta" ] || continue + sdir=$(dirname "$meta") + VHNAME=""; VHROOT=""; DOMAINS="" + # shellcheck source=/dev/null + . "$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 "}" + } >> "$OUT" + maps="${maps} map ${VHNAME} ${DOMAINS}"$'\n' + site_count=$((site_count + 1)) +done + +## --- 5. ALWAYS add a health vhost mapped to the catch-all so the server is +## valid with zero customer sites and HAProxy health checks (which hit by IP / +## unknown Host) get a 200. Exact-domain maps above win over this '*'. --- +{ + echo "" + echo "virtualhost _health {" + echo " vhRoot /usr/local/lsws/shared-ols-health" + echo " configFile /usr/local/lsws/shared-ols-health/vhconf.conf" + echo " allowSymbolLink 1" + echo " enableScript 0" + echo "}" +} >> "$OUT" +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 "}" +} >> "$OUT" + +chown -R lsadm:nogroup "$LSWS_CONF" 2>/dev/null || true +echo "render-shared-ols: wrote $OUT ($site_count customer vhost(s) + health)" From e99b8cb2d1b8277ad1d465c2a1add75c795f2d5c Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 10 Jun 2026 06:42:31 -0700 Subject: [PATCH 3/7] 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// (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/.* (the shared-ols container has no /home/). Co-Authored-By: Claude Opus 4.8 (1M context) --- configs/shared-ols/vhconf.tpl | 6 ++++-- scripts/entrypoint-lsphp.sh | 35 +++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/configs/shared-ols/vhconf.tpl b/configs/shared-ols/vhconf.tpl index 8add16c..86a2d78 100644 --- a/configs/shared-ols/vhconf.tpl +++ b/configs/shared-ols/vhconf.tpl @@ -59,12 +59,14 @@ context / { ## cache block here. OLS stores each vhost's cache in its own subdir under the ## module storagePath automatically (per-vhost isolation, spec 5.2). -errorlog ~~LOG_DIR~~/error_log { +## Per-vhost logs in the shared-ols container's OWN writable log dir (NOT +## /home/, which doesn't exist here, and NOT the read-only /mnt/users mount). +errorlog /usr/local/lsws/logs/~~VHNAME~~.error_log { logLevel WARN rollingSize 50M keepDays 7 } -accesslog ~~LOG_DIR~~/access_log { +accesslog /usr/local/lsws/logs/~~VHNAME~~.access_log { rollingSize 50M keepDays 7 } diff --git a/scripts/entrypoint-lsphp.sh b/scripts/entrypoint-lsphp.sh index f9e7168..934d20c 100644 --- a/scripts/entrypoint-lsphp.sh +++ b/scripts/entrypoint-lsphp.sh @@ -40,11 +40,30 @@ if [ ! -x "$LSPHP_BIN" ]; then exit 1 fi -## ---- user + directories (mirror entrypoint-litespeed.sh paths) ---- +## ---- user ---- 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" + +## ---- locate the customer docroot ---- +## Unlike cac-fpm/cac-litespeed (docroot at /home/$user), the shared-ols tier +## mounts each site at /mnt/users// — the SAME absolute path the +## shared-ols vhost uses as docRoot, because OLS hands lsphp exactly that path as +## SCRIPT_FILENAME (feedback_ols_lsapi_no_script_filename_remap). The panel +## mounts exactly ONE site dir here, so glob it (wildcard-safe: the on-disk dir +## is wildcard. for wildcard sites, which the glob picks up verbatim). +SITE_DIR="" +for d in /mnt/users/"$user"/*/; do + [ -d "$d" ] || continue + SITE_DIR="${d%/}" + break +done +if [ -z "$SITE_DIR" ]; then + ## No bind mount yet (e.g. hand-run for testing) — fall back to a sane path so + ## lsphp still starts; OLS will send the real docRoot at request time. + SITE_DIR="/mnt/users/$user/site" +fi +mkdir -p "$SITE_DIR/public_html" "$SITE_DIR/logs/php-fpm" ## ---- detached-lsphp pool sizing ---- # shellcheck source=/dev/null @@ -71,7 +90,7 @@ if [ -n "$SCAN_DIR" ]; then mkdir -p "$SCAN_DIR" cat > "$SCAN_DIR/99-user-error-log.ini" </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 From fc65b68bd6e1846f5a318b45a0ec2de1c8510202 Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 10 Jun 2026 06:54:28 -0700 Subject: [PATCH 4/7] fix(cac-lsphp): mount docroot at /home/$user + symlink for true 1:1 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer concern: sites with /home//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// -> /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//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) --- configs/shared-ols/vhconf.tpl | 8 ++-- scripts/entrypoint-lsphp.sh | 77 +++++++++++++++++------------------ 2 files changed, 41 insertions(+), 44 deletions(-) diff --git a/configs/shared-ols/vhconf.tpl b/configs/shared-ols/vhconf.tpl index 86a2d78..9f54d4f 100644 --- a/configs/shared-ols/vhconf.tpl +++ b/configs/shared-ols/vhconf.tpl @@ -4,10 +4,10 @@ ## (matches the shared-vhost-template.tpl convention). One directive per line — ## OLS PlainConf does NOT accept ';' separators. ## -## CRITICAL (feedback_ols_lsapi_no_script_filename_remap): docRoot here MUST be -## the SAME absolute path the cac-lsphp sidecar has mounted, because OLS hands -## lsphp exactly docRoot+URI as SCRIPT_FILENAME and lsphp opens it. Both are -## /mnt/users///public_html. The panel asserts this parity. +## docRoot is /mnt/users///public_html — the shared-ols container's +## view (bulk /docker/users->/mnt/users mount). OLS sends lsphp exactly this path +## (no remap); the cac-lsphp sidecar symlinks /mnt/users// -> its +## real /home/ mount, so PHP canonicalises it to /home//public_html. docRoot ~~DOCROOT~~ enableScript 1 diff --git a/scripts/entrypoint-lsphp.sh b/scripts/entrypoint-lsphp.sh index 934d20c..a07da04 100644 --- a/scripts/entrypoint-lsphp.sh +++ b/scripts/entrypoint-lsphp.sh @@ -7,18 +7,21 @@ ## network (extProcessor type lsapi, address :9000) exactly ## like the shared httpd connects to a cac-fpm container's php-fpm on :9000. ## -## Structurally this is the LiteSpeed analogue of entrypoint-fpm.sh: same -## `uid`/`user` contract, same /home/$user/{public_html,logs} layout, same -## per-site ini drop-in mechanism as entrypoint-litespeed.sh. The only -## difference from cac-litespeed is that OLS lives elsewhere, so this PID 1 is -## lsphp itself rather than a webserver supervisor. +## 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. ## -## IMPORTANT (see whp memory feedback_ols_lsapi_no_script_filename_remap): -## OLS hands lsphp exactly its vhost docRoot path, so the shared-ols vhost -## docRoot and THIS container's docroot mount MUST be the same absolute path. -## The panel mounts the site at /mnt/users// in BOTH containers; -## this entrypoint does not assume any particular path — it just runs lsphp, -## which opens whatever absolute SCRIPT_FILENAME the webserver sends. +## 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///public_html. We create a symlink +## /mnt/users// -> /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. set -euo pipefail @@ -32,7 +35,8 @@ 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 -export user +: "${domain:=localhost}" +export user domain LSPHP_BIN="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp" if [ ! -x "$LSPHP_BIN" ]; then @@ -40,30 +44,24 @@ if [ ! -x "$LSPHP_BIN" ]; then exit 1 fi -## ---- user ---- +## ---- 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" -## ---- locate the customer docroot ---- -## Unlike cac-fpm/cac-litespeed (docroot at /home/$user), the shared-ols tier -## mounts each site at /mnt/users// — the SAME absolute path the -## shared-ols vhost uses as docRoot, because OLS hands lsphp exactly that path as -## SCRIPT_FILENAME (feedback_ols_lsapi_no_script_filename_remap). The panel -## mounts exactly ONE site dir here, so glob it (wildcard-safe: the on-disk dir -## is wildcard. for wildcard sites, which the glob picks up verbatim). -SITE_DIR="" -for d in /mnt/users/"$user"/*/; do - [ -d "$d" ] || continue - SITE_DIR="${d%/}" - break -done -if [ -z "$SITE_DIR" ]; then - ## No bind mount yet (e.g. hand-run for testing) — fall back to a sane path so - ## lsphp still starts; OLS will send the real docRoot at request time. - SITE_DIR="/mnt/users/$user/site" -fi -mkdir -p "$SITE_DIR/public_html" "$SITE_DIR/logs/php-fpm" +## ---- compatibility symlink for the OLS-sent path ---- +## OLS sends SCRIPT_FILENAME under /mnt/users///public_html +## (the shared-ols container's view). Point that at our real /home/$user mount so +## the path resolves. 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 +mkdir -p "/mnt/users/$user" +ln -sfn "/home/$user" "/mnt/users/$user/$SAFE_DOMAIN" ## ---- detached-lsphp pool sizing ---- # shellcheck source=/dev/null @@ -90,7 +88,7 @@ if [ -n "$SCAN_DIR" ]; then mkdir -p "$SCAN_DIR" cat > "$SCAN_DIR/99-user-error-log.ini" </dev/null || true +## 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 From 7552760ba05a45f2738dd8708ee67f7e596abfbb Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 10 Jun 2026 07:02:54 -0700 Subject: [PATCH 5/7] fix(cac-lsphp): normalize $_SERVER DOCUMENT_ROOT/SCRIPT_FILENAME to /home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The symlink makes __FILE__/__DIR__/realpath/getcwd report /home//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//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) --- Dockerfile.lsphp | 1 + scripts/cac-lsphp-normalize.php | 30 ++++++++++++++++++++++++++++++ scripts/entrypoint-lsphp.sh | 9 +++++++++ 3 files changed, 40 insertions(+) create mode 100644 scripts/cac-lsphp-normalize.php diff --git a/Dockerfile.lsphp b/Dockerfile.lsphp index af1599e..c70de7b 100644 --- a/Dockerfile.lsphp +++ b/Dockerfile.lsphp @@ -41,6 +41,7 @@ RUN apt-get update && \ 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 diff --git a/scripts/cac-lsphp-normalize.php b/scripts/cac-lsphp-normalize.php new file mode 100644 index 0000000..039a715 --- /dev/null +++ b/scripts/cac-lsphp-normalize.php @@ -0,0 +1,30 @@ +/mnt/users mount, + * so OLS sends lsphp $_SERVER['DOCUMENT_ROOT'] / ['SCRIPT_FILENAME'] under + * /mnt/users///... . The sidecar symlinks that back to the real + * /home/ mount, so file operations resolve and PHP's own __FILE__/__DIR__/ + * realpath()/getcwd() already report /home//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//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); diff --git a/scripts/entrypoint-lsphp.sh b/scripts/entrypoint-lsphp.sh index a07da04..c2f9edb 100644 --- a/scripts/entrypoint-lsphp.sh +++ b/scripts/entrypoint-lsphp.sh @@ -90,6 +90,15 @@ if [ -n "$SCAN_DIR" ]; then ; rendered at container start by entrypoint-lsphp.sh error_log = /home/${user}/logs/php-fpm/error.log log_errors = On +EOF + ## Normalise \$_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] from the OLS-sent + ## /mnt/users path back to /home/ so cac-lsphp is byte-for-byte 1:1 with + ## cac-fpm. Customer sites have no auto_prepend by default, so this is safe; a + ## site that sets its own .user.ini auto_prepend overrides it (paths still + ## resolve via the symlink either way). + cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF' +; rendered at container start by entrypoint-lsphp.sh +auto_prepend_file = /scripts/cac-lsphp-normalize.php EOF ## Per-site opcache override (panel: Advanced Tuning → OpCache size); falls ## back to the baked lsphp-overrides.ini defaults when unset. From 6bb494c72fced5cd8b2bdead41d734bc5f44dd58 Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 10 Jun 2026 08:34:55 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(shared-ols):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20watcher=20starvation,=20atomic=20render,=20O(N)=20c?= =?UTF-8?q?hown,=20safe=20meta=20parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- configs/shared-ols/vhconf.tpl | 72 ----------------------------- scripts/entrypoint-shared-ols.sh | 10 +++- scripts/ols-htaccess-watcher.sh | 19 +++++++- scripts/render-shared-ols-config.sh | 51 +++++++++++++++----- 4 files changed, 65 insertions(+), 87 deletions(-) delete mode 100644 configs/shared-ols/vhconf.tpl diff --git a/configs/shared-ols/vhconf.tpl b/configs/shared-ols/vhconf.tpl deleted file mode 100644 index 9f54d4f..0000000 --- a/configs/shared-ols/vhconf.tpl +++ /dev/null @@ -1,72 +0,0 @@ -## Per-site OLS vhost detail — rendered by the WHP panel (shared_ols_manager) -## to $SITES_ROOT//vhconf.conf and referenced from the vhost stanza's -## `configFile` in httpd_config.conf. ~~PLACEHOLDERS~~ are filled by the panel -## (matches the shared-vhost-template.tpl convention). One directive per line — -## OLS PlainConf does NOT accept ';' separators. -## -## docRoot is /mnt/users///public_html — the shared-ols container's -## view (bulk /docker/users->/mnt/users mount). OLS sends lsphp exactly this path -## (no remap); the cac-lsphp sidecar symlinks /mnt/users// -> its -## real /home/ mount, so PHP canonicalises it to /home//public_html. - -docRoot ~~DOCROOT~~ -enableScript 1 - -## Remote detached lsphp over LSAPI/TCP. address = the site's sidecar container -## on the docker network. autoStart 0 = OLS NEVER spawns it (it's a separate -## container). maxConns MUST equal the sidecar's PHP_LSAPI_CHILDREN — the panel -## writes both from the single fpm_max_children value so they can't drift. -## NO `env` lines: detached lsphp owns its env in the sidecar (spec 5.2). -## NOTE on `path`: required syntactically but UNUSED for a remote autoStart-0 -## processor (OLS never spawns it). Point it at a path that always exists in the -## shared-ols image (the stock fcgi-bin/lsphp), NOT a version-specific -## /usr/local/lsws/lsphpNN — the shared-ols image carries only one lsphp build, -## while sites may run any PHP version on their sidecar. The sidecar owns the -## real PHP runtime/version. -extprocessor ~~VHNAME~~_lsphp { - type lsapi - address ~~SIDECAR~~:9000 - maxConns ~~MAXCONNS~~ - autoStart 0 - path /usr/local/lsws/fcgi-bin/lsphp - initTimeout 60 - retryTimeout 0 - respBuffer 0 - persistConn 1 -} - -scripthandler { - add lsapi:~~VHNAME~~_lsphp php -} - -## context / drives static serving + .htaccess. RewriteFile .htaccess is OLS's -## autoLoadHtaccess equivalent — re-read on graceful restart (the watcher -## triggers that within the documented window). -context / { - allowBrowse 1 - location $DOC_ROOT/ - rewrite { - enable 1 - RewriteFile .htaccess - } - addDefaultCharset off -} - -## LSCache is enabled at MODULE scope (httpd_config_base.tpl) and honored per -## response via the LiteSpeed Cache WP plugin's X-LiteSpeed-Cache-Control -## headers — a `configFile`-loaded vhost in OLS 1.8.4 does NOT accept a bare -## `cache {}` block (verified 2026-06-10), so there is intentionally no per-vhost -## cache block here. OLS stores each vhost's cache in its own subdir under the -## module storagePath automatically (per-vhost isolation, spec 5.2). - -## Per-vhost logs in the shared-ols container's OWN writable log dir (NOT -## /home/, which doesn't exist here, and NOT the read-only /mnt/users mount). -errorlog /usr/local/lsws/logs/~~VHNAME~~.error_log { - logLevel WARN - rollingSize 50M - keepDays 7 -} -accesslog /usr/local/lsws/logs/~~VHNAME~~.access_log { - rollingSize 50M - keepDays 7 -} diff --git a/scripts/entrypoint-shared-ols.sh b/scripts/entrypoint-shared-ols.sh index 7fc116a..ceb4e5e 100644 --- a/scripts/entrypoint-shared-ols.sh +++ b/scripts/entrypoint-shared-ols.sh @@ -45,11 +45,17 @@ EOF printf 'ok\n' > "$HEALTH_DIR/html/healthz" printf 'shared-ols\n' > "$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/index.html" 2>/dev/null || true + ## ---- assemble httpd_config.conf from the panel's per-site files ---- /scripts/render-shared-ols-config.sh -chown -R lsadm:nogroup "$LSWS_CONF" "$HEALTH_DIR" 2>/dev/null || true - ## ---- 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 diff --git a/scripts/ols-htaccess-watcher.sh b/scripts/ols-htaccess-watcher.sh index 38e132c..5b11703 100644 --- a/scripts/ols-htaccess-watcher.sh +++ b/scripts/ols-htaccess-watcher.sh @@ -52,7 +52,22 @@ while read -r fname; do .htaccess) ;; *) continue ;; esac - ## Drain further events for DEBOUNCE seconds (coalesce the burst), then act. - while read -r -t "$DEBOUNCE" _; do :; done + ## A tenant .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 done diff --git a/scripts/render-shared-ols-config.sh b/scripts/render-shared-ols-config.sh index eae7da7..8cbc6bc 100644 --- a/scripts/render-shared-ols-config.sh +++ b/scripts/render-shared-ols-config.sh @@ -11,8 +11,10 @@ ## (Empirically established 2026-06-10 — see the OLS-tier PoC.) ## ## Per-site contract — the panel writes, for each site, a directory: -## $SITES_ROOT//vhconf.conf (rendered from configs/shared-ols/vhconf.tpl) -## $SITES_ROOT//site.meta (shell: VHNAME, VHROOT, DOMAINS="a.com,www.a.com") +## $SITES_ROOT//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//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). ## @@ -28,10 +30,23 @@ 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" +flock 9 || { echo "render-shared-ols: could not acquire render lock" >&2; exit 1; } +trap 'rm -f "$TMP"' EXIT +## 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 @@ -52,13 +67,13 @@ awk ' /^scriptHandler ?\{/ { skip=1; next } skip && /^\}/ { skip=0; next } !skip { print } -' "$STOCK" > "$OUT" +' "$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" -} >> "$OUT" +} >> "$TMP" ## --- 4. emit per-site vhost stanzas + collect listener map lines --- maps="" @@ -66,9 +81,13 @@ site_count=0 for meta in "$SITES_ROOT"/*/site.meta; do [ -e "$meta" ] || continue sdir=$(dirname "$meta") - VHNAME=""; VHROOT=""; DOMAINS="" - # shellcheck source=/dev/null - . "$meta" + ## PARSE site.meta with sed — 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. sed extraction treats them as plain data. + VHNAME=$(sed -n 's/^VHNAME=//p' "$meta" | head -1) + VHROOT=$(sed -n 's/^VHROOT=//p' "$meta" | head -1) + DOMAINS=$(sed -n 's/^DOMAINS=//p' "$meta" | head -1) 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 @@ -82,7 +101,7 @@ for meta in "$SITES_ROOT"/*/site.meta; do echo " enableScript 1" echo " restrained 1" echo "}" - } >> "$OUT" + } >> "$TMP" maps="${maps} map ${VHNAME} ${DOMAINS}"$'\n' site_count=$((site_count + 1)) done @@ -98,7 +117,7 @@ done echo " allowSymbolLink 1" echo " enableScript 0" echo "}" -} >> "$OUT" +} >> "$TMP" maps="${maps} map _health *"$'\n' ## --- 6. listeners (HTTP :80 + HTTPS :443 self-signed) carrying ALL maps. @@ -119,7 +138,17 @@ maps="${maps} map _health *"$'\n' echo " certFile ${CERT_FILE}" printf '%s' "$maps" echo "}" -} >> "$OUT" +} >> "$TMP" -chown -R lsadm:nogroup "$LSWS_CONF" 2>/dev/null || true +## --- 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)" From 08f35032c5a1263d66c37f76f37093b87aeca82b Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 10 Jun 2026 09:25:05 -0700 Subject: [PATCH 7/7] =?UTF-8?q?fix(shared-ols):=20re-review=20hardening=20?= =?UTF-8?q?=E2=80=94=20bounded=20flock=20+=20stale-tmp=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/render-shared-ols-config.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/render-shared-ols-config.sh b/scripts/render-shared-ols-config.sh index 8cbc6bc..873d121 100644 --- a/scripts/render-shared-ols-config.sh +++ b/scripts/render-shared-ols-config.sh @@ -43,8 +43,14 @@ mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT" ## 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" -flock 9 || { echo "render-shared-ols: could not acquire render lock" >&2; exit 1; } +## 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) ---