Files
enricobuehler 98e68a49a7
ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m42s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 6m46s
ci / rust (pull_request) Failing after 4m25s
android / android (pull_request) Canceled after 9m18s
nix / flake (pull_request) Canceled after 6m51s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1s
fix(host,web,clients,ci): the comments were the spec, and the code had drifted
Security review 2026-08-25, 58 confirmed findings across host, console,
clients and supply chain. Nearly every serious one is a documented boundary
whose code stopped enforcing what its comment promised — so where the two
disagreed, the comment won and the code was made to match, and where it
could not be, the comment was corrected instead.

Critical — a console session cookie alone reached code execution: every
pairing route rode the generic catch-all with the operator's admin bearer
attached. Arming, approving and PIN submission now sit behind the console
password like the other trust-root routes, and the armed PIN is returned
once in that gated response instead of riding a 1 s status poll.

High — the plugin lane no longer reads the unredacted log ring (which
carried the webhook credentials the /hooks carve-out exists to withhold);
hook lines log an origin and a short id, never a URL or a command line; a
plugin-reported pid is held to procscan's start-time floor before the
SYSTEM host will signal it; ClipOffer is gated on the live grant mask, so a
revoked guest loses the host clipboard in both directions; ENet refuses
connects with no live launch instead of letting LAN peers squat all four
slots; Windows secrets are born with their DACL applied rather than
world-readable; the sysext feed binds FEED and a monotonic SERIAL inside
the signed bytes; privileged_field allowlists the host-resolved launch
kinds so a new kind is privileged by default; five parser panics reachable
from one malformed NALU are range-checked; release-signing jobs pin bun,
sccache and actions by checksum/SHA; h2 -> 0.4.19 (RUSTSEC-2026-0258).

Deep links only auto-dial by stable record id now — a display name or an
address gets a confirmation on every client. The Apple identity key moves
to ThisDeviceOnly so it stops riding encrypted backups.

pf-vdisplay stops routing session identity through the process environment:
the injector backend threads through a typed slot, so per-batch getenv no
longer races a per-session setenv. The four remaining writes have no
in-repo readers and are documented as such; the SAFETY proof that claimed
ENV_LOCK made them sound is gone.

Verified: cargo clippy --workspace --all-targets --locked -D warnings and
cargo fmt --all --check clean in the CI image; web builds, tsc --noEmit
clean, 22/22 server tests; Swift debug+release + 26/26, Kotlin :kit 7/7.

Not fixed, deliberately: the plugin token can still mint command execution
(the plugin launch kind exists so a plugin names a command the host runs —
per-plugin identity does not change that, and the runner is one process
hosting fibers, so there is nowhere to hang a credential); the shared
plugin-UI origin; the rollback Authenticode publisher pin (Azure mints a
fresh leaf per request, and the signer subject is not in the tree). Each is
now described accurately where it lives instead of being claimed closed.
2026-08-26 09:22:36 +02:00

