`punktfunk-sysext` checked the SHA256 of every image it downloaded, which sounds like verification but isn't: SHA256SUMS lives on the same registry as the images it describes, so anything able to replace an image could replace its checksum in the same request. The checksum only ever proved the download wasn't corrupted. Each feed now carries SHA256SUMS.asc, a detached OpenPGP signature over the manifest, and the client verifies it before believing a line of it. The key is packages@unom.io (AF245C506F4E4763) — the same one that already signs our RPMs, so boxes have one key to trust and we have one key to rotate. Its public half is baked into the script rather than fetched from the feed, because a key you fetch from the thing you're authenticating authenticates nothing; gpg (present on Bazzite) does the verifying against a throwaway keyring holding only that key, so "good signature" and "signed by us" are the same statement. Rollout: stable feeds only publish on a tag, so a `--seal` mode re-signs an existing manifest without rebuilding an image, and every rpm.yml run seals the OTHER channel of its Fedora major too. Canary pushes are frequent, so all live feeds seal within a day of this landing and a key rotation propagates without republishing anything. Until a feed is sealed the client refuses it and says so, naming PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 as the informed way through. Two things testing changed. The baked-key fingerprint check compared against an empty string — the armor block is a single-quoted shell literal, so the extracted range carried `FEED_KEY='` on its first line and gpg saw no armor at all; every publish would have "mismatched" and, on a tag, failed the release. And `status` captured fetch_manifest's stderr but only printed it on failure, swallowing the ALLOW_UNSIGNED warning precisely when someone was running unverified. Verified end to end on Bazzite 44 against a file:// feed with a throwaway key: unsigned feed refused; ALLOW_UNSIGNED=1 proceeds and says so; wrong signer rejected; tampered manifest rejected; good signature accepted; `update` exits 1 before touching anything on a bad feed. Publisher side: signs when the baked key matches, refuses on mismatch, refuses with no key, and its signature round-trips through the real client. shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry
punktfunk-host is published as an RPM to Gitea's RPM package registry in the public unom
org (stable groups bazzite/fedora-44, canary groups bazzite-canary/fedora-44-canary), so
Bazzite / Fedora Atomic hosts layer and update it with rpm-ostree. CI (.gitea/workflows/rpm.yml)
builds and publishes on every push to main (a rolling <next-minor>-0.ciN.g<sha> build — the base
is derived from the latest stable tag by scripts/ci/pf-version.sh — to the *-canary
groups) and on vX.Y.Z tags (a clean X.Y.Z-1 to the base groups, plus attached to the unified
Gitea Release) — separate repos, so a stable box never jumps to a canary build (see
Release Channels). The baseurl below subscribes to the
bazzite stable group; use bazzite-canary for the latest main builds. The RPM is built in the
Fedora 43 image (ci/fedora-rpm.Dockerfile) so its auto-generated library Requires
(libavcodec.so.NN, …) match Bazzite's sonames; the NVIDIA driver lib (libcuda.so.1) is
excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends).
This is the same package as the COPR / bootc
paths — same spec (punktfunk.spec) — just self-hosted in Gitea instead of COPR, mirroring the
Debian/apt setup.
Install on a Bazzite host (one-time)
# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo
# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both so dnf/rpm-ostree imports each.
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[gitea-unom-bazzite]
name=punktfunk (unom, Bazzite)
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
REPO
# Layer the host + the web console (pairing/status), then reboot into the new deployment.
# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep
# settings. The registry carries punktfunk-web because CI builds the spec --with web; COPR can't.)
rpm-ostree install punktfunk punktfunk-web
systemctl reboot
If
rpm-ostreecan't complete the metadata GPG check non-interactively, setrepo_gpgcheck=0(TLS-only trust to the self-hosted registry).
Per-package signing (gpgcheck=1, active)
CI GPG-signs every RPM: packaging/rpm/sign-rpms.sh (run from rpm.yml between build and publish)
signs with the dedicated EdDSA key packages@unom.io (AF245C506F4E4763) and self-verifies
with rpmkeys --checksig before publishing, so an unsigned/bad build never reaches the registry.
The public key is served from the registry (the gpgkey= URL above) and committed at
packaging/rpm/RPM-GPG-KEY-punktfunk. (This is a GPG/OpenPGP key — a step-ca/X.509 cert can't
sign RPMs; step-ca is only for registry/console TLS.)
RPM_GPG_PRIVATE_KEYis an org-level secret onunom, not a repo secret — it will not show up under this repository's Actions secrets. Verify it end to end instead of by its absence there:curl -O <repo-url>/package/punktfunk-web/<ver>/x86_64/…rpm && rpm -qp --qf '%{RSAHEADER:pgpsig}\n'(orrpmkeys --checksig, which reportsNOKEYuntil you import the public key —NOKEYstill means signed, just by a key that box doesn't have yet).
On a v* tag build, a missing key fails the build: sign-rpms.sh will not publish unsigned
RPMs into a repo whose own instructions say gpgcheck=1, because every user's dnf upgrade would
then break on them. Non-release builds still fall through unsigned so forks and local builds work.
How it was set up (and how to rotate the key):
# 1. Generate a DEDICATED, passphrase-less signing key (separate from the Gitea metadata key).
gpg --batch --gen-key <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Name-Real: punktfunk packages
Name-Email: packages@unom.io
Expire-Date: 0
%commit
EOF
gpg --armor --export-secret-keys packages@unom.io # -> the RPM_GPG_PRIVATE_KEY CI secret
gpg --armor --export packages@unom.io > packaging/rpm/RPM-GPG-KEY-punktfunk # public half
# 2. Add the armored PRIVATE key as the RPM_GPG_PRIVATE_KEY Gitea Actions secret, at the ORG level
# (git.unom.io/org/unom/settings/actions/secrets) so every repo's workflows inherit it. Commit
# the public half and publish it to the registry so the gpgkey= URL resolves:
curl --user "<user>:<write:package-PAT>" --upload-file packaging/rpm/RPM-GPG-KEY-punktfunk \
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
Rotating the key means a new generic-registry version (bump punktfunk-keys/1 → /2 and the
gpgkey= URL), since the registry rejects re-uploading an existing file.
This key also signs the Bazzite sysext feed, and a third copy of its public half is baked into
packaging/bazzite/punktfunk-sysext.sh (FEED_KEY=) — that script is bootstrapped by curl on
machines that have nothing installed yet, so it can't fetch the key from the thing it's
authenticating. A rotation must update all three: the CI secret, this directory's
RPM-GPG-KEY-punktfunk (+ its registry upload), and FEED_KEY. publish-sysext-feed.sh compares
its signing key's fingerprint against FEED_KEY and refuses to sign on a mismatch, so forgetting
the third one fails the publish instead of stranding every Bazzite box in front of a feed it
can't verify.
After reboot, as the desktop user:
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (re-login).
# Bazzite is atomic — use ujust, NOT `usermod -aG input`.
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamescope defaults
systemctl --user enable --now punktfunk-host
# Web console — enable it and read the auto-generated login password (then open https://<host-ip>:47992):
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
(See ../bazzite/README.md for the full appliance walkthrough —
udev/group, host.env, the Steam session unit, firewall, verify.)
Updates
rpm-ostree upgrade # pulls the newest punktfunk with the system update
systemctl reboot # rpm-ostree changes apply on reboot
Layered packages are re-resolved against their repos on every rpm-ostree upgrade, so the box
tracks new builds automatically (Bazzite's auto-update timer does this for you). To pin or stop
tracking: rpm-ostree override / rpm-ostree uninstall punktfunk.
Build an RPM locally
PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh # host + client
PF_VERSION=0.0.1 PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh # + punktfunk-web (needs bun on PATH)
# -> dist/punktfunk-0.0.1-1.fcNN.x86_64.rpm (+ punktfunk-web-0.0.1-1.fcNN.x86_64.rpm with PF_WITH_WEB=1;
# the web subpackage vendors a bun binary, so it's arch-specific, not noarch)
Run it inside the Fedora 43 builder image so the deps resolve and match Bazzite:
docker build -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm ci
docker run --rm -v "$PWD:/src" -w /src punktfunk-fedora-rpm \
bash -lc 'git config --global --add safe.directory /src && PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh'
A plain rpmbuild/COPR build with no pf_version/pf_release defines produces 0.3.0-1 (the
spec defaults).
aarch64 — the client RPM
The client builds for aarch64; the host does not (its encode stack is NVENC/QSV/AMF, all
x86). PF_WITHOUT_HOST=1 drops the host binary, the tray, the headless-session data, the
firewalld services and the main package's %files, leaving exactly one RPM: punktfunk-client.
Omitting the main %files is what keeps rpm from emitting an empty punktfunk next to it.
This is not a cross-compile — %build runs cargo for the host architecture, so run it on an
arm64 machine (or an emulated arm64 container, which is very slow):
docker build --platform linux/arm64 -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm-arm64 ci
docker run --rm --platform linux/arm64 -v "$PWD:/src" -w /src punktfunk-fedora-rpm-arm64 \
bash -lc 'git config --global --add safe.directory /src && \
PF_VERSION=0.0.1 PF_WITHOUT_HOST=1 bash packaging/rpm/build-rpm.sh'
# -> dist/punktfunk-client-0.0.1-1.fcNN.aarch64.rpm
PF_WITHOUT_HOST=1 works on x86_64 too, if you only want the client RPM. The flag is orthogonal
to the architecture; it is just that aarch64 has no other option.