Merge main: plugin art rides THROUGH the H-2 confinement, not around it
ci / web (pull_request) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m0s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / rust (pull_request) Successful in 9m22s

PR #58 hardened the art proxy in the same three files this branch rewrote, and
the two changes pull in opposite directions: #58 narrowed what the host will read
from disk, while WP1.2 widened what counts as a local art path so an extracted
scanner's covers can be served at all. Resolved so the widening goes through the
gate rather than beside it.

Kept from #58, unchanged: art_path_is_confined (UNC refusal, canonicalize-or-
refuse, config-dir exclusion, roots check), the image-extension whitelist,
sniff_image_type, validate_art_paths as write-time validation, the AuthLane
privileged-field check on every entry in a reconcile payload, and the launch
redaction in GET /library.

Three reconciliations:

  * `local_art_bytes` converts a `file://` value to a path BEFORE calling
    art_path_is_servable, so the confinement check and the read see the same
    path. Ordering is the point: percent-decoding happens before
    canonicalization, so a `%2e%2e` escape cannot hide from the traversal check.
    Pinned by a test.

  * `art_roots()` gains $HOME on POSIX. This is the one that would have bitten
    silently: the list was empty on non-Windows, which was correct while
    is_local_art_path was Windows-shaped (Playnite is Windows-only, so nothing on
    a POSIX host was ever classified as local art and the confinement had nothing
    to confine). Once WP1.2 classifies POSIX paths as local, an empty root list
    is not "secure by default" — it serves NO plugin art on Linux, which is every
    cover the lutris and steam plugins emit. $HOME is the exact analogue of the
    Windows users base #58 already ships, and covers Steam's librarycache and
    grid overrides, Lutris's coverart/banners (both copies), Heroic's caches and
    all the Flatpak variants. It is not the load-bearing control: a value still
    needs an image extension, must canonicalize to a real regular file inside a
    root and outside the config dir, and must CONTAIN image bytes.

  * The two tests that both wanted to mutate PUNKTFUNK_LIBRARY_ART_ROOTS became
    one. Cargo runs tests as parallel threads of a single process, so two tests
    setting the same env var race. The `file://` and confinement assertions moved
    into #58's existing confined test; what remains of the WP1.2 test is the
    pure classification/rewrite half, which touches neither env nor filesystem.

Also: `steam_ui` was missing from the list of host-resolved launch kinds in
privileged_field's doc comment and in the 403 a plugin sees. Prose only — the
check is a denylist (prep, launch.kind = "command"), so steam_ui was never
actually refused — but a plugin author reading that error would have concluded
otherwise.

