diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index e2a708c..b290d1a 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: @@ -144,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.lsphp b/Dockerfile.lsphp new file mode 100644 index 0000000..c70de7b --- /dev/null +++ b/Dockerfile.lsphp @@ -0,0 +1,63 @@ +## 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/cac-lsphp-normalize.php \ + /scripts/ +RUN chmod +x /scripts/entrypoint-lsphp.sh /scripts/detect-memory-lsphp.sh /scripts/healthcheck-lsphp.sh + +## Apply production lsphp ini overrides into lsphp's scan dir (path varies by +## PHP minor version; ask lsphp directly — same idiom as Dockerfile.litespeed). +COPY ./configs/litespeed/lsphp-overrides.ini /etc/lsws-templates/lsphp-overrides.ini +RUN bash -c 'set -e; \ + SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \ + mkdir -p "$SCAN_DIR"; \ + cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \ + echo "wrote overrides to $SCAN_DIR"' + +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/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/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/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..c2f9edb --- /dev/null +++ b/scripts/entrypoint-lsphp.sh @@ -0,0 +1,135 @@ +#!/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 identical to cac-fpm/cac-litespeed: same `uid`/`user` contract, +## the customer docroot mounted at /home/$user (so PHP sees /home/$user/public_html +## EXACTLY like the standalone tiers — true 1:1 drop-in for WordPress ABSPATH, +## config paths, and DB-stored absolute paths). The only difference is OLS lives +## in a separate container, so this PID 1 is lsphp itself. +## +## THE SYMLINK (see feedback_ols_lsapi_no_script_filename_remap): OLS has no +## ProxyFCGISetEnvIf-style remap — it hands lsphp exactly its vhost docRoot path. +## The shared-ols container serves from its bulk /docker/users->/mnt/users mount, +## so its docRoot (and the SCRIPT_FILENAME it sends us) is +## /mnt/users///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 + +: "${PHPVER:=83}" +: "${environment:=PROD}" +export CONTAINER_ROLE="lsphp_only" +export PHPVER environment + +## ---- env validation (same contract as entrypoint-fpm / entrypoint-litespeed) ---- +if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then + echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2 + exit 1 +fi +: "${domain:=localhost}" +export user domain + +LSPHP_BIN="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp" +if [ ! -x "$LSPHP_BIN" ]; then + echo "FATAL: lsphp binary not found at $LSPHP_BIN (PHPVER=$PHPVER)." >&2 + exit 1 +fi + +## ---- user + directories (identical to entrypoint-litespeed.sh: docroot at +## /home/$user, the customer's bind-mounted domain dir) ---- +if ! id -u "$user" >/dev/null 2>&1; then + useradd -u "$uid" -m -s /bin/bash "$user" +fi +mkdir -p "/home/$user/public_html" "/home/$user/logs/php-fpm" + +## ---- compatibility symlink for the OLS-sent path ---- +## OLS sends SCRIPT_FILENAME under /mnt/users///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 +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" < 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. + if [ -n "${OPCACHE_MEMORY_MB:-}" ] || [ -n "${OPCACHE_MAX_FILES:-}" ]; then + { + echo "; rendered at container start by entrypoint-lsphp.sh" + echo "; per-site override from WHP whp.sites.opcache_*_override" + [ -n "${OPCACHE_MEMORY_MB:-}" ] && echo "opcache.memory_consumption = ${OPCACHE_MEMORY_MB}" + [ -n "${OPCACHE_MAX_FILES:-}" ] && echo "opcache.max_accelerated_files = ${OPCACHE_MAX_FILES}" + } > "$SCAN_DIR/99-user-opcache.ini" + fi +fi + +## ---- ownership ---- +## Ensure the dirs we created + the log file are customer-owned so lsphp (running +## as $user) can read code and write logs. Customer content is already +## customer-owned from the host side, so we don't recurse the whole (potentially +## large) tree on every boot. +touch "/home/$user/logs/php-fpm/error.log" +chown "$uid:$uid" "/home/$user" "/home/$user/public_html" "/home/$user/logs" "/home/$user/logs/php-fpm" "/home/$user/logs/php-fpm/error.log" 2>/dev/null || true + +## ---- exec lsphp -b as the customer user (PID 1) ---- +## Bind port is unprivileged (9000), so no root port-bind step is needed — start +## directly as $user. Prefer setpriv (util-linux, on the Ubuntu base); fall back +## to runuser. exec so lsphp becomes PID 1 and receives Docker's signals +## directly (clean stop/restart, matches the php-fpm container's lifecycle). +echo "entrypoint-lsphp: exec $LSPHP_BIN -b $LSPHP_BIND as $user (uid=$uid)" +if command -v setpriv >/dev/null 2>&1; then + exec setpriv --reuid "$uid" --regid "$uid" --init-groups "$LSPHP_BIN" -b "$LSPHP_BIND" +elif command -v runuser >/dev/null 2>&1; then + exec runuser -u "$user" -- "$LSPHP_BIN" -b "$LSPHP_BIND" +else + exec sudo -u "$user" -E "$LSPHP_BIN" -b "$LSPHP_BIND" +fi diff --git a/scripts/entrypoint-shared-ols.sh b/scripts/entrypoint-shared-ols.sh new file mode 100644 index 0000000..ceb4e5e --- /dev/null +++ b/scripts/entrypoint-shared-ols.sh @@ -0,0 +1,126 @@ +#!/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" + +## ---- 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 + +## ---- 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/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 diff --git a/scripts/ols-htaccess-watcher.sh b/scripts/ols-htaccess-watcher.sh new file mode 100644 index 0000000..5b11703 --- /dev/null +++ b/scripts/ols-htaccess-watcher.sh @@ -0,0 +1,73 @@ +#!/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 + ## 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 new file mode 100644 index 0000000..873d121 --- /dev/null +++ b/scripts/render-shared-ols-config.sh @@ -0,0 +1,160 @@ +#!/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 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). +## +## Idempotent: always rebuilds from the stock config, so re-runs never compound. +set -euo pipefail + +LSWS_CONF=/usr/local/lsws/conf +TPL_DIR=${TPL_DIR:-/etc/shared-ols-templates} +SITES_ROOT=${SITES_ROOT:-$LSWS_CONF/shared-sites} +LSCACHE_ROOT=${LSCACHE_ROOT:-/var/lscache} +CERT_FILE=${CERT_FILE:-$LSWS_CONF/cert/shared-ols.crt} +KEY_FILE=${KEY_FILE:-$LSWS_CONF/cert/shared-ols.key} +export LSCACHE_ROOT + +OUT="$LSWS_CONF/httpd_config.conf" +TMP="$LSWS_CONF/.httpd_config.conf.tmp.$$" +STOCK="/usr/local/lsws/.conf/httpd_config.conf" + +mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT" + +## --- SERIALIZE concurrent renders + write ATOMICALLY --- +## The panel can fire two renders at once (parallel provisioning), and the +## in-container .htaccess watcher issues `lswsctrl restart` independently. If OLS +## (re)reads httpd_config.conf while it's half-written, it fails to parse and the +## whole tier 503s. So: (1) flock so only one render runs at a time; (2) build +## into $TMP and atomically `mv` into place at the end, so any concurrent OLS +## restart always sees a COMPLETE config (the old one until the instant of mv). +exec 9>"$LSWS_CONF/.render.lock" +## Bounded wait (-w): if a previous render is hung, fail after 30s rather than +## blocking the panel's `docker exec` call (and thus the site-save request) +## indefinitely. The caller re-tries on the next change. +flock -w 30 9 || { echo "render-shared-ols: could not acquire render lock within 30s" >&2; exit 1; } +trap 'rm -f "$TMP"' EXIT +## Sweep any stale temp configs left by a prior SIGKILL (trap EXIT doesn't run on +## SIGKILL); each render uses a unique $$ suffix so this never races a live render. +rm -f "$LSWS_CONF"/.httpd_config.conf.tmp.* 2>/dev/null || true +## From here on, build into $TMP (not $OUT). + +## --- 1. start from a pristine stock config (idempotent) --- +if [ ! -f "$STOCK" ]; then + ## Some image builds keep the only copy at conf/; snapshot it once so future + ## renders have a clean base to strip. + mkdir -p "$(dirname "$STOCK")" + cp "$OUT" "$STOCK" +fi + +## --- 2. strip stock blocks that conflict or would run PHP LOCALLY --- +## extProcessor lsphp (autoStart 1, uds) + the server scriptHandler are removed +## so this server NEVER executes PHP itself — all PHP goes to remote sidecars. +## listener HTTP/HTTPS + vhTemplate docker are removed (we add our own). +awk ' + /^listener HTTP \{/ { skip=1; next } + /^listener HTTPS \{/ { skip=1; next } + /^vhTemplate docker ?\{/ { skip=1; next } + /^extProcessor lsphp ?\{/{ skip=1; next } + /^scriptHandler ?\{/ { skip=1; next } + skip && /^\}/ { skip=0; next } + !skip { print } +' "$STOCK" > "$TMP" + +## --- 3. append our server-level base (real-IP, cache module, no local PHP) --- +{ + echo "" + envsubst '${LSCACHE_ROOT}' < "$TPL_DIR/httpd_config_base.tpl" +} >> "$TMP" + +## --- 4. emit per-site vhost stanzas + collect listener map lines --- +maps="" +site_count=0 +for meta in "$SITES_ROOT"/*/site.meta; do + [ -e "$meta" ] || continue + sdir=$(dirname "$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 + fi + { + echo "" + echo "virtualhost ${VHNAME} {" + echo " vhRoot ${VHROOT}" + echo " configFile ${sdir}/vhconf.conf" + echo " allowSymbolLink 1" + echo " enableScript 1" + echo " restrained 1" + echo "}" + } >> "$TMP" + maps="${maps} map ${VHNAME} ${DOMAINS}"$'\n' + site_count=$((site_count + 1)) +done + +## --- 5. ALWAYS add a health vhost mapped to the catch-all so the server is +## valid with zero customer sites and HAProxy health checks (which hit by IP / +## unknown Host) get a 200. Exact-domain maps above win over this '*'. --- +{ + echo "" + echo "virtualhost _health {" + echo " vhRoot /usr/local/lsws/shared-ols-health" + echo " configFile /usr/local/lsws/shared-ols-health/vhconf.conf" + echo " allowSymbolLink 1" + echo " enableScript 0" + echo "}" +} >> "$TMP" +maps="${maps} map _health *"$'\n' + +## --- 6. listeners (HTTP :80 + HTTPS :443 self-signed) carrying ALL maps. +## HAProxy terminates real TLS and connects to this tier on :443 ssl verify +## none (same as shared-httpd), so :443 needs a cert — self-signed is fine. --- +{ + echo "" + echo "listener shared_http {" + echo " address *:80" + echo " secure 0" + printf '%s' "$maps" + echo "}" + echo "" + echo "listener shared_https {" + echo " address *:443" + echo " secure 1" + echo " keyFile ${KEY_FILE}" + echo " certFile ${CERT_FILE}" + printf '%s' "$maps" + echo "}" +} >> "$TMP" + +## --- 7. publish atomically. Validate the temp parses as non-empty, then mv into +## place (rename is atomic on the same filesystem) so a concurrent OLS restart +## never sees a half-written config. chown only the file we wrote — NOT a +## recursive chown of the whole conf tree (that was O(N-sites) on every single +## change; the per-site files are world-readable and owned correctly already). --- +if [ ! -s "$TMP" ]; then + echo "render-shared-ols: refusing to publish empty config" >&2 + exit 1 +fi +chown lsadm:nogroup "$TMP" 2>/dev/null || true +mv -f "$TMP" "$OUT" +echo "render-shared-ols: wrote $OUT ($site_count customer vhost(s) + health)"