With the pkg-cache BuildKit mirror on, `FROM almalinux/10-base` was resolved by tag through the cache, so a compromised cache could substitute the image. Each job using the mirror now looks up the base tag's digest DIRECTLY at registry-1.docker.io (.gitea/scripts/resolve-base-image.sh, HTTPS, HEAD on the manifest, regex-validated) and builds FROM image@sha256:... via the BASE_IMAGE build-arg, so the mirror can only serve content-addressed blobs. If the lookup fails, the mirror is NOT used for that job and the tag is pulled straight from Docker Hub. The digest is resolved every run (no manual bumps); local builds default to the tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
Internal package + image cache (PKG_CACHE)
Opt-in build support for the LAN package cache CT130 pkg-cache at https://192.168.1.40 (Proxmox node pve, storage shared-storage-tn).
Without PKG_CACHE, builds use the upstream repositories. The one change from before is that the EPEL and Remi release RPMs are now GPG-verified against keys/pkg-cache/ before they are installed.
Why it exists: during the 2026-10-21 PHP security release, builds failed because:
- Remi mirrors lagged hours behind origin;
- Remi's origin reset or timed out HTTPS under load;
- an EPEL mirror had a broken certificate.
On that day a build without the cache spent 1031 s in its first dnf step. Through the cache the same step took 40 s cold and 21 s warm.
What the cache serves
| URL (https, pkg-cache CA) | Upstream tiers, tried in order (stale copy last) |
|---|---|
https://192.168.1.40/remi/ |
https://rpms.remirepo.net, then http://rpms.remirepo.net (origin only, no mirrors) |
https://192.168.1.40/epel/ |
https://dl.fedoraproject.org/pub/epel, https://mirror.math.princeton.edu/pub/epel, https://mirrors.kernel.org/fedora-epel |
https://192.168.1.40/almalinux/ |
https://repo.almalinux.org/almalinux, https://mirror.math.princeton.edu/pub/almalinux |
https://192.168.1.40/ubuntu/ |
https://archive.ubuntu.com/ubuntu, https://us.archive.ubuntu.com/ubuntu |
https://192.168.1.40/ubuntu-security/ |
https://security.ubuntu.com/ubuntu, https://archive.ubuntu.com/ubuntu |
https://192.168.1.40/litespeed/ |
https://rpms.litespeedtech.com, then http://rpms.litespeedtech.com |
192.168.1.40:5000 (TLS, pkg-cache CA) |
Docker Hub pull-through cache (registry-1.docker.io) |
Plain http://192.168.1.40/ answers 403 to LAN clients.
Cache rules:
- Metadata has a 3-minute TTL. After that it is revalidated upstream (
If-Modified-Since). Metadata means:repomd.xml*- repodata files whose names don't start with a hash
- apt
dists/, exceptby-hash/ *-latest*.rpmandremi-release-N.rpmRPM-GPG-KEY*- directory listings
- Packages (
*.rpm,*.deb, hash-named repodata,by-hash/) are cached for 30 days and evicted after 60 days unused. Total size is capped at 110 GB, with 15 GB kept free. - On a connect error, timeout or 5xx, the request falls through to the next tier. Only the last tier may answer from a stale copy, so the order is primary, then fallback, then stale.
proxy_cache_lockis on.- Responses carry
X-Cache-StatusandX-Pkgcache-Tier.
Building with the cache
docker buildx build --build-arg PKG_CACHE=https://192.168.1.40 --build-arg PHPVER=85 -f Dockerfile .
scripts/pkg-cache-repos.sh (installed as /usr/local/sbin/) and keys/pkg-cache/ (installed as /usr/local/share/pkg-cache/) are wired into Dockerfile, Dockerfile.fpm, Dockerfile.shared-httpd and Dockerfile.shared-ols. The script has four modes.
rpm handles AlmaLinux, EPEL and Remi:
- Each
baseurlbecomes<cache>/<prefix>/... <origin>/.... dnf fails over to the origin if the cache dies. - That section's mirrorlist/metalink is commented out.
- The section gets
sslcacert=pointing at a bundle of the system CAs plus the pkg-cache CA. The bundle lives in the state dir and is deleted byrestore. - It also gets
gpgcheck=1. - AlmaLinux and Remi also get
repo_gpgcheck=1, because both signrepomd.xml(measured 2026-10-21:repomd.xml.ascreturns 200). EPEL publishes no repomd signature (404), so its metadata integrity rests on HTTPS end to end.
apt handles Ubuntu archive/security and LiteSpeed:
- Their URIs are rewritten to the cache.
/etc/apt/apt.conf.d/99pkg-cachesetsAcquire::https::192.168.1.40::CaInfo, so the CA is trusted for the cache host only.- apt verifies the signed
InRelease/Release.gpgas always. It has no failover, so CI must probe the cache first.
release-install epel [remi]:
- Downloads the release RPM over HTTPS, from the cache or from the origin.
- Checks the package name.
- Imports the key committed in
keys/pkg-cache/. - Requires
rpm -Kto reportdigests signatures OK. - Installs with
--setopt=localpkg_gpgcheck=1.
restore:
- Puts rewritten files back.
- Deletes files the script created.
- Deletes any repo/apt file that appeared during the RUN and still references the cache.
- Then fails the build if any file under
/etc/yum.repos.d,/etc/dnfor/etc/aptstill mentions the cache.
Every RUN that rewrites ends with restore, so no shipped layer carries the LAN address. This was verified on the built images: 0 references, no state dir, no apt drop-in.
PKG_CACHE must start with https://. http:// is refused.
Dockerfile.litespeed and Dockerfile.lsphp are not wired yet. Two things are needed first:
- the multi-stage
lsphp-apt-upgrade.shflow needs aPKG_CACHEARG in each stage; - its madison check must accept the cache URL. It currently requires
*rpms.litespeedtech.com*, and via the cache madison showshttps://192.168.1.40/litespeed/debian.
Trust model
keys/pkg-cache/ca.crt is the pkg-cache internal CA:
- EC P-256, valid to 2036.
- Name-constrained to IP
192.168.1.40and DNSpkg-cache.internal.cloud-hosting.io/pkg-cache.geekhome.local. Even its private key, which exists only on CT130 at/etc/pkgcache-ca/ca.key, cannot mint a certificate that clients would accept for any other host. - It is trusted only by the rewritten repos, the apt per-host option and the BuildKit/dockerd registry config. It is never added to a system trust store.
The server certificate /etc/pkgcache-ca/server.crt expires 2029-10-20. pkgcache-stats prints the expiry. Renew it with the same openssl x509 -req ... -extfile srv.cnf steps in /etc/pkgcache-ca/.
Remaining risks:
- Replay of old but validly signed metadata. Signatures stop tampering, not freshness. The cache's own plain-http tiers for Remi and LiteSpeed are the leg where an internet MITM could replay. The php.net gates (
EXPECT_PHP_MIN,EXPECT_LSPHP_PKG/EXPECT_PHP_VERSION) fail an image build that gets old PHP. Josh to decide whether to drop those http tiers: you gain replay resistance and lose availability when Remi's HTTPS is down, as it was all day on 2026-10-21. - Proposed, not implemented: the same kind of minimum-version gate for other security-relevant packages:
- CI resolves the newest
openssl/openssl-libsandhttpdfrom AlmaLinux BaseOS/AppStream, andImageMagickfrom EPEL, directly from the origin over HTTPS (e.g.dnf repoquery --repofrompath=o,https://repo.almalinux.org/almalinux/10/BaseOS/x86_64/os/ --repo=o --latest-limit=1 --qf '%{evr}' openssl-libs). - It passes the results as
EXPECT_OPENSSL_MIN/EXPECT_HTTPD_MIN/EXPECT_IMAGEMAGICK_MIN. - The Dockerfile asserts
rpm -q --qf '%{EVR}'is not older (rpm --eval "%{lua: print(rpm.vercmp('A','B'))}").
- CI resolves the newest
CI integration (APPLIED 2026-10-21)
-
Runner network. Ephemeral runners are NATed and
iptables FORWARDdrops all RFC1918 destinations:eph-pve-*onpve, vmbr2, 10.99.98.0/24;eph-pve3-*onpve3, vmbr1, 10.99.99.0/24.
One rule per node now sits above those DROPs. It is persistent through a
post-up/post-downpair placed before the first DROP line in/etc/network/interfaces.d/vmbr2-ci-isolated(pve) and/etc/network/interfaces.d/vmbr1-ci-isolated(pve3). The Proxmox firewall is disabled on both nodes, so its reloads do not touch these chains. Backups:/root/vmbr{2,1}-ci-isolated.bak-pkgcache-20261021.iptables -I FORWARD 1 -i vmbr2 -o vmbr0 -d 192.168.1.40/32 -p tcp -m multiport --dports 443,5000 \ -m comment --comment pkg-cache-ct130 -j ACCEPTVerified from throwaway CTs on vmbr2 and vmbr1:
- :443 and :5000 work with TLS verified;
- CT130 :22/:80, pve :8006, .193 :22, the router's :53 and 10.10.14.20 stay blocked;
- internet access is unchanged.
The LAN runner VM Git-Builders (VM103, 192.168.1.126 on vmbr0) needs no rule.
gitea-runner.internal.cloud-hosting.ioresolves to 10.10.14.20, which is unroutable from both the LAN and the DC (stale record?). -
Workflow (
.gitea/workflows/build-push.yaml). Jobs Build-and-Push, Build-FPM-Images, Build-Shared-httpd and Build-Shared-OLS run a "Probe package cache" step. It curlshttps://192.168.1.40/andhttps://192.168.1.40:5000/v2/with--cacert keys/pkg-cache/ca.crt --max-time 5.- If both answer:
PKG_CACHE=https://192.168.1.40is passed as a build arg, and buildx is set up with the Docker Hub mirror (buildkitd-config-inline,ca=). - If either fails: that output is empty, the build uses upstream repos /
Docker Hub, and a
::warning::is emitted.
Litespeed and lsphp jobs are unchanged (their Dockerfiles are not wired). Fail-back measured with the workflow's own step code, from a runner-bridge CT:
cache state probe result time up url + mirror set <1 s nginx + registry stopped both empty, warning <1 s (connection refused) silently dropped both empty, warning 10 s (2 x 5 s) Mid-build cache loss is also covered: dnf has the origin as a 2nd baseurl, and BuildKit falls back from the mirror to Docker Hub (measured with the registry stopped).
- If both answer:
-
dockerd clients (plain
docker pull). Untested, because it needs a daemon restart. Use TLS, noinsecure-registries:install -D -m0644 keys/pkg-cache/ca.crt /etc/docker/certs.d/192.168.1.40:5000/ca.crt # /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:TAGgets an anonymous pull token fromauth.docker.ioand sends aHEAD /v2/<repo>/manifests/<tag>toregistry-1.docker.ioover HTTPS (never the mirror; HEADs don't count against the pull rate limit). It printsIMAGE@sha256:<index digest>. Input and output are regex-validated; values reachrun:only viaenv:.- The job passes
BASE_IMAGE=<that>as a build arg; the Dockerfiles sayARG BASE_IMAGE=<tag>/FROM ${BASE_IMAGE}. The mirror can then only serve content-addressed manifests/blobs, which BuildKit verifies against the digest. - Lookup fails:
BASE_IMAGEfalls back to the tag, the probe step does not enable the registry mirror (registry-mirror=<not used: Docker Hub direct>), 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:...andpkg-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:
- backs up every
/etc/yum.repos.d/*.repoto/root/pkgcache-repo-backup-<UTC>/; - installs the name-constrained CA as a system anchor;
- rewrites the AlmaLinux, EPEL and Remi sections to
baseurl=<cache>/... <origin>/...; - comments out mirrorlist/metalink in those sections;
- sets
gpgcheck=1, plusrepo_gpgcheck=1for AlmaLinux and Remi.
revert [DIR] restores byte-identical files (verified on AL9 and AL10) and
removes the anchor. status shows the current state.
Why not "cache first, mirrorlist as fallback": measured on AL10, with
mirrorlist/metalink left in place dnf ignores baseurl completely (0 cache
requests). The origin as second baseurl is the fallback dnf really performs.
Measured on 192.168.1.148 (AlmaLinux 10.2), 2026-10-21:
| test | result |
|---|---|
baseline dnf makecache --refresh (original mirrorlists) |
rc=0, 1209 s |
via cache makecache --refresh |
rc=0, 22 s (2nd run, non-interactive: 2 s) |
via cache dnf download php-cli |
rc=0, 1 s |
dnf -y --downloadonly update php-cli |
"Nothing to do" (already 8.5.12) |
| cache down (nginx stopped): makecache --refresh / download | rc=0 16 s / rc=0 24 s, served by origin |
| cache silently dropped (iptables DROP): makecache --refresh / download | rc=0 225 s / rc=0 59 s (dnf's 30 s connect timeout per attempt) |
Datacenter hosts (whp01, whp02, sdbees, TrueSelfCA) cannot reach 192.168.1.40 and are not configured. That needs CT130 on NetBird (see the report: setup key / route pending).
Operating the cache (CT130)
-
nginx config.
/etc/nginx/conf.d/pkgcache-http.confholds the cache zone, maps and log format./etc/nginx/pkgcache/{server-common,meta,pkg}.confhold the shared settings./etc/nginx/sites-available/pkgcacheis generated by/usr/local/sbin/pkgcache-gen-nginx. Edit theUPSTREAMStable, then runpkgcache-gen-nginx > /etc/nginx/sites-available/pkgcache && nginx -t && systemctl reload nginx.- Listeners: :443 TLS for the LAN,
127.0.0.1:80for local tools, and :80 for the LAN answers 403.
-
Registry. Config is
/etc/docker/registry/config.yml(Debiandocker-registry2.8.3, the same code asregistry:2, with TLS). Servicedocker-registry. -
TLS. Everything is in
/etc/pkgcache-ca/:ca.key(0600),ca.crt,server.key,server-chain.crt, and theca.cnf/srv.cnfopenssl configs. -
Data. nginx uses
/srv/cache/nginx, the registry uses/srv/cache/registry. Both live on the 150 GB mount pointmp0(backup=0). -
Security-release day.
pkgcache-refreshre-fetches every cached metadata entry right away. Variants:pkgcache-refresh remirefreshes one prefix;--listlists the entries;--purgedeletes them instead.
A refresh bypasses the cache from localhost only and replaces each entry in place. If upstream is down, the old copy stays. Metadata is at most 3 minutes old anyway.
-
Monitoring.
pkgcache-statsshows:- disk usage;
- hit ratio;
- fallback-tier use;
- the STALE count;
- upstream errors;
- certificate expiry.
Logs are
/var/log/nginx/pkgcache.access.log(fieldscache=,tier=,up=) andjournalctl -u docker-registry. Suggested Uptime Kuma checks arehttps://192.168.1.40/andhttps://192.168.1.40:5000/v2/, with certificate checking against the CA. -
Docker Hub rate limits. Anonymous pulls are limited per egress IP, and all clients share it. Add
username/password(a Hub access token) underproxy:if needed.