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.
200 lines
12 KiB
Bash
Executable File
200 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Headless KDE Plasma session for the punktfunk host (no KMS scanout → kwin --virtual).
|
|
#
|
|
# Brings up the full desktop, not just the compositor, and waits for it to be *actually
|
|
# ready* before starting the portals/plasma — no blind `sleep`. A bare session is missing
|
|
# pieces a full Plasma login would start, so this also: exports the Xwayland DISPLAY (X11 apps
|
|
# like Steam need it), starts the polkit authentication agent (Discover/PackageKit need it to
|
|
# authorize installs), and supervises plasmashell (it draws wallpaper + panels; a crash must
|
|
# not leave the desktop gone). The env matters: without XDG_MENU_PREFIX=plasma- the launcher
|
|
# resolves ${XDG_MENU_PREFIX}applications.menu → "applications.menu", which doesn't exist on KDE
|
|
# installs (it ships plasma-applications.menu) — plasmashell runs fine but the menu is EMPTY.
|
|
#
|
|
# bash scripts/headless/run-headless-kde.sh [WxH] # default 1920x1080
|
|
#
|
|
# Then in another shell:
|
|
# WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
|
|
# punktfunk-host punktfunk1-host --source virtual --seconds 14400 # zero-copy is on by default
|
|
set -euo pipefail
|
|
|
|
RES="${1:-1920x1080}"
|
|
W="${RES%x*}"
|
|
H="${RES#*x}"
|
|
|
|
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}"
|
|
export XDG_CURRENT_DESKTOP=KDE
|
|
export XDG_MENU_PREFIX=plasma-
|
|
export KDE_FULL_SESSION=true
|
|
export KDE_SESSION_VERSION=6
|
|
export DESKTOP_SESSION=plasma
|
|
export WAYLAND_DISPLAY=wayland-kde
|
|
export KWIN_WAYLAND_NO_PERMISSION_CHECKS=1
|
|
|
|
# The probe binary (gates readiness on KWin actually exposing zkde_screencast — not merely
|
|
# the socket existing). Prefer a source-tree build (dev box), then a packaged install on PATH
|
|
# (the RPM/.deb put it at /usr/bin — there is no $ROOT/target there, and the old cargo-run
|
|
# fallback has no Cargo.toml either, so without this the probe never succeeds and the session
|
|
# restart-loops), then `cargo run` as a last resort.
|
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
if [[ -x "$ROOT/target/release/punktfunk-host" ]]; then
|
|
PROBE=("$ROOT/target/release/punktfunk-host" probe-compositor)
|
|
elif [[ -x "$ROOT/target/debug/punktfunk-host" ]]; then
|
|
PROBE=("$ROOT/target/debug/punktfunk-host" probe-compositor)
|
|
elif command -v punktfunk-host >/dev/null 2>&1; then
|
|
PROBE=(punktfunk-host probe-compositor)
|
|
else
|
|
PROBE=(cargo run -q --manifest-path "$ROOT/Cargo.toml" -p punktfunk-host -- probe-compositor)
|
|
fi
|
|
|
|
# kwin to its own log (so its EGL/GPU-init errors are captured, not lost to the terminal).
|
|
# `--xwayland`: enable Xwayland so X11 apps (Discord/Electron, Steam, many launchers) work. Without
|
|
# 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.
|
|
# 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=$!
|
|
|
|
# Active readiness wait: poll until KWin is up AND advertises the zkde_screencast global
|
|
# (what the virtual-output backend needs), or fail fast with a useful message. kwin can also
|
|
# exit immediately if EGL/GPU init fails — catch that.
|
|
echo "waiting for KWin ($RES) to become ready…"
|
|
DEADLINE=$(( SECONDS + 30 ))
|
|
until "${PROBE[@]}" >/dev/null 2>&1; do
|
|
if ! kill -0 "$KWIN_PID" 2>/dev/null; then
|
|
echo "ERROR: kwin_wayland exited during startup — see $KWIN_LOG:" >&2
|
|
tail -n 20 "$KWIN_LOG" >&2 || true
|
|
exit 1
|
|
fi
|
|
if (( SECONDS >= DEADLINE )); then
|
|
echo "ERROR: KWin did not become ready within 30s. Last probe:" >&2
|
|
"${PROBE[@]}" >&2 || true
|
|
exit 1
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
echo "KWin ready."
|
|
|
|
# Detect the Xwayland display KWin started and export it. Without DISPLAY, X11 apps launched in
|
|
# this session (Steam, many games/launchers) fail with "can't open display" even though Xwayland
|
|
# is running — KWin sets DISPLAY only for its own children, not for apps launched via the
|
|
# plasma menu / D-Bus activation. KWin brings Xwayland up a moment after itself; poll for it.
|
|
DISPLAY_NUM=""
|
|
for _ in $(seq 1 40); do
|
|
# `|| true`: under `set -euo pipefail`, grep/pgrep exit non-zero when nothing's there yet
|
|
# (common a few seconds into a systemd-launched boot), which would abort the WHOLE script —
|
|
# killing KWin — on the first iteration instead of retrying. Tolerate it so the loop polls,
|
|
# and so a session with no Xwayland at all still proceeds (DISPLAY just stays unset → warn).
|
|
# Primary: KWin reserves + logs the X11 display ("Using public X11 display :N") up front, even
|
|
# with Xwayland-on-demand (no Xwayland *process* until the first X client connects) — so the
|
|
# old pgrep-the-process check found nothing and never set DISPLAY. Read the log; fall back to a
|
|
# live Xwayland process for older KWin.
|
|
d=$(grep -oE 'Using public X11 display :[0-9]+' "$KWIN_LOG" 2>/dev/null | grep -oE '[0-9]+' | head -1 || true)
|
|
if [[ -n "$d" ]]; then DISPLAY_NUM="$d"; break; fi
|
|
d=$(pgrep -a Xwayland 2>/dev/null | grep -oE ' :[0-9]+' | tr -d ' :' | head -1 || true)
|
|
if [[ -n "$d" && -S "/tmp/.X11-unix/X$d" ]]; then DISPLAY_NUM="$d"; break; fi
|
|
# Most reliable here: KWin --xwayland reserves the /tmp/.X11-unix/X<N> socket up front (Xwayland
|
|
# starts on demand on first connect) and on this KWin neither logs the display nor runs a process
|
|
# until then. The socket IS the signal — take it. (Fresh boot has a tmpfs /tmp, so no stale one.)
|
|
s=$(ls /tmp/.X11-unix/X[0-9]* 2>/dev/null | head -1 || true)
|
|
if [[ -n "$s" && -S "$s" ]]; then DISPLAY_NUM="${s##*/X}"; break; fi
|
|
sleep 0.25
|
|
done
|
|
if [[ -n "$DISPLAY_NUM" ]]; then
|
|
export DISPLAY=":$DISPLAY_NUM"
|
|
echo "Xwayland on $DISPLAY"
|
|
else
|
|
echo "WARN: no Xwayland display detected — X11 apps (Steam) won't open a display" >&2
|
|
fi
|
|
|
|
# Only NOW restart the portals, and against the correct env: the xdg-desktop-portal chain
|
|
# binds the compositor that existed when it started, so a stale portal points at a dead
|
|
# socket and RemoteDesktop/EIS (input injection) times out. Import the session env into the
|
|
# systemd/D-Bus activation environment FIRST (the missing piece — the Sway script does this;
|
|
# without it the restarted portal can inherit an empty WAYLAND_DISPLAY) — including DISPLAY so
|
|
# D-Bus-activated X11 apps inherit it — then restart.
|
|
systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP DBUS_SESSION_BUS_ADDRESS XDG_RUNTIME_DIR ${DISPLAY:+DISPLAY} 2>/dev/null || true
|
|
dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP DBUS_SESSION_BUS_ADDRESS ${DISPLAY:+DISPLAY} 2>/dev/null || true
|
|
|
|
# Pre-seed the RemoteDesktop grant for headless input injection (libei). KWin's xdg portal would
|
|
# otherwise pop an "Allow remote control?" dialog on every session Start() — which a headless host
|
|
# can't answer, so EIS setup times out and input silently fails. The `kde-authorized` permission-
|
|
# store table (this tiny GVariant DB, shipped next to this script) pre-authorizes it. Copy it in if
|
|
# the user has none yet; never clobber an existing one.
|
|
DB="$HOME/.local/share/flatpak/db/kde-authorized"
|
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
if [[ ! -s "$DB" && -s "$SELF_DIR/kde-authorized" ]]; then
|
|
mkdir -p "$(dirname "$DB")" && cp "$SELF_DIR/kde-authorized" "$DB"
|
|
echo "seeded RemoteDesktop grant: $DB"
|
|
fi
|
|
|
|
# Virtual "Punktfunk" speaker: a null sink (shipped next to this script) that the host captures +
|
|
# streams, set default so desktop audio goes there instead of a real/AirPlay device — a headless
|
|
# host has no speakers, and on a LAN with AirPlay gear PipeWire otherwise picks a random HomePod.
|
|
# pipewire reads its own config at start, so on FIRST install (config not yet present) restart it
|
|
# once to load the sink; later boots already have it. (Also disable AirPlay discovery out of band:
|
|
# `sudo dnf remove pipewire-config-raop`.)
|
|
PWSINK="$HOME/.config/pipewire/pipewire.conf.d/50-punktfunk-sink.conf"
|
|
if [[ ! -s "$PWSINK" && -s "$SELF_DIR/punktfunk-sink.conf" ]]; then
|
|
mkdir -p "$(dirname "$PWSINK")" && cp "$SELF_DIR/punktfunk-sink.conf" "$PWSINK"
|
|
echo "installed Punktfunk virtual speaker → restarting pipewire to load it"
|
|
systemctl --user restart pipewire 2>/dev/null || true
|
|
fi
|
|
|
|
# Reach graphical-session.target so xdg-desktop-portal (which is ordered After / fails its start
|
|
# job without it) can come up — a headless linger session never gets there on its own, and Fedora's
|
|
# target carries RefuseManualStart=yes, so drop that in once. Without the portal, libei input fails.
|
|
GST_DROPIN="$HOME/.config/systemd/user/graphical-session.target.d"
|
|
if [[ ! -f "$GST_DROPIN/10-punktfunk-headless.conf" ]]; then
|
|
mkdir -p "$GST_DROPIN"
|
|
printf '[Unit]\nRefuseManualStart=no\n' > "$GST_DROPIN/10-punktfunk-headless.conf"
|
|
systemctl --user daemon-reload 2>/dev/null || true
|
|
fi
|
|
systemctl --user start graphical-session.target 2>/dev/null || true
|
|
|
|
# Bring the portal up against the env imported above. `start` (not the old `try-restart`, a no-op
|
|
# when inactive — the headless first-boot case) so it actually comes up; `--no-block` since
|
|
# xdg-desktop-portal is Type=dbus and blocks ~30s waiting for its bus name, and it only needs to be
|
|
# ready before the FIRST client streams (seconds later), not before plasmashell.
|
|
systemctl --user --no-block restart plasma-xdg-desktop-portal-kde.service 2>/dev/null || true
|
|
systemctl --user --no-block start xdg-desktop-portal.service 2>/dev/null || true
|
|
|
|
# Polkit authentication agent: without it, Discover / PackageKit can't get authorization to
|
|
# install packages (polkitd is the policy engine; the *agent* is the GUI prompt). A full Plasma
|
|
# session starts it; our bare session must do it explicitly. Force the Qt Wayland platform — it
|
|
# exits immediately if it can't pick one. comm is truncated to 15 chars ("polkit-kde-auth").
|
|
POLKIT_AGENT=/usr/lib/x86_64-linux-gnu/libexec/polkit-kde-authentication-agent-1
|
|
if [[ -x "$POLKIT_AGENT" ]] && ! pgrep -x polkit-kde-auth >/dev/null; then
|
|
QT_QPA_PLATFORM=wayland setsid "$POLKIT_AGENT" \
|
|
>"${TMPDIR:-/tmp}/punktfunk-polkit-agent.log" 2>&1 &
|
|
fi
|
|
|
|
kbuildsycoca6 >/dev/null 2>&1 || true # rebuild the menu cache under the correct env
|
|
|
|
# Supervise plasmashell: it draws the desktop (wallpaper + panels). It can crash (a transient
|
|
# GPU/QML hiccup), and a single unsupervised `plasmashell &` then leaves the streamed session
|
|
# with only app windows — no desktop, no wallpaper, no launcher. Restart it for as long as KWin
|
|
# lives, so the desktop self-heals.
|
|
( while kill -0 "$KWIN_PID" 2>/dev/null; do
|
|
plasmashell
|
|
echo "plasmashell exited — restarting in 2s" >&2
|
|
sleep 2
|
|
done ) &
|
|
|
|
echo "headless KDE up on $WAYLAND_DISPLAY ($RES), kwin pid $KWIN_PID (log: $KWIN_LOG)"
|
|
wait "$KWIN_PID"
|