Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two exceptions are recorded below and in the review doc. The review's headline is that `plugin_may_access` was the one authorization gate in the system that was allow-by-default — a hand-maintained denylist of route prefixes, where every sibling gate is deny-by-default. Its own doc comment names the two capabilities it exists to withhold, and both were reachable one route over, because ~1450 commits of new routes were added and the list was never one of the things anyone remembered to update. So the gate is now an allowlist, and a test walks the live route table and fails the build for any route that has not been deliberately classified for both non-admin lanes. That test is the actual fix: it is what stops the next route from arriving pre-authorized. Route reachability and field authority turned out to be different questions. A provider plugin has to be able to reconcile its own library entries — that is what a scanner plugin IS — but `prep` and a `command` launch inside that payload are handed to `/bin/sh -c` as the host user, and every execution site documents them as operator-typed. Requests now carry the lane that authorized them, and those two fields are refused to everyone but the operator's own token. The art proxy read any absolute path off disk in the host process, which on Windows is LocalSystem, from a path the plugin lane could write and then read back — so it yielded `mgmt-token`, which is full admin. It now serves only real images (extension AND magic bytes, so a renamed secret fails), only from inside an allowed root, only after canonicalization, and never over UNC; and a path it would refuse to serve can no longer be persisted in the first place. On Windows, the config-dir hardening was skipped exactly when it was needed — it ran only in the branch that CREATES host.env, so the case it was written for (a local user pre-created the directory and planted one) was the one case it never ran in. It is now unconditional and first, an existing host.env is re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files theirs after the directory was re-owned is gone. The identity and token readers were hardening the directory only on the path that GENERATED a new secret, so a planted cert/key or token was adopted verbatim and permanently; they harden before the first read now. `ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as FIXED and it was in no commit in this repository's history — the local EoP it described was live, and it is the payload half of the config-dir chain above. Also: the three input planes are bounded and lossy like the mic plane on the same loop already was; Android's library client no longer accepts any publicly-trusted certificate for the pinned host; the usbip vhci nodes get their own group instead of riding on `input`, which every packaging scriptlet tells users to join; a registry URL can no longer inject a TOML table into bunfig.toml; the pairing cooldown is charged before the arming state is read, so armed/disarmed is no longer a free oracle; and the whole Low tier, of which the two worth naming are a clipboard MIME NUL that panicked the host on one control message, and an unauthenticated global logout that let any LAN peer sign the operator out on a loop. NOT fixed, deliberately: H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does not work: the document's origin goes opaque, its subresource requests are then cross-site, the SameSite=Lax session cookie is not sent, and every plugin asset 302s to /login. The "open in new tab" link is the same escalation with no iframe at all, so the sandbox attribute is not where this gets fixed either. It needs a second listener — a distinct origin that is still the same site — which changes the console's deploy model and wants on-glass validation. The mechanism and the dead end are written down at the iframe. H-6 registry authentication, whose other half lives in unom/infra. The in-repo halves are done: workflow_dispatch inputs no longer interpolate into run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the syft installer is pinned to its tag instead of main. Digest pinning is left until the registry is authenticated, because a tag — content-keyed or not — can simply be overwritten while anonymous pushes are accepted. M-5 is half done: the oracle is closed, but binding the arming window needs the console to learn the fingerprint first, which is a knock-then-bind flow rather than an edit. Verified: cargo fmt --all --check clean; cargo check --all-targets green on Linux and on Windows (confirmed non-vacuous — a planted type error in windows/install.rs fails the build); scripts/xcheck.sh windows check green; cargo test -p punktfunk-host --bins 416 passed, the single failure being gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental UDP-loopback flake that fails identically on clean main in the same container; cargo test -p pf-clipboard 13 passed; web console typechecks.
431 lines
23 KiB
Bash
Executable File
431 lines
23 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# punktfunk — Steam Deck HOST installer (stream FROM the Deck to other devices).
|
|
#
|
|
# SteamOS is an immutable, read-only Arch base, so the host can't be a system package and a
|
|
# prebuilt binary would break on an OS library bump. Instead we build the host natively inside a
|
|
# Debian-trixie distrobox (ABI-matched to SteamOS's FFmpeg/glibc) — the binary then runs natively
|
|
# on SteamOS — and wire it up as proper systemd USER services. A rebuild always matches the
|
|
# running OS. AMD encode uses VAAPI; NVIDIA uses NVENC (auto-detected).
|
|
#
|
|
# Run it on the Deck (Desktop Mode "Konsole", or over ssh). Idempotent — safe to re-run to update
|
|
# config or pick up new options. To rebuild after pulling new source, use update.sh.
|
|
#
|
|
# bash scripts/steamdeck/install.sh # PIN pairing required; Moonlight compat ON
|
|
# bash scripts/steamdeck/install.sh --no-gamestream # SECURE native-only (no Moonlight/#5/#9 surface)
|
|
# bash scripts/steamdeck/install.sh --open # trusted LAN: accept unpaired clients (TOFU)
|
|
# bash scripts/steamdeck/install.sh --no-web # skip the management web console
|
|
# PUNKTFUNK_SRC=~/src/punktfunk bash scripts/steamdeck/install.sh # source elsewhere
|
|
#
|
|
set -euo pipefail
|
|
|
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
|
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
have() { command -v "$1" >/dev/null 2>&1; }
|
|
|
|
# --- options ---------------------------------------------------------------
|
|
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
|
BOX="${PUNKTFUNK_BOX:-pf2}"
|
|
BOX_IMAGE="${PUNKTFUNK_BOX_IMAGE:-docker.io/library/debian:trixie}"
|
|
MGMT_PORT="${PUNKTFUNK_MGMT_PORT:-47990}"
|
|
WEB_PORT="${PUNKTFUNK_WEB_PORT:-47992}"
|
|
OPEN=0
|
|
WITH_WEB=1
|
|
GAMESTREAM=1 # Moonlight/GameStream compat on by default; --no-gamestream for a secure native-only host
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--open) OPEN=1 ;;
|
|
--no-web) WITH_WEB=0 ;;
|
|
--no-gamestream) GAMESTREAM=0 ;;
|
|
--src=*) SRC="${arg#--src=}" ;;
|
|
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
|
*) die "unknown option: $arg (try --help)" ;;
|
|
esac
|
|
done
|
|
TARGET_DIR="$SRC/target-steamos"
|
|
BIN="$TARGET_DIR/release/punktfunk-host"
|
|
CONFIG="$HOME/.config/punktfunk"
|
|
UNITS="$HOME/.config/systemd/user"
|
|
XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
# Set when this run does something that only a fresh login picks up (input-group add, first-time
|
|
# KWin .desktop grant). Drives the loud "reboot before streaming" note in the summary.
|
|
NEED_RELOGIN=0
|
|
|
|
# --- 0. preflight ----------------------------------------------------------
|
|
log "Preflight"
|
|
[ -f /etc/os-release ] && . /etc/os-release || true
|
|
case "${ID:-}${ID_LIKE:-}" in
|
|
*steamos*|*arch*) ok "SteamOS / Arch base detected (${PRETTY_NAME:-unknown})" ;;
|
|
*) warn "This installer targets SteamOS; '${PRETTY_NAME:-unknown}' may differ — on a normal distro use the apt/rpm packages instead." ;;
|
|
esac
|
|
[ -d "$SRC/crates/punktfunk-host" ] || die "no punktfunk source at $SRC. Clone or rsync it there first, or pass --src=DIR (see scripts/steamdeck/README.md)."
|
|
ok "source: $SRC"
|
|
if ! have distrobox; then
|
|
die "distrobox not found. Install it once (no root needed):
|
|
curl -sfL https://raw.githubusercontent.com/89luca89/distrobox/main/install | sh -s -- --prefix ~/.local
|
|
then re-run this script (ensure ~/.local/bin is on PATH)."
|
|
fi
|
|
DISTROBOX="$(command -v distrobox)" # baked into the web unit (may be /usr/bin or ~/.local/bin)
|
|
ok "distrobox: $DISTROBOX"
|
|
|
|
# --- acquire sudo up front (before the ~15-min build) ----------------------
|
|
# Steps 4-5 (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger) need root. Prompt NOW,
|
|
# not after the build — so you authorize once and walk away, and a non-interactive run fails LOUDLY
|
|
# here instead of silently skipping the tuning at the very end. A stock SteamOS 'deck' has no
|
|
# password, so sudo can't work until you set one.
|
|
SUDO_OK=0
|
|
if sudo -n true 2>/dev/null; then
|
|
SUDO_OK=1
|
|
elif [ -t 0 ]; then
|
|
warn "sudo is needed once (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger):"
|
|
if sudo -v; then
|
|
SUDO_OK=1
|
|
# keep the sudo timestamp warm across the long build so steps 4-5 don't re-prompt / expire
|
|
( while sudo -n -v 2>/dev/null; do sleep 50; done ) &
|
|
_pf_sudo_keepalive=$!
|
|
trap '[ -n "${_pf_sudo_keepalive:-}" ] && kill "$_pf_sudo_keepalive" 2>/dev/null || true' EXIT
|
|
fi
|
|
fi
|
|
if [ "$SUDO_OK" != 1 ]; then
|
|
if [ -t 0 ]; then
|
|
warn "No sudo — a stock SteamOS 'deck' account has no password. Set one and re-run: passwd"
|
|
else
|
|
warn "No TTY for the sudo prompt (non-interactive run) — system tuning + linger will be SKIPPED."
|
|
warn "Run in Konsole or an interactive 'ssh -t' session (or pre-authorize sudo) to enable them."
|
|
fi
|
|
fi
|
|
|
|
# --- 1. build container + toolchain ---------------------------------------
|
|
log "Build container '$BOX' ($BOX_IMAGE)"
|
|
if distrobox list 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qx "$BOX"; then
|
|
ok "container '$BOX' exists"
|
|
else
|
|
log "creating '$BOX' (first time — pulls the image)…"
|
|
distrobox create --yes --name "$BOX" --image "$BOX_IMAGE" --home "$HOME"
|
|
ok "created '$BOX'"
|
|
fi
|
|
|
|
log "Provisioning build dependencies in '$BOX' (idempotent; apt + rustup + bun)"
|
|
# One non-interactive provisioning pass. APT deps mirror the Linux host build (FFmpeg/PipeWire/
|
|
# DRM/EGL/VAAPI dev libs); rustup + bun are per-user under the shared $HOME.
|
|
distrobox enter "$BOX" -- bash -lc '
|
|
set -e
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y -qq --no-install-recommends \
|
|
build-essential pkg-config clang cmake curl git ca-certificates \
|
|
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libswscale-dev libavdevice-dev \
|
|
libpipewire-0.3-dev libspa-0.2-dev \
|
|
libgbm-dev libegl-dev libgl-dev libdrm-dev libva-dev \
|
|
libxkbcommon-dev libudev-dev libssl-dev libopus-dev libsdl2-dev \
|
|
nodejs >/dev/null
|
|
command -v rustc >/dev/null 2>&1 || command -v ~/.cargo/bin/rustc >/dev/null 2>&1 || \
|
|
curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path >/dev/null
|
|
# bun builds AND runs the web console now (the Nitro `bun` preset + our Bun.serve TLS entry —
|
|
# bun-native output, so the old srvx mis-resolution that forced node no longer applies).
|
|
command -v bun >/dev/null 2>&1 || command -v ~/.bun/bin/bun >/dev/null 2>&1 || \
|
|
curl -fsSL https://bun.sh/install | bash >/dev/null
|
|
'
|
|
ok "build deps ready"
|
|
|
|
# --- 2. build host (+ web) -------------------------------------------------
|
|
log "Building punktfunk-host (release) — first build is slow (~10-15 min)"
|
|
# vulkan-encode matches the packaged builds (deb/arch): the raw Vulkan Video HEVC/AV1 backend
|
|
# (real RFI loss recovery). Pure-Rust ash — no extra system dep. A featureless hand build would
|
|
# silently fall back to libav VAAPI.
|
|
distrobox enter "$BOX" -- bash -lc "
|
|
set -e
|
|
export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'
|
|
cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode
|
|
"
|
|
[ -x "$BIN" ] || die "build did not produce $BIN"
|
|
ok "host binary: $BIN"
|
|
|
|
if [ "$WITH_WEB" = 1 ]; then
|
|
log "Building the management web console (bun)"
|
|
distrobox enter "$BOX" -- bash -lc "
|
|
set -e
|
|
export PATH=\$HOME/.bun/bin:\$PATH
|
|
cd '$SRC/web' && bun install --frozen-lockfile && bun run build
|
|
"
|
|
[ -f "$SRC/web/.output/server/index.mjs" ] || die "web build did not produce web/.output/server/index.mjs"
|
|
ok "web console built"
|
|
fi
|
|
|
|
# --- 2b. plugin runner (scripting) -----------------------------------------
|
|
# The console's plugin store and `punktfunk-host plugins …` shell out to the scripting runner —
|
|
# the SDK's runner CLI bundled to one self-contained JS, run on a pinned bun (it import()s the
|
|
# operator's .ts plugins; see packaging/debian/build-scripting-deb.sh). The .deb lays it out under
|
|
# /usr, which is read-only here, so ship the SAME payload user-scoped — wrapper + private bun +
|
|
# bundle under ~/.local, where the host's runner discovery looks after the /usr layouts.
|
|
log "Building the plugin runner (scripting)"
|
|
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib/punktfunk-scripting" "$HOME/.local/share/punktfunk-scripting"
|
|
distrobox enter "$BOX" -- bash -lc "
|
|
set -e
|
|
export PATH=\$HOME/.bun/bin:\$PATH
|
|
cd '$SRC/sdk'
|
|
bun install --frozen-lockfile --ignore-scripts
|
|
bun build src/runner-cli.ts --target=bun --outfile \"\$HOME/.local/share/punktfunk-scripting/runner-cli.js\"
|
|
# Pin the runtime: copy the box's bun next to the bundle so a bun self-update (or a box rebuild)
|
|
# never changes what the runner executes under.
|
|
install -m0755 \"\$(command -v bun)\" \"\$HOME/.local/lib/punktfunk-scripting/bun\"
|
|
"
|
|
grep -q 'attempt=' "$HOME/.local/share/punktfunk-scripting/runner-cli.js" \
|
|
|| die "runner bundle missing the dynamic plugin import — wrong build"
|
|
cat > "$HOME/.local/bin/punktfunk-scripting" <<'WRAP'
|
|
#!/bin/sh
|
|
# Generated by scripts/steamdeck/install.sh — user-scoped punktfunk-scripting (the .deb's /usr/bin
|
|
# wrapper, relocated): the runner bundle on its private pinned bun.
|
|
exec "$HOME/.local/lib/punktfunk-scripting/bun" "$HOME/.local/share/punktfunk-scripting/runner-cli.js" "$@"
|
|
WRAP
|
|
chmod 0755 "$HOME/.local/bin/punktfunk-scripting"
|
|
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).
|
|
# Auto encoder: Vulkan Video (or VAAPI fallback) on the Deck's AMD GPU, NVENC on NVIDIA.
|
|
PUNKTFUNK_ENCODER=auto
|
|
# Van Gogh (LCD/OLED Deck) RADV still gates VK_KHR_video_encode_* behind this perftest flag;
|
|
# without it the Vulkan backend can't open and sessions fall back to libav VAAPI. Harmless on
|
|
# GPUs where encode is exposed by default.
|
|
RADV_PERFTEST=video_encode
|
|
# The host auto-detects the live session (Game Mode gamescope / Desktop KDE) per connect.
|
|
# Override the compositor only if detection misbehaves: PUNKTFUNK_COMPOSITOR=gamescope
|
|
EOF
|
|
ok "wrote host.env"
|
|
else
|
|
ok "host.env exists (left as-is)"
|
|
fi
|
|
|
|
# KWin authorization for Desktop-Mode streaming (and mid-stream Game↔Desktop switches): KWin
|
|
# resolves a connecting client's /proc/<pid>/exe against a .desktop `Exec=` and only then grants
|
|
# the restricted Wayland globals it lists (see packaging/linux/io.unom.Punktfunk.Host.desktop).
|
|
# Exec must therefore be THIS install's binary path, not the packaged /usr/bin one. KWin reads
|
|
# grants at session start — after first install, restart the Desktop session (Game Mode and back).
|
|
DESKTOP_DST="$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
|
# First-time install of the grant: KWin only reads it at session start, so a fresh login is required
|
|
# before Desktop-mode capture works. A re-run that just rewrites it needs no relogin.
|
|
[ -f "$DESKTOP_DST" ] || NEED_RELOGIN=1
|
|
mkdir -p "$HOME/.local/share/applications"
|
|
sed "s|^Exec=.*|Exec=$BIN|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" > "$DESKTOP_DST"
|
|
ok "KWin desktop-capture authorization (io.unom.Punktfunk.Host.desktop → $BIN)"
|
|
|
|
# KDE Desktop-mode INPUT: a normal Plasma login lacks the RemoteDesktop portal grant the host's libei
|
|
# input path needs, so it would pop an "Allow remote control?" dialog a headless host can't answer.
|
|
# Seed it once (per-user, no root) — mirrors packaging/bazzite/kde-desktop-setup.sh. Game Mode
|
|
# (gamescope) needs none of this; the .desktop above already grants org_kde_kwin_fake_input.
|
|
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
|
|
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
|
|
if [ -s "$GRANT_DST" ]; then
|
|
ok "KDE RemoteDesktop grant already present"
|
|
elif [ -s "$GRANT_SRC" ]; then
|
|
mkdir -p "$(dirname "$GRANT_DST")"
|
|
install -m644 "$GRANT_SRC" "$GRANT_DST"
|
|
systemctl --user restart xdg-permission-store 2>/dev/null || true
|
|
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
|
|
fi
|
|
|
|
if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
|
|
# Random login password + session secret for the web console, generated once.
|
|
# `|| 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)"
|
|
# `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
|
|
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
|
|
fi
|
|
|
|
# --- 3b. HDR gamescope (punktfunk-gamescope, best-effort) ------------------
|
|
# Stock gamescope offers only 8-bit capture, so Game Mode streams SDR. The punktfunk build adds
|
|
# the 10-bit BT.2020 PQ formats, and the host attempts HDR by default the moment it is present
|
|
# (PUNKTFUNK_GAMESCOPE_HDR=0 forces SDR). Best-effort by design: on any failure this warns and
|
|
# the host streams SDR — see build-gamescope.sh, which also wires PUNKTFUNK_GAMESCOPE_BIN into
|
|
# host.env only while the binary provably runs on SteamOS.
|
|
PUNKTFUNK_SRC="$SRC" PUNKTFUNK_BOX="$BOX" bash "$SRC/scripts/steamdeck/build-gamescope.sh"
|
|
|
|
# --- 4. system tuning (needs sudo: UDP buffers + gamepad udev rule + vhci-hcd + input group) --------
|
|
log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input group)"
|
|
# sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
|
|
# skip here (no password / no TTY) was already reported loudly there.
|
|
if [ "$SUDO_OK" = 1 ]; then
|
|
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' \
|
|
| sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
|
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
|
ok "UDP socket buffers raised to 32 MB (persisted)"
|
|
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
|
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
|
sudo udevadm control --reload-rules && sudo udevadm trigger || true
|
|
ok "installed udev rule (virtual gamepads + native Steam Deck controller)"
|
|
fi
|
|
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a *real* USB device so Steam
|
|
# Input adopts it (else it degrades to plain UHID, which Steam ignores — "no controller appears").
|
|
# Persist the autoload AND load it now so passthrough works without waiting for a reboot.
|
|
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
|
|
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
|
|
sudo modprobe vhci-hcd 2>/dev/null || warn "could not load vhci-hcd now (loads on next boot) — needed for the native Steam Deck pad"
|
|
ok "vhci-hcd autoload installed (native Steam Deck controller transport)"
|
|
fi
|
|
if id -nG "$USER" | grep -qw input; then
|
|
ok "already in the 'input' group"
|
|
else
|
|
sudo usermod -aG input "$USER"
|
|
NEED_RELOGIN=1
|
|
warn "added $USER to the 'input' group (applies on next login)"
|
|
fi
|
|
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
|
|
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
|
|
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
|
|
# /etc/atomic-update.conf.d/ (itself on the stock keep list, so it self-preserves).
|
|
if [ -f "$SRC/scripts/punktfunk-atomic-keep.conf" ]; then
|
|
sudo install -Dm644 "$SRC/scripts/punktfunk-atomic-keep.conf" /etc/atomic-update.conf.d/punktfunk.conf
|
|
ok "system tuning registered to survive SteamOS updates (atomic-update.conf.d)"
|
|
fi
|
|
else
|
|
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
|
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
|
warn "A stock SteamOS 'deck' account has NO password, so sudo can't work until you set one:"
|
|
warn " passwd # set a sudo password once, then re-run this script"
|
|
warn "Or apply it by hand (then reboot):"
|
|
warn " sudo install -m644 $SRC/scripts/60-punktfunk.rules /etc/udev/rules.d/ &&"
|
|
warn " sudo install -m644 $SRC/scripts/punktfunk-modules.conf /etc/modules-load.d/punktfunk.conf &&"
|
|
warn " sudo usermod -aG input $USER &&"
|
|
warn " printf 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432\\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf &&"
|
|
warn " sudo sysctl --system && sudo udevadm control --reload-rules && sudo udevadm trigger"
|
|
fi
|
|
|
|
# --- 5. systemd user services ---------------------------------------------
|
|
log "Installing systemd user services"
|
|
mkdir -p "$UNITS"
|
|
# The native punktfunk/1 plane is always on; --gamestream additionally enables the Moonlight-compat
|
|
# planes (the Deck commonly streams to Moonlight too). --no-gamestream → secure native-only (no #5/#9
|
|
# surface; native clients only).
|
|
SERVE_ARGS="serve --mgmt-bind 0.0.0.0:$MGMT_PORT"
|
|
[ "$GAMESTREAM" = 1 ] && SERVE_ARGS="$SERVE_ARGS --gamestream"
|
|
[ "$OPEN" = 1 ] && SERVE_ARGS="$SERVE_ARGS --open"
|
|
cat > "$UNITS/punktfunk-host.service" <<EOF
|
|
# Generated by scripts/steamdeck/install.sh — punktfunk Steam Deck host (native binary).
|
|
[Unit]
|
|
Description=punktfunk host (GameStream + punktfunk/1)
|
|
After=pipewire.service
|
|
|
|
[Service]
|
|
EnvironmentFile=-%h/.config/punktfunk/host.env
|
|
Environment=XDG_RUNTIME_DIR=$XRD
|
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=$XRD/bus
|
|
ExecStart=$BIN $SERVE_ARGS
|
|
Restart=on-failure
|
|
RestartSec=2
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
ok "punktfunk-host.service ($SERVE_ARGS)"
|
|
|
|
if [ "$WITH_WEB" = 1 ]; then
|
|
# The console is a Nitro server run by bun (Bun.serve, HTTPS — HTTP/1.1 over TLS — with the host's
|
|
# identity cert); it lives in the build container and proxies to the host's loopback HTTPS mgmt API.
|
|
cat > "$UNITS/punktfunk-web.service" <<EOF
|
|
# Generated by scripts/steamdeck/install.sh — punktfunk web console (bun in the '$BOX' distrobox).
|
|
[Unit]
|
|
Description=punktfunk management web console
|
|
After=punktfunk-host.service
|
|
|
|
[Service]
|
|
ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs'
|
|
Restart=on-failure
|
|
RestartSec=3
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
ok "punktfunk-web.service (port $WEB_PORT)"
|
|
fi
|
|
|
|
# The runner's user unit (OPT-IN, matching the .deb: installed but NOT enabled — the runner is
|
|
# inert until you add scripts/plugins). ExecStart is rewritten from the packaged /usr wrapper to
|
|
# the user-scoped one section 2b installed.
|
|
sed 's|^ExecStart=.*|ExecStart=%h/.local/bin/punktfunk-scripting|' \
|
|
"$SRC/scripts/punktfunk-scripting.service" > "$UNITS/punktfunk-scripting.service"
|
|
ok "punktfunk-scripting.service (opt-in: systemctl --user enable --now punktfunk-scripting)"
|
|
|
|
# Post-OS-update self-heal: SteamOS A/B updates can bump library sonames the host binary links
|
|
# (FFmpeg/PipeWire/libva) — this oneshot probes the binary with ldd before punktfunk-host starts
|
|
# and re-runs update.sh only when it actually stopped loading. Milliseconds on a normal boot.
|
|
cat > "$UNITS/punktfunk-rebuild-check.service" <<EOF
|
|
# Generated by scripts/steamdeck/install.sh — rebuild the host if a SteamOS update broke its libs.
|
|
[Unit]
|
|
Description=punktfunk SteamOS post-update rebuild check
|
|
Before=punktfunk-host.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=$SRC/scripts/steamdeck/rebuild-check.sh
|
|
# A cold-ish rebuild is minutes, not seconds.
|
|
TimeoutStartSec=1800
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
chmod +x "$SRC/scripts/steamdeck/rebuild-check.sh" 2>/dev/null || true
|
|
systemctl --user enable punktfunk-rebuild-check.service 2>/dev/null || true
|
|
ok "punktfunk-rebuild-check.service (auto-rebuild after SteamOS updates)"
|
|
|
|
systemctl --user daemon-reload
|
|
loginctl show-user "$USER" 2>/dev/null | grep -q 'Linger=yes' || { sudo loginctl enable-linger "$USER" 2>/dev/null && ok "enabled linger (services run without login)" || warn "could not enable linger — services stop when you log out (sudo loginctl enable-linger $USER)"; }
|
|
# enable + restart (not `enable --now`): restart picks up unit-file changes on a re-run, where
|
|
# `--now` would no-op against an already-running service.
|
|
systemctl --user enable punktfunk-host.service 2>/dev/null
|
|
systemctl --user restart punktfunk-host.service
|
|
ok "punktfunk-host started"
|
|
if [ "$WITH_WEB" = 1 ]; then
|
|
# The host writes the mgmt token on first start; give it a moment so the web unit finds it.
|
|
for _ in $(seq 1 10); do [ -f "$CONFIG/mgmt-token" ] && break; sleep 0.5; done
|
|
systemctl --user enable punktfunk-web.service 2>/dev/null
|
|
systemctl --user restart punktfunk-web.service
|
|
ok "punktfunk-web started"
|
|
fi
|
|
|
|
# --- 6. summary ------------------------------------------------------------
|
|
IP="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1 || true)"
|
|
echo
|
|
log "Done — punktfunk host is running on this Steam Deck"
|
|
echo " • Host status: systemctl --user status punktfunk-host"
|
|
if [ "$WITH_WEB" = 1 ]; then
|
|
echo " • Web console: https://${IP:-steamdeck.local}:$WEB_PORT (login: see $CONFIG/web.env)"
|
|
echo " • Pair a device: open the web console → Devices → arm pairing → enter the PIN on the client"
|
|
fi
|
|
if [ "$OPEN" = 1 ]; then
|
|
echo " • Mode: --open (unpaired clients accepted — trusted LAN only)"
|
|
else
|
|
echo " • Pairing required (secure default). From a client, pick this host and enter the PIN the host shows."
|
|
fi
|
|
echo " • Update later: bash $SRC/scripts/steamdeck/update.sh"
|
|
if [ "$NEED_RELOGIN" = 1 ]; then
|
|
echo
|
|
warn "ONE MORE STEP before streaming — reboot the Deck (or fully log out and back in)."
|
|
echo " KWin only authorizes Desktop-mode screen capture on a fresh session, and the new 'input'"
|
|
echo " group (native Steam Deck controller passthrough) only applies to a new login. Streaming"
|
|
echo " Game Mode with a generic Xbox pad works now; Desktop capture + the native Deck pad need the reboot."
|
|
fi
|