diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index c17e418..9a4f875 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -51,6 +51,7 @@ jobs: scripts/ols-htaccess-watcher.sh \ scripts/create-vhost-litespeed.sh \ scripts/install-lscache-wp.sh \ + scripts/lsphp-apt-upgrade.sh \ scripts/tune-mpm.sh \ scripts/tests/lsphp-info-probe.test.sh @@ -72,6 +73,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Per-run cache-bust value. Each job's buildx builder is created fresh by + # setup-buildx-action (no cache-from/cache-to here), so today nothing is + # cached between runs — this keeps it that way if a cache is ever added: + # the package-upgrade layers consume CACHE_BUST and can never be reused. + - name: Cache-bust value + id: bust + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + - name: Login to Gitea uses: docker/login-action@v3 with: @@ -86,6 +95,7 @@ jobs: push: true build-args: | PHPVER=${{ matrix.phpver }} + CACHE_BUST=${{ steps.bust.outputs.value }} tags: | repo.anhonesthost.net/cloud-hosting-platform/cac:php${{ matrix.phpver }} ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac:latest' || '' }} @@ -105,6 +115,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Per-run cache-bust value. Each job's buildx builder is created fresh by + # setup-buildx-action (no cache-from/cache-to here), so today nothing is + # cached between runs — this keeps it that way if a cache is ever added: + # the package-upgrade layers consume CACHE_BUST and can never be reused. + - name: Cache-bust value + id: bust + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + - name: Login to Gitea uses: docker/login-action@v3 with: @@ -120,6 +138,7 @@ jobs: push: true build-args: | PHPVER=${{ matrix.phpver }} + CACHE_BUST=${{ steps.bust.outputs.value }} tags: | repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:php${{ matrix.phpver }} ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:latest' || '' }} @@ -143,6 +162,29 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Per-run cache-bust value. Each job's buildx builder is created fresh by + # setup-buildx-action (no cache-from/cache-to here), so today nothing is + # cached between runs — this keeps it that way if a cache is ever added: + # the package-upgrade layers consume CACHE_BUST and can never be reused. + - name: Cache-bust value + id: bust + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + + # Ask the LiteSpeed apt repo (the same one the base image's apt uses) for the + # CURRENT lsphp version. The Dockerfile asserts the installed package equals + # this (build fails if the repo changed mid-build — just re-run) and writes + # it into the image labels org.whp.php-version / org.whp.lsphp-package-version. + - name: Resolve current lsphp${{ matrix.phpver }} from the LiteSpeed repo + id: lsphp + run: | + set -euo pipefail + idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) + pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) + if [ -z "$pkg" ]; then echo "::error::lsphp${{ matrix.phpver }} not found in the LiteSpeed repo index"; exit 1; fi + echo "lsphp${{ matrix.phpver }} in repo: $pkg (PHP ${pkg%%-*})" + echo "pkg=$pkg" >> "$GITHUB_OUTPUT" + echo "php=${pkg%%-*}" >> "$GITHUB_OUTPUT" + - name: Login to Gitea uses: docker/login-action@v3 with: @@ -159,6 +201,9 @@ jobs: build-args: | PHPVER=${{ matrix.phpver }} OLS_VERSION=1.8.4 + CACHE_BUST=${{ steps.bust.outputs.value }} + EXPECT_LSPHP_PKG=${{ steps.lsphp.outputs.pkg }} + EXPECT_PHP_VERSION=${{ steps.lsphp.outputs.php }} # OLS_VERSION pinned to 1.8.4 — only release with prebuilt images # for every PHP version we ship (1.8.5 and 1.9.0 don't have an # lsphp81 variant on Docker Hub). Bump alongside a local rebuild @@ -187,6 +232,29 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Per-run cache-bust value. Each job's buildx builder is created fresh by + # setup-buildx-action (no cache-from/cache-to here), so today nothing is + # cached between runs — this keeps it that way if a cache is ever added: + # the package-upgrade layers consume CACHE_BUST and can never be reused. + - name: Cache-bust value + id: bust + run: echo "value=$(date -u +%Y%m%dT%H%M%SZ)-${{ gitea.run_id }}" >> "$GITHUB_OUTPUT" + + # Ask the LiteSpeed apt repo (the same one the base image's apt uses) for the + # CURRENT lsphp version. The Dockerfile asserts the installed package equals + # this (build fails if the repo changed mid-build — just re-run) and writes + # it into the image labels org.whp.php-version / org.whp.lsphp-package-version. + - name: Resolve current lsphp${{ matrix.phpver }} from the LiteSpeed repo + id: lsphp + run: | + set -euo pipefail + idx=$(curl -fsS --retry 3 --max-time 60 http://rpms.litespeedtech.com/debian/dists/noble/main/binary-amd64/Packages) + pkg=$(printf '%s\n' "$idx" | awk -v p="lsphp${{ matrix.phpver }}" '/^Package: /{n=$2} /^Version: / && n==p {print $2}' | sort -V | tail -n1) + if [ -z "$pkg" ]; then echo "::error::lsphp${{ matrix.phpver }} not found in the LiteSpeed repo index"; exit 1; fi + echo "lsphp${{ matrix.phpver }} in repo: $pkg (PHP ${pkg%%-*})" + echo "pkg=$pkg" >> "$GITHUB_OUTPUT" + echo "php=${pkg%%-*}" >> "$GITHUB_OUTPUT" + - name: Login to Gitea uses: docker/login-action@v3 with: @@ -203,6 +271,9 @@ jobs: build-args: | PHPVER=${{ matrix.phpver }} OLS_VERSION=1.8.4 + CACHE_BUST=${{ steps.bust.outputs.value }} + EXPECT_LSPHP_PKG=${{ steps.lsphp.outputs.pkg }} + EXPECT_PHP_VERSION=${{ steps.lsphp.outputs.php }} # OLS_VERSION pinned to 1.8.4 to match Build-LiteSpeed-Images — same # prebuilt base, same lsphp binaries. Bump both together. tags: | diff --git a/Dockerfile b/Dockerfile index e999c5e..1b10086 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ FROM almalinux/10-base ARG PHPVER=83 +# Per-CI-run value: the first RUN below (dnf update) and everything after it can +# never be a layer-cache hit, so every build gets the repos' current packages. +# Local builds: --build-arg CACHE_BUST=$(date +%s) or --no-cache. +ARG CACHE_BUST= # Install repos, update, install only needed packages, clean up in one layer RUN dnf install -y \ @@ -23,6 +27,7 @@ RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \ RUN openssl req -newkey rsa:2048 -nodes -keyout /etc/pki/tls/private/localhost.key -x509 -days 3650 -subj "/CN=localhost" -out /etc/pki/tls/certs/localhost.crt && \ mkdir -p /run/php-fpm/ && \ /scripts/install-php$PHPVER.sh && \ + echo "PHP version report:" && php -v && rpm -q php-cli php-common php-fpm && \ rm -rf /tmp/* # Download and install wp-cli (consider pinning version for reproducibility) diff --git a/Dockerfile.fpm b/Dockerfile.fpm index 6685497..3500108 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -1,5 +1,9 @@ FROM almalinux/10-base ARG PHPVER=83 +# Per-CI-run value: the first RUN below (dnf update) and everything after it can +# never be a layer-cache hit, so every build gets the repos' current packages. +# Local builds: --build-arg CACHE_BUST=$(date +%s) or --no-cache. +ARG CACHE_BUST= # Install repos, update, install only needed packages (no httpd/mod_ssl), clean up in one layer RUN dnf install -y \ @@ -22,6 +26,7 @@ RUN dnf install -y ImageMagick ImageMagick-libs ImageMagick-heic && \ # Create needed dirs, install PHP, clean up (no SSL cert, no httpd) RUN mkdir -p /run/php-fpm/ && \ /scripts/install-php$PHPVER.sh && \ + echo "PHP version report:" && php -v && rpm -q php-cli php-common php-fpm && \ rm -rf /tmp/* # Download and install wp-cli diff --git a/Dockerfile.litespeed b/Dockerfile.litespeed index a0b5332..7282054 100644 --- a/Dockerfile.litespeed +++ b/Dockerfile.litespeed @@ -45,8 +45,23 @@ ENV PHPVER=${PHPVER} ## lsphp${PHPVER}-ldap is the only extra ext we add (everything else WP needs ## ships in the prebuilt base). lsphp84 + lsphp85 don't ship imap or pspell ## from LiteSpeed — customers needing imap should pin to 8.3 or earlier. -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +## +## SECURITY UPDATES AT BUILD TIME: lsphp-apt-upgrade.sh also upgrades the OS and +## sweeps EVERY installed lsphp${PHPVER}* package to the LiteSpeed repo's current +## release, failing the build if the repo is unreachable — so a rebuild ships a +## PHP security fix as soon as LiteSpeed publishes the .deb, without waiting for +## a new litespeedtech/openlitespeed base tag. See the script header. +## +## CACHE_BUST: CI passes a per-run value so this layer can never be a cache hit +## (an ARG's first use in a RUN is part of that layer's cache key). Local builds: +## pass --build-arg CACHE_BUST=$(date +%s) or use --no-cache, or you may get the +## lsphp from your last build. EXPECT_* are resolved by CI from the repo index +## and asserted here; the same values become the image labels below. +ARG CACHE_BUST= +ARG EXPECT_LSPHP_PKG= +ARG EXPECT_PHP_VERSION= +COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh +RUN /usr/local/sbin/lsphp-apt-upgrade.sh "${PHPVER}" \ gettext-base sudo cron \ ca-certificates curl wget \ lsphp${PHPVER}-ldap && \ @@ -87,6 +102,14 @@ RUN sed -i 's|^[[:space:]]*address[[:space:]]\+\*:| address 127.0 ## Cron entry for log rotation (mirrors cac:phpXX). RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab +## Inspect without running the image: +## docker buildx imagetools inspect --format '{{json .Image.Config.Labels}}' +## Values come from CI's repo-index lookup and were asserted against the +## installed packages by lsphp-apt-upgrade.sh above ("unverified" = local build). +LABEL org.whp.php-version="${EXPECT_PHP_VERSION:-unverified}" \ + org.whp.lsphp-package-version="${EXPECT_LSPHP_PKG:-unverified}" \ + org.whp.php-flavour="lsphp${PHPVER}" + EXPOSE 80 443 ## Healthcheck: the entrypoint drops a static /healthz into the customer diff --git a/Dockerfile.lsphp b/Dockerfile.lsphp index 9171365..47c178a 100644 --- a/Dockerfile.lsphp +++ b/Dockerfile.lsphp @@ -60,9 +60,20 @@ ARG PHPVER=83 ## Toolchain layer, deliberately BEFORE the source COPY so editing the extension ## does not re-resolve the PHP version (which is what caused the skew above). ## Records the exact lsphp version the headers belong to; stage 2 pins to it. +## +## lsphp-apt-upgrade.sh (see its header) refuses to run on stale/unreachable +## repo lists and sweeps every lsphp${PHPVER}* package to the repo's current +## release. CACHE_BUST is per-CI-run so this layer — and therefore the recorded +## version stage 2 pins to — is never a cache hit; it is re-declared in stage 2. +## --no-os-upgrade: this stage is thrown away, OS updates would only cost time. +## EXPECT_* (CI's repo-index lookup) are asserted here too, so a mismatch fails +## before the compile rather than after it. +ARG CACHE_BUST= +ARG EXPECT_LSPHP_PKG= +ARG EXPECT_PHP_VERSION= +COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh RUN set -e; \ - apt-get update; \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + /usr/local/sbin/lsphp-apt-upgrade.sh --no-os-upgrade "${PHPVER}" \ build-essential autoconf pkg-config \ lsphp${PHPVER}-dev; \ RTV=$(dpkg-query -W -f='${Version}' lsphp${PHPVER}); \ @@ -141,17 +152,29 @@ ENV PHPVER=${PHPVER} ## cache hit, new version => this layer re-runs and lands on the same one. The ## explicit `=$V` pins then make a mid-build repo roll a LOUD apt failure instead ## of a silent skew. Verified satisfiable on PHP 8.1/8.3/8.5 (2026-08-05). +## +## SECURITY UPDATES AT BUILD TIME: lsphp-apt-upgrade.sh upgrades the Ubuntu OS +## packages and every other lsphp${PHPVER}* package (extensions such as -msgpack +## are NOT dragged along by the -common pin) to the repos' current versions, and +## fails the build if the repo is unreachable. CACHE_BUST makes this layer a +## guaranteed cache miss per CI run; EXPECT_* are CI's repo-index lookup, +## asserted by the script and written into the image labels below. +ARG CACHE_BUST= +ARG EXPECT_LSPHP_PKG= +ARG EXPECT_PHP_VERSION= COPY --from=ext-build /build-out/lsphp.version /etc/cac-lsphp-build.version +COPY ./scripts/lsphp-apt-upgrade.sh /usr/local/sbin/lsphp-apt-upgrade.sh RUN set -e; \ V=$(cat /etc/cac-lsphp-build.version); \ - apt-get update; \ - if ! DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + if ! /usr/local/sbin/lsphp-apt-upgrade.sh "${PHPVER}" \ ca-certificates \ lsphp${PHPVER}="$V" lsphp${PHPVER}-common="$V" lsphp${PHPVER}-ldap="$V"; then \ - echo "FATAL: lsphp${PHPVER} $V is what cac_path_parity was compiled against," >&2; \ - echo " but the LiteSpeed repo no longer offers it (it keeps only the" >&2; \ - echo " current release). The ext-build stage is almost certainly a stale" >&2; \ - echo " cache hit — rebuild with --no-cache." >&2; \ + echo "FATAL: lsphp-apt-upgrade.sh failed (reason above). If apt said" >&2; \ + echo " \"Version '$V' ... was not found\" or refused a downgrade: $V is what" >&2; \ + echo " cac_path_parity was compiled against, but the LiteSpeed repo (which" >&2; \ + echo " keeps only the current release) now offers a newer one — either it" >&2; \ + echo " published mid-build or ext-build was a stale cache hit. Re-run the" >&2; \ + echo " build (CI) / rebuild with --no-cache (local)." >&2; \ exit 1; \ fi; \ apt-get clean; \ @@ -218,6 +241,14 @@ RUN bash -c 'set -e; \ ## default so the runuser exec path can't drop it. ENV LSPHP_ENABLE_USER_INI=on +## Inspect without running the image: +## docker buildx imagetools inspect --format '{{json .Image.Config.Labels}}' +## Values come from CI's repo-index lookup and were asserted against the +## installed packages by lsphp-apt-upgrade.sh above ("unverified" = local build). +LABEL org.whp.php-version="${EXPECT_PHP_VERSION:-unverified}" \ + org.whp.lsphp-package-version="${EXPECT_LSPHP_PKG:-unverified}" \ + org.whp.php-flavour="lsphp${PHPVER}" + EXPOSE 9000 ## TCP-connect + lsphp-alive check (LSAPI isn't FastCGI, so no cgi-fcgi ping). diff --git a/scripts/lsphp-apt-upgrade.sh b/scripts/lsphp-apt-upgrade.sh new file mode 100755 index 0000000..377cd73 --- /dev/null +++ b/scripts/lsphp-apt-upgrade.sh @@ -0,0 +1,97 @@ +#!/bin/bash +## lsphp-apt-upgrade.sh — BUILD-TIME ONLY. Brings every installed lsphp${PHPVER}* +## package (and, by default, the Ubuntu OS packages) up to what the repos offer +## RIGHT NOW, then proves it. +## +## Why this exists: cac-litespeed / cac-lsphp start FROM the LiteSpeed prebuilt +## image litespeedtech/openlitespeed:-lsphpNN, which LiteSpeed republishes +## rarely (the 1.8.4 tags were built 2025-12-16 and ship lsphp85 8.5.0 / +## lsphp84 8.4.15, measured 2026-10-21, while rpms.litespeedtech.com/debian +## offers 8.5.11 / 8.4.26). The base image already carries that apt repo, so a +## PHP security release reaches us the moment LiteSpeed publishes the .deb — +## provided every build actually asks for it. Before this script the lsphp core +## only moved forward as a SIDE EFFECT of `apt-get install lsphpNN-ldap` (whose +## exact-version dependency on lsphpNN-common drags the family along); lsphp +## extension packages without that coupling (e.g. lsphpNN-msgpack) and every +## Ubuntu package stayed at the base image's December versions. +## +## Fails LOUDLY — never ships the base image's PHP by accident: +## * `apt-get update --error-on=any`: plain `apt-get update` only WARNS when a +## repo is unreachable and carries on with no/old lists, exit 0. +## * the LiteSpeed repo must be the candidate source for lsphp${PHPVER}. +## * after upgrading, no lsphp${PHPVER}* package may remain upgradable. +## * if CI passed the version it resolved from the repo index (EXPECT_LSPHP_PKG, +## EXPECT_PHP_VERSION), the installed package / running PHP must match it; +## that same value becomes the image LABEL, so the label cannot lie. +## +## Usage: lsphp-apt-upgrade.sh [--no-os-upgrade] PHPVER [extra apt packages / pkg=ver pins...] +## Extra packages are installed in the same transaction set (after the OS +## upgrade, before the lsphp sweep). Does NOT clean apt lists — the caller +## decides (multi-stage builders want them; shipped layers delete them). +set -euo pipefail + +OS_UPGRADE=1 +if [ "${1:-}" = "--no-os-upgrade" ]; then OS_UPGRADE=0; shift; fi +PHPVER="${1:?usage: lsphp-apt-upgrade.sh [--no-os-upgrade] PHPVER [pkgs...]}" +shift + +export DEBIAN_FRONTEND=noninteractive +APT=(apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold) +LSPHP="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp" +PHPCLI="/usr/local/lsws/lsphp${PHPVER}/bin/php" + +die() { echo "FATAL(lsphp-apt-upgrade): $*" >&2; exit 1; } + +echo "lsphp-apt-upgrade: PHPVER=${PHPVER} os_upgrade=${OS_UPGRADE} cache_bust=${CACHE_BUST:-}" +echo "lsphp-apt-upgrade: base image lsphp${PHPVER}=$(dpkg-query -W -f='${Version}' "lsphp${PHPVER}" 2>/dev/null || echo none)" + +"${APT[@]}" update --error-on=any || die "apt-get update failed (repo unreachable?). Refusing to build with stale package lists." + +# The candidate for lsphpNN must come from the LiteSpeed repo — not "none", and +# not merely whatever is already installed. +if ! apt-cache madison "lsphp${PHPVER}" | grep -q 'rpms.litespeedtech.com'; then + apt-cache policy "lsphp${PHPVER}" >&2 || true + die "the LiteSpeed apt repo does not offer lsphp${PHPVER} (repo missing from sources, or its index is empty)." +fi +CAND=$(apt-cache policy "lsphp${PHPVER}" | awk '/Candidate:/ {print $2; exit}') +echo "lsphp-apt-upgrade: repo candidate lsphp${PHPVER}=${CAND}" + +if [ "$OS_UPGRADE" = 1 ]; then + # --with-new-pkgs: let an upgrade pull in a new dependency instead of being + # silently "kept back". Never removes packages (that would be dist-upgrade). + "${APT[@]}" upgrade --with-new-pkgs +fi + +if [ $# -gt 0 ]; then + "${APT[@]}" install --no-install-recommends "$@" +fi + +# Sweep EVERY installed lsphpNN* package (core + all extensions) to the candidate. +mapfile -t PKGS < <(dpkg-query -W -f='${db:Status-Abbrev} ${Package}\n' "lsphp${PHPVER}*" 2>/dev/null | awk '$1 == "ii" {print $2}') +[ "${#PKGS[@]}" -gt 0 ] || die "no lsphp${PHPVER}* packages installed — wrong base image?" +"${APT[@]}" install --only-upgrade --no-install-recommends "${PKGS[@]}" + +LEFT=$(apt list --upgradable 2>/dev/null | grep "^lsphp${PHPVER}" || true) +[ -z "$LEFT" ] || die "lsphp packages still upgradable after the sweep: +$LEFT" + +INST=$(dpkg-query -W -f='${Version}' "lsphp${PHPVER}") +RUNV=$("$PHPCLI" -r 'echo PHP_VERSION;') || die "lsphp${PHPVER} CLI does not run" + +if [ -n "${EXPECT_LSPHP_PKG:-}" ] && [ "$INST" != "$EXPECT_LSPHP_PKG" ]; then + die "installed lsphp${PHPVER}=$INST but CI resolved $EXPECT_LSPHP_PKG from the repo index (repo changed mid-build? re-run)." +fi +if [ -n "${EXPECT_PHP_VERSION:-}" ] && [ "$RUNV" != "$EXPECT_PHP_VERSION" ]; then + die "lsphp${PHPVER} reports PHP $RUNV but the image will be labelled $EXPECT_PHP_VERSION." +fi +if [ -z "${EXPECT_PHP_VERSION:-}" ]; then + echo "lsphp-apt-upgrade: WARNING no EXPECT_PHP_VERSION build-arg — image label will read 'unverified' (fine for local builds; CI always sets it)." >&2 +fi + +printf '%s\n' "$RUNV" > /etc/cac-php-version + +echo "lsphp-apt-upgrade: ===== version report =====" +dpkg-query -W -f=' ${Package} ${Version}\n' "lsphp${PHPVER}*" +echo " lsphp -v: $("$LSPHP" -v | head -n1)" +echo " OS packages still upgradable: $(apt list --upgradable 2>/dev/null | grep -vc '^Listing' || true)" +echo "lsphp-apt-upgrade: OK lsphp${PHPVER}=${INST} (PHP ${RUNV})"