ci / bun-nix (pull_request) Successful in 29s
ci / docs-site (pull_request) Successful in 1m37s
ci / web (pull_request) Successful in 2m39s
ci / rust-arm64 (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m50s
nix / flake (pull_request) Failing after 23m28s
60-punktfunk.rules chgrp's the usbip vhci attach/detach nodes to a dedicated
`punktfunk` group (security-review 2026-08-05 M-4: writing `attach` materialises
an arbitrary emulated USB device, so it must not ride on `input`). Four of the
six install paths shipped that rule in 0.25.0 without ever creating the group.
chgrp then failed, the nodes stayed root:root 0644, and the virtual Steam Deck
pad silently never attached — while `usermod -aG punktfunk` failed outright with
"group 'punktfunk' does not exist".
Affected and fixed:
* arch — post_upgrade() called only _ensure_update_group, so every box that
reached 0.25.0 by `pacman -Syu` missed it; post_install was correct.
* nix — no users.groups.punktfunk at all, though host.users' own description
already promised the usbip/vhci pad. Declares it now and adds
host.users to both groups.
* bazzite sysext — a group is host state and cannot ride an image, and the
deb/rpm scriptlets that would create it never run there.
* steamdeck install.sh/update.sh — handled `input` only. Both now create the
group and join it: running that script IS the statement "make my
Deck a host with native pad passthrough".
deb and rpm were correct throughout (one postinst/%post for install + upgrade).
Also on the Deck path: web.env secret hygiene. install.sh's `chmod 600` sat
inside the create-only branch despite a comment calling it "the idempotent belt
for a pre-existing file", and update.sh never touched the config dir at all — so
an install set up once and only updated since kept web.env world-readable
(0644) with the console password and session secret in it. Both scripts now
harden ~/.config/punktfunk to 0700 and web.env to 0600 on every run, and say so
loudly, because a chmod does not un-leak an already-readable secret: the
password still needs rotating.
Both group blocks are `if ensure_group ...` rather than `ensure_group || true`:
a failed groupadd must not fall through to a usermod against a nonexistent
group, which under `set -e` aborted install.sh after the long build and
update.sh before the service restart (verified: exit 6, no restart).
Docs: the group is now documented where people actually look — the per-distro
guides, install.md, steamos-host.md, a new troubleshooting entry for "pad
arrives as an Xbox 360 controller", and the uninstall pages. The 0.25.0 notes
gain the "group does not exist" caveat and turn the password bullet from
"consider rotating" into a real instruction, and CHANGELOG records the known
issue against the breaking change that introduced it.
Verified: bash -n on all four scripts; the arch scriptlet's post_upgrade driven
in a container (creates the group, idempotent on re-run); the ensure_group
helper and both membership branches, including a control that reproduces the
original bug (chgrp to a missing group leaves the node root:root 0644); the
find -perm /0077 probe across 0644/0640/0604/0600/0400 on GNU findutils;
`nix flake check --no-build` (the exact CI gate) and a NixOS eval showing
alice.extraGroups == ["input","punktfunk"]; docs-site build + typecheck.
318 lines
15 KiB
Bash
318 lines
15 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 || :
|
|
# 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 || :
|
|
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
|