Files
punktfunk/packaging/bazzite/publish-sysext-feed.sh
T
enricobuehlerandClaude Opus 5 1a18ae1fae
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 25s
ci / web (push) Successful in 2m46s
ci / docs-site (push) Successful in 2m45s
ci / rust-arm64 (push) Successful in 11m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
ci / rust (push) Canceled after 18m14s
fix(packaging/bazzite): the feed publisher signed a redirect page, not the manifest
Every Bazzite install on the stable channel has been refusing the feed:

  !! the feed's SHA256SUMS is NOT signed by packages@unom.io (AF245C506F4E4763).

The client was right and the feed was wrong. The registry answers a file GET
with a 303 See Other pointing at presigned object storage, and `curl -f` does
not treat a 3xx as an error — so the publisher's two un-`-L`'d manifest reads
"succeeded" holding the redirect's HTML body, `<a href="…">See Other</a>.`, and
handed that to callers as the manifest.

That broke both of them:

  * --seal signed the HTML page. Its presigned URL is regenerated per request
    and expires 300s later, so the published .asc covers bytes that exist
    nowhere and can never verify. Stable feeds only publish on a tag, so they
    are only ever sealed — f43 and f44 were re-broken at 19:54Z on 2026-07-29
    by the seal step of a canary run, and every canary push re-broke them.
    f43-canary survived by accident: on the publish path the same polluted
    bytes get both signed and uploaded, so it is at least self-consistent.

  * the publish merge read took the page for the previous manifest, and
    `grep -v " $FNAME$"` kept it — so each publish prepended a stale redirect
    page and dropped every prior image line. f43 runs KEEP=0 (keep all) and
    holds exactly one line, with 0.20.0 and 0.19.2 still in the registry and
    no longer listed. This has been quietly eating feed history since long
    before signing existed; nobody noticed because the client's latest() only
    matches ^punktfunk-.*-x86-64\.raw$, so an HTML line is invisible to it.

Both reads now go through one read_manifest that follows redirects AND keeps
only well-formed "<sha256>  <filename>" lines, so a manifest is never again
whatever the transport happened to return. --seal re-publishes a manifest that
normalizing changed, since the signature has to cover the bytes a client
downloads — that is what repairs the live feeds — and refuses outright when a
manifest lists no images, rather than sealing an empty feed that would read as
"up to date" to `punktfunk-sysext update`. The publish path now logs its
carry-over count; silence there is what let the history loss run for months.

Verified end to end against a fake registry that 303s to a fresh URL per
request, with a throwaway key and the real scripts: HEAD reproduces the
injected page, the lost image line and the client's VERIFY-FAIL; the fix keeps
both images and verifies; and the new --seal over the broken state normalizes
the manifest and turns it back to VERIFY-OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:09:23 +02:00

186 lines
10 KiB
Bash