Gates on .21: punktfunk-host 433 passed / 0 failed (including #58's H-2 tests and
the new file:// ones), full workspace tests clean, workspace clippy -D warnings
clean, cargo fmt --all --check clean, OpenAPI drift test green.
This commit is contained in:
2026-08-05 19:59:11 +02:00
57 changed files with 2697 additions and 279 deletions
+13 -3
View File
@@ -14,9 +14,19 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
# are root-only by default while the host runs as a user service — grant the `input`
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# are root-only by default while the host runs as a user service — grant the dedicated
# `punktfunk` group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
#
# ⚠ This is deliberately NOT the `input` group (2026-08-05 review M-4). Writing `attach` hands the
# kernel a caller-supplied socket fd and materialises an arbitrary, fully userspace-emulated USB
# device — a root-only kernel primitive. Every packaging scriptlet tells the user to
# `usermod -aG input $USER` as step 1, so putting it on `input` handed that primitive to a group
# people are routinely told to join: a member could present a HID keyboard and inject keystrokes
# into a root TTY or the lock screen, or drive any of hundreds of in-tree USB drivers from
# userspace, all without CAP_SYS_ADMIN. The uinput/uhid grants above are already systemwide input
# injection, but neither reaches kernel USB enumeration — this one does, so it gets its own group
# that nothing else asks users to join.
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp punktfunk /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich
# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no
+13 -1
View File
@@ -52,7 +52,19 @@ fi
# it `kwin_wayland --virtual` brings up NO X server at all (no display reserved), and those apps die
# with "Missing X Server or $DISPLAY". KWin starts Xwayland on demand but reserves + logs the X11
# display up front, which the detection below reads.
KWIN_LOG="${TMPDIR:-/tmp}/punktfunk-kwin.log"
# The log lives in the per-user 0700 XDG_RUNTIME_DIR, not at a fixed name in a world-writable
# /tmp. This file is not just a log: the DISPLAY detection below GREPS it for "Using public X11
# display :N" and exports the result, so at a predictable path in a shared directory any local user
# could pre-create it (or symlink it) and steer the DISPLAY of a shipped systemd service
# (2026-08-05 review L-15). `pf-vdisplay` already resolves XDG_RUNTIME_DIR for its own paths; this
# matches. Without a runtime dir, fall back to a private mktemp rather than a guessable name.
if [[ -n "${XDG_RUNTIME_DIR:-}" && -d "${XDG_RUNTIME_DIR}" ]]; then
KWIN_LOG="${XDG_RUNTIME_DIR}/punktfunk-kwin.log"
: >"$KWIN_LOG"
chmod 600 "$KWIN_LOG"
else
KWIN_LOG="$(mktemp -t punktfunk-kwin.XXXXXXXX.log)"
fi
kwin_wayland --virtual --xwayland --width "$W" --height "$H" --no-lockscreen \
--socket "$WAYLAND_DISPLAY" >"$KWIN_LOG" 2>&1 &
KWIN_PID=$!
+8 -1
View File
@@ -11,7 +11,14 @@
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
these distros already authorize for their session switcher (e.g. Nobara's
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
lingering user unit, no polkit agent), where interactive auth can never be answered.
⚠ These defaults authorize every local subject — including a seatless ssh session or a
service account — so they are NOT the whole authorization story (2026-08-05 review L-14).
They cannot be tightened without breaking the lingering-user-unit deployment, which polkit
classifies under allow_any precisely because it has no session. The actual gate is in the
helper: pf-dm-helper refuses any caller whose PKEXEC_UID is not in the `punktfunk` group.
Keep the two in step — loosening the helper's check makes these defaults load-bearing. -->
<action id="io.unom.punktfunk.dm-helper">
<description>Stop or restore the display manager for a Punktfunk stream</description>
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
+35 -7
View File
@@ -13,6 +13,38 @@
# local-seat operation, not arbitrary unit management.
set -eu
# The polkit action has to stay permissive (`allow_any=yes`): the host commonly runs as a LINGERING
# user unit, which has no logind session at all, so polkit classifies it under `allow_any` and any
# stricter default would make the takeover unauthorizable in its primary deployment. The cost of
# that is that polkit alone authorizes *every* local subject — a seatless ssh session, a service
# account — to run this as root (2026-08-05 review L-14).
#
# So the authorization decision is made HERE instead, where the caller is knowable: pkexec sets
# PKEXEC_UID from the authenticated caller, and only a member of the `punktfunk` group (created by
# the packages) may proceed. That keeps the sessionless host working while making membership of one
# explicit group — not merely "has a local uid" — the thing that grants these verbs.
require_authorized_caller() {
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to run unauthenticated" >&2
exit 1
}
user=$(getent passwd "$uid" | cut -d: -f1) || user=
[ -n "$user" ] || {
echo "pf-dm-helper: PKEXEC_UID $uid resolves to no local user — refusing" >&2
exit 1
}
# `id -nG` lists the primary group too, so a user whose primary group IS punktfunk also passes.
for g in $(id -nG "$user" 2>/dev/null); do
[ "$g" = punktfunk ] && return 0
done
echo "pf-dm-helper: user '$user' is not in the 'punktfunk' group — refusing." >&2
echo " Grant it with: sudo usermod -aG punktfunk $user (then re-login)" >&2
exit 1
}
require_authorized_caller
dm_unit() {
target=$(readlink /etc/systemd/system/display-manager.service) || {
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
@@ -40,13 +72,9 @@ case "${1-}" in
# what breaks that dependency (the setup docs already ask for it).
#
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
# so this grant enables lingering for that caller alone.
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
exit 1
}
exec loginctl enable-linger "$uid"
# so this grant enables lingering for that caller alone. (Its presence is already checked by
# `require_authorized_caller` above, which also proved the caller is in the punktfunk group.)
exec loginctl enable-linger "${PKEXEC_UID}"
;;
*)
echo "usage: pf-dm-helper stop|restore|linger" >&2
+12 -1
View File
@@ -185,6 +185,11 @@ ok "plugin runner: ~/.local/bin/punktfunk-scripting"
# --- 3. config -------------------------------------------------------------
log "Configuration ($CONFIG)"
mkdir -p "$CONFIG"
# Owner-only: this directory holds web.env (console password + session secret), the mgmt token and
# the host key. A plain `mkdir -p` leaves it 0755 at the Deck's default umask, so the secrets below
# sat in a world-TRAVERSABLE directory (2026-08-05 review L-19). Matches what the host itself does
# via `pf_paths::create_private_dir`, and is idempotent on an existing dir.
chmod 700 "$CONFIG" 2>/dev/null || true
if [ ! -f "$CONFIG/host.env" ]; then
cat > "$CONFIG/host.env" <<'EOF'
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
@@ -235,10 +240,16 @@ if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
WEB_PW="$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom 2>/dev/null | head -c 12 || true)"
WEB_SECRET="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 32 || true)"
cat > "$CONFIG/web.env" <<EOF
# `umask 077` around the redirect, not `chmod 600` after it: the heredoc CREATES the file at
# the ambient umask (0022 on a Deck ⇒ world-readable), so the console password and session
# secret existed group/world-readable for the window between the redirect and the chmod
# (2026-08-05 review L-19). Setting the mask first means the file is never readable at all.
# The chmod stays as the idempotent belt for a pre-existing file.
(umask 077; cat > "$CONFIG/web.env" <<EOF
PUNKTFUNK_UI_PASSWORD=$WEB_PW
PUNKTFUNK_UI_SECRET=$WEB_SECRET
EOF
)
chmod 600 "$CONFIG/web.env"
ok "wrote web.env (generated login password)"
else
+9 -2
View File
@@ -14,6 +14,13 @@ if [ ! -s "$PWFILE" ]; then
PW=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20)
(umask 077; printf 'PUNKTFUNK_UI_PASSWORD=%s\n' "$PW" > "$PWFILE")
chmod 600 "$PWFILE" 2>/dev/null || true
echo "punktfunk web console login password generated: $PW"
echo "(stored in $PWFILE — open https://<host-ip>:47992 and log in)"
# Do NOT echo the password itself. Anything this script prints is captured by systemd into
# the PERSISTENT journal, which on Debian/Ubuntu is readable by the `adm` and
# `systemd-journal` groups — so printing it published a 0600 secret to every member of them,
# permanently, and the .deb postinst then documented `journalctl` as the way to read it
# (2026-08-05 review L-18). Point at the file instead: it is the same one command, it is
# correctly 0600, and it stays readable only by the user who owns the console.
echo "punktfunk web console login password generated."
echo "Read it with: cut -d= -f2- $PWFILE"
echo "(then open https://<host-ip>:47992 and log in)"
fi