436 lines
23 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.
# A signature alone still only says "we signed these bytes, once" — not which feed they were
# signed for, nor whether they are the current ones. So the manifest also carries a `# FEED` and a
# `# SERIAL` header INSIDE the signed bytes, and fetch_manifest refuses one whose FEED is not the
# feed it fetched, or whose SERIAL is below the highest this box has accepted. Without both, anyone
# who can write the registry WITHOUT the key (a leaked write:package token) copies the canary
# manifest + images into the stable path, or puts last month's back, and every box verifies it
# happily. Same two rules the Rust updater enforces (crates/pf-update-check/src/manifest.rs).
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"
FLOOR_FILE="$EXT_DIR/.punktfunk.serial-floor"
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] | reapply | status | remove"
echo " reapply: re-run the host-state steps a sysext image cannot carry (groups, /etc"
echo " mirrors, udev, sysctl, modules) without reinstalling the image."
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_name -> the feed this box reads: f43, f43-canary, … (Fedora major x channel). The publisher
# stamps this same name into the signed manifest, which is what makes the two comparable.
feed_name() {
local suffix=""
[ "$(channel)" = canary ] && suffix="-canary"
echo "f$(os_version_id)$suffix"
}
feed_url() { echo "$REGISTRY/$(feed_name)"; }
# The highest manifest serial ever accepted for a feed — the anti-rollback floor, persisted the way
# the Rust updater persists its own per-channel `serial_floor` (crates/punktfunk-host/src/update.rs).
# Per FEED, never global: serials are publish timestamps, so a canary publish would otherwise raise
# the floor above the next stable manifest and lock the stable channel out.
serial_floor() {
local v
v="$(sed -n "s/^$(feed_name) //p" "$FLOOR_FILE" 2>/dev/null | head -n1)"
case "$v" in ''|*[!0-9]*) echo 0 ;; *) echo "$v" ;; esac
}
# Raise it (never lower — the caller compares first). Best-effort and quiet on purpose: `status`
# runs unprivileged, and before the extensions dir exists at all, so it must report the feed rather
# than die — or nag — over not being able to record it. `2>/dev/null` comes BEFORE the redirect it
# is there to silence: redirections are set up left to right, so the other order still prints the
# shell's own "No such file or directory" to the terminal.
raise_serial_floor() {
local f; f="$(feed_name)"
{ grep -v "^$f " "$FLOOR_FILE" 2>/dev/null || :; echo "$f $1"; } 2>/dev/null > "$FLOOR_FILE.new" \
&& mv -f "$FLOOR_FILE.new" "$FLOOR_FILE" 2>/dev/null || :
}
# 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 want got serial floor
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
# Signed — by us, at some point, for something. The two facts a signature cannot carry on its own
# are stamped inside the document (see the Trust note at the top) and checked here.
want="$(feed_name)"
got="$(sed -n 's/^# FEED //p' "$sums" | head -n1)"
serial="$(sed -n 's/^# SERIAL //p' "$sums" | head -n1)"
case "$serial" in ''|*[!0-9]*) serial="" ;; esac
if [ -z "$got" ] || [ -z "$serial" ]; then
echo "!! the feed $feed is signed but UNBOUND: its manifest carries no '# FEED'/'# SERIAL'" >&2
echo "!! header, so the signature says nothing about which feed or which publish it covers." >&2
echo "!! (a feed published before binding existed; it is bound on the next publish, or now with" >&2
echo "!! TOKEN=… bash packaging/bazzite/publish-sysext-feed.sh --seal $want)" >&2
return 1
fi
if [ "$got" != "$want" ]; then
echo "!! this manifest was signed for the feed '$got', but it is being served as '$want' —" >&2
echo "!! another channel's (or another OS release's) feed is being replayed here. Refusing." >&2
return 1
fi
floor="$(serial_floor)"
if [ "$serial" -lt "$floor" ]; then
echo "!! manifest serial $serial is older than the last accepted $floor — refusing rollback." >&2
echo "!! An old but validly-signed manifest is being replayed at $feed." >&2
return 1
fi
if [ "$serial" -gt "$floor" ]; then raise_serial_floor "$serial"; 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 || :
# The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update
# helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
# 'punktfunk' owns the vhci attach/detach nodes the rule we just mirrored into /etc chgrp's to.
# A group cannot ride an image either (/etc/group is host state), and the deb/rpm scriptlets that
# would normally create it never run on an image-based install — so without this the chgrp fails,
# attach/detach stay root-only and the virtual Steam Deck pad never attaches. Deliberately NOT
# 'input': writing 'attach' materialises an arbitrary emulated USB device (review 2026-08-05 M-4),
# so it stays a group users join on purpose — see `ujust add-user-to-input-group` for the other one.
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Creating the group is necessary but NOT sufficient, and the difference is invisible until a
# stream fails: `pf-dm-helper` gates on MEMBERSHIP, so a host whose user never joined gets
# "stopping the display manager needs privilege" on every managed takeover — sddm's autologin
# Relogin loop then churns logind sessions for the whole stream. Joining stays opt-in (writing
# vhci `attach` materialises an arbitrary emulated USB device), so say so instead of doing it.
local _pf_user="${SUDO_USER:-}"
if [ -n "$_pf_user" ] && ! id -nG "$_pf_user" 2>/dev/null | tr ' ' '\n' | grep -qx punktfunk; then
echo "!! $_pf_user is not in the 'punktfunk' group — the managed gamescope takeover cannot stop"
echo "!! the display manager, and the virtual Steam Deck pad cannot attach. To opt in:"
echo "!! sudo usermod -aG punktfunk $_pf_user"
fi
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 || :
# ds_inhibit dontaudit drop-in (Bazzite ships steamos-manager; keyed on its binary): Valve's
# ds_inhibit walks /proc/*/fd on every open/close of a hid-playstation hidraw — exactly what the
# virtual DualSense is — and SELinux denies steamos_manager_t that walk at ~324 AVCs/sec;
# setroubleshootd amplifies the flood into a box-wide stall that starves the stream (gamescope
# 0 fps, encode submit ~150 ms/frame). The policy STORE is host state (/var/lib/selinux), so the
# image carries only the CIL source and the module must be inserted here. Keyed on the module
# NAME for idempotence (a policy rebuild costs seconds every merge otherwise — if the rules ever
# change, RENAME the file and every reference so existing installs converge). Rationale and the
# dontaudit-vs-allow choice: the .cil header / packaging/bazzite/README.md.
if command -v semodule >/dev/null 2>&1 && [ -e /usr/lib/steamos-manager ] \
&& [ -f /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil ] \
&& ! semodule -l 2>/dev/null | grep -qx punktfunk-ds-inhibit; then
echo "installing SELinux drop-in 'punktfunk-ds-inhibit' (silences the steamos-manager ds_inhibit audit flood)…"
semodule -i /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil \
|| echo "!! semodule -i failed — the ds_inhibit audit flood stays live; see packaging/bazzite/README.md" >&2
fi
# 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
# NOT "nothing to do": re-run post_merge. Every step in it is idempotent, and skipping it here
# is how host state silently rots one release behind the image.
#
# The trap, field-proven on a Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09): an upgrade
# is driven by the script from the OLD image — this file is replaced by the very
# `systemd-sysext refresh` that runs mid-upgrade — so a post_merge step ADDED in the new
# release is executed by nobody. The old script doesn't have it, and the new script never gets
# a turn, because from then on `update` matches this branch and returns. The step is then
# permanently unreachable on exactly the installs that need it.
#
# That cost the `punktfunk` group (added to post_merge in 0.26.0): it was never created, so
# `pf-dm-helper` refused every caller — it gates on membership — and every managed gamescope
# takeover fell back to "stopping the display manager needs privilege", leaving sddm's autologin
# Relogin loop churning for the whole stream.
echo "already on $cur (channel $(channel)) — re-applying host state."
post_merge
return
fi
# Even a correctly bound, in-date manifest can offer only OLDER images (a mistaken republish, an
# over-eager prune). `latest` reports the newest the feed HAS, not the newest that ever shipped,
# so without this the box walks backwards onto a superseded — possibly known-vulnerable —
# release, silently. Rolling back stays possible; it just has to be asked for.
case "$cur" in
[0-9]*)
if [ "$ver" != "$cur" ] \
&& [ "$(printf '%s\n%s\n' "$ver" "$cur" | sort -V | tail -n1)" = "$cur" ] \
&& [ "${PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE:-0}" != 1 ]; then
echo "!! the feed's newest image ($ver) is OLDER than the installed $cur — refusing to" >&2
echo "!! downgrade. For a deliberate rollback:" >&2
echo "!! sudo PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE=1 punktfunk-sysext update" >&2
exit 1
fi ;;
esac
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
# $FLOOR_FILE deliberately survives: it is anti-rollback state, not installation state, and a
# remove/re-install cycle is the obvious way to hand a box a replayed manifest it already refused.
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 "$@" ;;
reapply) shift; need_root; post_merge ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
esac