diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index bdc72b2a..7355c98a 100644 --- a/crates/punktfunk-host/src/native/gamepad.rs +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -140,6 +140,78 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref { chosen } +/// Detection half of [`warn_if_ds_inhibit_storm`], split out for tests: `true` when a process +/// named `steamos-manager` is running (its full name fits `comm`'s 15-char limit exactly) AND +/// SELinux is enforcing (the `enforce` file reads `1`). Both halves are required for the storm: +/// a permissive box logs one denial per walk and moves on, and without steamos-manager there is +/// no ds_inhibit to trigger. +#[cfg(target_os = "linux")] +fn ds_inhibit_storm_risk(proc_root: &std::path::Path, enforce: &std::path::Path) -> bool { + let enforcing = std::fs::read_to_string(enforce).is_ok_and(|v| v.trim() == "1"); + if !enforcing { + return false; + } + std::fs::read_dir(proc_root) + .ok() + .into_iter() + .flatten() + .flatten() + .any(|e| { + std::fs::read_to_string(e.path().join("comm")) + .is_ok_and(|c| c.trim() == "steamos-manager") + }) +} + +/// One-shot diagnostic for the Bazzite/SteamOS ds_inhibit audit storm: Valve's `steamos-manager` +/// reacts to every open/close of a `hid-playstation` hidraw — exactly what our virtual +/// DualSense / DualShock 4 is; it has no VID/PID or virtual/uhid filtering — by walking +/// `/proc/*/fd/` to see whether Steam holds the node. Under SELinux enforcing that walk is +/// denied (`steamos_manager_t` lacks `sys_ptrace`/`dac_*`; measured ~324 AVCs/sec on Bazzite), +/// and `setroubleshootd` amplifies the flood into a box-wide fork storm that starves the stream +/// (gamescope 0 fps, encode submit ~150 ms/frame). The audit lines read `comm="tokio-rt-worker"` +/// and look like us — they are steamos-manager's (check `scontext=`). +/// +/// Warn-only, never degrade: a per-pad fold has no wire channel back to the client +/// (`Welcome::gamepad` only describes the session default), and it would strip the DS5 feature +/// set on exactly the platform where users want it. The real fix is the shipped SELinux drop-in; +/// this warning cannot see the policy store (root-only), so it fires even where that drop-in is +/// already installed — it names that, and puts the cause in OUR logs so the audit-log trap above +/// doesn't get someone blaming the encoder again. +#[cfg(target_os = "linux")] +fn warn_if_ds_inhibit_storm(chosen: GamepadPref) { + use std::sync::atomic::{AtomicBool, Ordering}; + static ONCE: AtomicBool = AtomicBool::new(true); + // Selection criterion is the bound driver (`sony`/`playstation`) plus the touchpad's mouse + // node — i.e. the hid-playstation backends. (The kernel registers the touchpad from its own + // hardcoded DS5/DS4 handling, so no descriptor shaping can duck the selection.) + let playstation = matches!( + chosen, + GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4 + ); + if !playstation + || !ds_inhibit_storm_risk( + std::path::Path::new("/proc"), + std::path::Path::new("/sys/fs/selinux/enforce"), + ) + || !ONCE.swap(false, Ordering::Relaxed) + { + return; + } + tracing::warn!( + gamepad = chosen.as_str(), + "steamos-manager is running and SELinux is enforcing — its ds_inhibit scans /proc on \ + every open/close of this pad's hidraw, the scan is denied at hundreds of AVCs/sec, and \ + setroubleshootd can amplify that into a box-wide stall that starves the stream. Install \ + the shipped SELinux drop-in (`sudo punktfunk-sysext reapply`, or `sudo semodule -i \ + /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil`) — harmless if already installed. \ + Masking setroubleshootd (`sudo systemctl mask --now setroubleshootd`) hardens the box \ + against any audit flood. Details: packaging/bazzite/README.md." + ); +} + +#[cfg(not(target_os = "linux"))] +fn warn_if_ds_inhibit_storm(_chosen: GamepadPref) {} + /// The Valve product id (`28DE:xxxx`) a virtual Steam backend enumerates as, or `None` for a /// non-Steam backend. This is the identity the conflict gate compares against the *physical* Valve /// devices attached to the host: only a genuine duplicate (same VID **and** PID) confuses Steam @@ -323,6 +395,10 @@ pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { // The XUSB escape hatch can only present a 360 identity, so the One S / Elite wishes fold when // `PUNKTFUNK_XBOX_BACKEND=xusb` is set. let chosen = degrade_xbox_identity(chosen); + // Bazzite/SteamOS heads-up, warn-only (see the fn for why never a degrade): a + // hid-playstation pad on a box running steamos-manager under SELinux enforcing risks the + // ds_inhibit audit storm. + warn_if_ds_inhibit_storm(chosen); match pref { GamepadPref::Auto => { // The operator's env knob deserves a diagnostic when it didn't drive the @@ -519,4 +595,38 @@ mod tests { assert_eq!(steam_backend_product(Xbox360), None); assert_eq!(steam_backend_product(SwitchPro), None); } + + // The ds_inhibit-storm detection needs BOTH halves: steamos-manager running AND SELinux + // enforcing. A permissive box logs one denial per walk without storming, and without + // steamos-manager there is no ds_inhibit — either alone must stay silent. + #[cfg(target_os = "linux")] + #[test] + fn ds_inhibit_storm_risk_needs_both_halves() { + use super::ds_inhibit_storm_risk; + let dir = tempfile::tempdir().unwrap(); + let proc_root = dir.path().join("proc"); + let enforce = dir.path().join("enforce"); + std::fs::create_dir_all(proc_root.join("123")).unwrap(); + + // Enforcing + steamos-manager present → risk. + std::fs::write(proc_root.join("123/comm"), "steamos-manager\n").unwrap(); + std::fs::write(&enforce, "1\n").unwrap(); + assert!(ds_inhibit_storm_risk(&proc_root, &enforce)); + + // Permissive (or SELinux absent — the enforce file unreadable) → no risk. + std::fs::write(&enforce, "0\n").unwrap(); + assert!(!ds_inhibit_storm_risk(&proc_root, &enforce)); + assert!(!ds_inhibit_storm_risk( + &proc_root, + &dir.path().join("missing") + )); + + // Enforcing but no steamos-manager (a comm that merely CONTAINS the name must not + // match — the scan compares the whole trimmed comm) → no risk. + std::fs::write(&enforce, "1\n").unwrap(); + std::fs::write(proc_root.join("123/comm"), "not-steamos\n").unwrap(); + assert!(!ds_inhibit_storm_risk(&proc_root, &enforce)); + std::fs::write(proc_root.join("123/comm"), "steamos-managerX\n").unwrap(); + assert!(!ds_inhibit_storm_risk(&proc_root, &enforce)); + } } diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index 93924310..7570a411 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -504,6 +504,29 @@ desktop viewer. `ExecStart=/usr/bin/punktfunk-host serve --gamestream` (or bare `serve` for native-only) if needed (section 5). +- **Stream lags, then freezes, with a DualSense-type client pad (SELinux enforcing).** The virtual + DualSense / DualShock 4 binds the kernel's `hid-playstation` driver, and Valve's `ds_inhibit` + (inside `steamos-manager`, shipped on Bazzite) reacts to *any* such hidraw by walking + `/proc/*/fd/` on every open/close. SELinux denies `steamos_manager_t` that walk, spraying + **~324 `avc: denied` per second**, and `setroubleshootd` amplifies the flood into a box-wide + fork storm that starves the stream (gamescope 0 fps, `tx_mbps` collapsing) — measured live on + Bazzite 43, 2026-08-15. Two traps while diagnosing: the AVC lines read `comm="tokio-rt-worker"` + — that is **steamos-manager, not punktfunk** (check `scontext=…steamos_manager_t…`); and once + started the setroubleshootd storm **outlives the denials by 15+ minutes**, so the box stays + starved after the pad is gone. Fixes: + - punktfunk ships a `dontaudit` SELinux drop-in that silences the flood (ds_inhibit then simply + leaves the pad uninhibited — harmless). The sysext installs it automatically on + install/update; on an existing install run `sudo punktfunk-sysext reapply`. On a layered or + bootc host: `sudo semodule -i /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil` + (remove with `sudo semodule -r punktfunk-ds-inhibit`). + - **Hardening (recommended on any streaming host):** `sudo systemctl mask --now + setroubleshootd`. It is purely a desktop alert daemon — nothing depends on it + (`systemctl list-dependencies --reverse setroubleshootd` returns only itself) — and masking + it makes the box robust against *any* AVC burst, not just this one. Reversible with `unmask`. + - Workaround with the feature loss: set the **client's** Controller type to Xbox 360 (uinput, + no `hid-playstation`) — costs adaptive triggers, lightbar and touchpad. The host-side + `PUNKTFUNK_GAMEPAD` knob does **not** help: an explicit client choice outranks it. + - **Moonlight can't see the host.** Ensure UDP 5353 (mDNS) and the GameStream ports are open (section 6) and client + host are on the same L2 LAN segment. diff --git a/packaging/bazzite/punktfunk-ds-inhibit.cil b/packaging/bazzite/punktfunk-ds-inhibit.cil new file mode 100644 index 00000000..a20fabd8 --- /dev/null +++ b/packaging/bazzite/punktfunk-ds-inhibit.cil @@ -0,0 +1,28 @@ +; SELinux drop-in for Bazzite / SteamOS-derived hosts: silence the audit flood Valve's +; ds_inhibit (inside steamos-manager) produces around ANY `hid-playstation` hidraw — +; which is exactly what punktfunk's virtual DualSense / DualShock 4 is (ds_inhibit +; selects by bound driver + touchpad mouse node; it has no VID/PID or virtual filtering). +; +; ds_inhibit reacts to every open/close of such a hidraw by walking /proc/*/fd/ to see +; whether Steam holds the node. The distro policy denies steamos_manager_t the three +; capabilities that walk needs, so the scan never succeeds, the state machine never +; latches, and every open/close sprays denials — ~324 AVCs/sec measured on Bazzite 43 +; (2026-08-15). setroubleshootd then amplifies the flood into a box-wide fork storm +; (267+ procs/sec) that starves the stream: gamescope 0 fps, encode submit ~150 ms/frame. +; +; dontaudit, NOT allow: granting another vendor's daemon sys_ptrace/dac_override is not +; ours to do — silencing the audit noise is. The scan keeps failing quietly and +; ds_inhibit simply leaves the pad uninhibited, which is what we want anyway (the +; "inhibit" would disable the touchpad-as-mouse of a pad we created for a game). The +; underlying gap — steamos_manager_t lacking what its own ds_inhibit needs — is a +; Bazzite/Valve policy bug; this drop-in is the containment we can ship. A bare, +; un-amplified AVC is cheap, but with dontaudit not even that remains. +; +; Installed idempotently by `punktfunk-sysext` post_merge (and best-effort by the RPM +; %post) wherever steamos-manager exists; the policy store is host state, so a sysext +; image cannot carry the module itself — only this source. By hand: +; sudo semodule -i punktfunk-ds-inhibit.cil # remove: sudo semodule -r punktfunk-ds-inhibit +; ⚠ The installers key idempotence on the module NAME (= this filename): if these rules +; ever change, rename the file (and every reference) so existing installs converge. +(dontaudit steamos_manager_t self (capability (dac_override dac_read_search sys_ptrace))) +(dontaudit steamos_manager_t self (cap_userns (sys_ptrace))) diff --git a/packaging/bazzite/punktfunk-sysext.sh b/packaging/bazzite/punktfunk-sysext.sh index 7111767a..40c53438 100644 --- a/packaging/bazzite/punktfunk-sysext.sh +++ b/packaging/bazzite/punktfunk-sysext.sh @@ -191,6 +191,22 @@ post_merge() { # 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" ] \ diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index a75b5afe..254c3115 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -450,6 +450,13 @@ install -Dm0644 packaging/kde/host.env %{buildroot}%{_datadir}/% # screencast/virtual-output grant ships as io.unom.Punktfunk.Host.desktop, installed above). install -d %{buildroot}%{_datadir}/%{name}/bazzite install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%{name}/bazzite/kde-desktop-setup.sh +# SELinux dontaudit drop-in for Bazzite/SteamOS: Valve's ds_inhibit (steamos-manager) walks +# /proc/*/fd on every open/close of a hid-playstation hidraw — our virtual DualSense — and the +# denied walk sprays ~324 AVCs/sec, which setroubleshootd amplifies into a box-wide stall that +# starves the stream. Shipped as CIL source (the policy STORE is host state); inserted by %%post +# below / punktfunk-sysext post_merge where steamos-manager exists. See the file's header. +install -Dm0644 packaging/bazzite/punktfunk-ds-inhibit.cil \ + %{buildroot}%{_datadir}/%{name}/selinux/punktfunk-ds-inhibit.cil # Layered-update helper for rpm-ostree hosts: `rpm-ostree upgrade` only re-resolves layered # packages when the BASE changes, so a frozen Bazzite base pins punktfunk forever. The script # forces a re-resolve of just this layer (--uninstall + --install of the same names in one @@ -674,6 +681,17 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || : # Apply the UDP socket-buffer tuning (also auto-applied at boot by systemd-sysctl; on rpm-ostree # it takes effect on the next boot into the layered deployment). sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || : +# Bazzite/SteamOS only (keyed on the steamos-manager binary): insert the ds_inhibit dontaudit +# drop-in — Valve's ds_inhibit walks /proc on every open/close of our virtual DualSense's hidraw, +# the denied walk sprays AVCs, and setroubleshootd amplifies that into a box-wide stall (see +# packaging/bazzite/punktfunk-ds-inhibit.cil). Keyed on the module NAME for idempotence (a policy +# rebuild costs seconds — rename the file if the rules ever change). Best-effort and never fatal: +# rpm-ostree's scriptlet sandbox may refuse semodule; the sysext post_merge and the README's +# manual command cover that path. +if command -v semodule >/dev/null 2>&1 && [ -e /usr/lib/steamos-manager ] && + ! semodule -l 2>/dev/null | grep -qx punktfunk-ds-inhibit; then + semodule -i %{_datadir}/%{name}/selinux/punktfunk-ds-inhibit.cil >/dev/null 2>&1 || : +fi echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)" # Naming only the usbip pad here is how a Nobara host shipped broken: its owner had no Deck pad, so # they correctly skipped this group — and then every managed gamescope takeover degraded silently,