#!/usr/bin/env bash ## entrypoint-litespeed.sh — PID 1 for cac-litespeed:phpNN. ## Built on litespeedtech/openlitespeed:1.8.x-lsphp83 prebuilt base. Native ## LSAPI (no FPM proxy), one customer per container. ## ## Process supervision: starts OLS via `openlitespeed -n` (no-daemon + ## crash-guard, per OLS source: lshttpdmain.cpp). SIGTERM is forwarded. ## crond runs in the background for customer crontabs; OLS itself is the ## process we wait on (if OLS dies, the container exits and Docker ## restarts it per its restart policy). set -euo pipefail : "${PHPVER:=83}" : "${environment:=PROD}" : "${LSCACHE_AUTOINSTALL:=1}" export CONTAINER_ROLE="litespeed_only" export PHPVER environment LSCACHE_AUTOINSTALL ## ---- env validation ---- if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2 exit 1 fi : "${domain:=localhost}" export user domain ## ---- user + directories ---- if ! id -u "$user" >/dev/null 2>&1; then ## Ubuntu's useradd; mirror what the AL10 entrypoints do with adduser useradd -u "$uid" -m -s /bin/bash "$user" fi mkdir -p "/home/$user/public_html" ## Log dirs mirror cac:phpNN exactly — apache/ for web server access+error, ## php-fpm/ for PHP errors. OLS isn't Apache and lsphp isn't php-fpm, but ## the customer-facing paths stay identical so log-gathering, analytics, ## and the customer's "where do I find my access log?" mental model all ## just work without per-image-family special cases. mkdir -p "/home/$user/logs/apache" "/home/$user/logs/php-fpm" mkdir -p "/home/$user/lscache" mkdir -p /tmp/lshttpd/swap chmod 1777 /tmp/lshttpd ## ---- memory + lsphp pool sizing ---- # shellcheck source=/dev/null source /scripts/detect-memory-litespeed.sh echo "Container memory: ${CONTAINER_MEMORY_MB}MB | LSAPI_CHILDREN=${LSAPI_CHILDREN} | PHPVER=${PHPVER}" ## ---- self-signed cert (idempotent) ---- mkdir -p /usr/local/lsws/conf/cert if [ ! -f /usr/local/lsws/conf/cert/self.crt ]; then openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ -keyout /usr/local/lsws/conf/cert/self.key \ -out /usr/local/lsws/conf/cert/self.crt \ -subj "/CN=${domain}" 2>/dev/null fi ## ---- render httpd_config + vhconf from templates ---- /scripts/create-vhost-litespeed.sh ## ---- point PHP error_log at the same customer-visible path that ## cac:phpNN uses for php-fpm errors. Drop-in compat: customer code that ## was tailing /home/$user/logs/php-fpm/error.log on the old image will ## see lsphp's PHP errors in the exact same file on the new image. ## Rendered as a tiny ini in lsphp's scan dir; PHP merges it after the ## production-tuning overrides at startup. ## Captured, then matched in the shell. As a single pipeline this was ## `lsphp -i | awk '…{print;exit}'`: awk stops at the "Scan this dir" line, ## which is near the top of the output, so lsphp can still be writing when awk ## closes the pipe. lsphp then dies 141, `set -o pipefail` (line 12) makes that ## the pipeline's status, and because this is a BARE ASSIGNMENT `set -e` KILLS ## PID 1 — the container never starts. (Its twin in entrypoint-lsphp.sh chose a ## degraded fallback instead; this one just exits.) That is a race on whether ## the reader closes before the writer's last write() returns, not a function of ## how big the output is: see the long note over the probe helpers in ## entrypoint-lsphp.sh for the measurements. The rule is simply that no ## `writer | early-exiting-reader` belongs in a pipefail script. ## ## A here-string would remove the pipeline, but bash spills a here-string to ## /tmp/sh-thd.XXXXXX above a build-dependent size (65536 for the bash 5.2.21 in ## this image, between 4096 and 16384 for Debian's 5.2.15) — and on this line, ## a bare assignment, a temp file it cannot create is again `set -e` killing ## PID 1: `docker run --read-only` reproduces exactly that. So the extraction is ## done with parameter expansion, which allocates nothing. ## ## Same answer as the awk it replaces: first line starting "Scan this dir", then ## the text between the FIRST and SECOND '=> ' on it (awk's $2 under -F'=> '), ## empty if the line carries no separator, empty if there is no such line. ## `|| true` on the capture keeps a genuinely failing lsphp as an empty ## SCAN_DIR — which the `-n` test below already handles — not a boot failure. LSPHP_INFO=$(/usr/local/lsws/lsphp"${PHPVER}"/bin/lsphp -i 2>/dev/null || true) SCAN_DIR="" ## The leading newline is what makes a match on LINE 1 behave like every other ## line, exactly as awk's `^` anchor does. scan_rest=$'\n'"$LSPHP_INFO" if [[ $scan_rest == *$'\nScan this dir'* ]]; then ## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`. scan_rest=${scan_rest#*$'\nScan this dir'} scan_line="Scan this dir${scan_rest%%$'\n'*}" if [[ $scan_line == *'=> '* ]]; then SCAN_DIR=${scan_line#*'=> '} SCAN_DIR=${SCAN_DIR%%'=> '*} fi unset scan_line fi unset scan_rest if [ -n "$SCAN_DIR" ]; then cat > "$SCAN_DIR/99-user-error-log.ini" < "$SCAN_DIR/99-user-opcache.ini" fi fi ## ---- ownership: OLS runs as $user end-to-end (server-level user set by ## create-vhost-litespeed.sh, no setUIDMode). So OLS runtime dirs need to ## be customer-owned for log writes, swap files, lsphp socket creation. ## Master still starts as root for port binding, then drops privs to $user. chown -R "$user:$user" /usr/local/lsws/logs /usr/local/lsws/conf/cert /tmp/lshttpd 2>/dev/null || true chown -R "$user:$user" "/home/$user" chmod 755 "/home/$user" ## ---- drop healthz so docker HEALTHCHECK passes before customer files ## Always rewrite as customer; suexec lsphp will read it as that uid too. sudo -u "$user" sh -c "echo ok > /home/$user/public_html/healthz" ## ---- DEV: local mariadb + memcached for parity with cac entrypoints ---- if [ "$environment" = "DEV" ]; then echo "Starting Dev Deployment (litespeed)" mkdir -p "/home/$user/_db_backups" ## mariadb-server + memcached are NOT baked into the image (saves ~500MB ## on PROD pulls). Install them at runtime, but only once per container — ## the command -v guard means a restart of an already-bootstrapped ## container skips the apt step and DEV boot stays ~1.5s like PROD. ## First-boot in DEV adds ~30-60s for the apt install; acceptable ## tradeoff per the design spec. if ! command -v mysqld >/dev/null 2>&1; then echo "DEV first boot: installing mariadb-server + memcached..." apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ mariadb-server memcached apt-get clean rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* fi mkdir -p /run/mysqld && chown mysql:mysql /run/mysqld nohup mysqld --user=mysql &>/dev/null & if [ ! -f "/home/$user/mysql_creds" ]; then sleep 10 mysql_user=$(openssl rand -hex 7) mysql_password=$(openssl rand -hex 12) mysql_db="devdb_$(openssl rand -hex 3)" mysql -e "CREATE DATABASE $mysql_db;" mysql -e "CREATE USER '$mysql_user'@'localhost' IDENTIFIED BY '$mysql_password';" mysql -e "GRANT ALL PRIVILEGES ON *.* TO '$mysql_user'@'localhost' WITH GRANT OPTION;" mysql -e "FLUSH PRIVILEGES;" { echo "MySQL User: $mysql_user" echo "MySQL Password: $mysql_password" echo "MySQL Database: $mysql_db" } > "/home/$user/mysql_creds" cat "/home/$user/mysql_creds" fi /usr/bin/memcached -d -u "$user" fi ## ---- user crontab ---- if [ ! -f "/home/$user/crontab" ]; then { echo "# User crontab for $user" echo "# Add your cron jobs here" } > "/home/$user/crontab" chown "$user:$user" "/home/$user/crontab" fi crontab -u "$user" "/home/$user/crontab" service cron start >/dev/null 2>&1 || /usr/sbin/cron ## ---- LSCache plugin (background, non-fatal) ---- ( /scripts/install-lscache-wp.sh "$user" >>/var/log/lscache-install.log 2>&1 || true ) & ## Stream OLS + customer logs to PID-1 stdout so `docker logs` works. Started ## once, before the supervisor loop — it follows the files across OLS restarts. touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log touch "/home/$user/logs/apache/error_log" "/home/$user/logs/apache/access_log" touch "/home/$user/logs/php-fpm/error.log" chown "$user:$user" "/home/$user/logs/apache/error_log" \ "/home/$user/logs/apache/access_log" \ "/home/$user/logs/php-fpm/error.log" tail -F /usr/local/lsws/logs/error.log \ /usr/local/lsws/logs/access.log \ "/home/$user/logs/apache/error_log" \ "/home/$user/logs/apache/access_log" \ "/home/$user/logs/php-fpm/error.log" 2>/dev/null & ## ---- supervise OLS in DAEMON mode (NOT `openlitespeed -n` + wait) ---- ## OLS performs INTERNAL graceful self-restarts: the LiteSpeed Cache / ## QUIC.cloud integration refreshes the QUIC.cloud IP allowlist on a schedule ## and, when it changes, sends SIGUSR1 → "request a graceful server restart". ## In `-n` foreground mode the OLD main PID exits after the zero-downtime ## handoff; a bare `wait` on that PID lets bash (PID 1) exit and tears the whole ## container down. Worse, that exit is *clean*, so `RestartPolicy` doesn't ## reliably catch it — the container just stops and HAProxy serves 503 until ## someone manually starts it. (Root-caused on whp02 alsacorp, 2026-06-06.) ## ## Daemon mode is OLS's native model: it owns the SIGUSR1 handoff, keeps the ## listeners bound across generations, and rewrites lshttpd.pid to the new main. ## PID 1 just FOLLOWS the pidfile — a graceful self-restart is invisible here ## (zero downtime), and we only ever relaunch on a genuine crash (no live main). STOP_REQUESTED=0 term_handler() { STOP_REQUESTED=1 /usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true } trap term_handler TERM INT ## Authoritative, path-independent liveness check: `lswsctrl status` prints ## "litespeed is running with PID N." when up (and "...is not running" when ## down). We match the running message specifically — a bare grep for "running" ## would also match "not running". (This image keeps the pidfile under ## /tmp/lshttpd, not logs/, so we never hard-code a pidfile path.) ## ## Read into a variable and match with a here-string rather than piping into ## `grep -qi`: `grep -q` closes the pipe on its first match, and under the ## `set -o pipefail` at the top of this file a writer that is still writing when ## that happens dies 141 and the pipeline reports FALSE — i.e. "OLS is down" ## precisely because the "running" line matched, which here means a spurious ## relaunch of a healthy OLS, five of which trip the crash-loop cap and exit ## PID 1. (Same defect that shipped in entrypoint-lsphp.sh's cac_path_parity ## probe.) The reason to change it is STRUCTURAL — a pipefail script must not ## pipe into an early-exit reader, whatever the payload — because "lswsctrl ## prints one short line so it always wins" is a size argument, and size ## arguments about this race are wrong: see the measurements over the probe ## helpers in entrypoint-lsphp.sh, where 41 KB SIGPIPEd 11% of the time into a ## 64 KB pipe. A non-zero `lswsctrl` still means "not running", exactly as ## pipefail made it mean before. ## ## A here-string is the right shape HERE, where those helpers use `[[ ]]`: the ## reason to avoid `<<<` there is that bash spills a large here-string to ## /tmp/sh-thd.XXXXXX and so makes a writable temp dir a boot precondition. The ## threshold is 65536 bytes in this image's bash 5.2.21 and no lower than 4096 ## in any bash this repo has met; `lswsctrl status` prints well under 100 bytes ## and cannot approach it, so no temp file is ever created and the case- ## insensitive match stays a plain `grep -i` instead of a hand-rolled glob. ols_running() { local st st=$(/usr/local/lsws/bin/lswsctrl status 2>/dev/null) || return 1 grep -qi 'running with pid' <<<"$st" } ## Crash-loop cap: if OLS can't stay up, bail out so Docker's restart policy and ## the site-health monitor escalate instead of us hot-looping forever. MAX_STARTS=5 WINDOW=60 starts="" start_ols() { /usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true ## wait up to 10s for the daemon to report running for _ in $(seq 1 20); do ols_running && return 0 sleep 0.5 done return 1 } if ! start_ols; then echo "entrypoint: OLS failed to start (not running after 10s)." >&2 exit 1 fi echo "entrypoint: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)" while true; do if ols_running; then sleep 3 continue fi ## Not running this instant. This is EITHER a clean shutdown OR the brief ## handoff window of a graceful self-restart (status momentarily reports down ## while the new main takes over). Grace, then re-check before judging. sleep 2 if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then continue fi if [ "$STOP_REQUESTED" -eq 1 ]; then echo "entrypoint: SIGTERM received, OLS stopped — exiting." exit 0 fi ## Genuine crash: not running and no shutdown requested. Relaunch, capped. now=$(date +%s) starts="$starts $now" pruned="" for t in $starts; do [ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t" done starts="$pruned" n=$(echo $starts | wc -w) echo "entrypoint: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2 if [ "$n" -ge "$MAX_STARTS" ]; then echo "entrypoint: OLS crash-looping ($n starts in ${WINDOW}s) — bailing out for Docker restart policy / monitor." >&2 exit 1 fi start_ols || true done