The low/medium findings from the July host+Windows security review, as
implemented in the audit session's working tree:
- Webhooks and library-art fetches refuse loopback/link-local/metadata
targets and no longer follow redirects (SSRF pivots from the privileged
host process).
- The paired-client rosters (/clients, /native/clients) move off the
streaming-client auth lane — one paired device could enumerate every other
device's name + fingerprint; only the bearer/loopback console keeps them.
- Device-name sanitizing extends to bidi/format control characters
(spoofable rendering) via the shared native_pairing::is_spoofy_char;
stream-marker quoting uses the same set.
- The SYSTEM service resolves powershell by its full System32 path —
CreateProcess checks the launching EXE's own directory first, so a planted
powershell.exe beside the host binary would have run as SYSTEM.
- The pf-vdisplay driver's opt-in file log moves from world-writable
C:\Users\Public to WUDFHost's own temp dir.
- GameStream pairing sessions are single-use (removed whatever the outcome).
- Uninstall also removes the pf_mouse driver-store entry (rider from the
virtual-HID-mouse work).
- openapi.json regenerated (hardened-config-dir doc wording).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI image's rustfmt reformats these files (multi-line assert!/tracing! macros,
match-arm and struct-variant wrapping) — pre-existing drift that the Format job caught.
Reformat to match. Pure formatting; no logic change. main.rs also gets a blank line
before a standalone comment so rustfmt stops mis-indenting it as a trailing-comment
continuation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two concurrent Windows sessions both drive the same pf-vdisplay monitor's
single-capturer IDD-push channel (newest-delivery-wins), which freezes the live
client and can wedge the driver (observed live: a concurrent-session test wedged
.173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/
Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to
REJECT — a 2nd client gets a clean 503 and the live session is protected — instead
of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps
separate (real multi-view). Centralized as admission::effective_conflict(), shared
by the native handshake + GameStream h_launch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull the GameStream mode-conflict decision out of h_launch into a pure
gamestream_admission(live, req_fp, policy) -> GsDecision so the 503/join/take-over
logic is unit-tested (no live session / same-client → Serve; different client →
Reject/Join/Serve per policy; anonymous requester treated as different) — the
GameStream path can't be driven without a Moonlight client, so this covers the logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the mode-conflict admission surface deferred from the initial Stage 4:
- REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC
connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to
<client>' string, which the client reads from ApplicationClosed (validated on
loopback: the probe logs 'closed by peer: host busy … (code 66)').
- Windows default: separate (incl. the unconfigured default) resolves to JOIN — the
Windows native host admits a second client at the live mode instead of the old
silent last-wins reconfigure of the shared monitor (release-note behavior fix; the
reconfigure is now opt-in as steal). separate stays multi-view on Linux.
- GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept
[u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches —
reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate →
take over. Same-client re-launch is never a conflict.
Native reject-reason loopback-validated; Windows join-default pending .173 rebuild;
GameStream 503 pending a Moonlight client (can't drive /launch autonomously).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the GameStream/Moonlight plane up to the native plane's capability parity.
HDR (Windows only):
- New host_hdr_capable() gate (Windows + PUNKTFUNK_10BIT, matching the native
policy). serverinfo layers SCM_HEVC_MAIN10 onto the probed/static codec mask, so
Moonlight finally offers its HDR toggle (live: mask 0x10101 -> 0x10301).
- Parse the client's dynamicRangeMode into StreamConfig.hdr and pass it through to
OutputFormat::resolve, so a client HDR request proactively enables advanced color
on the per-session virtual display (PQ flows even from an SDR desktop). The
encoder bit depth now derives from the captured frame format (gs_bit_depth) rather
than a hard-coded 8 that mislabeled the already-Main10 HDR stream.
Game library in /applist:
- The catalog now layers library::all_games() (Steam/Epic/GOG/Xbox/custom) on top of
Desktop/apps.json, each with a STABLE GameStream id (FNV-1a, dedup-probed) and the
store-qualified library id. Launch routes through the existing security-reviewed
launch_title/launch_command via library::launch_gamestream_library — a client can
only pick an existing title, never inject a command.
- /appasset cover proxy: Moonlight fetches per-app covers from the host, so resolve
appid -> library cover URL and proxy the bytes (portrait -> header -> hero -> logo;
data: + bounded http(s) fetch), on a blocking thread. IsHdrSupported reflects the
host HDR capability.
4:4:4 stays off on GameStream by design: stock Moonlight is 4:2:0 and the Windows
IDD-push capturer can't deliver full chroma yet (capturer_supports_444() == false);
the gate is documented so it lights up once IDD-push full-chroma capture lands.
Validated live (Moonlight -> Windows NVENC host): HDR advertised, the Epic library
shows with covers, launch works. clippy clean; apps/serverinfo/library unit tests
cover the HDR mask, stable-id, dedup, and data-URL paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remediations from design/security-review-2026-06-28.md verified on Linux
(cargo check/clippy/test green; Windows-gated paths verify in CI):
- S1 [HIGH]: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185,
pre-auth out-of-order STREAM reassembly memory exhaustion on the
always-on default QUIC listener).
- #1 [HIGH]: remove the unauthenticated nvhttp `GET /pin` endpoint; the
GameStream PIN is delivered ONLY via the bearer-gated mgmt API, so a
network client can no longer submit its own displayed PIN and self-pair.
- #4 [HIGH->MED]: gate the unauthenticated RTSP/UDP media plane on a paired
`/launch` and bind it to the launching client's source IP (threaded
through the HTTPS handler), so an unpaired peer can neither start capture
on an idle host nor ride a paired client's active launch.
- #12: bound concurrent parked pairing waiters (MAX_PARKED_WAITERS) so a
pre-auth peer can't pin unbounded 300s handshakes. +regression test.
- #10: throttle the per-packet ENet control GCM-decrypt-failed warn
(exponential backoff) so a junk flood can't spam the log.
- #7 [MED->LOW]: serialize all process-global env mutation on the
session-setup path under a new vdisplay::ENV_LOCK (apply_session_env /
apply_input_env / the launch-cmd set_var / the gamescope env read), so
concurrent native sessions can't race set_var/getenv (data-race UB ->
host-wide DoS). Full per-session SessionContext threading remains a
follow-up for cross-session value confusion.
- #6 [MED]: move the gamescope EIS socket relay from world-writable /tmp to
$XDG_RUNTIME_DIR (per-user 0700) and reject a symlinked relay file, so a
local user can't intercept (keylog) or deny the remote session's input.
- S2: a malformed client Opus mic frame now drops that frame instead of
tearing down the shared host-lifetime virtual mic (cross-session DoS).
- S3: track held buttons/keys in capped HashSets (was unbounded Vec with
O(n) scans) so a paired client can't grow per-session input state.
- S5: reject fps==0/absurd at the open_video chokepoint (covers Hello,
ANNOUNCE, Reconfigure) so the encoder time_base/pts math can't div-by-0.
- S6: bound the shared mic mpsc (drop-newest when full).
- S4: cap Epic launcher-cache reads (catcache.bin/.item) so a planted giant
can't OOM the host during library enumeration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arm streaming-perf-stats capture from the web console, play, stop, and review the
run as graphs; finished captures are saved to disk as browsable/exportable
recordings. Covers both the native punktfunk/1 path and GameStream.
- stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve,
shared with the mgmt API + both streaming loops, mirroring NativePairing). The
hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF
for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic
temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids;
poison-resilient locks.
- native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing
~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput,
loss/FEC deltas — with no new per-frame work beyond the cheap atomic check.
FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's
percentiles (without zeroing the Windows-relay path's fps/encode).
- mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live;
recordings list/get/delete); api/openapi.json regenerated, in sync.
- web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live
graphs while armed, recordings table (view / download-JSON / delete), and a detail
view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput
+ health. Charts adapt to either path's stage set.
Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent
workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet
on-glass validated against a live session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pass over the apollo-comparison backlog (re-verified against current code).
Lands four items end-to-end plus a Windows-DualSense scoping doc.
- #5/#92/#26 — GameStream paired-cert allow-list. tls.rs surfaces the verified
peer cert to handlers (serve_https + PeerCertFingerprint, now shared with the
mgmt API instead of duplicated); nvhttp gates /launch /resume /applist /cancel
on AppState.paired and reports a real PairStatus; save_paired writes atomically
(temp+rename). Closes the "mTLS accepts any client cert" hole. + regression test.
- #6/#51/#19/#22 — NVENC caps query -> reference-frame invalidation. nvenc.rs
query_caps probes nvEncGetEncodeCaps (max dims / 10-bit / custom-VBV / RFI),
rejecting over-range modes and degrading 10-bit->8-bit instead of an opaque
InvalidParam. New Encoder::invalidate_ref_frames (default false -> caller
keyframes); the Windows NVENC path implements real RFI (multi-ref DPB +
nvEncInvalidateRefFrames, dedup + IDR-on-overflow). control.rs decodes the
0x0301 lost-frame range (Apollo's IDX_INVALIDATE_REF_FRAMES) -> AppState.rfi_range
-> encode loop, falling back to a keyframe. NOTE: the Windows NVENC impl is
RTX-box/CI-pending (can't compile on Linux); adversarially reviewed vs the SDK.
- #43/#72 — media socket QoS + buffer growth. New punktfunk_core::transport::qos:
grow_socket_buffers (factored out the native plane's 32MB SO_SNDBUF growth so the
GameStream sockets reuse it) + set_media_qos (opt-in PUNKTFUNK_DSCP=1: DSCP CS5
video / CS6 audio + Linux SO_PRIORITY, Apollo's scheme). Wired into UdpTransport
and the GameStream video/audio sockets. Windows IP_TOS needs qWAVE (follow-up).
- #8/#45 — GameStream input injection off the ENet service thread. on_receive no
longer injects inline (a slow inject head-blocked ENet keepalive/retransmit); it
forwards to a dedicated injector thread. The hardened InjectorService moved from
punktfunk1 into crate::inject (shared by both planes) + a coalesce step that sums
adjacent relative-mouse/scroll deltas while preserving button/key/abs ordering.
Docs: re-verified apollo-comparison.md status (22 items already done/obsolete since
the snapshot) + windows-dualsense-scoping.md (ViGEm can't emulate a DualSense; real
DS5 on Windows needs a VHF virtual-HID driver — web-research pass pending).
fmt + clippy -D warnings clean; full workspace test suite green; no C-ABI/OpenAPI drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full project rename, decided 2026-06-10:
- Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs.
- C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h,
PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl.
PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants).
- Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1.
WIRE BREAK: clients must be rebuilt from this revision.
- Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / ….
- Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the
persistent identity is unchanged, pinned fingerprints stay valid).
- Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection
(Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated.
- scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated.
Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of
"desktop but no apps/settings" over the stream: plasmashell launched without
XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and
rendered an empty menu. The script sets the complete KDE session env (menu prefix,
KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell.
Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS,
zero lumen references left outside .git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>