Files
cloud-apache-container/docs/pkg-cache.md
T
shadowdaoandClaude Opus 5.5 d1547f3418 ci: pin base images by digest resolved directly from Docker Hub
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>
2026-10-22 06:58:16 -07:00

239 lines
15 KiB
Markdown

# 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/`, except `by-hash/`
- `*-latest*.rpm` and `remi-release-N.rpm`
- `RPM-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_lock` is on.
- Responses carry `X-Cache-Status` and `X-Pkgcache-Tier`.
## Building with the cache
```bash
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 `baseurl` becomes `<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 by `restore`.
- It also gets `gpgcheck=1`.
- AlmaLinux and Remi also get `repo_gpgcheck=1`, because both sign `repomd.xml` (measured 2026-10-21: `repomd.xml.asc` returns 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-cache` sets `Acquire::https::192.168.1.40::CaInfo`, so the CA is trusted for the cache host only.
- apt verifies the signed `InRelease`/`Release.gpg` as 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 -K` to report `digests 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/dnf` or `/etc/apt` still 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.sh` flow needs a `PKG_CACHE` ARG in each stage;
- its madison check must accept the cache URL. It currently requires `*rpms.litespeedtech.com*`, and via the cache madison shows `https://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.40` and DNS `pkg-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-libs` and `httpd` from AlmaLinux BaseOS/AppStream, and `ImageMagick` from 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 integration (APPLIED 2026-10-21)
1. **Runner network.** Ephemeral runners are NATed and `iptables FORWARD` drops
all RFC1918 destinations:
- `eph-pve-*` on `pve`, vmbr2, 10.99.98.0/24;
- `eph-pve3-*` on `pve3`, vmbr1, 10.99.99.0/24.
One rule per node now sits **above** those DROPs. It is persistent through a
`post-up`/`post-down` pair 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 ACCEPT
```
Verified 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.io` resolves to 10.10.14.20, which
is unroutable from both the LAN and the DC (stale record?).
2. **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 curls `https://192.168.1.40/` and
`https://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.40` is 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).
3. **dockerd clients** (plain `docker pull`). Untested, because it needs a daemon restart.
Use TLS, no `insecure-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:TAG` gets an anonymous pull token from
`auth.docker.io` and sends a `HEAD /v2/<repo>/manifests/<tag>` to
`registry-1.docker.io` over HTTPS (never the mirror; HEADs don't count against the
pull rate limit). It prints `IMAGE@sha256:<index digest>`. Input and output are
regex-validated; values reach `run:` only via `env:`.
- The job passes `BASE_IMAGE=<that>` as a build arg; the Dockerfiles say
`ARG 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_IMAGE` falls 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:...`
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:
- backs up every `/etc/yum.repos.d/*.repo` to `/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`, plus `repo_gpgcheck=1` for 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.conf` holds the cache zone, maps and log format.
- `/etc/nginx/pkgcache/{server-common,meta,pkg}.conf` hold the shared settings.
- `/etc/nginx/sites-available/pkgcache` is **generated** by `/usr/local/sbin/pkgcache-gen-nginx`. Edit the `UPSTREAMS` table, then run `pkgcache-gen-nginx > /etc/nginx/sites-available/pkgcache && nginx -t && systemctl reload nginx`.
- Listeners: :443 TLS for the LAN, `127.0.0.1:80` for local tools, and :80 for the LAN answers 403.
- **Registry.** Config is `/etc/docker/registry/config.yml` (Debian `docker-registry` 2.8.3, the same code as `registry:2`, with TLS). Service `docker-registry`.
- **TLS.** Everything is in `/etc/pkgcache-ca/`: `ca.key` (0600), `ca.crt`, `server.key`, `server-chain.crt`, and the `ca.cnf`/`srv.cnf` openssl configs.
- **Data.** nginx uses `/srv/cache/nginx`, the registry uses `/srv/cache/registry`. Both live on the 150 GB mount point `mp0` (`backup=0`).
- **Security-release day.** `pkgcache-refresh` re-fetches every cached metadata entry right away. Variants:
- `pkgcache-refresh remi` refreshes one prefix;
- `--list` lists the entries;
- `--purge` deletes 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-stats` shows:
- disk usage;
- hit ratio;
- fallback-tier use;
- the STALE count;
- upstream errors;
- certificate expiry.
Logs are `/var/log/nginx/pkgcache.access.log` (fields `cache=`, `tier=`, `up=`) and `journalctl -u docker-registry`. Suggested Uptime Kuma checks are `https://192.168.1.40/` and `https://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) under `proxy:` if needed.