Files
punktfunk/packaging/bazzite/punktfunk-sysext.sh
T
enricobuehlerandClaude Opus 5 93902ff60e feat(packaging/bazzite): the sysext feed is signed, and the client refuses one that isn't
`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>
2026-07-29 12:12:21 +02:00

308 lines
14 KiB
Bash

#!/usr/bin/env bash
# punktfunk-sysext — install/update the punktfunk host on Bazzite / Fedora Atomic as a
# systemd-sysext, the no-layering path (rpm-ostree layering is a last resort per the Bazzite
# docs: it slows every update and can block upgrades; a sysext never enters an rpm-ostree
# transaction, needs no reboot, and is trivially removable).
#
# The image overlays /usr from /var/lib/extensions/punktfunk.raw with the host, tray and web
# console + their udev/sysctl/systemd-user payload; the RPMs' two /etc files (gamescope
# session drop-in, tray autostart) ride inside at /usr/share/punktfunk/etc/ and are copied
# into the real /etc here (a sysext can only carry /usr).
#
# Bootstrap (the script also ships inside the image as /usr/bin/punktfunk-sysext):
# curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
# sudo bash punktfunk-sysext.sh install # or: install --channel canary
# Thereafter:
# sudo punktfunk-sysext update | status | remove
#
# Feed: the Gitea generic package registry, one feed per Fedora major x channel
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + SHA256SUMS.asc + versioned
# .raw files — published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path
# uses. The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release.
#
# Trust: SHA256SUMS carries a detached OpenPGP signature (SHA256SUMS.asc) from packages@unom.io —
# the same key that signs our RPMs — and this script verifies it before believing a word of the
# manifest. The checksums alone could never have done that: they live on the same registry as the
# images they describe, so anything able to replace an image could replace its checksum too. The
# public key is baked in below rather than fetched, because a key fetched from the thing you are
# authenticating authenticates nothing.
set -euo pipefail
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
CONF=/etc/punktfunk-sysext.conf
EXT_DIR=/var/lib/extensions
IMG="$EXT_DIR/punktfunk.raw"
SIDECAR="$EXT_DIR/.punktfunk.version"
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
ETC_SRC=/usr/share/punktfunk/etc
PF_TMP="$(mktemp -d)"; trap 'rm -rf "$PF_TMP"' EXIT
# The feed's signing key: punktfunk packages <packages@unom.io>, AF245C506F4E4763. Identical to
# packaging/rpm/RPM-GPG-KEY-punktfunk — ONE key signs both the RPMs and this feed, so rotating it
# means updating both copies (the rotation runbook in packaging/rpm/README.md says so, and
# publish-sysext-feed.sh refuses to sign if the two ever disagree).
FEED_KEY='-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEai/2eRYJKwYBBAHaRw8BAQdAFxLGvh8wvzES9ylmxT4gy1i58EituotPyZwt
z+y9rbC0JXB1bmt0ZnVuayBwYWNrYWdlcyA8cGFja2FnZXNAdW5vbS5pbz6IkAQT
FgoAOBYhBDG6uOY81eoQ6beahK8kXFBvTkdjBQJqL/Z5AhsjBQsJCAcCBhUKCQgL
AgQWAgMBAh4BAheAAAoJEK8kXFBvTkdj1QsBAM0sI/qUzGEbuC2Zrk36QQBrUu/9
sy5uhYGZD6lMJ4uZAQC7W81H2gHlTDTA2Nq35HKW9IOU+Ll2c9fqa7fAIKf9Bg==
=e4Az
-----END PGP PUBLIC KEY BLOCK-----'
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
exit "${1:-0}"
}
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
channel() { # shellcheck disable=SC1090
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
feed_url() {
local suffix=""
[ "$(channel)" = canary ] && suffix="-canary"
echo "$REGISTRY/f$(os_version_id)$suffix"
}
# verify_manifest SUMS SIG -> 0 iff SIG is a good detached signature over SUMS by FEED_KEY.
# A throwaway keyring holding exactly our one key, so "good signature" and "signed by us" are the
# same statement — any other signer comes back NO_PUBKEY, and gpg exits non-zero.
# Spelled out with plain `if`s rather than `cond && action`: under `set -e` a failing test at the
# end of an && list is a trap that only bites on the path nobody exercises (here: a corrupt
# FEED_KEY), and this function must never abort the script — its whole job is to return a verdict.
verify_manifest() {
local home rc=1
home="$(mktemp -d)"; chmod 700 "$home"
if printf '%s\n' "$FEED_KEY" | GNUPGHOME="$home" gpg --batch --quiet --import 2>/dev/null; then
if GNUPGHOME="$home" gpg --batch --quiet --verify "$2" "$1" 2>/dev/null; then rc=0; fi
fi
rm -rf "$home"
return "$rc"
}
# fetch_manifest -> download the feed's SHA256SUMS into $PF_TMP and verify its signature.
# Returns non-zero (having said why) rather than exiting, so `status` can report a bad feed
# instead of dying on it; install/update turn that into a hard stop.
fetch_manifest() {
local feed sums sig
feed="$(feed_url)"
sums="$PF_TMP/SHA256SUMS"; sig="$PF_TMP/SHA256SUMS.asc"
curl -fsSL -o "$sums" "$feed/SHA256SUMS" || { echo "cannot reach the feed $feed" >&2; return 1; }
if [ "${PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED:-0}" = 1 ]; then
echo "!! PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 — the feed manifest is NOT being verified." >&2
return 0
fi
# curl's own "404"/"could not open file" is noise here — a missing signature is an expected
# state with a much better explanation below, so swallow it and say the useful thing instead.
if ! curl -fsSL -o "$sig" "$feed/SHA256SUMS.asc" 2>/dev/null; then
echo "!! the feed $feed has no SHA256SUMS.asc — refusing to install from an unsigned feed." >&2
echo "!! (a feed published before signing existed; it is sealed on the next publish. To install" >&2
echo "!! from it anyway, knowing the images are unauthenticated: PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1)" >&2
return 1
fi
if ! command -v gpg >/dev/null 2>&1; then
echo "!! gpg not found — cannot verify the feed signature. Install gnupg2." >&2
return 1
fi
if ! verify_manifest "$sums" "$sig"; then
echo "!! the feed's SHA256SUMS is NOT signed by packages@unom.io (AF245C506F4E4763)." >&2
echo "!! Someone has tampered with the feed, or the signing key was rotated and this script is" >&2
echo "!! older than the rotation. Do not install; re-download punktfunk-sysext.sh and retry." >&2
return 1
fi
return 0
}
# latest -> "VERSION FILENAME SHA256" for the newest image in the VERIFIED manifest (version sort).
# Call fetch_manifest first — reading $PF_TMP/SHA256SUMS directly is what keeps the signature
# check off the subshell path, where an `exit` would have vanished into a command substitution.
latest() {
awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
"$PF_TMP/SHA256SUMS" | sort -V | tail -n1
}
installed_version() {
if [ -f "$MARKER" ]; then
sed -n 's/^SYSEXT_VERSION_ID=//p' "$MARKER"
elif [ -f "$SIDECAR" ]; then
cat "$SIDECAR"
fi
}
merged() { [ -f "$MARKER" ]; }
post_merge() {
if ! merged; then
echo "!! image installed but NOT merged — 'systemd-sysext status' / 'journalctl -u systemd-sysext'" >&2
echo "!! (an OS release the image doesn't match? 'punktfunk-sysext update' fetches the right one)" >&2
return 1
fi
# What the RPM scriptlets would have done: pick up the uinput/uhid rule + the UDP buffer
# sysctl now, no reboot (both also auto-apply at boot once merged — the files live in /usr/lib).
udevadm control --reload 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
done
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a real USB device Steam
# Input adopts. Without it the pad falls back to plain UHID hid-steam, which Steam Input won't
# promote (Interface: -1) — so on a host in Game Mode the controller never appears and you can't
# navigate. Two things must be true at boot: the module loaded, and the vhci `attach`/`detach`
# sysfs files opened to the `input` group (the host runs unprivileged and can't modprobe/chown).
#
# A sysext CANNOT rely on its own /usr/lib/modules-load.d + /usr/lib/udev files for this: the
# image merges (systemd-sysext.service) AFTER systemd-modules-load and early udev have already
# run, so at a plain reboot vhci-hcd is never loaded and its rule never applied. Mirror BOTH into
# real /etc (read at the normal early-boot time, and shadowing the /usr copies by filename) so the
# module loads early and udev's coldplug trigger grants the group access. Refreshed every merge so
# a rule/module change in a new image propagates (neither is user-editable config). Then load +
# (re)apply now, no reboot, for this session.
install -Dm0644 /usr/lib/modules-load.d/punktfunk.conf /etc/modules-load.d/punktfunk.conf 2>/dev/null || :
install -Dm0644 /usr/lib/udev/rules.d/60-punktfunk.rules /etc/udev/rules.d/60-punktfunk.rules 2>/dev/null || :
udevadm control --reload 2>/dev/null || :
modprobe vhci-hcd 2>/dev/null || :
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.
udevadm trigger --subsystem-match=platform --sysname-match='vhci_hcd.*' 2>/dev/null || :
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
&& [ ! -e /etc/gamescope-session-plus/sessions.d/steam ]; then
install -Dm0644 "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
/etc/gamescope-session-plus/sessions.d/steam
fi
if [ -f "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" ]; then
install -Dm0644 "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \
/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
fi
}
# do_install VERSION FILENAME SHA256 | do_install --from-file X.raw
do_install() {
need_root
mkdir -p "$EXT_DIR"
local tmp="$EXT_DIR/.punktfunk.raw.new" ver
if [ "$1" = --from-file ]; then
ver="(local: $(basename "$2"))"
cp -f "$2" "$tmp"
else
ver="$1"
echo "downloading punktfunk $ver ($(channel), fedora $(os_version_id))…"
curl -fL --progress-bar -o "$tmp" "$(feed_url)/$2"
echo "$3 $tmp" | sha256sum -c --quiet
fi
mv -f "$tmp" "$IMG" # marker inside is extension-release.punktfunk — name must match
echo "$ver" > "$SIDECAR"
systemctl enable --now systemd-sysext.service >/dev/null 2>&1 || :
systemd-sysext refresh
post_merge
echo "punktfunk $ver merged into /usr."
}
layering_hint() {
if command -v rpm-ostree >/dev/null 2>&1 \
&& rpm-ostree status 2>/dev/null | grep -q 'LayeredPackages:.*punktfunk'; then
cat >&2 <<'EOF'
!! punktfunk is ALSO layered via rpm-ostree. The sysext now shadows it, but remove the
!! layer so it stops slowing/blocking OS updates (the reason this sysext exists):
!! sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot
EOF
fi
}
cmd_install() {
need_root
local from_file=""
while [ $# -gt 0 ]; do
case "$1" in
--channel) printf 'CHANNEL=%s\n' "${2:?}" > "$CONF"; shift 2 ;;
--from-file) from_file="${2:?}"; shift 2 ;;
*) usage 1 ;;
esac
done
if [ -n "$from_file" ]; then
do_install --from-file "$from_file"
else
fetch_manifest || exit 1
local l; l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
# shellcheck disable=SC2086
do_install $l
fi
layering_hint
cat <<'EOF'
First-run (once):
ujust add-user-to-input-group # virtual gamepads; then log out + back in
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
Updates: sudo punktfunk-sysext update
EOF
}
cmd_update() {
need_root
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
local cur l ver
cur="$(installed_version)"
fetch_manifest || exit 1
l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
if [ "$ver" = "$cur" ] && merged; then
echo "already on $cur (channel $(channel)) — nothing to do."
return
fi
echo "updating: ${cur:-<none>} -> $ver"
# shellcheck disable=SC2086
do_install $l
echo "restart the host to pick up the new binary: systemctl --user restart punktfunk-host"
}
cmd_status() {
echo "channel: $(channel)"
echo "feed: $(feed_url)"
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
echo "merged: $(merged && echo yes || echo no)"
echo "installed: $(installed_version || true)"
# Say WHY the feed is unreadable rather than printing a blank: unreachable and
# "signature does not verify" want very different reactions from whoever ran this.
if fetch_manifest 2>"$PF_TMP/status.err"; then
echo "latest: $(latest | cut -d' ' -f1)"
else
echo "latest: (unavailable)"
fi
# Unconditionally — fetch_manifest also warns on SUCCESS (ALLOW_UNSIGNED), and a status command
# that hides "this feed is not being verified" is worse than one that prints nothing at all.
[ -s "$PF_TMP/status.err" ] && sed 's/^/ /' "$PF_TMP/status.err" >&2
return 0
}
cmd_remove() {
need_root
# /etc cleanup needs the /usr payload for the unmodified-compare — do it BEFORE unmerging.
if merged; then
if cmp -s "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
/etc/gamescope-session-plus/sessions.d/steam 2>/dev/null; then
rm -f /etc/gamescope-session-plus/sessions.d/steam
fi
fi
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
rm -f "$IMG" "$SIDECAR" "$CONF"
systemd-sysext refresh 2>/dev/null || :
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
}
case "${1:-}" in
install) shift; cmd_install "$@" ;;
update) shift; cmd_update "$@" ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
esac