Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c02d94ec | ||
|
|
ba9650ee45 | ||
|
|
77af001af6 | ||
|
|
cf6936e225 | ||
|
|
b92725d2ec | ||
|
|
5e83c8db3b | ||
|
|
a325615690 | ||
|
|
e28c3dcce4 | ||
|
|
584099ff19 | ||
|
|
8790b027a9 | ||
|
|
9343a56ccf | ||
|
|
3047123f2b | ||
|
|
07378506a7 | ||
|
|
9761157a6b | ||
|
|
61bfdcfaf9 | ||
|
|
fb4946641a | ||
|
|
690ff8738d | ||
|
|
06df1c410b | ||
|
|
a3aa9f2b26 | ||
|
|
83522b00ef | ||
|
|
15e304e0c3 | ||
|
|
da16faaff5 | ||
|
|
03b8f3f730 | ||
|
|
433e74975c | ||
|
|
cc72fda741 | ||
|
|
a865a13940 | ||
|
|
8dbfdf599a | ||
|
|
2e85f458d3 | ||
|
|
08f35032c5 | ||
|
|
6bb494c72f | ||
|
|
7552760ba0 | ||
|
|
fc65b68bd6 | ||
|
|
e99b8cb2d1 | ||
|
|
19db8f170a | ||
|
|
19092911a3 | ||
|
|
50202538e4 | ||
|
|
2837d40f00 | ||
|
|
cfdaae116a | ||
|
|
87f154cdc8 | ||
|
|
f463519998 | ||
|
|
03cca745f7 | ||
|
|
d1c3cfadc0 | ||
|
|
80fa06592b | ||
|
|
9e13571d61 | ||
|
|
55c28a0c11 | ||
|
|
1756d496e5 | ||
|
|
d5d027c0ab | ||
|
|
28bb1055da | ||
|
|
e9604b8721 | ||
|
|
e81b0df5b8 | ||
|
|
c65f533dcc | ||
|
|
c6f1f42987 | ||
|
|
e20f5620d7 | ||
|
|
1490bde56e | ||
|
|
e5e055d198 | ||
|
|
c68b555a5f | ||
|
|
7f7cb456f0 | ||
|
|
dc6ce2bf12 | ||
|
|
fc55752379 | ||
|
|
367da7806c | ||
|
|
a5cb45a386 | ||
|
|
c78167871c | ||
|
|
87c4f2befc | ||
|
|
a153385d8f | ||
|
|
468bc7b088 | ||
|
|
8b9708e351 | ||
|
|
92ed9885ec | ||
|
|
844b21bd7c | ||
|
|
3d903b437f | ||
|
|
152dd413ef | ||
|
|
617fdbcd21 | ||
|
|
154f42ae09 | ||
|
|
b5857d73c2 | ||
|
|
b1de7021a3 | ||
|
|
9f8beb45b8 | ||
|
|
88f462eb04 | ||
|
|
e7b0bce666 | ||
|
|
5a097034c4 | ||
|
|
a41157fad0 | ||
|
|
4fd7ee465a | ||
|
|
8a7490ef98 | ||
|
|
9df776ef08 | ||
|
|
7bab6d39fc | ||
|
|
9630408ca0 | ||
|
|
49c5438866 | ||
|
|
885deb5979 | ||
|
|
23253e9f37 | ||
|
|
fde567d5f9 | ||
|
|
b2675abc30 | ||
|
|
aab89a7412 | ||
|
|
527ba5cf58 | ||
|
|
bbd2de6792 | ||
|
|
ed9ba0118b | ||
|
|
715b998404 | ||
|
|
7d988b338c | ||
|
|
b3e284a547 | ||
|
|
565482764d | ||
|
|
3d3e353c66 | ||
|
|
0373eb4ea8 | ||
|
|
36757fac8f | ||
|
|
0c8bdc4f04 | ||
|
|
f1ab086228 | ||
|
|
520af5b3a8 | ||
|
|
06a7cbc88d | ||
|
|
b1ec63617a | ||
|
|
5ead6ed456 | ||
|
|
b38b80e6fc | ||
|
|
b53a4999bf | ||
|
|
49f2266974 | ||
|
|
abb1da3a0f | ||
|
|
ac5c70d26b | ||
|
|
1d4d440a88 | ||
|
|
5108689aa4 | ||
|
|
3d51a63ae4 | ||
|
|
4ba4b7ae1e | ||
|
|
07999c4252 | ||
|
|
90841ada03 | ||
|
|
b2b3d284a6 |
@@ -0,0 +1,39 @@
|
|||||||
|
# Ignore version control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Ignore CI/CD and workflow files
|
||||||
|
.gitea/
|
||||||
|
.github/
|
||||||
|
.gitlab/
|
||||||
|
|
||||||
|
# Ignore local development files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Ignore OS and editor files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Ignore test and documentation files
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
README*
|
||||||
|
|
||||||
|
# Ignore node and Python artifacts (if present)
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Ignore secrets and configs
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
secrets/
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
name: Cloud Apache Container
|
||||||
|
run-name: ${{ gitea.actor }} pushed a change to trunk
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- trunk
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Shell gate. Runs FIRST and costs seconds; the images below do not depend on
|
||||||
|
# it (a red job here does not block a push that is otherwise fine), but it is
|
||||||
|
# the only place the ENTRYPOINT logic is executed at all. The .phpt suite and
|
||||||
|
# the Dockerfile's `lsphp -i` probe both test the extension, and both were
|
||||||
|
# green for the release whose entrypoint declared that same extension
|
||||||
|
# missing — see scripts/tests/lsphp-info-probe.test.sh for what went wrong
|
||||||
|
# and why it needed a test outside the image build to catch it.
|
||||||
|
Shell-Checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Runner images differ on whether they are root and whether sudo exists.
|
||||||
|
- name: Install shellcheck
|
||||||
|
run: |
|
||||||
|
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||||
|
(apt-get update && apt-get install -y shellcheck) ||
|
||||||
|
(sudo apt-get update && sudo apt-get install -y shellcheck)
|
||||||
|
fi
|
||||||
|
shellcheck --version
|
||||||
|
|
||||||
|
- name: Syntax check every shell script
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
find scripts ext -name '*.sh' -print0 | xargs -0 -n1 bash -n
|
||||||
|
|
||||||
|
# Deliberately NOT repo-wide. The older scripts (entrypoint.sh,
|
||||||
|
# entrypoint-fpm.sh, create-vhost.sh, create-php-config.sh,
|
||||||
|
# detect-memory.sh) carry pre-existing SC2154/SC2027 findings that predate
|
||||||
|
# this job; listing them here would make the gate red on arrival and
|
||||||
|
# therefore ignored. This is the set that is clean today — the scripts
|
||||||
|
# that run `set -o pipefail` plus the tests. Add files as they are fixed;
|
||||||
|
# do not add one that is not yet clean.
|
||||||
|
- name: shellcheck (warnings and above, on the clean set)
|
||||||
|
run: |
|
||||||
|
shellcheck -S warning \
|
||||||
|
scripts/entrypoint-lsphp.sh \
|
||||||
|
scripts/entrypoint-litespeed.sh \
|
||||||
|
scripts/entrypoint-shared-ols.sh \
|
||||||
|
scripts/render-shared-ols-config.sh \
|
||||||
|
scripts/ols-htaccess-watcher.sh \
|
||||||
|
scripts/create-vhost-litespeed.sh \
|
||||||
|
scripts/install-lscache-wp.sh \
|
||||||
|
scripts/tune-mpm.sh \
|
||||||
|
scripts/tests/lsphp-info-probe.test.sh
|
||||||
|
|
||||||
|
- name: lsphp probe regression test
|
||||||
|
run: ./scripts/tests/lsphp-info-probe.test.sh
|
||||||
|
|
||||||
|
Build-and-Push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
phpver: [74, 80, 81, 82, 83, 84, 85]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
PHPVER=${{ matrix.phpver }}
|
||||||
|
tags: |
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/cac:php${{ matrix.phpver }}
|
||||||
|
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac:latest' || '' }}
|
||||||
|
|
||||||
|
Build-FPM-Images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
phpver: [74, 80, 81, 82, 83, 84, 85]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push FPM Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: ./Dockerfile.fpm
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
PHPVER=${{ matrix.phpver }}
|
||||||
|
tags: |
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:php${{ matrix.phpver }}
|
||||||
|
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:latest' || '' }}
|
||||||
|
|
||||||
|
Build-LiteSpeed-Images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# PHP 7.4/8.0 deliberately excluded — the LiteSpeed prebuilt base
|
||||||
|
# images stop at older OLS releases for those PHP versions, and the
|
||||||
|
# cac-litespeed tier is a paid premium offering: 8.1+ is the
|
||||||
|
# modernization story we're selling.
|
||||||
|
phpver: [81, 82, 83, 84, 85]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push LiteSpeed Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: ./Dockerfile.litespeed
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
PHPVER=${{ matrix.phpver }}
|
||||||
|
OLS_VERSION=1.8.4
|
||||||
|
# OLS_VERSION pinned to 1.8.4 — only release with prebuilt images
|
||||||
|
# for every PHP version we ship (1.8.5 and 1.9.0 don't have an
|
||||||
|
# lsphp81 variant on Docker Hub). Bump alongside a local rebuild
|
||||||
|
# test when LiteSpeed publishes lsphp81 on a newer OLS release.
|
||||||
|
# See spec: docs/superpowers/specs/2026-06-01-cac-litespeed-design.md
|
||||||
|
tags: |
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:php${{ matrix.phpver }}
|
||||||
|
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:latest' || '' }}
|
||||||
|
|
||||||
|
Build-LSPHP-Images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# Same PHP matrix as cac-litespeed (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:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push Shared httpd Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: ./Dockerfile.shared-httpd
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/shared-httpd:latest
|
||||||
|
|
||||||
|
Build-Shared-OLS:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push Shared OLS Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
file: ./Dockerfile.shared-ols
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
# Single image (runs no PHP). PHPVER just selects the OLS base tag;
|
||||||
|
# pinned to 83 / OLS 1.8.4 to match the rest of the litespeed family.
|
||||||
|
build-args: |
|
||||||
|
PHPVER=83
|
||||||
|
OLS_VERSION=1.8.4
|
||||||
|
tags: |
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/shared-ols:latest
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Cloud Apache Container (CAC) is a Docker-based PHP web hosting environment that supports multiple PHP versions (7.4 through 8.4) with Apache, designed for both local development and production deployment.
|
||||||
|
|
||||||
|
## Common Development Commands
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
```bash
|
||||||
|
# Quick start with automated setup (creates helper scripts)
|
||||||
|
./local-dev.sh -n local-dev
|
||||||
|
|
||||||
|
# With specific PHP version
|
||||||
|
./local-dev.sh -n myproject -a 84 # PHP 8.4
|
||||||
|
|
||||||
|
# Helper scripts created by local-dev.sh:
|
||||||
|
./instance_start # Start container
|
||||||
|
./instance_stop # Stop container
|
||||||
|
./instance_logs # Tail Apache logs
|
||||||
|
./instance_db_info # Show MySQL credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building and Testing
|
||||||
|
```bash
|
||||||
|
# Build container locally
|
||||||
|
docker build -t cac:latest .
|
||||||
|
|
||||||
|
# Build with specific PHP version
|
||||||
|
docker build --build-arg PHP_VER=83 -t cac:php83 .
|
||||||
|
|
||||||
|
# Run container manually
|
||||||
|
docker run -d -p 80:80 -p 443:443 \
|
||||||
|
-e PHPVER=83 -e environment=DEV \
|
||||||
|
-e uid=$(id -u) -e user=$(whoami) -e domain=localhost \
|
||||||
|
-v"$(pwd)/user":/home/$(whoami) \
|
||||||
|
--name test-container cac:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Deployment
|
||||||
|
- Production git directory: `/root/whp`
|
||||||
|
- After `git pull`, sync web files: `rsync -av web-files/ /docker/whp/web/`
|
||||||
|
|
||||||
|
## Architecture and Key Components
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
- `/scripts/` - Container setup scripts (entrypoint, PHP installers, vhost creation)
|
||||||
|
- `/config/` - Apache and PHP configuration files
|
||||||
|
- `/web-files/` - Default web content (ping endpoint)
|
||||||
|
- `/.gitea/workflows/` - CI/CD pipeline for multi-PHP version builds
|
||||||
|
|
||||||
|
### Container Behavior
|
||||||
|
1. **Entrypoint Flow** (`scripts/entrypoint.sh`):
|
||||||
|
- Creates user with specified UID
|
||||||
|
- Sets up directory structure
|
||||||
|
- Configures Apache vhost based on environment variables
|
||||||
|
- In DEV mode: starts MariaDB and Memcached
|
||||||
|
- Starts Apache and PHP-FPM
|
||||||
|
|
||||||
|
2. **Environment Modes**:
|
||||||
|
- **DEV** (`environment=DEV`): Local database, memcached, automatic backups
|
||||||
|
- **PROD** (default): Expects external database/cache services
|
||||||
|
|
||||||
|
3. **PHP Version Management**:
|
||||||
|
- Controlled via `PHPVER` environment variable (74, 80, 81, 82, 83, 84)
|
||||||
|
- Each version has dedicated install script in `/scripts/`
|
||||||
|
- PHP-FPM configuration dynamically created based on version
|
||||||
|
|
||||||
|
### Key Environment Variables
|
||||||
|
- `uid` (required): User ID for file permissions
|
||||||
|
- `user` (required): Username for container user
|
||||||
|
- `domain` (required): Primary domain for Apache vhost
|
||||||
|
- `serveralias`: Additional domains (comma-separated)
|
||||||
|
- `PHPVER`: PHP version to use (default: 83)
|
||||||
|
- `environment`: DEV or PROD mode
|
||||||
|
|
||||||
|
## Important Technical Details
|
||||||
|
|
||||||
|
1. **Health Check**: Available at `/ping` endpoint
|
||||||
|
2. **Logs Location**: `/home/$user/logs/apache/` and `/home/$user/logs/php-fpm/`
|
||||||
|
3. **Database Backups** (DEV mode): Every 15 minutes to `/home/$user/_db_backups/`
|
||||||
|
4. **Log Rotation**: Compress after 3 days, delete after 7 days
|
||||||
|
5. **SSL**: Self-signed certificate auto-generated, proper SSL configured
|
||||||
|
6. **WordPress**: WP-CLI pre-installed for WordPress development
|
||||||
+43
-16
@@ -1,23 +1,50 @@
|
|||||||
FROM almalinux/9-base
|
FROM almalinux/10-base
|
||||||
ARG PHPVER=81
|
ARG PHPVER=83
|
||||||
#RUN dnf update -y && dnf upgrade -y
|
|
||||||
RUN dnf install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm -y
|
# Install repos, update, install only needed packages, clean up in one layer
|
||||||
RUN dnf install -y https://rpms.remirepo.net/enterprise/remi-release-9.rpm
|
RUN dnf install -y \
|
||||||
#RUN dnf update -y && dnf upgrade -y
|
https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm \
|
||||||
RUN dnf install -y httpd mod_ssl wget procps
|
https://rpms.remirepo.net/enterprise/remi-release-10.rpm && \
|
||||||
RUN openssl req -newkey rsa:2048 -nodes -keyout /etc/pki/tls/private/localhost.key -x509 -days 3650 -subj "/CN=localhost" -out /etc/pki/tls/certs/localhost.crt
|
dnf update -y && \
|
||||||
RUN mkdir /run/php-fpm/
|
dnf install -y httpd mod_ssl openssl wget procps cronie iproute postgresql-devel microdnf less git \
|
||||||
RUN mkdir /scripts
|
nano rsync unzip zip mariadb bind-utils jq patch nc tree dos2unix && \
|
||||||
COPY ./scripts/* /scripts/
|
dnf clean all && \
|
||||||
|
rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
|
||||||
|
|
||||||
|
# Copy scripts into the image and set permissions
|
||||||
|
COPY ./scripts/ /scripts/
|
||||||
RUN chmod +x /scripts/*
|
RUN chmod +x /scripts/*
|
||||||
#RUN /scripts/install-php$PHPVER.sh
|
|
||||||
RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
|
# Install ImageMagick from EPEL with HEIC/HEIF/AVIF support
|
||||||
RUN chmod +x wp-cli.phar
|
RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \
|
||||||
RUN mv wp-cli.phar /usr/local/bin/wp
|
dnf clean all
|
||||||
|
|
||||||
|
# Generate self-signed cert, create needed dirs, install PHP, clean up
|
||||||
|
RUN openssl req -newkey rsa:2048 -nodes -keyout /etc/pki/tls/private/localhost.key -x509 -days 3650 -subj "/CN=localhost" -out /etc/pki/tls/certs/localhost.crt && \
|
||||||
|
mkdir -p /run/php-fpm/ && \
|
||||||
|
/scripts/install-php$PHPVER.sh && \
|
||||||
|
rm -rf /tmp/*
|
||||||
|
|
||||||
|
# Download and install wp-cli (consider pinning version for reproducibility)
|
||||||
|
RUN curl -L -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
|
||||||
|
chmod +x /usr/local/bin/wp
|
||||||
|
|
||||||
|
# Download and install Composer
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
|
||||||
|
chmod +x /usr/local/bin/composer
|
||||||
|
|
||||||
|
# Copy configs and web files
|
||||||
COPY ./configs/default-index.conf /etc/httpd/conf.d/
|
COPY ./configs/default-index.conf /etc/httpd/conf.d/
|
||||||
COPY ./configs/prod-php.ini /etc/php.ini
|
COPY ./configs/prod-php.ini /etc/php.ini
|
||||||
COPY ./configs/phpinfo.php /var/www/html/
|
COPY ./configs/phpinfo.php /var/www/html/
|
||||||
COPY ./configs/mariadb.repo /etc/yum.repos.d/
|
COPY ./configs/mariadb.repo /etc/yum.repos.d/
|
||||||
COPY ./configs/index.php /var/www/html/
|
COPY ./configs/index.php /var/www/html/
|
||||||
RUN yum clean all
|
COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
|
||||||
|
|
||||||
|
# Set up cron job in a single layer
|
||||||
|
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/ || exit 1
|
||||||
|
|
||||||
ENTRYPOINT [ "/scripts/entrypoint.sh" ]
|
ENTRYPOINT [ "/scripts/entrypoint.sh" ]
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
FROM almalinux/10-base
|
||||||
|
ARG PHPVER=83
|
||||||
|
|
||||||
|
# Install repos, update, install only needed packages (no httpd/mod_ssl), clean up in one layer
|
||||||
|
RUN dnf install -y \
|
||||||
|
https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm \
|
||||||
|
https://rpms.remirepo.net/enterprise/remi-release-10.rpm && \
|
||||||
|
dnf update -y && \
|
||||||
|
dnf install -y openssl wget procps cronie iproute postgresql-devel microdnf less git \
|
||||||
|
nano rsync unzip zip mariadb bind-utils jq patch nc tree dos2unix fcgi && \
|
||||||
|
dnf clean all && \
|
||||||
|
rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
|
||||||
|
|
||||||
|
# Copy scripts into the image and set permissions
|
||||||
|
COPY ./scripts/ /scripts/
|
||||||
|
RUN chmod +x /scripts/*
|
||||||
|
|
||||||
|
# Install ImageMagick from EPEL with HEIC/HEIF/AVIF support
|
||||||
|
RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \
|
||||||
|
dnf clean all
|
||||||
|
|
||||||
|
# Create needed dirs, install PHP, clean up (no SSL cert, no httpd)
|
||||||
|
RUN mkdir -p /run/php-fpm/ && \
|
||||||
|
/scripts/install-php$PHPVER.sh && \
|
||||||
|
rm -rf /tmp/*
|
||||||
|
|
||||||
|
# Download and install wp-cli
|
||||||
|
RUN curl -L -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
|
||||||
|
chmod +x /usr/local/bin/wp
|
||||||
|
|
||||||
|
# Download and install Composer
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
|
||||||
|
chmod +x /usr/local/bin/composer
|
||||||
|
|
||||||
|
# Copy configs (PHP only, no Apache configs)
|
||||||
|
COPY ./configs/prod-php.ini /etc/php.ini
|
||||||
|
COPY ./configs/mariadb.repo /etc/yum.repos.d/
|
||||||
|
|
||||||
|
# Set up cron job for log rotation
|
||||||
|
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD SCRIPT_FILENAME=/fpm-ping SCRIPT_NAME=/fpm-ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 | grep -q pong || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/scripts/entrypoint-fpm.sh" ]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
## cac-litespeed — OpenLiteSpeed customer container, LSAPI-native.
|
||||||
|
##
|
||||||
|
## Built on top of the LiteSpeed-maintained prebuilt image rather than
|
||||||
|
## installed-from-RPM on AlmaLinux 10. Rationale:
|
||||||
|
## - The EL10 RPM ships an empty /usr/local/lsws/cgid/ directory (the
|
||||||
|
## lscgid suexec helper is built by the upstream tarball install.sh,
|
||||||
|
## not packaged), which makes LSAPI unusable.
|
||||||
|
## - The prebuilt image is Ubuntu 24.04-based and includes lsphp +
|
||||||
|
## everything WP/WooCommerce needs out of the box (memcached, redis,
|
||||||
|
## imagick, mbstring, mysqlnd, intl, gd, soap, bcmath, gmp, sodium,
|
||||||
|
## opcache, ...) — saves us a dozen explicit installs and avoids the
|
||||||
|
## libonig.so.105 packaging bug entirely.
|
||||||
|
## - LiteSpeed Inc maintains it; OLS upgrades become a base-image bump.
|
||||||
|
##
|
||||||
|
## Tradeoff vs the rest of the CAC family: this image is Ubuntu-based,
|
||||||
|
## not AlmaLinux. The "cac" naming is now slightly misleading (it's no
|
||||||
|
## longer Cloud *Apache* Container, it's Cloud LiteSpeed Container) but
|
||||||
|
## the panel doesn't care and the customer-facing contract is identical.
|
||||||
|
|
||||||
|
## ARG before FROM is special — it can be used in the FROM line, but the
|
||||||
|
## value goes out of scope inside the image, so we re-declare ARG PHPVER
|
||||||
|
## after FROM for any RUN steps that need it.
|
||||||
|
ARG OLS_VERSION=1.8.5
|
||||||
|
ARG PHPVER=83
|
||||||
|
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
|
||||||
|
ARG PHPVER=83
|
||||||
|
ENV PHPVER=${PHPVER}
|
||||||
|
|
||||||
|
## Tooling we layer on top of the base:
|
||||||
|
## - gettext-base: envsubst for runtime template rendering
|
||||||
|
## - sudo: install-lscache-wp.sh runs wp-cli as the customer user
|
||||||
|
## - composer: not in the base image (wp-cli is)
|
||||||
|
## - cron: customer crontab support (mirror cac:phpXX behaviour)
|
||||||
|
## - lsphp83-ldap: not in base image, useful for some WP plugins
|
||||||
|
##
|
||||||
|
## NOTE: mariadb-server + memcached were previously installed here for
|
||||||
|
## DEV-mode parity but bloated the PROD image by ~500MB. They are now
|
||||||
|
## installed at runtime by entrypoint-litespeed.sh ONLY when
|
||||||
|
## environment=DEV, mirroring the cac:phpNN pattern. The mysql CLI
|
||||||
|
## client (used by the DEV creds-bootstrap and by wp-cli) is already
|
||||||
|
## present in the litespeedtech/openlitespeed base via the mysql-client
|
||||||
|
## package, so no client-side install is needed at build time.
|
||||||
|
##
|
||||||
|
## All apt cache is cleaned in the same layer to keep image size down.
|
||||||
|
## lsphp${PHPVER}-ldap is the only extra ext we add (everything else WP needs
|
||||||
|
## ships in the prebuilt base). lsphp84 + lsphp85 don't ship imap or pspell
|
||||||
|
## from LiteSpeed — customers needing imap should pin to 8.3 or earlier.
|
||||||
|
RUN apt-get update && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
gettext-base sudo cron \
|
||||||
|
ca-certificates curl wget \
|
||||||
|
lsphp${PHPVER}-ldap && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||||
|
|
||||||
|
## Composer (matches the wp-cli pattern in the existing CAC Dockerfile —
|
||||||
|
## phar download, no `php install` pipe since lsphp's CLI mode is fine).
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/composer https://getcomposer.org/download/latest-stable/composer.phar && \
|
||||||
|
chmod +x /usr/local/bin/composer
|
||||||
|
|
||||||
|
## Our scripts + config templates layer in last (they change most often,
|
||||||
|
## keep them off the slow apt layer).
|
||||||
|
COPY ./scripts/entrypoint-litespeed.sh \
|
||||||
|
./scripts/create-vhost-litespeed.sh \
|
||||||
|
./scripts/detect-memory-litespeed.sh \
|
||||||
|
./scripts/install-lscache-wp.sh \
|
||||||
|
./scripts/log-rotate.sh \
|
||||||
|
/scripts/
|
||||||
|
RUN chmod +x /scripts/*
|
||||||
|
COPY ./configs/litespeed/ /etc/lsws-templates/
|
||||||
|
|
||||||
|
## Apply our production lsphp ini overrides. Ask lsphp for its scan dir
|
||||||
|
## directly (varies by PHP minor version: 8.3/8.4/8.5 each have their own
|
||||||
|
## /usr/local/lsws/lsphpNN/etc/php/8.M/mods-available/). Dockerfile RUN uses
|
||||||
|
## /bin/sh so we explicitly `bash -c` for safer scripting.
|
||||||
|
RUN bash -c 'set -e; \
|
||||||
|
SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
|
||||||
|
mkdir -p "$SCAN_DIR"; \
|
||||||
|
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
|
||||||
|
echo "wrote overrides to $SCAN_DIR"'
|
||||||
|
|
||||||
|
## Disable the OLS WebAdmin port for customer-facing containers. Bind admin
|
||||||
|
## listener to loopback so it's unreachable even from the docker network.
|
||||||
|
RUN sed -i 's|^[[:space:]]*address[[:space:]]\+\*:| address 127.0.0.1:|' \
|
||||||
|
/usr/local/lsws/admin/conf/admin_config.conf 2>/dev/null || true
|
||||||
|
|
||||||
|
## Cron entry for log rotation (mirrors cac:phpXX).
|
||||||
|
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
|
||||||
|
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
## Healthcheck: the entrypoint drops a static /healthz into the customer
|
||||||
|
## docroot at boot, so this passes even before any customer files exist.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsSk https://127.0.0.1/healthz || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/scripts/entrypoint-litespeed.sh"]
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
## cac-lsphp — per-site DETACHED lsphp (LSAPI) backend for the shared-ols tier.
|
||||||
|
##
|
||||||
|
## The LiteSpeed analogue of cac-fpm: a slim, single-tenant PHP backend that
|
||||||
|
## runs `lsphp -b 0.0.0.0:9000` (detached LSAPI mode) and NOTHING ELSE — no
|
||||||
|
## webserver. The shared OpenLiteSpeed container (shared-ols) sits in front and
|
||||||
|
## reaches this over the docker network via an extProcessor of type lsapi,
|
||||||
|
## address <this-container>:9000 — structurally identical to how shared-httpd
|
||||||
|
## reaches a cac-fpm container's php-fpm on :9000.
|
||||||
|
##
|
||||||
|
## Built on the SAME LiteSpeed prebuilt base as cac-litespeed so the lsphp
|
||||||
|
## binary + extension set are byte-for-byte the runtime customers already get
|
||||||
|
## on the litespeed tier (memcached, redis, imagick, mbstring, mysqlnd, intl,
|
||||||
|
## gd, soap, bcmath, gmp, sodium, opcache, ... + lsphpNN-ldap added below).
|
||||||
|
## We do NOT strip the bundled OpenLiteSpeed binaries: the "no webserver"
|
||||||
|
## guarantee comes from the ENTRYPOINT (it only ever execs lsphp), and deleting
|
||||||
|
## OLS files from the upstream image risks breaking lsphp's shared libs for no
|
||||||
|
## real benefit. Only :9000 is EXPOSEd, and OLS is never started.
|
||||||
|
##
|
||||||
|
## See the design spec + PoC: whp docs/superpowers/plans/2026-06-09-ols-lsphp-tier.md
|
||||||
|
## and the LSAPI path-parity finding (feedback_ols_lsapi_no_script_filename_remap).
|
||||||
|
|
||||||
|
ARG OLS_VERSION=1.8.4
|
||||||
|
ARG PHPVER=83
|
||||||
|
|
||||||
|
## ---- stage 1: build the cac_path_parity extension --------------------------
|
||||||
|
## $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] parity with cac-fpm, enforced
|
||||||
|
## from RINIT so a customer's .user.ini cannot displace it — see
|
||||||
|
## ext/cac-path-parity/cac_path_parity.c for why this is an extension and not an
|
||||||
|
## auto_prepend_file.
|
||||||
|
##
|
||||||
|
## WHICH lsphp THE .so IS BUILT AGAINST — read this before touching the apt lines.
|
||||||
|
## `lsphp${PHPVER}-dev` is NOT available at the version the base image ships:
|
||||||
|
## the LiteSpeed apt repo carries only the CURRENT release, and every prebuilt
|
||||||
|
## OLS base image is behind it (measured 2026-08-05 on OLS 1.8.4:
|
||||||
|
## lsphp81 8.1.33 base / 8.1.34 repo, lsphp83 8.3.28 / 8.3.32,
|
||||||
|
## lsphp85 8.5.0 / 8.5.8).
|
||||||
|
## Pinning -dev to the base version fails on all three with
|
||||||
|
## `E: Version '<base>' for 'lsphp<NN>-dev' was not found`.
|
||||||
|
##
|
||||||
|
## So installing -dev necessarily UPGRADES lsphp in this stage. The parity we can
|
||||||
|
## have — and the one this file now guarantees — is the other direction: the
|
||||||
|
## shipped runtime is pinned to whatever version this stage compiled against.
|
||||||
|
## That version is recorded here and consumed by stage 2, so the two apt layers
|
||||||
|
## are cache-locked to each other. Without this, `COPY ./ext` invalidating only
|
||||||
|
## THIS stage while stage 2's apt layer stayed cached produced a real, repeatable
|
||||||
|
## skew (reviewer measured a .so built on 8.3.32 shipped next to an 8.3.30
|
||||||
|
## runtime, with lsphp83-common at 8.3.31 — the vendor family is not always
|
||||||
|
## uniformly versioned either).
|
||||||
|
##
|
||||||
|
## Benign in practice (PHP holds ABI stable across a patch series) but it is the
|
||||||
|
## riskier direction — headers NEWER than the runtime — and the runtime assertion
|
||||||
|
## in stage 2 catches only load failure, never silent struct-layout drift.
|
||||||
|
##
|
||||||
|
## Separate stage on purpose: the compiler + headers (~400MB) stay out of the
|
||||||
|
## shipped image, which gains only the ~40KB .so. Costs ~1-2 min of CI per PHP
|
||||||
|
## version; both stages share the same base layer, so no extra pull.
|
||||||
|
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} AS ext-build
|
||||||
|
ARG PHPVER=83
|
||||||
|
|
||||||
|
## Toolchain layer, deliberately BEFORE the source COPY so editing the extension
|
||||||
|
## does not re-resolve the PHP version (which is what caused the skew above).
|
||||||
|
## Records the exact lsphp version the headers belong to; stage 2 pins to it.
|
||||||
|
RUN set -e; \
|
||||||
|
apt-get update; \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
build-essential autoconf pkg-config \
|
||||||
|
lsphp${PHPVER}-dev; \
|
||||||
|
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
|
||||||
|
DEVV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}-dev); \
|
||||||
|
if [ "$RTV" != "$DEVV" ]; then \
|
||||||
|
echo "FATAL: lsphp${PHPVER}=$RTV but lsphp${PHPVER}-dev=$DEVV — the headers" >&2; \
|
||||||
|
echo " do not belong to the PHP in this stage. Refusing to build." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
mkdir -p /build-out; \
|
||||||
|
printf '%s' "$RTV" > /build-out/lsphp.version; \
|
||||||
|
echo "cac_path_parity will be compiled against lsphp${PHPVER} $RTV"
|
||||||
|
|
||||||
|
## Build, then RUN THE .phpt SUITE as a build gate. Before this, ext/…/tests/
|
||||||
|
## existed but nothing ever executed it — neither this Dockerfile nor
|
||||||
|
## .gitea/workflows/build-push.yaml — so six green tests were documentation. It
|
||||||
|
## costs ~1s per PHP version and it runs against the SAME lsphp build the .so
|
||||||
|
## will ship next to.
|
||||||
|
##
|
||||||
|
## Two guards around `make test`, because its default failure mode is silence:
|
||||||
|
## - if PHP_EXECUTABLE is missing the Makefile prints "Cannot run tests without
|
||||||
|
## CLI sapi." and EXITS 0. The lsphp packages do ship a real CLI
|
||||||
|
## (php-config --php-binary => .../bin/phpN.N), but assert it rather than
|
||||||
|
## trusting it.
|
||||||
|
## - a run that executes ZERO tests also exits 0, so assert the summary shows
|
||||||
|
## every .phpt in the directory both ran and passed. Same reasoning as the
|
||||||
|
## `lsphp -i` probe below: an assertion that cannot fail is worse than none.
|
||||||
|
COPY ./ext/cac-path-parity /usr/src/cac-path-parity
|
||||||
|
RUN set -e; \
|
||||||
|
cd /usr/src/cac-path-parity; \
|
||||||
|
/usr/local/lsws/lsphp${PHPVER}/bin/phpize; \
|
||||||
|
./configure --enable-cac-path-parity \
|
||||||
|
--with-php-config=/usr/local/lsws/lsphp${PHPVER}/bin/php-config; \
|
||||||
|
make -j"$(nproc)"; \
|
||||||
|
PHP_BIN=$(/usr/local/lsws/lsphp${PHPVER}/bin/php-config --php-binary); \
|
||||||
|
if [ ! -x "$PHP_BIN" ]; then \
|
||||||
|
echo "FATAL: no CLI php at '$PHP_BIN' — \`make test\` would print" >&2; \
|
||||||
|
echo " 'Cannot run tests without CLI sapi.' and exit 0." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
EXPECTED=$(ls tests/*.phpt | wc -l); \
|
||||||
|
if [ "$EXPECTED" -lt 1 ]; then echo "FATAL: no .phpt tests found" >&2; exit 1; fi; \
|
||||||
|
if ! NO_INTERACTION=1 REPORT_EXIT_STATUS=1 make test >/tmp/make-test.log 2>&1; then \
|
||||||
|
cat /tmp/make-test.log >&2; \
|
||||||
|
echo "FATAL: cac_path_parity .phpt suite FAILED — not shipping this .so." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
cat /tmp/make-test.log; \
|
||||||
|
if ! grep -Eq "^Number of tests : +${EXPECTED} +${EXPECTED} *$" /tmp/make-test.log; then \
|
||||||
|
echo "FATAL: expected all ${EXPECTED} .phpt tests to run; the summary above disagrees." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
if ! grep -Eq "^Tests failed +: +0 " /tmp/make-test.log; then \
|
||||||
|
echo "FATAL: run-tests.php reported failures." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "cac_path_parity: ${EXPECTED}/${EXPECTED} .phpt tests passed"; \
|
||||||
|
cp modules/cac_path_parity.so /build-out/
|
||||||
|
|
||||||
|
## ---- stage 2: the shipped sidecar image ------------------------------------
|
||||||
|
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
|
||||||
|
ARG PHPVER=83
|
||||||
|
ENV PHPVER=${PHPVER}
|
||||||
|
|
||||||
|
## Match the cac-litespeed extension surface exactly: the only ext the prebuilt
|
||||||
|
## base lacks is lsphpNN-ldap. setpriv (util-linux) is already on the Ubuntu
|
||||||
|
## base; we add nothing else the sidecar doesn't need. All apt cache cleaned in
|
||||||
|
## the same layer to keep the image small.
|
||||||
|
##
|
||||||
|
## VERSION LOCKSTEP: `apt-get install lsphpNN-ldap` pulls lsphpNN-common forward,
|
||||||
|
## which drags the whole lsphpNN family to the repo's current release — the same
|
||||||
|
## upgrade the ext-build stage gets. Left implicit, the two stages resolve that
|
||||||
|
## independently and Docker caches them independently, so they drift apart (see
|
||||||
|
## the long comment on stage 1). Copying stage 1's recorded version in BEFORE
|
||||||
|
## this layer makes the version part of this layer's cache key: same version =>
|
||||||
|
## cache hit, new version => this layer re-runs and lands on the same one. The
|
||||||
|
## explicit `=$V` pins then make a mid-build repo roll a LOUD apt failure instead
|
||||||
|
## of a silent skew. Verified satisfiable on PHP 8.1/8.3/8.5 (2026-08-05).
|
||||||
|
COPY --from=ext-build /build-out/lsphp.version /etc/cac-lsphp-build.version
|
||||||
|
RUN set -e; \
|
||||||
|
V=$(cat /etc/cac-lsphp-build.version); \
|
||||||
|
apt-get update; \
|
||||||
|
if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
lsphp${PHPVER}="$V" lsphp${PHPVER}-common="$V" lsphp${PHPVER}-ldap="$V"; then \
|
||||||
|
echo "FATAL: lsphp${PHPVER} $V is what cac_path_parity was compiled against," >&2; \
|
||||||
|
echo " but the LiteSpeed repo no longer offers it (it keeps only the" >&2; \
|
||||||
|
echo " current release). The ext-build stage is almost certainly a stale" >&2; \
|
||||||
|
echo " cache hit — rebuild with --no-cache." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
apt-get clean; \
|
||||||
|
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||||
|
RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \
|
||||||
|
CMV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}-common); \
|
||||||
|
if [ "$RTV" != "$V" ] || [ "$CMV" != "$V" ]; then \
|
||||||
|
echo "FATAL: cac_path_parity.so was compiled against lsphp${PHPVER} $V but this" >&2; \
|
||||||
|
echo " image would ship lsphp${PHPVER}=$RTV / -common=$CMV." >&2; \
|
||||||
|
echo " Rebuild with --no-cache so both stages resolve the same release." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "runtime lsphp${PHPVER} pinned to $V (the version cac_path_parity was built against)"
|
||||||
|
|
||||||
|
## Scripts + the SHARED production lsphp ini (reused verbatim from the litespeed
|
||||||
|
## image — same runtime, same tuning). Scripts layer last (they change most).
|
||||||
|
COPY ./scripts/entrypoint-lsphp.sh \
|
||||||
|
./scripts/detect-memory-lsphp.sh \
|
||||||
|
./scripts/healthcheck-lsphp.sh \
|
||||||
|
./scripts/cac-lsphp-normalize.php \
|
||||||
|
/scripts/
|
||||||
|
RUN chmod +x /scripts/entrypoint-lsphp.sh /scripts/detect-memory-lsphp.sh /scripts/healthcheck-lsphp.sh
|
||||||
|
|
||||||
|
## Apply production lsphp ini overrides into lsphp's scan dir (path varies by
|
||||||
|
## PHP minor version; ask lsphp directly — same idiom as Dockerfile.litespeed).
|
||||||
|
COPY ./configs/litespeed/lsphp-overrides.ini /etc/lsws-templates/lsphp-overrides.ini
|
||||||
|
RUN bash -c 'set -e; \
|
||||||
|
SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
|
||||||
|
mkdir -p "$SCAN_DIR"; \
|
||||||
|
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
|
||||||
|
echo "wrote overrides to $SCAN_DIR"'
|
||||||
|
|
||||||
|
## Install the cac_path_parity extension into lsphp's own extension_dir and load
|
||||||
|
## it unconditionally. It is INERT until the entrypoint writes the per-site
|
||||||
|
## cac_path_parity.from/.to mapping, so it is safe in any context (including
|
||||||
|
## wp-cli runs, where $_SERVER carries no filesystem paths).
|
||||||
|
##
|
||||||
|
## The trailing `lsphp -i | grep` is a BUILD-TIME ASSERTION: if the .so fails to
|
||||||
|
## load (ABI drift after a base-image PHP bump, bad build) the image build fails
|
||||||
|
## here rather than shipping a sidecar that silently lost path parity. Note its
|
||||||
|
## limit: it proves the .so LOADS, not that it was built against these exact
|
||||||
|
## structs — silent layout drift would sail straight through. The version lockstep
|
||||||
|
## above is what actually removes that possibility; this stays as the backstop.
|
||||||
|
## NOTE: probe lsphp with `-i` ONLY. The lsphp binary is the LSAPI SAPI, not the
|
||||||
|
## CLI — it accepts just -[b|c|n|h|i|q|s|v|?] and answers anything else (`-m`,
|
||||||
|
## `-r`) by printing its usage text and exiting 0. A `lsphp -m | grep` check
|
||||||
|
## therefore never matches AND never fails, which is exactly the kind of silent
|
||||||
|
## always-false assertion this whole change exists to eliminate.
|
||||||
|
COPY --from=ext-build /build-out/cac_path_parity.so /tmp/cac_path_parity.so
|
||||||
|
RUN bash -c 'set -e; \
|
||||||
|
LSPHP="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"; \
|
||||||
|
EXT_DIR=$("$LSPHP" -i 2>/dev/null | awk -F" => " "/^extension_dir/ {print \$2; exit}"); \
|
||||||
|
SCAN_DIR=$("$LSPHP" -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
|
||||||
|
mkdir -p "$EXT_DIR" "$SCAN_DIR"; \
|
||||||
|
mv /tmp/cac_path_parity.so "$EXT_DIR/"; \
|
||||||
|
printf "; installed by Dockerfile.lsphp\nextension=cac_path_parity.so\n" \
|
||||||
|
> "$SCAN_DIR/00-cac-path-parity.ini"; \
|
||||||
|
"$LSPHP" -i 2>/dev/null | grep -q "^cac_path_parity support => enabled$"; \
|
||||||
|
echo "cac_path_parity installed into $EXT_DIR and verified loadable"'
|
||||||
|
|
||||||
|
## php-lsapi gates .user.ini parsing behind this env var (see entrypoint-lsphp.sh
|
||||||
|
## for the full explanation). Set here so the value is visible in `docker inspect`
|
||||||
|
## and survives an entrypoint override; the entrypoint re-exports it with the same
|
||||||
|
## default so the runuser exec path can't drop it.
|
||||||
|
ENV LSPHP_ENABLE_USER_INI=on
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
## TCP-connect + lsphp-alive check (LSAPI isn't FastCGI, so no cgi-fcgi ping).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD /scripts/healthcheck-lsphp.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/scripts/entrypoint-lsphp.sh"]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
FROM almalinux/10-base
|
||||||
|
|
||||||
|
# Install Apache and minimal dependencies (no PHP at all)
|
||||||
|
RUN dnf install -y \
|
||||||
|
https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm && \
|
||||||
|
dnf update -y && \
|
||||||
|
dnf install -y httpd mod_ssl openssl iproute cronie procps curl && \
|
||||||
|
dnf clean all && \
|
||||||
|
rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
|
||||||
|
|
||||||
|
# Copy scripts and set permissions
|
||||||
|
COPY ./scripts/detect-memory.sh /scripts/detect-memory.sh
|
||||||
|
COPY ./scripts/create-apache-mpm-config.sh /scripts/create-apache-mpm-config.sh
|
||||||
|
COPY ./scripts/log-rotate.sh /scripts/log-rotate.sh
|
||||||
|
COPY ./scripts/entrypoint-shared-httpd.sh /scripts/entrypoint-shared-httpd.sh
|
||||||
|
COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh
|
||||||
|
RUN chmod +x /scripts/*
|
||||||
|
|
||||||
|
# Generate self-signed SSL cert (same as main CAC image)
|
||||||
|
RUN openssl req -newkey rsa:2048 -nodes \
|
||||||
|
-keyout /etc/pki/tls/private/localhost.key \
|
||||||
|
-x509 -days 3650 -subj "/CN=localhost" \
|
||||||
|
-out /etc/pki/tls/certs/localhost.crt
|
||||||
|
|
||||||
|
# Copy Apache configs
|
||||||
|
COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
|
||||||
|
COPY ./configs/default-index.conf /etc/httpd/conf.d/
|
||||||
|
|
||||||
|
# Create vhosts directory (will be volume-mounted from host)
|
||||||
|
RUN mkdir -p /etc/httpd/conf.d/vhosts
|
||||||
|
|
||||||
|
# Set up cron job for log rotation
|
||||||
|
RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
|
||||||
|
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -sfk https://localhost/ping || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/scripts/entrypoint-shared-httpd.sh" ]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## shared-ols — the shared OpenLiteSpeed webserver tier.
|
||||||
|
##
|
||||||
|
## One OLS container fronting MANY tenants' detached cac-lsphp sidecars — the
|
||||||
|
## OLS analogue of the shared-httpd container. Runs NO PHP locally: every site's
|
||||||
|
## PHP goes to its own cac-lsphp:phpNN sidecar over LSAPI (extProcessor type
|
||||||
|
## lsapi, address <sidecar>:9000). HAProxy stays the TLS/WAF/SNI edge and routes
|
||||||
|
## OLS-type hostnames here on :443.
|
||||||
|
##
|
||||||
|
## Built on the SAME litespeedtech prebuilt base as cac-litespeed / cac-lsphp so
|
||||||
|
## the OLS build + plumbing (lscgid, cgid socket — see feedback_ols_packaging_landmines)
|
||||||
|
## are the proven ones. The base is lsphp-tagged but we never run that lsphp;
|
||||||
|
## the tag just selects the OLS build. Pinned to lsphp83 / OLS 1.8.4.
|
||||||
|
##
|
||||||
|
## Config model (established by PoC 2026-06-10): OLS has NO top-level `include`,
|
||||||
|
## so render-shared-ols-config.sh assembles httpd_config.conf from the panel's
|
||||||
|
## per-site files at boot + on every change. See that script + the plan.
|
||||||
|
|
||||||
|
ARG OLS_VERSION=1.8.4
|
||||||
|
ARG PHPVER=83
|
||||||
|
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
|
||||||
|
|
||||||
|
## Tooling the shared tier needs on top of the base:
|
||||||
|
## - inotify-tools: the .htaccess watcher (spec 5.3)
|
||||||
|
## - gettext-base: envsubst for render-shared-ols-config.sh
|
||||||
|
## - openssl: self-signed cert for the :443 listener (HAProxy verifies none)
|
||||||
|
## - curl/ca-certificates: HEALTHCHECK
|
||||||
|
## - procps: provides pgrep, which entrypoint-shared-ols.sh's ols_running()
|
||||||
|
## liveness check depends on. Only transitively present via the base image
|
||||||
|
## today (Ubuntu 24.04 pulls it in) — pin it explicitly so it can't be
|
||||||
|
## pruned as "unused" and silently break the supervisor's crash detection.
|
||||||
|
RUN apt-get update && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
inotify-tools gettext-base openssl ca-certificates curl procps && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||||
|
|
||||||
|
## Snapshot the stock httpd_config.conf so render-shared-ols-config.sh always has
|
||||||
|
## a pristine base to strip-and-rebuild from (the base image keeps it at conf/).
|
||||||
|
RUN mkdir -p /usr/local/lsws/.conf && \
|
||||||
|
cp /usr/local/lsws/conf/httpd_config.conf /usr/local/lsws/.conf/httpd_config.conf
|
||||||
|
|
||||||
|
COPY ./scripts/entrypoint-shared-ols.sh \
|
||||||
|
./scripts/render-shared-ols-config.sh \
|
||||||
|
./scripts/ols-htaccess-watcher.sh \
|
||||||
|
/scripts/
|
||||||
|
RUN chmod +x /scripts/entrypoint-shared-ols.sh /scripts/render-shared-ols-config.sh /scripts/ols-htaccess-watcher.sh
|
||||||
|
COPY ./configs/shared-ols/ /etc/shared-ols-templates/
|
||||||
|
|
||||||
|
## Admin console unreachable from tenant/edge networks (spec 5.2): bind the
|
||||||
|
## WebAdmin listener to loopback. Same sed as Dockerfile.litespeed.
|
||||||
|
RUN sed -i 's|^[[:space:]]*address[[:space:]]\+\*:| address 127.0.0.1:|' \
|
||||||
|
/usr/local/lsws/admin/conf/admin_config.conf 2>/dev/null || true
|
||||||
|
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
## Health: the entrypoint renders a catch-all _health vhost serving /healthz, so
|
||||||
|
## this passes from boot (zero customer sites) onward. Self-signed :443.
|
||||||
|
##
|
||||||
|
## MUST stay on /healthz, and must stay a LOOPBACK request. That vhost answers
|
||||||
|
## 421 for every other path/Host so an unmapped customer hostname can never look
|
||||||
|
## "up" to a monitor; /healthz answers 200 only for an internal client address
|
||||||
|
## (loopback here). Probing `/` instead would fail the healthcheck and restart
|
||||||
|
## the whole shared tier. WHP's setup-shared-ols.sh overrides this with the
|
||||||
|
## equivalent `curl -sfk https://localhost/healthz`; keep the two in step.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fsSk https://127.0.0.1/healthz || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/scripts/entrypoint-shared-ols.sh"]
|
||||||
@@ -1,71 +1,134 @@
|
|||||||
# Cloud Apache Container #
|
# Cloud Apache Container
|
||||||
This is the base container for running PHP based applications. Select the PHP version environment variables. PHP Version Defaults to PHP 8.1
|
|
||||||
|
|
||||||
*__You mush have docker or compatable containerization software running.__*
|
This is a base container for running PHP-based applications, supporting multiple PHP versions (7.4, 8.0, 8.1, 8.2, 8.3, 8.4). The default is PHP 8.3. The container is based on AlmaLinux 9 and uses Apache with mod_ssl. It is designed for both development and production use.
|
||||||
|
|
||||||
__You can pull this image locally by running:__
|
**You must have Docker or compatible containerization software running.**
|
||||||
|
|
||||||
```console
|
---
|
||||||
docker pull public.ecr.aws/s1f6k4w4/cac:latest
|
|
||||||
|
## What's New?
|
||||||
|
|
||||||
|
- **Optimized Image:** The Dockerfile has been refactored for smaller size, faster builds, and improved security. Unnecessary files and caches are removed during build.
|
||||||
|
- **Pre-built Images for Each PHP Version:** On every push, images for all supported PHP versions are built and pushed to the registry. You can pull the exact version you need (e.g., `cac:php74`, `cac:php84`, or `cac:latest`).
|
||||||
|
- **.dockerignore Added:** The build context is now minimized, making builds faster and more secure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start: Local Development with `local-dev.sh`
|
||||||
|
|
||||||
|
The easiest way to start a local development environment is with the provided `local-dev.sh` script. This script automates container setup, volume creation, log directories, and WordPress installation.
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./local-dev.sh -n local-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
__You can then run a development version of the server by running the following commands:__
|
**Flags:**
|
||||||
*Note this is an example, you can modify the command(s) to fit your needs.*
|
- `-n` Name of the container (required)
|
||||||
|
- `-p` HTTP port (default: 80)
|
||||||
|
- `-s` HTTPS port (default: 443)
|
||||||
|
- `-r` Root path for files and database (default: current directory)
|
||||||
|
- `-a` PHP version (default: 8.3; options: 74, 80, 81, 82, 83, 84)
|
||||||
|
- `-v` Enable verbose mode
|
||||||
|
- `-h` Show help
|
||||||
|
|
||||||
```console
|
The script will:
|
||||||
|
- Create a user directory and log folders
|
||||||
|
- Create a Docker volume for MySQL
|
||||||
|
- Start the container with the correct environment variables
|
||||||
|
- Generate helper scripts in your root path:
|
||||||
|
- `instance_start` – Start the container
|
||||||
|
- `instance_stop` – Stop the container
|
||||||
|
- `instance_logs` – Tail Apache logs
|
||||||
|
- `instance_db_info` – Show MySQL credentials
|
||||||
|
- Install WordPress in your web root
|
||||||
|
- Print MySQL credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Docker Usage
|
||||||
|
|
||||||
|
You can also run the container manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
mkdir -p local-development/domain.tld
|
mkdir -p local-development/domain.tld
|
||||||
cd local-development/domain.tld
|
cd local-development/domain.tld
|
||||||
mkdir {web,db}
|
mkdir user
|
||||||
docker run -it -p 80:80 -p 443:443 -e PHPVER=81 -e environment=DEV --mount type=bind,source="$(pwd)"/web,target=/home/myuser/public_html --mount type=bind,source="$(pwd)"/db,target=/var/lib/mysql -e uid=30001 -e user=myuser -e domain=domain.tld -e serveralias=www.domain.tld --name local-dev public.ecr.aws/s1f6k4w4/cac:latest
|
mkdir -p user/logs/{apache,system}
|
||||||
|
docker run -d -it -p 80:80 -p 443:443 -e PHPVER=84 -e environment=DEV --mount type=bind,source="$(pwd)"/user,target=/home/myuser -v"$name-mysql":/var/lib/mysql -e uid=30001 -e user=myuser -e domain=localhost --name local-dev repo.anhonesthost.net/cloud-hosting-platform/cac:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
*This will start the processes needed to run sites locally.*
|
---
|
||||||
|
|
||||||
The first time you start the container, it will take some time as it is installing all the required software to run the dev instance.
|
## Accessing the Container
|
||||||
|
|
||||||
__If you need to get into the container you can run:__
|
```bash
|
||||||
|
|
||||||
```console
|
|
||||||
docker exec -it local-dev /bin/bash
|
docker exec -it local-dev /bin/bash
|
||||||
```
|
```
|
||||||
|
|
||||||
__To install WordPress for your site__
|
---
|
||||||
|
|
||||||
```console
|
## WordPress Installation
|
||||||
cat /var/lib/mysql/creds
|
|
||||||
|
If using `local-dev.sh`, WordPress is installed automatically. For manual setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /home/myuser/mysql_creds
|
||||||
su - myuser
|
su - myuser
|
||||||
cd ~/public_html
|
cd ~/public_html
|
||||||
wp core download
|
wp core download
|
||||||
```
|
```
|
||||||
|
|
||||||
You should be able to then go into your browser and go to https://localhost (accept the SSL warning if it appears) and follow the prompts to setup the site.
|
Then visit https://localhost (accept the SSL warning) to complete setup.
|
||||||
|
|
||||||
The database credentials are shown in the /var/lib/mysql/creds file, which we had *cat* in the commands above.
|
---
|
||||||
|
|
||||||
### PHPVER ###
|
## Features
|
||||||
*74* - PHP 7.4
|
|
||||||
*80* - PHP 8.0
|
|
||||||
*81* - PHP 8.1
|
|
||||||
*82* - PHP 8.2
|
|
||||||
|
|
||||||
### Environment Variables ###
|
- **Multiple PHP Versions:** 7.4, 8.0, 8.1, 8.2, 8.3, 8.4 (set with `PHPVER` or `-a` flag)
|
||||||
__Required Tags__
|
- **Pre-built Images:** Pull the image for your desired PHP version directly from the registry. No need to build locally unless customizing.
|
||||||
*uid* - User ID for File Permissions
|
- **Optimized Build:** Smaller, faster, and more secure images thanks to the improved Dockerfile and `.dockerignore`.
|
||||||
*user* - Username for File Permissions
|
- **Automatic Database Setup:** MariaDB is started in DEV mode, credentials are auto-generated and stored in `/home/$user/mysql_creds`.
|
||||||
*domain* - Primary Domain for configuration
|
- **Database Backups:** Cron job backs up the database every 15 minutes to `/home/$user/_db_backups`.
|
||||||
|
- **Log Management:** Log rotation compresses logs older than 3 days and deletes those older than 7 days.
|
||||||
|
- **Memcached:** Started automatically in DEV mode.
|
||||||
|
- **SSL:** Self-signed certificate enabled by default.
|
||||||
|
- **Default Web Content:** `/home/$user/public_html` is the web root. `/ping` endpoint and `phpinfo.php` are available for diagnostics.
|
||||||
|
- **Helper Scripts:** `instance_start`, `instance_stop`, `instance_logs`, `instance_db_info` (created by `local-dev.sh`).
|
||||||
|
|
||||||
__Optional Tags__
|
---
|
||||||
*environment* - Set to DEV to start memcached and mysql locally for development purposes
|
|
||||||
*serveralias* - Set to allow alternative hostnames for a site.
|
|
||||||
*PHPVER* - Set to use a different version of PHP [refer to versions here.](#phpver)
|
|
||||||
|
|
||||||
### Helpful Notes ###
|
## Environment Variables
|
||||||
|
|
||||||
* On your first creation of a dev instance, you will be dumped to the logs output. Hit ```ctrl + c``` to exit the running process.
|
**Required:**
|
||||||
* If you want to restart the instance again, run ```docker start {name-of-your-container}``` in the example, *name-of-your-cintainer* is *local-dev*
|
- `uid` – User ID for file permissions
|
||||||
* To stop a restarted instance, run ```docker stop {name-of-your-container}```
|
- `user` – Username for file permissions
|
||||||
* To view log stream from container, run ```docker logs -f {name-of-your-container}```
|
- `domain` – Primary domain for configuration
|
||||||
* To delete a container, run ```docker rm {name-of-your-container}``` *__Note:__ this does not delete the files in public_html or database, as those are store in your system*
|
|
||||||
* To view running containers, run ```docker ps```
|
**Optional:**
|
||||||
* To view all created containers, run ```docker ps --all``
|
- `environment` – Set to `DEV` to start memcached and MySQL locally for development
|
||||||
* To view all container images downloaded on your system, run ```docker images```
|
- `serveralias` – Comma-separated list of alternative hostnames
|
||||||
|
- `PHPVER` – PHP version (see above)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Helpful Notes
|
||||||
|
|
||||||
|
- To restart the instance: `./instance_start` or `docker start {container-name}`
|
||||||
|
- To stop: `./instance_stop` or `docker stop {container-name}`
|
||||||
|
- To view logs: `./instance_logs` or `docker logs -f {container-name}`
|
||||||
|
- To get DB credentials: `./instance_db_info` or `cat /home/$user/mysql_creds`
|
||||||
|
- To delete a container: `docker rm {container-name}` (does not delete user files or DB volume)
|
||||||
|
- To view running containers: `docker ps`
|
||||||
|
- To view all containers: `docker ps --all`
|
||||||
|
- To view images: `docker images`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- The first run may take several minutes as dependencies are installed.
|
||||||
|
- If you need to change PHP version, stop and remove the container, then recreate with the desired version.
|
||||||
|
- For advanced configuration, see the scripts in the `scripts/` directory.
|
||||||
|
- The image is optimized for size and speed, but local development in DEV mode may install additional packages (MariaDB, memcached) at runtime using microdnf.
|
||||||
|
- The build context is minimized by the included `.dockerignore` file.
|
||||||
@@ -1,13 +1,2 @@
|
|||||||
DirectoryIndex index.html index.htm index.php
|
DirectoryIndex index.html index.htm index.php
|
||||||
Alias "/ping" "/var/www/html"
|
Alias "/ping" "/var/www/html"
|
||||||
|
|
||||||
<IfModule mpm_event_module>
|
|
||||||
StartServers 10
|
|
||||||
MinSpareThreads 25
|
|
||||||
MaxSpareThreads 75
|
|
||||||
ThreadLimit 64
|
|
||||||
ThreadsPerChild 25
|
|
||||||
MaxRequestWorkers 800
|
|
||||||
ServerLimit 32
|
|
||||||
MaxConnectionsPerChild 1500
|
|
||||||
</IfModule>
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
## OpenLiteSpeed APPEND fragment — added to the stock httpd_config.conf
|
||||||
|
## that ships with litespeedtech/openlitespeed. Keeping the stock config
|
||||||
|
## intact preserves all the cgid/lscgid plumbing (CGIRLimit defaults,
|
||||||
|
## fileAccessControl defaults, etc.) — when we tried writing a fully
|
||||||
|
## custom httpd_config.conf, lscgid never created its IPC socket and
|
||||||
|
## every PHP request 503'd. The upstream OLS docker template uses this
|
||||||
|
## append pattern too (see setup_docker.sh in litespeedtech/ols-dockerfiles).
|
||||||
|
##
|
||||||
|
## Rendered at container start by scripts/create-vhost-litespeed.sh via
|
||||||
|
## envsubst. Templated vars: $user $domain $vhost_map_aliases $PHPVER
|
||||||
|
## $LSAPI_CHILDREN (computed by detect-memory-litespeed.sh)
|
||||||
|
|
||||||
|
## --- real client IP behind HAProxy ---
|
||||||
|
## OLS equivalent of the Apache cac:phpNN mod_remoteip wiring
|
||||||
|
## (configs/remote_ip.conf + RemoteIPInternalProxy in entrypoint.sh). Without
|
||||||
|
## this, OLS records HAProxy's docker-bridge IP as the peer: every site's
|
||||||
|
## access_log and lsphp $_SERVER['REMOTE_ADDR'] collapse to one internal IP,
|
||||||
|
## silently breaking traffic analytics, WP security plugins, brute-force
|
||||||
|
## detection, Coraza source-IP correlation, geo, and rate-limiting.
|
||||||
|
## 1 = trust X-Forwarded-For (the container is only reachable via HAProxy;
|
||||||
|
## it is never bound to a public address). Mirrors the Apache side, which
|
||||||
|
## trusts the whole docker subnet via RemoteIPInternalProxy $docker_network.
|
||||||
|
## When enabled, OLS rewrites the remote IP for BOTH logging and the LSAPI
|
||||||
|
## REMOTE_ADDR before PHP sees it — so the default access_log format already
|
||||||
|
## records the real visitor; no LogFormat change needed.
|
||||||
|
useIpInProxyHeader 1
|
||||||
|
|
||||||
|
## --- our listeners (replace stock Default :8088) ---
|
||||||
|
listener HTTP {
|
||||||
|
address *:80
|
||||||
|
secure 0
|
||||||
|
map siteVH *
|
||||||
|
## NB: HTTP→HTTPS redirect is in site-template.tpl's rewrite{} block,
|
||||||
|
## NOT here — OLS 1.8 listener-level rewrites are inert for vhTemplate
|
||||||
|
## members. Don't move it back to this listener.
|
||||||
|
}
|
||||||
|
|
||||||
|
listener HTTPS {
|
||||||
|
address *:443
|
||||||
|
secure 1
|
||||||
|
keyFile /usr/local/lsws/conf/cert/self.key
|
||||||
|
certFile /usr/local/lsws/conf/cert/self.crt
|
||||||
|
sslProtocol 24
|
||||||
|
enableSpdy 15
|
||||||
|
enableQuic 0
|
||||||
|
map siteVH *
|
||||||
|
}
|
||||||
|
|
||||||
|
## --- lsphp extProcessor (overrides the stock one which is hard-coded to
|
||||||
|
## PHP_LSAPI_CHILDREN=10 regardless of container memory).
|
||||||
|
##
|
||||||
|
## Sized dynamically by detect-memory-litespeed.sh based on the cgroup cap:
|
||||||
|
## 2 GiB container → LSAPI_CHILDREN ≈ 17 (was stuck at 10)
|
||||||
|
## 1 GiB container → LSAPI_CHILDREN ≈ 8
|
||||||
|
## 512 MiB → LSAPI_CHILDREN ≈ 3
|
||||||
|
##
|
||||||
|
## Idle-reduction knobs (the question that motivated this whole block):
|
||||||
|
## LSAPI_MAX_IDLE_CHILDREN=2 default was CHILDREN/2 (so 10/2=5)
|
||||||
|
## LSAPI_MAX_IDLE=60 default was 300 (5 min)
|
||||||
|
## Together: max 2 idle workers kept alive, anything idle >60s gets reaped.
|
||||||
|
## Trade-off: cold-start of an extra worker after idle reaping costs ~50-100ms
|
||||||
|
## on the first request to it. Worth it for shadowdao-sized low-traffic sites
|
||||||
|
## where the difference is "30 MB idle" vs "200 MB idle".
|
||||||
|
##
|
||||||
|
## memSoftLimit/memHardLimit: per-worker RLIMIT_AS catches a runaway PHP
|
||||||
|
## script before it hogs the whole pool's memory. Cgroup is still the host
|
||||||
|
## backstop (one-customer-per-container), but the per-worker cap protects
|
||||||
|
## the OTHER workers in the same pool from a bad-actor script. 1024M soft
|
||||||
|
## comfortably accommodates typical Divi/WooCommerce VSZ (~280-365 MB).
|
||||||
|
extProcessor lsphp {
|
||||||
|
type lsapi
|
||||||
|
address uds://tmp/lshttpd/lsphp.sock
|
||||||
|
maxConns ${LSAPI_CHILDREN}
|
||||||
|
env PHP_LSAPI_CHILDREN=${LSAPI_CHILDREN}
|
||||||
|
env LSAPI_MAX_IDLE_CHILDREN=2
|
||||||
|
env LSAPI_MAX_IDLE=60
|
||||||
|
env PHP_LSAPI_MAX_REQUESTS=500
|
||||||
|
env LSAPI_AVOID_FORK=200M
|
||||||
|
initTimeout 60
|
||||||
|
retryTimeout 0
|
||||||
|
persistConn 1
|
||||||
|
pcKeepAliveTimeout 30
|
||||||
|
respBuffer 0
|
||||||
|
autoStart 1
|
||||||
|
path /usr/local/lsws/lsphp${PHPVER}/bin/lsphp
|
||||||
|
backlog 100
|
||||||
|
instances 1
|
||||||
|
runOnStartUp 1
|
||||||
|
priority 0
|
||||||
|
memSoftLimit 1024M
|
||||||
|
memHardLimit 1500M
|
||||||
|
procSoftLimit 400
|
||||||
|
procHardLimit 500
|
||||||
|
}
|
||||||
|
|
||||||
|
## --- our vhost via vhTemplate (upstream's working pattern) ---
|
||||||
|
## The template file is /usr/local/lsws/conf/templates/site.conf — written
|
||||||
|
## by create-vhost-litespeed.sh at the same time as this fragment.
|
||||||
|
vhTemplate site {
|
||||||
|
templateFile conf/templates/site.conf
|
||||||
|
listeners HTTP, HTTPS
|
||||||
|
note cac-litespeed per-customer vhost
|
||||||
|
|
||||||
|
## vhDomain: customer's domain + serveralias list + `*` catchall so
|
||||||
|
## ip-only requests (e.g. HAProxy backend health check by container_name)
|
||||||
|
## still resolve. WHP/HAProxy filters hostnames upstream — no risk to
|
||||||
|
## allowing the catchall here.
|
||||||
|
member siteVH {
|
||||||
|
vhDomain ${domain}${vhost_map_aliases}, *
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
; Production lsphp overrides — mirrors configs/prod-php.ini for the FPM
|
||||||
|
; image, adapted for LSAPI defaults. Dropped into /usr/local/lsws/lsphpNN/etc/php.d/
|
||||||
|
|
||||||
|
memory_limit = 256M
|
||||||
|
post_max_size = 384M
|
||||||
|
upload_max_filesize = 256M
|
||||||
|
max_input_vars = 2000
|
||||||
|
max_execution_time = 60
|
||||||
|
max_input_time = 120
|
||||||
|
|
||||||
|
expose_php = Off
|
||||||
|
short_open_tag = Off
|
||||||
|
|
||||||
|
display_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
|
||||||
|
zend.exception_ignore_args = On
|
||||||
|
|
||||||
|
session.save_handler = files
|
||||||
|
session.use_cookies = 1
|
||||||
|
session.use_only_cookies = 1
|
||||||
|
session.use_strict_mode = 0
|
||||||
|
session.gc_probability = 1
|
||||||
|
session.gc_divisor = 1000
|
||||||
|
session.gc_maxlifetime = 1440
|
||||||
|
|
||||||
|
opcache.enable = 1
|
||||||
|
; Sized to fit Divi + WooCommerce + WP core comfortably without eviction
|
||||||
|
; thrash. Per-instance because shmem is per-process-RSS on Linux cgroups
|
||||||
|
; (vs PHP-FPM's COW-shared model — one lsphp PARENT per httpd worker in
|
||||||
|
; OLS, each with its own opcache segment).
|
||||||
|
;
|
||||||
|
; Sizing history:
|
||||||
|
; 128 MB / 10000 files (original): blew 800+ MiB shmem under setUIDMode 2
|
||||||
|
; because that gave 8+ lsphp instances each at 128 MB → 1+ GiB shmem.
|
||||||
|
; 32 MB / 4000 files (2026-06-02): solved the shmem problem but caused
|
||||||
|
; opcache eviction thrash on Divi/WC sites (3000+4000 unique PHP files
|
||||||
|
; each); manifested as ~40% sustained CPU on alphaoneaminos and
|
||||||
|
; elevated OOM cycling on brain-jar (5378 oom_kills/9h on 2026-06-03).
|
||||||
|
; 64 MB / 8000 files (current): fits Divi+WC bytecode without eviction;
|
||||||
|
; N lsphp × 64 MB ≈ 512 MiB shmem worst case, still acceptable.
|
||||||
|
;
|
||||||
|
; Override per-site via OPCACHE_MEMORY_MB / OPCACHE_MAX_FILES env vars
|
||||||
|
; (panel: Advanced Tuning → OpCache size) for outliers.
|
||||||
|
opcache.memory_consumption = 64
|
||||||
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.max_accelerated_files = 8000
|
||||||
|
opcache.revalidate_freq = 60
|
||||||
|
opcache.enable_cli = Off
|
||||||
|
|
||||||
|
output_buffering = 4096
|
||||||
|
default_charset = "UTF-8"
|
||||||
|
|
||||||
|
file_uploads = On
|
||||||
|
max_file_uploads = 20
|
||||||
|
|
||||||
|
soap.wsdl_cache_enabled = 1
|
||||||
|
soap.wsdl_cache_dir = "/tmp"
|
||||||
|
soap.wsdl_cache_ttl = 86400
|
||||||
|
soap.wsdl_cache_limit = 5
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
## OLS vhTemplate for the per-customer vhost. Mirrors the structure of the
|
||||||
|
## upstream docker.conf template but with our paths and LSCache wiring.
|
||||||
|
## Templated vars (envsubst): $user
|
||||||
|
##
|
||||||
|
## $VH_NAME, $VH_ROOT, $DOC_ROOT, $SERVER_ROOT are OLS macros — they MUST
|
||||||
|
## stay literal in the output (not in the envsubst allow-list).
|
||||||
|
|
||||||
|
allowSymbolLink 1
|
||||||
|
enableScript 1
|
||||||
|
restrained 1
|
||||||
|
## No setUIDMode — OLS itself runs as ${user} (set at server level by
|
||||||
|
## create-vhost-litespeed.sh), so lsphp inherits that uid without needing
|
||||||
|
## suEXEC per request. This is the key to single-lsphp-instance topology:
|
||||||
|
## with setUIDMode 2, each httpd worker had to lscgid-spawn its own lsphp
|
||||||
|
## (= N opcache shmem segments). Without it, ONE persistent lsphp parent
|
||||||
|
## serves all httpd workers via the shared socket, and LSAPI children-mode
|
||||||
|
## actually works (1 parent + N children = 1 shmem segment).
|
||||||
|
##
|
||||||
|
## Safe because cac-litespeed is one-customer-per-container — the container
|
||||||
|
## boundary IS the privsep boundary.
|
||||||
|
vhRoot /home/${user}/public_html/
|
||||||
|
configFile $SERVER_ROOT/conf/vhosts/$VH_NAME/vhconf.conf
|
||||||
|
|
||||||
|
virtualHostConfig {
|
||||||
|
docRoot $VH_ROOT
|
||||||
|
|
||||||
|
## Drop-in log paths matching cac:phpNN (Apache+FPM bundled) so existing
|
||||||
|
## WHP log-gathering code (whp-traffic-aggregator.php, process-log-review.php,
|
||||||
|
## customer-facing log views) keeps working unchanged for migrated sites.
|
||||||
|
## Customer's "Apache access log" is just OLS's access log under the same
|
||||||
|
## filename. No `.log` suffix — matches the bundled cac convention.
|
||||||
|
errorlog /home/${user}/logs/apache/error_log {
|
||||||
|
useServer 0
|
||||||
|
logLevel WARN
|
||||||
|
rollingSize 10M
|
||||||
|
keepDays 14
|
||||||
|
compressArchive 1
|
||||||
|
}
|
||||||
|
|
||||||
|
accesslog /home/${user}/logs/apache/access_log {
|
||||||
|
useServer 0
|
||||||
|
rollingSize 10M
|
||||||
|
keepDays 7
|
||||||
|
compressArchive 1
|
||||||
|
}
|
||||||
|
|
||||||
|
index {
|
||||||
|
useServer 0
|
||||||
|
indexFiles index.php, index.html
|
||||||
|
autoIndex 0
|
||||||
|
}
|
||||||
|
|
||||||
|
## LSCache plugin owns Cache-Control / Expires entirely — server-level
|
||||||
|
## expires off so we don't double-emit headers.
|
||||||
|
expires {
|
||||||
|
enableExpires 0
|
||||||
|
}
|
||||||
|
|
||||||
|
accessControl {
|
||||||
|
allow *
|
||||||
|
}
|
||||||
|
|
||||||
|
context / {
|
||||||
|
location $DOC_ROOT/
|
||||||
|
allowBrowse 1
|
||||||
|
rewrite {
|
||||||
|
enable 1
|
||||||
|
inherit 0
|
||||||
|
autoLoadHtaccess 1
|
||||||
|
RewriteFile .htaccess
|
||||||
|
}
|
||||||
|
addDefaultCharset off
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrite {
|
||||||
|
enable 1
|
||||||
|
autoLoadHtaccess 1
|
||||||
|
logLevel 0
|
||||||
|
## Force HTTPS — OLS 1.8 listener-level rewrites don't apply per-vhost,
|
||||||
|
## so the redirect lives here. The RewriteCond guards against an infinite
|
||||||
|
## loop (SERVER_PORT=80 means "this request came in on the HTTP listener,
|
||||||
|
## not HTTPS"). Per-customer .htaccess rules still apply (autoLoadHtaccess).
|
||||||
|
RewriteCond %{SERVER_PORT} 80
|
||||||
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [L,R=301]
|
||||||
|
}
|
||||||
|
|
||||||
|
## Per-vhost LSCache storage. The server-level `module cache` block in
|
||||||
|
## stock httpd_config.conf is already enabled (ls_enabled 1); the LSCWP
|
||||||
|
## plugin flips cache on/off per request via X-LiteSpeed-Cache-Control.
|
||||||
|
module cache {
|
||||||
|
storagePath /home/${user}/lscache
|
||||||
|
checkPrivateCache 1
|
||||||
|
checkPublicCache 1
|
||||||
|
enableCache 0
|
||||||
|
enablePrivateCache 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# MariaDB 10.11 CentOS repository list - created 2023-04-03 23:52 UTC
|
# MariaDB 11.4.5 CentOS repository list - created 2023-04-03 23:52 UTC
|
||||||
# https://mariadb.org/download/
|
# https://mariadb.org/download/
|
||||||
[mariadb]
|
[mariadb]
|
||||||
name = MariaDB
|
name = MariaDB
|
||||||
# rpm.mariadb.org is a dynamic mirror if your preferred mirror goes offline. See https://mariadb.org/mirrorbits/ for details.
|
# rpm.mariadb.org is a dynamic mirror if your preferred mirror goes offline. See https://mariadb.org/mirrorbits/ for details.
|
||||||
# baseurl = https://rpm.mariadb.org/10.11/centos/$releasever/$basearch
|
# baseurl = https://rpm.mariadb.org/10.11/centos/$releasever/$basearch
|
||||||
baseurl = https://mirrors.xtom.com/mariadb/yum/10.11/centos/$releasever/$basearch
|
baseurl = https://mirror.mariadb.org/yum/11.4/rhel$releasever-amd64
|
||||||
module_hotfixes = 1
|
module_hotfixes = 1
|
||||||
# gpgkey = https://rpm.mariadb.org/RPM-GPG-KEY-MariaDB
|
# gpgkey = https://rpm.mariadb.org/RPM-GPG-KEY-MariaDB
|
||||||
gpgkey = https://mirrors.xtom.com/mariadb/yum/RPM-GPG-KEY-MariaDB
|
gpgkey = https://mirrors.xtom.com/mariadb/yum/RPM-GPG-KEY-MariaDB
|
||||||
gpgcheck = 1
|
gpgcheck = 1
|
||||||
|
|||||||
+10
-2
@@ -1091,7 +1091,7 @@ session.save_handler = memcache
|
|||||||
; RPM note : session directory must be owned by process owner
|
; RPM note : session directory must be owned by process owner
|
||||||
; for mod_php, see /etc/httpd/conf.d/php.conf
|
; for mod_php, see /etc/httpd/conf.d/php.conf
|
||||||
; for php-fpm, see /etc/php-fpm.d/*conf
|
; for php-fpm, see /etc/php-fpm.d/*conf
|
||||||
session.save_path = "tcp://localhost:11211"
|
session.save_path = "tcp://memcache:11211"
|
||||||
|
|
||||||
; Whether to use strict session mode.
|
; Whether to use strict session mode.
|
||||||
; Strict session mode does not accept an uninitialized session ID, and
|
; Strict session mode does not accept an uninitialized session ID, and
|
||||||
@@ -1496,7 +1496,15 @@ ldap.max_links = -1
|
|||||||
;dba.default_handler=
|
;dba.default_handler=
|
||||||
|
|
||||||
[opcache]
|
[opcache]
|
||||||
; see /etc/php.d/10-opcache.ini
|
; Optimized for shared hosting — reduce idle memory footprint
|
||||||
|
; Default 128MB is excessive for most WordPress sites
|
||||||
|
opcache.memory_consumption = 64
|
||||||
|
opcache.interned_strings_buffer = 8
|
||||||
|
opcache.max_accelerated_files = 4000
|
||||||
|
; Revalidate files every 60s in production (reduces stat() calls)
|
||||||
|
opcache.revalidate_freq = 60
|
||||||
|
; Don't waste memory on CLI scripts
|
||||||
|
opcache.enable_cli = Off
|
||||||
|
|
||||||
[curl]
|
[curl]
|
||||||
; A default value for the CURLOPT_CAINFO option. This is required to be an
|
; A default value for the CURLOPT_CAINFO option. This is required to be an
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
RemoteIPHeader X-Forwarded-For
|
||||||
|
LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## ---- shared-ols append (do not edit below) ----
|
||||||
|
## Server-level config for the SHARED OpenLiteSpeed tier. Appended to the
|
||||||
|
## stock httpd_config.conf AFTER render-shared-ols-config.sh strips the stock
|
||||||
|
## listeners, vhTemplate docker, AND the stock `extProcessor lsphp` +
|
||||||
|
## `scriptHandler` (so this server NEVER runs PHP locally — every site's PHP
|
||||||
|
## goes to its own detached cac-lsphp sidecar over LSAPI). Rendered with
|
||||||
|
## envsubst; only ${LSCACHE_ROOT} is substituted here.
|
||||||
|
|
||||||
|
serverName shared-ols
|
||||||
|
|
||||||
|
## Real client IP behind HAProxy. HAProxy sets X-Forwarded-For (the real
|
||||||
|
## client) and X-Forwarded-Proto. Mode 1 = always use X-Forwarded-For as the
|
||||||
|
## client IP. HAProxy is the ONLY thing that ever connects to this tier (it's on
|
||||||
|
## client-net with no host-published ports) and it OVERWRITES X-Forwarded-For
|
||||||
|
## with %[src] (set-header, not add-header), so a client can't spoof it — mode 1
|
||||||
|
## is safe here and matches the working standalone litespeed config.
|
||||||
|
## NOTE: mode 2 ("trusted IP only") does NOT mean "trust the proxy header" — it
|
||||||
|
## extracts the real IP ONLY when the connecting peer is in a TRUSTED access
|
||||||
|
## list, which this tier never configured. With mode 2 + no trusted IP, OLS kept
|
||||||
|
## HAProxy's container IP as REMOTE_ADDR for every request, so WP security
|
||||||
|
## plugins saw all tenants as one IP and blocking it locked everyone out.
|
||||||
|
useIpInProxyHeader 1
|
||||||
|
|
||||||
|
## LSCache enabled at MODULE scope for the whole tier (dedicated cache volume,
|
||||||
|
## ephemeral across rebuilds; OLS auto-keys a per-vhost subdir under storagePath).
|
||||||
|
## PUBLIC (anonymous) caching ONLY: enableCache 1 + checkPublicCache 1 let OLS
|
||||||
|
## serve cacheable, non-logged-in responses marked by the LiteSpeed Cache WP
|
||||||
|
## plugin's X-LiteSpeed-Cache-Control headers (ignoreRespCacheCtrl=0 honors them).
|
||||||
|
##
|
||||||
|
## PRIVATE caching is intentionally OFF (enablePrivateCache 0 + checkPrivateCache 0).
|
||||||
|
## Logged-in / cookie-bearing pages must NEVER be cached at the tier. We previously
|
||||||
|
## left enablePrivateCache=1 assuming "no plugin -> nothing cached," but that was
|
||||||
|
## WRONG: with private storage + reqCookieCache on, OLS privately cached logged-in
|
||||||
|
## responses regardless of plugin, serving stale wp-admin (e.g. a "failed update"
|
||||||
|
## nag that persisted for the full privateExpireInSeconds TTL). Keeping private
|
||||||
|
## cache off guarantees logged-in pages are always served fresh.
|
||||||
|
module cache {
|
||||||
|
storagePath ${LSCACHE_ROOT}
|
||||||
|
checkPrivateCache 0
|
||||||
|
checkPublicCache 1
|
||||||
|
maxCacheObjSize 10000000
|
||||||
|
maxStaleAge 200
|
||||||
|
qsCache 1
|
||||||
|
reqCookieCache 1
|
||||||
|
respCookieCache 1
|
||||||
|
ignoreReqCacheCtrl 0
|
||||||
|
ignoreRespCacheCtrl 0
|
||||||
|
enableCache 1
|
||||||
|
enablePrivateCache 0
|
||||||
|
}
|
||||||
|
## ---- end shared-ols server append ----
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<Directory "/mnt/users/~~user~~/~~domain~~">
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<Directory "/mnt/users/~~user~~/~~domain~~/public_html">
|
||||||
|
Options All MultiViews
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName "~~domain~~"
|
||||||
|
~~alias_block~~
|
||||||
|
DocumentRoot "/mnt/users/~~user~~/~~domain~~/public_html"
|
||||||
|
RewriteEngine on
|
||||||
|
RewriteCond %{SERVER_NAME} =~~domain~~
|
||||||
|
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
<IfModule mod_ssl.c>
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName "~~domain~~"
|
||||||
|
~~alias_block~~
|
||||||
|
DocumentRoot "/mnt/users/~~user~~/~~domain~~/public_html"
|
||||||
|
|
||||||
|
SSLCertificateFile /etc/pki/tls/certs/localhost.crt
|
||||||
|
SSLCertificateKeyFile /etc/pki/tls/private/localhost.key
|
||||||
|
|
||||||
|
~~proxy_block~~
|
||||||
|
|
||||||
|
DirectoryIndex index.php index.html index.htm
|
||||||
|
|
||||||
|
ErrorLog "/var/log/httpd/~~domain~~-error.log"
|
||||||
|
CustomLog "/var/log/httpd/~~domain~~-access.log" combined
|
||||||
|
</VirtualHost>
|
||||||
|
</IfModule>
|
||||||
@@ -40,6 +40,42 @@
|
|||||||
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|
||||||
|
#
|
||||||
|
# When we also provide SSL we have to listen to the
|
||||||
|
# standard HTTPS port in addition.
|
||||||
|
#
|
||||||
|
Listen 443 https
|
||||||
|
|
||||||
|
##
|
||||||
|
## SSL Global Context
|
||||||
|
##
|
||||||
|
## All SSL configuration in this context applies both to
|
||||||
|
## the main server and all SSL-enabled virtual hosts.
|
||||||
|
##
|
||||||
|
|
||||||
|
# Pass Phrase Dialog:
|
||||||
|
# Configure the pass phrase gathering process.
|
||||||
|
# The filtering dialog program (`builtin' is a internal
|
||||||
|
# terminal dialog) has to provide the pass phrase on stdout.
|
||||||
|
SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
|
||||||
|
|
||||||
|
# Inter-Process Session Cache:
|
||||||
|
# Configure the SSL Session Cache: First the mechanism
|
||||||
|
# to use and second the expiring timeout (in seconds).
|
||||||
|
SSLSessionCache shmcb:/run/httpd/sslcache(512000)
|
||||||
|
SSLSessionCacheTimeout 300
|
||||||
|
|
||||||
|
#
|
||||||
|
# Use "SSLCryptoDevice" to enable any supported hardware
|
||||||
|
# accelerators. Use "openssl engine -v" to list supported
|
||||||
|
# engine names. NOTE: If you enable an accelerator and the
|
||||||
|
# server does not start, consult the error logs and ensure
|
||||||
|
# your accelerator is functioning properly.
|
||||||
|
#
|
||||||
|
SSLCryptoDevice builtin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<IfModule mod_ssl.c>
|
<IfModule mod_ssl.c>
|
||||||
<VirtualHost _default_:443>
|
<VirtualHost _default_:443>
|
||||||
ServerName "~~domain~~"
|
ServerName "~~domain~~"
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# Decision record: entrypoint boot posture on a failed `lsphp -i` probe, and CI gating for the pipeline-shape check
|
||||||
|
|
||||||
|
Date: 2026-08-05
|
||||||
|
Status: recommendations for sign-off; nothing here is implemented by this document.
|
||||||
|
Background: the SIGPIPE-under-pipefail fix (here-strings replacing `printf | awk` in
|
||||||
|
`scripts/entrypoint-lsphp.sh` and `scripts/entrypoint-litespeed.sh`) removed a boot-killing
|
||||||
|
141 and, as a side effect, changed what happens when `lsphp -i` *genuinely* produces
|
||||||
|
nothing. Two judgement calls were flagged for a human decision. This document is that
|
||||||
|
decision's working-out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Call 1 — boot posture when `lsphp -i` genuinely fails
|
||||||
|
|
||||||
|
### What actually happens today (post-fix, as-written in the repo)
|
||||||
|
|
||||||
|
The two container types are **not** currently symmetric, which matters for the decision:
|
||||||
|
|
||||||
|
- **`cac-lsphp`** (`scripts/entrypoint-lsphp.sh`): fail-open, but *not silent*. An empty
|
||||||
|
`SCAN_DIR` falls to an else-branch that distinguishes "lsphp answered but reported no
|
||||||
|
scan dir" from "`lsphp -i` produced no usable output at all" (`lsphp_info_is_usable`,
|
||||||
|
keyed on the `PHP Version =>` banner), prints a distinct `WARNING:` to stderr for each,
|
||||||
|
and every boot ends with a summary line
|
||||||
|
`entrypoint-lsphp: $_SERVER path parity = <MODE> (...)` where `<MODE>` becomes
|
||||||
|
`none (no scan dir)` or `none (lsphp -i unusable)`. So `docker logs` carries a
|
||||||
|
greppable verdict — but nothing on the platform consumes it.
|
||||||
|
- **`cac-litespeed`** (`scripts/entrypoint-litespeed.sh`, lines 81–102): fail-open and
|
||||||
|
**completely silent**. `if [ -n "$SCAN_DIR" ] ... fi` with no else. An empty probe means
|
||||||
|
the per-site `error_log` ini and the opcache override ini are simply never written, and
|
||||||
|
nothing says so anywhere.
|
||||||
|
- **Shared tiers** (`entrypoint-shared-ols.sh`, `entrypoint-shared-httpd.sh`): no
|
||||||
|
`lsphp -i` probe exists in them at all. The question is moot there today, but the
|
||||||
|
blast-radius argument below still applies if one is ever added.
|
||||||
|
|
||||||
|
What degradation costs the customer, concretely:
|
||||||
|
|
||||||
|
| Lost fragment | cac-lsphp | cac-litespeed | Customer-visible symptom |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `99-user-error-log.ini` | yes | yes | PHP errors go to stderr/`docker logs` instead of `/home/$user/logs/php-fpm/error.log`. Baked `log_errors = On` still applies, so errors are *not lost* — they are somewhere the customer can't see and support doesn't look first. Ticket shape: "my error log is empty." |
|
||||||
|
| `99-user-opcache.ini` | yes | yes | Panel-set opcache overrides silently revert to baked defaults (64 MB / 8000 files). A site tuned up for a big codebase gets cache thrash; hard to attribute. |
|
||||||
|
| `99-cac-path-parity.ini` | yes | n/a | Extension gets no mapping ⇒ `$_SERVER` path parity with cac-fpm is lost entirely in the "no output" case (the auto_prepend fallback is only written in the "extension missing from module list" branch, which requires the probe to have answered). Symptom: Wordfence/path-sensitive plugins misbehave after migration; extremely hard to diagnose from the outside. |
|
||||||
|
|
||||||
|
### The premise to examine first
|
||||||
|
|
||||||
|
The fail-open argument assumes "the site serves either way; only ini fragments differ."
|
||||||
|
That is true when `lsphp -i` answers but lacks a `Scan this dir` line (weird packaging,
|
||||||
|
still a working interpreter). It is **much weaker** when `lsphp -i` produces *nothing*:
|
||||||
|
the same `$LSPHP_BIN` that just failed to print phpinfo is what the entrypoint `exec`s
|
||||||
|
as PID 1 to serve requests. A binary that can't run `-i` because of a missing shared
|
||||||
|
library will not serve LSAPI either — fail-open in that case doesn't buy a serving
|
||||||
|
container, it buys a container that passes `docker ps`, then wedges or crash-loops
|
||||||
|
*after* the entrypoint's diagnostic moment has passed. So the two sub-cases deserve
|
||||||
|
different treatment, and conflating them is the main framing error in a flat
|
||||||
|
"fail-open vs fail-closed" question.
|
||||||
|
|
||||||
|
But there is a counterweight, and it is specific to this fleet: **`lsphp -i` can fail
|
||||||
|
transiently on a healthy image.** These hosts have documented fork-starvation episodes
|
||||||
|
(Committed_AS pinned against CommitLimit during backup windows; it has broken backups
|
||||||
|
before). A `fork()`/`execve` failure at boot time yields exactly "empty `LSPHP_INFO`"
|
||||||
|
through the `|| true`. Under fail-closed, a container (re)starting during a memory-pressure
|
||||||
|
window would refuse to boot **because of the host's state, not its own**, and its restart
|
||||||
|
policy would then add restart churn to a host already under pressure. The previous
|
||||||
|
incident has the same shape: the last time "the probe returned nothing useful" happened
|
||||||
|
in production, the image was *fine* — the probe itself was broken (the SIGPIPE bug), and
|
||||||
|
the fail-closed behavior converted a cosmetic probe defect into a fleet-wide
|
||||||
|
fails-to-boot on busy hosts only. Historically, probe-side failures have outnumbered
|
||||||
|
genuinely-corrupt-image failures **1–0 (or 2–0 counting fork starvation as observed
|
||||||
|
class)**. That base rate is the strongest single argument against pure fail-closed.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
**A. Fail-open as fixed (status quo).**
|
||||||
|
- Buys: no false boot failures from probe-side defects or host memory pressure; matches
|
||||||
|
the extension's documented posture; sibling entrypoints consistent (once litespeed
|
||||||
|
gains the warning it currently lacks).
|
||||||
|
- Costs: "starts but serves degraded" is precisely the state this platform is worst at
|
||||||
|
noticing — the container watchdog's filter has historically been `exited|dead|created`
|
||||||
|
only, blind to running-but-wrong, and its post-start verify checks `State.Running`, so
|
||||||
|
it would even score a restart of a degraded container as success. Nothing alerts on the
|
||||||
|
stderr WARNINGs. Realistic outcome of a genuine failure under Option A: **weeks of
|
||||||
|
silent degradation**, surfaced eventually as a confusing customer ticket ("where are my
|
||||||
|
errors?" / plugin misbehavior), with the diagnostic clue buried in `docker logs` of a
|
||||||
|
container nobody had reason to inspect.
|
||||||
|
|
||||||
|
**B. Fail-closed (`exit 1`) whenever `lsphp -i` yields nothing usable.**
|
||||||
|
- Buys: loud, immediate, operator-visible failure; watchdog catches `exited`; the
|
||||||
|
diagnostic (the WARNING line) is the *last* thing in `docker logs`, exactly where an
|
||||||
|
on-call looks first. A genuinely broken image cannot masquerade as healthy for weeks.
|
||||||
|
- Costs: converts transient host-side failures (fork starvation) and any future
|
||||||
|
probe-side defect into customer downtime. On a bad-image push, every container that
|
||||||
|
restarts goes down *hard* — arguably correct (they weren't going to serve anyway), but
|
||||||
|
it also means a probe regression like the one just fixed would again be a mass boot
|
||||||
|
outage rather than a mass warning. The customer experience of B's false positive is
|
||||||
|
strictly worse than A's true positive: total outage vs. degraded-but-serving.
|
||||||
|
- Note: B is not "the old behavior restored" — the old behavior also failed to boot on
|
||||||
|
*healthy* images because the probe itself was the broken part. Anyone arguing "the old
|
||||||
|
crash-loop caught real problems" should first account for the fact that the only
|
||||||
|
production firing of that crash was a false positive.
|
||||||
|
|
||||||
|
**C. Fail-open + a machine-detectable degraded marker (middle).**
|
||||||
|
Boot and serve, but make the degraded state a *first-class, monitorable fact* rather than
|
||||||
|
a log line:
|
||||||
|
1. On any degraded branch, write a marker file (e.g. `/run/cac-degraded` containing the
|
||||||
|
mode string) in addition to the existing stderr WARNING, in **both** entrypoints —
|
||||||
|
and add the currently missing warning + summary line to `entrypoint-litespeed.sh` so
|
||||||
|
the two siblings really are consistent (today's "consistency" claim is only half true).
|
||||||
|
2. Retry the probe once or twice with a short sleep before accepting degradation. This
|
||||||
|
is nearly free and specifically absorbs the fork-starvation transient, which is the
|
||||||
|
most likely real-world cause of an empty probe on this fleet.
|
||||||
|
3. Surface the marker: either a periodic platform check (`docker exec ... test -f
|
||||||
|
/run/cac-degraded` across cac containers, or grepping the boot summary line
|
||||||
|
`path parity = none` from `docker logs --tail`) wired into existing monitoring/alerting,
|
||||||
|
or fold it into the health story. **Caution on the healthcheck route:** the
|
||||||
|
HEALTHCHECKs (all seven Dockerfiles have one) are liveness probes; making "degraded
|
||||||
|
config" report unhealthy is tempting but risky — if the watchdog ever learns to
|
||||||
|
restart unhealthy containers, a persistently-degraded-but-serving site would be
|
||||||
|
restart-looped for a condition a restart cannot fix. Keep degraded ≠ unhealthy;
|
||||||
|
alert on the marker through a channel that pages a human instead of triggering an
|
||||||
|
automated restart.
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Option C, with one fail-closed carve-out considered and (narrowly) rejected.**
|
||||||
|
|
||||||
|
The carve-out I weighed: fail closed *only* in the `lsphp -i is unusable` sub-case (probe
|
||||||
|
empty), fail open in the `answered but no scan dir` sub-case — on the logic that an
|
||||||
|
interpreter that can't print phpinfo probably can't serve. I recommend against it, for
|
||||||
|
two reasons. First, the fork-starvation transient lands in exactly that sub-case and
|
||||||
|
would cause real, host-pressure-correlated boot failures of healthy sites; the retry in
|
||||||
|
C(2) mitigates but can't eliminate it. Second, if the binary truly cannot execute, the
|
||||||
|
final `exec "$LSPHP_BIN" -b ...` fails on its own a few lines later and PID 1 dies
|
||||||
|
anyway — fail-closed-at-the-probe mostly duplicates a crash the container will have
|
||||||
|
regardless, while adding a new false-positive class. The genuinely dangerous residue —
|
||||||
|
binary runs but is subtly broken — is better caught by the existing HEALTHCHECK
|
||||||
|
(TCP :9000 + `pgrep lsphp`), which *does* fail in that world; the platform gap is that
|
||||||
|
the watchdog ignores unhealthy, and that gap is worth closing on its own merits.
|
||||||
|
|
||||||
|
Per-type answer: same posture for `cac-lsphp` and `cac-litespeed` (their failure domains
|
||||||
|
are equivalent for this probe; the current behavioral gap between them is an oversight,
|
||||||
|
not a design). For **shared-tier** entrypoints the answer is *more* strongly fail-open
|
||||||
|
than for per-site containers, should a similar probe ever appear there: a fail-closed
|
||||||
|
shared tier takes down every site on the host, and the marginal diagnostic value of a
|
||||||
|
crash over a marker does not scale with the blast radius.
|
||||||
|
|
||||||
|
Why C over A, stated so it can be disagreed with: A and C serve the customer identically;
|
||||||
|
they differ only in whether the platform can *notice*. The honest failure-likelihood
|
||||||
|
ranking on this fleet is (1) transient probe failure under memory pressure, (2) probe
|
||||||
|
logic regression, (3) genuinely broken image — and only (3) is the case where B
|
||||||
|
outperforms. C handles (1) with the retry, (2) and (3) with the marker + alert, and its
|
||||||
|
worst case is "degraded for as long as the alerting channel takes to be read" instead of
|
||||||
|
A's "degraded for weeks" or B's "down because the host was busy." If you believe a
|
||||||
|
corrupt image push is *more* likely than a probe/host transient, B becomes defensible —
|
||||||
|
but the recent incident history says otherwise.
|
||||||
|
|
||||||
|
What a customer experiences, per option, when the failure is genuine: A/C — site up,
|
||||||
|
error log empty, opcache defaults, possible plugin path weirdness; C additionally gets it
|
||||||
|
noticed and fixed in hours. B — site down until an operator acts, but the operator is
|
||||||
|
paged immediately by existing machinery. Which failure gets silently tolerated for weeks:
|
||||||
|
A's, unambiguously. B's cannot be (crash-loops are loud); C's only if the marker alert is
|
||||||
|
never actually wired up — which is why C **is not done** until the alerting side ships,
|
||||||
|
and shipping the marker without the consumer would be A with extra steps.
|
||||||
|
|
||||||
|
Before committing, measure:
|
||||||
|
- Fleet scan: does `path parity = none` (or an empty scan dir) occur on any live
|
||||||
|
container today? (`docker logs` grep across cac-lsphp/litespeed containers.) If it
|
||||||
|
already fires, the fix priority changes from "posture" to "why".
|
||||||
|
- Frequency of boot-time fork failures during backup windows (correlate container start
|
||||||
|
timestamps with `MemAvailable` dips) — validates or kills the retry rationale.
|
||||||
|
- Whether the watchdog's unhealthy-blindness fix (in progress platform-side) will ever
|
||||||
|
auto-restart on unhealthy; that decides whether degraded may ever map to unhealthy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Call 2 — should the new CI check gate releases?
|
||||||
|
|
||||||
|
### What actually exists (`.gitea/workflows/build-push.yaml`)
|
||||||
|
|
||||||
|
A `Shell-Checks` job runs on every push to trunk: (1) `bash -n` over every script,
|
||||||
|
(2) `shellcheck -S warning` over an explicit allow-list of the pipefail-era scripts,
|
||||||
|
(3) `scripts/tests/lsphp-info-probe.test.sh`, whose section 5 is the structural scan
|
||||||
|
that outlaws early-exit-reader pipelines in any `pipefail`-enabled script under
|
||||||
|
`scripts/` and `ext/`. None of the six image build jobs has `needs: Shell-Checks`, and
|
||||||
|
the workflow's own comment says so: "a red job here does not block a push that is
|
||||||
|
otherwise fine." So today a red check produces a red run *annotation* while all seven
|
||||||
|
images publish anyway.
|
||||||
|
|
||||||
|
False-positive surface, honestly assessed:
|
||||||
|
- `bash -n` and the test's sections 1–4 are deterministic (the pre-fix pipeline is run
|
||||||
|
only informationally; no pass/fail depends on the race). Near-zero FP risk.
|
||||||
|
- `shellcheck` runs against a hand-curated clean set, so it goes red only when a listed
|
||||||
|
file regresses or a new shellcheck version tightens — low FP risk, nonzero.
|
||||||
|
- The structural scan is a line-oriented awk text scan with flat quote-stripping. Its
|
||||||
|
own header is candid: it can miss multi-line pipelines (false negatives) and could
|
||||||
|
misread exotic quoting (false positives). It requires the reader to be the first word
|
||||||
|
after `|` and special-cases `case a|b)`, `sed "s|x|y|"`, `||`. FP risk is real but
|
||||||
|
bounded — and an FP is a *visible* red on a specific line, trivially triaged.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
**A. Leave it non-blocking (status quo).**
|
||||||
|
Buys: zero risk that a scanner quirk halts a release. Costs: this platform's own history
|
||||||
|
says non-blocking signals decay into wallpaper — one-shot installers drifted, the
|
||||||
|
watchdog's blind spot sat unnoticed, and this exact bug class shipped to production
|
||||||
|
through a green build gate and **two** code reviews. A check that exists precisely
|
||||||
|
because the build gate can't see entrypoint runtime, but that cannot stop a release,
|
||||||
|
protects nothing on the day it matters; it merely timestamps the moment the regression
|
||||||
|
was ignorable. The failure it would have caught kills PID 1 on busy production hosts
|
||||||
|
only — the single most expensive place to discover it.
|
||||||
|
|
||||||
|
**B. Hard gate now (add `needs: Shell-Checks` to all six build jobs).**
|
||||||
|
Buys: the regression class physically cannot ship again through this pipeline. Costs: an
|
||||||
|
unproven scanner acquires veto power over every release, including emergency ones. Worth
|
||||||
|
sizing that cost concretely rather than abstractly: releases here are operator-driven
|
||||||
|
pushes to trunk, not a high-frequency train; a false red costs the minutes needed to read
|
||||||
|
the offending line, either fix real sloppiness or adjust the scanner, and re-push. There
|
||||||
|
is no mechanism by which a red silently *delays* an unattended release for days, because
|
||||||
|
releases are attended. The scary version of "blocks all releases" mostly doesn't apply to
|
||||||
|
this repo's release model. The one genuinely bad scenario: an urgent security rebuild
|
||||||
|
blocked at 2 a.m. by a scanner FP — mitigable, since the operator can comment out one
|
||||||
|
`needs:` line in the same push, an escape hatch that is itself visible in the diff.
|
||||||
|
|
||||||
|
**C. Staged: soft-fail now, hard gate after N clean runs.**
|
||||||
|
Buys: evidence before enforcement; if the scanner has an FP mode, it is discovered while
|
||||||
|
red is cheap. Costs: N pushes of window during which the bug class can ship again, and a
|
||||||
|
follow-up task of exactly the kind this platform historically forgets (see: one-shot
|
||||||
|
installer drift). If staged, the flip must be a dated calendar/scheduler entry or a
|
||||||
|
tracked ticket, not an intention.
|
||||||
|
|
||||||
|
**D. Split the gate: hard-gate the deterministic steps now, soft-fail the scanner.**
|
||||||
|
Put `bash -n` + the probe regression test (sections 1–4) in a blocking job immediately —
|
||||||
|
they have effectively no FP surface and section 2's marker check alone prevents the
|
||||||
|
specific regression (reintroducing the inline pipelines) — and leave shellcheck + the
|
||||||
|
structural scan non-blocking until proven. Buys: immediate protection against the exact
|
||||||
|
bug that happened, zero new FP-block risk. Costs: two jobs to maintain; the structural
|
||||||
|
scan (the only piece that generalizes beyond this one probe) stays advisory.
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**B — make it a hard gate now**, with C's discipline applied only to future *additions*
|
||||||
|
to the check, and one prerequisite: run the full `Shell-Checks` job as-is against the
|
||||||
|
current trunk and the last ~5 release tags first (locally or via a manual dispatch) to
|
||||||
|
confirm it is green on everything already shipped. If that back-test is green, gate. If
|
||||||
|
it is not, fix or scope the scanner, then gate.
|
||||||
|
|
||||||
|
Reasoning, laid out to be disagreeable-with: the decision hinges on comparing the two
|
||||||
|
error costs *as they occur in this repo's actual release process*. A false red costs an
|
||||||
|
attended operator minutes, with a one-line escape hatch, and every FP found makes the
|
||||||
|
scanner better. A false green — or a true red that doesn't block — reproduces a
|
||||||
|
production incident whose failure mode is "customer containers refuse to boot, but only
|
||||||
|
on hosts busy enough to have collapsed pipe buffers," i.e. undetectable in dev by
|
||||||
|
construction. The asymmetry is roughly minutes-of-operator-time vs.
|
||||||
|
production-boot-outage, and the probability of FP red is bounded by a back-test we can
|
||||||
|
run *today* instead of estimating. Choosing C is defensible only if you weight "new check
|
||||||
|
might be flaky in ways a back-test can't reveal" highly — plausible for a check with
|
||||||
|
runtime/timing behavior, but sections 1–4 are deterministic and section 5 is a pure text
|
||||||
|
scan over files that are fully known at back-test time. Flakiness that a back-test can't
|
||||||
|
surface would have to come from the runner environment (shellcheck version drift is the
|
||||||
|
main one — worth pinning the shellcheck version when gating). D is the fallback if the
|
||||||
|
back-test turns up scanner noise that can't be resolved same-day.
|
||||||
|
|
||||||
|
Two obligations come with gating, or the gate rots:
|
||||||
|
1. The shellcheck allow-list must grow with the repo — a new `set -o pipefail` script
|
||||||
|
that never gets listed is invisibly exempt from step (2) (the structural scan, by
|
||||||
|
contrast, auto-discovers pipefail scripts). A one-line CI assertion that every
|
||||||
|
pipefail-enabled script appears in the shellcheck list would close that seam.
|
||||||
|
2. Green must not be oversold: the scanner's documented limits (single-line pipelines,
|
||||||
|
flat quote-stripping) mean a multi-line pipeline reintroduction passes it. The gate
|
||||||
|
raises the floor; it does not replace review. Record that limit where reviewers see
|
||||||
|
it (it already is, in the test header — keep it there).
|
||||||
|
|
||||||
|
Before committing, measure: the back-test above (mandatory); shellcheck version on the
|
||||||
|
Gitea runner vs. pinned (drift is the likeliest future FP source); and after gating,
|
||||||
|
track red-run causes for the first month — if more than ~1 in 10 reds is a scanner FP,
|
||||||
|
revisit with D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Framing notes (where the question as posed deserves push-back)
|
||||||
|
|
||||||
|
1. "Previously it failed loudly and the watchdog caught it" is only half the history:
|
||||||
|
the *only* production firing of the loud failure was a false positive (the probe's own
|
||||||
|
SIGPIPE), not a broken image. The old posture's loudness was never demonstrated on a
|
||||||
|
genuine failure; pricing it as a proven detection mechanism overstates its record.
|
||||||
|
2. The fail-open "consistency with the sibling" claim is currently aspirational:
|
||||||
|
`entrypoint-litespeed.sh` fails open *silently* while `entrypoint-lsphp.sh` fails open
|
||||||
|
loudly with mode strings. Whatever posture is chosen, making the siblings actually
|
||||||
|
consistent (warning + summary line + marker in both) is part of the work.
|
||||||
|
3. "Fail-open vs fail-closed" flattens two distinct sub-cases — probe empty vs. probe
|
||||||
|
answered without a scan dir — that have different likely causes and different serving
|
||||||
|
prognoses. The entrypoint already distinguishes them in its reporting; the decision
|
||||||
|
should too (and above, does).
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# phpize / configure / make artifacts from building this extension locally.
|
||||||
|
# The shipped build happens inside Dockerfile.lsphp's ext-build stage, so
|
||||||
|
# nothing generated here is ever committed.
|
||||||
|
.deps
|
||||||
|
.libs/
|
||||||
|
Makefile
|
||||||
|
Makefile.fragments
|
||||||
|
Makefile.global
|
||||||
|
Makefile.objects
|
||||||
|
acinclude.m4
|
||||||
|
aclocal.m4
|
||||||
|
autom4te.cache/
|
||||||
|
build/
|
||||||
|
config.guess
|
||||||
|
config.h
|
||||||
|
config.h.in
|
||||||
|
config.log
|
||||||
|
config.nice
|
||||||
|
config.status
|
||||||
|
config.sub
|
||||||
|
configure
|
||||||
|
configure.ac
|
||||||
|
include/
|
||||||
|
install-sh
|
||||||
|
libtool
|
||||||
|
ltmain.sh
|
||||||
|
missing
|
||||||
|
mkinstalldirs
|
||||||
|
modules/
|
||||||
|
run-tests.php
|
||||||
|
*.lo
|
||||||
|
*.la
|
||||||
|
*.o
|
||||||
|
*.so
|
||||||
|
tests/*.php
|
||||||
|
tests/*.diff
|
||||||
|
tests/*.exp
|
||||||
|
tests/*.log
|
||||||
|
tests/*.out
|
||||||
|
tests/*.sh
|
||||||
|
!tests/fpm-parity-check.sh
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/*
|
||||||
|
* cac_path_parity — $_SERVER filesystem-path parity for the shared-ols tier.
|
||||||
|
*
|
||||||
|
* WHAT PROBLEM THIS SOLVES
|
||||||
|
* ------------------------
|
||||||
|
* A site on the standalone tiers (cac / cac-fpm / cac-litespeed) sees:
|
||||||
|
*
|
||||||
|
* $_SERVER['DOCUMENT_ROOT'] = /home/<user>/public_html
|
||||||
|
* $_SERVER['SCRIPT_FILENAME'] = /home/<user>/public_html/index.php
|
||||||
|
*
|
||||||
|
* On the shared-ols tier the webserver is a SHARED OpenLiteSpeed container that
|
||||||
|
* serves every tenant out of one bulk `/docker/users -> /mnt/users:ro` mount, so
|
||||||
|
* its vhost docRoot is /mnt/users/<user>/<domain>/public_html. OLS has no
|
||||||
|
* ProxyFCGISetEnvIf-style remap (unlike shared-httpd -> cac-fpm): it hands lsphp
|
||||||
|
* exactly that path. The cac-lsphp sidecar symlinks /mnt/users/<user>/<domain>
|
||||||
|
* -> /home/<user>, so every file OPERATION resolves and PHP's own __FILE__ /
|
||||||
|
* __DIR__ / realpath() / getcwd() already report /home/<user>/... — but the RAW
|
||||||
|
* strings OLS put in $_SERVER still read /mnt/users. Moving a site from cac-fpm
|
||||||
|
* to cac-lsphp therefore changed two $_SERVER values, which is exactly the kind
|
||||||
|
* of difference that surfaces later as a broken plugin path or a mismatched
|
||||||
|
* absolute path stored in the database.
|
||||||
|
*
|
||||||
|
* WHY THIS IS AN EXTENSION AND NOT AN auto_prepend_file
|
||||||
|
* -----------------------------------------------------
|
||||||
|
* The first fix was an `auto_prepend_file` drop-in that realpath()'d the two
|
||||||
|
* keys. `auto_prepend_file` is PHP_INI_PERDIR, so ANY site with its own
|
||||||
|
* .user.ini auto_prepend_file silently displaces it and the normaliser never
|
||||||
|
* runs — the state 7 live shared_ols sites are actually in today (Wordfence and
|
||||||
|
* cPanel imports). PHP resolves a single winning value for auto_prepend_file
|
||||||
|
* after the .user.ini chain is parsed, so there is no way to "chain" from the
|
||||||
|
* losing side either.
|
||||||
|
*
|
||||||
|
* The obvious hardening — `php_admin_value auto_prepend_file` — is WORSE, not
|
||||||
|
* better: making our prepend un-overridable makes the customer's prepend
|
||||||
|
* un-runnable, which would disable those same 7 Wordfence WAFs. The two goals
|
||||||
|
* are irreconcilable as long as the mechanism IS the prepend hook.
|
||||||
|
*
|
||||||
|
* An extension sidesteps that entirely. RINIT runs before any userland code and
|
||||||
|
* cannot be displaced by .user.ini, and it consumes no userland hook — so the
|
||||||
|
* customer's auto_prepend_file remains the only prepend in play and keeps
|
||||||
|
* working untouched. Both constraints are satisfied at once.
|
||||||
|
*
|
||||||
|
* The mapping comes from two PHP_INI_SYSTEM entries. PHP_INI_SYSTEM is NOT
|
||||||
|
* settable from .user.ini (which honours only PHP_INI_PERDIR/PHP_INI_USER), nor
|
||||||
|
* from ini_set(), nor from .htaccess — so a customer cannot point the rewrite
|
||||||
|
* somewhere else or switch it off. The cac-lsphp entrypoint writes them from the
|
||||||
|
* same `user`/`domain` env the symlink is built from, so the two can't drift.
|
||||||
|
*
|
||||||
|
* WHY A STRING PREFIX SWAP AND NOT realpath()
|
||||||
|
* -------------------------------------------
|
||||||
|
* The old normaliser called realpath(), which worked only because the sidecar
|
||||||
|
* symlinks /mnt/users/<user>/<domain> -> /home/<user>. A plain prefix swap is
|
||||||
|
* better on every axis that matters here:
|
||||||
|
*
|
||||||
|
* - It is byte-identical to cac-fpm BY CONSTRUCTION. realpath() resolves ALL
|
||||||
|
* symlinks, so a customer who makes public_html itself a symlink would get
|
||||||
|
* some third path — cac-fpm reports the literal /home/<user>/public_html.
|
||||||
|
* - It cannot fail. realpath() returns false for a path that does not exist
|
||||||
|
* (and is constrained by open_basedir), leaving the value half-normalised.
|
||||||
|
* - It costs no syscall. realpath() is an lstat chain on every request.
|
||||||
|
*
|
||||||
|
* The prefix is this site's FULL mount path (/mnt/users/<user>/<domain>), not
|
||||||
|
* the bare bulk-mount root, and it only matches on a path-component boundary —
|
||||||
|
* so a value pointing at another tenant, or one that is already canonical, is
|
||||||
|
* never touched.
|
||||||
|
*
|
||||||
|
* FAILURE MODES — every one of them leaves $_SERVER untouched and lets the
|
||||||
|
* request proceed. Nothing here can warn, throw, or 500 a customer site:
|
||||||
|
* - mapping unset/empty (any tier that is not shared-ols) -> RINIT returns
|
||||||
|
* immediately, extension is inert.
|
||||||
|
* - either side of the mapping not an ABSOLUTE path -> inert. Nothing
|
||||||
|
* the entrypoint writes is anything else, and a relative prefix cannot
|
||||||
|
* usefully match a SAPI-supplied path, so a malformed mapping is treated
|
||||||
|
* exactly like an absent one.
|
||||||
|
* - $_SERVER absent or not an array -> return.
|
||||||
|
* - key absent from $_SERVER -> skip that key.
|
||||||
|
* - key present but not a string -> skip that key.
|
||||||
|
* - value shorter than the prefix / prefix mismatch -> skip that key.
|
||||||
|
* - value matches the prefix mid-component -> skip that key.
|
||||||
|
* There is no error path, no userland-visible diagnostic, and no dependency on
|
||||||
|
* the filesystem being readable.
|
||||||
|
*
|
||||||
|
* The one thing this CANNOT detect is a well-formed but WRONG mapping: it will
|
||||||
|
* confidently rewrite to a wrong path and say nothing. That is accepted by
|
||||||
|
* construction rather than overlooked — entrypoint-lsphp.sh derives from/to from
|
||||||
|
* the same two variables it builds the compatibility symlink from, so a wrong
|
||||||
|
* mapping means the symlink is wrong too and the site is already broken in a far
|
||||||
|
* louder way. The only runtime signal is `lsphp -i`, which prints
|
||||||
|
* "Rewriting => active" alongside the live from/to values.
|
||||||
|
*
|
||||||
|
* That row is what the post-deploy fleet canary greps, so MINFO's "active" test
|
||||||
|
* must stay a mirror of the conditions RINIT actually rewrites under — see
|
||||||
|
* PHP_MINFO_FUNCTION below, which shares cacpp_mapping_active() with RINIT
|
||||||
|
* precisely so the two cannot drift. A MINFO that reported "active" for a
|
||||||
|
* mapping RINIT treats as inert would mask exactly the failure the canary
|
||||||
|
* exists to catch.
|
||||||
|
*
|
||||||
|
* SCOPE / KNOWN LIMITS
|
||||||
|
* --------------------
|
||||||
|
* Only $_SERVER is rewritten. LSAPI also answers getenv('DOCUMENT_ROOT') from
|
||||||
|
* the request environment, and that still returns the /mnt/users string. That is
|
||||||
|
* a deliberate limit: touching the SAPI environment risks the request env lsphp
|
||||||
|
* itself reads. Real-world PHP (WordPress and its plugin ecosystem) reads
|
||||||
|
* $_SERVER, not getenv(), for these.
|
||||||
|
*
|
||||||
|
* The sidecar's compatibility symlink is still REQUIRED and is not replaced by
|
||||||
|
* this extension: it is what makes the path OLS sends actually resolve on disk.
|
||||||
|
* This extension only corrects the strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifdef HAVE_CONFIG_H
|
||||||
|
#include "config.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "php.h"
|
||||||
|
#include "php_ini.h"
|
||||||
|
#include "ext/standard/info.h"
|
||||||
|
#include "SAPI.h"
|
||||||
|
#include "zend_compile.h" /* zend_is_auto_global_str() */
|
||||||
|
#include "php_cac_path_parity.h"
|
||||||
|
|
||||||
|
ZEND_DECLARE_MODULE_GLOBALS(cac_path_parity)
|
||||||
|
|
||||||
|
#define CACPP_G(v) ZEND_MODULE_GLOBALS_ACCESSOR(cac_path_parity, v)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The $_SERVER keys that carry a FILESYSTEM path. URI-derived keys (PHP_SELF,
|
||||||
|
* SCRIPT_NAME, REQUEST_URI) are already identical across tiers and are left
|
||||||
|
* alone. PATH_TRANSLATED and CONTEXT_DOCUMENT_ROOT are usually absent under
|
||||||
|
* OLS; rewriting them is a no-op when they are, and correct when they are not.
|
||||||
|
*/
|
||||||
|
static const struct {
|
||||||
|
const char *name;
|
||||||
|
size_t len;
|
||||||
|
} cacpp_keys[] = {
|
||||||
|
{ ZEND_STRL("DOCUMENT_ROOT") },
|
||||||
|
{ ZEND_STRL("SCRIPT_FILENAME") },
|
||||||
|
{ ZEND_STRL("PATH_TRANSLATED") },
|
||||||
|
{ ZEND_STRL("CONTEXT_DOCUMENT_ROOT") },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* clang-format off */
|
||||||
|
PHP_INI_BEGIN()
|
||||||
|
/*
|
||||||
|
* PHP_INI_SYSTEM is load-bearing: it is the reason a customer's .user.ini
|
||||||
|
* cannot reach these. Do not relax to PERDIR.
|
||||||
|
*/
|
||||||
|
STD_PHP_INI_ENTRY("cac_path_parity.from", "", PHP_INI_SYSTEM, OnUpdateString,
|
||||||
|
from, zend_cac_path_parity_globals, cac_path_parity_globals)
|
||||||
|
STD_PHP_INI_ENTRY("cac_path_parity.to", "", PHP_INI_SYSTEM, OnUpdateString,
|
||||||
|
to, zend_cac_path_parity_globals, cac_path_parity_globals)
|
||||||
|
PHP_INI_END()
|
||||||
|
/* clang-format on */
|
||||||
|
|
||||||
|
static PHP_GINIT_FUNCTION(cac_path_parity)
|
||||||
|
{
|
||||||
|
#if defined(COMPILE_DL_CAC_PATH_PARITY) && defined(ZTS)
|
||||||
|
ZEND_TSRMLS_CACHE_UPDATE();
|
||||||
|
#endif
|
||||||
|
cac_path_parity_globals->from = NULL;
|
||||||
|
cac_path_parity_globals->to = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* THE MAPPING PREDICATE — one definition, two callers.
|
||||||
|
*
|
||||||
|
* RINIT uses it to decide whether to rewrite; MINFO uses it to REPORT whether
|
||||||
|
* rewriting is live. Those two tests were written out longhand in two places
|
||||||
|
* and promptly drifted: the absolute-path guard was added to RINIT only, so
|
||||||
|
* `lsphp -i` went on printing "Rewriting => active" for a mapping RINIT had
|
||||||
|
* already decided to ignore. That row is the fleet canary's signal, so the lie
|
||||||
|
* masked precisely the failure the canary looks for. Keep them sharing this.
|
||||||
|
*
|
||||||
|
* Pure predicates over two NUL-terminated strings: no allocation, no side
|
||||||
|
* effect, no way to fail — MINFO gains no error path by calling them, and the
|
||||||
|
* fail-open invariant is untouched.
|
||||||
|
*/
|
||||||
|
static int cacpp_mapping_configured(const char *from, const char *to)
|
||||||
|
{
|
||||||
|
return from != NULL && *from != '\0' && to != NULL && *to != '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cacpp_mapping_active(const char *from, const char *to)
|
||||||
|
{
|
||||||
|
return cacpp_mapping_configured(from, to) && *from == '/' && *to == '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trailing slashes would defeat the component-boundary test below. */
|
||||||
|
static size_t cacpp_trim(const char *s, size_t len)
|
||||||
|
{
|
||||||
|
while (len > 1 && s[len - 1] == '/') {
|
||||||
|
len--;
|
||||||
|
}
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void cacpp_rewrite_key(zval *server, const char *key, size_t key_len,
|
||||||
|
const char *from, size_t from_len,
|
||||||
|
const char *to, size_t to_len)
|
||||||
|
{
|
||||||
|
zval *val = zend_hash_str_find(Z_ARRVAL_P(server), key, key_len);
|
||||||
|
if (val == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ZVAL_DEREF(val);
|
||||||
|
if (Z_TYPE_P(val) != IS_STRING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *s = Z_STRVAL_P(val);
|
||||||
|
size_t len = Z_STRLEN_P(val);
|
||||||
|
|
||||||
|
if (len < from_len || memcmp(s, from, from_len) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Only replace on a PATH-COMPONENT boundary. Without this,
|
||||||
|
* from=/mnt/users/bob/site.com would also match a sibling directory
|
||||||
|
* /mnt/users/bob/site.com.bak and silently rewrite another site's path
|
||||||
|
* into this site's /home.
|
||||||
|
*/
|
||||||
|
if (len != from_len && s[from_len] != '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t tail_len = len - from_len;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* to="/" is the one absolute prefix that survives cacpp_trim() as a bare
|
||||||
|
* separator, and the tail always starts with one — splicing both would give
|
||||||
|
* "//public_html". Drop it when there IS a tail; keep it when there is not
|
||||||
|
* (value == from exactly, where "/" is the correct answer). Unreachable from
|
||||||
|
* the entrypoint, which always writes to=/home/<user>.
|
||||||
|
*/
|
||||||
|
size_t eff_to_len = to_len;
|
||||||
|
if (tail_len > 0 && eff_to_len == 1 && to[0] == '/') {
|
||||||
|
eff_to_len = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
zend_string *out = zend_string_alloc(eff_to_len + tail_len, 0);
|
||||||
|
|
||||||
|
memcpy(ZSTR_VAL(out), to, eff_to_len);
|
||||||
|
memcpy(ZSTR_VAL(out) + eff_to_len, s + from_len, tail_len);
|
||||||
|
ZSTR_VAL(out)[eff_to_len + tail_len] = '\0';
|
||||||
|
|
||||||
|
zval nv;
|
||||||
|
ZVAL_STR(&nv, out);
|
||||||
|
/*
|
||||||
|
* Update the track_vars array IN PLACE. $_SERVER in the symbol table and
|
||||||
|
* PG(http_globals)[TRACK_VARS_SERVER] are two references to the SAME
|
||||||
|
* zend_array, which is why this is visible to userland. Do NOT
|
||||||
|
* SEPARATE_ARRAY() here: that would copy the array and leave the symbol
|
||||||
|
* table pointing at the original, i.e. silently do nothing. This is the
|
||||||
|
* same in-place pattern php_register_variable_ex() uses.
|
||||||
|
*/
|
||||||
|
zend_hash_str_update(Z_ARRVAL_P(server), key, key_len, &nv);
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP_RINIT_FUNCTION(cac_path_parity)
|
||||||
|
{
|
||||||
|
#if defined(ZTS) && defined(COMPILE_DL_CAC_PATH_PARITY)
|
||||||
|
ZEND_TSRMLS_CACHE_UPDATE();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
const char *from = CACPP_G(from);
|
||||||
|
const char *to = CACPP_G(to);
|
||||||
|
|
||||||
|
/* Unconfigured (any tier that isn't shared-ols) => completely inert. */
|
||||||
|
if (!cacpp_mapping_configured(from, to)) {
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both sides must be ABSOLUTE. The entrypoint only ever writes absolute
|
||||||
|
* paths; a relative prefix would be a typo or a mangled ini, and matching it
|
||||||
|
* against a SAPI-supplied path could only ever produce nonsense. Treat it
|
||||||
|
* like an absent mapping — inert, no diagnostic, request proceeds.
|
||||||
|
*/
|
||||||
|
if (!cacpp_mapping_active(from, to)) {
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* With auto_globals_jit=On (the default) $_SERVER is not built yet at
|
||||||
|
* RINIT — php_hash_environment() only MARKED it for lazy creation. Reading
|
||||||
|
* PG(http_globals)[TRACK_VARS_SERVER] here without this call finds IS_UNDEF
|
||||||
|
* and the extension silently does nothing, which is precisely the failure
|
||||||
|
* the auto_prepend approach had. Forcing the auto-global now builds it (via
|
||||||
|
* the SAPI's register_server_variables) so there is something to rewrite,
|
||||||
|
* and the later userland access gets the corrected array.
|
||||||
|
*/
|
||||||
|
zend_is_auto_global_str(ZEND_STRL("_SERVER"));
|
||||||
|
|
||||||
|
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
|
||||||
|
if (Z_TYPE_P(server) != IS_ARRAY) {
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t from_len = cacpp_trim(from, strlen(from));
|
||||||
|
size_t to_len = cacpp_trim(to, strlen(to));
|
||||||
|
|
||||||
|
for (size_t i = 0; i < sizeof(cacpp_keys) / sizeof(cacpp_keys[0]); i++) {
|
||||||
|
cacpp_rewrite_key(server, cacpp_keys[i].name, cacpp_keys[i].len,
|
||||||
|
from, from_len, to, to_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP_MINIT_FUNCTION(cac_path_parity)
|
||||||
|
{
|
||||||
|
REGISTER_INI_ENTRIES();
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP_MSHUTDOWN_FUNCTION(cac_path_parity)
|
||||||
|
{
|
||||||
|
UNREGISTER_INI_ENTRIES();
|
||||||
|
return SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP_MINFO_FUNCTION(cac_path_parity)
|
||||||
|
{
|
||||||
|
const char *from = CACPP_G(from);
|
||||||
|
const char *to = CACPP_G(to);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Report what RINIT would ACTUALLY do, by asking the same predicates RINIT
|
||||||
|
* asks — never a longhand copy of them (see cacpp_mapping_active above for
|
||||||
|
* what that cost last time). Three distinct answers, because "configured but
|
||||||
|
* ignored" is a different operational problem from "not configured" and the
|
||||||
|
* canary must be able to tell them apart.
|
||||||
|
*/
|
||||||
|
const char *state;
|
||||||
|
if (cacpp_mapping_active(from, to)) {
|
||||||
|
state = "active";
|
||||||
|
} else if (cacpp_mapping_configured(from, to)) {
|
||||||
|
state = "inactive (mapping not absolute)";
|
||||||
|
} else {
|
||||||
|
state = "inactive (unconfigured)";
|
||||||
|
}
|
||||||
|
|
||||||
|
php_info_print_table_start();
|
||||||
|
php_info_print_table_header(2, "cac_path_parity support", "enabled");
|
||||||
|
php_info_print_table_row(2, "Version", PHP_CAC_PATH_PARITY_VERSION);
|
||||||
|
/*
|
||||||
|
* The canary greps for this row: "active" proves the mapping is live — and,
|
||||||
|
* since the predicate is shared with RINIT, proves the request path agrees.
|
||||||
|
*/
|
||||||
|
php_info_print_table_row(2, "Rewriting", state);
|
||||||
|
php_info_print_table_end();
|
||||||
|
|
||||||
|
DISPLAY_INI_ENTRIES();
|
||||||
|
}
|
||||||
|
|
||||||
|
zend_module_entry cac_path_parity_module_entry = {
|
||||||
|
STANDARD_MODULE_HEADER,
|
||||||
|
"cac_path_parity",
|
||||||
|
NULL, /* no userland functions — by design */
|
||||||
|
PHP_MINIT(cac_path_parity),
|
||||||
|
PHP_MSHUTDOWN(cac_path_parity),
|
||||||
|
PHP_RINIT(cac_path_parity),
|
||||||
|
NULL, /* RSHUTDOWN */
|
||||||
|
PHP_MINFO(cac_path_parity),
|
||||||
|
PHP_CAC_PATH_PARITY_VERSION,
|
||||||
|
PHP_MODULE_GLOBALS(cac_path_parity),
|
||||||
|
PHP_GINIT(cac_path_parity),
|
||||||
|
NULL, /* GSHUTDOWN */
|
||||||
|
NULL, /* post-deactivate */
|
||||||
|
STANDARD_MODULE_PROPERTIES_EX
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef COMPILE_DL_CAC_PATH_PARITY
|
||||||
|
#if defined(ZTS)
|
||||||
|
ZEND_TSRMLS_CACHE_DEFINE()
|
||||||
|
#endif
|
||||||
|
ZEND_GET_MODULE(cac_path_parity)
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
dnl config.m4 for the cac_path_parity extension.
|
||||||
|
dnl Built out-of-tree against the image's own lsphp:
|
||||||
|
dnl phpize && ./configure --with-php-config=/usr/local/lsws/lsphpNN/bin/php-config
|
||||||
|
dnl No external libraries, no optional features — pure core API.
|
||||||
|
|
||||||
|
PHP_ARG_ENABLE([cac_path_parity],
|
||||||
|
[whether to enable cac_path_parity support],
|
||||||
|
[AS_HELP_STRING([--enable-cac-path-parity],
|
||||||
|
[Enable cac_path_parity ($_SERVER path parity for the shared-ols tier)])],
|
||||||
|
[no])
|
||||||
|
|
||||||
|
if test "$PHP_CAC_PATH_PARITY" != "no"; then
|
||||||
|
AC_DEFINE(HAVE_CAC_PATH_PARITY, 1, [Have cac_path_parity support])
|
||||||
|
PHP_NEW_EXTENSION(cac_path_parity, cac_path_parity.c, $ext_shared)
|
||||||
|
fi
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* cac_path_parity — $_SERVER filesystem-path parity for the shared-ols tier.
|
||||||
|
*
|
||||||
|
* See cac_path_parity.c for the full rationale.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef PHP_CAC_PATH_PARITY_H
|
||||||
|
#define PHP_CAC_PATH_PARITY_H
|
||||||
|
|
||||||
|
extern zend_module_entry cac_path_parity_module_entry;
|
||||||
|
#define phpext_cac_path_parity_ptr &cac_path_parity_module_entry
|
||||||
|
|
||||||
|
#define PHP_CAC_PATH_PARITY_VERSION "1.0.0"
|
||||||
|
|
||||||
|
#if defined(ZTS) && defined(COMPILE_DL_CAC_PATH_PARITY)
|
||||||
|
ZEND_TSRMLS_CACHE_EXTERN()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
ZEND_BEGIN_MODULE_GLOBALS(cac_path_parity)
|
||||||
|
char *from;
|
||||||
|
char *to;
|
||||||
|
ZEND_END_MODULE_GLOBALS(cac_path_parity)
|
||||||
|
|
||||||
|
#endif /* PHP_CAC_PATH_PARITY_H */
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: rewrites the configured prefix on a filesystem $_SERVER key
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
|
||||||
|
HTTP_HOST=site.com
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// NOTE: the CLI SAPI overwrites DOCUMENT_ROOT (to "") and SCRIPT_FILENAME /
|
||||||
|
// PATH_TRANSLATED (to the script path) AFTER the env import, so those three
|
||||||
|
// cannot be driven from --ENV-- here. They go through the identical code path
|
||||||
|
// as CONTEXT_DOCUMENT_ROOT (one loop over one key table); the real web-SAPI
|
||||||
|
// proof for them is tests/fpm-parity-check.sh.
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
// Non-path vars must be untouched.
|
||||||
|
var_dump($_SERVER['HTTP_HOST']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(21) "/home/bob/public_html"
|
||||||
|
string(8) "site.com"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: a sibling dir sharing the prefix is NOT rewritten
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com.bak/public_html
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// Replacement happens only on a path-COMPONENT boundary. Without that guard a
|
||||||
|
// neighbouring directory would be folded into this container's /home.
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(39) "/mnt/users/bob/site.com.bak/public_html"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: an exact prefix match (no trailing component) is rewritten
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com/
|
||||||
|
cac_path_parity.to=/home/bob/
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// Also covers trailing slashes in the configured values being tolerated.
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(9) "/home/bob"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: completely inert when no mapping is configured
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// cac-fpm / cac-litespeed never configure a mapping, so the extension must be
|
||||||
|
// a no-op there. This is the no-regression guarantee for the other tiers.
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(35) "/mnt/users/bob/site.com/public_html"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: works with auto_globals_jit=On (lazy $_SERVER, the default)
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
auto_globals_jit=1
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// With auto_globals_jit=On, $_SERVER does not exist yet when extension RINIT
|
||||||
|
// runs. The extension forces the auto-global so there is something to rewrite;
|
||||||
|
// drop that call and this test prints the /mnt/users path.
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(21) "/home/bob/public_html"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: mapping is PHP_INI_SYSTEM — userland cannot change it
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// PHP_INI_SYSTEM entries are not modifiable at runtime, and .user.ini honours
|
||||||
|
// only PHP_INI_PERDIR|PHP_INI_USER — so a customer cannot redirect or disable
|
||||||
|
// the rewrite the way they can displace an auto_prepend_file.
|
||||||
|
var_dump(ini_set('cac_path_parity.from', '/tmp'));
|
||||||
|
var_dump(ini_set('cac_path_parity.to', '/tmp'));
|
||||||
|
var_dump(ini_get('cac_path_parity.from'));
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
bool(false)
|
||||||
|
bool(false)
|
||||||
|
string(23) "/mnt/users/bob/site.com"
|
||||||
|
string(21) "/home/bob/public_html"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: a non-absolute mapping is inert, not applied
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=mnt/users/bob/site.com/public_html
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// The value here is deliberately relative TOO, so the prefix would match and be
|
||||||
|
// rewritten if the absolute-path guard in RINIT were removed. Nothing the
|
||||||
|
// entrypoint writes looks like this; the guard exists so a mangled ini degrades
|
||||||
|
// to "inert" rather than to "confidently wrong".
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(34) "mnt/users/bob/site.com/public_html"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: to=/ does not produce a doubled separator
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/
|
||||||
|
variables_order=EGPCS
|
||||||
|
--ENV--
|
||||||
|
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// Degenerate mapping, unreachable from entrypoint-lsphp.sh (which always writes
|
||||||
|
// to=/home/<user>). Before the eff_to_len collapse this returned
|
||||||
|
// "//public_html". A path with a doubled leading slash is not the same string as
|
||||||
|
// the cac-fpm value, which is the entire point of this extension.
|
||||||
|
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
string(12) "/public_html"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: MINFO reports a non-absolute mapping as INACTIVE, not active
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// `lsphp -i | grep Rewriting` is the signal the post-deploy fleet canary uses to
|
||||||
|
// confirm parity is live on a host. When the absolute-path guard was added to
|
||||||
|
// RINIT, MINFO was left testing only "both values non-empty" — so this exact
|
||||||
|
// mapping (relative `from`, silently INERT since 007) still printed
|
||||||
|
// "Rewriting => active". A canary that reports healthy for a dead mapping hides
|
||||||
|
// precisely the failure it was deployed to find.
|
||||||
|
//
|
||||||
|
// MINFO and RINIT now share cacpp_mapping_active(); revert MINFO to the
|
||||||
|
// non-empty test and this prints "active".
|
||||||
|
ob_start();
|
||||||
|
phpinfo(INFO_MODULES);
|
||||||
|
$info = ob_get_clean();
|
||||||
|
|
||||||
|
// Also assert the row is unique, so the match below cannot be some other
|
||||||
|
// module's identically-named row.
|
||||||
|
var_dump(preg_match_all('/^Rewriting => (.+)$/m', $info, $m));
|
||||||
|
var_dump(rtrim($m[1][0]));
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
int(1)
|
||||||
|
string(31) "inactive (mapping not absolute)"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
--TEST--
|
||||||
|
cac_path_parity: MINFO reports a well-formed mapping as ACTIVE
|
||||||
|
--EXTENSIONS--
|
||||||
|
cac_path_parity
|
||||||
|
--INI--
|
||||||
|
cac_path_parity.from=/mnt/users/bob/site.com
|
||||||
|
cac_path_parity.to=/home/bob
|
||||||
|
--FILE--
|
||||||
|
<?php
|
||||||
|
// The other half of 009. Tightening MINFO must not overshoot into the opposite
|
||||||
|
// lie: a canary that reports "inactive" on a perfectly good mapping would page
|
||||||
|
// the fleet for nothing and, worse, train us to ignore the row. This mapping is
|
||||||
|
// the exact shape entrypoint-lsphp.sh writes (/mnt/users/<user>/<domain> ->
|
||||||
|
// /home/<user>), and 001 proves RINIT really does rewrite under it.
|
||||||
|
ob_start();
|
||||||
|
phpinfo(INFO_MODULES);
|
||||||
|
$info = ob_get_clean();
|
||||||
|
|
||||||
|
var_dump(preg_match_all('/^Rewriting => (.+)$/m', $info, $m));
|
||||||
|
var_dump(rtrim($m[1][0]));
|
||||||
|
?>
|
||||||
|
--EXPECT--
|
||||||
|
int(1)
|
||||||
|
string(6) "active"
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## fpm-parity-check.sh — end-to-end proof under a REAL web SAPI.
|
||||||
|
##
|
||||||
|
## WHY NOT .phpt: the CLI SAPI overwrites DOCUMENT_ROOT / SCRIPT_FILENAME /
|
||||||
|
## PATH_TRANSLATED after importing the environment, and the cli-server SAPI does
|
||||||
|
## not process .user.ini at all — so neither can exercise the two things that
|
||||||
|
## actually matter here.
|
||||||
|
##
|
||||||
|
## WHY PHP-FPM: php-fpm takes DOCUMENT_ROOT and SCRIPT_FILENAME as caller-
|
||||||
|
## supplied FastCGI params and honours .user.ini — structurally the same shape as
|
||||||
|
## OpenLiteSpeed handing a detached lsphp its LSAPI params. It is the closest
|
||||||
|
## analogue available without an OLS runtime.
|
||||||
|
##
|
||||||
|
## Asserts:
|
||||||
|
## 1. CONTROL — no mapping => PHP reports the raw /mnt/users paths, i.e. the
|
||||||
|
## test reproduces the bug before claiming to fix it.
|
||||||
|
## 2. FIX — mapping => both keys read /home/<user>/... .
|
||||||
|
## 3. WORDFENCE — mapping AND a customer .user.ini auto_prepend_file (the state
|
||||||
|
## 7 live shared_ols sites are in): paths are STILL corrected
|
||||||
|
## AND the customer's prepend STILL runs. This is the case the
|
||||||
|
## old auto_prepend_file normaliser silently lost.
|
||||||
|
## 4. OLD — for the record: the previous auto_prepend mechanism, with the
|
||||||
|
## same customer .user.ini, does NOT run. This is the evidence
|
||||||
|
## that hardening the prepend hook could not have worked.
|
||||||
|
##
|
||||||
|
## Exit codes: 0 = all assertions passed, 1 = an assertion FAILED, 2 = the
|
||||||
|
## harness could not run (missing binary, php-fpm refused to start, .so would not
|
||||||
|
## load). 2 is deliberately distinct from 1: a startup problem previously
|
||||||
|
## surfaced as all nine assertions failing with an empty `got:`, which reads like
|
||||||
|
## nine parity bugs and is the opposite of the truth.
|
||||||
|
##
|
||||||
|
## Usage: ./fpm-parity-check.sh [ROOT] [PHP_FPM_BIN] [EXT_SO]
|
||||||
|
## ROOT defaults to /mnt/users (falls back to a temp dir if not creatable).
|
||||||
|
## PHP_FPM_BIN is auto-detected; every packaging of php-fpm this repo touches
|
||||||
|
## uses a different name (`php-fpm` in the official docker images,
|
||||||
|
## `php-fpm8.N` on Debian/Ubuntu, /usr/sbin/... unlinked from PATH), so a
|
||||||
|
## single hardcoded default is guaranteed to be wrong somewhere and its only
|
||||||
|
## symptom was a silent `SKIP`.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
find_fpm() {
|
||||||
|
local c
|
||||||
|
for c in php-fpm php-fpm8.5 php-fpm8.4 php-fpm8.3 php-fpm8.2 php-fpm8.1; do
|
||||||
|
if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi
|
||||||
|
done
|
||||||
|
for c in /usr/local/sbin/php-fpm /usr/sbin/php-fpm /usr/sbin/php-fpm8.*; do
|
||||||
|
if [ -x "$c" ]; then echo "$c"; return 0; fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT="${1:-/mnt/users}"
|
||||||
|
FPM_BIN="${2:-$(find_fpm || true)}"
|
||||||
|
EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}"
|
||||||
|
PORT="${PORT:-9001}"
|
||||||
|
|
||||||
|
command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; }
|
||||||
|
[ -n "$FPM_BIN" ] && [ -x "$FPM_BIN" ] || { echo "SKIP: php-fpm not found (pass it as \$2)"; exit 0; }
|
||||||
|
[ -f "$EXT_SO" ] || { echo "SKIP: $EXT_SO not built (run phpize && ./configure && make)"; exit 0; }
|
||||||
|
|
||||||
|
## `${VAR%%$'\n'*}` rather than `| head -1`: same first line, no pipeline, so
|
||||||
|
## nothing here can be decided by a SIGPIPE race under the pipefail on line 39.
|
||||||
|
## This one only ever fed an echo, so it could not have misled anyone — it is
|
||||||
|
## changed so that "no pipefail script in this repo pipes into an early-exit
|
||||||
|
## reader" stays a rule with no exceptions to remember.
|
||||||
|
FPM_VERSION=$("$FPM_BIN" -n -v 2>/dev/null || true)
|
||||||
|
echo "php-fpm: $FPM_BIN (${FPM_VERSION%%$'\n'*})"
|
||||||
|
echo "extension: $EXT_SO"
|
||||||
|
|
||||||
|
## Pre-flight. If the .so will not load into THIS php-fpm (PHP API mismatch is
|
||||||
|
## the usual cause) every assertion below would fail identically and blame the
|
||||||
|
## extension's logic. Say what actually happened instead.
|
||||||
|
## Captured into a variable and matched with a here-string, not piped into
|
||||||
|
## `grep -qx`. `grep -q` exits on its first match, and with `set -o pipefail`
|
||||||
|
## (line 39) a writer still writing at that moment dies 141 and the pipeline
|
||||||
|
## reads FALSE — announcing "cannot load the extension" *because* the extension
|
||||||
|
## was listed. The reason to change it is structural, not that `php-fpm -m` is
|
||||||
|
## small: there is no payload size that makes this shape safe (41 KB SIGPIPEs
|
||||||
|
## about 11% of the time into a 64 KB pipe — see the note over the probe helpers
|
||||||
|
## in scripts/entrypoint-lsphp.sh), and this pre-flight exists precisely to stop
|
||||||
|
## a harness malfunction being reported as an extension fault, so it must not
|
||||||
|
## have one of its own. (The same construct on 40 KB of `lsphp -i` is what broke
|
||||||
|
## entrypoint-lsphp.sh in production.)
|
||||||
|
##
|
||||||
|
## A here-string, not the `[[ ]]` form those helpers use, on purpose: `<<<`
|
||||||
|
## spills to /tmp/sh-thd.XXXXXX above ~4-64 KB depending on the bash build, so
|
||||||
|
## it is a writable-temp-dir precondition, which is unacceptable on a boot path
|
||||||
|
## and irrelevant here — `php-fpm -m` is ~1 KB, and this harness has already
|
||||||
|
## created a docroot and a pool config by the time it runs.
|
||||||
|
FPM_MODULES=$("$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>/dev/null || true)
|
||||||
|
if ! grep -qx 'cac_path_parity' <<<"$FPM_MODULES"; then
|
||||||
|
echo "HARNESS FAILURE: $FPM_BIN cannot load $EXT_SO" >&2
|
||||||
|
"$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>&1 | grep -i 'unable\|warning\|error' >&2
|
||||||
|
echo " The .so must be built against the same PHP as this php-fpm binary." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$ROOT" 2>/dev/null || ROOT="$(mktemp -d)/mnt/users"
|
||||||
|
USER_NAME=bob
|
||||||
|
SITE="$ROOT/$USER_NAME/site.com"
|
||||||
|
DOCROOT="$SITE/public_html"
|
||||||
|
HOME_PATH="/home/$USER_NAME"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
## php-fpm REFUSES to start as root unless the pool names a non-root user/group,
|
||||||
|
## and the pool this script generates had neither — so as shipped it never got
|
||||||
|
## past startup in any root context (which is every container in this repo).
|
||||||
|
## Resolve a real unprivileged account rather than assuming www-data exists.
|
||||||
|
POOL_USER=""
|
||||||
|
POOL_GROUP=""
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
for u in www-data nobody daemon; do
|
||||||
|
if id -u "$u" >/dev/null 2>&1; then POOL_USER="$u"; break; fi
|
||||||
|
done
|
||||||
|
for g in www-data nogroup nobody daemon; do
|
||||||
|
if getent group "$g" >/dev/null 2>&1; then POOL_GROUP="$g"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$POOL_USER" ] && [ -n "$POOL_GROUP" ] || {
|
||||||
|
echo "HARNESS FAILURE: running as root but found no unprivileged user/group for the pool" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DOCROOT" || { echo "cannot create $DOCROOT"; exit 1; }
|
||||||
|
## The pool worker is not root: it has to be able to read the fixtures under
|
||||||
|
## $TMP (mktemp -d is 0700) and walk down to $DOCROOT.
|
||||||
|
chmod 755 "$TMP"
|
||||||
|
trap 'rm -rf "$TMP"; rm -f "$DOCROOT/.user.ini"' EXIT
|
||||||
|
|
||||||
|
cat > "$DOCROOT/probe.php" <<'PHP'
|
||||||
|
<?php
|
||||||
|
echo "DOCUMENT_ROOT=" . $_SERVER['DOCUMENT_ROOT'] . "\n";
|
||||||
|
echo "SCRIPT_FILENAME=" . $_SERVER['SCRIPT_FILENAME'] . "\n";
|
||||||
|
echo "PREPEND_RAN=" . (defined('CUSTOMER_PREPEND_RAN') ? 'yes' : 'no') . "\n";
|
||||||
|
PHP
|
||||||
|
|
||||||
|
## Stand-in for the customer's wordfence-waf.php.
|
||||||
|
cat > "$SITE/customer-waf.php" <<'PHP'
|
||||||
|
<?php
|
||||||
|
define('CUSTOMER_PREPEND_RAN', 1);
|
||||||
|
PHP
|
||||||
|
|
||||||
|
## Stand-in for the OLD mechanism (scripts/cac-lsphp-normalize.php).
|
||||||
|
cat > "$TMP/old-normalize.php" <<'PHP'
|
||||||
|
<?php
|
||||||
|
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) {
|
||||||
|
if (!empty($_SERVER[$k]) && strncmp($_SERVER[$k], '/mnt/users/', 11) === 0) {
|
||||||
|
$r = realpath($_SERVER[$k]);
|
||||||
|
if ($r !== false) { $_SERVER[$k] = $r; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PHP
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "[global]"
|
||||||
|
echo "error_log = $TMP/fpm-error.log"
|
||||||
|
echo "daemonize = no"
|
||||||
|
echo "[www]"
|
||||||
|
echo "listen = 127.0.0.1:$PORT"
|
||||||
|
echo "pm = static"
|
||||||
|
echo "pm.max_children = 2"
|
||||||
|
## Only when we are root: php-fpm hard-errors on a root pool, and warns
|
||||||
|
## (harmlessly, but noisily) if a non-root master names a user at all.
|
||||||
|
if [ -n "$POOL_USER" ]; then
|
||||||
|
echo "user = $POOL_USER"
|
||||||
|
echo "group = $POOL_GROUP"
|
||||||
|
fi
|
||||||
|
} > "$TMP/fpm.conf"
|
||||||
|
|
||||||
|
## Returns non-zero when php-fpm never answered. Callers MUST distinguish that
|
||||||
|
## from an assertion failure — an unstarted php-fpm makes every expect() below
|
||||||
|
## fail with an empty `got:`, which looks like nine parity bugs.
|
||||||
|
run_case() {
|
||||||
|
: > "$TMP/fpm.out"
|
||||||
|
"$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \
|
||||||
|
>"$TMP/fpm.out" 2>&1 &
|
||||||
|
local pid=$! out=""
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
sleep 0.15
|
||||||
|
## SC1007: `QUERY_STRING=` IS the intent — an empty FastCGI param in the
|
||||||
|
## per-command environment prefix, exactly as a webserver sends it for a
|
||||||
|
## URL with no query string. Not a truncated assignment.
|
||||||
|
# shellcheck disable=SC1007
|
||||||
|
out=$(SCRIPT_FILENAME="$DOCROOT/probe.php" DOCUMENT_ROOT="$DOCROOT" \
|
||||||
|
SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \
|
||||||
|
cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null)
|
||||||
|
[ -n "$out" ] && break
|
||||||
|
## Master already gone => it will never answer; stop waiting 6s for it.
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
done
|
||||||
|
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
|
||||||
|
printf '%s' "$out"
|
||||||
|
[ -n "$out" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
die_startup() {
|
||||||
|
echo
|
||||||
|
echo "HARNESS FAILURE: php-fpm never answered for case '$1'." >&2
|
||||||
|
echo " This is a STARTUP/environment failure, NOT a parity assertion failure." >&2
|
||||||
|
echo " php-fpm: $FPM_BIN" >&2
|
||||||
|
echo " pool user/group: ${POOL_USER:-<none, master is not root>}/${POOL_GROUP:-}" >&2
|
||||||
|
echo " --- php-fpm output ---" >&2
|
||||||
|
sed 's/^/ /' "$TMP/fpm.out" >&2
|
||||||
|
echo " --- pool error_log ---" >&2
|
||||||
|
[ -s "$TMP/fpm-error.log" ] && sed 's/^/ /' "$TMP/fpm-error.log" >&2
|
||||||
|
echo " ----------------------" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
expect() {
|
||||||
|
local label="$1" got="$2" want="$3"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
echo " PASS $label"
|
||||||
|
else
|
||||||
|
echo " FAIL $label"
|
||||||
|
echo " want: $want"
|
||||||
|
echo " got: $got"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
field() { printf '%s' "$1" | sed -n "s/^$2=//p"; }
|
||||||
|
|
||||||
|
EXT=( -d "extension=$EXT_SO" )
|
||||||
|
MAP=( -d "cac_path_parity.from=$SITE" -d "cac_path_parity.to=$HOME_PATH" )
|
||||||
|
USERINI_LINE="auto_prepend_file = $SITE/customer-waf.php"
|
||||||
|
|
||||||
|
echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) =="
|
||||||
|
rm -f "$DOCROOT/.user.ini"
|
||||||
|
out=$(run_case "${EXT[@]}") || die_startup "1. CONTROL"
|
||||||
|
expect "DOCUMENT_ROOT is the raw OLS path" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
|
||||||
|
expect "SCRIPT_FILENAME is the raw OLS path" "$(field "$out" SCRIPT_FILENAME)" "$DOCROOT/probe.php"
|
||||||
|
|
||||||
|
echo "== 2. FIX: mapping configured =="
|
||||||
|
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "2. FIX"
|
||||||
|
expect "DOCUMENT_ROOT == cac-fpm value" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
|
||||||
|
expect "SCRIPT_FILENAME == cac-fpm value" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
|
||||||
|
|
||||||
|
echo "== 3. WORDFENCE: customer .user.ini auto_prepend_file present =="
|
||||||
|
printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini"
|
||||||
|
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "3. WORDFENCE"
|
||||||
|
expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
|
||||||
|
expect "SCRIPT_FILENAME still corrected" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
|
||||||
|
expect "customer auto_prepend_file still ran" "$(field "$out" PREPEND_RAN)" "yes"
|
||||||
|
|
||||||
|
echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) =="
|
||||||
|
out=$(run_case -d "auto_prepend_file=$TMP/old-normalize.php") || die_startup "4. OLD MECHANISM"
|
||||||
|
expect "auto_prepend normaliser is displaced by the customer's .user.ini" \
|
||||||
|
"$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
|
||||||
|
expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes"
|
||||||
|
|
||||||
|
rm -f "$DOCROOT/.user.ini"
|
||||||
|
if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES"; fi
|
||||||
|
exit "$fail"
|
||||||
+15
-9
@@ -5,18 +5,20 @@ https_port='443'
|
|||||||
root_path="$(pwd)"
|
root_path="$(pwd)"
|
||||||
verbose='false'
|
verbose='false'
|
||||||
|
|
||||||
while getopts 'n:p:s:r:vh' flag; do
|
while getopts 'n:p:s:r:a:vh' flag; do
|
||||||
case "${flag}" in
|
case "${flag}" in
|
||||||
n) name="${OPTARG}" ;;
|
n) name="${OPTARG}" ;;
|
||||||
p) http_port="${OPTARG}" ;;
|
p) http_port="${OPTARG}" ;;
|
||||||
s) https_port="${OPTARG}" ;;
|
s) https_port="${OPTARG}" ;;
|
||||||
r) root_path="${OPTARG}" ;;
|
r) root_path="${OPTARG}" ;;
|
||||||
|
a) phpver="${OPTARG}" ;;
|
||||||
v) verbose='true' ;;
|
v) verbose='true' ;;
|
||||||
h) echo "Variables"
|
h) echo "Variables"
|
||||||
echo "-n = Name of Container, Required"
|
echo "-n = Name of Container, Required"
|
||||||
echo "-p = Non-https Port Override, default 80"
|
echo "-p = Non-https Port Override, default 80"
|
||||||
echo "-s = Https Port Override, default 443"
|
echo "-s = Https Port Override, default 443"
|
||||||
echo "-r = Root Path for files and database, defaults to current working path"
|
echo "-r = Root Path for files and database, defaults to current working path"
|
||||||
|
echo "-a = PHP App Version, Default to 8.3"
|
||||||
echo "-v = Enable Verbose Mode"
|
echo "-v = Enable Verbose Mode"
|
||||||
exit 1 ;;
|
exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -34,16 +36,20 @@ if [ -z "$name" ]; then
|
|||||||
echo "Name not set, please set it with -n"
|
echo "Name not set, please set it with -n"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$phpver" ]; then
|
||||||
|
phpver=83;
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Building Docker Image..."
|
echo "Building Docker Image..."
|
||||||
user=$(whoami)
|
user=$(whoami)
|
||||||
uid=$(id -u)
|
uid=$(id -u)
|
||||||
if [ ! -d "$root_path/db" ]; then
|
if [ ! -d "$root_path/user" ]; then
|
||||||
mkdir -p "$root_path/db";
|
mkdir -p "$root_path/user";
|
||||||
|
mkdir -p "$root_path/user/logs/{apache,system}";
|
||||||
fi
|
fi
|
||||||
if [ ! -d "$root_path/web" ]; then
|
$check_docker volume create "$name-mysql"
|
||||||
mkdir -p "$root_path/web";
|
$check_docker run --pull=always -d -p "$http_port":80 -p "$https_port":443 -e PHPVER=$phpver -e environment=DEV --mount type=bind,source="$root_path"/user,target=/home/"$user" --mount type=bind,source="$(pwd)"/user/logs/apache,target=/etc/httpd/logs --mount type=bind,source="$(pwd)"/user/logs/system,target=/var/log -v"$name-mysql":/var/lib/mysql -e uid="$uid" -e user="$user" -e domain="$name-local.dev" --name "$name" repo.anhonesthost.net/cloud-hosting-platform/cac:latest
|
||||||
fi
|
|
||||||
$check_docker run -d -p "$http_port":80 -p "$https_port":443 -e PHPVER=82 -e environment=DEV --mount type=bind,source="$root_path"/web,target=/home/"$user"/public_html --mount type=bind,source="$root_path"/db,target=/var/lib/mysql -e uid="$uid" -e user="$user" -e domain="$name-local.dev" --name "$name" public.ecr.aws/s1f6k4w4/cac
|
|
||||||
echo "Creating management scripts in root directory..."
|
echo "Creating management scripts in root directory..."
|
||||||
echo "#!/usr/bin/env bash" > "$root_path/instance_start"
|
echo "#!/usr/bin/env bash" > "$root_path/instance_start"
|
||||||
echo "docker start $name" >> "$root_path/instance_start"
|
echo "docker start $name" >> "$root_path/instance_start"
|
||||||
@@ -54,8 +60,8 @@ echo "docker exec $name bash -c 'tail -f /etc/httpd/logs/*'" >> "$root_path/inst
|
|||||||
echo "#!/usr/bin/env bash" > "$root_path/instance_db_info"
|
echo "#!/usr/bin/env bash" > "$root_path/instance_db_info"
|
||||||
echo "docker exec $name cat /var/lib/mysql/creds" >> "$root_path/instance_db_info"
|
echo "docker exec $name cat /var/lib/mysql/creds" >> "$root_path/instance_db_info"
|
||||||
chmod +x $root_path/instance_*
|
chmod +x $root_path/instance_*
|
||||||
echo "Waiting 120 seconds for setup to finish"
|
echo "Waiting 160 seconds for setup to finish"
|
||||||
sleep 120;
|
sleep 160;
|
||||||
echo "Installing WordPress..."
|
echo "Installing WordPress..."
|
||||||
wpdbuser=$(docker exec $name cat /var/lib/mysql/creds |grep User| awk -F ": " {'print $2'})
|
wpdbuser=$(docker exec $name cat /var/lib/mysql/creds |grep User| awk -F ": " {'print $2'})
|
||||||
wpdbpass=$(docker exec $name cat /var/lib/mysql/creds |grep Password| awk -F ": " {'print $2'})
|
wpdbpass=$(docker exec $name cat /var/lib/mysql/creds |grep Password| awk -F ": " {'print $2'})
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
resources:
|
|
||||||
- name: cac
|
|
||||||
type: git
|
|
||||||
source:
|
|
||||||
uri: https://repo.anhonesthost.net/cloud-hosting-platform/cloud-apache-container.git
|
|
||||||
branch: trunk
|
|
||||||
|
|
||||||
- name: build-cac-74
|
|
||||||
type: docker-image
|
|
||||||
source:
|
|
||||||
repository: registry.dnspegasus.net/cac
|
|
||||||
tag: 74
|
|
||||||
|
|
||||||
- name: build-cac-80
|
|
||||||
type: docker-image
|
|
||||||
source:
|
|
||||||
repository: registry.dnspegasus.net/cac
|
|
||||||
tag: 80
|
|
||||||
|
|
||||||
- name: build-cac-81
|
|
||||||
type: docker-image
|
|
||||||
source:
|
|
||||||
repository: registry.dnspegasus.net/cac
|
|
||||||
tag: 81
|
|
||||||
|
|
||||||
- name: build-cac-82
|
|
||||||
type: docker-image
|
|
||||||
source:
|
|
||||||
repository: registry.dnspegasus.net/cac
|
|
||||||
tag: 82
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
- name: publish-cac-74
|
|
||||||
plan:
|
|
||||||
- get: cac
|
|
||||||
trigger: true
|
|
||||||
- put: build-cac-74
|
|
||||||
params:
|
|
||||||
build: cac
|
|
||||||
build_args:
|
|
||||||
PHPVER: 74
|
|
||||||
- name: publish-cac-80
|
|
||||||
plan:
|
|
||||||
- get: cac
|
|
||||||
trigger: true
|
|
||||||
- put: build-cac-80
|
|
||||||
params:
|
|
||||||
build: cac
|
|
||||||
build_args:
|
|
||||||
PHPVER: 80
|
|
||||||
- name: publish-cac-81
|
|
||||||
plan:
|
|
||||||
- get: cac
|
|
||||||
trigger: true
|
|
||||||
- put: build-cac-81
|
|
||||||
params:
|
|
||||||
build: cac
|
|
||||||
build_args:
|
|
||||||
PHPVER: 81
|
|
||||||
- name: publish-cac-82
|
|
||||||
plan:
|
|
||||||
- get: cac
|
|
||||||
trigger: true
|
|
||||||
- put: build-cac-82
|
|
||||||
params:
|
|
||||||
build: cac
|
|
||||||
build_args:
|
|
||||||
PHPVER: 82
|
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cac-lsphp $_SERVER path normaliser (auto_prepend) — DEGRADED FALLBACK ONLY.
|
||||||
|
*
|
||||||
|
* SUPERSEDED by the cac_path_parity PHP extension (ext/cac-path-parity/), which
|
||||||
|
* does this from RINIT where a customer's .user.ini cannot displace it. The
|
||||||
|
* entrypoint only wires this file up when that extension is not loadable in the
|
||||||
|
* running image, and logs a WARNING when it does. Do not extend this script —
|
||||||
|
* fix the extension instead.
|
||||||
|
*
|
||||||
|
* It is kept because the flaw documented at the bottom of this docblock is
|
||||||
|
* exactly why the extension exists, and because an image where the extension
|
||||||
|
* failed to load should degrade to the old behaviour rather than to nothing.
|
||||||
|
*
|
||||||
|
* The shared-ols container serves from its bulk /docker/users->/mnt/users mount,
|
||||||
|
* so OLS sends lsphp $_SERVER['DOCUMENT_ROOT'] / ['SCRIPT_FILENAME'] under
|
||||||
|
* /mnt/users/<user>/<domain>/... . The sidecar symlinks that back to the real
|
||||||
|
* /home/<user> mount, so file operations resolve and PHP's own __FILE__/__DIR__/
|
||||||
|
* realpath()/getcwd() already report /home/<user>/public_html. But the RAW env
|
||||||
|
* strings OLS set still read /mnt/users, which would leak to the (uncommon) apps
|
||||||
|
* that build or compare paths from $_SERVER['DOCUMENT_ROOT'].
|
||||||
|
*
|
||||||
|
* Canonicalise those two via realpath() so cac-lsphp is byte-for-byte 1:1 with
|
||||||
|
* cac-fpm/cac-litespeed (where DOCUMENT_ROOT is natively /home/<user>/public_html).
|
||||||
|
* Cheap (two realpath calls, cached by realpath_cache) and side-effect-free.
|
||||||
|
*
|
||||||
|
* Customer sites have no auto_prepend by default, so this is the only prepend in
|
||||||
|
* play. If a site sets its own auto_prepend_file via .user.ini it overrides this
|
||||||
|
* (theirs wins) — acceptable: paths still resolve via the symlink, only the raw
|
||||||
|
* string differs.
|
||||||
|
*/
|
||||||
|
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $__cl_key) {
|
||||||
|
if (!empty($_SERVER[$__cl_key]) && strncmp($_SERVER[$__cl_key], '/mnt/users/', 11) === 0) {
|
||||||
|
$__cl_real = realpath($_SERVER[$__cl_key]);
|
||||||
|
if ($__cl_real !== false) {
|
||||||
|
$_SERVER[$__cl_key] = $__cl_real;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($__cl_key, $__cl_real);
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Generate Apache MPM event tuning config at runtime using detect-memory.sh values.
|
||||||
|
|
||||||
|
cat <<EOF > /etc/httpd/conf.d/mpm-tuning.conf
|
||||||
|
<IfModule mpm_event_module>
|
||||||
|
StartServers ${APACHE_START_SERVERS}
|
||||||
|
MinSpareThreads ${APACHE_MIN_SPARE_THREADS}
|
||||||
|
MaxSpareThreads ${APACHE_MAX_SPARE_THREADS}
|
||||||
|
ThreadLimit 64
|
||||||
|
ThreadsPerChild 25
|
||||||
|
MaxRequestWorkers ${APACHE_MAX_REQUEST_WORKERS}
|
||||||
|
ServerLimit ${APACHE_SERVER_LIMIT}
|
||||||
|
MaxConnectionsPerChild ${APACHE_MAX_CONNECTIONS_PER_CHILD}
|
||||||
|
</IfModule>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -2,27 +2,57 @@
|
|||||||
|
|
||||||
rm /etc/php-fpm.d/www.conf
|
rm /etc/php-fpm.d/www.conf
|
||||||
|
|
||||||
|
FPM_LISTEN=${FPM_LISTEN:-/run/php-fpm/www.sock}
|
||||||
|
|
||||||
|
# Determine listen directive and ownership based on socket vs TCP
|
||||||
|
if echo "$FPM_LISTEN" | grep -q '/'; then
|
||||||
|
# Unix socket mode (standalone — Apache and FPM in same container)
|
||||||
|
listen_directive="$FPM_LISTEN"
|
||||||
|
listen_owner_block="listen.owner = apache
|
||||||
|
listen.group = apache"
|
||||||
|
env_block=""
|
||||||
|
else
|
||||||
|
# TCP port mode (shared httpd — FPM in separate container)
|
||||||
|
listen_directive="0.0.0.0:${FPM_LISTEN}"
|
||||||
|
listen_owner_block=""
|
||||||
|
# Override DOCUMENT_ROOT so PHP plugins (e.g., WordFence) that use
|
||||||
|
# $_SERVER['DOCUMENT_ROOT'] find files at the FPM container's path,
|
||||||
|
# not the shared httpd's /mnt/users/ mount path.
|
||||||
|
env_block="env[DOCUMENT_ROOT] = /home/$user/public_html"
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<EOF > /etc/php-fpm.d/$user.conf
|
cat <<EOF > /etc/php-fpm.d/$user.conf
|
||||||
|
|
||||||
[$user]
|
[$user]
|
||||||
|
|
||||||
user = $user
|
user = $user
|
||||||
group = $user
|
group = $user
|
||||||
listen = /run/php-fpm/www.sock
|
listen = ${listen_directive}
|
||||||
listen.owner = apache
|
${listen_owner_block}
|
||||||
listen.group = apache
|
|
||||||
|
|
||||||
pm = static
|
pm = ${PHP_FPM_PM}
|
||||||
pm.max_children = 10
|
pm.max_children = ${PHP_FPM_MAX_CHILDREN}
|
||||||
pm.max_requests = 150
|
pm.max_requests = ${PHP_FPM_MAX_REQUESTS}
|
||||||
|
pm.process_idle_timeout = ${PHP_FPM_PROCESS_IDLE_TIMEOUT}
|
||||||
|
|
||||||
slowlog = /etc/httpd/logs/error_log
|
; Settings used when pm = dynamic (fallback if user overrides FPM_PM)
|
||||||
|
pm.start_servers = ${PHP_FPM_START_SERVERS}
|
||||||
|
pm.min_spare_servers = ${PHP_FPM_MIN_SPARE}
|
||||||
|
pm.max_spare_servers = ${PHP_FPM_MAX_SPARE}
|
||||||
|
|
||||||
|
; Health check endpoints
|
||||||
|
ping.path = /fpm-ping
|
||||||
|
ping.response = pong
|
||||||
|
pm.status_path = /fpm-status
|
||||||
|
|
||||||
|
slowlog = /home/$user/logs/php-fpm/slowlog
|
||||||
request_slowlog_timeout = 3s
|
request_slowlog_timeout = 3s
|
||||||
|
|
||||||
php_admin_value[error_log] = /etc/httpd/logs/error_log
|
php_admin_value[error_log] = /home/$user/logs/php-fpm/error.log
|
||||||
php_admin_flag[log_errors] = on
|
php_admin_flag[log_errors] = on
|
||||||
php_value[soap.wsdl_cache_dir] = /var/lib/php/wsdlcache
|
php_value[soap.wsdl_cache_dir] = /var/lib/php/wsdlcache
|
||||||
|
${env_block}
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## create-vhost-litespeed.sh — sets up OLS config for one customer site.
|
||||||
|
##
|
||||||
|
## Approach: keep the stock LiteSpeed-shipped httpd_config.conf VERBATIM
|
||||||
|
## (it has all the cgid/lscgid plumbing that lscgid needs to actually
|
||||||
|
## create its IPC socket), and just APPEND our listeners + vhTemplate.
|
||||||
|
## The custom vhost template lives at conf/templates/site.conf and points
|
||||||
|
## at /home/${user}/public_html. envsubst renders our user/domain into
|
||||||
|
## both files at container start.
|
||||||
|
##
|
||||||
|
## Expects in env: user, domain, serveralias (optional).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TPL_DIR=${TPL_DIR:-/etc/lsws-templates}
|
||||||
|
LSWS_CONF=/usr/local/lsws/conf
|
||||||
|
|
||||||
|
## Ensure the conf dir has stock config to append to. On first boot with
|
||||||
|
## a fresh image this is a no-op (image ships with conf/ populated). With
|
||||||
|
## a future volume mount of conf/, the upstream entrypoint pattern would
|
||||||
|
## copy from .conf/* — keep parity:
|
||||||
|
if [ -z "$(ls -A -- "$LSWS_CONF/" 2>/dev/null)" ]; then
|
||||||
|
cp -R /usr/local/lsws/.conf/* "$LSWS_CONF/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Build the serveralias suffix for vhDomain. Empty for none, else
|
||||||
|
## ",alias1,alias2" prepended to the comma list.
|
||||||
|
vhost_map_aliases=""
|
||||||
|
if [ -n "${serveralias:-}" ]; then
|
||||||
|
for alias in $(echo "$serveralias" | tr ',' ' '); do
|
||||||
|
[ -z "$alias" ] && continue
|
||||||
|
vhost_map_aliases="${vhost_map_aliases},${alias}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
export vhost_map_aliases user domain
|
||||||
|
|
||||||
|
## --- prep the stock httpd_config.conf before appending ours ---
|
||||||
|
## Stock ships with `listener HTTP {*:80}`, `listener HTTPS {*:443}`, and
|
||||||
|
## a `vhTemplate docker` mapped to /var/www/vhosts/$VH_NAME/html — these
|
||||||
|
## conflict with our ports and would shadow our siteVH vhost. Strip them
|
||||||
|
## and the demo `virtualHost Example`, but KEEP `listener Default` (it's
|
||||||
|
## bound to 8088 — harmless internally, removing risks unrelated breakage).
|
||||||
|
## Always restart from a stock copy so re-runs are idempotent (otherwise
|
||||||
|
## a second sed pass on already-stripped config corrupts it).
|
||||||
|
cp /usr/local/lsws/.conf/httpd_config.conf "$LSWS_CONF/httpd_config.conf"
|
||||||
|
|
||||||
|
## Strip the stock blocks we replace. Use awk: easier than sed range-deletes
|
||||||
|
## to skip a NAMED block of arbitrary length terminated by a top-level `}`.
|
||||||
|
## extProcessor lsphp is stripped because the stock one hard-codes
|
||||||
|
## PHP_LSAPI_CHILDREN=10 regardless of container size — our appended
|
||||||
|
## extProcessor scales it from detect-memory-litespeed.sh.
|
||||||
|
awk '
|
||||||
|
BEGIN { skip = 0 }
|
||||||
|
/^listener HTTP \{/ || /^listener HTTPS \{/ || /^vhTemplate docker \{/ || /^extProcessor lsphp\{/ || /^extProcessor lsphp \{/ { skip = 1; next }
|
||||||
|
skip && /^\}/ { skip = 0; next }
|
||||||
|
!skip { print }
|
||||||
|
' "$LSWS_CONF/httpd_config.conf" > "$LSWS_CONF/httpd_config.conf.new"
|
||||||
|
mv "$LSWS_CONF/httpd_config.conf.new" "$LSWS_CONF/httpd_config.conf"
|
||||||
|
|
||||||
|
## Server-level user/group → customer. Without this, OLS runs as nobody and
|
||||||
|
## either can't read customer files (no setUIDMode) or has to lscgid-spawn a
|
||||||
|
## per-uid lsphp for every httpd worker (the setUIDMode 2 pathway). With OLS
|
||||||
|
## itself running as ${user}, a single shared lsphp parent serves all httpd
|
||||||
|
## workers, LSAPI children-mode actually engages, and shmem stops fanning out.
|
||||||
|
## OLS still starts as root (PID 1 binds 80/443) then drops privs after bind.
|
||||||
|
sed -i \
|
||||||
|
-e "s|^user[[:space:]].*|user ${user}|" \
|
||||||
|
-e "s|^group[[:space:]].*|group ${user}|" \
|
||||||
|
"$LSWS_CONF/httpd_config.conf"
|
||||||
|
|
||||||
|
## --- append our listeners + vhTemplate ---
|
||||||
|
SENTINEL="## ---- cac-litespeed append (do not edit below) ----"
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "$SENTINEL"
|
||||||
|
envsubst '${user} ${domain} ${vhost_map_aliases} ${PHPVER} ${LSAPI_CHILDREN}' < "$TPL_DIR/httpd_config.tpl"
|
||||||
|
} >> "$LSWS_CONF/httpd_config.conf"
|
||||||
|
|
||||||
|
## --- write our vhost template to /usr/local/lsws/conf/templates/site.conf ---
|
||||||
|
envsubst '${user}' < "$TPL_DIR/site-template.tpl" \
|
||||||
|
> "$LSWS_CONF/templates/site.conf"
|
||||||
|
|
||||||
|
## --- per-vhost config file the vhTemplate will reference ---
|
||||||
|
## OLS creates conf/vhosts/$VH_NAME/ at template-instantiation time, but
|
||||||
|
## we pre-create it to satisfy the configFile path and write a minimal
|
||||||
|
## vhconf.conf (empty body — all real config is inline in the template's
|
||||||
|
## virtualHostConfig{} block).
|
||||||
|
mkdir -p "$LSWS_CONF/vhosts/siteVH"
|
||||||
|
echo "## auto-generated; real vhost config is in templates/site.conf" \
|
||||||
|
> "$LSWS_CONF/vhosts/siteVH/vhconf.conf"
|
||||||
|
|
||||||
|
## Permissions: OLS reads conf/ as lsadm. Don't break that.
|
||||||
|
chown -R lsadm:nogroup "$LSWS_CONF" 2>/dev/null || true
|
||||||
@@ -34,6 +34,12 @@ cat <<EOF > /etc/httpd/conf.d/$domain.conf
|
|||||||
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
|
||||||
|
Listen 443 https
|
||||||
|
SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
|
||||||
|
SSLSessionCache shmcb:/run/httpd/sslcache(512000)
|
||||||
|
SSLSessionCacheTimeout 300
|
||||||
|
SSLCryptoDevice builtin
|
||||||
|
|
||||||
<IfModule mod_ssl.c>
|
<IfModule mod_ssl.c>
|
||||||
<VirtualHost _default_:443>
|
<VirtualHost _default_:443>
|
||||||
ServerName "$domain"
|
ServerName "$domain"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## detect-memory-litespeed.sh — sibling to detect-memory.sh.
|
||||||
|
## Computes LSAPI_CHILDREN + extprocessor memSoftLimit/memHardLimit from
|
||||||
|
## container memory cap. Sourced by entrypoint-litespeed.sh.
|
||||||
|
|
||||||
|
## ---- container memory detection (mirrors detect-memory.sh) ----
|
||||||
|
CONTAINER_MEMORY_BYTES=""
|
||||||
|
|
||||||
|
if [ -f /sys/fs/cgroup/memory.max ]; then
|
||||||
|
val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
|
||||||
|
if [ "$val" != "max" ] && [ -n "$val" ]; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$val
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
|
||||||
|
val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
|
||||||
|
if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$val
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then
|
||||||
|
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
|
||||||
|
if [ -n "$mem_kb" ]; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$((mem_kb * 1024))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONTAINER_MEMORY_BYTES" ]; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024))
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
|
||||||
|
|
||||||
|
## ---- budget split (LSAPI workers get the lion's share) ----
|
||||||
|
OS_RESERVE_MB=50
|
||||||
|
OLS_RESERVE_MB=40 # OpenLiteSpeed daemon footprint
|
||||||
|
DEV_OVERHEAD_MB=0
|
||||||
|
if [ "${environment:-PROD}" = "DEV" ]; then
|
||||||
|
DEV_OVERHEAD_MB=125
|
||||||
|
fi
|
||||||
|
|
||||||
|
AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - OLS_RESERVE_MB - DEV_OVERHEAD_MB))
|
||||||
|
if [ "$AVAILABLE_MB" -lt 60 ]; then
|
||||||
|
AVAILABLE_MB=60
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---- LSAPI children (analogous to PHP_FPM_MAX_CHILDREN) ----
|
||||||
|
## Per the 2026-06-02 cac-litespeed memory-sizing finding (vantagehealth
|
||||||
|
## OOM-spawn loop at 512 MB cap): each lsphp worker carries ~115 MB
|
||||||
|
## shmem-rss + ~25 MB anon + ~10 MB file ≈ 130-150 MB real cgroup cost
|
||||||
|
## per worker on heavy WP workloads. shmem is RSS-accounted per worker
|
||||||
|
## (vs cac-fpm's COW-shared fork model) so the cost is real per cgroup,
|
||||||
|
## not just per process.
|
||||||
|
##
|
||||||
|
## 115 (the previous default) was set from idle-state measurements and
|
||||||
|
## ran brain-jar.com into 142 OOM-kills at 1 GiB on 2026-06-02 night —
|
||||||
|
## the formula computed CHILDREN=8, which left zero headroom once Divi
|
||||||
|
## page renders started growing worker anon. Bumped to 130 to track the
|
||||||
|
## active per-worker cost; gives slightly fewer workers but real headroom.
|
||||||
|
##
|
||||||
|
## Sub-512 MB containers remain unsafe for dynamic WP on OLS — the floor
|
||||||
|
## of 2 workers still applies but it'll be cap-marginal. Per-site override
|
||||||
|
## via FPM_MAX_CHILDREN env var (panel edit-site UI) overrides this for
|
||||||
|
## sites where the default isn't right for their workload.
|
||||||
|
LSPHP_WORKER_ESTIMATE_MB=${LSPHP_WORKER_ESTIMATE_MB:-130}
|
||||||
|
|
||||||
|
calc_lsapi_children=$((AVAILABLE_MB / LSPHP_WORKER_ESTIMATE_MB))
|
||||||
|
if [ "$calc_lsapi_children" -lt 2 ]; then
|
||||||
|
calc_lsapi_children=2
|
||||||
|
fi
|
||||||
|
if [ "$calc_lsapi_children" -gt 50 ]; then
|
||||||
|
calc_lsapi_children=50
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Per-site override knobs — site-pool-env.php still passes FPM_MAX_CHILDREN
|
||||||
|
## for backward compat, so prefer LSAPI_CHILDREN if set, else FPM_MAX_CHILDREN,
|
||||||
|
## else the calculated value.
|
||||||
|
LSAPI_CHILDREN=${LSAPI_CHILDREN:-${FPM_MAX_CHILDREN:-$calc_lsapi_children}}
|
||||||
|
|
||||||
|
## Per-worker mem limits (RLIMIT_AS) live in httpd_config.tpl now as
|
||||||
|
## hard-coded 1024M soft / 1500M hard — those values comfortably fit
|
||||||
|
## typical Divi/WooCommerce VSZ (~280-365 MB) while still catching a
|
||||||
|
## true runaway script. Cgroup remains the real backstop. The earlier
|
||||||
|
## AVAILABLE/CHILDREN formula was killing legitimate workers because
|
||||||
|
## it conflated VSZ (RLIMIT_AS) with RSS-budget arithmetic.
|
||||||
|
|
||||||
|
export CONTAINER_MEMORY_MB LSAPI_CHILDREN
|
||||||
@@ -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
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# detect-memory.sh — Detect container memory and calculate tuning parameters.
|
||||||
|
# Must be sourced (not executed) so variables are available to the caller.
|
||||||
|
|
||||||
|
# --- Memory detection (cgroups v2 → v1 → /proc/meminfo → fallback) ---
|
||||||
|
CONTAINER_MEMORY_BYTES=""
|
||||||
|
|
||||||
|
# cgroups v2
|
||||||
|
if [ -f /sys/fs/cgroup/memory.max ]; then
|
||||||
|
val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
|
||||||
|
if [ "$val" != "max" ] && [ -n "$val" ]; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$val
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# cgroups v1
|
||||||
|
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
|
||||||
|
val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
|
||||||
|
# Values near page-aligned max (like 9223372036854771712) mean "no limit"
|
||||||
|
if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$val
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# /proc/meminfo (host memory — used when no cgroup limit is set)
|
||||||
|
if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then
|
||||||
|
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
|
||||||
|
if [ -n "$mem_kb" ]; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$((mem_kb * 1024))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
if [ -z "$CONTAINER_MEMORY_BYTES" ]; then
|
||||||
|
CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024))
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
|
||||||
|
|
||||||
|
# --- Budget calculation ---
|
||||||
|
CONTAINER_ROLE=${CONTAINER_ROLE:-combined} # combined | fpm_only | httpd_only
|
||||||
|
|
||||||
|
OS_RESERVE_MB=50
|
||||||
|
FIXED_PROCESS_MB=50
|
||||||
|
DEV_OVERHEAD_MB=0
|
||||||
|
if [ "$environment" = "DEV" ]; then
|
||||||
|
DEV_OVERHEAD_MB=125
|
||||||
|
fi
|
||||||
|
|
||||||
|
AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - FIXED_PROCESS_MB - DEV_OVERHEAD_MB))
|
||||||
|
if [ "$AVAILABLE_MB" -lt 60 ]; then
|
||||||
|
AVAILABLE_MB=60
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$CONTAINER_ROLE" in
|
||||||
|
fpm_only)
|
||||||
|
PHP_BUDGET_MB=$AVAILABLE_MB
|
||||||
|
APACHE_BUDGET_MB=0
|
||||||
|
;;
|
||||||
|
httpd_only)
|
||||||
|
PHP_BUDGET_MB=0
|
||||||
|
APACHE_BUDGET_MB=$AVAILABLE_MB
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
PHP_BUDGET_MB=$((AVAILABLE_MB * 80 / 100))
|
||||||
|
APACHE_BUDGET_MB=$((AVAILABLE_MB * 20 / 100))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- PHP-FPM parameters (skipped for httpd_only) ---
|
||||||
|
if [ "$CONTAINER_ROLE" != "httpd_only" ]; then
|
||||||
|
# PHP_WORKER_ESTIMATE_MB sizes the divisor for pm.max_children. The
|
||||||
|
# previous default of 60 was optimistic for modern Woo/Elementor stacks:
|
||||||
|
# the alphaone 2026-06-01 incident measured ~193 MB resident per worker
|
||||||
|
# against the 60 MB assumption, and 15 calculated children put peak
|
||||||
|
# demand (15 * 193 = 2.9 GB) over the 1-2 GiB container cap. 128 lands
|
||||||
|
# closer to plugin-heavy WP reality while remaining conservative for
|
||||||
|
# leaner sites. Customers can still override via the FPM_MAX_CHILDREN
|
||||||
|
# env var on the container if a different shape is justified.
|
||||||
|
PHP_WORKER_ESTIMATE_MB=${PHP_WORKER_ESTIMATE_MB:-128}
|
||||||
|
|
||||||
|
calc_max_children=$((PHP_BUDGET_MB / PHP_WORKER_ESTIMATE_MB))
|
||||||
|
# Floor at 2, cap at 50
|
||||||
|
if [ "$calc_max_children" -lt 2 ]; then
|
||||||
|
calc_max_children=2
|
||||||
|
fi
|
||||||
|
if [ "$calc_max_children" -gt 50 ]; then
|
||||||
|
calc_max_children=50
|
||||||
|
fi
|
||||||
|
|
||||||
|
PHP_FPM_PM=${FPM_PM:-ondemand}
|
||||||
|
PHP_FPM_MAX_CHILDREN=${FPM_MAX_CHILDREN:-$calc_max_children}
|
||||||
|
PHP_FPM_PROCESS_IDLE_TIMEOUT=${FPM_PROCESS_IDLE_TIMEOUT:-5s}
|
||||||
|
PHP_FPM_MAX_REQUESTS=${FPM_MAX_REQUESTS:-200}
|
||||||
|
|
||||||
|
# Dynamic mode fallbacks (used if user overrides FPM_PM=dynamic)
|
||||||
|
PHP_FPM_START_SERVERS=${FPM_START_SERVERS:-2}
|
||||||
|
PHP_FPM_MIN_SPARE=${FPM_MIN_SPARE_SERVERS:-1}
|
||||||
|
PHP_FPM_MAX_SPARE=${FPM_MAX_SPARE_SERVERS:-3}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Apache MPM parameters (skipped for fpm_only) ---
|
||||||
|
if [ "$CONTAINER_ROLE" != "fpm_only" ]; then
|
||||||
|
# ServerLimit: roughly 1 process per ~25 workers, floor 2, cap 16
|
||||||
|
calc_server_limit=$((APACHE_BUDGET_MB / 30))
|
||||||
|
if [ "$calc_server_limit" -lt 2 ]; then
|
||||||
|
calc_server_limit=2
|
||||||
|
fi
|
||||||
|
if [ "$calc_server_limit" -gt 16 ]; then
|
||||||
|
calc_server_limit=16
|
||||||
|
fi
|
||||||
|
|
||||||
|
# MaxRequestWorkers: ServerLimit * ThreadsPerChild (25)
|
||||||
|
calc_max_request_workers=$((calc_server_limit * 25))
|
||||||
|
if [ "$calc_max_request_workers" -gt 400 ]; then
|
||||||
|
calc_max_request_workers=400
|
||||||
|
fi
|
||||||
|
|
||||||
|
# StartServers: 1 for ≤1GB, 2 for larger
|
||||||
|
calc_start_servers=1
|
||||||
|
if [ "$CONTAINER_MEMORY_MB" -gt 1024 ]; then
|
||||||
|
calc_start_servers=2
|
||||||
|
fi
|
||||||
|
|
||||||
|
APACHE_START_SERVERS=${APACHE_START_SERVERS:-$calc_start_servers}
|
||||||
|
APACHE_SERVER_LIMIT=${APACHE_SERVER_LIMIT:-$calc_server_limit}
|
||||||
|
APACHE_MAX_REQUEST_WORKERS=${APACHE_MAX_REQUEST_WORKERS:-$calc_max_request_workers}
|
||||||
|
APACHE_MIN_SPARE_THREADS=${APACHE_MIN_SPARE_THREADS:-5}
|
||||||
|
APACHE_MAX_SPARE_THREADS=${APACHE_MAX_SPARE_THREADS:-15}
|
||||||
|
APACHE_MAX_CONNECTIONS_PER_CHILD=${APACHE_MAX_CONNECTIONS_PER_CHILD:-3000}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Export all variables ---
|
||||||
|
export CONTAINER_ROLE CONTAINER_MEMORY_MB
|
||||||
|
if [ "$CONTAINER_ROLE" != "httpd_only" ]; then
|
||||||
|
export PHP_FPM_PM PHP_FPM_MAX_CHILDREN PHP_FPM_PROCESS_IDLE_TIMEOUT PHP_FPM_MAX_REQUESTS
|
||||||
|
export PHP_FPM_START_SERVERS PHP_FPM_MIN_SPARE PHP_FPM_MAX_SPARE
|
||||||
|
fi
|
||||||
|
if [ "$CONTAINER_ROLE" != "fpm_only" ]; then
|
||||||
|
export APACHE_START_SERVERS APACHE_SERVER_LIMIT APACHE_MAX_REQUEST_WORKERS
|
||||||
|
export APACHE_MIN_SPARE_THREADS APACHE_MAX_SPARE_THREADS APACHE_MAX_CONNECTIONS_PER_CHILD
|
||||||
|
fi
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if [ -z "$PHPVER" ]; then
|
||||||
|
PHPVER="83";
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$environment" ]; then
|
||||||
|
environment="PROD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Default to FPM-only role
|
||||||
|
export CONTAINER_ROLE="fpm_only"
|
||||||
|
export FPM_LISTEN=${FPM_LISTEN:-9000}
|
||||||
|
|
||||||
|
adduser -u $uid $user
|
||||||
|
|
||||||
|
mkdir -p /home/$user/public_html
|
||||||
|
mkdir -p /home/$user/logs/php-fpm
|
||||||
|
|
||||||
|
ln -sf /home/$user/logs/php-fpm /var/log/php-fpm
|
||||||
|
|
||||||
|
source /scripts/detect-memory.sh
|
||||||
|
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_children=${PHP_FPM_MAX_CHILDREN} | Listen=${FPM_LISTEN}"
|
||||||
|
|
||||||
|
/scripts/create-php-config.sh
|
||||||
|
|
||||||
|
mkdir -p /run/php-fpm/
|
||||||
|
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
||||||
|
chown -R $user:$user /home/$user
|
||||||
|
chmod -R 755 /home/$user
|
||||||
|
|
||||||
|
if [[ $environment == 'DEV' ]]; then
|
||||||
|
echo "Starting Dev Deployment (FPM-only mode)"
|
||||||
|
mkdir -p /home/$user/_db_backups
|
||||||
|
if ! command -v microdnf &> /dev/null; then
|
||||||
|
echo "microdnf not found, installing with dnf..."
|
||||||
|
dnf install -y microdnf && dnf clean all
|
||||||
|
fi
|
||||||
|
microdnf install -y MariaDB-server MariaDB-client memcached
|
||||||
|
sed -r -i 's/session.save_path="memcache:11211/session.save_path="localhost:11211/' /etc/php.ini
|
||||||
|
nohup mysqld -umysql &
|
||||||
|
if [ ! -f /home/$user/mysql_creds ]; then
|
||||||
|
echo "Give MySQL a chance to finish starting..."
|
||||||
|
sleep 10
|
||||||
|
mysql_user=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')
|
||||||
|
mysql_password=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 18 ; echo '')
|
||||||
|
mysql_db=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 6 ; echo '')
|
||||||
|
mysql -e "CREATE DATABASE devdb_"$mysql_db";"
|
||||||
|
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
||||||
|
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
||||||
|
mysql -e "FLUSH PRIVILEGES;"
|
||||||
|
echo "# User crontab for $user" > /home/$user/crontab
|
||||||
|
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
|
||||||
|
chown $user:$user /home/$user/crontab
|
||||||
|
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
|
||||||
|
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
|
||||||
|
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
|
||||||
|
cat /home/$user/mysql_creds
|
||||||
|
fi
|
||||||
|
/usr/bin/memcached -d -u $user
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $environment == 'PROD' ]]; then
|
||||||
|
if [ -f /etc/php.d/50-memcached.ini ]; then
|
||||||
|
sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set up user crontab
|
||||||
|
if [ ! -f /home/$user/crontab ]; then
|
||||||
|
echo "# User crontab for $user" > /home/$user/crontab
|
||||||
|
echo "# Add your cron jobs here" >> /home/$user/crontab
|
||||||
|
echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
|
||||||
|
chown $user:$user /home/$user/crontab
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load user crontab
|
||||||
|
crontab -u $user /home/$user/crontab
|
||||||
|
|
||||||
|
/usr/sbin/crond
|
||||||
|
|
||||||
|
# Tail PHP-FPM logs (becomes PID 1 process)
|
||||||
|
touch /home/$user/logs/php-fpm/error.log
|
||||||
|
tail -f /home/$user/logs/php-fpm/*
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## entrypoint-litespeed.sh — PID 1 for cac-litespeed:phpNN.
|
||||||
|
## Built on litespeedtech/openlitespeed:1.8.x-lsphp83 prebuilt base. Native
|
||||||
|
## LSAPI (no FPM proxy), one customer per container.
|
||||||
|
##
|
||||||
|
## Process supervision: starts OLS via `openlitespeed -n` (no-daemon +
|
||||||
|
## crash-guard, per OLS source: lshttpdmain.cpp). SIGTERM is forwarded.
|
||||||
|
## crond runs in the background for customer crontabs; OLS itself is the
|
||||||
|
## process we wait on (if OLS dies, the container exits and Docker
|
||||||
|
## restarts it per its restart policy).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${PHPVER:=83}"
|
||||||
|
: "${environment:=PROD}"
|
||||||
|
: "${LSCACHE_AUTOINSTALL:=1}"
|
||||||
|
|
||||||
|
export CONTAINER_ROLE="litespeed_only"
|
||||||
|
export PHPVER environment LSCACHE_AUTOINSTALL
|
||||||
|
|
||||||
|
## ---- env validation ----
|
||||||
|
if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then
|
||||||
|
echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
: "${domain:=localhost}"
|
||||||
|
export user domain
|
||||||
|
|
||||||
|
## ---- user + directories ----
|
||||||
|
if ! id -u "$user" >/dev/null 2>&1; then
|
||||||
|
## Ubuntu's useradd; mirror what the AL10 entrypoints do with adduser
|
||||||
|
useradd -u "$uid" -m -s /bin/bash "$user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "/home/$user/public_html"
|
||||||
|
## Log dirs mirror cac:phpNN exactly — apache/ for web server access+error,
|
||||||
|
## php-fpm/ for PHP errors. OLS isn't Apache and lsphp isn't php-fpm, but
|
||||||
|
## the customer-facing paths stay identical so log-gathering, analytics,
|
||||||
|
## and the customer's "where do I find my access log?" mental model all
|
||||||
|
## just work without per-image-family special cases.
|
||||||
|
mkdir -p "/home/$user/logs/apache" "/home/$user/logs/php-fpm"
|
||||||
|
mkdir -p "/home/$user/lscache"
|
||||||
|
|
||||||
|
mkdir -p /tmp/lshttpd/swap
|
||||||
|
chmod 1777 /tmp/lshttpd
|
||||||
|
|
||||||
|
## ---- memory + lsphp pool sizing ----
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source /scripts/detect-memory-litespeed.sh
|
||||||
|
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | LSAPI_CHILDREN=${LSAPI_CHILDREN} | PHPVER=${PHPVER}"
|
||||||
|
|
||||||
|
## ---- self-signed cert (idempotent) ----
|
||||||
|
mkdir -p /usr/local/lsws/conf/cert
|
||||||
|
if [ ! -f /usr/local/lsws/conf/cert/self.crt ]; then
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||||
|
-keyout /usr/local/lsws/conf/cert/self.key \
|
||||||
|
-out /usr/local/lsws/conf/cert/self.crt \
|
||||||
|
-subj "/CN=${domain}" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---- render httpd_config + vhconf from templates ----
|
||||||
|
/scripts/create-vhost-litespeed.sh
|
||||||
|
|
||||||
|
## ---- point PHP error_log at the same customer-visible path that
|
||||||
|
## cac:phpNN uses for php-fpm errors. Drop-in compat: customer code that
|
||||||
|
## was tailing /home/$user/logs/php-fpm/error.log on the old image will
|
||||||
|
## see lsphp's PHP errors in the exact same file on the new image.
|
||||||
|
## Rendered as a tiny ini in lsphp's scan dir; PHP merges it after the
|
||||||
|
## production-tuning overrides at startup.
|
||||||
|
## Captured, then matched in the shell. As a single pipeline this was
|
||||||
|
## `lsphp -i | awk '…{print;exit}'`: awk stops at the "Scan this dir" line,
|
||||||
|
## which is near the top of the output, so lsphp can still be writing when awk
|
||||||
|
## closes the pipe. lsphp then dies 141, `set -o pipefail` (line 12) makes that
|
||||||
|
## the pipeline's status, and because this is a BARE ASSIGNMENT `set -e` KILLS
|
||||||
|
## PID 1 — the container never starts. (Its twin in entrypoint-lsphp.sh chose a
|
||||||
|
## degraded fallback instead; this one just exits.) That is a race on whether
|
||||||
|
## the reader closes before the writer's last write() returns, not a function of
|
||||||
|
## how big the output is: see the long note over the probe helpers in
|
||||||
|
## entrypoint-lsphp.sh for the measurements. The rule is simply that no
|
||||||
|
## `writer | early-exiting-reader` belongs in a pipefail script.
|
||||||
|
##
|
||||||
|
## A here-string would remove the pipeline, but bash spills a here-string to
|
||||||
|
## /tmp/sh-thd.XXXXXX above a build-dependent size (65536 for the bash 5.2.21 in
|
||||||
|
## this image, between 4096 and 16384 for Debian's 5.2.15) — and on this line,
|
||||||
|
## a bare assignment, a temp file it cannot create is again `set -e` killing
|
||||||
|
## PID 1: `docker run --read-only` reproduces exactly that. So the extraction is
|
||||||
|
## done with parameter expansion, which allocates nothing.
|
||||||
|
##
|
||||||
|
## Same answer as the awk it replaces: first line starting "Scan this dir", then
|
||||||
|
## the text between the FIRST and SECOND '=> ' on it (awk's $2 under -F'=> '),
|
||||||
|
## empty if the line carries no separator, empty if there is no such line.
|
||||||
|
## `|| true` on the capture keeps a genuinely failing lsphp as an empty
|
||||||
|
## SCAN_DIR — which the `-n` test below already handles — not a boot failure.
|
||||||
|
LSPHP_INFO=$(/usr/local/lsws/lsphp"${PHPVER}"/bin/lsphp -i 2>/dev/null || true)
|
||||||
|
SCAN_DIR=""
|
||||||
|
## The leading newline is what makes a match on LINE 1 behave like every other
|
||||||
|
## line, exactly as awk's `^` anchor does.
|
||||||
|
scan_rest=$'\n'"$LSPHP_INFO"
|
||||||
|
if [[ $scan_rest == *$'\nScan this dir'* ]]; then
|
||||||
|
## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`.
|
||||||
|
scan_rest=${scan_rest#*$'\nScan this dir'}
|
||||||
|
scan_line="Scan this dir${scan_rest%%$'\n'*}"
|
||||||
|
if [[ $scan_line == *'=> '* ]]; then
|
||||||
|
SCAN_DIR=${scan_line#*'=> '}
|
||||||
|
SCAN_DIR=${SCAN_DIR%%'=> '*}
|
||||||
|
fi
|
||||||
|
unset scan_line
|
||||||
|
fi
|
||||||
|
unset scan_rest
|
||||||
|
if [ -n "$SCAN_DIR" ]; then
|
||||||
|
cat > "$SCAN_DIR/99-user-error-log.ini" <<EOF
|
||||||
|
; rendered at container start by entrypoint-litespeed.sh
|
||||||
|
error_log = /home/${user}/logs/php-fpm/error.log
|
||||||
|
log_errors = On
|
||||||
|
EOF
|
||||||
|
|
||||||
|
## Per-site opcache override (panel: Advanced Tuning → OpCache size).
|
||||||
|
## Falls back to the global lsphp-overrides.ini values (64 MB / 8000 files)
|
||||||
|
## when the env vars aren't set. Numeric range/sanity is enforced in the
|
||||||
|
## WHP panel before the env var lands here.
|
||||||
|
if [ -n "${OPCACHE_MEMORY_MB:-}" ] || [ -n "${OPCACHE_MAX_FILES:-}" ]; then
|
||||||
|
{
|
||||||
|
echo "; rendered at container start by entrypoint-litespeed.sh"
|
||||||
|
echo "; per-site override from WHP whp.sites.opcache_*_override"
|
||||||
|
[ -n "${OPCACHE_MEMORY_MB:-}" ] && echo "opcache.memory_consumption = ${OPCACHE_MEMORY_MB}"
|
||||||
|
[ -n "${OPCACHE_MAX_FILES:-}" ] && echo "opcache.max_accelerated_files = ${OPCACHE_MAX_FILES}"
|
||||||
|
} > "$SCAN_DIR/99-user-opcache.ini"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---- ownership: OLS runs as $user end-to-end (server-level user set by
|
||||||
|
## create-vhost-litespeed.sh, no setUIDMode). So OLS runtime dirs need to
|
||||||
|
## be customer-owned for log writes, swap files, lsphp socket creation.
|
||||||
|
## Master still starts as root for port binding, then drops privs to $user.
|
||||||
|
chown -R "$user:$user" /usr/local/lsws/logs /usr/local/lsws/conf/cert /tmp/lshttpd 2>/dev/null || true
|
||||||
|
chown -R "$user:$user" "/home/$user"
|
||||||
|
chmod 755 "/home/$user"
|
||||||
|
|
||||||
|
## ---- drop healthz so docker HEALTHCHECK passes before customer files
|
||||||
|
## Always rewrite as customer; suexec lsphp will read it as that uid too.
|
||||||
|
sudo -u "$user" sh -c "echo ok > /home/$user/public_html/healthz"
|
||||||
|
|
||||||
|
## ---- DEV: local mariadb + memcached for parity with cac entrypoints ----
|
||||||
|
if [ "$environment" = "DEV" ]; then
|
||||||
|
echo "Starting Dev Deployment (litespeed)"
|
||||||
|
mkdir -p "/home/$user/_db_backups"
|
||||||
|
## mariadb-server + memcached are NOT baked into the image (saves ~500MB
|
||||||
|
## on PROD pulls). Install them at runtime, but only once per container —
|
||||||
|
## the command -v guard means a restart of an already-bootstrapped
|
||||||
|
## container skips the apt step and DEV boot stays ~1.5s like PROD.
|
||||||
|
## First-boot in DEV adds ~30-60s for the apt install; acceptable
|
||||||
|
## tradeoff per the design spec.
|
||||||
|
if ! command -v mysqld >/dev/null 2>&1; then
|
||||||
|
echo "DEV first boot: installing mariadb-server + memcached..."
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
mariadb-server memcached
|
||||||
|
apt-get clean
|
||||||
|
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||||
|
fi
|
||||||
|
mkdir -p /run/mysqld && chown mysql:mysql /run/mysqld
|
||||||
|
nohup mysqld --user=mysql &>/dev/null &
|
||||||
|
if [ ! -f "/home/$user/mysql_creds" ]; then
|
||||||
|
sleep 10
|
||||||
|
mysql_user=$(openssl rand -hex 7)
|
||||||
|
mysql_password=$(openssl rand -hex 12)
|
||||||
|
mysql_db="devdb_$(openssl rand -hex 3)"
|
||||||
|
mysql -e "CREATE DATABASE $mysql_db;"
|
||||||
|
mysql -e "CREATE USER '$mysql_user'@'localhost' IDENTIFIED BY '$mysql_password';"
|
||||||
|
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '$mysql_user'@'localhost' WITH GRANT OPTION;"
|
||||||
|
mysql -e "FLUSH PRIVILEGES;"
|
||||||
|
{
|
||||||
|
echo "MySQL User: $mysql_user"
|
||||||
|
echo "MySQL Password: $mysql_password"
|
||||||
|
echo "MySQL Database: $mysql_db"
|
||||||
|
} > "/home/$user/mysql_creds"
|
||||||
|
cat "/home/$user/mysql_creds"
|
||||||
|
fi
|
||||||
|
/usr/bin/memcached -d -u "$user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---- user crontab ----
|
||||||
|
if [ ! -f "/home/$user/crontab" ]; then
|
||||||
|
{
|
||||||
|
echo "# User crontab for $user"
|
||||||
|
echo "# Add your cron jobs here"
|
||||||
|
} > "/home/$user/crontab"
|
||||||
|
chown "$user:$user" "/home/$user/crontab"
|
||||||
|
fi
|
||||||
|
crontab -u "$user" "/home/$user/crontab"
|
||||||
|
service cron start >/dev/null 2>&1 || /usr/sbin/cron
|
||||||
|
|
||||||
|
## ---- LSCache plugin (background, non-fatal) ----
|
||||||
|
( /scripts/install-lscache-wp.sh "$user" >>/var/log/lscache-install.log 2>&1 || true ) &
|
||||||
|
|
||||||
|
## Stream OLS + customer logs to PID-1 stdout so `docker logs` works. Started
|
||||||
|
## once, before the supervisor loop — it follows the files across OLS restarts.
|
||||||
|
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
|
||||||
|
touch "/home/$user/logs/apache/error_log" "/home/$user/logs/apache/access_log"
|
||||||
|
touch "/home/$user/logs/php-fpm/error.log"
|
||||||
|
chown "$user:$user" "/home/$user/logs/apache/error_log" \
|
||||||
|
"/home/$user/logs/apache/access_log" \
|
||||||
|
"/home/$user/logs/php-fpm/error.log"
|
||||||
|
tail -F /usr/local/lsws/logs/error.log \
|
||||||
|
/usr/local/lsws/logs/access.log \
|
||||||
|
"/home/$user/logs/apache/error_log" \
|
||||||
|
"/home/$user/logs/apache/access_log" \
|
||||||
|
"/home/$user/logs/php-fpm/error.log" 2>/dev/null &
|
||||||
|
|
||||||
|
## ---- supervise OLS in DAEMON mode (NOT `openlitespeed -n` + wait) ----
|
||||||
|
## OLS performs INTERNAL graceful self-restarts: the LiteSpeed Cache /
|
||||||
|
## QUIC.cloud integration refreshes the QUIC.cloud IP allowlist on a schedule
|
||||||
|
## and, when it changes, sends SIGUSR1 → "request a graceful server restart".
|
||||||
|
## In `-n` foreground mode the OLD main PID exits after the zero-downtime
|
||||||
|
## handoff; a bare `wait` on that PID lets bash (PID 1) exit and tears the whole
|
||||||
|
## container down. Worse, that exit is *clean*, so `RestartPolicy` doesn't
|
||||||
|
## reliably catch it — the container just stops and HAProxy serves 503 until
|
||||||
|
## someone manually starts it. (Root-caused on whp02 alsacorp, 2026-06-06.)
|
||||||
|
##
|
||||||
|
## Daemon mode is OLS's native model: it owns the SIGUSR1 handoff, keeps the
|
||||||
|
## listeners bound across generations, and rewrites lshttpd.pid to the new main.
|
||||||
|
## PID 1 just FOLLOWS the pidfile — a graceful self-restart is invisible here
|
||||||
|
## (zero downtime), and we only ever relaunch on a genuine crash (no live main).
|
||||||
|
STOP_REQUESTED=0
|
||||||
|
term_handler() {
|
||||||
|
STOP_REQUESTED=1
|
||||||
|
/usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap term_handler TERM INT
|
||||||
|
|
||||||
|
## Authoritative, path-independent liveness check: `lswsctrl status` prints
|
||||||
|
## "litespeed is running with PID N." when up (and "...is not running" when
|
||||||
|
## down). We match the running message specifically — a bare grep for "running"
|
||||||
|
## would also match "not running". (This image keeps the pidfile under
|
||||||
|
## /tmp/lshttpd, not logs/, so we never hard-code a pidfile path.)
|
||||||
|
##
|
||||||
|
## Read into a variable and match with a here-string rather than piping into
|
||||||
|
## `grep -qi`: `grep -q` closes the pipe on its first match, and under the
|
||||||
|
## `set -o pipefail` at the top of this file a writer that is still writing when
|
||||||
|
## that happens dies 141 and the pipeline reports FALSE — i.e. "OLS is down"
|
||||||
|
## precisely because the "running" line matched, which here means a spurious
|
||||||
|
## relaunch of a healthy OLS, five of which trip the crash-loop cap and exit
|
||||||
|
## PID 1. (Same defect that shipped in entrypoint-lsphp.sh's cac_path_parity
|
||||||
|
## probe.) The reason to change it is STRUCTURAL — a pipefail script must not
|
||||||
|
## pipe into an early-exit reader, whatever the payload — because "lswsctrl
|
||||||
|
## prints one short line so it always wins" is a size argument, and size
|
||||||
|
## arguments about this race are wrong: see the measurements over the probe
|
||||||
|
## helpers in entrypoint-lsphp.sh, where 41 KB SIGPIPEd 11% of the time into a
|
||||||
|
## 64 KB pipe. A non-zero `lswsctrl` still means "not running", exactly as
|
||||||
|
## pipefail made it mean before.
|
||||||
|
##
|
||||||
|
## A here-string is the right shape HERE, where those helpers use `[[ ]]`: the
|
||||||
|
## reason to avoid `<<<` there is that bash spills a large here-string to
|
||||||
|
## /tmp/sh-thd.XXXXXX and so makes a writable temp dir a boot precondition. The
|
||||||
|
## threshold is 65536 bytes in this image's bash 5.2.21 and no lower than 4096
|
||||||
|
## in any bash this repo has met; `lswsctrl status` prints well under 100 bytes
|
||||||
|
## and cannot approach it, so no temp file is ever created and the case-
|
||||||
|
## insensitive match stays a plain `grep -i` instead of a hand-rolled glob.
|
||||||
|
ols_running() {
|
||||||
|
local st
|
||||||
|
st=$(/usr/local/lsws/bin/lswsctrl status 2>/dev/null) || return 1
|
||||||
|
grep -qi 'running with pid' <<<"$st"
|
||||||
|
}
|
||||||
|
|
||||||
|
## Crash-loop cap: if OLS can't stay up, bail out so Docker's restart policy and
|
||||||
|
## the site-health monitor escalate instead of us hot-looping forever.
|
||||||
|
MAX_STARTS=5
|
||||||
|
WINDOW=60
|
||||||
|
starts=""
|
||||||
|
|
||||||
|
start_ols() {
|
||||||
|
/usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true
|
||||||
|
## wait up to 10s for the daemon to report running
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
ols_running && return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! start_ols; then
|
||||||
|
echo "entrypoint: OLS failed to start (not running after 10s)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "entrypoint: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if ols_running; then
|
||||||
|
sleep 3
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Not running this instant. This is EITHER a clean shutdown OR the brief
|
||||||
|
## handoff window of a graceful self-restart (status momentarily reports down
|
||||||
|
## while the new main takes over). Grace, then re-check before judging.
|
||||||
|
sleep 2
|
||||||
|
if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$STOP_REQUESTED" -eq 1 ]; then
|
||||||
|
echo "entrypoint: SIGTERM received, OLS stopped — exiting."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Genuine crash: not running and no shutdown requested. Relaunch, capped.
|
||||||
|
now=$(date +%s)
|
||||||
|
starts="$starts $now"
|
||||||
|
pruned=""
|
||||||
|
for t in $starts; do
|
||||||
|
[ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t"
|
||||||
|
done
|
||||||
|
starts="$pruned"
|
||||||
|
n=$(echo $starts | wc -w)
|
||||||
|
echo "entrypoint: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2
|
||||||
|
if [ "$n" -ge "$MAX_STARTS" ]; then
|
||||||
|
echo "entrypoint: OLS crash-looping ($n starts in ${WINDOW}s) — bailing out for Docker restart policy / monitor." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
start_ols || true
|
||||||
|
done
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## entrypoint-lsphp.sh — PID 1 for cac-lsphp:phpNN.
|
||||||
|
##
|
||||||
|
## The per-site PHP backend for the SHARED OpenLiteSpeed tier. Runs lsphp in
|
||||||
|
## DETACHED LSAPI mode (`lsphp -b <addr:port>`) and nothing else — no
|
||||||
|
## webserver. The shared-ols container connects to this over the docker
|
||||||
|
## network (extProcessor type lsapi, address <this-container>:9000) exactly
|
||||||
|
## like the shared httpd connects to a cac-fpm container's php-fpm on :9000.
|
||||||
|
##
|
||||||
|
## Structurally identical to cac-fpm/cac-litespeed: same `uid`/`user` contract,
|
||||||
|
## the customer docroot mounted at /home/$user (so PHP sees /home/$user/public_html
|
||||||
|
## EXACTLY like the standalone tiers — true 1:1 drop-in for WordPress ABSPATH,
|
||||||
|
## config paths, and DB-stored absolute paths). The only difference is OLS lives
|
||||||
|
## in a separate container, so this PID 1 is lsphp itself.
|
||||||
|
##
|
||||||
|
## THE SYMLINK (see feedback_ols_lsapi_no_script_filename_remap): OLS has no
|
||||||
|
## ProxyFCGISetEnvIf-style remap — it hands lsphp exactly its vhost docRoot path.
|
||||||
|
## The shared-ols container serves from its bulk /docker/users->/mnt/users mount,
|
||||||
|
## so its docRoot (and the SCRIPT_FILENAME it sends us) is
|
||||||
|
## /mnt/users/<user>/<domain>/public_html. We create a symlink
|
||||||
|
## /mnt/users/<user>/<domain> -> /home/$user so that path resolves to the real
|
||||||
|
## /home/$user/public_html files. PHP canonicalises the symlink, so
|
||||||
|
## __FILE__/__DIR__/realpath all report /home/$user/public_html (verified
|
||||||
|
## 2026-06-10) — the customer never sees the /mnt/users path.
|
||||||
|
##
|
||||||
|
## THE $_SERVER STRINGS: the symlink makes paths RESOLVE, but the raw strings OLS
|
||||||
|
## put in $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] still read /mnt/users.
|
||||||
|
## The cac_path_parity extension (baked into the image, configured per-site
|
||||||
|
## below) rewrites those two at request start, so a site moved from cac-fpm to
|
||||||
|
## cac-lsphp sees byte-identical values. It replaced an auto_prepend_file
|
||||||
|
## normaliser that any site's own .user.ini silently displaced — see
|
||||||
|
## ext/cac-path-parity/cac_path_parity.c.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${PHPVER:=83}"
|
||||||
|
: "${environment:=PROD}"
|
||||||
|
export CONTAINER_ROLE="lsphp_only"
|
||||||
|
export PHPVER environment
|
||||||
|
|
||||||
|
## ---- env validation (same contract as entrypoint-fpm / entrypoint-litespeed) ----
|
||||||
|
if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then
|
||||||
|
echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
: "${domain:=localhost}"
|
||||||
|
export user domain
|
||||||
|
|
||||||
|
LSPHP_BIN="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"
|
||||||
|
if [ ! -x "$LSPHP_BIN" ]; then
|
||||||
|
echo "FATAL: lsphp binary not found at $LSPHP_BIN (PHPVER=$PHPVER)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---- user + directories (identical to entrypoint-litespeed.sh: docroot at
|
||||||
|
## /home/$user, the customer's bind-mounted domain dir) ----
|
||||||
|
if ! id -u "$user" >/dev/null 2>&1; then
|
||||||
|
useradd -u "$uid" -m -s /bin/bash "$user"
|
||||||
|
fi
|
||||||
|
mkdir -p "/home/$user/public_html" "/home/$user/logs/php-fpm"
|
||||||
|
|
||||||
|
## ---- compatibility symlink for the OLS-sent path ----
|
||||||
|
## OLS sends SCRIPT_FILENAME under /mnt/users/<user>/<safe-domain>/public_html
|
||||||
|
## (the shared-ols container's view). Point that at our real /home/$user mount so
|
||||||
|
## the path resolves. <safe-domain> matches the on-disk convention: wildcard
|
||||||
|
## `*.foo.com` is stored as `wildcard.foo.com`.
|
||||||
|
SAFE_DOMAIN="$domain"
|
||||||
|
case "$domain" in
|
||||||
|
\*.*) SAFE_DOMAIN="wildcard.${domain#\*.}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
## Both of these get interpolated into generated php.ini fragments below. They
|
||||||
|
## are panel-validated and both already feed `ln -sfn` and the shared-ols vhost
|
||||||
|
## config, so a hostile value is not reachable today — this is the belt to that
|
||||||
|
## brace. A newline in $domain is an INI-DIRECTIVE INJECTION into the generated
|
||||||
|
## fragment (measured against the pre-fix script: domain=$'evil.com\nprecision =
|
||||||
|
## 7\n; ' put that directive in 99-cac-path-parity.ini and lsphp reported
|
||||||
|
## `precision => 7`); `$(...)` yields an ini parse error and `"` an empty value,
|
||||||
|
## and BOTH of those leave the
|
||||||
|
## path-parity extension INERT — the exact silent parity loss this whole change
|
||||||
|
## exists to eliminate. Quoting the emitted values (done below) neutralises
|
||||||
|
## newlines and quotes; it does NOT neutralise php.ini's own `${VAR}`
|
||||||
|
## interpolation, which is why the character class is checked as well.
|
||||||
|
INI_TOKENS_OK=yes
|
||||||
|
case "$user" in ''|*[!A-Za-z0-9._-]*) INI_TOKENS_OK=no ;; esac
|
||||||
|
case "$SAFE_DOMAIN" in ''|*[!A-Za-z0-9._-]*) INI_TOKENS_OK=no ;; esac
|
||||||
|
if [ "$INI_TOKENS_OK" != yes ]; then
|
||||||
|
echo "WARNING: entrypoint-lsphp: user/domain contain characters outside [A-Za-z0-9._-] — refusing to generate php.ini fragments from them, so the \$_SERVER path-parity mapping and the per-site error_log are BOTH skipped (the extension stays inert, log_errors stays On from the image defaults and PHP logs to stderr i.e. \`docker logs\`; requests are unaffected). user=$(printf '%q' "$user") domain=$(printf '%q' "$domain")" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
## The exact path prefix the shared-ols container serves this site from — the
|
||||||
|
## string OLS puts in SCRIPT_FILENAME/DOCUMENT_ROOT. Used twice: for the symlink
|
||||||
|
## that makes it RESOLVE, and for the cac_path_parity mapping that makes it READ
|
||||||
|
## like cac-fpm. Deriving both from one variable keeps them in lockstep.
|
||||||
|
OLS_SITE_PATH="/mnt/users/$user/$SAFE_DOMAIN"
|
||||||
|
mkdir -p "/mnt/users/$user"
|
||||||
|
ln -sfn "/home/$user" "$OLS_SITE_PATH"
|
||||||
|
|
||||||
|
## ---- detached-lsphp pool sizing ----
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source /scripts/detect-memory-lsphp.sh
|
||||||
|
|
||||||
|
## LSAPI tuning (spec §5.1). PHP_LSAPI_CHILDREN MUST equal the shared-ols vhost
|
||||||
|
## maxConns — the WHP panel writes both from the single fpm_max_children value,
|
||||||
|
## so they can't drift. LSAPI_MAX_IDLE is THE RAM win: idle children exit, so an
|
||||||
|
## idle site's footprint collapses toward baseline (ondemand-like).
|
||||||
|
export PHP_LSAPI_CHILDREN="${PHP_LSAPI_CHILDREN:-$LSAPI_CHILDREN}"
|
||||||
|
export PHP_LSAPI_MAX_REQUESTS="${PHP_LSAPI_MAX_REQUESTS:-500}"
|
||||||
|
export LSAPI_MAX_IDLE="${LSAPI_MAX_IDLE:-30}"
|
||||||
|
export LSAPI_EXTRA_CHILDREN="${LSAPI_EXTRA_CHILDREN:-5}"
|
||||||
|
export LSAPI_AVOID_FORK="${LSAPI_AVOID_FORK:-0}"
|
||||||
|
## LSAPI_KEEP_LISTEN=2 works around a leak in lsphp's own bookkeeping — not a
|
||||||
|
## setting we're tuning for taste. The master keeps a `busy` worker counter in
|
||||||
|
## a MAP_SHARED page it shares with its children; measured live on whp01,
|
||||||
|
## that counter drifts NEGATIVE over days of uptime (arclightcourt.com-01 was
|
||||||
|
## at busy=-8 after 6.9 days; a healthy sibling sat at 0..9). php-src
|
||||||
|
## sapi/litespeed/lsapilib.c computes each child's idle-exit grace period as
|
||||||
|
## `10 + busy*10` seconds (capped by LSAPI_MAX_IDLE) INSIDE
|
||||||
|
## `if (s_keep_listener == 1)` — with busy=-8 that's `wait_time = -70`, so
|
||||||
|
## workers exit after ~1s idle instead of 10-30s. No worker then lingers in
|
||||||
|
## accept(), so the master's "an idle worker is already accepting, don't
|
||||||
|
## fork" guard never fires and it forks for every single connection —
|
||||||
|
## observed slamming the hard child ceiling under bot traffic
|
||||||
|
## (`Reached max children process limit`) and, on rejection, leaving the
|
||||||
|
## pending connection to rot in the kernel backlog as a 503. Confirmed
|
||||||
|
## asymmetry: the affected site logged 306 OLS-side `ExtConn timed out` /
|
||||||
|
## deadlock / `oops! 503` errors where an identically-configured healthy
|
||||||
|
## sibling logged 0. Restarting the container resets the counter to 0 (it's
|
||||||
|
## initialised at master start) but it drifts negative again over about a
|
||||||
|
## week — a reset, not a cure. LSAPI_KEEP_LISTEN=2 skips the `== 1` branch
|
||||||
|
## entirely, so idle-exit timing is never derived from the leaked counter and
|
||||||
|
## instead falls straight back to LSAPI_MAX_IDLE above. The is_enough_free_mem()
|
||||||
|
## memory guard sits immediately above that branch in lsapilib.c and is NOT
|
||||||
|
## part of it, so it still applies at =2 — this does not trade away the
|
||||||
|
## memory-pressure protection LSAPI_MAX_IDLE exists for. Still overridable
|
||||||
|
## (e.g. back to 1) per-container as an escape hatch.
|
||||||
|
export LSAPI_KEEP_LISTEN="${LSAPI_KEEP_LISTEN:-2}"
|
||||||
|
LSPHP_BIND="${LSPHP_BIND:-0.0.0.0:9000}"
|
||||||
|
|
||||||
|
## ---- .user.ini support ----
|
||||||
|
## php-lsapi compiles .user.ini support in but leaves it DISABLED by default:
|
||||||
|
## sapi/litespeed/lsapi_main.c has `static int parse_user_ini = 0;` and only
|
||||||
|
## sets it in PHP_MINIT_FUNCTION(litespeed) when the PROCESS ENV contains
|
||||||
|
## LSPHP_ENABLE_USER_INI=on. Without it, lsphp never enters the user-ini chain
|
||||||
|
## at all — and does so SILENTLY, because `user_ini.filename` / `user_ini.cache_ttl`
|
||||||
|
## still report their core defaults in phpinfo(). Every other WHP PHP tier
|
||||||
|
## (cac, cac-fpm, cac-litespeed) honors .user.ini, so leaving it off here made
|
||||||
|
## the shared-ols tier quietly inconsistent: customer memory_limit /
|
||||||
|
## max_input_vars overrides were ignored, and — the reason this was found —
|
||||||
|
## Wordfence's `auto_prepend_file` WAF never loaded on ANY shared-ols site.
|
||||||
|
##
|
||||||
|
## Exported here rather than relying solely on the Dockerfile ENV because the
|
||||||
|
## runuser fallback below resets the environment; an export survives all three
|
||||||
|
## exec paths. Still overridable per-container (set LSPHP_ENABLE_USER_INI=off in
|
||||||
|
## the site's env) as an escape hatch for a site whose legacy cPanel-generated
|
||||||
|
## .user.ini has not been remediated yet.
|
||||||
|
export LSPHP_ENABLE_USER_INI="${LSPHP_ENABLE_USER_INI:-on}"
|
||||||
|
|
||||||
|
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP_LSAPI_CHILDREN=${PHP_LSAPI_CHILDREN} | LSAPI_MAX_IDLE=${LSAPI_MAX_IDLE} | LSAPI_KEEP_LISTEN=${LSAPI_KEEP_LISTEN} | PHPVER=${PHPVER} | bind=${LSPHP_BIND} | user_ini=${LSPHP_ENABLE_USER_INI}"
|
||||||
|
|
||||||
|
## Validate a numeric value destined for a generated php.ini fragment.
|
||||||
|
## Sets INI_NUM to the value when it is acceptable, and to "" (plus a WARNING)
|
||||||
|
## when it is not. Never fatal: a rejected override just leaves the image
|
||||||
|
## default in place, and the site serves either way.
|
||||||
|
##
|
||||||
|
## Digits-only is what closes the injection: no newline, quote, `$` or `{` can
|
||||||
|
## survive it, so neither an ini-directive injection nor php.ini's `${VAR}`
|
||||||
|
## interpolation is reachable regardless of what the caller sent.
|
||||||
|
##
|
||||||
|
## The range bound is a separate, weaker concern: it is a sanity check, NOT a
|
||||||
|
## guarantee that the value works. Measured — with `99-prod-overrides.ini`
|
||||||
|
## setting `opcache.interned_strings_buffer = 16`, a memory_consumption of 8 or
|
||||||
|
## 16 is ACCEPTED here and still aborts opcache at startup ("Insufficient shared
|
||||||
|
## memory for interned strings buffer"), loading no opcache at all. The floor is
|
||||||
|
## not raised to cover that because doing so would forfeit the superset property
|
||||||
|
## below; the panel clamps at 32, well clear of it.
|
||||||
|
validate_ini_num() {
|
||||||
|
local name="$1" val="$2" min="$3" max="$4"
|
||||||
|
INI_NUM=""
|
||||||
|
case "$val" in
|
||||||
|
''|*[!0-9]*) ;;
|
||||||
|
*)
|
||||||
|
## Length-cap first: `[ -lt ]` on a 25-digit string is an arithmetic
|
||||||
|
## error, not a comparison. 7 digits covers every max below.
|
||||||
|
if [ "${#val}" -le 7 ] && [ "$val" -ge "$min" ] && [ "$val" -le "$max" ]; then
|
||||||
|
INI_NUM="$val"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "WARNING: entrypoint-lsphp: ${name}=$(printf '%q' "$val") is not a plain integer in ${min}-${max} — ignoring it; the image default from 99-prod-overrides.ini applies." >&2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
## ---- per-site ini drop-ins (identical mechanism to entrypoint-litespeed.sh) ----
|
||||||
|
## error_log → the same customer-visible path cac:phpNN / cac-litespeed use, so
|
||||||
|
## "where's my PHP error log?" is answered identically across all site types.
|
||||||
|
## Capture lsphp's own info once and read both answers out of it. Probe with
|
||||||
|
## `-i` ONLY: lsphp is the LSAPI SAPI, not the CLI — it accepts just
|
||||||
|
## -[b|c|n|h|i|q|s|v|?] and answers `-m`/`-r` by printing usage and exiting 0, so
|
||||||
|
## a `lsphp -m | grep` test never matches and never errors either.
|
||||||
|
##
|
||||||
|
## ---- CAC-TEST: probe helpers BEGIN ----
|
||||||
|
## Everything between these two markers is extracted verbatim and executed by
|
||||||
|
## scripts/tests/lsphp-info-probe.test.sh — the markers are inert comments with
|
||||||
|
## no runtime effect, and they exist so the test exercises THE SHIPPED CODE
|
||||||
|
## rather than a copy of it that can drift away from it. Keep BOTH markers: the
|
||||||
|
## extractor refuses to emit anything unless it sees the END one, so a half-
|
||||||
|
## deleted pair is reported there as a marker error instead of silently
|
||||||
|
## sourcing the rest of this file.
|
||||||
|
##
|
||||||
|
## WHY THESE MATCH `$1` IN THE SHELL AND NEVER PIPE IT INTO A READER.
|
||||||
|
##
|
||||||
|
## What broke. Both probes used to be `printf '%s\n' "$LSPHP_INFO" | <reader>`,
|
||||||
|
## and both readers stop early — `grep -q` at its first match, `awk` at `exit`.
|
||||||
|
## When the reader closes the pipe with the writer still writing, the writer
|
||||||
|
## takes SIGPIPE and dies 141; `set -o pipefail` (line 34) adopts 141 as the
|
||||||
|
## PIPELINE's status; and the branch reads FALSE **because the thing it was
|
||||||
|
## looking for was present early enough to stop the reader**. Measured on whp02
|
||||||
|
## against the published cac-lsphp:php83: 5/5 runs status=141 with pipefail,
|
||||||
|
## 0 without.
|
||||||
|
##
|
||||||
|
## THE RULE, and it is not about size. ANY `writer | early-exiting-reader`
|
||||||
|
## under pipefail is a latent 141. PAYLOAD SIZE IS NOT A SAFETY ARGUMENT. Each
|
||||||
|
## run is decided by a race — whether the reader's close lands before the
|
||||||
|
## writer's final write() returns — and the payload only sets how many write()
|
||||||
|
## syscalls the writer has to lose. Measured here against a default
|
||||||
|
## 65536-byte pipe (confirmed with F_GETPIPE_SZ):
|
||||||
|
## 41144 bytes -> 141 in 32/300 runs (11%) — well UNDER capacity
|
||||||
|
## 65012 bytes -> 141 in 25/30 runs — not 100% even AT capacity
|
||||||
|
## 500 KB into a 1 MiB pipe -> 200/200 SIGPIPE written 4096 bytes at a
|
||||||
|
## time, 0/200 written as one 500 KB write
|
||||||
|
## and strace caught printf dying having written 12086 of 40406 bytes into a
|
||||||
|
## 65536-byte pipe, i.e. losing with 53 KB of room to spare. The reason the same
|
||||||
|
## image failed 5/5 on whp02 and 10/10 clean in a dev container is the WRITER's
|
||||||
|
## syscall size: bash <= 5.2.15 pushes ~37 KB per write, bash >= 5.2.21 pushes
|
||||||
|
## 80-160 bytes, so the newer shell needs hundreds of chances to lose the race
|
||||||
|
## and the older one needs a couple. (An earlier draft of this comment blamed
|
||||||
|
## pipe capacity and fs.pipe-user-pages-soft. Both were wrong: that soft limit
|
||||||
|
## clamps new pipes to two pages rather than one, applies only once a single uid
|
||||||
|
## holds more than 1024 pipes, and is skipped entirely for CAP_SYS_RESOURCE.)
|
||||||
|
##
|
||||||
|
## The only SOUND reasons a call site is safe are structural:
|
||||||
|
## * the file does not set pipefail; or
|
||||||
|
## * the reader provably consumes to EOF (no `q`, `-q`, `-l`, `-m`, `exit`,
|
||||||
|
## `break`); or
|
||||||
|
## * the pipeline's status is discarded.
|
||||||
|
## The reader's implementation is not a defence either: at 248 KB, mawk, gawk,
|
||||||
|
## `grep -q` and `head -1` each returned 141 on 10/10, and these images have
|
||||||
|
## already drifted between mawk 1.3.4 (cac-lsphp) and gawk 5.2.1
|
||||||
|
## (cac-litespeed:php83) — not a property this repo controls.
|
||||||
|
##
|
||||||
|
## WHY PURE-BASH MATCHING RATHER THAN A HERE-STRING. `<<<` does remove the
|
||||||
|
## pipeline, but it is not free: above a build-dependent size bash materialises
|
||||||
|
## the string as /tmp/sh-thd.XXXXXX, so it makes a writable temp dir a
|
||||||
|
## PRECONDITION OF BOOTING. Measured in this image (bash 5.2.21) the switch is
|
||||||
|
## at exactly 65536 bytes and `lsphp -i` is 39934, so the here-string form was
|
||||||
|
## not hitting disk here — but Debian's bash 5.2.15 switches somewhere between
|
||||||
|
## 4096 and 16384, where the same payload would. What that costs is not
|
||||||
|
## theoretical:
|
||||||
|
## docker run --read-only ... 'SCAN_DIR=$(awk ... <<<"$BIG")'
|
||||||
|
## -> bash: cannot create temp file for here-document: Read-only file
|
||||||
|
## system ... and the script is dead: exit 1, PID 1 gone.
|
||||||
|
## which is the exact boot failure this branch exists to remove, re-acquired
|
||||||
|
## from a different direction and gated on which bash the base image ships.
|
||||||
|
## `[[ ]]` and `${...}` allocate nothing and cannot fail that way. Where the
|
||||||
|
## subject is a couple of hundred bytes and provably cannot approach the
|
||||||
|
## threshold, a here-string is still fine — see `ols_running` in
|
||||||
|
## entrypoint-litespeed.sh, which says so at the call site.
|
||||||
|
##
|
||||||
|
## THE PATTERNS ARE THE OLD ONES RE-EXPRESSED, NOT APPROXIMATED.
|
||||||
|
## grep -q '^cac_path_parity support => enabled$' — an anchored whole-line
|
||||||
|
## match, so the subject is wrapped in a newline at BOTH ends and the glob
|
||||||
|
## matches \n<line>\n; the wrapping is what keeps the first line and an
|
||||||
|
## unterminated last line matching exactly as grep matched them.
|
||||||
|
## grep -q '^PHP Version => ' — anchored at the start
|
||||||
|
## only, so only a leading newline is added.
|
||||||
|
## awk -F'=> ' '/^Scan this dir/ {print $2; exit}' — first matching line,
|
||||||
|
## then the text between the FIRST and SECOND '=> ' on it ($2), or empty if
|
||||||
|
## there is no separator. `${x#*'=> '}` then `${y%%'=> '*}` is that, exactly.
|
||||||
|
## scripts/tests/lsphp-info-probe.test.sh asserts this equivalence against the
|
||||||
|
## grep/awk originals over the edge cases (match on the first line, on the last
|
||||||
|
## line with no trailing newline, decoy substrings, a second separator, an empty
|
||||||
|
## value, a missing key), so "same answer as before" is checked, not asserted.
|
||||||
|
##
|
||||||
|
## Statuses are unchanged: a genuinely-absent extension is still a clean 1, and
|
||||||
|
## a genuinely-missing "Scan this dir" line is still empty output with status 0.
|
||||||
|
lsphp_info_has_parity_ext() {
|
||||||
|
[[ $'\n'"$1"$'\n' == *$'\ncac_path_parity support => enabled\n'* ]]
|
||||||
|
}
|
||||||
|
lsphp_info_scan_dir() {
|
||||||
|
local rest line val
|
||||||
|
rest=$'\n'"$1"
|
||||||
|
[[ $rest == *$'\nScan this dir'* ]] || return 0
|
||||||
|
## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`.
|
||||||
|
rest=${rest#*$'\nScan this dir'}
|
||||||
|
line="Scan this dir${rest%%$'\n'*}"
|
||||||
|
val=""
|
||||||
|
if [[ $line == *'=> '* ]]; then
|
||||||
|
val=${line#*'=> '}
|
||||||
|
val=${val%%'=> '*}
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$val"
|
||||||
|
}
|
||||||
|
## Did `lsphp -i` answer at all? Separates "the extension is not there" from
|
||||||
|
## "our probe produced nothing to look in", so neither gets reported as the
|
||||||
|
## other. Keyed on the phpinfo banner, which is line 2 of every `lsphp -i`
|
||||||
|
## (verified against lsphp83 8.3.32) and is not something LSPHP_INFO could
|
||||||
|
## contain from any other source.
|
||||||
|
lsphp_info_is_usable() {
|
||||||
|
[[ $'\n'"$1" == *$'\nPHP Version => '* ]]
|
||||||
|
}
|
||||||
|
## ---- CAC-TEST: probe helpers END ----
|
||||||
|
|
||||||
|
PATH_PARITY_MODE="none"
|
||||||
|
LSPHP_INFO=$("$LSPHP_BIN" -i 2>/dev/null || true)
|
||||||
|
SCAN_DIR=$(lsphp_info_scan_dir "$LSPHP_INFO")
|
||||||
|
## `|| true` above is what keeps a broken probe survivable: fail-open is
|
||||||
|
## deliberate here and below — the site serves either way, only the $_SERVER
|
||||||
|
## strings differ. What the failure gets REPORTED as is handled at each of the
|
||||||
|
## two places it changes the outcome (the parity branch, and the no-scan-dir
|
||||||
|
## else at the bottom of this block).
|
||||||
|
if [ -n "$SCAN_DIR" ]; then
|
||||||
|
mkdir -p "$SCAN_DIR"
|
||||||
|
## Values emitted double-quoted via printf rather than interpolated into an
|
||||||
|
## unquoted heredoc — see the INI_TOKENS_OK note above for what that prevents.
|
||||||
|
##
|
||||||
|
## Gated on INI_TOKENS_OK for the same reason the mapping below is: this
|
||||||
|
## fragment interpolates $user into generated ini too. Leaving it ungated was
|
||||||
|
## an INCONSISTENCY, not a live hole — a newline is inert inside the quotes,
|
||||||
|
## and a `${`-bearing $user cannot exist because the useradd above would have
|
||||||
|
## failed under `set -euo pipefail`. But "this particular unvetted value
|
||||||
|
## happens to be contained" is the reasoning this branch already rejected one
|
||||||
|
## screenful up, so it is not the reasoning that guards this line either.
|
||||||
|
##
|
||||||
|
## Rejecting costs such a user nothing it needs: `log_errors = On` is already
|
||||||
|
## baked in by 99-prod-overrides.ini, so PHP still logs — to stderr, i.e.
|
||||||
|
## `docker logs`, which is MORE visible than a per-site file, not less. No
|
||||||
|
## legitimate user reaches this branch (verified fleet-wide: 30 shared_ols
|
||||||
|
## sites across 4 hosts, none rejected by the charset check).
|
||||||
|
if [ "$INI_TOKENS_OK" = yes ]; then
|
||||||
|
{
|
||||||
|
echo '; rendered at container start by entrypoint-lsphp.sh'
|
||||||
|
printf 'error_log = "%s"\n' "/home/$user/logs/php-fpm/error.log"
|
||||||
|
echo 'log_errors = On'
|
||||||
|
} > "$SCAN_DIR/99-user-error-log.ini"
|
||||||
|
else
|
||||||
|
## The container filesystem survives `docker restart`, so a fragment an
|
||||||
|
## earlier boot wrote from a different env must not outlive the rejection.
|
||||||
|
rm -f "$SCAN_DIR/99-user-error-log.ini"
|
||||||
|
fi
|
||||||
|
## ---- $_SERVER path parity with cac-fpm ----
|
||||||
|
## Point the cac_path_parity extension at THIS site's mapping. Same two
|
||||||
|
## values the compatibility symlink above is built from, so the rewrite and
|
||||||
|
## the symlink can never disagree.
|
||||||
|
##
|
||||||
|
## Both settings are PHP_INI_SYSTEM: a customer's .user.ini (PHP_INI_PERDIR /
|
||||||
|
## PHP_INI_USER only) cannot redirect or disable them, and the extension
|
||||||
|
## occupies no userland hook — so the customer's own auto_prepend_file (the
|
||||||
|
## Wordfence WAF on several live sites) keeps working untouched. That
|
||||||
|
## combination is why this is an extension: the previous auto_prepend_file
|
||||||
|
## normaliser was itself PHP_INI_PERDIR and any site with its own prepend
|
||||||
|
## silently displaced it, while making OUR prepend win would have disabled
|
||||||
|
## THEIRS. See ext/cac-path-parity/cac_path_parity.c.
|
||||||
|
if [ "$INI_TOKENS_OK" != yes ]; then
|
||||||
|
## Already warned above. Write NOTHING: neither the mapping (we will not
|
||||||
|
## generate ini from an unvetted string) nor the auto_prepend fallback (which
|
||||||
|
## would not be correct for such a site either). The extension stays inert,
|
||||||
|
## the request path is unaffected.
|
||||||
|
rm -f "$SCAN_DIR/99-cac-path-parity.ini" "$SCAN_DIR/99-cac-lsphp-normalize.ini"
|
||||||
|
PATH_PARITY_MODE="none (user/domain rejected)"
|
||||||
|
elif lsphp_info_has_parity_ext "$LSPHP_INFO"; then
|
||||||
|
{
|
||||||
|
echo '; rendered at container start by entrypoint-lsphp.sh'
|
||||||
|
printf 'cac_path_parity.from = "%s"\n' "$OLS_SITE_PATH"
|
||||||
|
printf 'cac_path_parity.to = "%s"\n' "/home/$user"
|
||||||
|
} > "$SCAN_DIR/99-cac-path-parity.ini"
|
||||||
|
## Drop the pre-extension fallback if an older image left one here — the
|
||||||
|
## container filesystem survives a "docker restart", so an in-place upgrade
|
||||||
|
## must not keep a stale auto_prepend pointing at the old normaliser.
|
||||||
|
rm -f "$SCAN_DIR/99-cac-lsphp-normalize.ini"
|
||||||
|
PATH_PARITY_MODE="extension"
|
||||||
|
else
|
||||||
|
## Degraded fallback for an image built before the extension existed (or one
|
||||||
|
## where it failed to load). Restores the old, .user.ini-defeatable
|
||||||
|
## behaviour rather than losing normalisation entirely — but say so loudly,
|
||||||
|
## because in this mode parity is NOT guaranteed.
|
||||||
|
##
|
||||||
|
## FAIL-OPEN, DELIBERATELY: a probe that cannot answer must never stop the
|
||||||
|
## container. The site serves either way; only the $_SERVER strings differ.
|
||||||
|
cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF'
|
||||||
|
; rendered at container start by entrypoint-lsphp.sh (DEGRADED FALLBACK)
|
||||||
|
auto_prepend_file = /scripts/cac-lsphp-normalize.php
|
||||||
|
EOF
|
||||||
|
## ...but do not DIAGNOSE more than was established. The old wording said
|
||||||
|
## "extension not loadable in this image" for EVERY reason this branch is
|
||||||
|
## reached — including the probe breaking on its own, which is exactly what
|
||||||
|
## happened (see the SIGPIPE note on the helpers above): a false verdict
|
||||||
|
## that sent operators to rebuild an image whose extension was fine and
|
||||||
|
## whose build gate had passed. The claim now carries its evidence, and the
|
||||||
|
## evidence is real: reaching here at all means SCAN_DIR was parsed out of
|
||||||
|
## this same output, so `lsphp -i` did answer and its module list is
|
||||||
|
## authoritative. The case where it did NOT answer never gets here — it is
|
||||||
|
## caught and reported honestly at the `lsphp_info_is_usable` check above.
|
||||||
|
PATH_PARITY_MODE="auto_prepend (DEGRADED)"
|
||||||
|
echo "WARNING: entrypoint-lsphp: cac_path_parity extension not loadable in this image — '${LSPHP_BIN} -i' answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it — falling back to the auto_prepend normaliser, which a site's own .user.ini auto_prepend_file will silently displace. Rebuild/repull cac-lsphp:php${PHPVER}." >&2
|
||||||
|
fi
|
||||||
|
## Per-site opcache override (panel: Advanced Tuning → OpCache size); falls
|
||||||
|
## back to the baked lsphp-overrides.ini defaults when unset.
|
||||||
|
##
|
||||||
|
## SAME INJECTION CLASS AS THE MAPPING ABOVE, and it was left open when that
|
||||||
|
## one was closed. These two lines interpolated the raw env into an UNQUOTED
|
||||||
|
## `echo`, so (measured against the pre-fix script on this branch's image)
|
||||||
|
## OPCACHE_MEMORY_MB=$'128\nprecision = 7\n; ' put `precision = 7` into
|
||||||
|
## 99-user-opcache.ini and lsphp duly reported `precision => 7`.
|
||||||
|
##
|
||||||
|
## WHP does cast (int) and clamp these before setting the env
|
||||||
|
## (web-files/libs/site-pool-env.php: 32-512 MB, 2000-32000 files) — but
|
||||||
|
## "the panel validates it" is exactly the argument this branch rejected for
|
||||||
|
## `domain`, and the panel is a different repo on a different release cadence.
|
||||||
|
## Validate at the point of use, where the ini is actually generated.
|
||||||
|
##
|
||||||
|
## The accepted ranges below are deliberately a strict SUPERSET of the panel's
|
||||||
|
## clamps (32-512 and 2000-32000), so widening a panel clamp later can never
|
||||||
|
## start silently rejecting real sites here.
|
||||||
|
##
|
||||||
|
## Provenance, stated honestly: max_accelerated_files [200, 1000000] IS PHP's
|
||||||
|
## own clamp. For memory_consumption, 8 is PHP's documented floor but 4096 is
|
||||||
|
## OURS — PHP imposes no upper bound on that directive. It is a typo guard, not
|
||||||
|
## a vendor limit. An out-of-range value is not merely ignored: PHP resets the
|
||||||
|
## directive to its COMPILED default, discarding the image's own
|
||||||
|
## `99-prod-overrides` value, which is a further reason to reject rather than
|
||||||
|
## pass such a value through.
|
||||||
|
OPCACHE_LINES=()
|
||||||
|
if [ -n "${OPCACHE_MEMORY_MB:-}" ]; then
|
||||||
|
validate_ini_num OPCACHE_MEMORY_MB "$OPCACHE_MEMORY_MB" 8 4096
|
||||||
|
if [ -n "$INI_NUM" ]; then
|
||||||
|
OPCACHE_LINES+=("$(printf 'opcache.memory_consumption = "%s"' "$INI_NUM")")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${OPCACHE_MAX_FILES:-}" ]; then
|
||||||
|
validate_ini_num OPCACHE_MAX_FILES "$OPCACHE_MAX_FILES" 200 1000000
|
||||||
|
if [ -n "$INI_NUM" ]; then
|
||||||
|
OPCACHE_LINES+=("$(printf 'opcache.max_accelerated_files = "%s"' "$INI_NUM")")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "${#OPCACHE_LINES[@]}" -gt 0 ]; then
|
||||||
|
{
|
||||||
|
echo "; rendered at container start by entrypoint-lsphp.sh"
|
||||||
|
echo "; per-site override from WHP whp.sites.opcache_*_override"
|
||||||
|
printf '%s\n' "${OPCACHE_LINES[@]}"
|
||||||
|
} > "$SCAN_DIR/99-user-opcache.ini"
|
||||||
|
else
|
||||||
|
## Nothing valid to say. Remove rather than leave whatever a previous boot
|
||||||
|
## wrote. Defensive only — do not read this as fixing a reachable bug: the
|
||||||
|
## writable layer does outlive a `docker restart`, but so does the
|
||||||
|
## environment, and changing these vars requires a RECREATE, which starts
|
||||||
|
## from a fresh layer with no stale fragment. Kept because it is free, and
|
||||||
|
## because it makes "no valid override" mean the same thing on every boot
|
||||||
|
## regardless of how the container got here.
|
||||||
|
rm -f "$SCAN_DIR/99-user-opcache.ini"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
## No scan dir means none of the per-site ini drop-ins land — including the
|
||||||
|
## path-parity mapping. Previously this failed silently; it must not, because
|
||||||
|
## the tier's cac-fpm parity guarantee is one of the things lost.
|
||||||
|
##
|
||||||
|
## Two different things land here and they are not the same report. "lsphp
|
||||||
|
## reports no additional-ini scan dir" ASSERTS that lsphp answered us, which
|
||||||
|
## is false when the probe produced nothing at all — and that was the wrong
|
||||||
|
## half of the same mistake the parity branch above made: describing a probe
|
||||||
|
## that could not answer as a finding about the image. Say which one it was.
|
||||||
|
if lsphp_info_is_usable "$LSPHP_INFO"; then
|
||||||
|
echo "WARNING: entrypoint-lsphp: lsphp reports no additional-ini scan dir — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied." >&2
|
||||||
|
PATH_PARITY_MODE="none (no scan dir)"
|
||||||
|
else
|
||||||
|
echo "WARNING: entrypoint-lsphp: '${LSPHP_BIN} -i' produced no usable phpinfo output (${#LSPHP_INFO} bytes) — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied. This is a PROBE failure and establishes nothing about what the image contains; run '${LSPHP_BIN} -i' in this container before concluding anything about it." >&2
|
||||||
|
PATH_PARITY_MODE="none (lsphp -i unusable)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "entrypoint-lsphp: \$_SERVER path parity = ${PATH_PARITY_MODE} (${OLS_SITE_PATH} -> /home/${user})"
|
||||||
|
|
||||||
|
## ---- ownership ----
|
||||||
|
## Ensure the dirs we created + the log file are customer-owned so lsphp (running
|
||||||
|
## as $user) can read code and write logs. Customer content is already
|
||||||
|
## customer-owned from the host side, so we don't recurse the whole (potentially
|
||||||
|
## large) tree on every boot.
|
||||||
|
touch "/home/$user/logs/php-fpm/error.log"
|
||||||
|
chown "$uid:$uid" "/home/$user" "/home/$user/public_html" "/home/$user/logs" "/home/$user/logs/php-fpm" "/home/$user/logs/php-fpm/error.log" 2>/dev/null || true
|
||||||
|
|
||||||
|
## ---- exec lsphp -b as the customer user (PID 1) ----
|
||||||
|
## Bind port is unprivileged (9000), so no root port-bind step is needed — start
|
||||||
|
## directly as $user. Prefer setpriv (util-linux, on the Ubuntu base); fall back
|
||||||
|
## to runuser. exec so lsphp becomes PID 1 and receives Docker's signals
|
||||||
|
## directly (clean stop/restart, matches the php-fpm container's lifecycle).
|
||||||
|
echo "entrypoint-lsphp: exec $LSPHP_BIN -b $LSPHP_BIND as $user (uid=$uid)"
|
||||||
|
if command -v setpriv >/dev/null 2>&1; then
|
||||||
|
exec setpriv --reuid "$uid" --regid "$uid" --init-groups "$LSPHP_BIN" -b "$LSPHP_BIND"
|
||||||
|
elif command -v runuser >/dev/null 2>&1; then
|
||||||
|
exec runuser -u "$user" -- "$LSPHP_BIN" -b "$LSPHP_BIND"
|
||||||
|
else
|
||||||
|
exec sudo -u "$user" -E "$LSPHP_BIN" -b "$LSPHP_BIND"
|
||||||
|
fi
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
export CONTAINER_ROLE="httpd_only"
|
||||||
|
|
||||||
|
if [ -z "$environment" ]; then
|
||||||
|
environment="PROD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate self-signed SSL cert if not already present
|
||||||
|
if [ ! -f /etc/pki/tls/certs/localhost.crt ]; then
|
||||||
|
openssl req -newkey rsa:2048 -nodes \
|
||||||
|
-keyout /etc/pki/tls/private/localhost.key \
|
||||||
|
-x509 -days 3650 -subj "/CN=localhost" \
|
||||||
|
-out /etc/pki/tls/certs/localhost.crt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create log directory
|
||||||
|
mkdir -p /var/log/httpd
|
||||||
|
|
||||||
|
# Remove default configs that conflict
|
||||||
|
rm -f /etc/httpd/conf.d/userdir.conf
|
||||||
|
|
||||||
|
# Configure RemoteIP for Docker network
|
||||||
|
docker_network=$(ip addr show | grep eth0 | grep inet | awk -F " " '{print $2}')
|
||||||
|
if [ -n "$docker_network" ]; then
|
||||||
|
echo "RemoteIPInternalProxy $docker_network" >> /etc/httpd/conf.d/remoteip.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect memory and calculate Apache MPM tuning
|
||||||
|
source /scripts/detect-memory.sh
|
||||||
|
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | Apache workers=${APACHE_MAX_REQUEST_WORKERS} | Role=${CONTAINER_ROLE}"
|
||||||
|
|
||||||
|
# Generate MPM tuning config
|
||||||
|
/scripts/create-apache-mpm-config.sh
|
||||||
|
|
||||||
|
# Write SSL global config (matches standalone CAC behavior)
|
||||||
|
cat <<'EOF' > /etc/httpd/conf.d/ssl-global.conf
|
||||||
|
Listen 443 https
|
||||||
|
SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
|
||||||
|
SSLSessionCache shmcb:/run/httpd/sslcache(512000)
|
||||||
|
SSLSessionCacheTimeout 300
|
||||||
|
SSLCryptoDevice builtin
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Disable the default ssl.conf if present (we use per-vhost SSL)
|
||||||
|
if [ -f /etc/httpd/conf.d/ssl.conf ]; then
|
||||||
|
mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure vhosts directory exists and is included
|
||||||
|
mkdir -p /etc/httpd/conf.d/vhosts
|
||||||
|
if ! grep -q 'IncludeOptional conf.d/vhosts/' /etc/httpd/conf/httpd.conf; then
|
||||||
|
echo 'IncludeOptional conf.d/vhosts/*.conf' >> /etc/httpd/conf/httpd.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Apache
|
||||||
|
/usr/sbin/httpd -k start
|
||||||
|
|
||||||
|
# Start cron for log rotation
|
||||||
|
/usr/sbin/crond
|
||||||
|
|
||||||
|
# Tail Apache logs (becomes PID 1 process)
|
||||||
|
# Use a loop to pick up new log files as vhosts are added.
|
||||||
|
# tail -f only watches files that exist at start time.
|
||||||
|
touch /var/log/httpd/error_log
|
||||||
|
TAIL_PID=""
|
||||||
|
while true; do
|
||||||
|
LOG_FILES=$(find /var/log/httpd/ -name '*.log' -o -name '*_log' 2>/dev/null | sort)
|
||||||
|
if [ -n "$TAIL_PID" ]; then
|
||||||
|
kill "$TAIL_PID" 2>/dev/null
|
||||||
|
wait "$TAIL_PID" 2>/dev/null
|
||||||
|
fi
|
||||||
|
tail -f $LOG_FILES &
|
||||||
|
TAIL_PID=$!
|
||||||
|
# Re-check for new log files every 60 seconds
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## entrypoint-shared-ols.sh — PID 1 for the shared-ols tier.
|
||||||
|
##
|
||||||
|
## One OpenLiteSpeed container fronting MANY tenants' detached cac-lsphp
|
||||||
|
## sidecars (the OLS analogue of the shared-httpd container). Webserver ONLY —
|
||||||
|
## it runs NO PHP locally (render-shared-ols-config.sh strips the stock local
|
||||||
|
## lsphp; every site's PHP goes to its own sidecar over LSAPI). HAProxy stays
|
||||||
|
## the TLS/WAF/SNI edge and routes OLS-type hostnames here on :443.
|
||||||
|
##
|
||||||
|
## Reuses cac-litespeed's hard-won DAEMON-MODE supervision (NOT `openlitespeed
|
||||||
|
## -n` + wait): OLS self-restarts on QUIC.cloud IP refresh would otherwise exit
|
||||||
|
## PID 1 cleanly and tear the container down. See entrypoint-litespeed.sh and
|
||||||
|
## feedback_ols_quiccloud_restart_kills_container.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${environment:=PROD}"
|
||||||
|
export CONTAINER_ROLE="shared_ols"
|
||||||
|
|
||||||
|
LSWS_CONF=/usr/local/lsws/conf
|
||||||
|
CERT_DIR="$LSWS_CONF/cert"
|
||||||
|
HEALTH_DIR=/usr/local/lsws/shared-ols-health
|
||||||
|
export SITES_ROOT="${SITES_ROOT:-$LSWS_CONF/shared-sites}"
|
||||||
|
export LSCACHE_ROOT="${LSCACHE_ROOT:-/var/lscache}"
|
||||||
|
export CERT_FILE="$CERT_DIR/shared-ols.crt"
|
||||||
|
export KEY_FILE="$CERT_DIR/shared-ols.key"
|
||||||
|
|
||||||
|
mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT" "$CERT_DIR" "$HEALTH_DIR/html"
|
||||||
|
|
||||||
|
## ---- self-signed cert for the :443 listener (HAProxy verifies none) ----
|
||||||
|
if [ ! -f "$CERT_FILE" ]; then
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||||
|
-keyout "$KEY_FILE" -out "$CERT_FILE" -subj "/CN=shared-ols" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---- health vhost (catch-all) ----
|
||||||
|
## This vhost is mapped `map _health *` by render-shared-ols-config.sh, so it
|
||||||
|
## answers EVERY Host that no customer vhost claims. It exists so the server is
|
||||||
|
## valid with zero customer sites and so local/edge health probes get a 200.
|
||||||
|
##
|
||||||
|
## IT MUST NOT ANSWER 200 FOR AN UNMAPPED CUSTOMER HOST.
|
||||||
|
## It used to serve html/index.html ("shared-ols", 11 bytes) with HTTP 200 to
|
||||||
|
## anything that fell through. Measured 2026-08: three live customer sites
|
||||||
|
## (their vhost had silently stopped being rendered) served that 200 for ~2
|
||||||
|
## months and no monitor noticed, because every uptime check asks "is it 200?"
|
||||||
|
## and the answer was yes. A hostname this server cannot serve now gets
|
||||||
|
## 421 Misdirected Request -- semantically exact (RFC 7540 s9.1.2: the server is
|
||||||
|
## not able to produce a response for the combination of scheme and authority in
|
||||||
|
## the request URI) and unambiguous to monitoring in a way 404 is not, since a
|
||||||
|
## 404 is a perfectly normal answer from a real, working site.
|
||||||
|
##
|
||||||
|
## THE DISCRIMINATOR: request path /healthz AND an INTERNAL client address.
|
||||||
|
## * Path alone is not enough -- anyone can request /healthz.
|
||||||
|
## * REMOTE_ADDR is the half an outside caller cannot choose, BECAUSE of
|
||||||
|
## `useIpInProxyHeader 1` in httpd_config_base.tpl: OLS resolves the client
|
||||||
|
## IP from X-Forwarded-For, and HAProxy -- the only thing that can reach
|
||||||
|
## this tier, which has no host-published ports and sits on client-net --
|
||||||
|
## SETS (not appends) that header:
|
||||||
|
## `http-request set-header X-Forwarded-For %[var(txn.real_ip)]` in
|
||||||
|
## haproxy-manager-base/templates/hap_backend.tpl, which DISCARDS whatever
|
||||||
|
## the client sent. So a request arriving from outside carries the real
|
||||||
|
## public client IP. Verified on the lab: `-H 'X-Forwarded-For: 8.8.8.8'`
|
||||||
|
## on /healthz returns 421.
|
||||||
|
## * MEASURED LIMIT OF THE IP GATE, stated plainly rather than assumed away:
|
||||||
|
## OLS takes the FIRST element of a multi-value X-Forwarded-For as
|
||||||
|
## REMOTE_ADDR. `X-Forwarded-For: 10.0.0.1, 8.8.8.8` returns 200 on /healthz
|
||||||
|
## here, and anchoring the pattern ^...$ does NOT change that (tested both
|
||||||
|
## ways) -- because by the time the rule sees REMOTE_ADDR it is already the
|
||||||
|
## single token `10.0.0.1`. The anchors are kept because they are correct
|
||||||
|
## and free, not because they close that hole. What closes it is HAProxy:
|
||||||
|
## `http-request set-header X-Forwarded-For %[var(txn.real_ip)]` REPLACES
|
||||||
|
## whatever the client sent with one value.
|
||||||
|
## * AND THE GATE IS NOT LOAD-BEARING ANYWAY. It only guards /healthz. `/`,
|
||||||
|
## and every other path, is 421 UNCONDITIONALLY -- no header, source
|
||||||
|
## address or Host can talk this vhost into a 200 there. So even a total
|
||||||
|
## bypass of the IP gate buys an attacker a 3-byte `ok` on /healthz, never
|
||||||
|
## a "the site is up" answer on the URL a monitor actually requests. That
|
||||||
|
## is the property this change exists to guarantee, and it does not rest on
|
||||||
|
## anything spoofable.
|
||||||
|
## * The probes that MUST keep passing all originate inside: the Docker
|
||||||
|
## HEALTHCHECK (`curl -sfk https://127.0.0.1/healthz` in Dockerfile.shared-ols,
|
||||||
|
## overridden by WHP's setup-shared-ols.sh to `https://localhost/healthz`)
|
||||||
|
## connects over loopback and sends no X-Forwarded-For, so REMOTE_ADDR falls
|
||||||
|
## back to the peer, 127.0.0.1. An edge/host probe of the container IP comes
|
||||||
|
## from the docker gateway (172.16/12), also allowed.
|
||||||
|
##
|
||||||
|
## `/` is 421 for EVERY client, internal ones included -- there is deliberately
|
||||||
|
## no "internal clients still get the old 200 page" escape hatch, because that
|
||||||
|
## is exactly the response that hid the outage. Anything probing this tier for
|
||||||
|
## liveness must ask for /healthz.
|
||||||
|
##
|
||||||
|
## WHY REWRITE AND NOT A REDIRECT CONTEXT: `context / { type redirect
|
||||||
|
## statusCode 421 }` was measured on this image (OLS 1.8.4) and does NOT work --
|
||||||
|
## 421 is not in OLS's accepted status-code list, so it silently degrades to a
|
||||||
|
## 302 with a literal, unexpanded `Location: $DOC_ROOT/?`. A rewrite `[R=421,L]`
|
||||||
|
## does emit a real 421.
|
||||||
|
##
|
||||||
|
## WHY THE THE_REQUEST GUARD ON THE ERROR PAGE: a bare [R=421] has no body, and
|
||||||
|
## a bare 421 with no explanation is a support ticket. `errorpage 421` supplies
|
||||||
|
## the body, but OLS fetches that URL as a fresh internal request that runs
|
||||||
|
## through these same rules -- without an exception it is itself 421'd and the
|
||||||
|
## body comes back empty (measured: content-length 0). %{IS_SUBREQ} and
|
||||||
|
## %{ENV:REDIRECT_STATUS} are NOT populated by OLS's rewrite engine (both
|
||||||
|
## measured, both no-ops), but %{THE_REQUEST} keeps the ORIGINAL request line
|
||||||
|
## across the internal fetch. So: serve misdirected.html when the client did not
|
||||||
|
## itself ask for it, which lets the error page render while a direct external
|
||||||
|
## GET /misdirected.html still gets 421 -- no path on this catch-all answers 200
|
||||||
|
## to an outside caller.
|
||||||
|
##
|
||||||
|
## The body is deliberately generic: no branding, no customer names, nothing
|
||||||
|
## that reveals which hostnames this server does serve. Every unmapped Host and
|
||||||
|
## every path gets the byte-identical 421, so the response cannot be used to
|
||||||
|
## enumerate configured vs unconfigured hostnames.
|
||||||
|
cat > "$HEALTH_DIR/vhconf.conf" <<'EOF'
|
||||||
|
docRoot $VH_ROOT/html
|
||||||
|
enableScript 0
|
||||||
|
|
||||||
|
errorpage 421 {
|
||||||
|
url /misdirected.html
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrite {
|
||||||
|
enable 1
|
||||||
|
rules <<<END_rules
|
||||||
|
RewriteCond %{THE_REQUEST} !\s/+misdirected\.html
|
||||||
|
RewriteRule ^/?misdirected\.html$ - [L]
|
||||||
|
RewriteCond %{REMOTE_ADDR} ^(127\.0\.0\.1|::1|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+)$
|
||||||
|
RewriteRule ^/?healthz$ - [L]
|
||||||
|
RewriteRule .* - [R=421,L]
|
||||||
|
END_rules
|
||||||
|
}
|
||||||
|
|
||||||
|
context / {
|
||||||
|
allowBrowse 1
|
||||||
|
location $DOC_ROOT/
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
printf 'ok\n' > "$HEALTH_DIR/html/healthz"
|
||||||
|
cat > "$HEALTH_DIR/html/misdirected.html" <<'EOF'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>421 Misdirected Request</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>421 Misdirected Request</h1>
|
||||||
|
<p>This hostname is not configured on this server.</p>
|
||||||
|
<p>If you own this domain, check that its DNS points to the correct server and
|
||||||
|
that the site is active in your hosting control panel.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
EOF
|
||||||
|
## The old catch-all index.html ("shared-ols") is gone on purpose, and actively
|
||||||
|
## removed so an in-place upgrade of a long-lived container cannot leave it
|
||||||
|
## behind. If these rewrite rules were ever to stop applying, `context /` would
|
||||||
|
## fall back to serving the docRoot index -- with no index.html that is a 403,
|
||||||
|
## which is wrong-but-loud, instead of a 200 that is wrong-and-silent.
|
||||||
|
rm -f "$HEALTH_DIR/html/index.html"
|
||||||
|
|
||||||
|
## ---- ownership: OLS reads conf/ as lsadm. chown the base conf dir + health dir
|
||||||
|
## NON-recursively (the per-site files under conf/shared-sites are written by the
|
||||||
|
## panel and are world-readable; a recursive chown here would be O(N-sites) on
|
||||||
|
## every container (re)start, delaying first-listen after a crash). The render
|
||||||
|
## script chowns the httpd_config.conf it produces. ----
|
||||||
|
chown lsadm:nogroup "$LSWS_CONF" "$HEALTH_DIR" "$HEALTH_DIR/html" 2>/dev/null || true
|
||||||
|
chown lsadm:nogroup "$HEALTH_DIR/vhconf.conf" "$HEALTH_DIR/html/healthz" "$HEALTH_DIR/html/misdirected.html" 2>/dev/null || true
|
||||||
|
|
||||||
|
## ---- assemble httpd_config.conf from the panel's per-site files ----
|
||||||
|
/scripts/render-shared-ols-config.sh
|
||||||
|
|
||||||
|
## ---- stream OLS logs to PID-1 stdout (follows across restarts) ----
|
||||||
|
mkdir -p /usr/local/lsws/logs
|
||||||
|
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
|
||||||
|
tail -F /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log 2>/dev/null &
|
||||||
|
|
||||||
|
## ---- .htaccess watcher (required; spec 5.3). Background; the panel monitors
|
||||||
|
## that it stays alive (its death silently stops rewrite changes applying). ----
|
||||||
|
/scripts/ols-htaccess-watcher.sh &
|
||||||
|
WATCHER_PID=$!
|
||||||
|
|
||||||
|
## ---- supervise OLS in DAEMON mode (verbatim model from entrypoint-litespeed.sh) ----
|
||||||
|
STOP_REQUESTED=0
|
||||||
|
term_handler() {
|
||||||
|
STOP_REQUESTED=1
|
||||||
|
kill "$WATCHER_PID" 2>/dev/null || true
|
||||||
|
/usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap term_handler TERM INT
|
||||||
|
|
||||||
|
## NOT `lswsctrl status` (unlike the otherwise-identical function in
|
||||||
|
## entrypoint-litespeed.sh). `lswsctrl` appends a timestamped line to
|
||||||
|
## logs/lsrestart.log on EVERY invocation it makes, including `status` — and
|
||||||
|
## this loop polls every 3s forever. Measured on whp01: lsrestart.log is 96 MB,
|
||||||
|
## holding 1,819,286 `status` lines against 2,429 real `restart` lines; at one
|
||||||
|
## poll per 3s that's ~63 days of continuous polling, which is exactly the
|
||||||
|
## file's age, and it isn't rotated on any host (whp01/whp02/sdbees all growing
|
||||||
|
## at ~1.5 MB/day). So: check liveness directly instead of shelling out to a
|
||||||
|
## tool whose logging is a side effect we don't want on a fixed timer.
|
||||||
|
##
|
||||||
|
## Verified (docker run litespeedtech/openlitespeed:1.8.4-lsphp83, the exact
|
||||||
|
## base this image is built FROM — see Dockerfile.shared-ols): the running main
|
||||||
|
## process shows in `ps` as `openlitespeed (lshttpd - main)`, one PID, always
|
||||||
|
## present while OLS is up and absent the instant it is killed (checked via
|
||||||
|
## `ps aux` immediately after `kill -9` on the main PID). `pgrep -f` matches
|
||||||
|
## against the full command line, and no other process on this image's `ps`
|
||||||
|
## output contains that string, so this cannot cross-match an unrelated
|
||||||
|
## process. It also cannot self-match: pgrep excludes its own PID by default,
|
||||||
|
## and the invoking process here is bash executing this script file, whose own
|
||||||
|
## argv never contains the pattern text (only the *source lines* of this script
|
||||||
|
## do, which `pgrep -f` never sees).
|
||||||
|
##
|
||||||
|
## Deliberately NOT the pidfile (/tmp/lshttpd/lshttpd.pid, confirmed present in
|
||||||
|
## the same probe): pidfiles are known to go stale across a crash (verified —
|
||||||
|
## after `kill -9` the file still held the dead PID), and treating a stale PID
|
||||||
|
## as "alive" if the kernel ever reuses that number is a false positive this
|
||||||
|
## supervisor cannot afford (see below). `pgrep -f` reads the live process
|
||||||
|
## table, so there is no staleness window to reason about.
|
||||||
|
##
|
||||||
|
## Conservative on both failure directions, which matters because this is a
|
||||||
|
## supervisor predicate, not a metric: a false negative makes start_ols() run
|
||||||
|
## `lswsctrl start` against an already-running OLS — verified against the same
|
||||||
|
## probe base image, that is NOT a no-op, it sends SIGUSR1 to the live main
|
||||||
|
## process, i.e. the same graceful self-restart QUIC.cloud IP refreshes trigger
|
||||||
|
## (see entrypoint-litespeed.sh's note on that handoff) — a brief, zero-
|
||||||
|
## downtime blip at worst. A false positive is worse: it leaves a genuinely
|
||||||
|
## dead OLS un-revived until some later poll happens to notice. So if this
|
||||||
|
## predicate is ever in doubt it should err toward reporting "not running", not
|
||||||
|
## "running".
|
||||||
|
ols_running() {
|
||||||
|
pgrep -f 'lshttpd - main' >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_STARTS=5
|
||||||
|
WINDOW=60
|
||||||
|
starts=""
|
||||||
|
|
||||||
|
start_ols() {
|
||||||
|
/usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
ols_running && return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! start_ols; then
|
||||||
|
echo "entrypoint-shared-ols: OLS failed to start (not running after 10s)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "entrypoint-shared-ols: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if ols_running; then
|
||||||
|
sleep 3
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$STOP_REQUESTED" -eq 1 ]; then
|
||||||
|
echo "entrypoint-shared-ols: SIGTERM received, OLS stopped — exiting."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
now=$(date +%s)
|
||||||
|
starts="$starts $now"
|
||||||
|
pruned=""
|
||||||
|
for t in $starts; do
|
||||||
|
[ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t"
|
||||||
|
done
|
||||||
|
starts="$pruned"
|
||||||
|
n=$(echo $starts | wc -w)
|
||||||
|
echo "entrypoint-shared-ols: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2
|
||||||
|
if [ "$n" -ge "$MAX_STARTS" ]; then
|
||||||
|
echo "entrypoint-shared-ols: OLS crash-looping — bailing for Docker restart policy / monitor." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
start_ols || true
|
||||||
|
done
|
||||||
+63
-13
@@ -1,29 +1,56 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
if [ -z "$PHPVER" ]; then
|
if [ -z "$PHPVER" ]; then
|
||||||
PHPVER="81";
|
PHPVER="83";
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$environment" ]; then
|
||||||
|
environment="PROD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
adduser -u $uid $user
|
adduser -u $uid $user
|
||||||
|
|
||||||
mkdir -p /home/$user/public_html
|
mkdir -p /home/$user/public_html
|
||||||
|
mkdir -p /home/$user/logs/{apache,php-fpm}
|
||||||
|
|
||||||
chown -R $user:$user /home/$user
|
mv /var/log/httpd /var/log/httpd.bak
|
||||||
chmod -R 755 /home/$user
|
|
||||||
|
|
||||||
/scripts/install-php$PHPVER.sh
|
ln -s /home/$user/logs/apache /var/log/httpd
|
||||||
|
ln -s /home/$user/logs/php-fpm /var/log/php-fpm
|
||||||
|
|
||||||
|
rm -f /etc/httpd/conf.d/userdir.conf
|
||||||
|
docker_network=$(ip addr show |grep eth0 |grep inet |awk -F " " {'print $2'})
|
||||||
|
echo "RemoteIPInternalProxy $docker_network" >> /etc/httpd/conf.d/remoteip.conf
|
||||||
|
# /scripts/install-php$PHPVER.sh
|
||||||
|
|
||||||
|
source /scripts/detect-memory.sh
|
||||||
|
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_children=${PHP_FPM_MAX_CHILDREN} | Apache workers=${APACHE_MAX_REQUEST_WORKERS}"
|
||||||
|
|
||||||
/scripts/create-vhost.sh
|
/scripts/create-vhost.sh
|
||||||
/scripts/create-php-config.sh
|
/scripts/create-php-config.sh
|
||||||
|
/scripts/create-apache-mpm-config.sh
|
||||||
|
|
||||||
|
if [ -f /etc/httpd/conf.d/ssl.conf ]; then
|
||||||
|
mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
|
||||||
|
fi
|
||||||
|
|
||||||
/usr/sbin/httpd -k start
|
/usr/sbin/httpd -k start
|
||||||
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
/usr/sbin/php-fpm -y /etc/php-fpm.conf
|
||||||
|
chown -R $user:$user /home/$user
|
||||||
|
chmod -R 755 /home/$user
|
||||||
|
|
||||||
if [[ $environment == 'DEV' ]]; then
|
if [[ $environment == 'DEV' ]]; then
|
||||||
echo "Starting Dev Deployment"
|
echo "Starting Dev Deployment"
|
||||||
dnf install -y MariaDB-server MariaDB-client memcached
|
mkdir -p /home/$user/_db_backups
|
||||||
|
# Ensure microdnf is available for installing MariaDB and memcached in DEV mode
|
||||||
|
if ! command -v microdnf &> /dev/null; then
|
||||||
|
echo "microdnf not found, installing with dnf..."
|
||||||
|
dnf install -y microdnf && dnf clean all
|
||||||
|
fi
|
||||||
|
microdnf install -y MariaDB-server MariaDB-client memcached
|
||||||
|
sed -r -i 's/session.save_path="memcache:11211/session.save_path="localhost:11211/' /etc/php.ini
|
||||||
nohup mysqld -umysql &
|
nohup mysqld -umysql &
|
||||||
if [ ! -f /var/lib/mysql/creds ]; then
|
if [ ! -f /home/$user/mysql_creds ]; then
|
||||||
echo "Give MySQL a chance to finish starting..."
|
echo "Give MySQL a chance to finish starting..."
|
||||||
sleep 10
|
sleep 10
|
||||||
mysql_user=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')
|
mysql_user=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13 ; echo '')
|
||||||
@@ -33,14 +60,37 @@ if [[ $environment == 'DEV' ]]; then
|
|||||||
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
mysql -e "CREATE USER '"$mysql_user"'@'localhost' IDENTIFIED BY '"$mysql_password"';"
|
||||||
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '"$mysql_user"'@'localhost' WITH GRANT OPTION;"
|
||||||
mysql -e "FLUSH PRIVILEGES;"
|
mysql -e "FLUSH PRIVILEGES;"
|
||||||
echo "MySQL User: "$mysql_user > /var/lib/mysql/creds
|
# Create user crontab with MySQL backup job
|
||||||
echo "MySQL Password: "$mysql_password >> /var/lib/mysql/creds
|
echo "# User crontab for $user" > /home/$user/crontab
|
||||||
echo "MySQL Database: devdb_"$mysql_db >> /var/lib/mysql/creds
|
echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
|
||||||
cat /var/lib/mysql/creds
|
chown $user:$user /home/$user/crontab
|
||||||
|
echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
|
||||||
|
echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
|
||||||
|
echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
|
||||||
|
cat /home/$user/mysql_creds
|
||||||
|
|
||||||
fi
|
fi
|
||||||
/usr/bin/memcached -d -u $user
|
/usr/bin/memcached -d -u $user
|
||||||
|
|
||||||
fi
|
fi
|
||||||
tail -f /etc/httpd/logs/*
|
|
||||||
|
if [[ $environment == 'PROD' ]]; then
|
||||||
|
sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set up user crontab
|
||||||
|
if [ ! -f /home/$user/crontab ]; then
|
||||||
|
echo "# User crontab for $user" > /home/$user/crontab
|
||||||
|
echo "# Add your cron jobs here" >> /home/$user/crontab
|
||||||
|
echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
|
||||||
|
chown $user:$user /home/$user/crontab
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load user crontab
|
||||||
|
crontab -u $user /home/$user/crontab
|
||||||
|
|
||||||
|
/usr/sbin/crond
|
||||||
|
tail -f /var/log/httpd/*
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## install-lscache-wp.sh — auto-install the official litespeed-cache plugin
|
||||||
|
## on first boot if WP is detected and plugin not already managed.
|
||||||
|
## Idempotent: re-runs are no-ops. Honors LSCACHE_AUTOINSTALL=0 escape hatch.
|
||||||
|
##
|
||||||
|
## Args: $1 = $user (customer system user)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
user="${1:?usage: install-lscache-wp.sh <user>}"
|
||||||
|
home="/home/${user}"
|
||||||
|
|
||||||
|
if [ "${LSCACHE_AUTOINSTALL:-1}" = "0" ]; then
|
||||||
|
echo "[lscache] LSCACHE_AUTOINSTALL=0 — skipping plugin install."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$home/public_html/wp-config.php" ]; then
|
||||||
|
echo "[lscache] No wp-config.php in $home/public_html — skipping (not a WP site)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
## With setUIDMode 2, lsphp runs as the customer, and customer owns their
|
||||||
|
## home tree — wp-cli also runs as the customer, files end up correctly owned.
|
||||||
|
if ! command -v wp >/dev/null 2>&1; then
|
||||||
|
echo "[lscache] wp-cli not on PATH — skipping (image build issue, not fatal)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sudo -u "$user" -- wp --path="$home/public_html" plugin is-installed litespeed-cache 2>/dev/null; then
|
||||||
|
echo "[lscache] litespeed-cache already installed — leaving customer's settings alone."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[lscache] Installing litespeed-cache plugin for $user…"
|
||||||
|
sudo -u "$user" -- wp --path="$home/public_html" plugin install litespeed-cache --activate \
|
||||||
|
|| { echo "[lscache] plugin install failed (network? wp-cli? perms?) — non-fatal."; exit 0; }
|
||||||
|
|
||||||
|
echo "[lscache] Done."
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
dnf module enable php:remi-7.4 -y
|
dnf module enable php:remi-7.4 -y
|
||||||
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-xmlrpc \
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-xmlrpc \
|
||||||
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd libzip php-cli
|
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd php-pgsql libzip php-cli
|
||||||
exit 0
|
exit 0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
dnf module enable php:remi-8.0 -y
|
dnf module enable php:remi-8.0 -y
|
||||||
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
||||||
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd libzip php-cli
|
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
|
||||||
exit 0
|
exit 0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
dnf module enable php:remi-8.1 -y
|
dnf module enable php:remi-8.1 -y
|
||||||
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
||||||
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
php-mysqlnd php-mbstring php-ioncube-loader php-intl php-gd libzip php-cli
|
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
|
||||||
exit 0
|
exit 0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
dnf module enable php:remi-8.2 -y
|
dnf module enable php:remi-8.2 -y
|
||||||
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
||||||
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
php-mysqlnd php-mbstring php-intl php-gd libzip php-cli
|
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
|
||||||
exit 0
|
exit 0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
dnf module enable php:remi-8.3 -y
|
||||||
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
||||||
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
|
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
dnf module enable php:remi-8.4 -y
|
||||||
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
||||||
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
|
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
dnf module enable php:remi-8.5 -y
|
||||||
|
dnf install -y php php-fpm php-mysqlnd php-xml php-pecl-zip php-sodium php-soap php-pecl-xmlrpc \
|
||||||
|
php-pecl-redis5 php-pecl-memcached php-pecl-memcache php-pecl-ip2location php-pecl-imagick php-pecl-geoip \
|
||||||
|
php-mysqlnd php-mbstring php-intl php-gd php-pgsql libzip php-cli
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Set the log directory
|
||||||
|
LOG_DIR="/var/log/httpd"
|
||||||
|
|
||||||
|
# Get current date
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# Rotate access log
|
||||||
|
if [ -f "$LOG_DIR/access_log" ]; then
|
||||||
|
cp "$LOG_DIR/access_log" "$LOG_DIR/access_log.$DATE"
|
||||||
|
cat /dev/null > "$LOG_DIR/access_log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rotate error log
|
||||||
|
if [ -f "$LOG_DIR/error_log" ]; then
|
||||||
|
cp "$LOG_DIR/error_log" "$LOG_DIR/error_log.$DATE"
|
||||||
|
cat /dev/null > "$LOG_DIR/error_log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress logs older than 3 days
|
||||||
|
find "$LOG_DIR" -name "*.log.*" -type f -mtime +3 -exec gzip {} \;
|
||||||
|
|
||||||
|
# Delete logs older than 7 days
|
||||||
|
find "$LOG_DIR" -name "*.log.*" -type f -mtime +7 -delete
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
user=$1
|
||||||
|
mysql_db=$2
|
||||||
|
dt=$(date +%y%m%d-%T)
|
||||||
|
if [ ! -d /home/$user/_db_backups ]; then
|
||||||
|
mkdir -p /home/$user/_db_backups
|
||||||
|
fi
|
||||||
|
|
||||||
|
/usr/bin/mysqldump $mysql_db > /home/$user/_db_backups/$mysql_db.$dt.sql
|
||||||
|
chown -R $user:$user /home/$user/_db_backups
|
||||||
|
/usr/bin/find /home/$user/_db_backups/ -type f -mmin +360 -delete
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## ols-htaccess-watcher.sh — graceful-restart the shared OLS when any tenant's
|
||||||
|
## .htaccess changes. OLS reads .htaccess (RewriteFile) at (re)start, NOT per
|
||||||
|
## request, so without this a WordPress permalink/LiteSpeed-Cache change would
|
||||||
|
## silently not take effect. Required by spec 5.3.
|
||||||
|
##
|
||||||
|
## Watches all docroots for .htaccess writes, COALESCES bursts (a WP plugin save
|
||||||
|
## touches the file several times) within a debounce window, and RATE-LIMITS to
|
||||||
|
## a floor (one restart per FLOOR seconds) so many tenants saving at once can't
|
||||||
|
## trigger a restart storm. Debounce/floor are env-tunable (panel discloses the
|
||||||
|
## resulting "~60s" window to customers).
|
||||||
|
##
|
||||||
|
## Failure of THIS process is the silent-ticket failure mode (spec 7): if it
|
||||||
|
## dies, tenants' rewrite changes stop applying with no error. The entrypoint
|
||||||
|
## runs it and the panel monitors it (check-ols-htaccess-watcher.php).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
## WATCH_ROOT is deliberately left as the host-wide /mnt/users, not narrowed to
|
||||||
|
## the shared-OLS tenant set, even though that set IS derivable in-container
|
||||||
|
## (render-shared-ols-config.sh's $SITES_ROOT/*/site.meta VHROOT= is exactly
|
||||||
|
## that list). Narrowing it would mean handing inotifywait a fixed argv list of
|
||||||
|
## VHROOT dirs at process start — and inotifywait cannot be told to watch a NEW
|
||||||
|
## directory once running. The panel provisions sites onto this container live,
|
||||||
|
## between renders; a site added after the watcher started would then sit
|
||||||
|
## outside every watch until the next container restart, i.e. exactly the
|
||||||
|
## silent-failure mode (spec 7) this script exists to prevent, now for brand
|
||||||
|
## new tenants instead of none. Doing this safely needs a reload path (SIGHUP
|
||||||
|
## re-exec off the current site.meta list, coordinated with
|
||||||
|
## render-shared-ols-config.sh) that does not exist yet and is its own change.
|
||||||
|
## So: WATCH_ROOT stays broad, and correctness comes entirely from the path
|
||||||
|
## match below, which is sufficient on its own.
|
||||||
|
WATCH_ROOT="${OLS_WATCH_ROOT:-/mnt/users}"
|
||||||
|
DEBOUNCE="${OLS_HTACCESS_DEBOUNCE:-15}" # coalesce window (s)
|
||||||
|
FLOOR="${OLS_HTACCESS_FLOOR:-60}" # min seconds between restarts
|
||||||
|
LSWSCTRL=/usr/local/lsws/bin/lswsctrl
|
||||||
|
last_restart=0
|
||||||
|
|
||||||
|
log() { echo "ols-htaccess-watcher: $*" >&2; }
|
||||||
|
|
||||||
|
do_restart() {
|
||||||
|
path="$1"
|
||||||
|
now=$(date +%s)
|
||||||
|
if [ $((now - last_restart)) -lt "$FLOOR" ]; then
|
||||||
|
log "within ${FLOOR}s floor — coalescing, skipping restart ($path)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if "$LSWSCTRL" restart >/dev/null 2>&1; then
|
||||||
|
last_restart=$now
|
||||||
|
log "graceful restart issued — $path changed"
|
||||||
|
else
|
||||||
|
log "WARNING: lswsctrl restart failed ($path)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! command -v inotifywait >/dev/null 2>&1; then
|
||||||
|
log "FATAL: inotifywait not installed (inotify-tools)"; exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$WATCH_ROOT"
|
||||||
|
log "watching $WATCH_ROOT for docroot (public_html) .htaccess changes (debounce=${DEBOUNCE}s floor=${FLOOR}s)"
|
||||||
|
|
||||||
|
## -m monitor, -r recursive. We filter in the read loop rather than --include
|
||||||
|
## so this works on older inotify-tools too. modify/create/delete/move all
|
||||||
|
## matter (delete of .htaccess also changes rewrite behavior).
|
||||||
|
##
|
||||||
|
## --format '%w%f' (full path), NOT '%f' (basename only). OLS reads .htaccess
|
||||||
|
## (RewriteFile) only from a vhost's DOCROOT — VHROOT, i.e.
|
||||||
|
## /mnt/users/<user>/<domain>/public_html (see render-shared-ols-config.sh /
|
||||||
|
## entrypoint-lsphp.sh) — never anything below it. A basename-only match fires
|
||||||
|
## for ANY .htaccess anywhere under a tenant, at any depth, and WordPress
|
||||||
|
## plugins write plenty of those that OLS never opens: measured on whp01 over
|
||||||
|
## 24h, this watcher fired 63 restarts, of which the docroot .htaccess actually
|
||||||
|
## changed in 0. All 28 distinct files behind those 63 were plugin guard files
|
||||||
|
## — Wordfence self-healing waf/views/vendor/tmp/models/lib/.htaccess, W3 Total
|
||||||
|
## Cache writing one per cached URL under wp-content/cache/page_enhanced/, plus
|
||||||
|
## WPForms/Gravity Forms/UpdraftPlus/Groundhogg/WP Staging upload guards — and
|
||||||
|
## most of those tenants are on the shared Apache tier (cac-fpm), not this OLS
|
||||||
|
## tier at all, so their cache churn was restarting the OLS serving 15 unrelated
|
||||||
|
## tenants for no reason. Matching the full path down to /public_html/.htaccess
|
||||||
|
## is what actually ties a change to something OLS will reread.
|
||||||
|
inotifywait -m -r -e modify,create,delete,move "$WATCH_ROOT" --format '%w%f' 2>/dev/null |
|
||||||
|
while read -r path; do
|
||||||
|
case "$path" in
|
||||||
|
*/public_html/.htaccess) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
## A tenant DOCROOT .htaccess changed. Coalesce the save-burst, then restart ONCE.
|
||||||
|
##
|
||||||
|
## The coalesce is HARD-BOUNDED to DEBOUNCE seconds: a previous version blocked
|
||||||
|
## on `read -t DEBOUNCE` which, on a busy multi-tenant server, never timed out
|
||||||
|
## (unrelated file writes under $WATCH_ROOT kept resetting it) — so the restart
|
||||||
|
## was starved and rewrite changes silently never applied. Here we read further
|
||||||
|
## events only until the deadline OR ~2s of total quiet, whichever comes first,
|
||||||
|
## so continuous activity can delay us by at most DEBOUNCE. do_restart's FLOOR
|
||||||
|
## then rate-limits across consecutive bursts.
|
||||||
|
deadline=$(( $(date +%s) + DEBOUNCE ))
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
|
if read -r -t 2 _; then
|
||||||
|
continue # more activity — keep coalescing toward the deadline
|
||||||
|
else
|
||||||
|
break # ~2s of total quiet — the burst has settled
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
do_restart "$path"
|
||||||
|
done
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## render-shared-ols-config.sh — assemble httpd_config.conf for the shared-ols
|
||||||
|
## tier from the per-site files the WHP panel drops into $SITES_ROOT.
|
||||||
|
##
|
||||||
|
## WHY THIS EXISTS: OpenLiteSpeed has NO top-level `include` directive (unlike
|
||||||
|
## Apache's IncludeOptional that shared-httpd relies on). So we cannot just drop
|
||||||
|
## per-vhost files in a dir and have OLS pick them up — the listener `map` lines
|
||||||
|
## and the vhost stanzas must live IN httpd_config.conf. This script is the
|
||||||
|
## "include" OLS lacks: it concatenates the panel's per-site pieces into one
|
||||||
|
## valid httpd_config.conf, then the caller issues `lswsctrl restart`.
|
||||||
|
## (Empirically established 2026-06-10 — see the OLS-tier PoC.)
|
||||||
|
##
|
||||||
|
## Per-site contract — the panel writes, for each site, a directory:
|
||||||
|
## $SITES_ROOT/<vhname>/vhconf.conf (rendered by the WHP panel from its own
|
||||||
|
## web-files/configs/shared-ols-vhconf-template.tpl
|
||||||
|
## — the single source of truth for vhost detail)
|
||||||
|
## $SITES_ROOT/<vhname>/site.meta (VHNAME=, VHROOT=, DOMAINS=a.com,www.a.com)
|
||||||
|
## This script turns each into a `virtualhost {configFile}` stanza + a listener
|
||||||
|
## `map` line. A site dir missing either file is skipped (logged).
|
||||||
|
##
|
||||||
|
## Idempotent: always rebuilds from the stock config, so re-runs never compound.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LSWS_CONF=/usr/local/lsws/conf
|
||||||
|
TPL_DIR=${TPL_DIR:-/etc/shared-ols-templates}
|
||||||
|
SITES_ROOT=${SITES_ROOT:-$LSWS_CONF/shared-sites}
|
||||||
|
LSCACHE_ROOT=${LSCACHE_ROOT:-/var/lscache}
|
||||||
|
CERT_FILE=${CERT_FILE:-$LSWS_CONF/cert/shared-ols.crt}
|
||||||
|
KEY_FILE=${KEY_FILE:-$LSWS_CONF/cert/shared-ols.key}
|
||||||
|
export LSCACHE_ROOT
|
||||||
|
|
||||||
|
OUT="$LSWS_CONF/httpd_config.conf"
|
||||||
|
TMP="$LSWS_CONF/.httpd_config.conf.tmp.$$"
|
||||||
|
STOCK="/usr/local/lsws/.conf/httpd_config.conf"
|
||||||
|
|
||||||
|
mkdir -p "$SITES_ROOT" "$LSCACHE_ROOT"
|
||||||
|
|
||||||
|
## --- SERIALIZE concurrent renders + write ATOMICALLY ---
|
||||||
|
## The panel can fire two renders at once (parallel provisioning), and the
|
||||||
|
## in-container .htaccess watcher issues `lswsctrl restart` independently. If OLS
|
||||||
|
## (re)reads httpd_config.conf while it's half-written, it fails to parse and the
|
||||||
|
## whole tier 503s. So: (1) flock so only one render runs at a time; (2) build
|
||||||
|
## into $TMP and atomically `mv` into place at the end, so any concurrent OLS
|
||||||
|
## restart always sees a COMPLETE config (the old one until the instant of mv).
|
||||||
|
exec 9>"$LSWS_CONF/.render.lock"
|
||||||
|
## Bounded wait (-w): if a previous render is hung, fail after 30s rather than
|
||||||
|
## blocking the panel's `docker exec` call (and thus the site-save request)
|
||||||
|
## indefinitely. The caller re-tries on the next change.
|
||||||
|
flock -w 30 9 || { echo "render-shared-ols: could not acquire render lock within 30s" >&2; exit 1; }
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
## Sweep any stale temp configs left by a prior SIGKILL (trap EXIT doesn't run on
|
||||||
|
## SIGKILL); each render uses a unique $$ suffix so this never races a live render.
|
||||||
|
rm -f "$LSWS_CONF"/.httpd_config.conf.tmp.* 2>/dev/null || true
|
||||||
|
## From here on, build into $TMP (not $OUT).
|
||||||
|
|
||||||
|
## --- 1. start from a pristine stock config (idempotent) ---
|
||||||
|
if [ ! -f "$STOCK" ]; then
|
||||||
|
## Some image builds keep the only copy at conf/; snapshot it once so future
|
||||||
|
## renders have a clean base to strip.
|
||||||
|
mkdir -p "$(dirname "$STOCK")"
|
||||||
|
cp "$OUT" "$STOCK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
## --- 2. strip stock blocks that conflict or would run PHP LOCALLY ---
|
||||||
|
## extProcessor lsphp (autoStart 1, uds) + the server scriptHandler are removed
|
||||||
|
## so this server NEVER executes PHP itself — all PHP goes to remote sidecars.
|
||||||
|
## listener HTTP/HTTPS + vhTemplate docker are removed (we add our own).
|
||||||
|
awk '
|
||||||
|
/^listener HTTP \{/ { skip=1; next }
|
||||||
|
/^listener HTTPS \{/ { skip=1; next }
|
||||||
|
/^vhTemplate docker ?\{/ { skip=1; next }
|
||||||
|
/^extProcessor lsphp ?\{/{ skip=1; next }
|
||||||
|
/^scriptHandler ?\{/ { skip=1; next }
|
||||||
|
skip && /^\}/ { skip=0; next }
|
||||||
|
!skip { print }
|
||||||
|
' "$STOCK" > "$TMP"
|
||||||
|
|
||||||
|
## --- 3. append our server-level base (real-IP, cache module, no local PHP) ---
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
envsubst '${LSCACHE_ROOT}' < "$TPL_DIR/httpd_config_base.tpl"
|
||||||
|
} >> "$TMP"
|
||||||
|
|
||||||
|
## --- 4. emit per-site vhost stanzas + collect listener map lines ---
|
||||||
|
##
|
||||||
|
## First value of KEY= in a site.meta, as plain data. This replaces
|
||||||
|
## `sed -n 's/^KEY=//p' "$meta" | head -1`, which was a pipeline whose reader
|
||||||
|
## (`head -1`) exits after one line while the writer (`sed`) may still be
|
||||||
|
## flushing: the writer then dies 141, and `set -euo pipefail` (line 22) makes
|
||||||
|
## the whole ASSIGNMENT fail, which aborts this script mid-render. A truncated
|
||||||
|
## httpd_config.conf is never written (the render is atomic), but the effect is
|
||||||
|
## that a site the panel just provisioned silently never appears in the config
|
||||||
|
## and every subsequent render fails the same way.
|
||||||
|
##
|
||||||
|
## Measured in this image, `sed -n 's/^DOMAINS=//p' | head -1`:
|
||||||
|
## 400 matching lines (~6 KB of sed output) -> 0 0 0 0 0
|
||||||
|
## 6000 matching lines (~90 KB of sed output) -> 141 141 141
|
||||||
|
## Do not read a threshold into those two rows. There is no size below which
|
||||||
|
## this is safe: each run is a RACE on whether `head` closes the pipe before
|
||||||
|
## `sed`'s final write() returns, and the payload only decides how many write()
|
||||||
|
## syscalls sed has to lose. Measured against a default 65536-byte pipe
|
||||||
|
## (F_GETPIPE_SZ), 41144 bytes SIGPIPEd on 32 of 300 runs — 11%, well under
|
||||||
|
## capacity — and 65012 bytes still only on 25 of 30, so it is neither safe
|
||||||
|
## below capacity nor certain at it; strace caught a writer dying having put
|
||||||
|
## 12086 of 40406 bytes into a 65536-byte pipe. What actually separated a host
|
||||||
|
## that failed 5/5 from one that passed 10/10 was the WRITER's syscall size
|
||||||
|
## (bash <= 5.2.15 writes ~37 KB at a time, >= 5.2.21 writes 80-160 bytes), not
|
||||||
|
## the host's pipe capacity. (An earlier draft of this comment blamed
|
||||||
|
## fs.pipe-user-pages-soft; that limit clamps new pipes to two pages, not one,
|
||||||
|
## only past 1024 pipes for one uid, and never for CAP_SYS_RESOURCE.)
|
||||||
|
##
|
||||||
|
## So the rule this file follows is structural, not statistical: under pipefail,
|
||||||
|
## a writer piped into a reader that can stop early (`head`, `grep -q`/`-l`/`-m`,
|
||||||
|
## `sed q`, `awk ... exit`, `read`) is a latent 141 — full stop. A call site is
|
||||||
|
## only sound when the file does not set pipefail, or the reader provably runs
|
||||||
|
## to EOF, or the status is thrown away. "A site.meta would never be that big"
|
||||||
|
## was never one of those, least of all for panel-written input we do not
|
||||||
|
## validate.
|
||||||
|
##
|
||||||
|
## awk reads the FILE directly and stops at the first hit: no pipeline, so
|
||||||
|
## nothing for pipefail to adopt. Same semantics as before, verified against
|
||||||
|
## the old form on duplicate keys, decoy keys (`notVHNAME=`), empty values and
|
||||||
|
## missing keys: first match wins, the rest of the line is the value, verbatim.
|
||||||
|
meta_value() {
|
||||||
|
awk -v k="$1" 'index($0, k "=") == 1 { print substr($0, length(k) + 2); exit }' "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
maps=""
|
||||||
|
site_count=0
|
||||||
|
for meta in "$SITES_ROOT"/*/site.meta; do
|
||||||
|
[ -e "$meta" ] || continue
|
||||||
|
sdir=$(dirname "$meta")
|
||||||
|
## EXTRACT from site.meta — do NOT `source` it. The panel writes these values
|
||||||
|
## (derived from DB domains), so they should be safe, but sourcing paneldata as
|
||||||
|
## shell would execute any metacharacters as root in this container if a value
|
||||||
|
## ever slipped validation. meta_value treats them as plain data.
|
||||||
|
VHNAME=$(meta_value VHNAME "$meta")
|
||||||
|
VHROOT=$(meta_value VHROOT "$meta")
|
||||||
|
DOMAINS=$(meta_value DOMAINS "$meta")
|
||||||
|
if [ -z "$VHNAME" ] || [ -z "$VHROOT" ] || [ -z "$DOMAINS" ] || [ ! -f "$sdir/vhconf.conf" ]; then
|
||||||
|
echo "render-shared-ols: skipping $sdir (incomplete: VHNAME/VHROOT/DOMAINS/vhconf.conf)" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "virtualhost ${VHNAME} {"
|
||||||
|
echo " vhRoot ${VHROOT}"
|
||||||
|
echo " configFile ${sdir}/vhconf.conf"
|
||||||
|
echo " allowSymbolLink 1"
|
||||||
|
echo " enableScript 1"
|
||||||
|
echo " restrained 1"
|
||||||
|
echo "}"
|
||||||
|
} >> "$TMP"
|
||||||
|
maps="${maps} map ${VHNAME} ${DOMAINS}"$'\n'
|
||||||
|
site_count=$((site_count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
## --- 5. ALWAYS add a health vhost mapped to the catch-all so the server is
|
||||||
|
## valid with zero customer sites. Exact-domain maps above win over this '*'.
|
||||||
|
##
|
||||||
|
## THIS MAP IS WHY AN UNMAPPED HOST GETS AN ANSWER AT ALL. Anything the loop
|
||||||
|
## above did not emit a `map` for -- a customer domain whose site dir went
|
||||||
|
## missing, a stale DNS record, a scanner probing by IP -- lands here. It used
|
||||||
|
## to answer 200 with an 11-byte "shared-ols" body, which is how three live
|
||||||
|
## customer sites stayed silently broken for ~2 months: every uptime monitor
|
||||||
|
## asks "is it 200?" and it was.
|
||||||
|
##
|
||||||
|
## The health vhost (its vhconf.conf is written by entrypoint-shared-ols.sh,
|
||||||
|
## which carries the full rationale) now answers 421 Misdirected Request with a
|
||||||
|
## short generic body for any Host it cannot serve, and keeps 200 ONLY for
|
||||||
|
## GET /healthz from an internal client address -- the Docker HEALTHCHECK and
|
||||||
|
## edge liveness probes. Do NOT reintroduce a 200 here for `/`: probe /healthz.
|
||||||
|
##
|
||||||
|
## The listener `map` itself is unchanged, deliberately. Dropping the catch-all
|
||||||
|
## instead would make OLS answer an unmapped Host from whichever vhost it
|
||||||
|
## considers first, which is worse: an unmapped Host would be served SOMEONE
|
||||||
|
## ELSE'S SITE. ---
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "virtualhost _health {"
|
||||||
|
echo " vhRoot /usr/local/lsws/shared-ols-health"
|
||||||
|
echo " configFile /usr/local/lsws/shared-ols-health/vhconf.conf"
|
||||||
|
echo " allowSymbolLink 1"
|
||||||
|
echo " enableScript 0"
|
||||||
|
echo "}"
|
||||||
|
} >> "$TMP"
|
||||||
|
maps="${maps} map _health *"$'\n'
|
||||||
|
|
||||||
|
## --- 6. listeners (HTTP :80 + HTTPS :443 self-signed) carrying ALL maps.
|
||||||
|
## HAProxy terminates real TLS and connects to this tier on :443 ssl verify
|
||||||
|
## none (same as shared-httpd), so :443 needs a cert — self-signed is fine. ---
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "listener shared_http {"
|
||||||
|
echo " address *:80"
|
||||||
|
echo " secure 0"
|
||||||
|
printf '%s' "$maps"
|
||||||
|
echo "}"
|
||||||
|
echo ""
|
||||||
|
echo "listener shared_https {"
|
||||||
|
echo " address *:443"
|
||||||
|
echo " secure 1"
|
||||||
|
echo " keyFile ${KEY_FILE}"
|
||||||
|
echo " certFile ${CERT_FILE}"
|
||||||
|
printf '%s' "$maps"
|
||||||
|
echo "}"
|
||||||
|
} >> "$TMP"
|
||||||
|
|
||||||
|
## --- 7. publish atomically. Validate the temp parses as non-empty, then mv into
|
||||||
|
## place (rename is atomic on the same filesystem) so a concurrent OLS restart
|
||||||
|
## never sees a half-written config. chown only the file we wrote — NOT a
|
||||||
|
## recursive chown of the whole conf tree (that was O(N-sites) on every single
|
||||||
|
## change; the per-site files are world-readable and owned correctly already). ---
|
||||||
|
if [ ! -s "$TMP" ]; then
|
||||||
|
echo "render-shared-ols: refusing to publish empty config" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown lsadm:nogroup "$TMP" 2>/dev/null || true
|
||||||
|
mv -f "$TMP" "$OUT"
|
||||||
|
echo "render-shared-ols: wrote $OUT ($site_count customer vhost(s) + health)"
|
||||||
Executable
+564
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
## lsphp-info-probe.test.sh — regression test for the SIGPIPE-under-pipefail
|
||||||
|
## class of bug that shipped in cac-lsphp (trunk 9343a56) and silently turned
|
||||||
|
## $_SERVER path parity off on every shared-ols site whose host lost the race.
|
||||||
|
##
|
||||||
|
## THE BUG. entrypoint-lsphp.sh asked "is cac_path_parity loaded?" with
|
||||||
|
##
|
||||||
|
## printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'
|
||||||
|
##
|
||||||
|
## under `set -euo pipefail`. `grep -q` exits the instant it matches; printf is
|
||||||
|
## still pushing the rest of `lsphp -i` into the pipe, takes SIGPIPE, and exits
|
||||||
|
## 141; pipefail prefers that over grep's 0. So the test read FALSE **because
|
||||||
|
## the extension was present** — present early enough in the output to stop the
|
||||||
|
## reader — and the container fell back to the degraded auto_prepend normaliser
|
||||||
|
## the extension exists to replace, while telling the operator the extension was
|
||||||
|
## "not loadable in this image".
|
||||||
|
##
|
||||||
|
## THE RULE THIS FILE ENFORCES, stated so nobody re-derives a wrong one: ANY
|
||||||
|
## `writer | early-exiting-reader` under pipefail is a latent 141. PAYLOAD SIZE
|
||||||
|
## IS NOT A SAFETY ARGUMENT — each run is a race on whether the reader's close
|
||||||
|
## lands before the writer's final write() returns, and size only sets how many
|
||||||
|
## write() syscalls the writer must survive. Measured against a default
|
||||||
|
## 65536-byte pipe: 41144 bytes SIGPIPEd on 32/300 runs (11%, well UNDER
|
||||||
|
## capacity) and 65012 bytes on 25/30 (not certain even AT capacity); 500 KB
|
||||||
|
## into a 1 MiB pipe was 200/200 when written 4096 bytes at a time and 0/200 as
|
||||||
|
## a single write. A call site is sound only for a STRUCTURAL reason: the file
|
||||||
|
## does not set pipefail, or the reader provably consumes to EOF (no `q`, `-q`,
|
||||||
|
## `-l`, `-m`, `exit`, `break`), or the pipeline's status is discarded. Section 4
|
||||||
|
## below prints this race happening at ~41 KB; if the numbers there ever read as
|
||||||
|
## "small payloads are fine", the numbers are right and the reading is wrong.
|
||||||
|
##
|
||||||
|
## WHY THE EXISTING SUITE DID NOT CATCH IT. The .phpt suite and
|
||||||
|
## fpm-parity-check.sh both test the EXTENSION; nothing executed the
|
||||||
|
## ENTRYPOINT's branch logic, and the Dockerfile's own `lsphp -i | grep -q`
|
||||||
|
## build gate runs under `bash -c 'set -e'` with NO pipefail, so it reported the
|
||||||
|
## extension present in the very image whose entrypoint declared it missing.
|
||||||
|
##
|
||||||
|
## WHAT THIS ASSERTS.
|
||||||
|
## 1. behaviour — the SHIPPED probe helpers (extracted verbatim from
|
||||||
|
## entrypoint-lsphp.sh, never copied, so they cannot drift) return the
|
||||||
|
## right answer AND a clean exit status under `set -euo pipefail` with a
|
||||||
|
## realistic ~40 KB phpinfo body.
|
||||||
|
## 2. structure — no script in this repo that enables pipefail pipes into a
|
||||||
|
## reader that can exit before its writer finishes. This is the check that
|
||||||
|
## fails deterministically against trunk; assertion 1's *old* form is a
|
||||||
|
## RACE (measured 141 on 3 of 5 runs here, 5 of 5 on whp02, and 0 of 5
|
||||||
|
## under a different Docker daemon), so no behavioural assertion about the
|
||||||
|
## broken code could be trusted to fail on every machine. Section 4 runs
|
||||||
|
## the old form anyway and prints what it did, for the record.
|
||||||
|
## 3. equivalence — the helpers' pure-bash matching answers exactly what the
|
||||||
|
## grep/awk patterns they replaced answer, checked case by case against
|
||||||
|
## those same patterns reading a FILE (a file, so the reference itself
|
||||||
|
## cannot SIGPIPE). Section 6.
|
||||||
|
## 4. the scan's own coverage — every reader shape section 5 claims to catch
|
||||||
|
## is caught, and a matched set of safe shapes is NOT flagged. Section 7.
|
||||||
|
## Without this the scan's regexes are unfalsified and can quietly stop
|
||||||
|
## matching; `grep -l` and `sed q` were both missed until section 7 existed.
|
||||||
|
##
|
||||||
|
## SCOPE LIMITS, stated rather than hidden. The structural scan is a text scan,
|
||||||
|
## so it under-reports in known ways:
|
||||||
|
## - it reads one line at a time. The repo's only multi-line pipeline
|
||||||
|
## (ols-htaccess-watcher.sh's `inotifywait … |` / `while read`) is invisible
|
||||||
|
## to it and was reviewed by hand: that reader loops until EOF, i.e. until
|
||||||
|
## the writer has already gone, so it cannot produce this failure.
|
||||||
|
## - its quote stripping is flat, so a pipe nested inside a command
|
||||||
|
## substitution inside a quoted string (`echo "x ($(a | head -1))"`) is read
|
||||||
|
## as quoted text and skipped.
|
||||||
|
## - the reader list is an ENUMERATION, not a proof. It knows `grep`
|
||||||
|
## (-q/-l/-L/-m and their long forms), `head`, `read`, `awk … exit` and
|
||||||
|
## `sed` with a q/Q command; it does not know an early exit hidden in
|
||||||
|
## `perl -ne '… last'`, `python -c`, `jq`, `head -c`, or any project-local
|
||||||
|
## program that stops reading. A reader not on the list is not thereby safe.
|
||||||
|
## - it only inspects the FIRST word after a pipe, so `foo | LC_ALL=C grep -q`
|
||||||
|
## or `foo | { grep -q x; }` reads as an unknown reader and is skipped.
|
||||||
|
## It is a guard against reintroducing the shape, not a proof of its absence.
|
||||||
|
##
|
||||||
|
## Usage: scripts/tests/lsphp-info-probe.test.sh [REPO_ROOT]
|
||||||
|
## Exit: 0 all assertions passed, 1 an assertion FAILED, 2 the test could not run.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="${1:-$(cd "$HERE/../.." && pwd)}"
|
||||||
|
ENTRYPOINT="$ROOT/scripts/entrypoint-lsphp.sh"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
pass=0; fail=0
|
||||||
|
ok() { pass=$((pass+1)); echo " ok — $1"; }
|
||||||
|
bad() { fail=$((fail+1)); echo " FAIL — $1" >&2; }
|
||||||
|
die() { echo "HARNESS FAILURE: $*" >&2; exit 2; }
|
||||||
|
|
||||||
|
[ -f "$ENTRYPOINT" ] || die "no entrypoint at $ENTRYPOINT (pass REPO_ROOT as \$1)"
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 1. Extract the real probe helpers. Not a copy: whatever the shipped
|
||||||
|
## entrypoint says between the markers is what runs below, so a future edit
|
||||||
|
## that reintroduces a pipeline is tested, not narrated.
|
||||||
|
##
|
||||||
|
## A missing block is an assertion FAILURE, not a harness error, and it does
|
||||||
|
## not stop the run: against a pre-fix checkout the probes are still inline
|
||||||
|
## pipelines, and section 5 below is what names them. Bailing out here would
|
||||||
|
## have replaced that report with "could not run".
|
||||||
|
##
|
||||||
|
## BOUNDED ON PURPOSE. The extraction buffers and emits NOTHING until it has
|
||||||
|
## seen the END marker, so deleting or mistyping that one line is a marker
|
||||||
|
## error (exit 4) rather than a slurp of every line after BEGIN into a file
|
||||||
|
## this harness then `source`s. That is not hypothetical: it happened during
|
||||||
|
## development and only failed loudly by luck — `set -u` tripped over an
|
||||||
|
## unbound variable two statements into the entrypoint's real boot code. An
|
||||||
|
## extractor whose failure mode is "execute arbitrary parts of the program
|
||||||
|
## under test" is not a safe thing to leave lying around, however careful the
|
||||||
|
## markers are today.
|
||||||
|
## exit 0 = both markers, block on stdout
|
||||||
|
## exit 3 = no BEGIN marker at all (pre-fix checkout, or block removed)
|
||||||
|
## exit 4 = BEGIN seen, END missing — refuse to emit, refuse to source
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
HAVE_HELPERS=yes
|
||||||
|
XRC=0
|
||||||
|
awk '
|
||||||
|
/^## ---- CAC-TEST: probe helpers BEGIN ----$/ { f = 1 }
|
||||||
|
f { buf = buf $0 ORS }
|
||||||
|
f && /^## ---- CAC-TEST: probe helpers END ----$/ { printf "%s", buf; found = 1; exit 0 }
|
||||||
|
END { if (found) exit 0; else if (f) exit 4; else exit 3 }
|
||||||
|
' "$ENTRYPOINT" > "$TMP/helpers.sh" || XRC=$?
|
||||||
|
|
||||||
|
case "$XRC" in
|
||||||
|
0)
|
||||||
|
for fn in lsphp_info_has_parity_ext lsphp_info_scan_dir lsphp_info_is_usable; do
|
||||||
|
if ! grep -q "^${fn}()" "$TMP/helpers.sh"; then
|
||||||
|
HAVE_HELPERS=no
|
||||||
|
bad "the probe-helper block in ${ENTRYPOINT#"$ROOT"/} defines no ${fn}()"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
HAVE_HELPERS=no
|
||||||
|
: > "$TMP/helpers.sh"
|
||||||
|
bad "${ENTRYPOINT#"$ROOT"/} has a 'CAC-TEST: probe helpers BEGIN' marker with no matching END marker. Nothing was extracted — an unterminated block would otherwise have pulled the whole rest of the entrypoint into a file this test sources and runs."
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
HAVE_HELPERS=no
|
||||||
|
bad "no probe-helper markers in ${ENTRYPOINT#"$ROOT"/} — either they were removed, or this is a pre-fix checkout where the probes are still inline pipelines (trunk 9343a56). Section 5 says which lines."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
HAVE_HELPERS=no
|
||||||
|
bad "extracting the probe-helper block from ${ENTRYPOINT#"$ROOT"/} failed (awk exit $XRC)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 2. Fixtures. Shaped like real `lsphp -i`: the two lines the probes look for
|
||||||
|
## sit near the TOP (that is what lets a reader stop early), with tens of KB
|
||||||
|
## of body after them. A fixture whose marker is near the end could not
|
||||||
|
## SIGPIPE at all and would make this whole test vacuous, so both properties
|
||||||
|
## are asserted before anything else runs.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
SCAN_PATH='/usr/local/lsws/lsphp83/etc/php/8.3/mods-available'
|
||||||
|
make_fixture() { # $1=outfile $2=yes|no (include the cac_path_parity line)
|
||||||
|
{
|
||||||
|
printf 'phpinfo()\nPHP Version => 8.3.27\n\n'
|
||||||
|
printf 'System => Linux 6ecb4e0a1c1f 5.15.0 #1 SMP x86_64\n'
|
||||||
|
printf 'Server API => LiteSpeed V8.3\n'
|
||||||
|
printf 'Configuration File (php.ini) Path => /usr/local/lsws/lsphp83/etc/php/8.3/litespeed\n'
|
||||||
|
printf 'Scan this dir for additional .ini files => %s\n' "$SCAN_PATH"
|
||||||
|
printf 'PHP API => 20230831\nDebug Build => no\nThread Safety => disabled\n\n'
|
||||||
|
printf 'bcmath\n\nBCMath support => enabled\n\n'
|
||||||
|
printf 'calendar\n\nCalendar support => enabled\n\n'
|
||||||
|
[ "$2" = yes ] && printf 'cac_path_parity\n\ncac_path_parity support => enabled\nRewriting => active\n\n'
|
||||||
|
printf 'Core\n\nPHP Version => 8.3.27\n\n'
|
||||||
|
## ~40 KB of directive rows, exactly the shape phpinfo() prints them in.
|
||||||
|
local i=0
|
||||||
|
while [ "$i" -lt 700 ]; do
|
||||||
|
printf 'some.directive_%03d => local_value_%03d => master_value_%03d\n' "$i" "$i" "$i"
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
} > "$1"
|
||||||
|
}
|
||||||
|
make_fixture "$TMP/info-present.txt" yes
|
||||||
|
make_fixture "$TMP/info-absent.txt" no
|
||||||
|
: > "$TMP/info-empty.txt"
|
||||||
|
|
||||||
|
echo "== fixture sanity =="
|
||||||
|
FIX_BYTES=$(wc -c < "$TMP/info-present.txt")
|
||||||
|
MATCH_OFF=$(grep -b -m1 '^cac_path_parity support => enabled$' "$TMP/info-present.txt" | cut -d: -f1)
|
||||||
|
if [ "$FIX_BYTES" -ge 32768 ]; then
|
||||||
|
ok "fixture is ${FIX_BYTES} bytes (realistic 'lsphp -i' is ~40 KB)"
|
||||||
|
else
|
||||||
|
bad "fixture is only ${FIX_BYTES} bytes — too small to reproduce the failure"
|
||||||
|
fi
|
||||||
|
if [ "$MATCH_OFF" -lt $((FIX_BYTES / 4)) ]; then
|
||||||
|
ok "match sits at byte ${MATCH_OFF} of ${FIX_BYTES} — most of the body is still unwritten when a reader could stop"
|
||||||
|
else
|
||||||
|
bad "match at byte ${MATCH_OFF} of ${FIX_BYTES} leaves too small a tail; the test would be vacuous"
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 3. Behaviour, under the REAL option set (`set -euo pipefail`, as line 34 of
|
||||||
|
## the entrypoint sets it). Each case runs in its own bash process so the
|
||||||
|
## options and the exit status are the genuine article, not something this
|
||||||
|
## harness simulated.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
cat > "$TMP/runner.sh" <<'RUNNER'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$1" # the extracted probe helpers
|
||||||
|
LSPHP_INFO=$(cat "$2")
|
||||||
|
case "$3" in
|
||||||
|
has_ext) lsphp_info_has_parity_ext "$LSPHP_INFO" ;;
|
||||||
|
scan_dir) lsphp_info_scan_dir "$LSPHP_INFO" ;;
|
||||||
|
usable) lsphp_info_is_usable "$LSPHP_INFO" ;;
|
||||||
|
legacy) printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$' ;;
|
||||||
|
*) echo "unknown case $3" >&2; exit 99 ;;
|
||||||
|
esac
|
||||||
|
RUNNER
|
||||||
|
|
||||||
|
## NOT `out=$(run …)`: command substitution runs the function in a subshell,
|
||||||
|
## where an exit status assigned to RC would be thrown away with it. Run in this
|
||||||
|
## shell, capture stdout through a file, and RC is the real thing.
|
||||||
|
RC=0; out=""
|
||||||
|
run() { # $1=fixture $2=case -> sets RC and out
|
||||||
|
RC=0
|
||||||
|
bash "$TMP/runner.sh" "$TMP/helpers.sh" "$1" "$2" >"$TMP/out" 2>&1 || RC=$?
|
||||||
|
out=$(cat "$TMP/out")
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== the shipped probes under set -euo pipefail =="
|
||||||
|
if [ "$HAVE_HELPERS" = no ]; then
|
||||||
|
echo " (skipped — no probe helpers to run; see the failure above)"
|
||||||
|
fi
|
||||||
|
if [ "$HAVE_HELPERS" = yes ]; then
|
||||||
|
|
||||||
|
run "$TMP/info-present.txt" has_ext
|
||||||
|
if [ "$RC" -eq 0 ]; then
|
||||||
|
ok "extension present => branch TAKEN (exit 0)"
|
||||||
|
elif [ "$RC" -eq 141 ]; then
|
||||||
|
bad "exit 141 (SIGPIPE): the probe is a pipeline again — this is the original bug"
|
||||||
|
else
|
||||||
|
bad "extension present => exit $RC (expected 0). Output: $out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
run "$TMP/info-absent.txt" has_ext
|
||||||
|
if [ "$RC" -eq 1 ]; then
|
||||||
|
ok "extension genuinely absent => branch NOT taken (exit 1, a clean 'no')"
|
||||||
|
else
|
||||||
|
bad "extension absent => exit $RC (expected 1). Output: $out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
run "$TMP/info-present.txt" scan_dir
|
||||||
|
if [ "$RC" -eq 0 ] && [ "$out" = "$SCAN_PATH" ]; then
|
||||||
|
ok "scan-dir probe returns '$out' (exit 0)"
|
||||||
|
elif [ "$RC" -eq 141 ]; then
|
||||||
|
bad "scan-dir probe exit 141 (SIGPIPE) — as a bare assignment under set -e that KILLS PID 1"
|
||||||
|
else
|
||||||
|
bad "scan-dir probe => exit $RC, got '$out', expected '$SCAN_PATH'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
run "$TMP/info-present.txt" usable
|
||||||
|
if [ "$RC" -eq 0 ]; then ok "usability probe: real phpinfo body => usable (exit 0)"
|
||||||
|
else bad "usability probe on real body => exit $RC (expected 0)"; fi
|
||||||
|
|
||||||
|
run "$TMP/info-empty.txt" usable
|
||||||
|
if [ "$RC" -eq 1 ]; then ok "usability probe: empty body => NOT usable (exit 1)"
|
||||||
|
else bad "usability probe on empty body => exit $RC (expected 1)"; fi
|
||||||
|
|
||||||
|
## The two failure causes must be reported as the different things they are.
|
||||||
|
## A probe that cannot answer has established nothing about the image, and the
|
||||||
|
## old message asserted the opposite of that for every reason it fired.
|
||||||
|
## SC2016: these patterns are the entrypoint's literal text, `$` and all.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
if grep -q 'if lsphp_info_is_usable "$LSPHP_INFO"; then' "$ENTRYPOINT" &&
|
||||||
|
grep -q 'This is a PROBE failure and establishes nothing' "$ENTRYPOINT"; then
|
||||||
|
ok "a probe that produced nothing is reported as OUR failure, not as a verdict on the image"
|
||||||
|
else
|
||||||
|
bad "entrypoint no longer reports an unusable 'lsphp -i' as a probe failure"
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
if grep -q 'not loadable in this image' "$ENTRYPOINT" &&
|
||||||
|
grep -q 'answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it' "$ENTRYPOINT"; then
|
||||||
|
ok "the 'extension not loadable' verdict now ships the evidence it rests on"
|
||||||
|
else
|
||||||
|
bad "the 'extension not loadable' message no longer states what it observed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fi # HAVE_HELPERS
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 4. The old form, for the record. NOT asserted: it is a race, and asserting a
|
||||||
|
## race would make this suite flap on whichever machine happens to win it.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
echo "== the pre-fix pipeline form on the same input (informational) =="
|
||||||
|
if [ "$HAVE_HELPERS" = no ]; then
|
||||||
|
echo " (skipped — the runner needs the helper block to source)"
|
||||||
|
fi
|
||||||
|
legacy_rcs=""
|
||||||
|
if [ "$HAVE_HELPERS" = yes ]; then
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
run "$TMP/info-present.txt" legacy
|
||||||
|
legacy_rcs="$legacy_rcs $RC"
|
||||||
|
done
|
||||||
|
echo " 10 runs of 'printf | grep -q' under pipefail:${legacy_rcs}"
|
||||||
|
echo " (0 = happened to finish writing, 141 = writer SIGPIPEd and pipefail reported the"
|
||||||
|
echo " extension MISSING because it was present. Either value is expected here.)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 5. Structural scan — the deterministic guard, and the part of this file that
|
||||||
|
## fails against trunk on EVERY machine. Any script that turns on pipefail
|
||||||
|
## and pipes into a reader that can stop early has this bug latent in it
|
||||||
|
## whether or not today's buffer sizes expose it, so the shape is what gets
|
||||||
|
## outlawed, not the symptom.
|
||||||
|
##
|
||||||
|
## It is a text scan, so it is honest about its limits: it strips quoted
|
||||||
|
## spans and comments (that is what keeps `case a|b)`, `sed "s|x|y|"` and
|
||||||
|
## `echo "a | b"` from being reported), it requires the reader to be the
|
||||||
|
## FIRST word after a pipe, and it reads one line at a time. This file is
|
||||||
|
## skipped because section 4 runs the broken form on purpose.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
echo "== structural scan: early-exit readers in pipefail scripts =="
|
||||||
|
mapfile -t PIPEFAIL_FILES < <(grep -rl --include='*.sh' -E '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*[[:space:]]*o?[[:space:]]*pipefail|^[[:space:]]*set[[:space:]]+-o[[:space:]]+pipefail' "$ROOT/scripts" "$ROOT/ext" 2>/dev/null | sort)
|
||||||
|
[ "${#PIPEFAIL_FILES[@]}" -gt 0 ] || die "found no pipefail-enabled scripts under $ROOT — the scan would be vacuous"
|
||||||
|
|
||||||
|
cat > "$TMP/scan.awk" <<'AWKPROG'
|
||||||
|
# Does this `grep …` invocation stop reading before EOF? -q/-l/-L/-m do; -c,
|
||||||
|
# -i, -v, -o, -n and the rest read the whole input and are none of our business.
|
||||||
|
# Walked option by option rather than pattern-matched in one go, because the
|
||||||
|
# letters have to be read the way grep reads them: a cluster like -im1 stops
|
||||||
|
# early, -e/-f/-A/-B/-C/-d/-D swallow the rest of their token as an ARGUMENT
|
||||||
|
# (so `grep -eq` is the pattern "q", not --quiet), and the first non-option word
|
||||||
|
# is the pattern, after which nothing is a flag any more.
|
||||||
|
function grep_stops_early(s, a, k, j, t, c, m) {
|
||||||
|
if (s !~ /^[[:space:]]*grep([[:space:];&)]|$)/) return 0
|
||||||
|
sub(/^[[:space:]]*grep([[:space:]]+|$)/, "", s)
|
||||||
|
m = split(s, a, /[[:space:]]+/)
|
||||||
|
for (k = 1; k <= m; k++) {
|
||||||
|
t = a[k]
|
||||||
|
if (t == "") continue
|
||||||
|
if (t == "--") return 0
|
||||||
|
if (t ~ /^--/) {
|
||||||
|
if (t ~ /^--(quiet|silent|max-count|files-with-match|files-without-match)/) return 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (t !~ /^-/) return 0 # the pattern; options are over
|
||||||
|
sub(/^-/, "", t)
|
||||||
|
for (j = 1; j <= length(t); j++) {
|
||||||
|
c = substr(t, j, 1)
|
||||||
|
if (c == "q" || c == "l" || c == "L" || c == "m") return 1
|
||||||
|
if (c ~ /[efABCdD]/) break # rest of the token is its argument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
{
|
||||||
|
raw = $0
|
||||||
|
l = raw
|
||||||
|
gsub(/\\"/, "X", l); gsub(/\\'/, "X", l) # escaped quotes are not delimiters
|
||||||
|
while (match(l, /'[^']*'/)) l = substr(l, 1, RSTART-1) "SQ" substr(l, RSTART+RLENGTH)
|
||||||
|
while (match(l, /"[^"]*"/)) l = substr(l, 1, RSTART-1) "DQ" substr(l, RSTART+RLENGTH)
|
||||||
|
if (l ~ /^[[:space:]]*#/) next # whole-line comment
|
||||||
|
sub(/[[:space:]]#.*/, "", l) # trailing comment
|
||||||
|
|
||||||
|
# `u` = the line with quote CHARACTERS dropped but their contents KEPT, used
|
||||||
|
# only to read a command's script argument. `l` cannot serve for that: it
|
||||||
|
# replaces a whole quoted span with a placeholder, so `sed 'q'` and
|
||||||
|
# `awk '{exit}'` lose the very token that makes them early-exit readers.
|
||||||
|
u = raw
|
||||||
|
gsub(/["']/, "", u)
|
||||||
|
sub(/[[:space:]]#.*/, "", u)
|
||||||
|
|
||||||
|
gsub(/\|\|/, " ", l) # || is not a pipeline
|
||||||
|
if (l !~ /\|/) next
|
||||||
|
n = split(l, seg, "|")
|
||||||
|
for (i = 2; i <= n; i++) {
|
||||||
|
r = seg[i]
|
||||||
|
if (grep_stops_early(r)) { print NR ": " raw; next }
|
||||||
|
# The `[[:space:];&)]|$` tail rather than a bare `[[:space:]]|$`: a reader
|
||||||
|
# can be the last word of a compound command (`… | head; }`), which the
|
||||||
|
# whitespace-only form silently skipped.
|
||||||
|
if (r ~ /^[[:space:]]*head([[:space:];&)]|$)/) { print NR ": " raw; next }
|
||||||
|
if (r ~ /^[[:space:]]*(while[[:space:]]+)?read([[:space:];&)]|$)/) { print NR ": " raw; next }
|
||||||
|
if (r ~ /^[[:space:]]*awk([[:space:];&)]|$)/ && u ~ /exit/) { print NR ": " raw; next }
|
||||||
|
# sed: a q/Q command anywhere in the script — `/re/q`, `2q`, `$q`, `1p;q`,
|
||||||
|
# `{…;q}`, and the bare `sed q` / `sed 'q'` that the earlier pattern missed
|
||||||
|
# (it required a `;`, `{` or `/` in front of the q, which a lone script has
|
||||||
|
# none of). Anchored on what may PRECEDE the q and what may FOLLOW it, so a
|
||||||
|
# q inside a replacement — `sed s/a/q/` — is not read as the command (which
|
||||||
|
# is what the trailing `/` exclusion buys: a command q is never followed by
|
||||||
|
# another delimiter, a replacement q always is).
|
||||||
|
if (r ~ /^[[:space:]]*sed([[:space:];&)]|$)/ &&
|
||||||
|
(u ~ /[;{\/][[:space:]]*[qQ]([^[:alnum:]\/]|$)/ ||
|
||||||
|
u ~ /[[:space:]]([0-9]+|\$)?[qQ]([[:space:];}]|$)/)) { print NR ": " raw; next }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AWKPROG
|
||||||
|
|
||||||
|
offenders=0
|
||||||
|
for f in "${PIPEFAIL_FILES[@]}"; do
|
||||||
|
[ "$(basename "$f")" = "$(basename "${BASH_SOURCE[0]}")" ] && continue
|
||||||
|
while IFS= read -r hit; do
|
||||||
|
offenders=$((offenders+1))
|
||||||
|
echo " FAIL — ${f#"$ROOT"/}:${hit}" >&2
|
||||||
|
done < <(awk -f "$TMP/scan.awk" "$f")
|
||||||
|
done
|
||||||
|
if [ "$offenders" -eq 0 ]; then
|
||||||
|
ok "${#PIPEFAIL_FILES[@]} pipefail-enabled scripts, no pipeline whose reader can outrun its writer"
|
||||||
|
else
|
||||||
|
bad "$offenders pipeline(s) above pipe into an early-exit reader under pipefail."
|
||||||
|
echo " Read the value into a variable and match it in the shell (see" >&2
|
||||||
|
echo " lsphp_info_has_parity_ext in scripts/entrypoint-lsphp.sh), or give the" >&2
|
||||||
|
echo " reader the file directly. Neither is a pipeline, so there is no second" >&2
|
||||||
|
echo " exit status for pipefail to prefer. A here-string also works, but it" >&2
|
||||||
|
echo " spills to a temp file above a build-dependent size, so it is not the" >&2
|
||||||
|
echo " right shape on a boot path." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 6. Equivalence. The helpers answer with `[[ ]]` and `${…}` what they used to
|
||||||
|
## answer with grep and awk, and "same patterns, different plumbing" is a
|
||||||
|
## claim that has to be checked rather than asserted — an anchored line match
|
||||||
|
## re-expressed as a glob is exactly where an off-by-one lives.
|
||||||
|
##
|
||||||
|
## The reference runs the ORIGINAL grep/awk patterns over a FILE, so the
|
||||||
|
## reference itself cannot SIGPIPE and cannot be accused of the bug it is
|
||||||
|
## refereeing. The file is written the way the old pipeline fed them,
|
||||||
|
## `printf '%s\n' "$SUBJECT"`, so the comparison is against the pre-fix
|
||||||
|
## behaviour byte for byte and not against a tidier reading of it.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
echo "== pure-bash matching vs the grep/awk patterns it replaced =="
|
||||||
|
if [ "$HAVE_HELPERS" = no ]; then
|
||||||
|
echo " (skipped — no probe helpers to compare)"
|
||||||
|
else
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$TMP/helpers.sh"
|
||||||
|
|
||||||
|
NL=$'\n'
|
||||||
|
eq_fail=0
|
||||||
|
eq_case() { # $1 = label, $2 = subject
|
||||||
|
local label="$1" subj="$2" f="$TMP/eq.txt"
|
||||||
|
local ref_has ref_use new_has new_use ref_scan new_scan
|
||||||
|
printf '%s\n' "$subj" > "$f"
|
||||||
|
|
||||||
|
ref_has=0; grep -q '^cac_path_parity support => enabled$' "$f" || ref_has=$?
|
||||||
|
new_has=0; lsphp_info_has_parity_ext "$subj" || new_has=$?
|
||||||
|
ref_use=0; grep -q '^PHP Version => ' "$f" || ref_use=$?
|
||||||
|
new_use=0; lsphp_info_is_usable "$subj" || new_use=$?
|
||||||
|
ref_scan=$(awk -F'=> ' '/^Scan this dir/ {print $2; exit}' "$f")
|
||||||
|
new_scan=$(lsphp_info_scan_dir "$subj")
|
||||||
|
|
||||||
|
if [ "$ref_has" = "$new_has" ] && [ "$ref_use" = "$new_use" ] && [ "$ref_scan" = "$new_scan" ]; then
|
||||||
|
ok "equivalent on: $label"
|
||||||
|
else
|
||||||
|
eq_fail=$((eq_fail+1))
|
||||||
|
bad "NOT equivalent on: $label — has_ext grep=$ref_has bash=$new_has; usable grep=$ref_use bash=$new_use; scan_dir awk='$ref_scan' bash='$new_scan'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
SD='Scan this dir for additional .ini files'
|
||||||
|
PARITY='cac_path_parity support => enabled'
|
||||||
|
eq_case "empty subject" ""
|
||||||
|
eq_case "match on the first line" "${PARITY}${NL}tail line"
|
||||||
|
eq_case "match on the last line" "head line${NL}${PARITY}"
|
||||||
|
eq_case "match is the only line" "${PARITY}"
|
||||||
|
eq_case "match sandwiched" "a${NL}${PARITY}${NL}b"
|
||||||
|
eq_case "not at line start (decoy)" "x ${PARITY}${NL}b"
|
||||||
|
eq_case "trailing space defeats the \$" "${PARITY} ${NL}b"
|
||||||
|
eq_case "prefix-only line (decoy)" "cac_path_parity support => enabled but no${NL}b"
|
||||||
|
eq_case "genuinely absent" "a${NL}b${NL}c"
|
||||||
|
eq_case "embedded blank lines" "a${NL}${NL}${PARITY}${NL}${NL}b"
|
||||||
|
eq_case "banner first, scan dir second" "PHP Version => 8.3.27${NL}${SD} => /a/b"
|
||||||
|
eq_case "scan dir on the first line" "${SD} => /a/b${NL}PHP Version => 8.3.27"
|
||||||
|
eq_case "scan dir on the last line" "PHP Version => 8.3.27${NL}${SD} => /a/b"
|
||||||
|
eq_case "scan dir, second separator" "${SD} => /a => /b${NL}x"
|
||||||
|
eq_case "scan dir, empty value" "${SD} => ${NL}x"
|
||||||
|
eq_case "scan dir, no separator" "Scan this dir is broken${NL}x"
|
||||||
|
eq_case "scan dir, first of two wins" "${SD} => /first${NL}${SD} => /second"
|
||||||
|
eq_case "scan dir, value has spaces" "${SD} => /a b/c ${NL}x"
|
||||||
|
eq_case "scan-dir line is a prefix" "Scan this directory => /a/b${NL}x"
|
||||||
|
eq_case "banner not at line start" "x PHP Version => 8.3.27${NL}b"
|
||||||
|
eq_case "banner without trailing space" "PHP Version =>${NL}b"
|
||||||
|
eq_case "CR-terminated lines" $'PHP Version => 8.3.27\r'"${NL}${PARITY}"$'\r'
|
||||||
|
eq_case "glob metacharacters in body" "*${NL}?${NL}[a-z]${NL}${PARITY}${NL}]["
|
||||||
|
eq_case "realistic 40 KB body" "$(cat "$TMP/info-present.txt")"
|
||||||
|
[ "$eq_fail" -eq 0 ] || echo " (an inequivalence here means the shipped probe now answers something the old grep/awk did not)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## 7. The scan's own coverage. Section 5 only proves something if its regexes
|
||||||
|
## actually match the shapes it claims to outlaw — an unfalsified scanner
|
||||||
|
## reports "clean" just as loudly when it has stopped matching anything.
|
||||||
|
## `grep -l foo` and `sed q` were both silently missed until this section
|
||||||
|
## existed; both are asserted below, alongside the safe forms that must NOT
|
||||||
|
## be reported, because a scanner that flags everything is no better.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
echo "== the structural scan catches what it claims to =="
|
||||||
|
cat > "$TMP/scan-bad.sh" <<'BADSH'
|
||||||
|
set -euo pipefail
|
||||||
|
a() { producer | grep -q needle; }
|
||||||
|
b() { producer | grep -qx needle; }
|
||||||
|
c() { producer | grep -m1 needle; }
|
||||||
|
d() { producer | grep -im1 needle; }
|
||||||
|
e() { producer | grep -l foo; }
|
||||||
|
f() { producer | grep -L foo; }
|
||||||
|
g() { producer | grep --quiet foo; }
|
||||||
|
h() { producer | grep --files-with-matches foo; }
|
||||||
|
i() { producer | head -1; }
|
||||||
|
j() { producer | head; }
|
||||||
|
k() { producer | read -r x; }
|
||||||
|
l() { producer | while read -r x; do :; done; }
|
||||||
|
m() { producer | awk '/x/ {print; exit}'; }
|
||||||
|
n() { producer | sed q; }
|
||||||
|
o() { producer | sed 'q'; }
|
||||||
|
p() { producer | sed 2q; }
|
||||||
|
q() { producer | sed -n '1p;q'; }
|
||||||
|
r() { producer | sed -n '/x/{p;q}'; }
|
||||||
|
s() { producer | sed '$q'; }
|
||||||
|
BADSH
|
||||||
|
cat > "$TMP/scan-good.sh" <<'GOODSH'
|
||||||
|
set -euo pipefail
|
||||||
|
a() { producer | grep -c needle; }
|
||||||
|
b() { producer | grep -i needle; }
|
||||||
|
c() { producer | grep -v needle; }
|
||||||
|
d() { producer | grep -o needle; }
|
||||||
|
e() { producer | awk '{print $1}'; }
|
||||||
|
f() { producer | sed -n 's/^K=//p'; }
|
||||||
|
g() { producer | sed 's/a/q/'; }
|
||||||
|
h() { producer | sed -e 'y/abc/xqz/'; }
|
||||||
|
i() { producer | wc -l; }
|
||||||
|
j() { producer | sort -u; }
|
||||||
|
k() { producer | tail -1; }
|
||||||
|
l() { case $x in a|b) : ;; esac; }
|
||||||
|
m() { echo "a | head -1"; }
|
||||||
|
n() { echo 'x | grep -q y'; }
|
||||||
|
o() { grep -q needle <<<"$1"; }
|
||||||
|
p() { grep -q needle "$file"; }
|
||||||
|
# q() { producer | grep -q commented-out; }
|
||||||
|
r() { producer | grep -eq foo; }
|
||||||
|
GOODSH
|
||||||
|
missed=""; falsely=""; hits_bad=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
fn=${line#*: }; fn=${fn%%(*}
|
||||||
|
hits_bad="$hits_bad $fn"
|
||||||
|
done < <(awk -f "$TMP/scan.awk" "$TMP/scan-bad.sh")
|
||||||
|
for want in a b c d e f g h i j k l m n o p q r s; do
|
||||||
|
case " ${hits_bad:-} " in *" $want "*) ;; *) missed="$missed $want" ;; esac
|
||||||
|
done
|
||||||
|
mapfile -t good_hits < <(awk -f "$TMP/scan.awk" "$TMP/scan-good.sh")
|
||||||
|
if [ -z "$missed" ]; then
|
||||||
|
ok "all 19 early-exit reader shapes are reported (incl. grep -l/-L/--quiet and bare 'sed q')"
|
||||||
|
else
|
||||||
|
bad "the scan misses these shapes in scan-bad.sh:$missed"
|
||||||
|
awk -f "$TMP/scan.awk" "$TMP/scan-bad.sh" >&2
|
||||||
|
fi
|
||||||
|
if [ "${#good_hits[@]}" -eq 0 ]; then
|
||||||
|
ok "18 read-to-EOF / quoted / non-pipeline forms are not reported"
|
||||||
|
else
|
||||||
|
falsely=$(printf '%s; ' "${good_hits[@]}")
|
||||||
|
bad "the scan false-positives on: $falsely"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "passed: $pass failed: $fail"
|
||||||
|
[ "$fail" -eq 0 ] || exit 1
|
||||||
|
exit 0
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Hot-adjust Apache MPM Event settings and graceful reload.
|
||||||
|
# Usage: tune-mpm.sh [--max-workers N] [--server-limit N] [--start-servers N]
|
||||||
|
# [--min-spare-threads N] [--max-spare-threads N]
|
||||||
|
# [--max-connections-per-child N]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Read current values from the config as defaults
|
||||||
|
CONFIG_FILE="/etc/httpd/conf.d/mpm-tuning.conf"
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "Error: $CONFIG_FILE not found. Run detect-memory.sh first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse current values from config
|
||||||
|
current_start=$(grep -oP 'StartServers\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "1")
|
||||||
|
current_min_spare=$(grep -oP 'MinSpareThreads\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "5")
|
||||||
|
current_max_spare=$(grep -oP 'MaxSpareThreads\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "15")
|
||||||
|
current_max_workers=$(grep -oP 'MaxRequestWorkers\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "50")
|
||||||
|
current_server_limit=$(grep -oP 'ServerLimit\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "2")
|
||||||
|
current_max_conn=$(grep -oP 'MaxConnectionsPerChild\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "3000")
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
START_SERVERS=$current_start
|
||||||
|
MIN_SPARE_THREADS=$current_min_spare
|
||||||
|
MAX_SPARE_THREADS=$current_max_spare
|
||||||
|
MAX_REQUEST_WORKERS=$current_max_workers
|
||||||
|
SERVER_LIMIT=$current_server_limit
|
||||||
|
MAX_CONNECTIONS_PER_CHILD=$current_max_conn
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--max-workers) MAX_REQUEST_WORKERS="$2"; shift 2 ;;
|
||||||
|
--server-limit) SERVER_LIMIT="$2"; shift 2 ;;
|
||||||
|
--start-servers) START_SERVERS="$2"; shift 2 ;;
|
||||||
|
--min-spare-threads) MIN_SPARE_THREADS="$2"; shift 2 ;;
|
||||||
|
--max-spare-threads) MAX_SPARE_THREADS="$2"; shift 2 ;;
|
||||||
|
--max-connections-per-child) MAX_CONNECTIONS_PER_CHILD="$2"; shift 2 ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--max-workers N] [--server-limit N] [--start-servers N]"
|
||||||
|
echo " [--min-spare-threads N] [--max-spare-threads N]"
|
||||||
|
echo " [--max-connections-per-child N]"
|
||||||
|
echo ""
|
||||||
|
echo "Current values:"
|
||||||
|
echo " StartServers: $current_start"
|
||||||
|
echo " MinSpareThreads: $current_min_spare"
|
||||||
|
echo " MaxSpareThreads: $current_max_spare"
|
||||||
|
echo " MaxRequestWorkers: $current_max_workers"
|
||||||
|
echo " ServerLimit: $current_server_limit"
|
||||||
|
echo " MaxConnectionsPerChild: $current_max_conn"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Write updated config
|
||||||
|
cat <<EOF > "$CONFIG_FILE"
|
||||||
|
<IfModule mpm_event_module>
|
||||||
|
StartServers ${START_SERVERS}
|
||||||
|
MinSpareThreads ${MIN_SPARE_THREADS}
|
||||||
|
MaxSpareThreads ${MAX_SPARE_THREADS}
|
||||||
|
ThreadLimit 64
|
||||||
|
ThreadsPerChild 25
|
||||||
|
MaxRequestWorkers ${MAX_REQUEST_WORKERS}
|
||||||
|
ServerLimit ${SERVER_LIMIT}
|
||||||
|
MaxConnectionsPerChild ${MAX_CONNECTIONS_PER_CHILD}
|
||||||
|
</IfModule>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "MPM config updated:"
|
||||||
|
echo " StartServers=$START_SERVERS ServerLimit=$SERVER_LIMIT MaxRequestWorkers=$MAX_REQUEST_WORKERS"
|
||||||
|
echo " MinSpareThreads=$MIN_SPARE_THREADS MaxSpareThreads=$MAX_SPARE_THREADS MaxConnectionsPerChild=$MAX_CONNECTIONS_PER_CHILD"
|
||||||
|
|
||||||
|
# Graceful reload
|
||||||
|
/usr/sbin/httpd -k graceful
|
||||||
|
echo "Apache graceful reload triggered."
|
||||||
Reference in New Issue
Block a user