767e67ca's per-channel mechanics were correct; they were aimed at the wrong binary. Each one is restored here pointed at punktfunk-encode-worker, and every host-side removal from #136 stays verbatim. All grants remain best-effort — an uncapped worker still encodes, at default priority, so a failed setcap must never fail an install. * Arch: setcap in post_install AND post_upgrade (a replaced binary is a new inode). * RPM: %caps(cap_sys_nice=ep) in %files, never a %post setcap — %caps applies, restores and verifies, and covers Fedora as well as Bazzite via rpm-ostree layering. * Bazzite + Arch sysext: setcap on the staging tree before mksquashfs, which does record security.capability. The assertion is amended, not removed: host EMPTY is still a hard fail, and the worker must carry exactly cap_sys_nice=ep — missing is fine, anything else is not. * deb: setcap in postinst. * NixOS: security.wrappers for the WORKER plus PUNKTFUNK_ENCODE_WORKER in the unit. A file capability cannot live on a store path, and an ambient grant is right here precisely because nothing ever identifies the worker. The host's ExecStart stays on the store path. * Steam Deck: setcap the worker; the .desktop the script writes stays valid this time. Four things the plan's channel table missed: * packaging/arch/build-sysext.sh had no capability handling at all, and a sysext can never run a pacman scriptlet — the SteamOS image would have shipped the lever permanently inert. * scripts/steamdeck/update.sh had none either. It rebuilds both binaries, so a new inode drops the grant, and it is the documented steady-state path: the lever would have died on the first update. It also never healed a Deck already capped by 0.26.0-1. * A capped worker is AT_SECURE, and glibc drops $ORIGIN-expanded RPATH entries for secure binaries unless they normalise into a trusted system dir. Copying the host's rpath under BUNDLE_FFMPEG=1 would have left the capped worker unable to find libavcodec on exactly the channel that bundles it. Absolute DT_RPATH instead. * Nix crane scopes by -p, so the worker would not have been built at all, and it needs its own addDriverRunpath. scripts/ci/assert-cap-matrix.sh mechanizes the lesson from 0.26.0-1 — verify the PACKAGE, never the board. It unpacks the built Arch package, the deb, the rpm and the mounted sysext raw and asserts one matrix: the host carries NOTHING (hard fail), the worker exactly cap_sys_nice=ep. The sysext reader first proves it can round-trip a capability through mksquashfs/unsquashfs at all, so an unreadable artifact fails rather than issuing a blind PASS, and --self-test red-teams the assertions themselves. Red-teaming the leg found a real bug: setcap originally ran BEFORE the assertion, so "the worker arrived carrying something unexpected" was unreachable and a stray %caps would have been silently overwritten. Both sysext scripts now assert, then grant, then assert again.
167 lines
8.8 KiB
Bash
Executable File
167 lines
8.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Wrap a built punktfunk pacman package into a systemd-sysext image — the update-survivable way to
|
|
# add it to an immutable Arch-derived distro (SteamOS 3): the .raw overlays /usr read-only from the
|
|
# writable /var/lib/extensions/, so it persists across A/B OS updates with no `steamos-readonly
|
|
# disable`. Works for either split package — on a Steam Deck you'd wrap the CLIENT. Needs
|
|
# `bsdtar`/`tar`, `squashfs-tools` (mksquashfs).
|
|
#
|
|
# Usage: bash build-sysext.sh [--gamescope <punktfunk-gamescope-*.pkg.tar.zst>] \
|
|
# <punktfunk-{host,client}-*.pkg.tar.zst>
|
|
# Output: <pkgname>.raw (e.g. punktfunk-client.raw)
|
|
#
|
|
# --gamescope folds the HDR-capable gamescope companion package (packaging/gamescope) into a HOST
|
|
# image as /usr/bin/punktfunk-gamescope — what lets the gamescope backend stream 10-bit BT.2020 PQ
|
|
# instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors
|
|
# the Bazzite image's fold-in, including the honesty check: the binary is verified by executing
|
|
# its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was —
|
|
# the host then stays SDR on that backend, by design.
|
|
#
|
|
# Capabilities in the image: NEVER on usr/bin/punktfunk-host, `cap_sys_nice=ep` on
|
|
# usr/bin/punktfunk-encode-worker (best-effort), and none on punktfunk-gamescope.
|
|
#
|
|
# ⚠ Capabilities are NOT lost on the way in — that was this comment's earlier claim and it is
|
|
# false: mksquashfs records security.capability, and the published Bazzite 0.26.0-1 image really
|
|
# did carry `cap_sys_nice=ep` on usr/bin/punktfunk-host. The host is left uncapped on purpose. A
|
|
# capability on the HOST binary makes it unidentifiable to KWin (which resolves a client's
|
|
# /proc/<pid>/exe to match it against a .desktop, and cannot read it for a capability-carrying
|
|
# process) and kills every Desktop-mode session.
|
|
#
|
|
# ⚠ And it is NOT enough to leave it out here: pacman scriptlets never run for a sysext, so the
|
|
# `setcap` in punktfunk-host.install cannot reach this image either way. The encode worker is
|
|
# therefore capped on the staging tree below — this is the only place a sysext can acquire it — and
|
|
# both halves of the matrix are asserted before mksquashfs, exactly as
|
|
# packaging/bazzite/build-sysext.sh does. `punktfunk-gamescope` is a compositor, not a KWin client,
|
|
# so it is unaffected by the host rule and simply runs without a capability here, pacing slightly
|
|
# worse.
|
|
set -euo pipefail
|
|
|
|
GAMESCOPE=""
|
|
if [ "${1:-}" = "--gamescope" ]; then
|
|
GAMESCOPE="${2:?--gamescope needs a punktfunk-gamescope package}"; shift 2
|
|
fi
|
|
# No braces in the message: a literal `}` inside ${1:?...} terminates the expansion early and
|
|
# corrupts $PKG (the tail of the message gets appended to the value — a real field bug).
|
|
PKG="${1:?usage: build-sysext.sh [--gamescope <pkg>] <punktfunk-host|client pkg.tar.zst>}"
|
|
[ -f "$PKG" ] || { echo "no such package: $PKG" >&2; exit 1; }
|
|
# Derive the package name from the file (pkgname is everything before the -<version>).
|
|
NAME="$(basename "$PKG" | sed -E 's/-[0-9].*//')"
|
|
[ -n "$NAME" ] || { echo "could not derive package name from $PKG" >&2; exit 1; }
|
|
if [ -n "$GAMESCOPE" ] && [ "$NAME" != "punktfunk-host" ]; then
|
|
echo "--gamescope only makes sense for a punktfunk-host image (got: $NAME)" >&2; exit 1
|
|
fi
|
|
|
|
STAGE="$(mktemp -d)"
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
|
|
# A pacman package is a (zstd) tarball; a sysext only carries /usr (the host /etc, /var are the
|
|
# system's). Extract just usr/ from the payload.
|
|
if command -v bsdtar >/dev/null 2>&1; then
|
|
bsdtar -C "$STAGE" -xf "$PKG" usr
|
|
else
|
|
tar -C "$STAGE" -xf "$PKG" usr
|
|
fi
|
|
|
|
# The HDR gamescope companion (see --gamescope in the header). Verified by its banner marker
|
|
# rather than trusted by filename: an unpatched gamescope shipped under this name would make the
|
|
# host promise HDR it cannot deliver, and the punktfunk/1 Welcome cannot take that back
|
|
# mid-session. Executing the staged binary needs a build box the binary runs on (the Arch CI
|
|
# container qualifies; it built it).
|
|
if [ -n "$GAMESCOPE" ]; then
|
|
[ -f "$GAMESCOPE" ] || { echo "no such package: $GAMESCOPE" >&2; exit 1; }
|
|
if command -v bsdtar >/dev/null 2>&1; then
|
|
bsdtar -C "$STAGE" -xf "$GAMESCOPE" usr
|
|
else
|
|
tar -C "$STAGE" -xf "$GAMESCOPE" usr
|
|
fi
|
|
GS_BIN="$STAGE/usr/bin/punktfunk-gamescope"
|
|
[ -x "$GS_BIN" ] || { echo "$GAMESCOPE did not provide usr/bin/punktfunk-gamescope" >&2; exit 1; }
|
|
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
|
|
echo "$GAMESCOPE's binary has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
|
|
echo "folded in $("$GS_BIN" --version 2>&1 | head -1)"
|
|
fi
|
|
|
|
# The marker systemd-sysext requires to merge the image. ID=_any merges onto ANY host os-release
|
|
# (SteamOS, Arch, Bazzite); ARCHITECTURE pins it to x86-64 so it's never merged on the wrong arch.
|
|
install -d "$STAGE/usr/lib/extension-release.d"
|
|
cat > "$STAGE/usr/lib/extension-release.d/extension-release.$NAME" <<EOF
|
|
ID=_any
|
|
ARCHITECTURE=x86-64
|
|
EOF
|
|
|
|
# CAP_SYS_NICE on the encode worker (see the header). A pacman payload carries no capabilities and
|
|
# no scriptlet ever runs for a sysext, so without this the SteamOS image ships the lever inert —
|
|
# on the box with the smallest GPU shared between game and encode. Needs CAP_SETFCAP, i.e. root or
|
|
# fakeroot; a plain-user build simply ships without it, which is a pacing loss and nothing more.
|
|
#
|
|
# `getcap` on an uncapped file exits 0 and prints nothing, so an empty read is unambiguous; the
|
|
# output form differs across libcap versions ("path cap_sys_nice=ep" since ~2.36, "path =
|
|
# cap_sys_nice+ep" before), hence the normalizer.
|
|
_pf_caps_of() {
|
|
local raw; raw="$(getcap "$1" 2>/dev/null || true)"
|
|
[ -n "$raw" ] || { printf ''; return 0; }
|
|
printf '%s' "${raw#* }" | sed -e 's/^= *//' -e 's/+/=/' -e 's/[[:space:]]*$//'
|
|
}
|
|
|
|
# BEFORE granting: refuse a capability that arrived from somewhere else. The setcap below would
|
|
# overwrite it and ship a correct-looking image while the surprise went unreported everywhere else.
|
|
# Order matters: assert first, then grant, or the "anything else" arm can never fire.
|
|
if command -v getcap >/dev/null 2>&1 && [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
|
|
arrived_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")"
|
|
case "$arrived_caps" in
|
|
''|cap_sys_nice=ep) : ;;
|
|
*)
|
|
echo "ERROR: staged usr/bin/punktfunk-encode-worker ARRIVED carrying '$arrived_caps'." >&2
|
|
echo " A pacman payload carries no capabilities, so something else granted it — find" >&2
|
|
echo " out what, because it is doing the same on the plain package path, unchecked." >&2
|
|
exit 1 ;;
|
|
esac
|
|
fi
|
|
|
|
if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
|
|
if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-encode-worker" 2>/dev/null; then
|
|
echo "granted CAP_SYS_NICE to usr/bin/punktfunk-encode-worker (GPU-priority lever active)"
|
|
else
|
|
echo "WARNING: could not setcap CAP_SYS_NICE on usr/bin/punktfunk-encode-worker (need" >&2
|
|
echo " root/CAP_SETFCAP) — the image ships without it and PyroWave encodes at" >&2
|
|
echo " default GPU priority." >&2
|
|
fi
|
|
fi
|
|
|
|
# Assert the final matrix before it is sealed into a read-only squashfs: host EMPTY (hard fail),
|
|
# worker exactly cap_sys_nice=ep or nothing at all (missing is fine — the grant is best-effort).
|
|
if command -v getcap >/dev/null 2>&1; then
|
|
if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then
|
|
staged_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-host")"
|
|
if [ -n "$staged_caps" ]; then
|
|
echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2
|
|
echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2
|
|
echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2
|
|
echo " The GPU-priority capability belongs on usr/bin/punktfunk-encode-worker, never here." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
|
|
worker_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")"
|
|
case "$worker_caps" in
|
|
'') echo "note: usr/bin/punktfunk-encode-worker ships uncapped — PyroWave encodes at default GPU priority" ;;
|
|
cap_sys_nice=ep) : ;;
|
|
*)
|
|
echo "ERROR: staged usr/bin/punktfunk-encode-worker carries '$worker_caps'," >&2
|
|
echo " expected exactly 'cap_sys_nice=ep' (or nothing at all)." >&2
|
|
echo " Refusing to bake an unexpected capability into a read-only image." >&2
|
|
exit 1 ;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
OUT="$NAME.raw"
|
|
rm -f "$OUT"
|
|
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet
|
|
echo "built $OUT"
|
|
echo " install: sudo cp $OUT /var/lib/extensions/ && sudo systemctl enable --now systemd-sysext"
|
|
if [ "$NAME" = "punktfunk-host" ]; then
|
|
echo " then: systemctl --user enable --now punktfunk-host"
|
|
else
|
|
echo " then: run 'punktfunk-client' (or let the Decky plugin launch it)"
|
|
fi
|