diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 9b2059c..37afd4d 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -16,10 +16,38 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Internal package cache (CT130 pkg-cache). Used ONLY if it answers over + # TLS with our pinned CA; otherwise this job builds from the upstream repos + # and Docker Hub exactly as before. Repo cache and registry mirror are + # probed separately so either can fail back on its own. + - name: Probe package cache + id: pkgcache + 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:-}" + [ -n "$url" ] || echo "::warning::pkg-cache unreachable - building from upstream repositories" + echo "url=${url}" >> "$GITHUB_OUTPUT" + echo "mirror=${mirror}" >> "$GITHUB_OUTPUT" + - name: Set up QEMU uses: docker/setup-qemu-action@v3 + # BuildKit ignores dockerd's registry-mirrors, so the mirror is set on the + # builder; BuildKit still falls back to Docker Hub if the mirror stops. + - name: Set up Docker Buildx (Docker Hub via pkg-cache mirror) + if: steps.pkgcache.outputs.mirror != '' + uses: docker/setup-buildx-action@v3 + with: + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["192.168.1.40:5000"] + [registry."192.168.1.40:5000"] + ca = ["${{ github.workspace }}/keys/pkg-cache/ca.crt"] + - name: Set up Docker Buildx + if: steps.pkgcache.outputs.mirror == '' uses: docker/setup-buildx-action@v3 - name: Login to Gitea @@ -36,6 +64,7 @@ jobs: push: true build-args: | NODEVER=${{ matrix.nodever }} + PKG_CACHE=${{ steps.pkgcache.outputs.url }} tags: | repo.anhonesthost.net/cloud-hosting-platform/cnoc:node${{ matrix.nodever }} ${{ matrix.nodever == '20' && 'repo.anhonesthost.net/cloud-hosting-platform/cnoc:latest' || '' }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f5ab341..713dda7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,23 @@ FROM almalinux/10-base ARG NODEVER=20 +# OPTIONAL internal package cache, e.g. --build-arg PKG_CACHE=https://192.168.1.40 +# (CT130 pkg-cache, https only). Unset = upstream repositories. epel-release is +# GPG-verified against keys/pkg-cache/ either way. Repo files are restored at +# the end of the RUN, so no layer keeps the cache address. Same script as +# cloud-apache-container (scripts/pkg-cache-repos.sh, docs/pkg-cache.md there). +ARG PKG_CACHE= +COPY ./scripts/pkg-cache-repos.sh /usr/local/sbin/pkg-cache-repos.sh +COPY ./keys/pkg-cache/ /usr/local/share/pkg-cache/ # Install repos, update, install only needed packages, clean up in one layer -RUN dnf install -y \ - https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm && \ +RUN /usr/local/sbin/pkg-cache-repos.sh rpm && \ + /usr/local/sbin/pkg-cache-repos.sh release-install epel && \ + /usr/local/sbin/pkg-cache-repos.sh rpm && \ + printf 'retries=10\ntimeout=60\n' >> /etc/dnf/dnf.conf && \ dnf update -y && \ dnf install -y wget procps cronie iproute nginx openssl git microdnf make gcc gcc-c++ dumb-init && \ dnf group install -y 'Development Tools' && \ + /usr/local/sbin/pkg-cache-repos.sh restore && \ dnf clean all && \ rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/* \ /var/cache/yum /tmp/* /var/tmp/* diff --git a/keys/pkg-cache/README b/keys/pkg-cache/README new file mode 100644 index 0000000..1787183 --- /dev/null +++ b/keys/pkg-cache/README @@ -0,0 +1,13 @@ +Trust material for the OPT-IN internal package cache (scripts/pkg-cache-repos.sh, +docs/pkg-cache.md). Copied into images at /usr/local/share/pkg-cache/; nothing +here is added to the system trust store. + +ca.crt "pkg-cache internal CA (CT130)", EC P-256, valid to 2036-10-18. + NAME-CONSTRAINED to IP 192.168.1.40 and DNS + pkg-cache.internal.cloud-hosting.io / pkg-cache.geekhome.local. + SHA-256 fingerprint BB:5B:9A:08:05:ED:2F:58:83:92:D0:99:9D:11:0A:30:14:88:68:F0:F0:88:65:5E:94:0B:75:EB:C2:A2:BD:F3 + (private key lives only on CT130: /etc/pkgcache-ca/ca.key) +RPM-GPG-KEY-EPEL-10 Fedora (epel10) 7D8D 15CB FC4E 6268 8591 FB26 33D9 8517 E37E D158 + fetched 2026-10-21 over HTTPS from dl.fedoraproject.org and + mirror.math.princeton.edu (identical); fingerprint matches + https://fedoraproject.org/security/ diff --git a/keys/pkg-cache/RPM-GPG-KEY-EPEL-10 b/keys/pkg-cache/RPM-GPG-KEY-EPEL-10 new file mode 100644 index 0000000..adf4f88 --- /dev/null +++ b/keys/pkg-cache/RPM-GPG-KEY-EPEL-10 @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGV4X6kBEAC3eQxgiWuo08uc3mHo4ELux++uqTnYz/tJzEf9Ou3h36WnhumA +Nvs+Ts5h8PBx879Y9/aIX1Z20p1kf6tBCinZnEJu59n+TAAsph0+XQlV1l5YkleK +Z2ff/Fg65k8QcLXWaIGykA/FaKznRiSurGuD6tRGhJw7DawEwBJr8QZSkRUpnH1L +URW97Q/iKrRPiE5VEayE0y8eAL28jIIiFvR+4oJMzvCsRRB/2wYZ2MlJOW91hcYf +mbUoXKOBD5UzsJylu7kj25K/ge8rEJ7KicOOwcdYddxsU3DxGSSfwF8AMagENcm2 +XROeXknjm84A8sNlUkFZBJwfuc7eRTiZGJrnQQVYLrkKj8Mxpq9Ts7hU51TqAWNI +uvGDlJdYNE3D2RMqjMEsZ8ej08Thrib6xslu4NzTBkt+6QNnXL4E3hEgYtoyio60 +GswSz2ulogKg7X4JrNdJYE8/qNowyF3hoVgj5TG1/wQRq+5HlMMOLjgGu9wzLUix +fnVfEUnzaofbrUf4/GabCaeY8xRe4tFQrvzigQ4g+kgwKKnfAeqBmPov0yljkw9z +BYJWR5zvaw0ffg9Ing00KUSaXBXA5jSlgk1603Y+LefY1SlXsTyqohiRvGH6FI77 +HNMo72DwoJfFcYjncZUzKgXWJECR4nhVsdj6pKoOjcQ4aSuyVxtsR86ASQARAQAB +tChGZWRvcmEgKGVwZWwxMCkgPGVwZWxAZmVkb3JhcHJvamVjdC5vcmc+iQJOBBMB +CAA4FiEEfY0Vy/xOYmiFkfsmM9mFF+N+0VgFAmV4X6kCGw8FCwkIBwIGFQoJCAsC +BBYCAwECHgECF4AACgkQM9mFF+N+0Vhv/A/+PlhPLSctGRCUEahE+cN4764Acc3p +l40ZYzXRhqR0/Tc1/cSDjlA3qVTc8SPohi5OJXwCyr9EiMqKoyoDN097euqbYpyp +yN/Pj0lBjsXwcpdDtZ21WGeQU0Khb04N68bMtJbDaxeBciTvDDQravZuPPh0m4Rg +Z6myEoa6Aa6EK0hI1Qwi1qIWeRiuEkVT671IaKVETBW5XiUpNBXDAB/L+6DzUF9u +scBzfsUDiPO6NrpYDtV3jwq22y6gWluIct/Ka8brwPbqK2sBfFzrHboRhfqlTGjs +7F9qUGwIQZn/A8iozXZYQ0+JG1bhQyvjA8eN1GOcRpT+O7H7JXN49o6IG2As4+iK +F04+qjqAu2sVfpD8mzM2VubFNllcKKiyCzRYHhSbObRCPzsudDL9GPiXeGGaCuWg +sDkiA1MESvf2tLETAGBs/TziO4GwmXUtlKbRiq1FYm90mVq9mBxPZ/Idn+yZusNB +0O5SXIbI8lYZw5n4XTK4b+byHRBYsOTHiTsGvjTF2Y7oSwW2CVUmL6RZ23mI4qoY +1p5kzRS+GjT1acnTei/FTsOlIKCsjfeHx7uxCkX6xpAD8P3UtLQqfsgH0CL4vSZt +TGO6L1InQlp4ZG3OYIomTKbD3/R0wod3U3dTqdulQMXL895u6OLTY3spY2m2MO2k +p9Dfd2pKuxK9Mys= +=mhQZ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/keys/pkg-cache/ca.crt b/keys/pkg-cache/ca.crt new file mode 100644 index 0000000..4413e94 --- /dev/null +++ b/keys/pkg-cache/ca.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICPzCCAeagAwIBAgIUJI0QJZ+PCrSQ/nKZFENwwcp8Zr0wCgYIKoZIzj0EAwIw +TDEmMCQGA1UEAwwdcGtnLWNhY2hlIGludGVybmFsIENBIChDVDEzMCkxIjAgBgNV +BAoMGWNsb3VkLWhvc3RpbmcuaW8gaW50ZXJuYWwwHhcNMjYxMDIxMTkzNjIyWhcN +MzYxMDE4MTkzNjIyWjBMMSYwJAYDVQQDDB1wa2ctY2FjaGUgaW50ZXJuYWwgQ0Eg +KENUMTMwKTEiMCAGA1UECgwZY2xvdWQtaG9zdGluZy5pbyBpbnRlcm5hbDBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABBJh75sC5vSzTnkYl0CYYEA/TKUHEXhbtanV +OVNW4NlD3za5+bu5YuKjpbFAva4TCZqWsaS9IMd/JfsaFbf5XVejgaUwgaIwEgYD +VR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFP7UCTf/ +v7KU1KBrs1vqa71/TZl0MF0GA1UdHgEB/wRTMFGgTzAKhwjAqAEo/////zAlgiNw +a2ctY2FjaGUuaW50ZXJuYWwuY2xvdWQtaG9zdGluZy5pbzAaghhwa2ctY2FjaGUu +Z2Vla2hvbWUubG9jYWwwCgYIKoZIzj0EAwIDRwAwRAIgOod4wPHu0kAzYAw8KeGg +cIrFMwQ4ATKVTkDioiSFOqICIEhY+sGj/YZ4oBMst3b/AAz9zMEJAXpnCkSTI6tM +YAIl +-----END CERTIFICATE----- diff --git a/scripts/install-node.sh b/scripts/install-node.sh index f0587d6..f8de518 100755 --- a/scripts/install-node.sh +++ b/scripts/install-node.sh @@ -18,7 +18,9 @@ declare -A MIN=( wget -qO- "https://rpm.nodesource.com/setup_${major}.x" | bash - dnf install -y nodejs -npm install -g npm@latest +# npm 12 dropped Node 18/20; on those lines keep the npm Node ships with +# (what the old per-version scripts did, silently, before this one failed hard). +npm install -g npm@latest || echo "WARNING: npm@latest does not support Node $major; keeping bundled npm $(npm --version)" >&2 have="$(node -p 'process.versions.node')" case "$have" in "$major".*) ;; *) echo "ERROR: wanted Node $major, got $have" >&2; exit 1;; esac diff --git a/scripts/pkg-cache-repos.sh b/scripts/pkg-cache-repos.sh new file mode 100755 index 0000000..37580a0 --- /dev/null +++ b/scripts/pkg-cache-repos.sh @@ -0,0 +1,227 @@ +#!/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