diff --git a/.gitea/scripts/resolve-base-image.sh b/.gitea/scripts/resolve-base-image.sh new file mode 100755 index 0000000..1c5b074 --- /dev/null +++ b/.gitea/scripts/resolve-base-image.sh @@ -0,0 +1,35 @@ +#!/bin/bash +## resolve-base-image.sh IMAGE:TAG — CI only (not copied into images). +## Prints IMAGE@sha256: for a Docker Hub tag, looked up DIRECTLY at +## registry-1.docker.io over HTTPS (never via the pkg-cache mirror), so the +## build can say `FROM IMAGE@sha256:...` and the mirror can only serve +## content-addressed blobs that BuildKit verifies. Exits non-zero (prints +## nothing on stdout) if the lookup fails; the workflow then skips the mirror +## and pulls the tag directly from Docker Hub. HEAD requests do not count +## against Docker Hub pull rate limits. +## Canonical copy: cloud-apache-container/.gitea/scripts/resolve-base-image.sh +## (docs/pkg-cache.md there); other repos carry identical copies. +set -euo pipefail +ref="${1:?usage: resolve-base-image.sh IMAGE:TAG}" +name_re='^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)?$' +tag_re='^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' +name="${ref%:*}"; tag="${ref##*:}" +[ "$name" != "$ref" ] || { echo "resolve-base-image: '$ref' has no :tag" >&2; exit 2; } +[[ "$name" =~ $name_re ]] || { echo "resolve-base-image: bad image name '$name'" >&2; exit 2; } +[[ "$tag" =~ $tag_re ]] || { echo "resolve-base-image: bad tag '$tag'" >&2; exit 2; } +repo="$name"; [[ "$repo" == */* ]] || repo="library/$repo" # official images + +curl_opts=(-fsS --proto '=https' --retry 3 --retry-all-errors --max-time 20) +token=$(curl "${curl_opts[@]}" "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull" | + python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') +[ -n "$token" ] || { echo "resolve-base-image: no Docker Hub token for $repo" >&2; exit 1; } +hdrs=$(curl "${curl_opts[@]}" -I \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ + "https://registry-1.docker.io/v2/${repo}/manifests/${tag}") +digest=$(printf '%s\n' "$hdrs" | tr -d '\r' | awk 'tolower($1)=="docker-content-digest:" {print $2}' | tail -n1) +[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "resolve-base-image: no valid digest for $ref (got '${digest}')" >&2; exit 1; } +printf '%s@%s\n' "$name" "$digest" diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 1f9ec60..43e7620 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -57,7 +57,8 @@ jobs: scripts/lsphp-abi-gate.sh \ scripts/tune-mpm.sh \ scripts/tests/lsphp-info-probe.test.sh \ - scripts/tests/lsphp-abi-gate.test.sh + scripts/tests/lsphp-abi-gate.test.sh \ + .gitea/scripts/resolve-base-image.sh - name: lsphp probe regression test run: ./scripts/tests/lsphp-info-probe.test.sh @@ -85,6 +86,26 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Base image pinned by DIGEST, looked up directly at Docker Hub (never via + # the pkg-cache mirror), so the mirror can only serve content-addressed + # blobs that BuildKit verifies. If the lookup fails this job does NOT use + # the mirror and pulls the tag straight from Docker Hub. docs/pkg-cache.md. + - name: Resolve base image digest (Docker Hub, direct) + id: base + env: + BASE_TAG: almalinux/10-base:latest + run: | + set -uo pipefail + if ref=$(.gitea/scripts/resolve-base-image.sh "$BASE_TAG") && [[ "$ref" =~ ^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$ ]]; then + echo "base-image: ${BASE_TAG} -> ${ref} (digest from registry-1.docker.io)" + echo "image=${ref}" >> "$GITHUB_OUTPUT" + echo "pinned=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::base image ${BASE_TAG}: Docker Hub digest lookup failed - pulling the tag directly from Docker Hub, pkg-cache registry mirror NOT used" + echo "image=${BASE_TAG}" >> "$GITHUB_OUTPUT" + echo "pinned=false" >> "$GITHUB_OUTPUT" + fi + # Internal package cache (CT130 pkg-cache, docs/pkg-cache.md). Used ONLY if # it answers over TLS with our pinned CA; otherwise (down, unreachable, # bad/expired cert) this job builds from the upstream repos and Docker Hub @@ -92,11 +113,14 @@ jobs: # separately so either can fail back on its own. - name: Probe package cache id: pkgcache + # The registry mirror is only used for a digest-pinned base image. + env: + BASE_PINNED: ${{ steps.base.outputs.pinned }} run: | url=""; mirror="" if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40/; then url="https://192.168.1.40"; fi - if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi - echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" + if [ "$BASE_PINNED" = true ] && curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi + echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" [ -n "$url" ] || echo "::warning::pkg-cache unreachable - building from upstream repositories" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "mirror=${mirror}" >> "$GITHUB_OUTPUT" @@ -165,6 +189,7 @@ jobs: build-args: | PHPVER=${{ matrix.phpver }} CACHE_BUST=${{ steps.bust.outputs.value }} + BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }} tags: | @@ -183,6 +208,26 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Base image pinned by DIGEST, looked up directly at Docker Hub (never via + # the pkg-cache mirror), so the mirror can only serve content-addressed + # blobs that BuildKit verifies. If the lookup fails this job does NOT use + # the mirror and pulls the tag straight from Docker Hub. docs/pkg-cache.md. + - name: Resolve base image digest (Docker Hub, direct) + id: base + env: + BASE_TAG: almalinux/10-base:latest + run: | + set -uo pipefail + if ref=$(.gitea/scripts/resolve-base-image.sh "$BASE_TAG") && [[ "$ref" =~ ^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$ ]]; then + echo "base-image: ${BASE_TAG} -> ${ref} (digest from registry-1.docker.io)" + echo "image=${ref}" >> "$GITHUB_OUTPUT" + echo "pinned=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::base image ${BASE_TAG}: Docker Hub digest lookup failed - pulling the tag directly from Docker Hub, pkg-cache registry mirror NOT used" + echo "image=${BASE_TAG}" >> "$GITHUB_OUTPUT" + echo "pinned=false" >> "$GITHUB_OUTPUT" + fi + # Internal package cache (CT130 pkg-cache, docs/pkg-cache.md). Used ONLY if # it answers over TLS with our pinned CA; otherwise (down, unreachable, # bad/expired cert) this job builds from the upstream repos and Docker Hub @@ -190,11 +235,14 @@ jobs: # separately so either can fail back on its own. - name: Probe package cache id: pkgcache + # The registry mirror is only used for a digest-pinned base image. + env: + BASE_PINNED: ${{ steps.base.outputs.pinned }} run: | url=""; mirror="" if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40/; then url="https://192.168.1.40"; fi - if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi - echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" + if [ "$BASE_PINNED" = true ] && curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi + echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" [ -n "$url" ] || echo "::warning::pkg-cache unreachable - building from upstream repositories" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "mirror=${mirror}" >> "$GITHUB_OUTPUT" @@ -264,6 +312,7 @@ jobs: build-args: | PHPVER=${{ matrix.phpver }} CACHE_BUST=${{ steps.bust.outputs.value }} + BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }} tags: | @@ -478,6 +527,26 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Base image pinned by DIGEST, looked up directly at Docker Hub (never via + # the pkg-cache mirror), so the mirror can only serve content-addressed + # blobs that BuildKit verifies. If the lookup fails this job does NOT use + # the mirror and pulls the tag straight from Docker Hub. docs/pkg-cache.md. + - name: Resolve base image digest (Docker Hub, direct) + id: base + env: + BASE_TAG: almalinux/10-base:latest + run: | + set -uo pipefail + if ref=$(.gitea/scripts/resolve-base-image.sh "$BASE_TAG") && [[ "$ref" =~ ^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$ ]]; then + echo "base-image: ${BASE_TAG} -> ${ref} (digest from registry-1.docker.io)" + echo "image=${ref}" >> "$GITHUB_OUTPUT" + echo "pinned=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::base image ${BASE_TAG}: Docker Hub digest lookup failed - pulling the tag directly from Docker Hub, pkg-cache registry mirror NOT used" + echo "image=${BASE_TAG}" >> "$GITHUB_OUTPUT" + echo "pinned=false" >> "$GITHUB_OUTPUT" + fi + # Internal package cache (CT130 pkg-cache, docs/pkg-cache.md). Used ONLY if # it answers over TLS with our pinned CA; otherwise (down, unreachable, # bad/expired cert) this job builds from the upstream repos and Docker Hub @@ -485,11 +554,14 @@ jobs: # separately so either can fail back on its own. - name: Probe package cache id: pkgcache + # The registry mirror is only used for a digest-pinned base image. + env: + BASE_PINNED: ${{ steps.base.outputs.pinned }} run: | url=""; mirror="" if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40/; then url="https://192.168.1.40"; fi - if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi - echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" + if [ "$BASE_PINNED" = true ] && curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi + echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" [ -n "$url" ] || echo "::warning::pkg-cache unreachable - building from upstream repositories" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "mirror=${mirror}" >> "$GITHUB_OUTPUT" @@ -528,16 +600,41 @@ jobs: platforms: linux/amd64 push: true build-args: | + BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} tags: | repo.anhonesthost.net/cloud-hosting-platform/shared-httpd:latest Build-Shared-OLS: runs-on: ubuntu-latest + # One place for the OLS base tag: digest lookup and build-args both read it. + env: + OLS_VERSION: "1.8.4" + PHPVER: "83" steps: - name: Checkout uses: actions/checkout@v4 + # Base image pinned by DIGEST, looked up directly at Docker Hub (never via + # the pkg-cache mirror), so the mirror can only serve content-addressed + # blobs that BuildKit verifies. If the lookup fails this job does NOT use + # the mirror and pulls the tag straight from Docker Hub. docs/pkg-cache.md. + - name: Resolve base image digest (Docker Hub, direct) + id: base + env: + BASE_TAG: litespeedtech/openlitespeed:${{ env.OLS_VERSION }}-lsphp${{ env.PHPVER }} + run: | + set -uo pipefail + if ref=$(.gitea/scripts/resolve-base-image.sh "$BASE_TAG") && [[ "$ref" =~ ^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$ ]]; then + echo "base-image: ${BASE_TAG} -> ${ref} (digest from registry-1.docker.io)" + echo "image=${ref}" >> "$GITHUB_OUTPUT" + echo "pinned=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::base image ${BASE_TAG}: Docker Hub digest lookup failed - pulling the tag directly from Docker Hub, pkg-cache registry mirror NOT used" + echo "image=${BASE_TAG}" >> "$GITHUB_OUTPUT" + echo "pinned=false" >> "$GITHUB_OUTPUT" + fi + # Internal package cache (CT130 pkg-cache, docs/pkg-cache.md). Used ONLY if # it answers over TLS with our pinned CA; otherwise (down, unreachable, # bad/expired cert) this job builds from the upstream repos and Docker Hub @@ -545,11 +642,14 @@ jobs: # separately so either can fail back on its own. - name: Probe package cache id: pkgcache + # The registry mirror is only used for a digest-pinned base image. + env: + BASE_PINNED: ${{ steps.base.outputs.pinned }} run: | url=""; mirror="" if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40/; then url="https://192.168.1.40"; fi - if curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi - echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" + if [ "$BASE_PINNED" = true ] && curl -fsS --max-time 5 --cacert keys/pkg-cache/ca.crt -o /dev/null https://192.168.1.40:5000/v2/; then mirror="192.168.1.40:5000"; fi + echo "pkg-cache: repos=${url:-} registry-mirror=${mirror:-}" [ -n "$url" ] || echo "::warning::pkg-cache unreachable - building from upstream repositories" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "mirror=${mirror}" >> "$GITHUB_OUTPUT" @@ -590,8 +690,9 @@ jobs: # 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 + PHPVER=${{ env.PHPVER }} + OLS_VERSION=${{ env.OLS_VERSION }} + BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} tags: | repo.anhonesthost.net/cloud-hosting-platform/shared-ols:latest diff --git a/Dockerfile b/Dockerfile index 3901d6f..87dd56e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,9 @@ -FROM almalinux/10-base +# Base image. CI passes BASE_IMAGE=almalinux/10-base@sha256:, resolved +# DIRECTLY from Docker Hub in the same run (.gitea/scripts/resolve-base-image.sh), +# so the pkg-cache registry mirror can only serve content-addressed blobs. +# Local builds use the tag. docs/pkg-cache.md "Base-image digest pinning". +ARG BASE_IMAGE=almalinux/10-base +FROM ${BASE_IMAGE} 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. diff --git a/Dockerfile.fpm b/Dockerfile.fpm index 76efa55..8d0978a 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -1,4 +1,9 @@ -FROM almalinux/10-base +# Base image. CI passes BASE_IMAGE=almalinux/10-base@sha256:, resolved +# DIRECTLY from Docker Hub in the same run (.gitea/scripts/resolve-base-image.sh), +# so the pkg-cache registry mirror can only serve content-addressed blobs. +# Local builds use the tag. docs/pkg-cache.md "Base-image digest pinning". +ARG BASE_IMAGE=almalinux/10-base +FROM ${BASE_IMAGE} 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. diff --git a/Dockerfile.shared-httpd b/Dockerfile.shared-httpd index 8b4a5d1..9a7e223 100644 --- a/Dockerfile.shared-httpd +++ b/Dockerfile.shared-httpd @@ -1,4 +1,9 @@ -FROM almalinux/10-base +# Base image. CI passes BASE_IMAGE=almalinux/10-base@sha256:, resolved +# DIRECTLY from Docker Hub in the same run (.gitea/scripts/resolve-base-image.sh), +# so the pkg-cache registry mirror can only serve content-addressed blobs. +# Local builds use the tag. docs/pkg-cache.md "Base-image digest pinning". +ARG BASE_IMAGE=almalinux/10-base +FROM ${BASE_IMAGE} # OPTIONAL internal package cache, e.g. --build-arg PKG_CACHE=https://192.168.1.40 # (CT130 pkg-cache, https only). Unset = upstream repositories. Release RPMs are diff --git a/Dockerfile.shared-ols b/Dockerfile.shared-ols index 3e617b7..cd83789 100644 --- a/Dockerfile.shared-ols +++ b/Dockerfile.shared-ols @@ -17,7 +17,12 @@ ARG OLS_VERSION=1.8.4 ARG PHPVER=83 -FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} +## CI passes BASE_IMAGE=litespeedtech/openlitespeed@sha256: of the +## ${OLS_VERSION}-lsphp${PHPVER} tag, resolved DIRECTLY from Docker Hub in the same +## run (.gitea/scripts/resolve-base-image.sh), so the pkg-cache registry mirror can +## only serve content-addressed blobs. Local builds use the tag. +ARG BASE_IMAGE=litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} +FROM ${BASE_IMAGE} ## OPTIONAL internal package cache, e.g. --build-arg PKG_CACHE=https://192.168.1.40 ## (CT130 pkg-cache, https only). Unset = upstream repositories. Release RPMs are diff --git a/docs/pkg-cache.md b/docs/pkg-cache.md index ceae8fa..b32b772 100644 --- a/docs/pkg-cache.md +++ b/docs/pkg-cache.md @@ -149,6 +149,36 @@ Remaining risks: # /etc/docker/daemon.json: { "registry-mirrors": ["https://192.168.1.40:5000"] } then restart docker ``` +## Base-image digest pinning (2026-10-22) + +Gap closed (security review): with the BuildKit mirror on, `FROM almalinux/10-base` +was resolved **by tag through the cache**, so a compromised cache could hand out a +different image for that tag. + +Now every job that uses the mirror first runs **"Resolve base image digest (Docker +Hub, direct)"**: +- `.gitea/scripts/resolve-base-image.sh IMAGE:TAG` gets an anonymous pull token from + `auth.docker.io` and sends a `HEAD /v2//manifests/` to + `registry-1.docker.io` over HTTPS (never the mirror; HEADs don't count against the + pull rate limit). It prints `IMAGE@sha256:`. Input and output are + regex-validated; values reach `run:` only via `env:`. +- The job passes `BASE_IMAGE=` as a build arg; the Dockerfiles say + `ARG BASE_IMAGE=` / `FROM ${BASE_IMAGE}`. The mirror can then only serve + content-addressed manifests/blobs, which BuildKit verifies against the digest. +- **Lookup fails:** `BASE_IMAGE` falls back to the tag, the probe step does **not** + enable the registry mirror (`registry-mirror=`), and + the tag is pulled straight from Docker Hub, as before the cache existed. The + package-repo cache (`PKG_CACHE`) is unaffected, since its content is GPG-checked. +- Freshness is automatic: the digest is looked up on every run, nothing is + hard-coded. Local builds without the arg use the tag. +- Log lines to look for: `base-image: almalinux/10-base:latest -> almalinux/10-base@sha256:...` + and `pkg-cache: repos=https://192.168.1.40 registry-mirror=192.168.1.40:5000`. + +Applied to: Build-and-Push, Build-FPM-Images, Build-Shared-httpd, Build-Shared-OLS +(`litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}`) here, and cloud-node-container. +The script is canonical here (`.gitea/scripts/resolve-base-image.sh`); other repos +carry byte-identical copies. + ## WHP hosts (`scripts/host/pkgcache-host-repos.sh`) `pkgcache-host-repos.sh apply https://192.168.1.40` does the following: