#!/bin/bash ## pkg-cache-repos.sh — BUILD-TIME ONLY, OPT-IN. Points dnf/apt at the internal ## package cache (nginx proxy_cache, CT130 "pkg-cache" on the Proxmox cluster) ## when PKG_CACHE is set; without it the repo files are left alone and builds ## use the upstream repositories as before. ## ## PKG_CACHE=https://192.168.1.40 pkg-cache-repos.sh rpm # AlmaLinux/EPEL/Remi ## PKG_CACHE=https://192.168.1.40 pkg-cache-repos.sh apt # Ubuntu + LiteSpeed ## pkg-cache-repos.sh release-install epel [remi] # GPG-verified release RPMs ## pkg-cache-repos.sh restore # undo everything above ## ## Every RUN that rewrites must `restore` before it ends: it puts rewritten files ## back, deletes files this script created, deletes any NEW repo/apt file that ## still references the cache, then FAILS if any reference is left — so no shipped ## layer carries the cache's LAN address (images run on hosts that can't reach it). ## ## Why: on 2026-10-21 (PHP security release) builds failed on Remi mirrors lagging ## hours behind origin, Remi's origin timing out over HTTPS, and an EPEL mirror ## with a broken certificate. The cache fetches from the ORIGINS over HTTPS (with ## origin/mirror fallbacks and stale-on-error), keeps metadata ~3 minutes and ## immutable .rpm/.deb files long-term. ## ## Integrity (security review 2026-10-21): ## * PKG_CACHE must be https://. The cache's certificate is issued by the ## name-constrained "pkg-cache internal CA" (keys/pkg-cache/ca.crt, valid only ## for 192.168.1.40 / pkg-cache.*). That CA is trusted ONLY by the rewritten ## repos (dnf sslcacert= bundle, apt per-host CaInfo) and only during the RUN — ## never added to the system trust store. ## * gpgcheck=1 is forced on every rewritten repo; repo_gpgcheck=1 on Remi and ## AlmaLinux, which sign repomd.xml (measured: repomd.xml.asc 200). EPEL ## publishes no repomd signature (404), so its metadata relies on HTTPS end ## to end (client->cache->dl.fedoraproject.org/mirrors, all verified). ## * release-install downloads epel-release / remi-release (from the cache, or ## from the HTTPS origin without PKG_CACHE), checks the package name, imports ## the signing keys committed in keys/pkg-cache/ and requires ## `rpm -K` "signatures OK" before installing with localpkg_gpgcheck=1. ## * The php.net version gates (EXPECT_PHP_MIN / EXPECT_PHP_VERSION) still fail ## a build that gets old PHP (replay of old-but-signed metadata). ## ## rpm: each rewritten baseurl keeps the upstream origin as a SECOND url, so if ## the cache dies mid-build dnf fails over to the origin by itself. ## apt: apt has no per-source failover; CI only sets PKG_CACHE after probing the ## cache (with the CA), and a build without PKG_CACHE uses upstream sources. set -euo pipefail MODE="${1:?usage: pkg-cache-repos.sh rpm|apt|release-install NAME...|restore}" shift || true STATE=/var/lib/pkg-cache-repos # saved originals + manifest + created files SHARE="${PKG_CACHE_SHARE:-/usr/local/share/pkg-cache}" # ca.crt + RPM-GPG-KEY-* C="${PKG_CACHE:-}" C="${C%/}" die() { echo "FATAL(pkg-cache-repos): $*" >&2; exit 1; } if [ -n "$C" ]; then case "$C" in https://*) ;; *) die "PKG_CACHE must be an https:// URL (plain http is refused), got '$C'" ;; esac HOSTPORT="${C#https://}"; HOSTPORT="${HOSTPORT%%/*}" [ -s "$SHARE/ca.crt" ] || die "PKG_CACHE set but $SHARE/ca.crt (pkg-cache CA) is missing" fi # Files that may hold repo configuration; restore re-checks all of them. repo_files() { find /etc/yum.repos.d /etc/dnf /etc/apt -type f 2>/dev/null | sort || true } start_session() { [ -d "$STATE" ] && return 0 mkdir -p "$STATE/orig" repo_files > "$STATE/manifest" # what existed before this RUN touched anything : > "$STATE/created" printf '%s\n' "$HOSTPORT" > "$STATE/host" } save() { # keep the first-seen (pre-rewrite) copy [ -e "$STATE/orig$1" ] && return 0 mkdir -p "$STATE/orig$(dirname "$1")" cp -p "$1" "$STATE/orig$1" } created() { printf '%s\n' "$1" >> "$STATE/created"; } if [ "$MODE" = restore ]; then [ -d "$STATE" ] || exit 0 host=$(cat "$STATE/host") # mapfile from a process substitution, not `cmd | while read`: no pipeline, # so nothing for pipefail to misreport (scripts/tests/lsphp-info-probe.test.sh). mapfile -t saved < <(cd "$STATE/orig" && find . -type f) for rel in "${saved[@]}"; do cat "$STATE/orig/$rel" > "/${rel#./}" done while read -r f; do [ -n "$f" ] && rm -f -- "$f"; done < "$STATE/created" # A repo/apt file that appeared during this RUN and still names the cache # (e.g. written by a package from the rewritten repos) is removed too. mapfile -t current < <(repo_files) for f in "${current[@]}"; do if ! grep -qxF -- "$f" "$STATE/manifest" && grep -qF -- "$host" "$f"; then echo "pkg-cache-repos: removing new file that references the cache: $f" rm -f -- "$f" fi done left=$(repo_files | xargs -r grep -lF -- "$host" 2>/dev/null || true) rm -rf "$STATE" [ -z "$left" ] || die "cache address $host still present after restore in: $left" echo "pkg-cache-repos: original repository files restored (no reference to $host left)" exit 0 fi if [ "$MODE" = release-install ]; then [ $# -gt 0 ] || die "release-install needs: epel and/or remi" tmp=$(mktemp -d) curl_opts=(-fsS --proto '=https' --retry 5 --retry-all-errors --max-time 120) if [ -n "$C" ]; then start_session cat /etc/pki/tls/certs/ca-bundle.crt "$SHARE/ca.crt" > "$STATE/ca-bundle.pem" curl_opts+=(--cacert "$STATE/ca-bundle.pem") fi files=() for name in "$@"; do case "$name" in epel) pkg=epel-release key=RPM-GPG-KEY-EPEL-10 path=/epel/epel-release-latest-10.noarch.rpm origin=https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm ;; remi) pkg=remi-release key=RPM-GPG-KEY-remi2024 path=/remi/enterprise/remi-release-10.rpm origin=https://rpms.remirepo.net/enterprise/remi-release-10.rpm ;; *) die "unknown release package '$name'" ;; esac url="$origin"; [ -n "$C" ] && url="$C$path" f="$tmp/$pkg.rpm" curl "${curl_opts[@]}" -o "$f" "$url" || die "download failed: $url" rpm --import "$SHARE/$key" || die "cannot import $SHARE/$key" got=$(rpm -qp --nosignature --qf '%{NAME}' "$f" 2>/dev/null || true) [ "$got" = "$pkg" ] || die "$url is '$got', expected $pkg" chk=$(rpm -K "$f" 2>&1 || true) [[ "$chk" == *": digests signatures OK" ]] || die "signature check failed for $url: $chk" echo "pkg-cache-repos: $pkg verified ($chk) from $url" files+=("$f") done dnf install -y --setopt=localpkg_gpgcheck=1 "${files[@]}" rm -rf "$tmp" exit 0 fi if [ -z "$C" ]; then echo "pkg-cache-repos($MODE): PKG_CACHE not set - using upstream repositories" exit 0 fi rewrite_rpm() { local f="$1" grep -q -E '^#? ?baseurl=https?://(repo\.almalinux\.org|download\.example|rpms\.remirepo\.net)/' "$f" || return 0 save "$f" # Per [section]: a (commented or live) baseurl on a known origin becomes # "baseurl=// /"; that section's # mirrorlist/metalink is commented out (dnf prefers them over baseurl) and it # gets sslcacert=, gpgcheck=1 and, for repos # that sign repomd.xml, repo_gpgcheck=1. Other sections are untouched. awk -v C="$C" -v BUNDLE="$STATE/ca-bundle.pem" ' function flush( i) { for (i = 1; i <= n; i++) { if (hit && buf[i] ~ /^(mirrorlist|metalink)=/) buf[i] = "#" buf[i] if (hit && buf[i] ~ /^(sslcacert|gpgcheck|repo_gpgcheck)[ \t]*=/) continue print buf[i] } if (hit) { print "sslcacert=" BUNDLE print "gpgcheck=1" if (signed) print "repo_gpgcheck=1" } n = 0; hit = 0; signed = 0 } function rw(line, re, prefix, origin, sig, p) { if (match(line, re)) { p = substr(line, RSTART + RLENGTH); sub(/[ \t].*$/, "", p) hit = 1; if (sig) signed = 1 return "baseurl=" C "/" prefix "/" p " " origin "/" p } return line } /^\[/ { flush() } { l = $0 if (l ~ /^#? ?baseurl=/) { l = rw(l, "^#? ?baseurl=https?://repo\\.almalinux\\.org/almalinux/", "almalinux", "https://repo.almalinux.org/almalinux", 1) l = rw(l, "^#? ?baseurl=https?://download\\.example/pub/epel/", "epel", "https://dl.fedoraproject.org/pub/epel", 0) l = rw(l, "^#? ?baseurl=https?://rpms\\.remirepo\\.net/", "remi", "https://rpms.remirepo.net", 1) } buf[++n] = l } END { flush() } ' "$f" > "$f.pkgcache" && cat "$f.pkgcache" > "$f" && rm -f "$f.pkgcache" } rewrite_apt() { local f="$1" grep -q -E 'https?://([a-z]{2}\.)?(archive|security)\.ubuntu\.com/|https?://rpms\.litespeedtech\.com/' "$f" || return 0 save "$f" sed -i -E \ -e "s%https?://(archive|[a-z]{2}\.archive)\.ubuntu\.com/ubuntu/?%$C/ubuntu/%g" \ -e "s%https?://security\.ubuntu\.com/ubuntu/?%$C/ubuntu-security/%g" \ -e "s%https?://rpms\.litespeedtech\.com/%$C/litespeed/%g" \ "$f" } case "$MODE" in rpm) start_session cat /etc/pki/tls/certs/ca-bundle.crt "$SHARE/ca.crt" > "$STATE/ca-bundle.pem" for f in /etc/yum.repos.d/*.repo; do [ -f "$f" ] && rewrite_rpm "$f"; done echo "pkg-cache-repos(rpm): repos now via $C:" grep -h '^baseurl=' /etc/yum.repos.d/*.repo | grep -F "$C/" | sort -u | sed 's/^/ /' ;; apt) start_session conf=/etc/apt/apt.conf.d/99pkg-cache # Trust the pkg-cache CA for the cache host ONLY (apt per-host option). printf 'Acquire::https::%s::CaInfo "%s";\n' "${HOSTPORT%%:*}" "$SHARE/ca.crt" > "$conf" created "$conf" for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [ -f "$f" ] && rewrite_apt "$f" done echo "pkg-cache-repos(apt): sources now via $C:" grep -h -E '^(deb|URIs:)' /etc/apt/sources.list /etc/apt/sources.list.d/* 2>/dev/null | grep -F "$C/" | sort -u | sed 's/^/ /' ;; *) die "unknown mode '$MODE'" ;; esac