#!/usr/bin/env bash
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry —
# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest and its
# detached OpenPGP signature SHA256SUMS.asc; punktfunk-sysext(8) on the boxes verifies that
# signature, then uses the manifest to find + check the newest image (the layout is also exactly
# what systemd-sysupdate's url-file source expects, so a .transfer feed can be added later
# without re-publishing anything).
#
# Signing uses RPM_GPG_PRIVATE_KEY — the SAME packages@unom.io key that signs the RPMs, so boxes
# have one key to trust and we have one key to rotate. Without checksums-plus-signature the feed
# was self-certifying: SHA256SUMS sits on the same registry as the images it describes, so whoever
# could swap an image could swap its checksum in the same breath.
#
# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh <feed> <image.raw>
# TOKEN=… bash publish-sysext-feed.sh --seal <feed>
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
# --seal re-sign a feed's EXISTING manifest without publishing an image. For feeds published
# before signing existed, and after a key rotation. Idempotent. Also re-publishes the
# manifest if normalizing it changed anything, because the signature has to cover the
# bytes a client downloads.
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name),
# RPM_GPG_PRIVATE_KEY (armored private key; absent => unsigned, fatal on a v* tag)
set -euo pipefail
SEAL=0
if [ "${1:-}" = --seal ]; then
SEAL=1; FEED="${2:?usage: publish-sysext-feed.sh --seal <feed>}"; RAW=""
else
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
fi
REGISTRY="${REGISTRY:-git.unom.io}"
OWNER="${OWNER:-unom}"
KEEP="${KEEP:-0}"
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
SUMS="$WORK/SHA256SUMS"
SIG="$WORK/SHA256SUMS.asc"
FETCHED="$WORK/SHA256SUMS.fetched" # exactly what the registry served, before normalization
# read_manifest -> the feed's current manifest, normalized into $SUMS and kept verbatim in
# $FETCHED. Returns non-zero iff the feed has no manifest at all.
#
# -L is not optional here. The registry answers a file GET with a 303 See Other pointing at
# presigned object storage, and `curl -f` does NOT treat a 3xx as an error — so without -L the call
# "succeeds" and hands back the redirect's HTML body ('<a href="…">See Other</a>.'). Both callers
# then took that page for the manifest: every publish prepended a stale redirect page and dropped
# every prior image line, and --seal signed a page whose presigned URL expired 300 seconds later —
# a signature over bytes that exist nowhere. Clients fetch WITH -L, so they checked the real
# manifest against that signature and refused the feed, which from a Bazzite box is indistinguishable
# from someone having tampered with it.
#
# The line filter is the second layer, and the one that does not depend on getting curl's flags
# right: whatever the transport hands back, only well-formed "<sha256> <filename>" lines are ever
# signed or re-published. It also scrubs a feed that already carries an injected page.
read_manifest() {
: > "$SUMS"; : > "$FETCHED"
curl -fsSL "${AUTH[@]}" -o "$FETCHED" "$BASE/SHA256SUMS" || return 1
grep -E '^[0-9a-f]{64} [^ ]+$' "$FETCHED" > "$SUMS" || :
}
# sign_manifest — detached-sign $SUMS into $SIG with RPM_GPG_PRIVATE_KEY. Prints nothing and
# returns 1 if no key is available; the caller decides whether that is survivable.
sign_manifest() {
[ -n "${RPM_GPG_PRIVATE_KEY:-}" ] || return 1
local home keyid baked
home="$(mktemp -d)"; chmod 700 "$home"
# Loopback pinentry for the same reason sign-rpms.sh needs it: no TTY in CI.
printf 'pinentry-mode loopback\n' > "$home/gpg.conf"
printf 'allow-loopback-pinentry\n' > "$home/gpg-agent.conf"
printf '%s' "$RPM_GPG_PRIVATE_KEY" | GNUPGHOME="$home" gpg --batch --quiet --import
keyid="$(GNUPGHOME="$home" gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')"
# A feed signed by a key the client script doesn't carry is a feed nobody can install from, and
# the failure would only surface on someone's Bazzite box. Compare fingerprints here instead: the
# baked-in public key in punktfunk-sysext.sh must be the key we are about to sign with.
# The armor block is a shell single-quoted literal in that script, so the range's first and last
# lines carry the `FEED_KEY='` prefix and the closing quote — strip them or gpg sees no armor at
# all and hands back an empty fingerprint, which would look like a mismatch on every publish.
baked="$(sed -n '/BEGIN PGP PUBLIC KEY BLOCK/,/END PGP PUBLIC KEY BLOCK/p' "$HERE/punktfunk-sysext.sh" \
| sed "s/^FEED_KEY='//; s/'\$//" \
| GNUPGHOME="$home" gpg --batch --quiet --with-colons --import-options show-only --import 2>/dev/null \
| awk -F: '/^fpr:/{print $10; exit}')"
if [ -z "$baked" ] || [ "$keyid" != "$baked" ]; then
echo "signing key $keyid does not match the key baked into punktfunk-sysext.sh ($baked)" >&2
echo "-> rotate BOTH (packaging/rpm/README.md), or clients cannot verify this feed." >&2
rm -rf "$home"; return 1
fi
GNUPGHOME="$home" gpg --batch --yes --armor --detach-sign --local-user "$keyid" \
--output "$SIG" "$SUMS"
rm -rf "$home"
echo "signed SHA256SUMS with $keyid"
}
# require_signature — on a v* tag an unsigned feed is a hard stop, exactly like sign-rpms.sh:
# clients refuse unsigned feeds, so publishing one would strand every box on the stable channel.
require_signature() {
case "${GITHUB_REF:-}" in
refs/tags/v*)
echo "release build (${GITHUB_REF}) but the sysext feed could not be signed — aborting." >&2
exit 1 ;;
esac
# Deliberately mode-neutral wording: in --seal mode nothing is published at all, and a log line
# claiming otherwise is the kind of thing that costs an hour six months from now.
echo "WARNING: $FEED left UNSIGNED (no usable RPM_GPG_PRIVATE_KEY); clients will refuse it." >&2
}
# --seal: re-sign whatever manifest the feed already has, no image, no pruning.
if [ "$SEAL" = 1 ]; then
read_manifest || { echo "no SHA256SUMS at $BASE — nothing to seal" >&2; exit 1; }
# An empty result means the manifest was ALL junk. Signing that would hand clients a feed that
# verifies and offers no images, which reads as "up to date" to `punktfunk-sysext update`.
if [ ! -s "$SUMS" ]; then
echo "$BASE/SHA256SUMS lists no images — refusing to seal it (feed needs a republish)" >&2
exit 1
fi
if ! sign_manifest; then
require_signature # non-release: warn and leave the live feed exactly as it was
exit 0
fi
# The signature must cover the bytes a client actually downloads, so a manifest that normalizing
# changed gets re-published with it — otherwise the .asc would describe a file the registry does
# not have, which is the very failure this is repairing. Manifest first, signature second, and
# neither when the stored copy was already clean.
if ! cmp -s "$SUMS" "$FETCHED"; then
echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $(grep -c '' <"$SUMS") kept"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
fi
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
echo "sealed $BASE ($(grep -c '' <"$SUMS") image(s))"
exit 0
fi
FNAME="$(basename "$RAW")"
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
# Merge into the existing manifest: drop any prior line for this filename, append ours.
if read_manifest; then
sed -i "\| $FNAME\$|d" "$SUMS"
# Said out loud on purpose. A manifest that silently shrinks is how a feed loses its rollback
# history, and that went unnoticed precisely because nothing ever reported the carry-over.
echo "carrying forward $(grep -c '' <"$SUMS") image(s) from the existing manifest"
else
echo "no manifest at $BASE yet — starting a new feed"
fi
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
PRUNE=()
if [ "$KEEP" -gt 0 ]; then
mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP")
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS"
done
fi
# Sign the finished manifest BEFORE anything is uploaded — a signing failure on a release must
# abort while the live feed is still whole, not halfway through being replaced.
sign_manifest || require_signature
# Upload order keeps consumers consistent: image first, then the manifest referencing it, then its
# signature, then prune deletions (already absent from the manifest). A client that catches the
# window between the manifest and its signature sees an unsigned feed and refuses — it does not see
# a manifest vouched for by a stale signature, which is why the .asc goes last and never first.
# Delete-before-put makes workflow re-runs idempotent (the registry 409s on duplicate filenames;
# first-publish 404s are fine).
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
if [ -f "$SIG" ]; then
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
fi
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
done
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"