`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>
308 lines
14 KiB
Bash
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
|