The comments were the spec, and the code had drifted — 2026-08-25 security review #396
@@ -29,7 +29,11 @@ jobs:
|
||||
announce:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# SHA-pinned, unlike the plain `@v4` the build workflows use: this job holds
|
||||
# UPDATE_MANIFEST_KEY — the Ed25519 key every host pins to decide whether an update is real —
|
||||
# and a tag is mutable, so whoever can move it runs code in front of that key. Same style as
|
||||
# the appleboy pins in deploy-services.yml; the trailing comment is the release it resolves to.
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
# Publish the SIGNED stable update manifest — the moment every host's update check learns
|
||||
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
|
||||
|
||||
@@ -115,10 +115,12 @@ jobs:
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
}
|
||||
# Arch ships bun in [extra], so the bootstrap takes the pacman-signed package instead of
|
||||
# piping bun.sh's installer into root's shell — this job builds and publishes the package,
|
||||
# and the installer would be upstream code choosing bytes we then ship. Kept behind the
|
||||
# `command -v` guard rather than folded into the list above: the image's baked bun is not
|
||||
# in pacman's db, so `--needed` cannot see it and would re-download bun on every run.
|
||||
command -v bun >/dev/null || pacman -S --noconfirm --needed bun
|
||||
bun --version
|
||||
|
||||
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
|
||||
|
||||
@@ -220,9 +220,25 @@ jobs:
|
||||
run: |
|
||||
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
|
||||
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
|
||||
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb (BUN_BIN, below),
|
||||
# so an install script piped into root's shell is upstream code choosing bytes we then
|
||||
# publish under REGISTRY_TOKEN. Not in Debian/Ubuntu, so a pin is the only option here.
|
||||
# ONE bun across the repo: same version as rpm.yml and windows-host.yml, and the same
|
||||
# asset + sum as rpm.yml (windows pins bun-windows-x64.zip, so its sum differs) — bump
|
||||
# all three together (the sums are in the release's SHASUMS256.txt). `-baseline` on
|
||||
# purpose: it needs no AVX2, so the bun we ship starts on every x86-64 box — something the
|
||||
# auto-detecting installer never promised, since it reads the BUILDER's CPU, not the user's.
|
||||
command -v bun >/dev/null || {
|
||||
apt-get install -y --no-install-recommends unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
BUN_VER=bun-v1.3.14
|
||||
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
|
||||
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
|
||||
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
|
||||
install -m0755 /tmp/bun /usr/local/bin/bun
|
||||
}
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
cd web
|
||||
|
||||
@@ -215,8 +215,17 @@ jobs:
|
||||
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
|
||||
# untouched; cargo --offline only needs sources for the crates it compiles.
|
||||
run: |
|
||||
# PINNED to a commit and checked by SHA-256. `master` is a mutable ref, and this is
|
||||
# third-party python executed in the SAME job that holds FLATPAK_GPG_PRIVATE_KEY — it
|
||||
# chooses which crate sources the signed build vendors, so an upstream push (or a bad
|
||||
# day at raw.githubusercontent) would be picking bytes we then sign. Bump both together:
|
||||
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
|
||||
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
|
||||
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
|
||||
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
|
||||
echo "$GEN_SHA /tmp/flatpak-cargo-generator.py" | sha256sum -c - \
|
||||
|| { echo "::error::flatpak-cargo-generator.py sha256 mismatch at $GEN_REF"; exit 1; }
|
||||
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
|
||||
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
|
||||
-o packaging/flatpak/cargo-sources.json
|
||||
@@ -413,10 +422,14 @@ jobs:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — the same repo
|
||||
# secret nix.yml publishes with (packaging/nix/README.md). Gated with the rest below: no
|
||||
# pinned host key, no deploy, never a first-contact-trusts-anything push.
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
|
||||
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
|
||||
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_*/DEPLOY_KNOWN_HOSTS not set — skipping repo deploy (bundle still published). See packaging/nix/README.md for the host key."
|
||||
exit 0
|
||||
fi
|
||||
# 1) Import the signing key into a throwaway keyring; sign the repo.
|
||||
@@ -481,7 +494,13 @@ jobs:
|
||||
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
|
||||
# FLATPAK_GPG_PRIVATE_KEY and ships the signed OSTree repo, so `accept-new` — which trusts
|
||||
# the first key it ever sees, and every run starts with an empty known_hosts, so EVERY run
|
||||
# is a first contact — would hand the deploy key and the publish to anything that won the
|
||||
# race for the address. The guard above skips the deploy when the secret is unset.
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
|
||||
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
|
||||
|
||||
@@ -235,16 +235,19 @@ jobs:
|
||||
env:
|
||||
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"`. Gated here with
|
||||
# the rest: no pinned host key, no publish — never a first-contact-trusts-anything deploy.
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
|
||||
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
|
||||
# repo deploy does, so this workflow stays green through setup.
|
||||
run: |
|
||||
set -eu
|
||||
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
|
||||
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ] && [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
echo "go=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "go=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
|
||||
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST/DEPLOY_KNOWN_HOSTS not set — skipping the binary cache publish (see packaging/nix/README.md)."
|
||||
fi
|
||||
|
||||
- name: Build the publishable packages
|
||||
@@ -269,6 +272,7 @@ jobs:
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
|
||||
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
|
||||
@@ -313,7 +317,13 @@ jobs:
|
||||
# unom-1 drops TCP dials under load.
|
||||
install -d -m700 ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
|
||||
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
|
||||
# NIX_CACHE_SIGNING_KEY and ships the signed cache, so `accept-new` — which trusts the
|
||||
# first key it ever sees, and every run starts with an empty known_hosts, so EVERY run is
|
||||
# a first contact — would hand the deploy key and the publish to anything that won the
|
||||
# race for the address. Preflight above skips the publish when the secret is unset.
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
|
||||
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
||||
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
|
||||
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
|
||||
|
||||
@@ -119,10 +119,24 @@ jobs:
|
||||
dnf -y install gamescope || true
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
|
||||
# This job holds RPM_GPG_PRIVATE_KEY, and the spec VENDORS this very binary into
|
||||
# punktfunk-web, so an install script piped into root's shell is upstream code running in
|
||||
# front of the signing key AND choosing bytes we then sign. Same discipline as
|
||||
# windows-host.yml's bun pin. Bump BUN_VER and BUN_SHA together (the sums are published in
|
||||
# the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we
|
||||
# ship starts on every x86-64 box — something the auto-detecting installer never promised,
|
||||
# since it reads the BUILDER's CPU, not the user's.
|
||||
command -v bun >/dev/null || {
|
||||
dnf -y install unzip
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
BUN_VER=bun-v1.3.14
|
||||
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
|
||||
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
|
||||
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
|
||||
install -m0755 /tmp/bun /usr/local/bin/bun
|
||||
}
|
||||
bun --version
|
||||
- uses: actions/cache@v4
|
||||
|
||||
Generated
+2
-2
@@ -1959,9 +1959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
|
||||
+4
-1
@@ -82,7 +82,10 @@ us beyond the download itself.
|
||||
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
|
||||
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
|
||||
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
|
||||
public key is baked into the script rather than fetched from the feed.
|
||||
public key is baked into the script rather than fetched from the feed. The manifest also names
|
||||
the feed it was signed for and carries a monotonic publish serial, both inside the signed bytes,
|
||||
so a genuinely-signed manifest replayed from another channel — or an older one put back — is
|
||||
refused too.
|
||||
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
|
||||
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
|
||||
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
|
||||
|
||||
@@ -46,16 +46,26 @@ ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
|
||||
&& cargo install cargo-ndk --locked \
|
||||
# Version-pinned like every other tool baked in here: unpinned, a rebuild months apart
|
||||
# silently bakes a different cargo-ndk, and this one drives the shipped Android .so builds.
|
||||
# crates.io is append-only with a checksummed index, so the version IS the pin. Bump freely.
|
||||
&& cargo install cargo-ndk@4.1.2 --locked \
|
||||
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo ndk --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
|
||||
|
||||
+19
-10
@@ -53,21 +53,30 @@ RUN pacman -Syu --noconfirm --needed \
|
||||
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
|
||||
# and its `-static-libstdc++` link is untouched.
|
||||
mold \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
|
||||
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
|
||||
# bootstrap the official binary — once, here, instead of per run.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& rm -rf /root/.bun \
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1) — so these bytes end up inside the
|
||||
# package arch.yml signs and publishes. Arch ships bun in [extra], so take the
|
||||
# pacman-signed package (pacman verifies package signatures by default) instead of piping
|
||||
# bun.sh's installer into root's shell, which would be upstream code choosing them. Same
|
||||
# call as arch.yml's bootstrap guard. It rides THIS transaction rather than a later layer
|
||||
# on purpose: -Syu refreshes the db in the same step that installs, so a cache-hit rebuild
|
||||
# can never resolve bun against a stale snapshot the mirrors no longer carry.
|
||||
bun \
|
||||
&& pacman -Scc --noconfirm \
|
||||
&& bun --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
|
||||
|
||||
@@ -17,8 +17,8 @@ RUN dnf -y install \
|
||||
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||
&& dnf -y install \
|
||||
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
|
||||
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
|
||||
# installer.
|
||||
# — the punktfunk-web console builds AND runs on bun (installed below); unzip extracts the
|
||||
# pinned bun zip.
|
||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||
# build toolchain + bindgen
|
||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||
@@ -43,8 +43,22 @@ RUN dnf -y install \
|
||||
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
|
||||
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
|
||||
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`. The spec
|
||||
# VENDORS this very binary into punktfunk-web, so the installer would be upstream code choosing
|
||||
# bytes rpm.yml then signs with RPM_GPG_PRIVATE_KEY. ONE bun across the repo: same version, asset
|
||||
# and sum as rpm.yml, deb.yml and rust-ci.Dockerfile — bump BUN_VERSION and BUN_SHA together (the
|
||||
# sums are in the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun
|
||||
# we ship starts on every x86-64 box — something the auto-detecting installer never promised, since
|
||||
# it reads the BUILDER's CPU, not the user's.
|
||||
ARG BUN_VERSION=1.3.14
|
||||
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
RUN curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
|
||||
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
|
||||
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
|
||||
&& install -m0755 /tmp/bun /usr/local/bin/bun \
|
||||
&& rm -f /tmp/bun.zip /tmp/bun \
|
||||
&& bun --version
|
||||
|
||||
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
|
||||
@@ -78,9 +92,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
|
||||
@@ -47,7 +47,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
||||
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||
# (n8.0 -> libavcodec 62); bump it to move FFmpeg — together with the commit SHA it is pinned to below.
|
||||
#
|
||||
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
|
||||
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
|
||||
@@ -57,16 +57,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
|
||||
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
|
||||
ARG FFMPEG_TAG=n8.0
|
||||
# The COMMIT that tag points at. A git tag is MUTABLE — upstream can move one, and unlike a branch
|
||||
# nobody would notice — and these .so's are BUNDLED into the host .deb every Ubuntu user installs.
|
||||
# The clone below asserts HEAD against this, so a moved tag fails the build loudly instead of
|
||||
# shipping. Same shape as the bun/sccache sha256 pins: a mismatch stops the build, it does not
|
||||
# silently "fix" itself. Bump alongside FFMPEG_TAG:
|
||||
# git ls-remote --tags https://github.com/FFmpeg/FFmpeg.git 'refs/tags/<new-tag>^{}'
|
||||
# Take the `^{}` line: these are ANNOTATED tags, so the bare ref is the tag OBJECT and the peeled
|
||||
# `^{}` is the commit — the commit is what a clone leaves at HEAD, and what this compares against.
|
||||
ARG FFMPEG_SHA=140fd653aed8cad774f991ba083e2d01e86420c7
|
||||
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
||||
ARG NVHDR_TAG=n12.2.72.0
|
||||
# Commit for NVHDR_TAG, asserted after checkout — see FFMPEG_SHA above for why and how to bump:
|
||||
# git ls-remote --tags https://github.com/FFmpeg/nv-codec-headers.git 'refs/tags/<new-tag>^{}'
|
||||
ARG NVHDR_SHA=c69278340ab1d5559c7d7bf0edf615dc33ddbba7
|
||||
RUN set -eux; \
|
||||
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
||||
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
||||
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
||||
test "$(git -C /tmp/nvhdr rev-parse HEAD)" = "$NVHDR_SHA" \
|
||||
|| { echo "error: nv-codec-headers $NVHDR_TAG is not $NVHDR_SHA — tag moved upstream" >&2; exit 1; }; \
|
||||
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
||||
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
||||
test "$(git -C /tmp/ffmpeg rev-parse HEAD)" = "$FFMPEG_SHA" \
|
||||
|| { echo "error: FFmpeg $FFMPEG_TAG is not $FFMPEG_SHA — tag moved upstream" >&2; exit 1; }; \
|
||||
cd /tmp/ffmpeg; \
|
||||
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
||||
--prefix=/opt/ffmpeg \
|
||||
@@ -98,9 +114,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
|
||||
+26
-5
@@ -11,7 +11,7 @@
|
||||
FROM ubuntu:26.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip extracts the pinned bun zip
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
|
||||
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
|
||||
@@ -34,8 +34,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
|
||||
# ci.yml's web/docs jobs use the oven/bun image instead, so this is only for the deb job.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
#
|
||||
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
|
||||
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb, so the installer would be
|
||||
# upstream code choosing bytes a signing job then publishes. ONE bun across the repo: same version,
|
||||
# asset and sum as deb.yml and rpm.yml — bump BUN_VERSION and BUN_SHA together (the sums are in the
|
||||
# release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we ship starts on
|
||||
# every x86-64 box — something the auto-detecting installer never promised, since it reads the
|
||||
# BUILDER's CPU, not the user's.
|
||||
ARG BUN_VERSION=1.3.14
|
||||
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
|
||||
RUN curl -fsSL -o /tmp/bun.zip \
|
||||
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
|
||||
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
|
||||
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
|
||||
&& install -m0755 /tmp/bun /usr/local/bin/bun \
|
||||
&& rm -f /tmp/bun.zip /tmp/bun \
|
||||
&& bun --version
|
||||
|
||||
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
|
||||
@@ -60,9 +74,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
# musl build: one static binary serves the Ubuntu and Fedora images alike.
|
||||
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
|
||||
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
|
||||
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
RUN curl -fsSL -o /tmp/sccache.tar.gz \
|
||||
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& rm -f /tmp/sccache.tar.gz \
|
||||
&& sccache --version
|
||||
|
||||
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.unom.punktfunk.models.PendingLinkConnect
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
|
||||
@@ -165,6 +166,36 @@ fun RequestAccessPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link that named a saved host by its label or its address rather than by its
|
||||
* stable id: both are guessable, and the activity is exported, so the dial happens on the user's
|
||||
* tap instead of on the link's say-so. A link that names the id — every shortcut Punktfunk itself
|
||||
* emits — never reaches this prompt.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkConnectPrompt(
|
||||
target: PendingLinkConnect,
|
||||
onConnect: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
title = "Open this link?",
|
||||
onDismiss = onDismiss,
|
||||
actions = listOf(
|
||||
DialogAction("Connect", primary = true, onClick = onConnect),
|
||||
DialogAction("Cancel", onClick = onDismiss),
|
||||
),
|
||||
) {
|
||||
PromptText("A link asks to connect to ${target.host.name} (${target.host.address}).")
|
||||
target.launch?.let { PromptText("It also asks the host to launch “$it”.") }
|
||||
PromptText(
|
||||
"It names the host by its label or address, which anything that can open a link " +
|
||||
"could guess. Shortcuts made in Punktfunk name the host's id and connect " +
|
||||
"without asking.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
|
||||
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
|
||||
|
||||
@@ -144,7 +144,7 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
activity.pendingDeepLink = null
|
||||
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
|
||||
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
|
||||
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
|
||||
val sameHost = target is HostResolution.Record && target.host.id == live.hostId
|
||||
if (!sameHost) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
|
||||
@@ -3,12 +3,14 @@ package io.unom.punktfunk
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingLinkConnect
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
|
||||
/**
|
||||
* Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing
|
||||
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
|
||||
* edit form, the local-network rationale, and finally the connect takeover.
|
||||
* ceremony, a link's connect confirmation, the parked "Waiting for approval…", the console's host
|
||||
* options, the speed test, the edit form, the local-network rationale, and finally the connect
|
||||
* takeover.
|
||||
*
|
||||
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
|
||||
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can
|
||||
@@ -33,6 +35,11 @@ internal fun ConnectPrompts(
|
||||
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
|
||||
onPaired: (PendingTrust, String) -> Unit,
|
||||
onRequestAccess: (PendingTrust) -> Unit,
|
||||
// ---- a link that named a saved host by a guessable reference ----------------------------
|
||||
/** Non-null while such a link waits for the OK that turns it into a plain dial. */
|
||||
pendingLinkConnect: PendingLinkConnect?,
|
||||
onConfirmLinkConnect: (PendingLinkConnect) -> Unit,
|
||||
onDismissLinkConnect: () -> Unit,
|
||||
// ---- the parked no-PIN request ----------------------------------------------------------
|
||||
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
|
||||
awaitingHostName: String?,
|
||||
@@ -89,6 +96,14 @@ internal fun ConnectPrompts(
|
||||
}
|
||||
}
|
||||
|
||||
pendingLinkConnect?.let { plc ->
|
||||
LinkConnectPrompt(
|
||||
target = plc,
|
||||
onConnect = { onConfirmLinkConnect(plc) },
|
||||
onDismiss = onDismissLinkConnect,
|
||||
)
|
||||
}
|
||||
|
||||
awaitingHostName?.let { hostLabel ->
|
||||
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.PendingLinkConnect
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -284,6 +285,8 @@ fun ConnectScreen(
|
||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
|
||||
// request-access-or-PIN choice).
|
||||
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
|
||||
// A `punktfunk://` link that named a saved host by a guessable reference, awaiting the OK.
|
||||
var pendingLinkConnect by remember { mutableStateOf<PendingLinkConnect?>(null) }
|
||||
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host being edited (name / address / port / MAC).
|
||||
@@ -673,8 +676,10 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
|
||||
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
|
||||
is HostResolution.Known -> {
|
||||
// A saved record. Pinned AND named by its (unguessable) id is the one-click contract:
|
||||
// do exactly what tapping its card does. Named by anything a web page could guess —
|
||||
// its label, its address — the same dial waits for a tap on the confirmation.
|
||||
is HostResolution.Record -> {
|
||||
// A pin that contradicts the stored one is the link being stale or lying. Hard
|
||||
// refusal: this is the one case where doing what the card does would be wrong.
|
||||
if (link.pinConflict(resolved.host)) {
|
||||
@@ -691,6 +696,10 @@ fun ConnectScreen(
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (resolved is HostResolution.Confirm) {
|
||||
pendingLinkConnect = PendingLinkConnect(resolved.host, profileRef, link.launch)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
connect(
|
||||
resolved.host.address, resolved.host.port,
|
||||
oneOffProfile = profileRef, launch = link.launch,
|
||||
@@ -821,6 +830,15 @@ fun ConnectScreen(
|
||||
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
|
||||
},
|
||||
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
|
||||
pendingLinkConnect = pendingLinkConnect,
|
||||
onConfirmLinkConnect = { plc ->
|
||||
pendingLinkConnect = null
|
||||
connect(
|
||||
plc.host.address, plc.host.port,
|
||||
oneOffProfile = plc.profile, launch = plc.launch,
|
||||
)
|
||||
},
|
||||
onDismissLinkConnect = { pendingLinkConnect = null },
|
||||
awaitingHostName = awaiting?.target?.name,
|
||||
onCancelApproval = {
|
||||
awaiting?.cancelled?.set(true)
|
||||
|
||||
@@ -874,7 +874,7 @@ class MainActivity : ComponentActivity() {
|
||||
val url = deepLinkFrom(intent) ?: return false
|
||||
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
|
||||
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
|
||||
return target is HostResolution.Known && target.host.id == live.hostId
|
||||
return target is HostResolution.Record && target.host.id == live.hostId
|
||||
}
|
||||
|
||||
/** The host a live stream is on — see [liveStream]. */
|
||||
|
||||
@@ -331,10 +331,11 @@ object SkiaConsole {
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
|
||||
* (the same dial the console's own Launch takes); anything that would need a trust decision
|
||||
* is a notice here — a link may never establish trust, and the console's Pair screen is
|
||||
* reached from the host's tile, not from a URL.
|
||||
* A `punktfunk://` link while the console is up. Named-by-id and pinned is the one-click
|
||||
* contract (the same dial the console's own Launch takes); anything that would need a trust
|
||||
* decision — or that named the host by a guessable label or address — is a notice here. A link
|
||||
* may never establish trust, the console's Pair screen is reached from the host's tile rather
|
||||
* than from a URL, and the console draws no prompt this shell could ask a question through.
|
||||
*/
|
||||
fun handleDeepLink(url: String) {
|
||||
if (handle == 0L) return
|
||||
@@ -357,7 +358,7 @@ object SkiaConsole {
|
||||
}
|
||||
}
|
||||
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
|
||||
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
|
||||
is io.unom.punktfunk.kit.link.HostResolution.Record -> {
|
||||
val kh = resolved.host
|
||||
if (link.pinConflict(kh)) {
|
||||
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
|
||||
@@ -367,6 +368,10 @@ object SkiaConsole {
|
||||
notice("Pair with ${kh.name} first — a link can't establish trust.")
|
||||
return
|
||||
}
|
||||
if (resolved is io.unom.punktfunk.kit.link.HostResolution.Confirm) {
|
||||
notice("A link can only dial ${kh.name} by its id — open it from the list.")
|
||||
return
|
||||
}
|
||||
launch(
|
||||
JSONObject()
|
||||
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
|
||||
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
|
||||
enum class Tab(val label: String, val icon: ImageVector) {
|
||||
@@ -37,6 +38,22 @@ data class PendingTrust(
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||
}
|
||||
|
||||
/**
|
||||
* A `punktfunk://` link that named a saved host by something GUESSABLE — its display name or its
|
||||
* address — instead of by its stable record id, waiting for the user's OK before it dials.
|
||||
*
|
||||
* MainActivity is exported with a BROWSABLE `punktfunk://` filter, so any app or web page can emit
|
||||
* `punktfunk://connect/Gaming%20PC?launch=steam:570`; guessing a label must not be enough to start
|
||||
* a stream and boot a game. A link that names the record id (the shortcuts this app emits) still
|
||||
* connects on its own. [profile] and [launch] are the link's, carried across the detour exactly as
|
||||
* [PendingTrust] carries them.
|
||||
*/
|
||||
data class PendingLinkConnect(
|
||||
val host: KnownHost,
|
||||
val profile: String? = null,
|
||||
val launch: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A stream session that just opened, and the state the stream screen needs about it.
|
||||
*
|
||||
|
||||
@@ -154,15 +154,22 @@ object DeepLinks {
|
||||
|
||||
/**
|
||||
* Resolve a link's host reference against the local store, in the documented order: stable
|
||||
* record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
|
||||
* the recovery path — a self-emitted shortcut that outlived the record it was written from
|
||||
* still lands on the right box (degraded to the confirmation sheet).
|
||||
* record id → unique case-insensitive name → `addr[:port]` literal, then the `host=` recovery
|
||||
* parameter — a self-emitted shortcut that outlived the record it was written from still lands
|
||||
* on the right box.
|
||||
*
|
||||
* Only the record id is UNGUESSABLE, so only the record id resolves to [HostResolution.Known],
|
||||
* the silent one-click contract. A display name comes from an mDNS instance name or a user
|
||||
* label ("Gaming PC"), and an address is a LAN address: any zero-permission app or web page can
|
||||
* emit `punktfunk://connect/Gaming%20PC` and would otherwise start a stream (and launch a
|
||||
* title) on a guess. Those all resolve to [HostResolution.Confirm] — the same host, behind the
|
||||
* user's OK.
|
||||
*/
|
||||
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
|
||||
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
|
||||
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
|
||||
when (byName.size) {
|
||||
1 -> return HostResolution.Known(byName[0])
|
||||
1 -> return HostResolution.Confirm(byName[0])
|
||||
0 -> Unit
|
||||
else -> return HostResolution.Ambiguous
|
||||
}
|
||||
@@ -173,7 +180,7 @@ object DeepLinks {
|
||||
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
|
||||
for ((addr, port) in listOfNotNull(literal, link.host)) {
|
||||
hosts.firstOrNull { it.address == addr && it.port == port }
|
||||
?.let { return HostResolution.Known(it) }
|
||||
?.let { return HostResolution.Confirm(it) }
|
||||
}
|
||||
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
|
||||
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
|
||||
@@ -429,8 +436,23 @@ sealed interface DeepLinkResult {
|
||||
|
||||
/** What the local host store made of a link's references. */
|
||||
sealed interface HostResolution {
|
||||
/** A record we already trust (subject to [DeepLink.pinConflict]). */
|
||||
data class Known(val host: KnownHost) : HostResolution
|
||||
/** A saved record — [Known] may act on its own, [Confirm] only once the user says so. */
|
||||
sealed interface Record : HostResolution {
|
||||
val host: KnownHost
|
||||
}
|
||||
|
||||
/**
|
||||
* A record we already trust, named by its stable (unguessable) id: the one-click contract,
|
||||
* subject to [DeepLink.pinConflict].
|
||||
*/
|
||||
data class Known(override val host: KnownHost) : Record
|
||||
|
||||
/**
|
||||
* The same record, but named by something GUESSABLE — its display name, its address, or the
|
||||
* `host=` recovery parameter. A link may not start a stream on a guess, so this one goes to
|
||||
* the confirmation the front-end shows: same dial, one tap later.
|
||||
*/
|
||||
data class Confirm(override val host: KnownHost) : Record
|
||||
|
||||
/**
|
||||
* No record, but the link says where to dial: the confirmation sheet's input, from which the
|
||||
|
||||
@@ -70,7 +70,9 @@ class DeepLinkVectorTest {
|
||||
* Resolution and emission — the half the vector file can't cover, because it depends on what is in
|
||||
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
|
||||
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
|
||||
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
|
||||
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying. Only the id — the
|
||||
* one reference nothing can guess — dials on its own; a name or an address resolves to the same
|
||||
* host behind a confirmation.
|
||||
*/
|
||||
class DeepLinkResolutionTest {
|
||||
private val fp = "a".repeat(64)
|
||||
@@ -86,20 +88,47 @@ class DeepLinkResolutionTest {
|
||||
|
||||
@Test
|
||||
fun idBeatsNameBeatsAddress() {
|
||||
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Record).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Record).host)
|
||||
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Record).host)
|
||||
assertEquals(
|
||||
desk,
|
||||
(resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Record).host,
|
||||
)
|
||||
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
|
||||
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The record id is a UUID nothing can guess; a display name ("Gaming PC") and a LAN address are
|
||||
* guesses any web page can make. So the id — and only the id — is the silent one-click dial;
|
||||
* everything else that finds a saved host stops at [HostResolution.Confirm].
|
||||
*/
|
||||
@Test
|
||||
fun onlyTheRecordIdDialsWithoutAsking() {
|
||||
assertEquals(HostResolution.Known(desk), resolve("punktfunk://connect/${desk.id}"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/desk"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/DESK"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50"))
|
||||
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50:9777"))
|
||||
// …including the `host=` recovery path, exactly as its own doc always claimed.
|
||||
assertEquals(
|
||||
HostResolution.Confirm(desk),
|
||||
resolve("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
|
||||
)
|
||||
// A launch id doesn't buy a name any authority it didn't have.
|
||||
assertEquals(
|
||||
HostResolution.Confirm(desk),
|
||||
resolve("punktfunk://connect/desk?launch=steam:570"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleIdRecoversThroughTheHostParameter() {
|
||||
val stale = "00000000-0000-4000-8000-000000000000"
|
||||
assertEquals(
|
||||
desk,
|
||||
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
|
||||
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Record).host,
|
||||
)
|
||||
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
|
||||
// rather than the recovery the grammar specifies.
|
||||
|
||||
@@ -24,8 +24,9 @@ struct ContentView: View {
|
||||
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
|
||||
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
|
||||
// The dev auto-connect hook (DEBUG-only — see `autoConnectIfAsked`) writes these three, so
|
||||
// they stay observed here; every OTHER stream setting reaches a session through
|
||||
// `EffectiveSettings`, resolved once per connect.
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -52,6 +53,29 @@ struct ContentView: View {
|
||||
/// a live session is already up. Surfaced as an informational alert (distinct from the
|
||||
/// "Connection failed" one, which is for actual connect errors).
|
||||
@State private var deepLinkNotice: String?
|
||||
/// A `punktfunk://` deep link that named a saved host by something GUESSABLE — its display
|
||||
/// name, its address, or the `host=` recovery parameter — instead of by its stable record id.
|
||||
/// Anything that can open a URL can guess "Gaming PC", so the link's action waits for this
|
||||
/// confirmation; a link that names the id (every shortcut this app emits) still runs on its own.
|
||||
private struct DeepLinkConfirm {
|
||||
let host: StoredHost
|
||||
let launch: String?
|
||||
let profile: ProfileSelection
|
||||
/// A `browse` link: open the host's library instead of dialing it.
|
||||
let browse: Bool
|
||||
|
||||
var actionTitle: String { browse ? "Open Library" : "Connect" }
|
||||
var message: String {
|
||||
let asked = browse
|
||||
? "open \(host.displayName)'s game library"
|
||||
: "connect to \(host.displayName)"
|
||||
+ (launch.map { " and launch \u{201C}\($0)\u{201D}" } ?? "")
|
||||
return "A link asked to \(asked). It names the host by its label or address, which "
|
||||
+ "anything that can open a link could guess — a shortcut made in Punktfunk names "
|
||||
+ "the host's id and opens without asking."
|
||||
}
|
||||
}
|
||||
@State private var deepLinkConfirm: DeepLinkConfirm?
|
||||
#if os(iOS)
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@@ -193,6 +217,29 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(deepLinkNotice ?? "")
|
||||
}
|
||||
// A link that named a saved host by a guessable reference: the dial (or the library)
|
||||
// happens on the user's word rather than on the link's.
|
||||
.alert(
|
||||
"Open this link?",
|
||||
isPresented: deepLinkConfirmPresented,
|
||||
presenting: deepLinkConfirm
|
||||
) { confirm in
|
||||
Button(confirm.actionTitle) { runDeepLinkConfirm(confirm) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { confirm in
|
||||
Text(confirm.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmed link's action: exactly what a `.known` (id-referenced) link would have done,
|
||||
/// one tap later.
|
||||
private func runDeepLinkConfirm(_ confirm: DeepLinkConfirm) {
|
||||
deepLinkConfirm = nil
|
||||
if confirm.browse {
|
||||
libraryTarget = LibraryTarget(host: confirm.host, profile: confirm.profile)
|
||||
} else {
|
||||
connect(confirm.host, launchID: confirm.launch, profile: confirm.profile)
|
||||
}
|
||||
}
|
||||
|
||||
private var driven: some View {
|
||||
@@ -482,6 +529,12 @@ struct ContentView: View {
|
||||
set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
private var deepLinkConfirmPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { deepLinkConfirm != nil && !consolePromptShowing },
|
||||
set: { if !$0 { deepLinkConfirm = nil } })
|
||||
}
|
||||
|
||||
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
|
||||
/// tvOS, whose alerts the focus engine drives natively.
|
||||
private var consolePromptShowing: Bool {
|
||||
@@ -558,6 +611,20 @@ struct ContentView: View {
|
||||
},
|
||||
])
|
||||
}
|
||||
if let confirm = deepLinkConfirm {
|
||||
return GamepadPrompt(
|
||||
id: "link-confirm",
|
||||
title: "Open this link?",
|
||||
message: confirm.message,
|
||||
actions: [
|
||||
GamepadPromptAction(id: "go", title: confirm.actionTitle, isPrimary: true) {
|
||||
runDeepLinkConfirm(confirm)
|
||||
},
|
||||
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||
deepLinkConfirm = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
if let notice = deepLinkNotice {
|
||||
return GamepadPrompt(
|
||||
id: "cant-open",
|
||||
@@ -654,11 +721,13 @@ struct ContentView: View {
|
||||
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
|
||||
/// `fp`/`host` recovery parameters and a one-off `profile`.
|
||||
///
|
||||
/// The security posture is the parser's plus three rules that live here, and none of them
|
||||
/// The security posture is the parser's plus four rules that live here, and none of them
|
||||
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
|
||||
/// confirmation, not a connect), never preempts a live session (same host → focus, different
|
||||
/// host → say so; NEVER tear one down on a background tap), and carries only references — a
|
||||
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
|
||||
/// confirmation, not a connect), never dials on a GUESSABLE reference (only the stable record
|
||||
/// id connects unattended — a label or an address becomes a confirmation), never preempts a
|
||||
/// live session (same host → focus, different host → say so; NEVER tear one down on a
|
||||
/// background tap), and carries only references — a profile it can't honor refuses with a
|
||||
/// notice rather than streaming with the wrong settings.
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
let link: DeepLink
|
||||
do {
|
||||
@@ -703,8 +772,12 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
}
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
let resolution = link.resolveHost(in: store.hosts)
|
||||
switch resolution {
|
||||
// A saved record. `.known` (named by its unguessable id) dials straight away; `.confirm`
|
||||
// (named by its label or its address, which anything that can open a URL could guess)
|
||||
// takes the same dial one tap later.
|
||||
case .known(let host), .confirm(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
@@ -718,10 +791,19 @@ struct ContentView: View {
|
||||
}
|
||||
return // deep-linked to the host we're already on — nothing to do
|
||||
}
|
||||
if case .confirm = resolution {
|
||||
deepLinkConfirm = DeepLinkConfirm(
|
||||
host: host, launch: link.launch, profile: selection, browse: false)
|
||||
return
|
||||
}
|
||||
connect(host, launchID: link.launch, profile: selection)
|
||||
case .unknown(let address, let port, let name, let fp):
|
||||
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
|
||||
// the user makes the trust decision with their eyes on it.
|
||||
// Never a silent connect — an unsaved host is a trust decision, and a link is not
|
||||
// where it gets made. This only NAMES what the link pointed at; adding the host is a
|
||||
// deliberate trip to the + button, where the fingerprint is on screen. (Linux, Android
|
||||
// and Windows instead pre-fill their trust prompt from the link; the outcome is the
|
||||
// same — nothing connects until a person looks at it — but the sheet is not seeded
|
||||
// here, so don't read this as doing that.)
|
||||
guard model.phase == .idle else {
|
||||
deepLinkNotice = "Already streaming. End that session first."
|
||||
return
|
||||
@@ -765,8 +847,10 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
}
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
let resolution = link.resolveHost(in: store.hosts)
|
||||
switch resolution {
|
||||
// Same rule as a connect link: only the record id opens on the link's own say-so.
|
||||
case .known(let host), .confirm(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
@@ -780,6 +864,11 @@ struct ContentView: View {
|
||||
}
|
||||
return // browsing the host we're already streaming — nothing to do
|
||||
}
|
||||
if case .confirm = resolution {
|
||||
deepLinkConfirm = DeepLinkConfirm(
|
||||
host: host, launch: nil, profile: selection, browse: true)
|
||||
return
|
||||
}
|
||||
libraryTarget = LibraryTarget(host: host, profile: selection)
|
||||
case .unknown(let address, _, let name, _):
|
||||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||||
@@ -1425,7 +1514,12 @@ struct ContentView: View {
|
||||
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope|… overrides the
|
||||
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
|
||||
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
|
||||
///
|
||||
/// DEBUG-ONLY, and compiled out of a release build: it streams to whatever host an
|
||||
/// environment variable names with the trust prompt auto-confirmed, which is a dev lever
|
||||
/// (`swift run`, the shot harness), never something a shipped app should answer to.
|
||||
private func autoConnectIfAsked() {
|
||||
#if DEBUG
|
||||
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
|
||||
!target.isEmpty, model.phase == .idle
|
||||
else { return }
|
||||
@@ -1460,5 +1554,6 @@ struct ContentView: View {
|
||||
effective.bitrateKbps = v
|
||||
}
|
||||
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
|
||||
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
|
||||
// fallback for unsigned builds — see `query(dataProtection:)`). The certificate's fingerprint is how
|
||||
// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from
|
||||
// every host, so the pair is presented on every connect but never regenerated once
|
||||
// stored. That invariant drives the error handling below: a Keychain that *refuses
|
||||
// access* (locked, ACL denied) is an error, not a first run — minting a replacement
|
||||
// would silently shadow the durable identity and break every existing pairing.
|
||||
// generated once and stored in the data-protection Keychain, this-device-only (with a legacy
|
||||
// file-keychain fallback for unsigned builds — see `query(dataProtection:)`). The certificate's
|
||||
// fingerprint is how hosts recognize this client after PIN pairing — losing the key un-pairs this
|
||||
// Mac from every host, so the pair is presented on every connect but never regenerated once
|
||||
// stored (and never leaves this device: see `add`). That invariant drives the error handling
|
||||
// below: a Keychain that *refuses access* (locked, ACL denied) is an error, not a first run —
|
||||
// minting a replacement would silently shadow the durable identity and break every existing
|
||||
// pairing.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
@@ -115,6 +116,16 @@ final class ClientIdentityStore: @unchecked Sendable {
|
||||
if case .denied(errSecMissingEntitlement) = result {
|
||||
return read(dataProtection: false)
|
||||
}
|
||||
// An item added before the this-device-only switch keeps the accessibility class it was
|
||||
// added with — it would keep riding backups forever, because the identity is never
|
||||
// regenerated. Re-stamp it on the way past (best-effort: a refusal just leaves the old
|
||||
// class, and the identity still reads).
|
||||
if case .found = result {
|
||||
SecItemUpdate(
|
||||
Self.query(dataProtection: true) as CFDictionary,
|
||||
[kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
|
||||
as CFDictionary)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -141,9 +152,12 @@ final class ClientIdentityStore: @unchecked Sendable {
|
||||
else { return errSecParam }
|
||||
var add = Self.query(dataProtection: true)
|
||||
add[kSecValueData as String] = data
|
||||
// After-first-unlock so a background reconnect can still read it; the access-group
|
||||
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
// After-first-unlock so a background reconnect can still read it, THIS DEVICE ONLY so it
|
||||
// never rides an encrypted backup or a device migration: this key is the whole credential
|
||||
// a host pairs with, and a restored backup would silently re-pair the restoring device
|
||||
// with every host. The access-group entitlement (not a per-binary ACL) gates it, so it
|
||||
// still survives rebuilds prompt-free.
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecMissingEntitlement else { return status }
|
||||
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
|
||||
|
||||
@@ -327,16 +327,22 @@ public struct DeepLink: Equatable, Sendable {
|
||||
}
|
||||
|
||||
/// Resolve this link's host reference against the local store, in the documented order:
|
||||
/// stable record id → unique case-insensitive name → `addr[:port]` literal. The `host=`
|
||||
/// parameter is the recovery path — a self-emitted shortcut that outlived the record it was
|
||||
/// written from still lands on the right box (degraded to the confirmation sheet).
|
||||
/// stable record id → unique case-insensitive name → `addr[:port]` literal, then the `host=`
|
||||
/// recovery path — a self-emitted shortcut that outlived the record it was written from still
|
||||
/// lands on the right box.
|
||||
///
|
||||
/// Only the record id is UNGUESSABLE, so only the record id resolves to `.known`, the silent
|
||||
/// one-click contract. A display name is an mDNS instance name or a user label ("Gaming PC")
|
||||
/// and an address is a LAN address: anything that can open a URL can guess those, and a guess
|
||||
/// must not be able to start a stream (or launch a title). They resolve to `.confirm` — the
|
||||
/// same host, behind the user's OK.
|
||||
public func resolveHost(in hosts: [StoredHost]) -> HostResolution {
|
||||
let reference = hostRef.lowercased()
|
||||
if let match = hosts.first(where: { $0.id.uuidString.lowercased() == reference }) {
|
||||
return .known(match)
|
||||
}
|
||||
let byName = hosts.filter { !$0.name.isEmpty && $0.name.lowercased() == reference }
|
||||
if byName.count == 1 { return .known(byName[0]) }
|
||||
if byName.count == 1 { return .confirm(byName[0]) }
|
||||
if byName.count > 1 { return .ambiguous }
|
||||
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
|
||||
// other per-host lookup in the client matches. The literal is only considered when the
|
||||
@@ -347,7 +353,7 @@ public struct DeepLink: Equatable, Sendable {
|
||||
if let match = hosts.first(where: {
|
||||
$0.address == candidate.address && $0.port == candidate.port
|
||||
}) {
|
||||
return .known(match)
|
||||
return .confirm(match)
|
||||
}
|
||||
}
|
||||
guard let target = literal ?? host else { return .unresolvable }
|
||||
@@ -356,8 +362,13 @@ public struct DeepLink: Equatable, Sendable {
|
||||
|
||||
/// What the local host store made of a link's references.
|
||||
public enum HostResolution: Equatable, Sendable {
|
||||
/// A record we already have (subject to `pinConflict`).
|
||||
/// A record we already have, named by its stable (unguessable) id: the one-click contract
|
||||
/// (subject to `pinConflict`).
|
||||
case known(StoredHost)
|
||||
/// The same record, named by something GUESSABLE — its display name, its address, or the
|
||||
/// `host=` recovery parameter. A link may not act on a guess, so this one waits for the
|
||||
/// user's confirmation; past that it is the `.known` path exactly.
|
||||
case confirm(StoredHost)
|
||||
/// No record, but the link says where to dial: the confirmation sheet's input, from which
|
||||
/// the normal pairing flow proceeds under the user's eyes. Never an auto-connect.
|
||||
case unknown(address: String, port: UInt16, name: String?, fp: String?)
|
||||
|
||||
@@ -207,7 +207,8 @@ final class SharedFoundationTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// Resolution order — id beats a unique name beats an address — plus the two refusals a
|
||||
/// front-end must surface rather than guess through.
|
||||
/// front-end must surface rather than guess through, and the rule that keeps a guessable
|
||||
/// reference from dialing: only the record id resolves to `.known`.
|
||||
func testDeepLinkHostResolution() throws {
|
||||
let desk = StoredHost(
|
||||
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
|
||||
@@ -222,14 +223,21 @@ final class SharedFoundationTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
try resolve("punktfunk://connect/11111111-2222-4333-8444-555555555555"), .known(desk))
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .known(desk))
|
||||
// The id is a UUID nothing can guess; a display name and a LAN address are guesses any web
|
||||
// page can make. So the id — and only the id — dials unattended; everything else that finds
|
||||
// a saved host stops at the confirmation.
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .confirm(desk))
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/DESK"), .confirm(desk))
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/couch"), .ambiguous)
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .known(desk))
|
||||
// A stale id with the recovery parameter: the address finds the record anyway.
|
||||
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .confirm(desk))
|
||||
XCTAssertEqual(
|
||||
try resolve("punktfunk://connect/desk?launch=steam:570"), .confirm(desk))
|
||||
// A stale id with the recovery parameter: the address finds the record anyway — and, being
|
||||
// an address, behind the confirmation exactly as its own doc always said.
|
||||
XCTAssertEqual(
|
||||
try resolve(
|
||||
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
|
||||
.known(desk))
|
||||
.confirm(desk))
|
||||
// Nothing local matches: the sheet gets the address, the claimed name and the pin — which
|
||||
// is what makes a first connect verified rather than blind trust-on-first-use.
|
||||
XCTAssertEqual(
|
||||
|
||||
+88
-20
@@ -50,7 +50,7 @@ mod cli {
|
||||
punktfunk — the Punktfunk client, headless
|
||||
|
||||
punktfunk discover [--json] [--timeout SECS]
|
||||
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
|
||||
punktfunk pair <host[:port]> [--pin N|-] [--name LABEL]
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
punktfunk hosts forget <host-ref>
|
||||
@@ -58,7 +58,7 @@ punktfunk — the Punktfunk client, headless
|
||||
punktfunk library <host-ref> [--json]
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
|
||||
[--exec] [--fullscreen]
|
||||
punktfunk open <punktfunk://…>
|
||||
punktfunk open <punktfunk://…> [--yes]
|
||||
punktfunk reachable <host-ref>
|
||||
punktfunk speed-test <host-ref>
|
||||
punktfunk profiles list [--json]
|
||||
@@ -98,7 +98,10 @@ address with `punktfunk hosts add` and it shows in `hosts list --probe`."
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
|
||||
--pin N the PIN the host is showing; without it the command asks, and
|
||||
refuses (exit 6) when there is no terminal to ask on
|
||||
refuses (exit 6) when there is no terminal to ask on. The value
|
||||
sits on argv, which every local user can read (/proc/*/cmdline)
|
||||
--pin - read the PIN from stdin instead (one line) — what a script or
|
||||
another program should use, so the secret never hits argv
|
||||
--name LABEL the label the host files this device under
|
||||
(default: this machine's name)
|
||||
|
||||
@@ -187,7 +190,12 @@ Same parser and same refusal rules as clicking the link in a shell: a
|
||||
contradicted fingerprint refuses and says so, an ambiguous name refuses
|
||||
rather than guessing, and an unknown host is never trusted from a URL —
|
||||
that is a decision for a person, at a surface that can show the fingerprint
|
||||
(exit 6 points at `punktfunk pair`). --exec as in launch."
|
||||
(exit 6 points at `punktfunk pair`). --exec as in launch.
|
||||
|
||||
A link that names its host by the stable record id opens straight away. One
|
||||
that names it by label or address is a guess anything could make, so it asks
|
||||
first; --yes answers for a script, and without a terminal it refuses (exit 6)
|
||||
rather than opening unasked."
|
||||
}
|
||||
"reachable" => {
|
||||
"\
|
||||
@@ -272,6 +280,11 @@ from the config directory for a true factory reset."
|
||||
/// Resolve a host reference the way every other surface does: stable id, then a unique
|
||||
/// name, then `addr[:port]` (design/client-deep-links.md §2). Sharing `resolve_host` is
|
||||
/// what keeps `punktfunk launch desk` and `punktfunk://connect/desk` from disagreeing.
|
||||
///
|
||||
/// [`HostResolution::Confirm`] — a guessable reference — is accepted WITHOUT a prompt here,
|
||||
/// and only here: this reference is an argument the user typed in their own terminal, so
|
||||
/// there is nobody else to confirm it with. The guessable-reference rule exists for URLs
|
||||
/// handed to us by someone else; that path is `open`, which does ask.
|
||||
fn resolve(reference: &str) -> Result<(KnownHosts, usize), u8> {
|
||||
let known = KnownHosts::load();
|
||||
let link = DeepLink {
|
||||
@@ -279,7 +292,7 @@ from the config directory for a true factory reset."
|
||||
..Default::default()
|
||||
};
|
||||
match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => Ok((known, i)),
|
||||
HostResolution::Known(i) | HostResolution::Confirm(i) => Ok((known, i)),
|
||||
HostResolution::Ambiguous => {
|
||||
eprintln!(
|
||||
"more than one saved host is called \"{reference}\" — use its address or id"
|
||||
@@ -459,31 +472,34 @@ from the config directory for a true factory reset."
|
||||
})
|
||||
}
|
||||
|
||||
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// `pair <host[:port]> [--pin N|-]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
|
||||
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
|
||||
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
|
||||
/// forever is worse than an exit code.
|
||||
///
|
||||
/// `--pin -` reads the PIN from stdin instead. A value on argv is readable by every local
|
||||
/// user (`/proc/*/cmdline` is world-readable on every distro we target) and the PIN is the
|
||||
/// only secret binding the ceremony to the operator's intent, so programmatic callers — the
|
||||
/// Decky backend among them — pipe it in rather than spelling it on the command line.
|
||||
fn pair(args: &[String]) -> u8 {
|
||||
let Some(target) = positional(args, 0) else {
|
||||
eprintln!("usage: punktfunk pair <host[:port]> [--pin N]");
|
||||
eprintln!("usage: punktfunk pair <host[:port]> [--pin N|-]");
|
||||
return UNRESOLVED;
|
||||
};
|
||||
let (addr, port) = split_host_port(&target);
|
||||
let pin = match value(args, "--pin") {
|
||||
Some(p) => p,
|
||||
let pin = match value(args, "--pin").as_deref() {
|
||||
Some("-") => read_pin(None),
|
||||
Some(p) => Some(p.to_string()),
|
||||
None if is_tty() => read_pin(Some(&addr)),
|
||||
None => {
|
||||
if !is_tty() {
|
||||
eprintln!("no --pin and no terminal to ask on");
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
eprint!("PIN shown on {addr}: ");
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line).is_err() {
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
line.trim().to_string()
|
||||
eprintln!("no --pin and no terminal to ask on");
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
};
|
||||
let Some(pin) = pin else {
|
||||
eprintln!("no PIN on stdin");
|
||||
return NEEDS_INTERACTION;
|
||||
};
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
@@ -898,6 +914,31 @@ from the config directory for a true factory reset."
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
|
||||
// The link named the host by something GUESSABLE — its label, its address — rather
|
||||
// than by its record id. A URL handed to us by someone else may not dial on a guess,
|
||||
// so a person says yes first. `--yes` is the scripted escape (and the only way in
|
||||
// without a terminal to ask on).
|
||||
Ok(PlanOutcome::ConfirmConnect(plan)) => {
|
||||
if !has(args, "--yes") {
|
||||
if !is_tty() {
|
||||
eprintln!(
|
||||
"that link names {} by label or address, not by its id — re-run with \
|
||||
--yes to open it",
|
||||
plan.host.name
|
||||
);
|
||||
return NEEDS_INTERACTION;
|
||||
}
|
||||
eprint!("Connect to {} ({})? [y/N] ", plan.host.name, plan.host.addr);
|
||||
let mut line = String::new();
|
||||
if std::io::stdin().read_line(&mut line).is_err()
|
||||
|| !line.trim().eq_ignore_ascii_case("y")
|
||||
{
|
||||
eprintln!("cancelled");
|
||||
return OK;
|
||||
}
|
||||
}
|
||||
run_plan(*plan, has(args, "--exec"), false)
|
||||
}
|
||||
// A URL may never pair or trust on its own — that is a decision for a person, at a
|
||||
// surface that can show them the fingerprint.
|
||||
Ok(PlanOutcome::ConfirmUnknown(u)) => {
|
||||
@@ -1049,7 +1090,11 @@ from the config directory for a true factory reset."
|
||||
..Default::default()
|
||||
};
|
||||
let (addr, port) = match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
// Nothing is dialled and no title is launched, so a guessable reference needs no
|
||||
// confirmation — it only picks which address to send one probe packet to.
|
||||
HostResolution::Known(i) | HostResolution::Confirm(i) => {
|
||||
(known.hosts[i].addr.clone(), known.hosts[i].port)
|
||||
}
|
||||
_ => split_host_port(&reference),
|
||||
};
|
||||
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
@@ -1220,6 +1265,19 @@ from the config directory for a true factory reset."
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
|
||||
/// One line of PIN from stdin — prompted when we're asking a person, silent for `--pin -`
|
||||
/// (a pipe from another program). `None` on a read error or an empty line (EOF), which the
|
||||
/// caller turns into [`NEEDS_INTERACTION`] rather than sending an empty PIN to the host.
|
||||
fn read_pin(prompt_for: Option<&str>) -> Option<String> {
|
||||
if let Some(addr) = prompt_for {
|
||||
eprint!("PIN shown on {addr}: ");
|
||||
}
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line).ok()?;
|
||||
let pin = line.trim();
|
||||
(!pin.is_empty()).then(|| pin.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1250,6 +1308,16 @@ from the config directory for a true factory reset."
|
||||
Some("10.0.0.1".into())
|
||||
);
|
||||
assert_eq!(positional(&argv(&["--json"]), 0), None);
|
||||
// `--pin -` (the PIN comes down stdin, never argv): the lone dash is that flag's
|
||||
// VALUE, not the host to pair with.
|
||||
assert_eq!(
|
||||
positional(&argv(&["--pin", "-", "desk"]), 0),
|
||||
Some("desk".into())
|
||||
);
|
||||
assert_eq!(
|
||||
value(&argv(&["desk", "--pin", "-"]), "--pin"),
|
||||
Some("-".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-4
@@ -407,11 +407,17 @@ def _cli_argv() -> list[str] | None:
|
||||
return [str(sibling)] if sibling.exists() else None
|
||||
|
||||
|
||||
async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
|
||||
async def _run_cli(
|
||||
args: list[str], timeout: float = 20.0, stdin_text: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout
|
||||
is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would
|
||||
corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out.
|
||||
|
||||
``stdin_text`` is written to the child and the pipe closed — the way a secret reaches the CLI,
|
||||
because argv does not qualify: ``/proc/*/cmdline`` is world-readable, so every process on the
|
||||
Deck can read a flag's value. See :meth:`Plugin.pair`.
|
||||
|
||||
The same ``_flatpak_env`` repair the client runs needed applies here unchanged — Decky's
|
||||
PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the
|
||||
sandbox is being started."""
|
||||
@@ -422,10 +428,12 @@ async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, st
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*prefix, *args,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
payload = stdin_text.encode() if stdin_text is not None else None
|
||||
out, err = await asyncio.wait_for(proc.communicate(payload), timeout=timeout)
|
||||
rc = proc.returncode if proc.returncode is not None else -1
|
||||
return (
|
||||
rc,
|
||||
@@ -761,21 +769,27 @@ class Plugin:
|
||||
return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0)
|
||||
|
||||
async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
|
||||
"""The PIN ceremony (``punktfunk pair <addr:port> --pin N --name LABEL``).
|
||||
"""The PIN ceremony (``punktfunk pair <addr:port> --pin - --name LABEL``).
|
||||
|
||||
The operator arms pairing on the host, which shows a 4-digit PIN; entering it here
|
||||
verifies the host end to end and pins its fingerprint, so every later connect is silent.
|
||||
``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host
|
||||
that isn't armed, and ``unreachable`` is a host that never answered.
|
||||
|
||||
The PIN goes down the child's STDIN (``--pin -``), never on argv: it is the only secret
|
||||
binding the ceremony to the operator's intent, and a value on the command line is readable
|
||||
by every local process for as long as the call runs (~100 s here) — long enough for anyone
|
||||
on the Deck to complete the pairing with their own keypair instead.
|
||||
|
||||
The budget is generous because the ceremony waits on a person at the other end."""
|
||||
rc, out, err = await _run_cli(
|
||||
[
|
||||
"pair", f"{addr}:{int(port)}",
|
||||
"--pin", str(pin).strip(),
|
||||
"--pin", "-",
|
||||
"--name", name,
|
||||
],
|
||||
timeout=100.0,
|
||||
stdin_text=f"{str(pin).strip()}\n",
|
||||
)
|
||||
if rc == 0:
|
||||
fp = ""
|
||||
|
||||
@@ -612,10 +612,10 @@ impl AppModel {
|
||||
}
|
||||
|
||||
/// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile
|
||||
/// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is
|
||||
/// only the GTK end of it — turn the outcome into the same messages a card click raises,
|
||||
/// so a link gets the identical wake, trust and error surfaces and NOT a second connect
|
||||
/// path of its own.
|
||||
/// resolution and every refusal rule — including "only a stable record id may dial
|
||||
/// unattended" — live in the shared brain (`plan_from_link`); this is only the GTK end of
|
||||
/// it: turn the outcome into the same messages a card click raises, so a link gets the
|
||||
/// identical wake, trust and error surfaces and NOT a second connect path of its own.
|
||||
fn open_deep_link(&mut self, url: &str, sender: &ComponentSender<AppModel>) {
|
||||
use pf_client_core::deeplink;
|
||||
use pf_client_core::orchestrate::{plan_from_link, PlanOutcome};
|
||||
@@ -660,6 +660,51 @@ impl AppModel {
|
||||
AppMsg::Connect(req)
|
||||
});
|
||||
}
|
||||
Ok(PlanOutcome::ConfirmConnect(plan)) => {
|
||||
// The link named this (saved, pinned) host by its LABEL or its ADDRESS rather
|
||||
// than by its record id. `x-scheme-handler/punktfunk` is registered by our
|
||||
// .desktop, so any web page can hand us such a URL and both of those are
|
||||
// guessable — the dial waits for a person. Deliberately not the PIN ceremony
|
||||
// below: this host is already pinned, and re-pairing it would throw that away.
|
||||
if self.busy {
|
||||
return self.toast("A session is already running — end it first.");
|
||||
}
|
||||
let req = ConnectRequest {
|
||||
name: plan.host.name.clone(),
|
||||
addr: plan.host.addr.clone(),
|
||||
port: plan.host.port,
|
||||
fp_hex: plan.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
launch: plan.launch.clone().map(|id| (id.clone(), id)),
|
||||
mac: plan.host.mac.clone(),
|
||||
profile: plan.profile_override.clone(),
|
||||
};
|
||||
let mut body = format!("A link asks to connect to {} ({}).", req.name, req.addr);
|
||||
if let Some((id, _)) = &req.launch {
|
||||
body.push_str(&format!("\n\nIt also asks the host to launch “{id}”."));
|
||||
}
|
||||
body.push_str(
|
||||
"\n\nIt names the host by its label or address, which anything that can \
|
||||
open a link could guess. A link that names the host's id connects without \
|
||||
asking.",
|
||||
);
|
||||
let dialog = adw::AlertDialog::new(Some("Open this link?"), Some(&body));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]);
|
||||
dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_close_response("cancel");
|
||||
let sender = sender.clone();
|
||||
let wake = plan.wake;
|
||||
dialog.connect_response(Some("connect"), move |_, _| {
|
||||
// The same two messages the `Connect` arm raises, so the confirmed link
|
||||
// gets the identical wake / trust / error surfaces a card click gets.
|
||||
sender.input(if wake {
|
||||
AppMsg::WakeConnect(req.clone())
|
||||
} else {
|
||||
AppMsg::Connect(req.clone())
|
||||
});
|
||||
});
|
||||
dialog.present(Some(&self.window));
|
||||
}
|
||||
Ok(PlanOutcome::ConfirmUnknown(unknown)) => {
|
||||
// Known-but-unpinned, or not known at all: the link may not pair and may not
|
||||
// trust on its own, so it opens the ordinary ceremony under the user's eyes —
|
||||
|
||||
+127
-52
@@ -326,6 +326,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// later instance over WM_COPYDATA) and this poll pulls them onto the UI thread. Thread-fed
|
||||
// state must be root state, like the pad count below.
|
||||
let (deep_link, set_deep_link) = cx.use_async_state(Option::<String>::None);
|
||||
// A link that named its host by something GUESSABLE (its label, its address, the `host=`
|
||||
// recovery parameter) rather than by the stable record id: `Some(plan)` arms the "Open this
|
||||
// link?" confirmation built at the bottom of this function. The plan is byte-for-byte the one
|
||||
// an id-referenced link carries, so confirming runs the identical dial one click later. Root
|
||||
// state like every other dialog flag in this shell.
|
||||
let (link_confirm, set_link_confirm) =
|
||||
cx.use_async_state(Option::<Box<pf_client_core::orchestrate::ConnectPlan>>::None);
|
||||
cx.use_effect((), {
|
||||
let set_deep_link = set_deep_link.clone();
|
||||
move || {
|
||||
@@ -363,16 +370,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
|
||||
|
||||
// Continuous LAN discovery (spawned once).
|
||||
// Route an arriving link. Parsing, host and profile resolution and every refusal rule live
|
||||
// in the shared brain (`plan_from_link`); this is only the WinUI end — turn the outcome into
|
||||
// the same call a tile click makes, so a link gets the identical wake, trust and error
|
||||
// surfaces rather than a second connect path of its own.
|
||||
// Route an arriving link. Parsing, host and profile resolution and every refusal rule —
|
||||
// including "only a stable record id may dial unattended" — live in the shared brain
|
||||
// (`plan_from_link`); this is only the WinUI end — turn the outcome into the same call a tile
|
||||
// click makes, so a link gets the identical wake, trust and error surfaces rather than a
|
||||
// second connect path of its own.
|
||||
cx.use_effect(deep_link.clone(), {
|
||||
let (ctx, set_screen, set_status, set_deep_link) = (
|
||||
let (ctx, set_screen, set_status, set_deep_link, set_link_confirm) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
set_deep_link.clone(),
|
||||
set_link_confirm.clone(),
|
||||
);
|
||||
let screen_now = screen.clone();
|
||||
move || {
|
||||
@@ -403,41 +412,16 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
);
|
||||
use pf_client_core::orchestrate::PlanOutcome;
|
||||
match plan {
|
||||
Ok(PlanOutcome::Connect(p)) => {
|
||||
let target = Target {
|
||||
name: p.host.name.clone(),
|
||||
addr: p.host.addr.clone(),
|
||||
port: p.host.port,
|
||||
fp_hex: p.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
mac: p.host.mac.clone(),
|
||||
mgmt_port: p.host.mgmt_port,
|
||||
profile: p.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
// With a MAC it takes the dial first wake path, so a sleeping host wakes
|
||||
// instead of erroring — exactly what clicking its tile would do. The
|
||||
// link's `launch=` id must reach the session (`--launch`) — this arm used
|
||||
// to drop it, so a game link opened a plain desktop session.
|
||||
match (p.launch.clone(), p.wake && !target.mac.is_empty()) {
|
||||
(Some(id), true) => {
|
||||
connect::initiate_launch_waking(
|
||||
&ctx,
|
||||
target,
|
||||
id,
|
||||
&set_screen,
|
||||
&set_status,
|
||||
);
|
||||
}
|
||||
(Some(id), false) => {
|
||||
connect::initiate_launch(&ctx, target, id, &set_screen, &set_status);
|
||||
}
|
||||
(None, true) => {
|
||||
connect::initiate_waking(&ctx, target, &set_screen, &set_status)
|
||||
}
|
||||
(None, false) => connect::initiate(&ctx, target, &set_screen, &set_status),
|
||||
}
|
||||
}
|
||||
Ok(PlanOutcome::Connect(p)) => dial_link(&ctx, &p, &set_screen, &set_status),
|
||||
// The link named a saved, pinned host by its LABEL or its ADDRESS rather than
|
||||
// by its record id. This app registers the `punktfunk` scheme (AppxManifest's
|
||||
// windows.protocol / the installer's URL Protocol key), so any web page can
|
||||
// hand us such a URL, and both of those references are guessable — it may not
|
||||
// dial on its own. Arm the confirmation instead; OK runs `dial_link` on the
|
||||
// very same plan, so the confirmed link and an id-referenced one are one code
|
||||
// path. Deliberately NOT the PIN ceremony below: this host is already pinned,
|
||||
// and re-pairing it would throw that pin away.
|
||||
Ok(PlanOutcome::ConfirmConnect(p)) => set_link_confirm.call(Some(p)),
|
||||
// Known but never pinned, or not known at all: a link may not pair and may not
|
||||
// trust on its own, so it opens the ordinary PIN ceremony seeded with what the
|
||||
// link CLAIMED — name shown as claimed, the fingerprint pre-filling the pin so
|
||||
@@ -729,19 +713,110 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
Screen::Stream => stream::session_page(ctx, &hud),
|
||||
};
|
||||
|
||||
// The "Open this link?" confirmation for a guessable-reference link (see `link_confirm`).
|
||||
// It lives at ROOT, not on a page: a link can arrive over WM_COPYDATA while any screen is
|
||||
// up, and a WinUI ContentDialog is a popup rather than a visual child, so it rides above
|
||||
// whatever is showing. Same discipline as the shell's other dialogs — ALWAYS MOUNTED, with
|
||||
// `is_open` doing the arming, in a stable trailing slot (unmounting a ContentDialog trips
|
||||
// the reactor backend's phantom-child bookkeeping; see hosts.rs's forget confirmation).
|
||||
let link_dialog: Element = {
|
||||
let pending = link_confirm;
|
||||
// Name the host AND the game, because that is the whole point of asking: it's what
|
||||
// lets someone tell their own shortcut from a link a web page just handed them.
|
||||
let content = pending
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
let mut s = format!(
|
||||
"A link asks to connect to {} ({}).",
|
||||
p.host.name, p.host.addr
|
||||
);
|
||||
if let Some(id) = &p.launch {
|
||||
s.push_str(&format!(
|
||||
"\n\nIt also asks the host to launch \u{201c}{id}\u{201d}."
|
||||
));
|
||||
}
|
||||
s.push_str(
|
||||
"\n\nIt names the host by its label or address, which anything that can open \
|
||||
a link could guess. Shortcuts made in Punktfunk name the host's id and \
|
||||
connect without asking.",
|
||||
);
|
||||
s
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (ctx2, ss, st, sc) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
set_link_confirm.clone(),
|
||||
);
|
||||
ContentDialog::new("Open this link?")
|
||||
.content(content)
|
||||
.primary_button_text("Connect")
|
||||
.close_button_text("Cancel")
|
||||
.is_open(pending.is_some())
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
sc.call(None);
|
||||
// Cancel (and Escape, which WinUI also reports as `None`) does nothing at all.
|
||||
if r == ContentDialogResult::Primary
|
||||
&& let Some(plan) = &pending
|
||||
{
|
||||
dial_link(&ctx2, plan, &ss, &st);
|
||||
}
|
||||
})
|
||||
.into()
|
||||
};
|
||||
|
||||
// The Stream screen is a plain status card (the session child owns the real stream window);
|
||||
// it's shown without the navigation entrance tween. Everything else slides + fades in.
|
||||
if matches!(screen, Screen::Stream) {
|
||||
return body;
|
||||
let page: Element = if matches!(screen, Screen::Stream) {
|
||||
body
|
||||
} else {
|
||||
let offset = (1.0 - progress) * 22.0;
|
||||
border(body)
|
||||
.opacity(progress)
|
||||
.margin(Thickness {
|
||||
left: 0.0,
|
||||
top: offset,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.into()
|
||||
};
|
||||
grid(vec![page, link_dialog]).into()
|
||||
}
|
||||
|
||||
/// Run a resolved link plan: the same four calls a host tile's click makes, so a link gets the
|
||||
/// identical wake, trust and error surfaces rather than a second connect path of its own. Shared
|
||||
/// by the two outcomes that dial — `PlanOutcome::Connect` (the link named the stable record id)
|
||||
/// and a confirmed `PlanOutcome::ConfirmConnect` — so the confirmation is one click in front of
|
||||
/// this, never a second implementation of it.
|
||||
fn dial_link(
|
||||
ctx: &Arc<AppCtx>,
|
||||
plan: &pf_client_core::orchestrate::ConnectPlan,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
let target = Target {
|
||||
name: plan.host.name.clone(),
|
||||
addr: plan.host.addr.clone(),
|
||||
port: plan.host.port,
|
||||
fp_hex: plan.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
mac: plan.host.mac.clone(),
|
||||
mgmt_port: plan.host.mgmt_port,
|
||||
profile: plan.profile_override.clone(),
|
||||
launch: None, // routed explicitly below (initiate_launch*)
|
||||
};
|
||||
// With a MAC it takes the dial first wake path, so a sleeping host wakes instead of
|
||||
// erroring — exactly what clicking its tile would do. The link's `launch=` id must reach
|
||||
// the session (`--launch`) — this used to drop it, so a game link opened a plain desktop
|
||||
// session.
|
||||
match (plan.launch.clone(), plan.wake && !target.mac.is_empty()) {
|
||||
(Some(id), true) => {
|
||||
connect::initiate_launch_waking(ctx, target, id, set_screen, set_status);
|
||||
}
|
||||
(Some(id), false) => connect::initiate_launch(ctx, target, id, set_screen, set_status),
|
||||
(None, true) => connect::initiate_waking(ctx, target, set_screen, set_status),
|
||||
(None, false) => connect::initiate(ctx, target, set_screen, set_status),
|
||||
}
|
||||
let offset = (1.0 - progress) * 22.0;
|
||||
border(body)
|
||||
.opacity(progress)
|
||||
.margin(Thickness {
|
||||
left: 0.0,
|
||||
top: offset,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
+174
-12
@@ -1921,19 +1921,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // sps_video_parameter_set_id
|
||||
s.bits(3, 0); // sps_max_sub_layers_minus1
|
||||
s.bit(1); // sps_temporal_id_nesting_flag
|
||||
|
||||
// profile_tier_level(1, 0): general_profile_space u(2), tier u(1),
|
||||
// profile_idc u(5), 32 compatibility flags, progressive/interlaced/
|
||||
// non-packed/frame-only, 43 constraint/reserved bits (all zero for every
|
||||
// profile branch the parser takes), inbld/reserved bit, level u(8).
|
||||
/// profile_tier_level()'s general block: general_profile_space u(2), tier u(1),
|
||||
/// profile_idc u(5), 32 compatibility flags, progressive/interlaced/non-packed/
|
||||
/// frame-only, 43 constraint/reserved bits (all zero for every profile branch the
|
||||
/// parser takes), inbld/reserved bit, level u(8). The per-sub-layer tail follows
|
||||
/// only when max_sub_layers_minus1 > 0.
|
||||
fn ptl_general(s: &mut BitSink, profile_idc: u8, level_idc: u32) {
|
||||
s.bits(2, 0);
|
||||
s.bit(0);
|
||||
s.bits(5, u32::from(o.profile_idc));
|
||||
s.bits(5, u32::from(profile_idc));
|
||||
s.bits(32, 0);
|
||||
s.bit(1); // general_progressive_source_flag
|
||||
s.bit(0); // general_interlaced_source_flag
|
||||
@@ -1942,7 +1938,15 @@ mod tests {
|
||||
s.bits(31, 0);
|
||||
s.bits(12, 0); // 43 zero bits total
|
||||
s.bit(0); // general_inbld_flag / reserved
|
||||
s.bits(8, o.level_idc); // general_level_idc
|
||||
s.bits(8, level_idc); // general_level_idc
|
||||
}
|
||||
|
||||
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // sps_video_parameter_set_id
|
||||
s.bits(3, 0); // sps_max_sub_layers_minus1
|
||||
s.bit(1); // sps_temporal_id_nesting_flag
|
||||
ptl_general(&mut s, o.profile_idc, o.level_idc);
|
||||
|
||||
s.ue(0); // sps_seq_parameter_set_id
|
||||
s.ue(o.chroma_format_idc);
|
||||
@@ -3579,4 +3583,162 @@ mod tests {
|
||||
assert!(plan.picture.is_idr);
|
||||
assert!(plan.warnings.is_empty(), "{:?}", plan.warnings);
|
||||
}
|
||||
|
||||
// ------- vendored-parser bounds regressions (deviations 9-11) -------
|
||||
|
||||
const VPS_NUT: u8 = 32;
|
||||
const SPS_NUT: u8 = 33;
|
||||
const PPS_NUT: u8 = 34;
|
||||
|
||||
/// Trailing bits, so the parser keeps reading past the guarded field instead of
|
||||
/// stopping short — a truncated NALU would be an error for the wrong reason.
|
||||
fn padded(mut s: BitSink) -> Vec<u8> {
|
||||
for _ in 0..64 {
|
||||
s.bits(8, 0xff);
|
||||
}
|
||||
s.finish()
|
||||
}
|
||||
|
||||
/// Deviation 9: `{vps,sps}_max_sub_layers_minus1` is u(3), so 7 is representable,
|
||||
/// but 7.4.3.1/7.4.3.2 stop at 6 — and the sub-layer arrays the parser then walks
|
||||
/// are six and seven deep. Both param sets are now a parse error, not a panic.
|
||||
/// This is also what keeps the planner's own
|
||||
/// `max_num_reorder_pics[max_sub_layers_minus1]` reads in bounds.
|
||||
#[test]
|
||||
fn a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic() {
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // vps_video_parameter_set_id
|
||||
s.bit(1); // vps_base_layer_internal_flag
|
||||
s.bit(1); // vps_base_layer_available_flag
|
||||
s.bits(6, 0); // vps_max_layers_minus1
|
||||
s.bits(3, 7); // vps_max_sub_layers_minus1 — one past the spec's 6
|
||||
s.bit(1); // vps_temporal_id_nesting_flag
|
||||
s.bits(16, 0xffff); // vps_reserved_0xffff_16bits
|
||||
ptl_general(&mut s, 1, 120);
|
||||
let vps = h265_nalu(VPS_NUT, &padded(s));
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&vps),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
|
||||
let mut s = BitSink::new();
|
||||
s.bits(4, 0); // sps_video_parameter_set_id
|
||||
s.bits(3, 7); // sps_max_sub_layers_minus1
|
||||
s.bit(1); // sps_temporal_id_nesting_flag
|
||||
ptl_general(&mut s, 1, 120);
|
||||
let sps = h265_nalu(SPS_NUT, &padded(s));
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&sps),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// The PPS fields ahead of the two this section attacks, all zero.
|
||||
fn pps_prefix() -> BitSink {
|
||||
let mut s = BitSink::new();
|
||||
s.ue(0); // pps_pic_parameter_set_id
|
||||
s.ue(0); // pps_seq_parameter_set_id
|
||||
s.bits(2, 0); // dependent_slice_segments_enabled / output_flag_present
|
||||
s.bits(3, 0); // num_extra_slice_header_bits
|
||||
s.bits(2, 0); // sign_data_hiding_enabled / cabac_init_present
|
||||
s.ue(0); // num_ref_idx_l0_default_active_minus1
|
||||
s.ue(0); // num_ref_idx_l1_default_active_minus1
|
||||
s.se(0); // init_qp_minus26
|
||||
s.bits(3, 0); // constrained_intra_pred / transform_skip / cu_qp_delta_enabled
|
||||
s.se(0); // pps_cb_qp_offset
|
||||
s.se(0); // pps_cr_qp_offset
|
||||
s.bits(4, 0); // chroma_qp_offsets / weighted_pred / weighted_bipred / bypass
|
||||
s
|
||||
}
|
||||
|
||||
/// The PPS fields after the tile block, all zero, plus rbsp_trailing_bits().
|
||||
fn pps_tail(mut s: BitSink) -> Vec<u8> {
|
||||
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
|
||||
s.bits(2, 0); // pps_scaling_list_data_present / lists_modification_present
|
||||
s.ue(0); // log2_parallel_merge_level_minus2
|
||||
s.bits(2, 0); // slice_segment_header_extension / pps_extension_present
|
||||
s.finish()
|
||||
}
|
||||
|
||||
/// Deviation 10: equation 7-42 subtracts `scaling_list_pred_matrix_id_delta` from
|
||||
/// matrixId in u32. Unbounded, it underflows into an out-of-bounds read of the
|
||||
/// six-entry scaling lists; 7.4.5 caps it at matrixId / (sizeId == 3 ? 3 : 1).
|
||||
#[test]
|
||||
fn a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic() {
|
||||
let mut s = pps_prefix();
|
||||
s.bits(2, 0); // tiles_enabled / entropy_coding_sync_enabled
|
||||
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
|
||||
s.bit(1); // pps_scaling_list_data_present_flag
|
||||
s.bit(0); // scaling_list_pred_mode_flag[0][0]
|
||||
s.ue(1); // scaling_list_pred_matrix_id_delta[0][0] — refMatrixId = 0 - 1
|
||||
let mut au = synth_sps(&SpsOpts::default());
|
||||
au.extend(h265_nalu(PPS_NUT, &padded(s)));
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&au),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Deviation 11: the tile counts were bounded by the picture's CTB size only, so a
|
||||
/// wide-enough SPS let them run past the width and height arrays. Table A.8 caps
|
||||
/// them at 20 columns and 22 rows for every level.
|
||||
#[test]
|
||||
fn a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic() {
|
||||
// 2048x2048 luma with 64x64 CTBs: 32 CTBs each way, so the picture bound
|
||||
// alone would admit 31 tile columns and rows.
|
||||
let sps = SpsOpts {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
..Default::default()
|
||||
};
|
||||
for (columns, rows) in [(25, 0), (0, 25)] {
|
||||
let mut s = pps_prefix();
|
||||
s.bit(1); // tiles_enabled_flag
|
||||
s.bit(0); // entropy_coding_sync_enabled_flag
|
||||
s.ue(columns); // num_tile_columns_minus1
|
||||
s.ue(rows); // num_tile_rows_minus1
|
||||
s.bit(1); // uniform_spacing_flag
|
||||
let mut au = synth_sps(&sps);
|
||||
au.extend(h265_nalu(PPS_NUT, &padded(s)));
|
||||
assert!(
|
||||
matches!(H265Planner::new().plan_au(&au), Err(PlanError::Parse(_))),
|
||||
"{columns} columns / {rows} rows must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tile ceiling's two downstream sites, both reached from one slice header:
|
||||
/// the entry-point maximum multiplied the two tile counts in u8 (20 x 22 overflows
|
||||
/// it), and `entry_point_offset_minus1` is 32 deep however large that maximum is.
|
||||
#[test]
|
||||
fn a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic() {
|
||||
let sps = SpsOpts {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
..Default::default()
|
||||
};
|
||||
let mut s = pps_prefix();
|
||||
s.bit(1); // tiles_enabled_flag
|
||||
s.bit(0); // entropy_coding_sync_enabled_flag
|
||||
s.ue(19); // num_tile_columns_minus1 — Table A.8's ceiling, and legal here
|
||||
s.ue(21); // num_tile_rows_minus1
|
||||
s.bit(1); // uniform_spacing_flag
|
||||
s.bit(0); // loop_filter_across_tiles_enabled_flag
|
||||
let mut au = synth_sps(&sps);
|
||||
au.extend(h265_nalu(PPS_NUT, &pps_tail(s)));
|
||||
|
||||
let mut s = BitSink::new();
|
||||
s.bit(1); // first_slice_segment_in_pic_flag
|
||||
s.bit(0); // no_output_of_prior_pics_flag
|
||||
s.ue(0); // slice_pic_parameter_set_id
|
||||
s.ue(2); // slice_type: I
|
||||
s.se(0); // slice_qp_delta
|
||||
s.ue(35); // num_entry_point_offsets — 440 tiles would allow it, 32 slots do not
|
||||
au.extend(h265_nalu(IDR_W_RADL, &padded(s)));
|
||||
|
||||
assert!(matches!(
|
||||
H265Planner::new().plan_au(&au),
|
||||
Err(PlanError::Parse(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,5 +105,108 @@ in the future."
|
||||
AV1 has its own `read_su`, and the H.26x `se(v)` callers all pass positive widths — so
|
||||
it is left to upstream rather than widened into this deviation.
|
||||
|
||||
9. `src/codec/h265/parser.rs` — `parse_vps` and `parse_sps`: reject
|
||||
`{vps,sps}_max_sub_layers_minus1 > 6` immediately after the read. The element is
|
||||
`u(3)`, so 7 is representable, but 7.4.3.1 and 7.4.3.2 both bound it at 6 — and
|
||||
every array the parser then walks with it is sized for the spec, not for the field:
|
||||
`profile_tier_level()`'s `sub_layer_*` flags are `[_; 6]` and the sub-layer ordering
|
||||
arrays are `[_; 7]`. A ~20-byte VPS NALU with the field set to 7 panicked inside
|
||||
`parse_profile_tier_level` with an index-out-of-bounds before any picture was
|
||||
decoded, on every reconnect. Bound at the spec's 6, not at the arrays' 5/6, because
|
||||
the two agree there — a conformant stream is never refused. This is also what keeps
|
||||
punktfunk's own `sps.max_num_reorder_pics[max_sub_layers_minus1]` and
|
||||
`sps.max_dec_pic_buffering_minus1[max_sub_layers_minus1]` reads in bounds
|
||||
(`pf-bitstream` h265.rs, and the `pf-vaadec` / `pf-dxvadec` / `pf-vkdecode` picture
|
||||
builders downstream of it) — they all take their `Sps` from this parser, so the
|
||||
parse-time check is the single choke point and none of them needs its own guard.
|
||||
Regression-tested in `pf-bitstream`
|
||||
(`a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
10. `src/codec/h265/parser.rs` — `parse_scaling_list_data`: read
|
||||
`scaling_list_pred_matrix_id_delta` with `read_ue_max(matrixId / factor)` instead of
|
||||
an unbounded `read_ue`, which is exactly the range 7.4.5 permits (`0` to
|
||||
`matrixId / ( sizeId == 3 ? 3 : 1 )`). Equation 7-42 subtracts
|
||||
`delta * factor` from `matrixId` in `u32`: unbounded it underflows — a debug panic
|
||||
on the subtraction, and in release a `~4e9` index into the six-entry
|
||||
`scaling_list_{4x4,8x8,16x16,32x32}`. Reachable from a PPS with
|
||||
`pps_scaling_list_data_present_flag` set, or the equivalent SPS flag. `factor` moved
|
||||
a few lines up so the bound and equation 7-42 share one definition; the same bound
|
||||
also makes `delta * factor` unable to overflow. Regression-tested in `pf-bitstream`
|
||||
(`a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
11. `src/codec/h265/parser.rs` — the tile syntax, three edits, all one defect. `parse_pps`
|
||||
bounded `num_tile_{columns,rows}_minus1` by the picture only
|
||||
(`pic_{width,height}_in_ctbs_y - 1`, which reaches 2110 on a legal SPS) while using
|
||||
them to index `column_width_minus1` / `row_height_minus1`. A PPS on a 2048x2048 SPS
|
||||
asking for 26 tile columns panicked. Annex A is the real bound: A.4.1 requires
|
||||
`num_tile_columns_minus1 < MaxTileCols` and `num_tile_rows_minus1 < MaxTileRows`,
|
||||
and Table A.8 peaks at 20 and 22 (levels 6, 6.1, 6.2). So:
|
||||
|
||||
- new `MAX_TILE_COLUMNS` / `MAX_TILE_ROWS` consts (20, 22), and the counts are read
|
||||
with `min(picture bound, const - 1)`;
|
||||
- the arrays grew from `[u32; 19]` / `[u32; 21]` to those consts. Upstream sized them
|
||||
one short of Table A.8 — the code stores the running remainder in
|
||||
`column_width_minus1[num_tile_columns_minus1]`, so the last tile needs a slot —
|
||||
which means bounding at the arrays would have refused a conformant 20-column
|
||||
stream. Growing by one entry each costs nothing and lets the guard be the spec's
|
||||
number rather than an implementation artefact. The upstream test asserting
|
||||
`[0; 19]` / `[0; 21]` follows the consts now.
|
||||
|
||||
Raising the ceiling to the spec's exposed two further panics downstream, in
|
||||
`parse_slice_header`'s entry-point block (both reachable before this change too, at
|
||||
the arrays' old 19x21 ceiling):
|
||||
|
||||
- the `num_entry_point_offsets` maximum computed
|
||||
`(num_tile_columns_minus1 + 1) * (num_tile_rows_minus1 + 1) - 1` in `u8`, which
|
||||
overflows above 256 tiles — 20x22 is 440. Widened to `u32`, matching the sibling
|
||||
branch two lines down;
|
||||
- `num_entry_point_offsets` was then bounded by that maximum while
|
||||
`entry_point_offset_minus1` is `[u32; 32]`, so a slice claiming 35 entry points
|
||||
indexed past it. Clamped to the array, the same way deviation 7 handles the
|
||||
long-term arrays. 7.4.7.1 puts no 32-entry cap on the element, so this refuses a
|
||||
conformant stream with more than 32 entry points — notably 4K wavefront
|
||||
(`entropy_coding_sync_enabled_flag`) streams, which carry one offset per CTB row.
|
||||
An error beats a panic, but the real fix is upstream sizing that array from the
|
||||
stream.
|
||||
|
||||
Regression-tested in `pf-bitstream`
|
||||
(`a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic`,
|
||||
`a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
12. `src/codec/av1/parser.rs` — `parse_tile_info`: the two non-uniform tile loops
|
||||
(`uniform_tile_spacing_flag == 0`) run until `start_sb` reaches `sb_cols` / `sb_rows`
|
||||
while filling `width_in_sbs_minus_1` / `height_in_sbs_minus_1`, which are
|
||||
`MAX_TILE_COLS` / `MAX_TILE_ROWS` (64) deep. Each iteration advances `start_sb` by at
|
||||
least one superblock, so a frame wide or tall enough — 4096 mi columns is 256
|
||||
superblocks — walks 256 entries into a 64-entry array. The uniform branch already
|
||||
checks `tile_cols > MAX_TILE_COLS` after the fact and is genuinely bounded before it
|
||||
(`tile_cols_log2 <= max_log2_tile_cols <= 6`); the non-uniform branch had neither.
|
||||
Guarded at the top of each loop body, returning the same
|
||||
`"Invalid tile_{cols,rows} {n}"` the uniform branch does. 64 is the spec's own
|
||||
ceiling (`MAX_TILE_COLS` / `MAX_TILE_ROWS` in 3, and a conformance requirement on
|
||||
`TileCols` / `TileRows` in 5.11.1), so it is both the array bound and the legal one.
|
||||
Regression-tested in the file's own test module
|
||||
(`more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
13. `src/codec/h264/parser.rs` — `parse_sps`: reject a picture whose macroblock count
|
||||
overflows `u32`, with the `checked_mul` idiom the frame-crop validation a few lines
|
||||
below already uses. `max_dpb_frames()` computes
|
||||
`max_dpb_mbs / (width_mb * height_mb)` (A.3.1); both dimensions are `ue(v)` read into
|
||||
`u16`, so each reaches 65536 macroblocks and their product reaches 2^33. In debug
|
||||
that is a multiply-overflow panic, in release it wraps — 65536 x 65536 wraps to
|
||||
exactly zero — and the division that follows panics on a zero divisor. `max_dpb_frames()`
|
||||
returns `usize`, not `Result`, and the DPB and `max_num_order_frames()` both call it,
|
||||
so the check belongs at the parse boundary where an `Err` is available. Bounded at
|
||||
the arithmetic limit rather than Table A-1's `MaxFS`: the level tables are the only
|
||||
range H.264 gives these elements, this parser enforces no other level conformance at
|
||||
parse time, and hardware decoders routinely accept a stream whose level_idc
|
||||
understates its resolution. Regression-tested in the file's own test module
|
||||
(`a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic`).
|
||||
**Report upstream — not yet filed.**
|
||||
|
||||
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
|
||||
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
|
||||
|
||||
@@ -2361,6 +2361,10 @@ impl Parser {
|
||||
let mut i = 0;
|
||||
|
||||
while start_sb < sb_cols {
|
||||
if i >= MAX_TILE_COLS {
|
||||
return Err(format!("Invalid tile_cols {}", i + 1));
|
||||
}
|
||||
|
||||
self.mi_col_starts[i] = start_sb << sb_shift;
|
||||
|
||||
let max_width = std::cmp::min(sb_cols - start_sb, max_tile_width_sb);
|
||||
@@ -2387,6 +2391,10 @@ impl Parser {
|
||||
let mut start_sb = 0;
|
||||
let mut i = 0;
|
||||
while start_sb < sb_rows {
|
||||
if i >= MAX_TILE_ROWS {
|
||||
return Err(format!("Invalid tile_rows {}", i + 1));
|
||||
}
|
||||
|
||||
self.mi_row_starts[i] = start_sb << sb_shift;
|
||||
let max_height = std::cmp::min(sb_rows - start_sb, max_tile_height_sb);
|
||||
ti.height_in_sbs_minus_1[i] = r.read_ns(max_height.try_into().unwrap())?;
|
||||
@@ -4291,4 +4299,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// punktfunk deviation 12: the non-uniform tile loops are bounded by the frame's
|
||||
/// superblock count, not by the 64-entry `width_in_sbs_minus_1` /
|
||||
/// `height_in_sbs_minus_1` they fill, so a frame made of one-superblock tiles
|
||||
/// walked off both. MAX_TILE_COLS / MAX_TILE_ROWS are the spec's own ceiling.
|
||||
#[test]
|
||||
fn more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic() {
|
||||
use crate::codec::av1::parser::{SequenceHeaderObu, TileInfo};
|
||||
use crate::codec::av1::reader::Reader;
|
||||
use std::rc::Rc;
|
||||
|
||||
// All zeroes: uniform_tile_spacing_flag = 0, then every ns() read decodes to
|
||||
// a one-superblock tile.
|
||||
let data = [0u8; 128];
|
||||
|
||||
// 4096 mi columns is 256 superblocks, so the column loop runs 256 times.
|
||||
let mut parser = Parser::default();
|
||||
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
|
||||
parser.mi_cols = 4096;
|
||||
parser.mi_rows = 4096;
|
||||
let err = parser
|
||||
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("Invalid tile_cols"), "{err}");
|
||||
|
||||
// Four superblocks wide: the column loop finishes, the row loop overruns.
|
||||
let mut parser = Parser::default();
|
||||
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
|
||||
parser.mi_cols = 64;
|
||||
parser.mi_rows = 4096;
|
||||
let err = parser
|
||||
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("Invalid tile_rows"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2102,6 +2102,13 @@ impl Parser {
|
||||
sps.pic_height_in_map_units_minus1 = r.read_ue()?;
|
||||
sps.frame_mbs_only_flag = r.read_bit()?;
|
||||
|
||||
// max_dpb_frames() divides MaxDpbMbs by the frame's macroblock count (A.3.1).
|
||||
// ue(v) admits 65536 macroblocks in each direction, and the u32 product of the
|
||||
// two wraps to zero long before that.
|
||||
let _ = (sps.width() / 16)
|
||||
.checked_mul(sps.height() / 16)
|
||||
.ok_or::<String>("Invalid picture size in macroblocks".into())?;
|
||||
|
||||
if !sps.frame_mbs_only_flag {
|
||||
sps.mb_adaptive_frame_field_flag = r.read_bit()?;
|
||||
}
|
||||
@@ -3059,4 +3066,43 @@ mod tests {
|
||||
assert_eq!(MaxLongTermFrameIdx::Idx(24), 24);
|
||||
assert!(MaxLongTermFrameIdx::Idx(24) < 25);
|
||||
}
|
||||
|
||||
/// punktfunk deviation 13: `max_dpb_frames()` divides MaxDpbMbs by the frame's
|
||||
/// macroblock count (A.3.1), a u32 product that wraps to zero at the widest
|
||||
/// picture `ue(v)` admits — 65536 x 65536 macroblocks is exactly 2^32. The SPS is
|
||||
/// refused at parse time now, so the division always has a divisor.
|
||||
#[test]
|
||||
fn a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic() {
|
||||
use crate::codec::h264::nalu_writer::NaluWriter;
|
||||
|
||||
let mut buf = Vec::<u8>::new();
|
||||
{
|
||||
let mut w = NaluWriter::new(&mut buf, true);
|
||||
w.write_header(3, 7).unwrap(); // nal_ref_idc = 3, SPS
|
||||
w.write_u(8, 66u32).unwrap(); // profile_idc: Baseline, so no chroma block
|
||||
w.write_u(8, 0u32).unwrap(); // constraint flags + reserved_zero_2bits
|
||||
w.write_u(8, 51u32).unwrap(); // level_idc: 5.1
|
||||
w.write_ue(0u32).unwrap(); // seq_parameter_set_id
|
||||
w.write_ue(0u32).unwrap(); // log2_max_frame_num_minus4
|
||||
w.write_ue(2u32).unwrap(); // pic_order_cnt_type: 2, nothing follows
|
||||
w.write_ue(1u32).unwrap(); // max_num_ref_frames
|
||||
w.write_f(1, 0u32).unwrap(); // gaps_in_frame_num_value_allowed_flag
|
||||
w.write_ue(65535u32).unwrap(); // pic_width_in_mbs_minus1
|
||||
w.write_ue(65535u32).unwrap(); // pic_height_in_map_units_minus1
|
||||
w.write_f(1, 1u32).unwrap(); // frame_mbs_only_flag
|
||||
w.write_f(1, 1u32).unwrap(); // direct_8x8_inference_flag
|
||||
w.write_f(1, 0u32).unwrap(); // frame_cropping_flag
|
||||
w.write_f(1, 0u32).unwrap(); // vui_parameters_present_flag
|
||||
w.write_f(1, 1u32).unwrap(); // rbsp_stop_one_bit
|
||||
while !w.aligned() {
|
||||
w.write_f(1, 0u32).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(buf.as_slice());
|
||||
let nalu = Nalu::next(&mut cursor).unwrap();
|
||||
assert!(matches!(nalu.header.type_, NaluType::Sps));
|
||||
let err = Parser::default().parse_sps(&nalu).unwrap_err();
|
||||
assert!(err.starts_with("Invalid picture size"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
+55
-10
@@ -42,6 +42,11 @@ const MAX_SHORT_TERM_REF_PIC_SETS: usize = 65;
|
||||
// 7.4.3.2.1:
|
||||
const MAX_LONG_TERM_REF_PIC_SETS: usize = 32;
|
||||
|
||||
// Table A.8: MaxTileCols and MaxTileRows peak at 20 and 22 at level 6.2, and A.4.1
|
||||
// makes those a bitstream conformance requirement for every level.
|
||||
const MAX_TILE_COLUMNS: usize = 20;
|
||||
const MAX_TILE_ROWS: usize = 22;
|
||||
|
||||
// From table 7-5.
|
||||
const DEFAULT_SCALING_LIST_0: [u8; 16] = [16; 16];
|
||||
|
||||
@@ -1239,10 +1244,10 @@ pub struct Pps {
|
||||
pub uniform_spacing_flag: bool,
|
||||
/// `column_width_minus1[ i ]` plus 1 specifies the width of the i-th tile
|
||||
/// column in units of CTBs.
|
||||
pub column_width_minus1: [u32; 19],
|
||||
pub column_width_minus1: [u32; MAX_TILE_COLUMNS],
|
||||
/// `row_height_minus1[ i ]` plus 1 specifies the height of the i-th tile row
|
||||
/// in units of CTBs.
|
||||
pub row_height_minus1: [u32; 21],
|
||||
pub row_height_minus1: [u32; MAX_TILE_ROWS],
|
||||
/// When set, specifies that in-loop filtering operations may be performed
|
||||
/// across tile boundaries in pictures referring to the PPS. When not set,
|
||||
/// specifies that in-loop filtering operations are not performed across
|
||||
@@ -2238,6 +2243,15 @@ impl Parser {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// u(3) can say 7, but 7.4.3.1 stops at 6 — and 7 walks off every sub-layer
|
||||
// array below, starting with profile_tier_level()'s six-deep flags.
|
||||
if vps.max_sub_layers_minus1 > 6 {
|
||||
return Err(format!(
|
||||
"Invalid max_sub_layers_minus1 {}",
|
||||
vps.max_sub_layers_minus1
|
||||
));
|
||||
}
|
||||
|
||||
r.skip_bits(16)?; // vps_reserved_0xffff_16bits
|
||||
|
||||
let ptl = &mut vps.profile_tier_level;
|
||||
@@ -2606,12 +2620,16 @@ impl Parser {
|
||||
// in Table 7-5 and Table 7-6 for i = 0..Min( 63, ( 1 << ( 4 + (
|
||||
// sizeId << 1 ) ) ) − 1 ).
|
||||
if !scaling_list_pred_mode_flag {
|
||||
let scaling_list_pred_matrix_id_delta: u32 = r.read_ue()?;
|
||||
// Equation 7-42's factor. 7.4.5 bounds the delta by
|
||||
// matrixId / factor, which is what keeps refMatrixId at or above
|
||||
// zero — unbounded it underflows into an out-of-bounds read.
|
||||
let factor: u32 = if size_id == 3 { 3 } else { 1 };
|
||||
let scaling_list_pred_matrix_id_delta: u32 =
|
||||
r.read_ue_max(matrix_id as u32 / factor)?;
|
||||
if scaling_list_pred_matrix_id_delta == 0 {
|
||||
Self::fill_default_scaling_list(sl, size_id, matrix_id);
|
||||
} else {
|
||||
// Equation 7-42
|
||||
let factor = if size_id == 3 { 3 } else { 1 };
|
||||
let ref_matrix_id =
|
||||
matrix_id as u32 - scaling_list_pred_matrix_id_delta * factor;
|
||||
if size_id == 0 {
|
||||
@@ -3104,6 +3122,14 @@ impl Parser {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// See parse_vps(): 7.4.3.2 bounds this at 6, u(3) does not.
|
||||
if sps.max_sub_layers_minus1 > 6 {
|
||||
return Err(format!(
|
||||
"Invalid max_sub_layers_minus1 {}",
|
||||
sps.max_sub_layers_minus1
|
||||
));
|
||||
}
|
||||
|
||||
Self::parse_profile_tier_level(
|
||||
&mut sps.profile_tier_level,
|
||||
&mut r,
|
||||
@@ -3485,8 +3511,16 @@ impl Parser {
|
||||
|
||||
// A mix of the rbsp data and the algorithm in 6.5.1
|
||||
if pps.tiles_enabled_flag {
|
||||
pps.num_tile_columns_minus1 = r.read_ue_max(sps.pic_width_in_ctbs_y - 1)?;
|
||||
pps.num_tile_rows_minus1 = r.read_ue_max(sps.pic_height_in_ctbs_y - 1)?;
|
||||
// 7.4.3.3.1 bounds these by the picture, Table A.8 bounds them by the
|
||||
// level — and the level cap is the one the arrays below are sized for.
|
||||
pps.num_tile_columns_minus1 = r.read_ue_max(std::cmp::min(
|
||||
sps.pic_width_in_ctbs_y - 1,
|
||||
MAX_TILE_COLUMNS as u32 - 1,
|
||||
))?;
|
||||
pps.num_tile_rows_minus1 = r.read_ue_max(std::cmp::min(
|
||||
sps.pic_height_in_ctbs_y - 1,
|
||||
MAX_TILE_ROWS as u32 - 1,
|
||||
))?;
|
||||
pps.uniform_spacing_flag = r.read_bit()?;
|
||||
if !pps.uniform_spacing_flag {
|
||||
pps.column_width_minus1[usize::from(pps.num_tile_columns_minus1)] =
|
||||
@@ -4122,12 +4156,21 @@ impl Parser {
|
||||
let max = if !pps.tiles_enabled_flag && pps.entropy_coding_sync_enabled_flag {
|
||||
sps.pic_height_in_ctbs_y - 1
|
||||
} else if pps.tiles_enabled_flag && !pps.entropy_coding_sync_enabled_flag {
|
||||
u32::from((pps.num_tile_columns_minus1 + 1) * (pps.num_tile_rows_minus1 + 1) - 1)
|
||||
// Widened: Table A.8 permits 20 x 22 tiles, whose product does not fit
|
||||
// the u8 the tile counts are stored in.
|
||||
(u32::from(pps.num_tile_columns_minus1) + 1)
|
||||
* (u32::from(pps.num_tile_rows_minus1) + 1)
|
||||
- 1
|
||||
} else {
|
||||
(u32::from(pps.num_tile_columns_minus1) + 1) * sps.pic_height_in_ctbs_y - 1
|
||||
};
|
||||
|
||||
hdr.num_entry_point_offsets = r.read_ue_max(max)?;
|
||||
// 7.4.7.1 puts no 32-entry cap on num_entry_point_offsets, but
|
||||
// entry_point_offset_minus1 is that deep, so the array is the real bound.
|
||||
hdr.num_entry_point_offsets = r.read_ue_max(std::cmp::min(
|
||||
max,
|
||||
hdr.entry_point_offset_minus1.len() as u32 - 1,
|
||||
))?;
|
||||
if hdr.num_entry_point_offsets > 0 {
|
||||
hdr.offset_len_minus1 = r.read_ue_max(31)?;
|
||||
for i in 0..hdr.num_entry_point_offsets as usize {
|
||||
@@ -4189,6 +4232,8 @@ mod tests {
|
||||
use crate::codec::h265::parser::NaluType;
|
||||
use crate::codec::h265::parser::Parser;
|
||||
use crate::codec::h265::parser::SliceType;
|
||||
use crate::codec::h265::parser::MAX_TILE_COLUMNS;
|
||||
use crate::codec::h265::parser::MAX_TILE_ROWS;
|
||||
|
||||
const STREAM_BEAR: &[u8] = include_bytes!("test_data/bear.h265");
|
||||
const STREAM_BEAR_NUM_NALUS: usize = 35;
|
||||
@@ -4718,8 +4763,8 @@ mod tests {
|
||||
assert_eq!(pps.num_tile_rows_minus1, 0);
|
||||
assert_eq!(pps.num_tile_columns_minus1, 0);
|
||||
assert!(pps.uniform_spacing_flag);
|
||||
assert_eq!(pps.column_width_minus1, [0; 19]);
|
||||
assert_eq!(pps.row_height_minus1, [0; 21]);
|
||||
assert_eq!(pps.column_width_minus1, [0; MAX_TILE_COLUMNS]);
|
||||
assert_eq!(pps.row_height_minus1, [0; MAX_TILE_ROWS]);
|
||||
assert!(pps.loop_filter_across_slices_enabled_flag);
|
||||
assert!(pps.loop_filter_across_tiles_enabled_flag);
|
||||
assert!(!pps.deblocking_filter_control_present_flag);
|
||||
|
||||
@@ -314,9 +314,13 @@ impl DeepLink {
|
||||
/// What the local host store made of a link's references.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HostResolution {
|
||||
/// Index into `KnownHosts::hosts` — a record we already trust (subject to
|
||||
/// [`DeepLink::pin_conflict`]).
|
||||
/// Index into `KnownHosts::hosts` — a record we already trust, named by its stable
|
||||
/// (unguessable) id: the one-click contract (subject to [`DeepLink::pin_conflict`]).
|
||||
Known(usize),
|
||||
/// The same index, for a record named by something GUESSABLE — its display name, its
|
||||
/// address, or the `host=` recovery parameter. A link may not act on a guess, so the
|
||||
/// front-end confirms first; past that it is the [`HostResolution::Known`] path exactly.
|
||||
Confirm(usize),
|
||||
/// No record, but the link says where to dial: the confirmation sheet's input, from which
|
||||
/// the normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
|
||||
Unknown {
|
||||
@@ -332,9 +336,16 @@ pub enum HostResolution {
|
||||
}
|
||||
|
||||
/// Resolve a link's host reference against the local store, in the documented order: stable
|
||||
/// record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
|
||||
/// the recovery path — a self-emitted shortcut that outlived the record it was written from
|
||||
/// still lands on the right box (degraded to the confirmation sheet).
|
||||
/// record id → unique case-insensitive name → `addr[:port]` literal, then the `host=` recovery
|
||||
/// path — a self-emitted shortcut that outlived the record it was written from still lands on
|
||||
/// the right box.
|
||||
///
|
||||
/// Only the record id is UNGUESSABLE, so only the record id resolves to
|
||||
/// [`HostResolution::Known`], the silent one-click contract. A display name is an mDNS instance
|
||||
/// name or a user label ("Gaming PC") and an address is a LAN address: the `.desktop` files
|
||||
/// register `x-scheme-handler/punktfunk`, so any web page can hand this resolver a guess, and a
|
||||
/// guess must not be able to start a stream (or launch a title). Those resolve to
|
||||
/// [`HostResolution::Confirm`] — the same host, behind the user's OK.
|
||||
///
|
||||
/// Returns an index rather than a borrow so callers can keep mutating the store (rekey,
|
||||
/// touch-last-used) without fighting the borrow checker.
|
||||
@@ -354,7 +365,7 @@ pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
match by_name.len() {
|
||||
1 => return HostResolution::Known(by_name[0]),
|
||||
1 => return HostResolution::Confirm(by_name[0]),
|
||||
0 => {}
|
||||
_ => return HostResolution::Ambiguous,
|
||||
}
|
||||
@@ -367,7 +378,7 @@ pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
|
||||
.flatten();
|
||||
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
|
||||
if let Some(i) = known.index_by_addr(&candidate.0, candidate.1) {
|
||||
return HostResolution::Known(i);
|
||||
return HostResolution::Confirm(i);
|
||||
}
|
||||
}
|
||||
match literal.or_else(|| link.host.clone()) {
|
||||
@@ -587,7 +598,9 @@ mod tests {
|
||||
|
||||
/// The one-click contract in resolution form: an id beats a name beats an address, an
|
||||
/// ambiguous name refuses, and a link whose record is gone still lands on the
|
||||
/// confirmation sheet via `host=`+`fp=` instead of dying.
|
||||
/// confirmation sheet via `host=`+`fp=` instead of dying. Only the id — the one reference
|
||||
/// nothing can guess — dials on its own; a name or an address finds the same host behind a
|
||||
/// confirmation.
|
||||
#[test]
|
||||
fn host_resolution_order_and_recovery() {
|
||||
let fp = "a".repeat(64);
|
||||
@@ -619,20 +632,23 @@ mod tests {
|
||||
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
|
||||
HostResolution::Known(0)
|
||||
);
|
||||
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Known(0));
|
||||
// A display name ("Gaming PC") and a LAN address are guesses any web page can make —
|
||||
// the same host, but only behind a confirmation. See the test below.
|
||||
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Confirm(0));
|
||||
assert_eq!(r("punktfunk://connect/couch"), HostResolution::Ambiguous);
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/192.168.1.50"),
|
||||
HostResolution::Known(0)
|
||||
HostResolution::Confirm(0)
|
||||
);
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/192.168.1.50:9777"),
|
||||
HostResolution::Known(0)
|
||||
HostResolution::Confirm(0)
|
||||
);
|
||||
// A stale id with the recovery parameters: the address finds the record anyway.
|
||||
// A stale id with the recovery parameters: the address finds the record anyway — and,
|
||||
// being an address, behind the confirmation exactly as this function's doc always said.
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
|
||||
HostResolution::Known(0)
|
||||
HostResolution::Confirm(0)
|
||||
);
|
||||
// Nothing local matches: the sheet gets the address, the claimed name and the pin —
|
||||
// which is what makes the first connect verified rather than blind TOFU.
|
||||
@@ -680,6 +696,40 @@ mod tests {
|
||||
assert!(!link.pin_conflict(&known.hosts[1]));
|
||||
}
|
||||
|
||||
/// The record id is a UUID nothing can guess; a display name and a LAN address are guesses
|
||||
/// any web page can make — and `x-scheme-handler/punktfunk` hands web pages this resolver.
|
||||
/// So the id, and only the id, is the silent one-click dial.
|
||||
#[test]
|
||||
fn only_the_record_id_dials_without_asking() {
|
||||
let fp = "a".repeat(64);
|
||||
let known = KnownHosts {
|
||||
hosts: vec![host(
|
||||
"Desk",
|
||||
"192.168.1.50",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
&fp,
|
||||
)],
|
||||
};
|
||||
let r = |url: &str| resolve_host(&parse(url).unwrap(), &known);
|
||||
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
|
||||
HostResolution::Known(0)
|
||||
);
|
||||
for guess in [
|
||||
"punktfunk://connect/desk",
|
||||
"punktfunk://connect/DESK",
|
||||
"punktfunk://connect/192.168.1.50",
|
||||
"punktfunk://connect/192.168.1.50:9777",
|
||||
// A launch id doesn't buy a name any authority it didn't have.
|
||||
"punktfunk://connect/desk?launch=steam:570",
|
||||
// …and neither does the `host=` recovery path.
|
||||
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50",
|
||||
] {
|
||||
assert_eq!(r(guess), HostResolution::Confirm(0), "{guess}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-emitted links round-trip and carry all three references, so they survive both a
|
||||
/// re-addressed host and a wiped store.
|
||||
#[test]
|
||||
|
||||
@@ -251,6 +251,13 @@ impl ConnectPlan {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlanOutcome {
|
||||
Connect(Box<ConnectPlan>),
|
||||
/// The same plan, for a link that named the host by something GUESSABLE — its display name,
|
||||
/// its address, or the `host=` recovery parameter — rather than by its stable record id.
|
||||
/// `x-scheme-handler/punktfunk` means any web page can emit such a link, so the front-end
|
||||
/// asks first and then runs it exactly as [`PlanOutcome::Connect`]. NOT
|
||||
/// [`PlanOutcome::ConfirmUnknown`]: this host is saved and pinned, and routing it through
|
||||
/// the pairing ceremony would drop the pin it already has.
|
||||
ConfirmConnect(Box<ConnectPlan>),
|
||||
/// The link resolved to no local record. The front-end shows the confirmation sheet with
|
||||
/// exactly this, and the normal pairing/TOFU flow proceeds under the user's eyes (§3.1).
|
||||
ConfirmUnknown(Box<UnknownHost>),
|
||||
@@ -316,7 +323,9 @@ impl PlanError {
|
||||
|
||||
/// Build a plan from a `punktfunk://` link against this device's stores — the shared half of
|
||||
/// every platform's URL router (§4). The security rules of §3 live here, not in the shells:
|
||||
/// no pairing, no silent trust, references resolved or refused.
|
||||
/// no pairing, no silent trust, no dial on a GUESSABLE reference (only the stable record id
|
||||
/// yields [`PlanOutcome::Connect`]; a name or an address yields
|
||||
/// [`PlanOutcome::ConfirmConnect`]), references resolved or refused.
|
||||
///
|
||||
/// Preempting a live session is the one rule that stays with the caller: only the front-end
|
||||
/// knows whether a session is running, and the answer ("focus it" / "end that one first")
|
||||
@@ -341,8 +350,12 @@ pub fn plan_from_link(
|
||||
_ => return Err(PlanError::UnknownProfile(reference.clone())),
|
||||
}
|
||||
}
|
||||
match crate::deeplink::resolve_host(link, known) {
|
||||
HostResolution::Known(i) => {
|
||||
let resolution = crate::deeplink::resolve_host(link, known);
|
||||
// A guessable reference (name / address / `host=`) reaches the same plan, but the front-end
|
||||
// must put a person in front of it — see `PlanOutcome::ConfirmConnect`.
|
||||
let confirm = matches!(resolution, HostResolution::Confirm(_));
|
||||
match resolution {
|
||||
HostResolution::Known(i) | HostResolution::Confirm(i) => {
|
||||
let host = &known.hosts[i];
|
||||
if link.pin_conflict(host) {
|
||||
return Err(PlanError::PinConflict {
|
||||
@@ -376,7 +389,11 @@ pub fn plan_from_link(
|
||||
// window title (it names nothing that is trusted).
|
||||
plan.host.name = link.name.clone().unwrap_or_else(|| plan.host.addr.clone());
|
||||
}
|
||||
Ok(PlanOutcome::Connect(Box::new(plan)))
|
||||
Ok(if confirm {
|
||||
PlanOutcome::ConfirmConnect(Box::new(plan))
|
||||
} else {
|
||||
PlanOutcome::Connect(Box::new(plan))
|
||||
})
|
||||
}
|
||||
HostResolution::Unknown {
|
||||
addr,
|
||||
@@ -910,8 +927,8 @@ mod tests {
|
||||
let plan =
|
||||
|url: &str| plan_from_link(&deeplink::parse(url).unwrap(), &known, &catalog, &base);
|
||||
|
||||
// A known, pinned host with a matching (or absent) fp: a plain connect.
|
||||
let out = plan("punktfunk://connect/Desk").unwrap();
|
||||
// A known, pinned host named by its (unguessable) record id: a plain connect.
|
||||
let out = plan("punktfunk://connect/11111111-2222-4333-8444-555555555555").unwrap();
|
||||
match out {
|
||||
PlanOutcome::Connect(p) => {
|
||||
assert_eq!(p.host.addr, "192.168.1.50");
|
||||
@@ -921,6 +938,23 @@ mod tests {
|
||||
other => panic!("expected a connect, got {other:?}"),
|
||||
}
|
||||
|
||||
// The SAME host named by its label — which any web page could guess, and
|
||||
// `x-scheme-handler/punktfunk` lets one hand us: the same plan, but the shell must ask
|
||||
// first. Deliberately not `ConfirmUnknown`: that would re-run the pairing ceremony on a
|
||||
// host that is already pinned.
|
||||
match plan("punktfunk://connect/Desk").unwrap() {
|
||||
PlanOutcome::ConfirmConnect(p) => {
|
||||
assert_eq!(p.host.addr, "192.168.1.50");
|
||||
assert!(p.host.fp_hex.is_some());
|
||||
}
|
||||
other => panic!("expected a confirm-connect, got {other:?}"),
|
||||
}
|
||||
// …and by its address, launch id and all.
|
||||
match plan("punktfunk://connect/192.168.1.50?launch=steam:570").unwrap() {
|
||||
PlanOutcome::ConfirmConnect(p) => assert_eq!(p.launch.as_deref(), Some("steam:570")),
|
||||
other => panic!("expected a confirm-connect, got {other:?}"),
|
||||
}
|
||||
|
||||
// A lying/stale fingerprint never connects, and says which host it was about.
|
||||
assert_eq!(
|
||||
plan(&format!("punktfunk://connect/Desk?fp={}", "b".repeat(64))),
|
||||
|
||||
@@ -102,14 +102,21 @@ async fn run(
|
||||
}
|
||||
}
|
||||
ClipCoordCmd::RemoteOffer { seq, mimes } => {
|
||||
client_seq = seq;
|
||||
let res = if mimes.is_empty() {
|
||||
backend.clear_offer()
|
||||
} else {
|
||||
backend.set_offer(&mimes)
|
||||
};
|
||||
if let Err(e) = res {
|
||||
tracing::debug!(error = %e, "clipboard apply remote offer failed");
|
||||
// Only while the client has sync on — with it off the device's grant was
|
||||
// revoked (or it never enabled sync at all), and its content must not go
|
||||
// on the host's real clipboard. Re-read HERE and not only at the sender:
|
||||
// the access lifecycle task clears the flag from another task, which can
|
||||
// land between the offer being queued and this arm running.
|
||||
if clip_enabled.load(Ordering::SeqCst) {
|
||||
client_seq = seq;
|
||||
let res = if mimes.is_empty() {
|
||||
backend.clear_offer()
|
||||
} else {
|
||||
backend.set_offer(&mimes)
|
||||
};
|
||||
if let Err(e) = res {
|
||||
tracing::debug!(error = %e, "clipboard apply remote offer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,8 +134,16 @@ async fn run(
|
||||
ClipEvent::Paste { mime, responder } => {
|
||||
// A host app is pasting the client's offered content: pull that format from
|
||||
// the client and hand it to the backend's responder. Off-task so the loop
|
||||
// keeps serving.
|
||||
tokio::spawn(fetch_into_pipe(conn.clone(), client_seq, mime, responder));
|
||||
// keeps serving. The enable snapshot rides along like `serve_fetch`'s: a
|
||||
// paste that raced a revoke must not fetch from the client, even though
|
||||
// the selection it owned is being dropped in the same breath.
|
||||
tokio::spawn(fetch_into_pipe(
|
||||
conn.clone(),
|
||||
client_seq,
|
||||
mime,
|
||||
responder,
|
||||
clip_enabled.load(Ordering::SeqCst),
|
||||
));
|
||||
}
|
||||
ClipEvent::Closed => break,
|
||||
}
|
||||
@@ -221,14 +236,20 @@ async fn serve_fetch(
|
||||
}
|
||||
|
||||
/// Pull `mime` of the client's current offer (`seq`) over an outbound fetch stream and hand the bytes
|
||||
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) responds with empty bytes
|
||||
/// so the pasting host app gets an empty paste instead of hanging.
|
||||
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) — or `enabled` being false,
|
||||
/// the client having lost the clipboard since the paste started — responds with empty bytes so the
|
||||
/// pasting host app gets an empty paste instead of hanging.
|
||||
async fn fetch_into_pipe(
|
||||
conn: quinn::Connection,
|
||||
seq: u32,
|
||||
mime: String,
|
||||
responder: PasteResponder,
|
||||
enabled: bool,
|
||||
) {
|
||||
if !enabled {
|
||||
responder.respond(Vec::new()).await;
|
||||
return;
|
||||
}
|
||||
let req = ClipFetch {
|
||||
seq,
|
||||
file_index: CLIP_FILE_INDEX_NONE,
|
||||
|
||||
@@ -68,6 +68,7 @@ pub enum ClipCoordCmd {
|
||||
/// clipboard; when disabled, it drops any selection it owns and stops forwarding host copies.
|
||||
SetEnabled(bool),
|
||||
/// The client copied: install its offered wire MIMEs as a lazy host selection (empty = clear).
|
||||
/// Ignored while sync is off, so a revoked device's content never reaches the host clipboard.
|
||||
RemoteOffer { seq: u32, mimes: Vec<String> },
|
||||
}
|
||||
|
||||
|
||||
@@ -1295,10 +1295,8 @@ impl Shell {
|
||||
self.mesh_scrim[2],
|
||||
self.mesh_scrim[3],
|
||||
];
|
||||
// SAFETY: `uniforms` is a local `[f32; 12]` — exactly 48 bytes — and `f32` has no padding
|
||||
// or invalid bit patterns, so reading it as bytes is sound; the slice is copied by
|
||||
// `Data::new_copy` before `uniforms` goes out of scope.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 48) };
|
||||
let words = uniforms.map(f32::to_ne_bytes);
|
||||
let bytes = words.as_flattened();
|
||||
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
|
||||
Some(shader) => {
|
||||
let mut paint = crate::theme::shaded();
|
||||
|
||||
@@ -488,27 +488,17 @@ impl VirtualPad {
|
||||
code,
|
||||
value,
|
||||
};
|
||||
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
|
||||
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
|
||||
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
|
||||
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
|
||||
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
&ev as *const _ as *const u8,
|
||||
std::mem::size_of::<InputEventRaw>(),
|
||||
)
|
||||
};
|
||||
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
||||
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
|
||||
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
|
||||
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
|
||||
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
|
||||
// the call). `write` READS exactly `size_of::<InputEventRaw>()` bytes from the live local `ev` —
|
||||
// a `#[repr(C)]` struct of all-integer fields with no padding (timeval=16 + u16 + u16 + i32 = 24,
|
||||
// the size asserted above), so every byte is initialized — and retains nothing past return, so
|
||||
// `ev` outlives the synchronous call and the read-only access cannot race or alias.
|
||||
let _ = unsafe {
|
||||
libc::write(
|
||||
self.fd.as_raw_fd(),
|
||||
bytes.as_ptr() as *const libc::c_void,
|
||||
bytes.len(),
|
||||
&ev as *const _ as *const libc::c_void,
|
||||
std::mem::size_of::<InputEventRaw>(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,7 +268,14 @@ impl Drop for ServerThread {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped.
|
||||
/// Serve the ONE USB/IP connection an attachment is for — the kernel's single `vhci_hcd` attach
|
||||
/// (our own in-process import, or the `usbip` CLI's) — with the vendored `usbip_sim::handler`,
|
||||
/// then stop. The listener is dropped the moment that connection is accepted: it speaks
|
||||
/// unauthenticated USB/IP on loopback, so keeping it open for the session lets ANY local user
|
||||
/// import the device — reading the streaming client's live controller reports and issuing HID
|
||||
/// SET_REPORT transfers at it. Nothing legitimate needs a second accept: every re-attach (kernel
|
||||
/// module reload, pad re-plug, the in-process→CLI fallback) goes through [`attach_device`] again
|
||||
/// and brings its own listener with it.
|
||||
async fn run_server(
|
||||
listener: std::net::TcpListener,
|
||||
server: Arc<UsbIpServer>,
|
||||
@@ -282,60 +289,62 @@ async fn run_server(
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop.notified() => break,
|
||||
r = listener.accept() => match r {
|
||||
Ok((mut sock, _)) => {
|
||||
// URB replies are small and interleave with the kernel's next SUBMITs; without
|
||||
// TCP_NODELAY the multi-interface request/response pattern collapses into
|
||||
// ~40 ms Nagle/delayed-ACK stalls (observed as ~22 reports/s on the Puck's
|
||||
// active hidraw against a 266 Hz source).
|
||||
sock.set_nodelay(true).ok();
|
||||
let server = server.clone();
|
||||
let trace = super::usbip_trace::trace_prefix(&label);
|
||||
let label = label.clone();
|
||||
tokio::spawn(async move {
|
||||
// The handler's Err arm used to be discarded. It is the *only* signal that
|
||||
// we tore the connection down rather than the kernel — and the kernel's
|
||||
// side of that (`recv xbuf`, `sendmsg failed`) reads identically either
|
||||
// way, so throwing it away cost days of mis-attributed diagnosis.
|
||||
let sink = trace.and_then(|prefix| {
|
||||
match super::usbip_trace::open_trace(&prefix) {
|
||||
Ok(s) => {
|
||||
tracing::info!(prefix, "usbip byte trace armed");
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "usbip trace files unopenable — running untraced");
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let res = match sink {
|
||||
Some(s) => {
|
||||
let mut traced = super::usbip_trace::TracedIo::wrap(sock, s);
|
||||
usbip_sim::handler(&mut traced, server).await
|
||||
}
|
||||
None => usbip_sim::handler(&mut sock, server).await,
|
||||
};
|
||||
match res {
|
||||
Ok(()) => tracing::debug!(label, "usbip connection closed by the kernel"),
|
||||
Err(e) => tracing::warn!(
|
||||
label,
|
||||
error = %e,
|
||||
"usbip server dropped the connection — the kernel will report this as a \
|
||||
transfer error on whatever URB was in flight"
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "usbip accept error");
|
||||
break;
|
||||
}
|
||||
let (mut sock, _) = tokio::select! {
|
||||
_ = stop.notified() => return,
|
||||
r = listener.accept() => match r {
|
||||
Ok(peer) => peer,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "usbip accept error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// The kernel has its socket; the port has no further legitimate caller. Closing it here is
|
||||
// what keeps the device from being a local privilege boundary for the rest of the session.
|
||||
drop(listener);
|
||||
// URB replies are small and interleave with the kernel's next SUBMITs; without
|
||||
// TCP_NODELAY the multi-interface request/response pattern collapses into
|
||||
// ~40 ms Nagle/delayed-ACK stalls (observed as ~22 reports/s on the Puck's
|
||||
// active hidraw against a 266 Hz source).
|
||||
sock.set_nodelay(true).ok();
|
||||
let trace = super::usbip_trace::trace_prefix(&label);
|
||||
let conn = tokio::spawn(async move {
|
||||
// The handler's Err arm used to be discarded. It is the *only* signal that
|
||||
// we tore the connection down rather than the kernel — and the kernel's
|
||||
// side of that (`recv xbuf`, `sendmsg failed`) reads identically either
|
||||
// way, so throwing it away cost days of mis-attributed diagnosis.
|
||||
let sink = trace.and_then(|prefix| match super::usbip_trace::open_trace(&prefix) {
|
||||
Ok(s) => {
|
||||
tracing::info!(prefix, "usbip byte trace armed");
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "usbip trace files unopenable — running untraced");
|
||||
None
|
||||
}
|
||||
});
|
||||
let res = match sink {
|
||||
Some(s) => {
|
||||
let mut traced = super::usbip_trace::TracedIo::wrap(sock, s);
|
||||
usbip_sim::handler(&mut traced, server).await
|
||||
}
|
||||
None => usbip_sim::handler(&mut sock, server).await,
|
||||
};
|
||||
match res {
|
||||
Ok(()) => tracing::debug!(label, "usbip connection closed by the kernel"),
|
||||
Err(e) => tracing::warn!(
|
||||
label,
|
||||
error = %e,
|
||||
"usbip server dropped the connection — the kernel will report this as a \
|
||||
transfer error on whatever URB was in flight"
|
||||
),
|
||||
}
|
||||
});
|
||||
// Stay until the kernel closes the connection (or the attachment is dropped) — the runtime
|
||||
// lives on this thread, so returning early would kill the task serving the device.
|
||||
tokio::select! {
|
||||
_ = stop.notified() => {}
|
||||
_ = conn => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,6 +764,45 @@ mod tests {
|
||||
assert_eq!(ss, Some(8));
|
||||
}
|
||||
|
||||
/// The emulation server serves exactly ONE USB/IP connection — the kernel's single `vhci_hcd`
|
||||
/// attach — and the loopback port closes with it. Anything that can still reach that port
|
||||
/// afterwards can import the device: read the streaming client's live controller reports and
|
||||
/// write HID SET_REPORTs at it, with no authentication anywhere in USB/IP to stop it. Needs
|
||||
/// neither root nor `vhci_hcd` — only the listener side is under test.
|
||||
#[test]
|
||||
fn usbip_server_serves_one_connection_then_closes_the_port() {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
|
||||
let server =
|
||||
ServerThread::spawn(listener, build_device(0, &report, &feedback), "test deck")
|
||||
.expect("spawn the emulation server");
|
||||
|
||||
// The one legitimate importer (what `attach_in_process` does before handing the fd to
|
||||
// `vhci_hcd`), held open for the rest of the test exactly as the kernel holds it.
|
||||
let _kernel = connect_loopback(port).expect("the attach connects");
|
||||
|
||||
// Poll: the accept races the connect above (the connection sits in the listen backlog
|
||||
// until the server thread picks it up), and the port closes only once it has.
|
||||
let mut refused = false;
|
||||
for _ in 0..100 {
|
||||
match TcpStream::connect(("127.0.0.1", port)) {
|
||||
Ok(_) => std::thread::sleep(Duration::from_millis(10)),
|
||||
Err(_) => {
|
||||
refused = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
refused,
|
||||
"a second USB/IP importer must find the port closed"
|
||||
);
|
||||
drop(server);
|
||||
}
|
||||
|
||||
/// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds
|
||||
/// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI.
|
||||
#[test]
|
||||
|
||||
@@ -64,10 +64,14 @@ fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
|
||||
batch.push(ev);
|
||||
}
|
||||
|
||||
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
|
||||
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
|
||||
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
|
||||
// EIS socket after the user switched to the KDE desktop).
|
||||
// The resolved input backend (published by the host per connect / mid-stream session switch
|
||||
// — `set_backend_id`) may have changed since we opened. Reopen against it so input FOLLOWS
|
||||
// the active session instead of injecting into a stale, still-warm backend (e.g. the managed
|
||||
// gamescope's EIS socket after the user switched to the KDE desktop).
|
||||
//
|
||||
// This runs once per BATCH, which is why the published value is a `RwLock` read and not a
|
||||
// `getenv`: the old `PUNKTFUNK_INPUT_BACKEND` round-trip made this hot path a data race
|
||||
// against the connect path's `setenv` (security-review 2026-08-25).
|
||||
let want = default_backend();
|
||||
if injector.is_some() && open_backend != Some(want) {
|
||||
tracing::info!(
|
||||
|
||||
@@ -46,12 +46,13 @@ pub struct SendInputInjector {
|
||||
touch_failed: bool,
|
||||
}
|
||||
|
||||
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
|
||||
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
|
||||
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
|
||||
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
|
||||
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
|
||||
// sound.
|
||||
// SAFETY: the only field that is not already `Send` is the `Option<HDESK>` (`touch_failed` is a
|
||||
// `bool`, and `SyntheticTouch` is `Send` on its own — `Arc<Mutex<..>>` + `JoinHandle`, with the
|
||||
// device handle carrying its own proof). The host creates and drives the injector from a single
|
||||
// dedicated thread; the desktop handle is opened, rebound, and closed on whichever thread owns the
|
||||
// value, and the type is not `Sync`, so there is never concurrent access. A desktop `HDESK` is not
|
||||
// thread-affine for ownership (`CloseDesktop` works from any thread; `SetThreadDesktop` rebinds the
|
||||
// current thread), so transferring ownership via `Send` is sound.
|
||||
unsafe impl Send for SendInputInjector {}
|
||||
|
||||
impl SendInputInjector {
|
||||
|
||||
+124
-11
@@ -136,8 +136,9 @@ impl AbsoluteAnchor {
|
||||
|
||||
/// The current absolute-coordinate anchor. A `RwLock` rather than an env var: the injector is
|
||||
/// host-lifetime and lives behind a channel, so a *session* can only reach it through process
|
||||
/// state — and process state that is typed and lock-guarded beats the `set_var` pattern the
|
||||
/// backend-select still uses (security-review 2026-06-28 #7).
|
||||
/// state — and process state that is typed and lock-guarded beats a `set_var`
|
||||
/// (security-review 2026-06-28 #7). The backend-select was the last holdout on that pattern and has
|
||||
/// since joined this one — see `SESSION_BACKEND`.
|
||||
static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::RwLock::new(None);
|
||||
|
||||
/// Anchor absolute coordinates at a specific output. `None` (the default) keeps the size-matched
|
||||
@@ -173,6 +174,66 @@ pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The backend the LIVE SESSION resolved to, published by the host from
|
||||
/// `pf_vdisplay::input_backend_id` when it routes a connect or a mid-stream Desktop↔Game switch.
|
||||
///
|
||||
/// A `RwLock` rather than an env var, for the reason [`ABSOLUTE_ANCHOR`] already gives: the injector
|
||||
/// is host-lifetime and lives behind a channel, so a *session* can only reach it through process
|
||||
/// state — and typed, lock-guarded process state beats `set_var`. This slot IS the backend-select
|
||||
/// that doc pointed at as the last holdout. It was a process-environment write in
|
||||
/// `pf_vdisplay::apply_input_env` read back here by `getenv`, and [`default_backend`] runs once per
|
||||
/// input batch on the injector service thread: a `getenv` on a hot path racing a per-session
|
||||
/// `setenv` is the `environ` data race, on a live streaming host, with no attacker needed
|
||||
/// (security-review 2026-08-25).
|
||||
///
|
||||
/// Host-lifetime and last-write-wins, exactly as the env var was: the host serves one session's
|
||||
/// input at a time, and nothing clears this when a session ends (the env value persisted too).
|
||||
#[cfg(target_os = "linux")]
|
||||
static SESSION_BACKEND: std::sync::RwLock<Option<Backend>> = std::sync::RwLock::new(None);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn session_backend() -> Option<Backend> {
|
||||
*SESSION_BACKEND.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The [`Backend`] an id names, in every spelling the `PUNKTFUNK_INPUT_BACKEND` knob accepts.
|
||||
/// Shared by that knob and by [`set_backend_id`], so the two can never drift apart.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn backend_from_id(id: &str) -> Option<Backend> {
|
||||
Some(match id.trim().to_ascii_lowercase().as_str() {
|
||||
"wlr" | "wlroots" | "wlrvirtual" => Backend::WlrVirtual,
|
||||
"kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => Backend::KwinFakeInput,
|
||||
"libei" | "ei" | "portal" => Backend::Libei,
|
||||
"gamescope" | "gamescope-ei" => Backend::GamescopeEi,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Point input at the backend this session's VIDEO resolved to (the two must not diverge). `id` is
|
||||
/// `pf_vdisplay::input_backend_id`'s verdict — `gamescope`/`kwin`/`libei`/`wlr`.
|
||||
///
|
||||
/// Threaded from the host rather than published through `PUNKTFUNK_INPUT_BACKEND`, the way
|
||||
/// `VirtualDisplay::set_launch_command` took the launch command off the env before it. Call it
|
||||
/// wherever a session's compositor is decided or re-decided; the operator-pinned
|
||||
/// `PUNKTFUNK_COMPOSITOR` path deliberately does not, which is what leaves the operator's own knob
|
||||
/// in charge there.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_backend_id(id: &str) {
|
||||
let Some(backend) = backend_from_id(id) else {
|
||||
tracing::warn!(
|
||||
value = id,
|
||||
"unknown input backend id — leaving input routing alone"
|
||||
);
|
||||
return;
|
||||
};
|
||||
tracing::debug!(?backend, "input: session backend set");
|
||||
*SESSION_BACKEND.write().unwrap_or_else(|e| e.into_inner()) = Some(backend);
|
||||
}
|
||||
|
||||
/// The host routes input on every platform; only Linux has a backend to choose between.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn set_backend_id(_id: &str) {}
|
||||
|
||||
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
|
||||
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
|
||||
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
|
||||
@@ -182,18 +243,25 @@ pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
|
||||
/// Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` API rather than the portal
|
||||
/// (`libei_ei_source`), so it is headless-capable too: no interactive approval to answer.
|
||||
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope` overrides the auto-detection.
|
||||
///
|
||||
/// Resolution order, unchanged from when the session's pick arrived through the process env:
|
||||
/// **the live session's published backend** ([`set_backend_id`]) — which the host writes from
|
||||
/// `pf_vdisplay::input_backend_id`, and which used to be a `set_var` of `PUNKTFUNK_INPUT_BACKEND`
|
||||
/// that overwrote the operator's own value — then the operator's `PUNKTFUNK_INPUT_BACKEND`, then
|
||||
/// the `PUNKTFUNK_COMPOSITOR` pin, then the `XDG_CURRENT_DESKTOP` sniff. The env rungs are reached
|
||||
/// only before any session has published (and on the operator-pinned path, which deliberately
|
||||
/// publishes nothing), so this stays off `getenv` entirely once a stream is up — see
|
||||
/// [`SESSION_BACKEND`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn default_backend() -> Backend {
|
||||
if let Some(b) = session_backend() {
|
||||
return b;
|
||||
}
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
|
||||
match v.trim().to_ascii_lowercase().as_str() {
|
||||
"wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual,
|
||||
"kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => {
|
||||
return Backend::KwinFakeInput
|
||||
}
|
||||
"libei" | "ei" | "portal" => return Backend::Libei,
|
||||
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
|
||||
other => tracing::warn!(
|
||||
value = other,
|
||||
match backend_from_id(&v) {
|
||||
Some(b) => return b,
|
||||
None => tracing::warn!(
|
||||
value = v.trim(),
|
||||
"unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting"
|
||||
),
|
||||
}
|
||||
@@ -715,3 +783,48 @@ mod sendinput;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/wlr.rs"]
|
||||
mod wlr;
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod backend_select_tests {
|
||||
use super::*;
|
||||
|
||||
/// The session's backend reaches the injector as a published VALUE ([`set_backend_id`]) and
|
||||
/// outranks every env rung below it — which is exactly the precedence the old
|
||||
/// `set_var("PUNKTFUNK_INPUT_BACKEND", ..)` had, since it OVERWROTE whatever was there. It is a
|
||||
/// `RwLock` read now because [`default_backend`] runs once per input batch on the injector
|
||||
/// service thread, and a `getenv` there raced the connect path's `setenv`
|
||||
/// (security-review 2026-08-25).
|
||||
///
|
||||
/// Deliberately makes no claim about the environment: the point is that nothing needs to write
|
||||
/// it, so the test does not write one either.
|
||||
#[test]
|
||||
fn the_session_backend_threads_through_instead_of_the_process_env() {
|
||||
set_backend_id("gamescope");
|
||||
assert_eq!(default_backend(), Backend::GamescopeEi);
|
||||
// A mid-stream Game→Desktop switch re-publishes; input follows, with no env write.
|
||||
set_backend_id("kwin");
|
||||
assert_eq!(default_backend(), Backend::KwinFakeInput);
|
||||
// An id nobody recognises must leave routing where it was rather than silently retargeting
|
||||
// input at a backend the video side did not choose.
|
||||
set_backend_id("not-a-backend");
|
||||
assert_eq!(default_backend(), Backend::KwinFakeInput);
|
||||
}
|
||||
|
||||
/// Every id `pf_vdisplay::input_backend_id` can emit must be one this crate accepts. The two are
|
||||
/// halves of one contract across a crate boundary that no compiler checks — pf-vdisplay must not
|
||||
/// depend on pf-inject (its manifest says so), so it emits `&'static str` and this pins the
|
||||
/// receiving end. Its counterpart is pf-vdisplay's
|
||||
/// `every_compositor_names_the_injector_backend_that_matches_it`.
|
||||
#[test]
|
||||
fn every_id_the_video_side_emits_maps_to_a_backend() {
|
||||
for (id, want) in [
|
||||
("gamescope", Backend::GamescopeEi),
|
||||
("kwin", Backend::KwinFakeInput),
|
||||
("libei", Backend::Libei),
|
||||
("wlr", Backend::WlrVirtual),
|
||||
] {
|
||||
assert_eq!(backend_from_id(id), Some(want), "{id}");
|
||||
}
|
||||
assert_eq!(backend_from_id("not-a-backend"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+116
-47
@@ -5,6 +5,7 @@
|
||||
//!
|
||||
//! - [`config_dir`] resolves the per-host config directory (XDG / `%ProgramData%`, `PUNKTFUNK_CONFIG_DIR` override).
|
||||
//! - [`create_private_dir`] makes it owner-private (0700 / restrictive DACL).
|
||||
//! - [`create_secret_dir`] the same, minus the Windows `BUILTIN\Users` read grant.
|
||||
//! - [`write_secret_file`] writes an owner-only secret (0600 / SYSTEM+Admins DACL).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -94,11 +95,32 @@ 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, first_hardening_of(dir));
|
||||
restrict_dir_to_system_admins(dir, first_hardening_of(dir), true);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// [`create_private_dir`] without the Windows `BUILTIN\Users` read grant — for a subdirectory whose
|
||||
/// *contents* are secrets rather than merely tamper-sensitive config: the host/service logs and the
|
||||
/// client log bundles paired devices upload.
|
||||
///
|
||||
/// The config dir's `Users:(OI)(CI)(RX)` is deliberate (the tray reads `mgmt-endpoint` out of it),
|
||||
/// but `(OI)` means every file born anywhere under it inherits that read — which left the logs
|
||||
/// (webhook URLs, command lines) and the uploaded bundles readable by any local user, the latter
|
||||
/// flatly contradicting the "reading them stays on the loopback-only bearer lane" split
|
||||
/// `mgmt::client_logs` documents (security-review 2026-08-25). Unix behaviour is identical to
|
||||
/// [`create_private_dir`] (0700 — the mode already excludes everyone else).
|
||||
pub fn create_secret_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let r = std::fs::create_dir_all(dir);
|
||||
restrict_dir_to_system_admins(dir, first_hardening_of(dir), false);
|
||||
r
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
create_private_dir(dir)
|
||||
}
|
||||
|
||||
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
|
||||
/// expensive recursive re-own.
|
||||
///
|
||||
@@ -136,7 +158,9 @@ pub fn restrict_existing_secret_file(path: &std::path::Path) {
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
restrict_to_system_admins(path);
|
||||
if let Err(e) = restrict_to_system_admins(path) {
|
||||
tracing::warn!(path = %path.display(), error = %e, "icacls hardening did not succeed");
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
|
||||
@@ -158,12 +182,13 @@ fn icacls_path() -> String {
|
||||
/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts
|
||||
/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a
|
||||
/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full
|
||||
/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing
|
||||
/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files
|
||||
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
|
||||
/// (object+container inherit so child files/dirs inherit it), and — when `users_read` — Users
|
||||
/// **read-only** (so existing reads of non-secret config keep working but a local user can no longer
|
||||
/// write/plant). [`create_secret_dir`] passes `false` for a dir whose contents are all secrets, and
|
||||
/// secret files 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, deep: bool) {
|
||||
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool, users_read: 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).
|
||||
@@ -183,24 +208,27 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
let status = std::process::Command::new(&icacls)
|
||||
.arg(dir.as_os_str())
|
||||
.args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
|
||||
// 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)
|
||||
])
|
||||
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) in the grant below, 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.
|
||||
let mut acl = std::process::Command::new(&icacls);
|
||||
acl.arg(dir.as_os_str()).args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
|
||||
]);
|
||||
if users_read {
|
||||
// BUILTIN\Users — read-only (no create/write → no plant). `(OI)` reaches every FILE born
|
||||
// under here as well, which is why [`create_secret_dir`] leaves this ACE off entirely.
|
||||
acl.args(["/grant:r", "*S-1-5-32-545:(OI)(CI)(RX)"]);
|
||||
}
|
||||
let status = acl
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
@@ -219,18 +247,21 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
|
||||
/// 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.
|
||||
/// **Windows ordering** (2026-08-05 review L-17, corrected by security-review 2026-08-25): the file
|
||||
/// cannot be BORN with the right DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`
|
||||
/// and this crate is `#![forbid(unsafe_code)]`, so it cannot call `CreateFileW` itself. So it is
|
||||
/// created EMPTY, `icacls`'d, and only then written — `install::set_web_password`'s ordering, and
|
||||
/// the DACL step is **fatal**.
|
||||
///
|
||||
/// 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.
|
||||
/// This used to write first and harden after, on the argument that the INHERITED ACL was already
|
||||
/// SYSTEM/Administrators-only. It never was: [`restrict_dir_to_system_admins`] deliberately grants
|
||||
/// `BUILTIN\Users` `(OI)(CI)(RX)` so non-secret config stays readable, and `(OI)` means every file
|
||||
/// born in the config dir inherits that read. Every secret was therefore world-readable for the
|
||||
/// life of the `icacls` child — long enough for a `ReadDirectoryChangesW` watcher (which the same
|
||||
/// directory ACL permits by design) to take `native-key.pem`, `key.pem` and `mgmt-token`. The
|
||||
/// `icacls` call is the ONLY control here, not defence in depth, which is also why a failure now
|
||||
/// returns an error and unlinks the still-empty file instead of filling it with a secret anyone can
|
||||
/// read. The open handle carries our own write access across the DACL change.
|
||||
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();
|
||||
@@ -241,6 +272,13 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
#[cfg(windows)]
|
||||
if let Err(e) = restrict_to_system_admins(path) {
|
||||
drop(f);
|
||||
// Never leave a 0-byte secret behind: callers gate on "does it exist / is it non-empty".
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(e);
|
||||
}
|
||||
f.write_all(contents)?;
|
||||
f.flush()?;
|
||||
#[cfg(unix)]
|
||||
@@ -248,19 +286,18 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
restrict_to_system_admins(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
|
||||
/// Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
|
||||
/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually
|
||||
/// running user keeps access). Without this the host key under the default Users-readable
|
||||
/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs
|
||||
/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust
|
||||
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
|
||||
/// `PATH`). Reports failure to the caller: [`write_secret_file`] treats it as fatal (it is the only
|
||||
/// control over the bytes it is about to write), [`restrict_existing_secret_file`] only warns.
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
fn restrict_to_system_admins(path: &std::path::Path) -> std::io::Result<()> {
|
||||
let icacls = icacls_path();
|
||||
let status = std::process::Command::new(icacls)
|
||||
.arg(path.as_os_str())
|
||||
@@ -275,14 +312,15 @@ fn restrict_to_system_admins(path: &std::path::Path) {
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
_ => tracing::warn!(
|
||||
path = %path.display(),
|
||||
"icacls hardening did not succeed — this secret may be readable by other local users"
|
||||
),
|
||||
.status()?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(std::io::Error::other(format!(
|
||||
"icacls could not restrict {} to SYSTEM/Administrators ({status}) — it would be readable \
|
||||
by other local users",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -321,4 +359,35 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The unix half of the create-empty → harden → write ordering: a secret is never even briefly
|
||||
/// group/world-readable, on the first write OR the truncate-and-rewrite one. (The Windows half —
|
||||
/// the `icacls` step being fatal — can only be exercised on Windows.)
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn secrets_are_owner_only_on_create_and_on_rewrite() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("pf-paths-secret-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
create_secret_dir(&dir).unwrap();
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&dir), 0o700, "a secrets dir is owner-only");
|
||||
|
||||
let key = dir.join("key.pem");
|
||||
write_secret_file(&key, b"-----BEGIN PRIVATE KEY-----\n").unwrap();
|
||||
assert_eq!(mode(&key), 0o600);
|
||||
write_secret_file(&key, b"rotated").unwrap();
|
||||
assert_eq!(mode(&key), 0o600, "the rewrite path keeps 0600");
|
||||
assert_eq!(std::fs::read(&key).unwrap(), b"rotated", "and truncates");
|
||||
|
||||
// A pre-existing world-readable file is tightened, not adopted.
|
||||
let planted = dir.join("mgmt-token");
|
||||
std::fs::write(&planted, b"old").unwrap();
|
||||
std::fs::set_permissions(&planted, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
write_secret_file(&planted, b"new").unwrap();
|
||||
assert_eq!(mode(&planted), 0o600);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use anyhow::{bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
use pf_client_core::video::{DmabufFrame, DrmFrameGuard};
|
||||
use std::os::fd::{BorrowedFd, IntoRawFd as _};
|
||||
use std::os::fd::{AsRawFd as _, BorrowedFd, IntoRawFd as _};
|
||||
|
||||
/// `fourcc('N','V','1','2')` — 8-bit 4:2:0 VAAPI output.
|
||||
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
||||
@@ -297,14 +297,15 @@ fn plane_image(
|
||||
.context("no importable memory type for dmabuf")?;
|
||||
|
||||
// Vulkan owns the fd it imports — dup so the decoder guard keeps the original.
|
||||
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
||||
// type and live for the call, and every builder struct is a local that outlives it.
|
||||
// SAFETY: `fd` is open for the whole borrow — it is a plane fd of the caller's
|
||||
// `DmabufFrame`, whose `DrmFrameGuard` (the thing that closes those fds) lives until
|
||||
// `import` hands it to the `HwFrame`. The borrow ends at `try_clone_to_owned`, which dups.
|
||||
let owned = unsafe { BorrowedFd::borrow_raw(fd) }
|
||||
.try_clone_to_owned()
|
||||
.context("dup dmabuf fd")?;
|
||||
let mut import_info = vk::ImportMemoryFdInfoKHR::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||
.fd(owned.into_raw_fd());
|
||||
.fd(owned.as_raw_fd());
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image);
|
||||
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
||||
// type and live for the call, and every builder struct is a local that outlives it.
|
||||
@@ -319,7 +320,9 @@ fn plane_image(
|
||||
)
|
||||
}
|
||||
.context("import dmabuf memory")?;
|
||||
// (On allocate_memory failure Vulkan still closed the dup'd fd — nothing leaks.)
|
||||
// Vulkan takes the fd only on a SUCCESSFUL import, so release `owned` here and let it close
|
||||
// the dup on every failure path above (`?` drops it) instead of leaking one fd per frame.
|
||||
let _ = owned.into_raw_fd();
|
||||
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
||||
// type and live for the call, and every builder struct is a local that outlives it.
|
||||
if let Err(e) = unsafe { device.bind_image_memory(image, memory, 0) } {
|
||||
|
||||
@@ -850,13 +850,14 @@ impl Presenter {
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
|
||||
let mut pc = [0f32; 16];
|
||||
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
||||
pc[..12].copy_from_slice(rows.as_flattened());
|
||||
pc[12] = mode;
|
||||
pc[13] = peak;
|
||||
// Crop: 1.0 unless the source image is a decode pool bigger than the picture.
|
||||
pc[14] = uv_scale[0];
|
||||
pc[15] = uv_scale[1];
|
||||
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
||||
let words = pc.map(f32::to_ne_bytes);
|
||||
let bytes = words.as_flattened();
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd_buf,
|
||||
self.csc.pipeline_layout,
|
||||
@@ -946,10 +947,11 @@ impl Presenter {
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
|
||||
let mut pc = [0f32; 16];
|
||||
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
||||
pc[..12].copy_from_slice(rows.as_flattened());
|
||||
pc[12] = mode;
|
||||
pc[13] = peak;
|
||||
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
||||
let words = pc.map(f32::to_ne_bytes);
|
||||
let bytes = words.as_flattened();
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd_buf,
|
||||
planar.pipeline_layout,
|
||||
@@ -1024,12 +1026,6 @@ fn csc_depth_packing_or_8bit(raw: RawVkFormat) -> (u8, bool) {
|
||||
})
|
||||
}
|
||||
|
||||
/// Flatten the 3×vec4 rows for the push-constant block.
|
||||
fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] {
|
||||
// SAFETY: [[f32;4];3] is 12 contiguous f32s.
|
||||
unsafe { std::slice::from_raw_parts(rows.as_ptr().cast::<f32>(), 12) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -522,12 +522,18 @@ fn chroma_offsets(
|
||||
/// `column_width_minus1` is `u32` in the parser and `u16` in libva; a value past
|
||||
/// 65535 columns is impossible for any real picture, so saturating is honest here
|
||||
/// and a wrap would not be.
|
||||
fn narrow_19(src: &[u32; 19]) -> [u16; 19] {
|
||||
std::array::from_fn(|i| u16::try_from(src[i]).unwrap_or(u16::MAX))
|
||||
///
|
||||
/// Takes a slice, not a fixed-size array: libva's 19/21 are a frozen ABI (see the
|
||||
/// `offset_of!` asserts in `va_h265`), while the parser's arrays carry one extra
|
||||
/// slot for the running remainder it stores at `[num_tile_*_minus1]`. Copying the
|
||||
/// first 19/21 is what libva wants anyway — it derives the last tile itself — and
|
||||
/// a slice means growing the parser side again cannot break this pair.
|
||||
fn narrow_19(src: &[u32]) -> [u16; 19] {
|
||||
std::array::from_fn(|i| u16::try_from(src.get(i).copied().unwrap_or(0)).unwrap_or(u16::MAX))
|
||||
}
|
||||
|
||||
fn narrow_21(src: &[u32; 21]) -> [u16; 21] {
|
||||
std::array::from_fn(|i| u16::try_from(src[i]).unwrap_or(u16::MAX))
|
||||
fn narrow_21(src: &[u32]) -> [u16; 21] {
|
||||
std::array::from_fn(|i| u16::try_from(src.get(i).copied().unwrap_or(0)).unwrap_or(u16::MAX))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -102,18 +102,18 @@ pub use session::{session_epoch, try_recover_session};
|
||||
/// Gamescope-session routing (plan §W3).
|
||||
#[path = "vdisplay/routing.rs"]
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, managed_session_available, preflight_takeover_privilege,
|
||||
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
|
||||
restore_takeover_on_startup, start_restore_worker, takeover_privilege_verdict,
|
||||
wants_dedicated_game_session, GamescopeRoute, TakeoverInapplicable, TakeoverVerdict,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
cancel_pending_tv_restore, dedicated_game_exited, focus_streamed_output,
|
||||
gamescope_xwayland_cursor_targets, launch_into_gamescope_session, launch_is_nested,
|
||||
steam_appid_from_launch, watch_steam_game_exit,
|
||||
};
|
||||
pub use routing::{
|
||||
input_backend_id, managed_session_available, preflight_takeover_privilege,
|
||||
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
|
||||
restore_takeover_on_startup, start_restore_worker, takeover_privilege_verdict,
|
||||
wants_dedicated_game_session, GamescopeRoute, TakeoverInapplicable, TakeoverVerdict,
|
||||
};
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -229,8 +229,9 @@ impl Compositor {
|
||||
///
|
||||
/// The **live session is the primary signal**, ahead of each backend's own probe. Those probes read
|
||||
/// the process env (`XDG_CURRENT_DESKTOP` for Mutter, `WAYLAND_DISPLAY` for KWin's registry
|
||||
/// handshake, `SWAYSOCK` for sway) — env a host started *outside* the session (a `systemd --user`
|
||||
/// unit, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path
|
||||
/// handshake, `SWAYSOCK` for sway — that last one *only* as inherited, since nothing exports it any
|
||||
/// more) — env a host started *outside* the session (a `systemd --user` unit, a TTY, ssh) never
|
||||
/// inherited. It is only retargeted at the live session on the connect path
|
||||
/// ([`apply_session_env`]), so enumerating before the first client connect reported "unavailable"
|
||||
/// for the very desktop the operator was sitting in — while [`detect`], which scans `/proc`, marked
|
||||
/// that same backend the default. The management API showed both badges on one row, and the answer
|
||||
@@ -287,15 +288,47 @@ fn compositor_from_pin(v: &str) -> Option<Compositor> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
|
||||
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
|
||||
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
||||
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
|
||||
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
|
||||
/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
|
||||
/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
|
||||
/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode)
|
||||
/// still carry a cross-session *value* confusion window inherent to a process-global env.
|
||||
/// Serializes **pf-vdisplay's own** process-env readers and writers on the per-session setup path,
|
||||
/// so two concurrent native sessions (each running `resolve_compositor` in its own
|
||||
/// `spawn_blocking`) can't interleave the retarget with each other's reads
|
||||
/// (security-review 2026-06-28 #7).
|
||||
///
|
||||
/// ## What it does NOT do
|
||||
///
|
||||
/// It does not make `set_var`/`remove_var` sound, and an earlier revision of this doc claimed it
|
||||
/// did. `setenv(3)` grows and replaces the `environ` array and swaps value pointers under it;
|
||||
/// `unsetenv(3)` shifts it. Any concurrent `getenv` anywhere in the process is a data race on that
|
||||
/// array — and by the time this path runs the host is a live streaming session with tokio, QUIC,
|
||||
/// mDNS, the HTTP API, capture threads and zbus tasks all up. None of glibc's own internals (`tzset`
|
||||
/// for log timestamps, `getaddrinfo`, gettext), zbus, wayland-client or the Mesa ICD loader takes
|
||||
/// this lock, and they cannot be made to. A client reconnect or a mid-stream Desktop↔Game switch is
|
||||
/// enough to hit it: best case a torn value, worst case a use-after-free mid-session
|
||||
/// (security-review 2026-08-25).
|
||||
///
|
||||
/// ## What actually fixes it
|
||||
///
|
||||
/// Not a wider lock — a shorter list. **Punktfunk no longer uses the process env as a channel to
|
||||
/// itself.** Every value one part of the host computes for another travels as a value: the launch
|
||||
/// command per session (`SessionContext.launch` → `set_launch_command`), the gamescope sub-mode
|
||||
/// ([`resolve_gamescope_route`]'s return → `set_gamescope_route`), the injector backend
|
||||
/// ([`input_backend_id`]'s return → `pf_inject::set_backend_id`, which retired the last
|
||||
/// `PUNKTFUNK_INPUT_BACKEND` write — its reader `getenv`s once per input BATCH, so that one was the
|
||||
/// sharpest edge of all), and `hyprctl`'s / `swaymsg`'s session handles, handed to those children
|
||||
/// with `Command::env`.
|
||||
///
|
||||
/// What is left is [`apply_session_env`]'s four — `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`,
|
||||
/// `WAYLAND_DISPLAY`, `XDG_CURRENT_DESKTOP`. These are the DESKTOP's variables, not ours: their
|
||||
/// readers are wayland-client, zbus, libpipewire and the Mesa loader, which take them from the
|
||||
/// process env and nowhere else, plus [`settle_desktop_portal`], which imports them into the
|
||||
/// activation environment BY NAME out of ours. (Punktfunk sniffs `XDG_CURRENT_DESKTOP` too —
|
||||
/// `mutter::is_available`, `detect`, `pf_inject::default_backend`'s last rung — but as a reader of
|
||||
/// the desktop's value, not as a channel of its own.) Threading them means connecting
|
||||
/// Wayland/D-Bus/PipeWire explicitly (`Connection::connect_to_socket`,
|
||||
/// `zbus::conn::Builder::address`); "set them once before threads exist" does not apply, because
|
||||
/// the values change per session — that is what this path is for.
|
||||
///
|
||||
/// The lock stays because ordering our own readers is still worth having, and because the writes
|
||||
/// that remain must not also race each other. Read it as "the discipline", never as "the proof".
|
||||
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
|
||||
@@ -726,7 +759,7 @@ pub fn gamescope_composites_cursor() -> bool {
|
||||
/// `GAMESCOPE_BIN` wrapper (or PATH shim), so the flags are ours.
|
||||
///
|
||||
/// **Ask the resolved ROUTE, never the env.** This used to test the spawn-vs-attach term by reading
|
||||
/// `PUNKTFUNK_GAMESCOPE_NODE`, which worked only while `apply_input_env` PUBLISHED its decision into
|
||||
/// `PUNKTFUNK_GAMESCOPE_NODE`, which worked only while the routing PUBLISHED its decision into
|
||||
/// that key. Phase 2.3 deleted the publication (routing.rs: "Nothing is written back to the two
|
||||
/// knobs") and left the key as an operator override — rung 2 of a 6-rung ladder — so the session
|
||||
/// that reaches [`GamescopeRoute::Attach`] at the ladder's rung 5 instead (a foreign gamescope on an
|
||||
|
||||
@@ -134,7 +134,7 @@ pub trait VirtualDisplay: Send {
|
||||
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
|
||||
fn set_launch_command(&mut self, _cmd: Option<String>) {}
|
||||
/// Set the RESOLVED gamescope sub-mode for this session (from
|
||||
/// [`apply_input_env`](crate::apply_input_env)). Carried on the backend instance for the same
|
||||
/// [`resolve_gamescope_route`](crate::resolve_gamescope_route)). Carried on the backend instance for the same
|
||||
/// reason as [`set_launch_command`](Self::set_launch_command) — it used to travel through
|
||||
/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`, where the GameStream plane and the mid-session switch
|
||||
/// watcher could overwrite one session's decision before another session's `create` read it.
|
||||
|
||||
@@ -68,7 +68,7 @@ pub struct GamescopeDisplay {
|
||||
/// per-instance discipline as `cmd`, and for the same reason: it used to arrive through
|
||||
/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`, which a concurrent connect could overwrite between
|
||||
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
/// ran `resolve_gamescope_route`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::panel_dpms`] darken hold — pending pickup by the
|
||||
@@ -629,14 +629,14 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
|
||||
// them (via the injected --nested-refresh + generated CVT modes, not the box's TV EDID) —
|
||||
// and relaunch it when the client's mode changes. Reuses the node + EIS discovery below.
|
||||
// THIS session's resolved sub-mode, handed over by the host from `apply_input_env`. It
|
||||
// THIS session's resolved sub-mode, handed over by the host from `resolve_gamescope_route`. It
|
||||
// used to be read out of the process env here, which meant a second connect could retarget
|
||||
// this one between the decision and this line.
|
||||
let (session_env, node_env) = match self.route.clone() {
|
||||
Some(crate::GamescopeRoute::Managed { client }) => (Some(client), None),
|
||||
Some(crate::GamescopeRoute::Attach { node }) => (None, Some(node)),
|
||||
Some(crate::GamescopeRoute::Spawn) => (None, None),
|
||||
// Nobody resolved a route (no `apply_input_env` on this path): bare spawn, which is
|
||||
// Nobody resolved a route (no `resolve_gamescope_route` on this path): bare spawn, which is
|
||||
// also what the ladder's own default arm picks.
|
||||
None => (None, None),
|
||||
};
|
||||
@@ -1059,7 +1059,7 @@ fn steamos_session_present() -> bool {
|
||||
|
||||
/// Does this box have the infrastructure the MANAGED gamescope mode drives — Bazzite's
|
||||
/// `gamescope-session-plus` or SteamOS's `gamescope-session`? The sub-mode ladder
|
||||
/// ([`crate::apply_input_env`]) only defaults to managed when this is true; a plain
|
||||
/// ([`crate::resolve_gamescope_route`]) only defaults to managed when this is true; a plain
|
||||
/// distro (neither present) falls through to the bare-spawn path instead of the old behaviour of
|
||||
/// defaulting to managed and then bailing on the missing session script.
|
||||
pub fn managed_session_available() -> bool {
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
|
||||
//!
|
||||
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
|
||||
//! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with
|
||||
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or it is discovered from `$XDG_RUNTIME_DIR/hypr/`
|
||||
//! and handed to each `hyprctl` child ([`hyprctl_command`]) — with the ScreenCast interface routed
|
||||
//! to xdph (`scripts/headless/portals.conf`).
|
||||
//!
|
||||
//! The focus contract [`focus_output`] rests on is verified on **Hyprland 0.56.2** (2026-08-17,
|
||||
//! headless probe against a real instance): `output create headless` leaves the new head
|
||||
@@ -195,18 +195,19 @@ impl HyprlandDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` (inherited from the session) **or** a discoverable instance socket
|
||||
/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env
|
||||
/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap,
|
||||
/// side-effect-free — safe on the enumeration path.
|
||||
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by an
|
||||
/// INHERITED `HYPRLAND_INSTANCE_SIGNATURE` **or** a discoverable instance socket under
|
||||
/// `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env import,
|
||||
/// unlike sway's `SWAYSOCK`). Cheap, side-effect-free — safe on the enumeration path.
|
||||
///
|
||||
/// Inherited is now all the signature can be: `apply_session_env` no longer exports it (the value
|
||||
/// goes to the `hyprctl` children instead — see [`hyprctl_command`]), so this reads only what the
|
||||
/// host was launched with. The socket scan below is the arm that matters, and it always was.
|
||||
///
|
||||
/// Both env reads take [`crate::with_env_lock`] — in ONE scope, so the pair is sampled from a single
|
||||
/// consistent view. This runs on a management worker (`/host/compositors` → [`crate::available`])
|
||||
/// concurrently with another connect's `apply_session_env`, which `set_var`s the signature for a
|
||||
/// live Hyprland session and `remove_var`s it for anything else; a glibc `getenv` racing that
|
||||
/// `setenv`/`unsetenv` is the `environ` realloc data race ENV_LOCK exists for. No caller holds the
|
||||
/// lock (it is not reentrant), and the `read_dir` below deliberately runs outside it.
|
||||
/// consistent view — which orders them against this crate's remaining env writers
|
||||
/// (`apply_session_env`'s four survivors) and nothing else. No caller holds the lock (it is not
|
||||
/// reentrant), and the `read_dir` below deliberately runs outside it.
|
||||
pub fn is_available() -> bool {
|
||||
let (sig, runtime) = crate::with_env_lock(|| {
|
||||
(
|
||||
@@ -887,12 +888,13 @@ const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
|
||||
/// job to settle, so it is the slowest helper on this path — and its result is already ignored.
|
||||
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Run `hyprctl <args>`, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the
|
||||
/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a
|
||||
/// hard failure, but for dispatch commands it can print an error with status 0 — see
|
||||
/// [`hyprctl_dispatch`].
|
||||
/// Run `hyprctl <args>`, returning stdout. `hyprctl` needs `HYPRLAND_INSTANCE_SIGNATURE` to reach
|
||||
/// the right instance socket; it is set on THIS CHILD ([`hyprctl_command`]) rather than exported
|
||||
/// into the host's own environment. It exits non-zero on a hard failure, but for dispatch commands
|
||||
/// it can print an error with status 0 — see [`hyprctl_dispatch`].
|
||||
fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
let out = crate::proc::output_within(Command::new("hyprctl").args(args), HYPRCTL_BUDGET)
|
||||
let mut cmd = hyprctl_command(args, crate::session::hypr_signature());
|
||||
let out = crate::proc::output_within(&mut cmd, HYPRCTL_BUDGET)
|
||||
.context("run hyprctl (is Hyprland installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
@@ -905,6 +907,24 @@ fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// The `hyprctl` invocation, with the live instance signature threaded onto the child.
|
||||
///
|
||||
/// It used to ride the process env: `apply_session_env` `set_var`'d it per connect (and
|
||||
/// `remove_var`'d it for a non-Hyprland session) so this child inherited it. Nothing outside
|
||||
/// pf-vdisplay ever read it, so that bought a per-connect `setenv` — a data race with any `getenv`
|
||||
/// on any other thread of a live streaming host — for a value one child needs. `Command::env` gives
|
||||
/// it to exactly that child, the way `set_launch_command` carries the launch. `sig` is `None` when
|
||||
/// there is no live Hyprland instance we can find: leave the child's env alone then, so an
|
||||
/// inherited one (host started inside the session) still wins.
|
||||
fn hyprctl_command(args: &[&str], sig: Option<String>) -> Command {
|
||||
let mut cmd = Command::new("hyprctl");
|
||||
cmd.args(args);
|
||||
if let Some(sig) = sig {
|
||||
cmd.env("HYPRLAND_INSTANCE_SIGNATURE", sig);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots
|
||||
/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent
|
||||
/// write between ours and xdph's read would silently steer capture at the other session's output.
|
||||
@@ -1544,6 +1564,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` reaches `hyprctl` as a per-CHILD override, never as a `set_var`
|
||||
/// on the host's own environment — that write was a `getenv` data race with every other thread
|
||||
/// of a live session (security-review 2026-08-25). Pinning both arms: a discovered signature is
|
||||
/// set on the child, and an undiscoverable one leaves the child's env untouched so an inherited
|
||||
/// signature still reaches the compositor.
|
||||
#[test]
|
||||
fn the_instance_signature_travels_on_the_child_not_the_process_env() {
|
||||
let overrides = |sig: Option<String>| -> Vec<(String, Option<String>)> {
|
||||
hyprctl_command(&["-j", "version"], sig)
|
||||
.get_envs()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.to_string_lossy().into_owned(),
|
||||
v.map(|v| v.to_string_lossy().into_owned()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
overrides(Some("abc123".to_string())),
|
||||
[(
|
||||
"HYPRLAND_INSTANCE_SIGNATURE".to_string(),
|
||||
Some("abc123".to_string())
|
||||
)]
|
||||
);
|
||||
assert!(overrides(None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_names_are_unique_and_prefixed() {
|
||||
let a = next_output_name();
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
|
||||
//! copy, which is where this was measured.
|
||||
//!
|
||||
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
|
||||
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
|
||||
//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast
|
||||
//! interface routed to xdpw (`scripts/headless/portals.conf`).
|
||||
//! Requirements: the host can reach the sway session — `SWAYSOCK` for swaymsg, inherited or
|
||||
//! discovered and set on each child ([`swaymsg_command`]), plus the portal activation env
|
||||
//! (`WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into `systemctl --user`, see
|
||||
//! `scripts/headless/prepare-session.sh`), with the ScreenCast interface routed to xdpw
|
||||
//! (`scripts/headless/portals.conf`).
|
||||
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -125,14 +126,19 @@ impl WlrootsDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
|
||||
/// (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration path.
|
||||
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by an INHERITED
|
||||
/// `SWAYSOCK` (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration
|
||||
/// path.
|
||||
///
|
||||
/// Under [`crate::with_env_lock`]: this runs on a management worker (`/host/compositors` →
|
||||
/// [`crate::available`]) concurrently with another connect's `apply_session_env`, which `set_var`s
|
||||
/// — and, when no sway session is live, `remove_var`s — this very key. A glibc `getenv` racing a
|
||||
/// `setenv` is the `environ` realloc data race ENV_LOCK exists for, and it is UB whichever key each
|
||||
/// side names. No caller holds the lock (the mutex is not reentrant).
|
||||
/// Inherited is now all it can be: `apply_session_env` no longer exports this key (the value goes
|
||||
/// to the `swaymsg` children instead — see [`swaymsg_command`]), so this can only ever report what
|
||||
/// the host was launched with, never what we ourselves wrote. That is the honest half: a
|
||||
/// `systemd --user` host inherits nothing, and [`crate::available`] covers it by asking the `/proc`
|
||||
/// scan whether a wlroots session is live BEFORE it consults this probe.
|
||||
///
|
||||
/// Still under [`crate::with_env_lock`]: it orders the read against this crate's remaining env
|
||||
/// writers (`apply_session_env`'s four survivors), which is all that lock has ever been able to do.
|
||||
/// No caller holds it — the mutex is not reentrant.
|
||||
pub fn is_available() -> bool {
|
||||
crate::with_env_lock(|| std::env::var_os("SWAYSOCK")).is_some()
|
||||
}
|
||||
@@ -632,13 +638,30 @@ const SWAYMSG_BUDGET: Duration = Duration::from_secs(5);
|
||||
/// settle, so it is the slowest helper on this path — and its result is already ignored.
|
||||
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
|
||||
|
||||
/// A bare `swaymsg`, with the live sway IPC socket threaded onto the child.
|
||||
///
|
||||
/// `SWAYSOCK` used to ride the process env: `apply_session_env` `set_var`'d it per connect (and
|
||||
/// `remove_var`'d it when nothing sway-shaped was live) so these children inherited it. Nothing
|
||||
/// outside pf-vdisplay ever read it, so that bought a per-connect `setenv` — a data race with any
|
||||
/// `getenv` on any other thread of a live streaming host — for a value two children need.
|
||||
/// `Command::env` gives it to exactly those children, the way `set_launch_command` carries the
|
||||
/// launch. `sock` is `None` when there is no sway IPC we can find: leave the child's env alone
|
||||
/// then, so an inherited one (host started inside the session) still wins.
|
||||
fn swaymsg_command(sock: Option<String>) -> Command {
|
||||
let mut cmd = Command::new("swaymsg");
|
||||
if let Some(sock) = sock {
|
||||
cmd.env("SWAYSOCK", sock);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `swaymsg -- <args>`, returning stdout (`--` so command tokens like `--custom` reach
|
||||
/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/
|
||||
/// stdout) when the command fails, so checking the status covers `{"success": false}` too.
|
||||
fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
let out =
|
||||
crate::proc::output_within(Command::new("swaymsg").arg("--").args(args), SWAYMSG_BUDGET)
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
let mut cmd = swaymsg_command(crate::session::sway_socket());
|
||||
let out = crate::proc::output_within(cmd.arg("--").args(args), SWAYMSG_BUDGET)
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg {:?} failed: {}{}",
|
||||
@@ -656,11 +679,9 @@ fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
|
||||
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
|
||||
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
|
||||
let out = crate::proc::output_within(
|
||||
Command::new("swaymsg").args(["-t", kind, "--raw"]),
|
||||
SWAYMSG_BUDGET,
|
||||
)
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
let mut cmd = swaymsg_command(crate::session::sway_socket());
|
||||
let out = crate::proc::output_within(cmd.args(["-t", kind, "--raw"]), SWAYMSG_BUDGET)
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg -t {kind} failed: {}",
|
||||
@@ -1061,6 +1082,33 @@ mod tests {
|
||||
assert_eq!(enable_argv("DP-1"), ["output", "DP-1", "enable"]);
|
||||
}
|
||||
|
||||
/// `SWAYSOCK` reaches `swaymsg` as a per-CHILD override, never as a `set_var` on the host's own
|
||||
/// environment — that write was a `getenv` data race with every other thread of a live session
|
||||
/// (security-review 2026-08-25). Pinning both arms: a known socket is set on the child, and an
|
||||
/// unknown one leaves the child's env untouched so an inherited `SWAYSOCK` still reaches sway.
|
||||
#[test]
|
||||
fn the_sway_socket_travels_on_the_child_not_the_process_env() {
|
||||
let overrides = |sock: Option<String>| -> Vec<(String, Option<String>)> {
|
||||
swaymsg_command(sock)
|
||||
.get_envs()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.to_string_lossy().into_owned(),
|
||||
v.map(|v| v.to_string_lossy().into_owned()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
overrides(Some("/run/user/1000/sway-ipc.1000.42.sock".to_string())),
|
||||
[(
|
||||
"SWAYSOCK".to_string(),
|
||||
Some("/run/user/1000/sway-ipc.1000.42.sock".to_string())
|
||||
)]
|
||||
);
|
||||
assert!(overrides(None).is_empty());
|
||||
}
|
||||
|
||||
fn head(connector: &str, enabled: bool) -> crate::monitors::PhysicalMonitor {
|
||||
crate::monitors::PhysicalMonitor {
|
||||
connector: connector.to_string(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Gamescope-session routing (plan §W3 — carved out of [`super`]): mode selection
|
||||
//! ([`pick_gamescope_mode`]), input-env routing ([`apply_input_env`]), dedicated-game-session
|
||||
//! ([`pick_gamescope_mode`]), injector-backend selection ([`input_backend_id`]), dedicated-game-session
|
||||
//! decisions/launch ([`wants_dedicated_game_session`], [`launch_into_gamescope_session`]), and the
|
||||
//! managed-session restore workers.
|
||||
|
||||
@@ -8,9 +8,9 @@ use super::*;
|
||||
/// The RESOLVED gamescope sub-mode for one session, with the payload `GamescopeDisplay::create`
|
||||
/// needs.
|
||||
///
|
||||
/// This is what [`apply_input_env`] hands back, and it is carried on the backend INSTANCE
|
||||
/// This is what [`resolve_gamescope_route`] hands back, and it is carried on the backend INSTANCE
|
||||
/// (`VirtualDisplay::set_gamescope_route`) exactly as `set_launch_command` carries the launch — not
|
||||
/// through process env. The env knobs used to BE the channel: `apply_input_env` wrote
|
||||
/// through process env. The env knobs used to BE the channel: `resolve_gamescope_route` wrote
|
||||
/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` and `create` read them back, but the lock was released in
|
||||
/// between, and the whole GameStream plane plus the mid-session switch watcher re-run the writer —
|
||||
/// so session B's decision could overwrite session A's before A's `create` ever read it. They
|
||||
@@ -79,7 +79,7 @@ fn pick_gamescope_mode(
|
||||
|
||||
/// The operator's gamescope overrides, sampled ONCE — at first use, and never written back.
|
||||
///
|
||||
/// `apply_input_env` used to both WRITE `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` (to publish the
|
||||
/// `resolve_gamescope_route` used to both WRITE `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` (to publish the
|
||||
/// sub-mode it chose) and READ them as operator overrides. Reading them live therefore fed the
|
||||
/// ladder its own previous output: the Attach arm set `_NODE=auto`, and `node_env` sits at rung 2 of
|
||||
/// [`pick_gamescope_mode`] — ABOVE `dedicated_launch` at rung 3 — so one Attach decision latched
|
||||
@@ -102,7 +102,7 @@ struct OperatorGamescope {
|
||||
managed: bool,
|
||||
attach: bool,
|
||||
/// The operator's `PUNKTFUNK_GAMESCOPE_NODE` VALUE, if set — the ladder needs its presence and
|
||||
/// `apply_input_env` needs its content to build the route.
|
||||
/// `resolve_gamescope_route` needs its content to build the route.
|
||||
node: Option<String>,
|
||||
/// Likewise `PUNKTFUNK_GAMESCOPE_SESSION` — the managed session flavour.
|
||||
session: Option<String>,
|
||||
@@ -137,26 +137,22 @@ fn operator_gamescope() -> &'static OperatorGamescope {
|
||||
})
|
||||
}
|
||||
|
||||
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
||||
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors.
|
||||
/// The injector backend that matches a video backend — the two must not diverge, so this is the one
|
||||
/// place that decides. `pf_inject::set_backend_id` takes the answer; the caller publishes it
|
||||
/// alongside [`resolve_gamescope_route`] whenever it routes a session.
|
||||
///
|
||||
/// For gamescope the sub-mode ladder ([`pick_gamescope_mode`]) selects **managed** (a host-managed
|
||||
/// session at the client's mode — tears the TV's autologin down on connect, restored on a debounced
|
||||
/// idle; only where session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at
|
||||
/// its own mode; explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the
|
||||
/// fallback for a foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless
|
||||
/// gamescope nesting the session's launch command — the plain-distro default).
|
||||
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over all of it.
|
||||
/// A `&'static str` because pf-vdisplay never depends on pf-inject (see the crate manifest's note),
|
||||
/// and these four ids are already the `PUNKTFUNK_INPUT_BACKEND` vocabulary the injector parses.
|
||||
///
|
||||
/// Returns the resolved [`GamescopeRoute`] when `chosen` is gamescope — the caller must carry it to
|
||||
/// the backend instance via `VirtualDisplay::set_gamescope_route`. It is a RETURN VALUE and no
|
||||
/// longer an env write precisely because two sessions connecting at once would otherwise clobber
|
||||
/// each other's decision through the process env.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[must_use = "the resolved gamescope route must reach the backend instance (set_gamescope_route)"]
|
||||
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<GamescopeRoute> {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backend = match chosen {
|
||||
/// This used to be an `unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", ..) }` inside
|
||||
/// `apply_input_env`, read back by `pf_inject::default_backend` — which runs once per input batch on
|
||||
/// the injector service thread. A `getenv` on that hot path racing this per-session `setenv` is the
|
||||
/// `environ` data race, on a live streaming host, reachable by nothing more exotic than a client
|
||||
/// reconnect (security-review 2026-08-25). Handing back a value removes the write entirely; the
|
||||
/// operator's own `PUNKTFUNK_INPUT_BACKEND` is still READ by the injector, and is no longer
|
||||
/// overwritten by us.
|
||||
pub fn input_backend_id(chosen: Compositor) -> &'static str {
|
||||
match chosen {
|
||||
Compositor::Gamescope => "gamescope",
|
||||
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
||||
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
|
||||
@@ -166,20 +162,26 @@ pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<Gam
|
||||
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
|
||||
// injector as sway/river, no code change.
|
||||
Compositor::Wlroots | Compositor::Hyprland => "wlr",
|
||||
};
|
||||
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (lib.rs) serializing
|
||||
// every process-env writer on the session-setup path; steady-state threads read cached
|
||||
// config, not the environment.
|
||||
unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend) };
|
||||
drop(_env_guard);
|
||||
resolve_gamescope_route(chosen, dedicated_launch)
|
||||
}
|
||||
}
|
||||
|
||||
/// The gamescope sub-mode ladder ALONE — no input-backend env write.
|
||||
/// The gamescope sub-mode ladder: **managed** (a host-managed session at the client's mode — tears
|
||||
/// the TV's autologin down on connect, restored on a debounced idle; only where session-plus/SteamOS
|
||||
/// actually exists), **attach** (mirror a running gamescope at its own mode; explicit via
|
||||
/// `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a foreign gamescope
|
||||
/// on an infra-less box), or **bare spawn** (a per-session headless gamescope nesting the session's
|
||||
/// launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over all
|
||||
/// of it.
|
||||
///
|
||||
/// Split out for the operator-pinned path (`PUNKTFUNK_COMPOSITOR` set), which deliberately leaves
|
||||
/// `PUNKTFUNK_INPUT_BACKEND` alone but still needs a route: without one, `create` would fall
|
||||
/// through to a bare spawn on a box the operator pinned to the managed session.
|
||||
/// Returns the resolved [`GamescopeRoute`] when `chosen` is gamescope — the caller must carry it to
|
||||
/// the backend instance via `VirtualDisplay::set_gamescope_route`. It is a RETURN VALUE and not an
|
||||
/// env write precisely because two sessions connecting at once would otherwise clobber each other's
|
||||
/// decision through the process env.
|
||||
///
|
||||
/// Nothing here touches the injector: a caller routing a session pairs this with
|
||||
/// [`input_backend_id`], while the operator-pinned (`PUNKTFUNK_COMPOSITOR`) path calls this ALONE —
|
||||
/// it deliberately leaves input routing to the operator's own knob, but still needs a route, or
|
||||
/// `create` would fall through to a bare spawn on a box pinned to the managed session.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[must_use = "the resolved gamescope route must reach the backend instance (set_gamescope_route)"]
|
||||
pub fn resolve_gamescope_route(
|
||||
@@ -190,8 +192,8 @@ pub fn resolve_gamescope_route(
|
||||
return None;
|
||||
}
|
||||
{
|
||||
// Sampled inside — `operator_gamescope` takes ENV_LOCK itself, and `apply_input_env` has
|
||||
// already dropped its guard before calling us (the mutex is not reentrant).
|
||||
// Sampled inside — `operator_gamescope` takes ENV_LOCK itself, and no caller holds it (the
|
||||
// mutex is not reentrant). Nothing on this path writes the env at all any more.
|
||||
let ov = operator_gamescope();
|
||||
let mode = pick_gamescope_mode(
|
||||
dedicated_launch,
|
||||
@@ -219,11 +221,6 @@ pub fn resolve_gamescope_route(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) -> Option<GamescopeRoute> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn resolve_gamescope_route(
|
||||
_chosen: Compositor,
|
||||
@@ -235,7 +232,7 @@ pub fn resolve_gamescope_route(
|
||||
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
||||
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
||||
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
||||
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
||||
/// `auto` honestly). Computed at the handshake and threaded into [`resolve_gamescope_route`] /
|
||||
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
||||
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||
use policy::GameSession;
|
||||
@@ -262,7 +259,7 @@ pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||
/// into the session — it would start twice. Takes the session's own resolved
|
||||
/// [`GamescopeRoute`] (from [`apply_input_env`]) rather than re-reading process env, so a
|
||||
/// [`GamescopeRoute`] (from [`resolve_gamescope_route`]) rather than re-reading process env, so a
|
||||
/// concurrent session's routing decision cannot change this session's answer.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn launch_is_nested(compositor: Compositor, route: Option<&GamescopeRoute>) -> bool {
|
||||
@@ -562,7 +559,23 @@ mod tests {
|
||||
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
||||
}
|
||||
|
||||
/// The ladder must not be able to read back its own output. `apply_input_env`'s Attach arm used
|
||||
/// The injector backend is a RETURN VALUE the caller hands to `pf_inject::set_backend_id`, never
|
||||
/// a `set_var` of `PUNKTFUNK_INPUT_BACKEND` — that write raced `pf_inject::default_backend`'s
|
||||
/// `getenv`, which runs once per input batch on the injector service thread
|
||||
/// (security-review 2026-08-25). Pinning the whole table because pf-vdisplay cannot depend on
|
||||
/// pf-inject: these four ids are one half of a cross-crate contract, and `pf-inject`'s
|
||||
/// `every_id_the_video_side_emits_maps_to_a_backend` pins the other.
|
||||
#[test]
|
||||
fn every_compositor_names_the_injector_backend_that_matches_it() {
|
||||
assert_eq!(input_backend_id(Compositor::Gamescope), "gamescope");
|
||||
assert_eq!(input_backend_id(Compositor::Kwin), "kwin");
|
||||
assert_eq!(input_backend_id(Compositor::Mutter), "libei");
|
||||
// Hyprland shares sway's wlr virtual-input protocols (D4) — same injector, on purpose.
|
||||
assert_eq!(input_backend_id(Compositor::Wlroots), "wlr");
|
||||
assert_eq!(input_backend_id(Compositor::Hyprland), "wlr");
|
||||
}
|
||||
|
||||
/// The ladder must not be able to read back its own output. `resolve_gamescope_route`'s Attach arm used
|
||||
/// to write `PUNKTFUNK_GAMESCOPE_NODE=auto`, and `node_env` outranks `dedicated_launch` — so
|
||||
/// while the override was read live, one Attach latched Attach for the host's lifetime and
|
||||
/// silently overrode `game_session=dedicated`. Sampling once is what breaks the loop, and it is
|
||||
|
||||
@@ -202,8 +202,10 @@ pub enum ActiveKind {
|
||||
/// The session environment that points a backend at the [detected](detect_active_session) active
|
||||
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
|
||||
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
|
||||
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
|
||||
/// per connect and every backend that reads them then opens against the live session.
|
||||
/// host serves one session at a time, so [`apply_session_env`] writes the first four into the
|
||||
/// process env per connect and every backend that reads them then opens against the live session.
|
||||
/// The last two are a *description* of the session, not something exported — their readers are
|
||||
/// `hyprctl` and `swaymsg`, which get them per spawn (see [`hypr_signature`] / [`sway_socket`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SessionEnv {
|
||||
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
|
||||
@@ -217,16 +219,16 @@ pub struct SessionEnv {
|
||||
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
|
||||
pub xdg_current_desktop: Option<String>,
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
|
||||
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
|
||||
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
|
||||
/// session env. `None` for every other compositor.
|
||||
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket, and
|
||||
/// it is handed to that child directly ([`hypr_signature`]) so the systemd-`--user` host works
|
||||
/// without inheriting the session env. `None` for every other compositor.
|
||||
pub hyprland_signature: Option<String>,
|
||||
/// `SWAYSOCK` of the live sway instance (`Some` only for a sway [`ActiveKind::DesktopWlroots`]).
|
||||
/// `swaymsg` needs it, and it was the LAST session variable the host could not derive: a
|
||||
/// `systemd --user` host that never inherited the login shell's environment had no sway IPC at
|
||||
/// all, so output enumeration and the chooser both failed. Derived from the detected compositor
|
||||
/// PID like the Hyprland signature above. `None` on river (wlroots, but no sway IPC) and every
|
||||
/// other compositor.
|
||||
/// PID like the Hyprland signature above, and likewise handed straight to the child
|
||||
/// ([`sway_socket`]). `None` on river (wlroots, but no sway IPC) and every other compositor.
|
||||
pub sway_socket: Option<String>,
|
||||
}
|
||||
|
||||
@@ -536,6 +538,35 @@ fn find_sway_socket(env: &EnvProbe, runtime: &str, uid: u32, pid: Option<u32>) -
|
||||
cands.into_iter().next().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// The `HYPRLAND_INSTANCE_SIGNATURE` to hand a `hyprctl` child, resolved at spawn time.
|
||||
///
|
||||
/// [`apply_session_env`] used to export this so `hyprctl` inherited it — a `setenv` on the connect
|
||||
/// path for a variable nothing outside this crate reads, i.e. a `getenv` race with every other
|
||||
/// thread bought for nothing. The reader takes it by argument now (`Command::env`), the way
|
||||
/// `set_launch_command` took the launch off the env before it. Resolving per spawn is also the more
|
||||
/// truthful answer: a Hyprland↔sway switch cannot leave a stale export pointing `hyprctl` at a dead
|
||||
/// instance, because there is no export.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn hypr_signature() -> Option<String> {
|
||||
let probe = EnvProbe::sample();
|
||||
let runtime = default_runtime_dir(&probe);
|
||||
find_hypr_signature(&probe, &runtime, crate::proc::current_uid())
|
||||
}
|
||||
|
||||
/// The `SWAYSOCK` to hand a `swaymsg` child, resolved at spawn time — the sway counterpart of
|
||||
/// [`hypr_signature`], off the process env for the same reason.
|
||||
///
|
||||
/// No compositor PID to match against here (detection's `/proc` scan is far too expensive to repeat
|
||||
/// per `swaymsg`), so this takes [`find_sway_socket`]'s other two arms: a valid inherited value, else
|
||||
/// the newest sway IPC socket we own. `None` on river (wlroots, no sway IPC) and when nothing
|
||||
/// sway-shaped is listening — which is what keeps `swaymsg` from being aimed at a dead socket.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn sway_socket() -> Option<String> {
|
||||
let probe = EnvProbe::sample();
|
||||
let runtime = default_runtime_dir(&probe);
|
||||
find_sway_socket(&probe, &runtime, crate::proc::current_uid(), None)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn detect_active_session() -> ActiveSession {
|
||||
ActiveSession::none()
|
||||
@@ -578,16 +609,30 @@ fn find_wayland_socket(env: &EnvProbe, runtime: &str, uid: u32) -> Option<String
|
||||
|
||||
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
|
||||
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
||||
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session; the next connect re-detects and
|
||||
/// re-applies.
|
||||
///
|
||||
/// Those four are what remains because their readers can only take them from the process env:
|
||||
/// wayland-client's `connect_to_env`, zbus's address lookup, libpipewire and the Mesa loader — plus
|
||||
/// [`settle_desktop_portal`], which imports them into the systemd / D-Bus activation environment BY
|
||||
/// NAME out of ours. Everything whose reader is code we own travels as a value instead: `hyprctl`'s
|
||||
/// instance signature and `swaymsg`'s socket are handed to those children with `Command::env`
|
||||
/// ([`hypr_signature`] / [`sway_socket`]), the launch command rides `set_launch_command`, and the
|
||||
/// gamescope sub-mode rides `set_gamescope_route`.
|
||||
///
|
||||
/// [`ENV_LOCK`] orders these writes against this crate's own env readers. It does **not** make them
|
||||
/// sound — see its doc — so shortening this list is the only thing that moves the needle.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_session_env(active: &ActiveSession) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let e = &active.env;
|
||||
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (see its doc in lib.rs)
|
||||
// that serializes every process-env writer on the session-setup path; steady-state streaming
|
||||
// threads read cached config, not the environment (security-review 2026-06-28 #7).
|
||||
// SAFETY: PARTIAL, and deliberately not a proof. `_env_guard` holds [`ENV_LOCK`], which orders
|
||||
// this against every env reader and writer *inside pf-vdisplay*, and steady-state streaming
|
||||
// threads read cached config rather than the environment. Nothing else in the process takes
|
||||
// that lock — not glibc, not zbus, not wayland-client, not the Mesa ICD loader — so each write
|
||||
// below is still a race with a concurrent `getenv` elsewhere, as `setenv(3)` always is. These
|
||||
// four survive only because their readers cannot be handed a value; every variable whose
|
||||
// readers are ours has been moved off (security-review 2026-06-28 #7, 2026-08-25).
|
||||
unsafe {
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||
@@ -597,25 +642,11 @@ pub fn apply_session_env(active: &ActiveSession) {
|
||||
if let Some(d) = &e.xdg_current_desktop {
|
||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||
}
|
||||
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live
|
||||
// compositor (fixes G4 for the systemd `--user` host, which never inherited it). Only set
|
||||
// when detection found a Hyprland session; a stale value from a previous connect is
|
||||
// cleared otherwise so a Hyprland→sway switch can't leave `hyprctl` pointed at a dead
|
||||
// instance.
|
||||
match &e.hyprland_signature {
|
||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
}
|
||||
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the
|
||||
// capture chooser) is unreachable without it, so a systemd `--user` host that never
|
||||
// inherited the login environment had no sway backend at all. Cleared when nothing
|
||||
// sway-shaped is live, so a sway→Hyprland switch can't leave `swaymsg` aimed at a dead
|
||||
// socket. `wlroots::is_available()` keys off this variable, so setting it here is also
|
||||
// what makes the backend visible at all.
|
||||
match &e.sway_socket {
|
||||
Some(sock) => std::env::set_var("SWAYSOCK", sock),
|
||||
None => std::env::remove_var("SWAYSOCK"),
|
||||
}
|
||||
// `HYPRLAND_INSTANCE_SIGNATURE` and `SWAYSOCK` were exported here too. Their only readers
|
||||
// are `hyprctl` and `swaymsg`, both spawned by this crate, so they travel to those children
|
||||
// as `Command::env` values now ([`hypr_signature`] / [`sway_socket`]): two fewer
|
||||
// `setenv`/`unsetenv` calls per connect, and nothing left to go stale across a Hyprland↔sway
|
||||
// switch — a child that is not spawned inherits no signature to be wrong about.
|
||||
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||
@@ -908,8 +939,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// river is the other wlroots desktop and ships no sway IPC. Reporting `None` is what keeps
|
||||
/// `apply_session_env` from exporting a `SWAYSOCK` that points at nothing — an exported lie
|
||||
/// would make `wlroots::is_available()` claim a backend that cannot answer.
|
||||
/// [`sway_socket`] from handing `swaymsg` a `SWAYSOCK` that points at nothing — a made-up
|
||||
/// socket would turn "no sway IPC here" into a connect that hangs or fails obscurely.
|
||||
#[test]
|
||||
fn no_sway_ipc_socket_reports_none() {
|
||||
let rt = FakeRuntime::new("none", &[]);
|
||||
|
||||
@@ -1047,13 +1047,13 @@ mod tests {
|
||||
num_tile_rows_minus1: 2,
|
||||
uniform_spacing_flag: false,
|
||||
column_width_minus1: {
|
||||
let mut w = [0u32; 19];
|
||||
let mut w = [0u32; 20];
|
||||
w[0] = 17;
|
||||
w[1] = 12;
|
||||
w
|
||||
},
|
||||
row_height_minus1: {
|
||||
let mut h = [0u32; 21];
|
||||
let mut h = [0u32; 22];
|
||||
h[0] = 9;
|
||||
h[1] = 8;
|
||||
h[2] = 16;
|
||||
|
||||
@@ -841,8 +841,8 @@ mod tests {
|
||||
num_tile_columns_minus1: 0,
|
||||
num_tile_rows_minus1: 0,
|
||||
uniform_spacing_flag: true,
|
||||
column_width_minus1: [0; 19],
|
||||
row_height_minus1: [0; 21],
|
||||
column_width_minus1: [0; 20],
|
||||
row_height_minus1: [0; 22],
|
||||
loop_filter_across_tiles_enabled_flag: true,
|
||||
loop_filter_across_slices_enabled_flag: false,
|
||||
deblocking_filter_control_present_flag: false,
|
||||
|
||||
@@ -746,8 +746,8 @@ mod tests {
|
||||
num_tile_columns_minus1: 0,
|
||||
num_tile_rows_minus1: 0,
|
||||
uniform_spacing_flag: true,
|
||||
column_width_minus1: [0; 19],
|
||||
row_height_minus1: [0; 21],
|
||||
column_width_minus1: [0; 20],
|
||||
row_height_minus1: [0; 22],
|
||||
loop_filter_across_tiles_enabled_flag: true,
|
||||
loop_filter_across_slices_enabled_flag: false,
|
||||
deblocking_filter_control_present_flag: false,
|
||||
|
||||
@@ -266,15 +266,9 @@ pub(crate) struct CudaApi {
|
||||
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
||||
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
||||
}
|
||||
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
||||
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
||||
// value with no interior mutability and no thread affinity. Moving the table to another thread
|
||||
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
|
||||
unsafe impl Send for CudaApi {}
|
||||
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
|
||||
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
|
||||
// points they address are themselves thread-safe.
|
||||
unsafe impl Sync for CudaApi {}
|
||||
// `Send`/`Sync` need no `unsafe impl`: every field is a bare fn pointer, which is already both.
|
||||
// The addresses stay valid because `cuda_api` `forget`s the `Library`, so `libcuda` is never
|
||||
// unloaded.
|
||||
|
||||
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
|
||||
/// the existing `ck()`/`!= 0` checks treat it as an ordinary driver error; distinct from any real
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#![allow(non_upper_case_globals)]
|
||||
|
||||
use anyhow::{bail, ensure, Result};
|
||||
use std::os::raw::{c_int, c_void};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
|
||||
pub(crate) const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||
pub(crate) const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||
@@ -70,7 +70,7 @@ unsafe extern "C" {
|
||||
pub(crate) fn glShaderSource(
|
||||
shader: u32,
|
||||
count: c_int,
|
||||
string: *const *const i8,
|
||||
string: *const *const c_char,
|
||||
length: *const c_int,
|
||||
);
|
||||
pub(crate) fn glCompileShader(shader: u32);
|
||||
@@ -80,7 +80,7 @@ unsafe extern "C" {
|
||||
pub(crate) fn glAttachShader(program: u32, shader: u32);
|
||||
pub(crate) fn glLinkProgram(program: u32);
|
||||
pub(crate) fn glGetProgramiv(program: u32, pname: u32, params: *mut c_int);
|
||||
pub(crate) fn glGetUniformLocation(program: u32, name: *const i8) -> c_int;
|
||||
pub(crate) fn glGetUniformLocation(program: u32, name: *const c_char) -> c_int;
|
||||
pub(crate) fn glUniform1i(location: c_int, v0: c_int);
|
||||
pub(crate) fn glDeleteProgram(program: u32);
|
||||
pub(crate) fn glTexSubImage2D(
|
||||
@@ -152,7 +152,7 @@ pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
|
||||
unsafe {
|
||||
let sh = glCreateShader(kind);
|
||||
ensure!(sh != 0, "glCreateShader failed");
|
||||
let ptr = src.as_ptr() as *const i8;
|
||||
let ptr = src.as_ptr() as *const c_char;
|
||||
let len = src.len() as c_int;
|
||||
glShaderSource(sh, 1, &ptr, &len);
|
||||
glCompileShader(sh);
|
||||
|
||||
@@ -631,9 +631,9 @@ impl VkBridge {
|
||||
let uv_off = y_pitch * height as u64;
|
||||
let dst_size = uv_off + y_pitch * height.div_ceil(2) as u64;
|
||||
// SAFETY: same structure and proofs as `import_linear` — `fd` is the caller's live dmabuf
|
||||
// (dup'd by `import_src`), sizes are checked (`import_src` asserts the fd covers
|
||||
// `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at least
|
||||
// the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
|
||||
// (dup'd by `import_src`), sizes are checked (every frame re-asserts that the imported src
|
||||
// covers `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at
|
||||
// least the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
|
||||
// update binds the live cached src buffer and the live dst buffer WHOLE_SIZE; every
|
||||
// `*Info`/array is a local outliving its synchronous call; `cmd`/`queue`/`fence` are this
|
||||
// bridge's own single-thread handles. The dispatch covers ⌈w/32⌉×⌈h/16⌉ groups of 8×8
|
||||
@@ -645,10 +645,15 @@ impl VkBridge {
|
||||
if !self.src_cache.contains_key(&fd) {
|
||||
let size = libc::lseek(fd, 0, libc::SEEK_END);
|
||||
anyhow::ensure!(size > 0, "lseek(dmabuf)");
|
||||
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
|
||||
self.import_src(fd, size as u64)?;
|
||||
}
|
||||
let src_buffer = self.src_cache[&fd].buffer;
|
||||
let (src_buffer, src_size) = {
|
||||
let s = &self.src_cache[&fd];
|
||||
(s.buffer, s.size)
|
||||
};
|
||||
// As in `import_linear`: this frame's chunk metadata, not the cached import's, decides
|
||||
// how far the shader reads.
|
||||
anyhow::ensure!(src_size >= span, "dmabuf smaller than frame span");
|
||||
self.ensure_dst(dst_size)?;
|
||||
self.ensure_csc()?;
|
||||
let (dst_buffer, dst_cuda_ptr) = {
|
||||
@@ -703,7 +708,8 @@ impl VkBridge {
|
||||
(uv_off / 4) as u32,
|
||||
(y_pitch / 4) as u32,
|
||||
];
|
||||
let push_bytes: &[u8] = std::slice::from_raw_parts(push.as_ptr().cast(), 28);
|
||||
let push_words = push.map(u32::to_ne_bytes);
|
||||
let push_bytes: &[u8] = push_words.as_flattened();
|
||||
self.device.cmd_push_constants(
|
||||
self.cmd,
|
||||
csc.playout,
|
||||
@@ -794,11 +800,11 @@ impl VkBridge {
|
||||
// SAFETY: `fd` is the live dmabuf fd handed in by the caller (borrowed; `import_src` dup's it
|
||||
// internally and Vulkan owns the dup). `libc::lseek` only queries the fd's size. The unsafe
|
||||
// `import_src`/`ensure_dst` are called with a valid fd and a checked size. The bounds are
|
||||
// proven: `import_src` asserts `size >= span` (so the cached `src_size >= span`),
|
||||
// `copy_size = src_size.min(span)`, and `ensure_dst(copy_size)` makes `dst` at least
|
||||
// `copy_size` — so the GPU `cmd_copy_buffer` of `copy_size` bytes reads/writes within both
|
||||
// buffers, and the later CUDA pitched copy reading `[offset, span)` from `dst.cuda.ptr` (=
|
||||
// `offset + stride*height = span <= copy_size`) stays inside the freshly-copied region. The
|
||||
// proven: `src_size >= span` is re-checked below against THIS frame's `offset`/`stride`
|
||||
// (an import cached on an earlier frame is not proof for this one), and `ensure_dst(span)`
|
||||
// makes `dst` at least `span` — so the GPU `cmd_copy_buffer` of `span` bytes reads/writes
|
||||
// within both buffers, and the later CUDA pitched copy reading `[offset, span)` from
|
||||
// `dst.cuda.ptr` (= `offset + stride*height = span`) stays inside the copied region. The
|
||||
// `*Info`/`region`/`cmds`/`submit` are locals that outlive the synchronous calls reading them.
|
||||
// `cmd`/`queue`/`fence` are this bridge's own handles, used on this single thread only. The
|
||||
// host-side `wait_for_fences` fully retires the Vulkan copy BEFORE CUDA reads the shared
|
||||
@@ -809,15 +815,17 @@ impl VkBridge {
|
||||
if !self.src_cache.contains_key(&fd) {
|
||||
let size = libc::lseek(fd, 0, libc::SEEK_END);
|
||||
anyhow::ensure!(size > 0, "lseek(dmabuf)");
|
||||
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
|
||||
self.import_src(fd, size as u64)?;
|
||||
}
|
||||
let (src_buffer, src_size) = {
|
||||
let s = &self.src_cache[&fd];
|
||||
(s.buffer, s.size)
|
||||
};
|
||||
let copy_size = src_size.min(span);
|
||||
self.ensure_dst(copy_size)?;
|
||||
// Per frame, not per import: `offset`/`stride` come from this frame's PipeWire chunk
|
||||
// metadata, so a cached import can be smaller than the span they describe. Clamping
|
||||
// the Vulkan copy instead would leave the CUDA de-stride below reading past `dst`.
|
||||
anyhow::ensure!(src_size >= span, "dmabuf smaller than frame span");
|
||||
self.ensure_dst(span)?;
|
||||
let dst = self.dst.as_ref().unwrap();
|
||||
|
||||
// Record + submit the GPU copy, wait on the fence (GPU-GPU, sub-millisecond).
|
||||
@@ -828,7 +836,7 @@ impl VkBridge {
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)
|
||||
.context("begin cmd")?;
|
||||
let region = vk::BufferCopy::default().size(copy_size);
|
||||
let region = vk::BufferCopy::default().size(span);
|
||||
self.device
|
||||
.cmd_copy_buffer(self.cmd, src_buffer, dst.buffer, &[region]);
|
||||
self.device
|
||||
|
||||
@@ -587,6 +587,18 @@ impl Config {
|
||||
"shard_payload too large to fit a datagram (header + crypto overhead)",
|
||||
));
|
||||
}
|
||||
// The floor the clamp helpers already bottom out at, enforced for the CLIENT: its value
|
||||
// comes off the wire from the peer (`Welcome::shard_payload`) and sets the reassembler's
|
||||
// per-frame shard floor ([`ReassemblerLimits::min_shard_bytes`]), so without this a
|
||||
// hostile Welcome drops that floor to two bytes and we accept confetti-sized shards for
|
||||
// the whole session. A host's value is always locally derived (every `shard_payload_for_*`
|
||||
// helper clamps here, and a client's ACK only ever confirms a size the host proposed), so
|
||||
// a hand-configured host below the floor stays legal.
|
||||
if self.role == Role::Client && self.shard_payload < MIN_SHARD_PAYLOAD {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
"negotiated shard_payload below MIN_SHARD_PAYLOAD",
|
||||
));
|
||||
}
|
||||
if self.fec.max_data_per_block == 0 {
|
||||
return Err(PunktfunkError::InvalidArg("max_data_per_block must be > 0"));
|
||||
}
|
||||
@@ -658,6 +670,26 @@ mod tests {
|
||||
assert!(c.validate().is_ok());
|
||||
}
|
||||
|
||||
/// The client's `shard_payload` is whatever the host's `Welcome` says, and the reassembler
|
||||
/// takes its per-frame shard floor from it — so a sub-floor negotiated value must be refused
|
||||
/// outright instead of quietly lowering that floor (a 2-byte shard is not a path this
|
||||
/// protocol runs on: it can't carry the QUIC control plane either).
|
||||
#[test]
|
||||
fn rejects_negotiated_shard_payload_below_the_floor() {
|
||||
let mut c = Config::p1_defaults(Role::Client);
|
||||
c.shard_payload = 2;
|
||||
assert!(c.validate().is_err());
|
||||
c.shard_payload = MIN_SHARD_PAYLOAD - 2;
|
||||
assert!(c.validate().is_err());
|
||||
c.shard_payload = MIN_SHARD_PAYLOAD;
|
||||
assert!(c.validate().is_ok());
|
||||
// The reassembler's floor can no longer be dragged below the production one by a peer.
|
||||
assert_eq!(
|
||||
crate::packet::ReassemblerLimits::from_config(&c).min_shard_bytes,
|
||||
MIN_SHARD_PAYLOAD
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_shard_payload() {
|
||||
let mut c = Config::p1_defaults(Role::Host);
|
||||
|
||||
@@ -155,7 +155,9 @@ impl ReassemblerLimits {
|
||||
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
|
||||
ReassemblerLimits {
|
||||
// `.min(c.shard_payload)`: never reject the session's own negotiated value — a
|
||||
// hand-configured session below the production floor still reassembles itself.
|
||||
// hand-configured session below the production floor still reassembles itself. It
|
||||
// can't be a NEGOTIATED one: `Config::validate` refuses a sub-floor value on the
|
||||
// client, which is the only side that takes it from the peer's `Welcome`.
|
||||
min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload),
|
||||
max_shard_bytes: crate::config::max_shard_payload(),
|
||||
max_data_shards: max_data,
|
||||
@@ -381,9 +383,11 @@ impl Reassembler {
|
||||
// final block first.
|
||||
let slice_stream = hdr.user_flags & crate::packet::USER_FLAG_SLICE_STREAM != 0;
|
||||
let block_idx = hdr.block_index as usize;
|
||||
// For a sentinel-opened frame the buffer must hold ANY final geometry the totals may
|
||||
// later pin — the maximum the negotiated limits allow (the design's "allocate at
|
||||
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
|
||||
// The most data shards any frame can carry under THIS packet's shard size: the ceiling the
|
||||
// per-frame block caps below derive from, and the clamp on a frame's buffer extent. NOT
|
||||
// what a frame is allocated at — that is only ever the extent its packets have PROVEN
|
||||
// (see `need_shards`), which is what keeps one small datagram from committing the whole
|
||||
// negotiated frame ceiling; the in-flight budget bounds the rest.
|
||||
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
|
||||
// The per-frame FEC-block ceiling under THIS packet's shard size (geometry is
|
||||
// per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level
|
||||
|
||||
@@ -562,6 +562,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Adopting a punch does more than pick a destination: `connect`ing to the *observed source*
|
||||
/// binds the data plane's full 5-tuple, so from that moment the kernel drops everything that
|
||||
/// isn't the punched peer. That is what stops a second source on the same authenticated IP (a
|
||||
/// co-NAT peer, another local process) from re-steering the video plane with a later punch or
|
||||
/// feeding the session's reassembler — the punch race is only ever open until the first
|
||||
/// accepted datagram fixes the tuple.
|
||||
#[test]
|
||||
fn a_punched_transport_only_accepts_the_punched_five_tuple() {
|
||||
let peer = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let reported = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
let host_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let host_addr = host_sock.local_addr().unwrap();
|
||||
peer.send_to(PUNCH_MAGIC, host_addr).unwrap();
|
||||
|
||||
let (transport, punched) = UdpTransport::from_socket_punch(
|
||||
host_sock,
|
||||
&reported.local_addr().unwrap().to_string(),
|
||||
std::net::IpAddr::from([127, 0, 0, 1]),
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(punched, "the peer's punch must be adopted");
|
||||
|
||||
// Same authenticated IP, different port: a punch AND a payload, both after the tuple is
|
||||
// fixed. Neither may be seen; the punched peer's datagram still must be.
|
||||
let stray = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
stray.send_to(PUNCH_MAGIC, host_addr).unwrap();
|
||||
stray.send_to(b"stray", host_addr).unwrap();
|
||||
peer.send_to(b"real", host_addr).unwrap();
|
||||
|
||||
let mut got: Vec<Vec<u8>> = Vec::new();
|
||||
for _ in 0..20 {
|
||||
match transport.recv().unwrap() {
|
||||
Some(p) => got.push(p),
|
||||
None => std::thread::sleep(std::time::Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![b"real".to_vec()],
|
||||
"only the punched 5-tuple may reach the session"
|
||||
);
|
||||
}
|
||||
|
||||
/// A punch from any source other than the QUIC-authenticated peer must be ignored: `PUNCH_MAGIC`
|
||||
/// is a fixed public constant with no key or session id, so honouring an off-peer punch lets
|
||||
/// anyone who lands an 8-byte datagram on the ephemeral data port steal (or redirect) the video
|
||||
|
||||
@@ -109,9 +109,13 @@ fn bundle_id(unix_ms: u64, fp_hex: &str, name: &str) -> String {
|
||||
}
|
||||
|
||||
impl ClientLogStore {
|
||||
/// Open the store, creating `dir` (owner-private, best-effort) if missing.
|
||||
/// Open the store, creating `dir` (owner-private, best-effort) if missing. `create_secret_dir`,
|
||||
/// not `create_private_dir`: on Windows the latter grants `BUILTIN\Users` an inheritable read,
|
||||
/// which every stored bundle then inherited — any local user could read them straight off disk,
|
||||
/// which is not the "reading them stays on the loopback-only bearer lane" split `mgmt::client_logs`
|
||||
/// documents (security-review 2026-08-25).
|
||||
pub fn new(dir: PathBuf) -> std::sync::Arc<Self> {
|
||||
if let Err(e) = pf_paths::create_private_dir(&dir) {
|
||||
if let Err(e) = pf_paths::create_secret_dir(&dir) {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "could not create client-logs dir");
|
||||
}
|
||||
std::sync::Arc::new(ClientLogStore { dir })
|
||||
@@ -121,7 +125,9 @@ impl ClientLogStore {
|
||||
/// Prunes that device's older bundles past [`KEEP_PER_DEVICE`] (best-effort).
|
||||
pub fn save(&self, fp_hex: &str, device_name: &str, body: &[u8]) -> std::io::Result<String> {
|
||||
let id = bundle_id(unix_ms_now(), fp_hex, device_name);
|
||||
std::fs::write(self.dir.join(format!("{id}.log")), body)?;
|
||||
// A bundle is whatever the client logged (addresses, host names) — owner-only, like the
|
||||
// host's own secrets, so the dir ACL is not the only thing keeping it off a local user.
|
||||
pf_paths::write_secret_file(&self.dir.join(format!("{id}.log")), body)?;
|
||||
// Prune this device's older bundles. The fp16 field is position 2 of the stem, and ids
|
||||
// sort chronologically because the timestamp leads.
|
||||
let fp16: String = fp_hex
|
||||
@@ -284,4 +290,22 @@ mod tests {
|
||||
assert!(listed.iter().any(|m| m.id == other), "other device pruned");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A bundle is stored owner-only — the unix half of "reading them stays on the loopback-only
|
||||
/// bearer lane"; on Windows the same `write_secret_file` call applies the SYSTEM/Admins DACL.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stored_bundles_are_not_world_readable() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (s, dir) = store();
|
||||
let id = s.save("abcdef0123456789", "Deck", b"secret log").unwrap();
|
||||
let md = std::fs::metadata(dir.join(format!("{id}.log"))).unwrap();
|
||||
assert_eq!(md.permissions().mode() & 0o077, 0, "bundle is owner-only");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o077,
|
||||
0,
|
||||
"so is the store dir"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,10 @@ fn terminate_blocking(shared: &LeaseShared) {
|
||||
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
|
||||
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
|
||||
/// pid the kernel has since recycled, contributes nothing.
|
||||
///
|
||||
/// And held to procscan's rule 1 like every other adopted process, because this is the one pid that
|
||||
/// arrives from *outside* the host: a plugin names it, so nothing but the start-time floor stands
|
||||
/// between `POST /game/end` and any process on the box — as SYSTEM, on Windows.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
|
||||
let pid = shared
|
||||
@@ -1027,7 +1031,41 @@ fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
|
||||
.and_then(crate::runstate::opinion)
|
||||
.filter(|l| l.running)?
|
||||
.pid?;
|
||||
crate::procscan::resolve(pid)
|
||||
let proc = crate::procscan::resolve(pid)?;
|
||||
if let Some(min) = shared.launch_stamp {
|
||||
let started = start_secs(proc);
|
||||
if started + crate::procscan::START_SLACK_SECS < min {
|
||||
return None; // predates this launch — never ours (rule 1)
|
||||
}
|
||||
}
|
||||
Some(proc)
|
||||
}
|
||||
|
||||
/// A resolved process's start time in seconds on the platform's process-start timeline — the scale
|
||||
/// [`LeaseShared::launch_stamp`] is on, so the two can be compared.
|
||||
///
|
||||
/// [`crate::procscan::ProcRef::start`] is otherwise opaque and its units are per-platform, and the
|
||||
/// matcher converts it inside each platform module rather than exposing it. Repeated here for the
|
||||
/// one pid the matcher never sees; `a_reported_pid_that_predates_the_launch_is_never_a_target`
|
||||
/// drives it against a real process, so the two cannot drift apart silently.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn start_secs(p: crate::procscan::ProcRef) -> f64 {
|
||||
#[cfg(target_os = "linux")]
|
||||
let per_sec = {
|
||||
// SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and
|
||||
// a non-positive answer (which is handled here) is its documented failure signal.
|
||||
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
|
||||
// The same fallback the matcher takes: a non-positive answer would poison the comparison.
|
||||
if ticks > 0 {
|
||||
ticks as f64
|
||||
} else {
|
||||
100.0
|
||||
}
|
||||
};
|
||||
// 100-nanosecond `FILETIME` ticks: the unit a Windows creation time is reported in.
|
||||
#[cfg(windows)]
|
||||
let per_sec = 10_000_000.0;
|
||||
p.start as f64 / per_sec
|
||||
}
|
||||
|
||||
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
|
||||
@@ -1746,6 +1784,69 @@ mod tests {
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
|
||||
}
|
||||
|
||||
/// 🛑 The pid a provider reports takes the same start-time floor every other adopted process
|
||||
/// takes. It is the only pid that reaches the termination ladder from *outside* the host, so
|
||||
/// that floor is the whole of what stands between a plugin and any process on the box.
|
||||
///
|
||||
/// Without it, a plugin that has a lease open reports the pid of anything it likes — security
|
||||
/// tooling, sshd, the operator's editor — and `POST /game/end` walks the ladder over it: on
|
||||
/// Windows the host is SYSTEM, so that reaches everything on the machine. The Playnite case is
|
||||
/// untouched, because it reports a pid it just started; the second half is that case.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_reported_pid_that_predates_the_launch_is_never_a_target() {
|
||||
const ID: &str = "playnite:reported-pid";
|
||||
const PROVIDER: &str = "playnite-reported-pid";
|
||||
|
||||
// Stands in for whatever a plugin decides to name: it exists before either stamp below.
|
||||
let mut victim = std::process::Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.expect("spawn the process a plugin would point at");
|
||||
crate::runstate::report(
|
||||
PROVIDER,
|
||||
[ID.to_string()].into_iter().collect(),
|
||||
[(ID.to_string(), Some(victim.id()))].into_iter().collect(),
|
||||
);
|
||||
|
||||
// A launch that happened a minute after that process started — so the report names
|
||||
// something this session never launched.
|
||||
let l = open(
|
||||
LeaseRequest {
|
||||
launch_stamp: launch_clock().map(|s| s + 60.0),
|
||||
..req(ID, DetectSpec::default(), false)
|
||||
},
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
|
||||
assert!(
|
||||
reported_proc(&l.shared()).is_none(),
|
||||
"a reported pid that predates the launch must not be signallable"
|
||||
);
|
||||
drop(l);
|
||||
|
||||
// The Playnite case, unchanged: the pid the provider started for this launch. The stamp is
|
||||
// taken *after* the process exists, which also pins the slack — an exact comparison would
|
||||
// reject the real game (`crate::procscan::START_SLACK_SECS`).
|
||||
let l = open(
|
||||
LeaseRequest {
|
||||
launch_stamp: launch_clock(),
|
||||
..req(ID, DetectSpec::default(), false)
|
||||
},
|
||||
Box::new(|| {}),
|
||||
);
|
||||
assert_eq!(
|
||||
reported_proc(&l.shared()).map(|p| p.pid),
|
||||
Some(victim.id()),
|
||||
"the pid a provider started for this launch is still what `End` aims at"
|
||||
);
|
||||
drop(l);
|
||||
|
||||
crate::runstate::forget(PROVIDER);
|
||||
let _ = victim.kill();
|
||||
let _ = victim.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untracked_lease_is_never_terminated() {
|
||||
let l = open(
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
//! 47999 (the PIN ceremony is HTTPS on nvhttp), so [`sync`] keeps the port closed until the
|
||||
//! first pairing lands and tears it down when the last one is removed.
|
||||
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use super::{AppState, LaunchSession, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::input::{GamepadEvent, InputEvent};
|
||||
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
use std::net::{IpAddr, UdpSocket};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -336,11 +336,14 @@ pub(crate) fn sync(state: &Arc<AppState>) -> Result<()> {
|
||||
/// `rusty_enet` 0.4.0 exposes no setter for `maximum_waiting_data` (the C default of 32 MiB of
|
||||
/// per-peer reassembly), so an off-path LAN peer that connects on 47999 can pin ~32 MiB × the
|
||||
/// `peer_limit` and occupy peer slots without ever authenticating — and the same unfiltered path
|
||||
/// lets an on-path attacker spoof the owner's source to feed the tracked peer. Filtering at the
|
||||
/// socket drops those datagrams BEFORE ENet allocates any per-peer state. The owner is read live
|
||||
/// from `launch` on each receive: before `/launch` (owner `None`) the filter passes everything,
|
||||
/// matching the plane's existing "trust the connect when no owner is captured" fallback used by
|
||||
/// the `Event::Connect` arm below. security-review 2026-08-15 findings 2 and 13.
|
||||
/// lets an on-path attacker spoof the owner's source to feed the tracked peer. Once the owner IS
|
||||
/// known, filtering at the socket drops those datagrams before ENet allocates any per-peer state.
|
||||
///
|
||||
/// The owner is read live from `launch` on each receive, so this covers only the window where a
|
||||
/// launch is recorded: before `/launch` (owner `None`) the filter passes everything, and what
|
||||
/// keeps an unauthenticated peer from squatting a slot through that whole idle window is
|
||||
/// [`accept_connect`] resetting the peer in the `Event::Connect` arm below.
|
||||
/// security-review 2026-08-15 findings 2 and 13; 2026-08-25 finding 1.
|
||||
struct OwnerFilteredSocket {
|
||||
inner: UdpSocket,
|
||||
state: Arc<AppState>,
|
||||
@@ -379,6 +382,23 @@ impl rusty_enet::Socket for OwnerFilteredSocket {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a fresh ENet connect may be admitted at all. A live `/launch` is the floor —
|
||||
/// Moonlight connects the control stream only after the RTSP handshake, so there is no such
|
||||
/// thing as a legitimate connect without one — and when the launching IP was captured on both
|
||||
/// sides it must match, the same source-IP bind the RTSP/media plane applies
|
||||
/// (`rtsp::authorized_launch`). Kept a free function so the gate the session actually runs is
|
||||
/// the thing the tests exercise.
|
||||
fn accept_connect(launch: Option<LaunchSession>, from: Option<IpAddr>) -> bool {
|
||||
match (launch.map(|l| l.peer_ip), from) {
|
||||
// No live `/launch`: nothing on this port is legitimate yet.
|
||||
(None, _) => false,
|
||||
// Launching IP known on both sides but mismatched → not the owner.
|
||||
(Some(Some(want)), Some(got)) => want == got,
|
||||
// The address couldn't be captured on one side → launch-present only.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the ENet control host on 47999 and service it on a dedicated thread until `stop`.
|
||||
fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
|
||||
@@ -503,23 +523,28 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
|
||||
match host.service() {
|
||||
Ok(Some(event)) => match event {
|
||||
Event::Connect { peer: p, .. } => {
|
||||
// Track this peer as THE session peer only if it comes from the
|
||||
// `/launch` owner's IP (when captured — `None` falls back to
|
||||
// trusting the connect, the pre-teardown behavior). The tracked
|
||||
// peer's disconnect now ENDS the session, so an unauthenticated
|
||||
// LAN peer that connects+disconnects on 47999 must not be able to
|
||||
// steal the slot and tear a live session down. Same source-IP
|
||||
// bind the RTSP/media plane uses (security-review #4).
|
||||
let owner_ip = state.launch.lock().unwrap().and_then(|s| s.peer_ip);
|
||||
// Admit only the launch owner ([`accept_connect`]): the tracked
|
||||
// peer's disconnect ENDS the session, so an unauthenticated LAN
|
||||
// peer that connects+disconnects on 47999 must not be able to
|
||||
// steal the slot and tear a live session down (security-review
|
||||
// #4). A refused peer is RESET, not merely left untracked — the
|
||||
// port is open the whole idle life of a paired host, and a peer
|
||||
// ENet keeps alive with its own pings would otherwise hold one of
|
||||
// four slots (plus its 32 MiB reassembly budget) indefinitely and
|
||||
// starve every later session (security-review 2026-08-25 #1).
|
||||
// `disconnect_now` frees the slot before the next tick and emits
|
||||
// no `Disconnect` event for the arm below to read as a session end.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
let from = p.address().map(|a| a.ip());
|
||||
if owner_ip.is_some() && from.is_some() && owner_ip != from {
|
||||
tracing::warn!(
|
||||
?from,
|
||||
"control: peer connected from a non-owner IP — ignoring"
|
||||
);
|
||||
} else {
|
||||
if accept_connect(launch, from) {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
} else {
|
||||
tracing::warn!(
|
||||
?from,
|
||||
"control: peer connected without a matching /launch — refusing"
|
||||
);
|
||||
p.disconnect_now(0);
|
||||
}
|
||||
}
|
||||
Event::Disconnect { peer: p, .. } => {
|
||||
@@ -1194,6 +1219,40 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// A live `/launch` whose owner IP is `peer_ip`.
|
||||
fn launched(peer_ip: Option<std::net::IpAddr>) -> Option<super::LaunchSession> {
|
||||
Some(super::LaunchSession {
|
||||
gcm_key: [0; 16],
|
||||
rikeyid: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip,
|
||||
owner_fp: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The ENet connect gate: 47999 is open the whole idle life of a paired host, so a connect
|
||||
/// with no live `/launch` behind it is refused (it would otherwise squat one of four peer
|
||||
/// slots until every later session fails), and a launched session admits only the owner's
|
||||
/// IP — falling back to launch-present-only when either side's address is unknown, like the
|
||||
/// RTSP plane's `authorized_launch`.
|
||||
#[test]
|
||||
fn connects_are_admitted_only_behind_a_matching_launch() {
|
||||
let owner: std::net::IpAddr = "192.168.1.20".parse().unwrap();
|
||||
let other: std::net::IpAddr = "192.168.1.99".parse().unwrap();
|
||||
// Idle host (no /launch): every connect is refused, address known or not.
|
||||
assert!(!super::accept_connect(None, Some(owner)));
|
||||
assert!(!super::accept_connect(None, None));
|
||||
// Launched with a captured owner IP: only that IP is the session peer.
|
||||
assert!(super::accept_connect(launched(Some(owner)), Some(owner)));
|
||||
assert!(!super::accept_connect(launched(Some(owner)), Some(other)));
|
||||
// Address unknown on one side → launch-present only (the pre-existing fallback).
|
||||
assert!(super::accept_connect(launched(Some(owner)), None));
|
||||
assert!(super::accept_connect(launched(None), Some(other)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_valid_rfi_range() {
|
||||
assert_eq!(decode_rfi_range(&rfi_msg(40, 47)), Some((40, 47)));
|
||||
|
||||
@@ -411,6 +411,7 @@ fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||
|
||||
async fn h_pair(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let uniqueid = q.get("uniqueid").cloned().unwrap_or_default();
|
||||
@@ -440,9 +441,17 @@ async fn h_pair(
|
||||
_ => Ok(pair_error_xml()),
|
||||
}
|
||||
} else if phrase == Some("pairchallenge") {
|
||||
// Reached only over the TLS port with the pinned host cert; the handshake is the
|
||||
// proof, so acknowledge success.
|
||||
Ok(paired_ok_xml())
|
||||
// The ceremony's last step, which Moonlight makes over the TLS port with the cert phase 4
|
||||
// has just pinned — so the pinned handshake is the proof, and only a pinned caller is told
|
||||
// it is paired. Anyone else (the plain-HTTP listener, or an HTTPS peer presenting a cert
|
||||
// that is not in the allow-list) gets the same answer an unpaired host gives, so this
|
||||
// endpoint asserts no pairing the caller does not hold (security-review 2026-08-25).
|
||||
if peer_is_paired(&peer, &st) {
|
||||
Ok(paired_ok_xml())
|
||||
} else {
|
||||
tracing::warn!("pairchallenge rejected — client is not paired");
|
||||
Ok(pair_error_xml())
|
||||
}
|
||||
} else if let Some(v) = q.get("clientchallenge") {
|
||||
st.pairing.clientchallenge(&st.identity, &uniqueid, v)
|
||||
} else if let Some(v) = q.get("serverchallengeresp") {
|
||||
@@ -537,6 +546,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `pairchallenge` is the ceremony's last step and Moonlight makes it over the TLS port with
|
||||
/// the cert phase 4 just pinned, so only a pinned caller is told it is paired. A plain-HTTP
|
||||
/// scanner (no client cert at all) and an HTTPS peer with an unpinned cert both get the
|
||||
/// unpaired answer — the endpoint must not assert a pairing the caller does not hold.
|
||||
#[tokio::test]
|
||||
async fn pairchallenge_answers_only_a_pinned_client() {
|
||||
async fn challenge(
|
||||
st: &Arc<AppState>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
) -> String {
|
||||
let q = HashMap::from([("phrase".to_string(), "pairchallenge".to_string())]);
|
||||
let resp = h_pair(State(st.clone()), peer, Query(q))
|
||||
.await
|
||||
.into_response();
|
||||
let b = axum::body::to_bytes(resp.into_body(), 64 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(b.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
let st = test_state();
|
||||
let der = b"pairchallenge-client-der".to_vec();
|
||||
let peer = Some(Extension(PeerCertFingerprint(Some(fp_of(&der)))));
|
||||
|
||||
// Plain HTTP (no cert) and an unpinned HTTPS cert both answer as an unpaired host.
|
||||
let plain = challenge(&st, None).await;
|
||||
assert!(plain.contains("<paired>0</paired>"), "plain HTTP: {plain}");
|
||||
let unpinned = challenge(&st, peer.clone()).await;
|
||||
assert!(
|
||||
unpinned.contains("<paired>0</paired>"),
|
||||
"unpinned cert: {unpinned}"
|
||||
);
|
||||
|
||||
// Once phase 4 has pinned the cert, the real client's last step still succeeds.
|
||||
st.paired.lock().unwrap().push(der);
|
||||
let pinned = challenge(&st, peer).await;
|
||||
assert!(pinned.contains("<paired>1</paired>"), "pinned: {pinned}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamestream_admission_policy_matrix() {
|
||||
use crate::vdisplay::policy::ModeConflict;
|
||||
|
||||
@@ -16,7 +16,7 @@ use rsa::sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the operator submits it
|
||||
@@ -28,7 +28,9 @@ use tokio::sync::Notify;
|
||||
const MAX_PARKED_WAITERS: usize = 4;
|
||||
|
||||
pub struct PinGate {
|
||||
pin: Mutex<Option<String>>,
|
||||
/// The submitted PIN and when it was submitted — a PIN no handshake consumed within the
|
||||
/// pairing window is discarded rather than held for the next one ([`PinGate::take`]).
|
||||
pin: Mutex<Option<(String, Instant)>>,
|
||||
notify: Notify,
|
||||
/// Handshakes currently parked in [`take`](Self::take) — drives the management API's
|
||||
/// `pin_pending` so a control pane knows when to prompt for the PIN.
|
||||
@@ -62,7 +64,7 @@ impl PinGate {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
*self.pin.lock().unwrap() = Some(pin);
|
||||
*self.pin.lock().unwrap() = Some((pin, Instant::now()));
|
||||
self.notify.notify_waiters();
|
||||
true
|
||||
}
|
||||
@@ -98,8 +100,18 @@ impl PinGate {
|
||||
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(p) = self.pin.lock().unwrap().take() {
|
||||
return Some(p);
|
||||
// A PIN must not outlive the handshake it was typed for: the slot is global and
|
||||
// unbound, so a PIN submitted with no handshake left to consume it (the submitter's
|
||||
// waiter died between the management API's `awaiting_pin` check and the submit) used
|
||||
// to sit here indefinitely and authenticate whoever knocked next — a PIN typed for
|
||||
// one device pairing another (security-review 2026-08-25). Its shelf life is the same
|
||||
// pairing window a handshake parks for, and a real client is ALREADY parked when the
|
||||
// operator submits, so it takes the PIN within microseconds — never near this bound.
|
||||
if let Some((p, at)) = self.pin.lock().unwrap().take() {
|
||||
if at.elapsed() < timeout {
|
||||
return Some(p);
|
||||
}
|
||||
tracing::warn!("pairing: discarding a PIN no handshake consumed in time");
|
||||
}
|
||||
if tokio::time::timeout_at(deadline, self.notify.notified())
|
||||
.await
|
||||
@@ -376,6 +388,20 @@ mod tests {
|
||||
assert!(!pairing.pin.awaiting_pin());
|
||||
}
|
||||
|
||||
/// A PIN nobody consumed must NOT authenticate the next handshake to arrive: the slot is
|
||||
/// global and unbound, so a PIN typed for a device that gave up would otherwise pair whoever
|
||||
/// knocked afterwards (security-review 2026-08-25).
|
||||
#[tokio::test]
|
||||
async fn unconsumed_pin_is_discarded() {
|
||||
let pairing = Pairing::new();
|
||||
pairing.pin.submit("1234".into()); // nothing parked — nobody takes it
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
// Older than the window this handshake parks for: discarded, not handed over…
|
||||
assert_eq!(pairing.pin.take(Duration::from_millis(5)).await, None);
|
||||
// …and gone for good, so the handshake after it doesn't inherit it either.
|
||||
assert_eq!(pairing.pin.take(Duration::from_millis(5)).await, None);
|
||||
}
|
||||
|
||||
/// A pre-auth peer flood can park at most `MAX_PARKED_WAITERS` pairing handshakes; the next
|
||||
/// `take` is refused immediately (returns `None` without parking), bounding the 300s-waiter DoS
|
||||
/// (security-review 2026-06-28 #12).
|
||||
|
||||
@@ -17,17 +17,22 @@ use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
|
||||
const PING_PAYLOAD: &str = "0011223344556677";
|
||||
|
||||
// The RTSP listener is UNAUTHENTICATED (no TLS/pairing) and one-thread-per-connection, so bound
|
||||
// every attacker-controllable dimension to deny a pre-auth slow-loris / memory-growth DoS: a hard
|
||||
// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, and
|
||||
// size caps on the request headers + body (real GameStream RTSP messages are a few hundred bytes).
|
||||
// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, a
|
||||
// whole-request deadline so a dribbling one can't either, and size caps on the request headers +
|
||||
// body (real GameStream RTSP messages are a few hundred bytes).
|
||||
const MAX_RTSP_CONNS: usize = 8;
|
||||
const RTSP_READ_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
// The per-read timeout bounds ONE read, not the request: a peer sending a byte just inside it
|
||||
// resets the timeout forever and holds one of the eight slots for good. This bounds the whole
|
||||
// message instead (security-review 2026-08-25) — a real client's request arrives in one segment.
|
||||
const RTSP_REQUEST_DEADLINE: Duration = Duration::from_secs(30);
|
||||
const MAX_RTSP_HEADER: usize = 16 * 1024;
|
||||
const MAX_RTSP_BODY: usize = 64 * 1024;
|
||||
const MAX_RTSP_MSG: usize = 128 * 1024;
|
||||
@@ -90,13 +95,15 @@ struct Request {
|
||||
|
||||
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
let peer = stream.peer_addr().ok();
|
||||
// A per-read timeout so a stalled/slow-loris peer can't pin this thread indefinitely.
|
||||
// A per-read timeout so a stalled peer can't pin this thread, plus the whole-request
|
||||
// deadline `read_message` enforces — a slow-loris defeats the first with the second absent.
|
||||
let _ = stream.set_read_timeout(Some(RTSP_READ_TIMEOUT));
|
||||
let deadline = Instant::now() + RTSP_REQUEST_DEADLINE;
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
|
||||
// response until EOF, so we answer one message and close the connection (which signals
|
||||
// the end of the response). Session state lives in `AppState`, not the connection.
|
||||
if let Some(req) = read_message(&mut stream, &mut buf)? {
|
||||
if let Some(req) = read_message(&mut stream, &mut buf, deadline)? {
|
||||
tracing::debug!(
|
||||
method = %req.method,
|
||||
cseq = %req.cseq,
|
||||
@@ -114,9 +121,17 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Read one complete RTSP message (headers + any Content-Length body) from the stream,
|
||||
/// buffering across reads and leaving any pipelined remainder in `buf`.
|
||||
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
|
||||
/// buffering across reads and leaving any pipelined remainder in `buf`. Gives up at `deadline`
|
||||
/// (the caller's whole-request budget), which the per-read timeout alone does not bound.
|
||||
fn read_message(
|
||||
stream: &mut TcpStream,
|
||||
buf: &mut Vec<u8>,
|
||||
deadline: Instant,
|
||||
) -> Result<Option<Request>> {
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("RTSP request deadline exceeded");
|
||||
}
|
||||
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
|
||||
// Cap the header section even when the terminator IS present (a single oversized header
|
||||
// block that fits a `\r\n\r\n` would otherwise skip the no-terminator cap below).
|
||||
@@ -674,6 +689,29 @@ mod tests {
|
||||
parse_announce(&body)
|
||||
}
|
||||
|
||||
/// The listener is unauthenticated, so a request that never finishes must not hold one of the
|
||||
/// eight connection slots: `read_message` gives up at the caller's deadline instead of resetting
|
||||
/// its per-read timeout forever. An already-passed deadline is the deterministic stand-in for a
|
||||
/// peer dribbling bytes — it bails without waiting on a socket that will never send.
|
||||
#[test]
|
||||
fn read_message_gives_up_at_the_request_deadline() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let client = TcpStream::connect(addr).expect("connect"); // stays open, sends nothing
|
||||
let (mut server, _) = listener.accept().expect("accept");
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
// `Request` isn't `Debug`, so match rather than `expect_err`.
|
||||
let err = match read_message(&mut server, &mut buf, Instant::now()) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("an elapsed deadline must end the request"),
|
||||
};
|
||||
assert!(
|
||||
format!("{err:#}").contains("deadline"),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
drop(client);
|
||||
}
|
||||
|
||||
/// `x-nv-vqos[0].bitStreamFormat` → codec (0=H264, 1=HEVC, 2=AV1; missing = H264).
|
||||
#[test]
|
||||
fn announce_codec_selection() {
|
||||
|
||||
@@ -289,7 +289,7 @@ fn run(
|
||||
)];
|
||||
let _prep = (!prep_cmds.is_empty()).then(|| crate::hooks::run_prep(&prep_cmds, &prep_env));
|
||||
// Open the virtual-display source: pick the live compositor, normalize the session env
|
||||
// (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
|
||||
// (apply_session_env + input/gamescope routing — ATTACH/resize + KWin/Mutter retargeting,
|
||||
// exactly like the native plane), create a virtual output at the client mode, and capture it.
|
||||
// Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
|
||||
// Desktop<->Game switch.
|
||||
@@ -657,9 +657,9 @@ fn open_gs_mirror_source(
|
||||
.map(Ok)
|
||||
.unwrap_or_else(crate::vdisplay::detect)
|
||||
.context("detect compositor")?;
|
||||
// A mirror streams an existing head — no gamescope sub-mode applies, so the resolved route is
|
||||
// deliberately dropped here rather than carried.
|
||||
let _ = crate::vdisplay::apply_input_env(compositor, false);
|
||||
// Point input at the same backend the video landed on. A mirror streams an existing head, so no
|
||||
// gamescope sub-mode applies and no route is resolved here at all.
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(compositor));
|
||||
let mut vd = crate::vdisplay::open_mirror(compositor, connector)?;
|
||||
// Cursor mode is the session's negotiated one: metadata where this encode path composites
|
||||
// `frame.cursor`, otherwise let the compositor embed it (§7.5 — one resolver, per-backend
|
||||
@@ -748,7 +748,7 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
|
||||
}
|
||||
|
||||
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
|
||||
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
|
||||
/// the session env (apply_session_env + input/gamescope routing — ATTACH/resize, KWin/Mutter
|
||||
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
|
||||
/// output at the client's mode, and capture it. Returns the capturer (it owns the output's keepalive;
|
||||
/// the stateless VirtualDisplay factory is dropped here) plus the resolved compositor. An apps.json
|
||||
@@ -817,16 +817,16 @@ fn open_gs_virtual_source(
|
||||
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
|
||||
let has_launch = launch.and_then(|t| t.command.as_deref()).is_some();
|
||||
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
|
||||
let r =
|
||||
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
|
||||
(crate::vdisplay::Compositor::Gamescope, r)
|
||||
let c = crate::vdisplay::Compositor::Gamescope;
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(c));
|
||||
(c, crate::vdisplay::resolve_gamescope_route(c, true))
|
||||
} else {
|
||||
let c = crate::vdisplay::compositor_for_kind(active.kind)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(crate::vdisplay::detect)
|
||||
.context("detect compositor")?;
|
||||
let r = crate::vdisplay::apply_input_env(c, false);
|
||||
(c, r)
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(c));
|
||||
(c, crate::vdisplay::resolve_gamescope_route(c, false))
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -849,7 +849,7 @@ fn open_gs_virtual_source(
|
||||
// interactive-session spawner launches it by id instead.
|
||||
vd.set_launch_command(launch.and_then(|t| t.command.clone()));
|
||||
// This plane's resolved gamescope sub-mode, on the instance for the same reason as the launch
|
||||
// command above — the GameStream and native planes both call `apply_input_env`, so publishing
|
||||
// command above — the GameStream and native planes both resolve a route, so publishing
|
||||
// through the process env let either retarget the other's `create`.
|
||||
vd.set_gamescope_route(gamescope_route.clone());
|
||||
// Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
//! firings are dropped with a warning, never queued unboundedly), per-hook `debounce_ms`, the
|
||||
//! exec timeout + process-group kill. Trust model (RFC §9.1): `hooks.json` is
|
||||
//! operator-privileged config in the DACL'd/0700 config dir; before executing a hook whose
|
||||
//! command is a script *path*, the host verifies the file is owned by the operator (or root)
|
||||
//! and not group/world-writable — the sshd/sudoers rule — and refuses loudly otherwise.
|
||||
//! command is a script *path*, the host verifies that file — and every directory above it — is
|
||||
//! owned by the operator (or root) and not group/world-writable — the sshd/sudoers rule — and
|
||||
//! refuses loudly otherwise. Log lines name a hook by [`cmd_label`]/[`webhook_origin`], never by
|
||||
//! its raw command line or URL: the tracing ring they land in is served over `GET /api/v1/logs`,
|
||||
//! and a webhook path segment (Slack, Discord, ntfy, Teams) *is* the bearer credential.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -159,11 +162,17 @@ impl HooksConfig {
|
||||
// Warn rather than reject (an internal-only `http://` receiver may be intentional).
|
||||
if h.hmac_secret_file.is_some() && url.starts_with("http://") {
|
||||
tracing::warn!(
|
||||
%url,
|
||||
url = %webhook_origin(url),
|
||||
"webhook has an hmac_secret_file but is http:// — the signed body is sent in cleartext; prefer https://"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(p) = h.hmac_secret_file.as_deref() {
|
||||
if let Some(why) = secret_file_complaint(p) {
|
||||
tracing::warn!(path = %p.display(),
|
||||
"webhook hmac_secret_file is {why} — it should be operator-owned and chmod 600");
|
||||
}
|
||||
}
|
||||
if h.timeout_s == 0 || h.timeout_s > MAX_TIMEOUT_S {
|
||||
return Err(at(&format!("`timeout_s` must be 1–{MAX_TIMEOUT_S}")));
|
||||
}
|
||||
@@ -172,6 +181,33 @@ impl HooksConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The documented `hmac_secret_file` hygiene check (see [`HookEntry::hmac_secret_file`]): the
|
||||
/// secret should be operator-owned and private. Returns the complaint to warn about, `None` when
|
||||
/// the file is fine (or absent — an unreadable secret is [`post_webhook`]'s fail-closed case, not
|
||||
/// this one's). A warning, not a refusal: the operator asked for signing, and refusing here would
|
||||
/// silently drop it.
|
||||
#[cfg(unix)]
|
||||
fn secret_file_complaint(path: &std::path::Path) -> Option<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
// SAFETY: geteuid has no preconditions and touches no memory.
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
if meta.uid() != euid && meta.uid() != 0 {
|
||||
return Some(format!(
|
||||
"owned by uid {} (host runs as uid {euid})",
|
||||
meta.uid()
|
||||
));
|
||||
}
|
||||
(meta.mode() & 0o077 != 0)
|
||||
.then(|| format!("group/world-accessible (mode {:o})", meta.mode() & 0o7777))
|
||||
}
|
||||
|
||||
/// Windows: the SYSTEM/Admins-DACL'd config dir is the boundary (as for [`exec_path_check`]).
|
||||
#[cfg(not(unix))]
|
||||
fn secret_file_complaint(_path: &std::path::Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- store
|
||||
|
||||
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
|
||||
@@ -365,25 +401,53 @@ fn dispatch(
|
||||
|
||||
// ------------------------------------------------------------------------- exec action
|
||||
|
||||
/// Short, stable id for one hook action (`#1a2b3c4d`) — all a log line keeps of the part that can
|
||||
/// carry a secret. The same id on every line about one firing, a different one for two hooks that
|
||||
/// share a program or a webhook host, so an operator can still tell which fired. Process-lifetime
|
||||
/// stable, like [`entry_key`]'s hash.
|
||||
fn short_id(s: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
s.hash(&mut hasher);
|
||||
format!("#{:08x}", hasher.finish() as u32)
|
||||
}
|
||||
|
||||
/// What the `cmd` field of a log line carries about an operator command: the program's file name
|
||||
/// plus its [`short_id`]. The arguments are dropped — a hook command line carries API tokens
|
||||
/// (`curl -H "Authorization: …"`) as readily as a webhook URL does, and these lines land in the
|
||||
/// tracing ring `GET /api/v1/logs` serves verbatim (security review 2026-08-24). A refusal still
|
||||
/// names the offending *path*, through the [`exec_path_check`] error — that is what the operator
|
||||
/// needs in order to fix it.
|
||||
fn cmd_label(cmd: &str) -> String {
|
||||
let prog = cmd
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_matches(['"', '\'']);
|
||||
let file = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
|
||||
format!("{file} {}", short_id(cmd))
|
||||
}
|
||||
|
||||
fn fire_exec(
|
||||
cmd: String,
|
||||
ev: &crate::events::HostEvent,
|
||||
timeout_s: u32,
|
||||
sem: &std::sync::Arc<tokio::sync::Semaphore>,
|
||||
) {
|
||||
let label = cmd_label(&cmd);
|
||||
let Ok(permit) = sem.clone().try_acquire_owned() else {
|
||||
tracing::warn!(cmd = %cmd, "hook dropped — too many hook executions in flight");
|
||||
tracing::warn!(cmd = %label, "hook dropped — too many hook executions in flight");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = exec_path_check(&cmd) {
|
||||
tracing::error!(cmd = %cmd, "REFUSING hook command — {e}");
|
||||
tracing::error!(cmd = %label, "REFUSING hook command — {e}");
|
||||
return;
|
||||
}
|
||||
let json = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
|
||||
let env = flatten_env(ev);
|
||||
let kind = ev.kind.name();
|
||||
let timeout = Duration::from_secs(u64::from(timeout_s));
|
||||
tracing::info!(cmd = %cmd, kind, "hook: running command");
|
||||
tracing::info!(cmd = %label, kind, "hook: running command");
|
||||
// Detached execution + off-thread reap (the `try_recover_session` recipe): the streaming
|
||||
// planes never wait on operator code. The permit rides along and frees on thread exit.
|
||||
std::thread::spawn(move || {
|
||||
@@ -434,7 +498,9 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
|
||||
|
||||
/// 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.
|
||||
/// script is privilege-escalation bait. The same rule covers every directory above the script: a
|
||||
/// writable parent is the same bait one level up, since whoever may rename an entry in it chooses
|
||||
/// what runs. 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
|
||||
@@ -449,43 +515,95 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
|
||||
/// 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;
|
||||
if cmd.split_whitespace().next().is_none() {
|
||||
let tokens = shell_tokens(cmd);
|
||||
if tokens.is_empty() {
|
||||
return Err("empty command".into());
|
||||
}
|
||||
// SAFETY: geteuid has no preconditions and touches no memory.
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
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 == '\'');
|
||||
for token in &tokens {
|
||||
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;
|
||||
let path = std::path::Path::new(token);
|
||||
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
|
||||
continue; // not an existing file — the shell will report it
|
||||
}
|
||||
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
|
||||
));
|
||||
// The script, then every directory up to `/`: an unchecked writable parent lets the
|
||||
// attacker swap a perfectly-owned script out from under us, which is why sshd walks the
|
||||
// whole chain rather than stat'ing the file alone.
|
||||
for node in path.ancestors() {
|
||||
let Ok(meta) = std::fs::metadata(node) else {
|
||||
continue;
|
||||
};
|
||||
path_node_check(node, &meta, euid)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ownership/mode rule [`exec_path_check`] applies to the script and to each directory above
|
||||
/// it. A world-writable *directory* with the sticky bit set (`/tmp`) passes: there only an entry's
|
||||
/// own owner can replace it, so the swap this rule exists to block is already impossible.
|
||||
#[cfg(unix)]
|
||||
fn path_node_check(
|
||||
path: &std::path::Path,
|
||||
meta: &std::fs::Metadata,
|
||||
euid: u32,
|
||||
) -> Result<(), String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if meta.uid() != euid && meta.uid() != 0 {
|
||||
return Err(format!(
|
||||
"{} is owned by uid {} (host runs as uid {euid}) — a hook script and the directories \
|
||||
holding it must be owned by the operator or root",
|
||||
path.display(),
|
||||
meta.uid()
|
||||
));
|
||||
}
|
||||
let sticky_dir = meta.is_dir() && meta.mode() & 0o1000 != 0;
|
||||
if meta.mode() & 0o022 != 0 && !sticky_dir {
|
||||
return Err(format!(
|
||||
"{} is group/world-writable (mode {:o}) — chmod go-w it first",
|
||||
path.display(),
|
||||
meta.mode() & 0o7777
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a command line into tokens the way `/bin/sh` would *for the purpose of finding paths*:
|
||||
/// whitespace separates, but a quoted or backslash-escaped run stays one token. Plain
|
||||
/// `split_whitespace` turned `"/opt/my hooks/run.sh"` into two nonexistent tokens, so the check
|
||||
/// above silently passed the case it most needs to catch — a script the shell really does run, at
|
||||
/// a path with a space in it (security review 2026-08-24).
|
||||
#[cfg(unix)]
|
||||
fn shell_tokens(cmd: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = cmd.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
// Inside '' a backslash is literal; inside "" and unquoted it escapes the next char.
|
||||
Some('"') if c == '\\' => cur.extend(chars.next()),
|
||||
Some(_) => cur.push(c),
|
||||
None if c == '\\' => cur.extend(chars.next()),
|
||||
None if c == '"' || c == '\'' => quote = Some(c),
|
||||
None if c.is_whitespace() => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
None => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -591,6 +709,7 @@ fn run_hook_process(
|
||||
) -> bool {
|
||||
use std::io::Write;
|
||||
use std::os::unix::process::CommandExt;
|
||||
let label = cmd_label(cmd);
|
||||
let mut c = std::process::Command::new("/bin/sh");
|
||||
c.arg("-c")
|
||||
.arg(cmd)
|
||||
@@ -603,7 +722,7 @@ fn run_hook_process(
|
||||
let mut child = match c.spawn() {
|
||||
Ok(ch) => ch,
|
||||
Err(e) => {
|
||||
tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch");
|
||||
tracing::error!(cmd = %label, error = %e, "hook command failed to launch");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -616,13 +735,13 @@ fn run_hook_process(
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero");
|
||||
tracing::warn!(cmd = %label, %status, "hook command exited non-zero");
|
||||
}
|
||||
return status.success();
|
||||
}
|
||||
Ok(None) => {
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!(cmd = %cmd, timeout_s = timeout.as_secs(),
|
||||
tracing::warn!(cmd = %label, timeout_s = timeout.as_secs(),
|
||||
"hook command timed out — killing its process group");
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -638,7 +757,7 @@ fn run_hook_process(
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed");
|
||||
tracing::warn!(cmd = %label, error = %e, "hook command wait failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -658,6 +777,7 @@ fn run_hook_process(
|
||||
timeout: Duration,
|
||||
) -> bool {
|
||||
use std::io::Write;
|
||||
let label = cmd_label(cmd);
|
||||
let stamp = format!(
|
||||
"pf-hook-{}-{}.json",
|
||||
std::process::id(),
|
||||
@@ -668,12 +788,12 @@ fn run_hook_process(
|
||||
);
|
||||
let json_path = std::env::temp_dir().join(stamp);
|
||||
if std::fs::write(&json_path, event_json).is_err() {
|
||||
tracing::warn!(cmd = %cmd, "hook: could not write event JSON temp file");
|
||||
tracing::warn!(cmd = %label, "hook: could not write event JSON temp file");
|
||||
}
|
||||
let cmdline = format!("{cmd} \"{}\"", json_path.display());
|
||||
match crate::interactive::spawn_in_active_session(&cmdline, None) {
|
||||
Ok(pid) => {
|
||||
tracing::debug!(cmd = %cmd, pid, "hook command launched in the interactive session");
|
||||
tracing::debug!(cmd = %label, pid, "hook command launched in the interactive session");
|
||||
// No child handle on this path — wait out the timeout, then clean the temp file.
|
||||
std::thread::sleep(timeout);
|
||||
let _ = std::fs::remove_file(&json_path);
|
||||
@@ -696,7 +816,7 @@ fn run_hook_process(
|
||||
// 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,
|
||||
cmd = %label,
|
||||
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"
|
||||
@@ -731,7 +851,7 @@ fn run_hook_process(
|
||||
break;
|
||||
}
|
||||
None if Instant::now() >= deadline => {
|
||||
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
|
||||
tracing::warn!(cmd = %label, "hook command timed out — killing it");
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
@@ -740,7 +860,9 @@ fn run_hook_process(
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"),
|
||||
Err(e) => {
|
||||
tracing::error!(cmd = %label, error = %e, "hook command failed to launch")
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&json_path);
|
||||
ok
|
||||
@@ -756,13 +878,14 @@ fn fire_webhook(
|
||||
ev: &crate::events::HostEvent,
|
||||
sem: &std::sync::Arc<tokio::sync::Semaphore>,
|
||||
) {
|
||||
let origin = webhook_origin(&url);
|
||||
let Ok(permit) = sem.clone().try_acquire_owned() else {
|
||||
tracing::warn!(url = %url, "webhook dropped — too many hook executions in flight");
|
||||
tracing::warn!(url = %origin, "webhook dropped — too many hook executions in flight");
|
||||
return;
|
||||
};
|
||||
let json = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
|
||||
let kind = ev.kind.name();
|
||||
tracing::info!(url = %url, kind, "hook: posting webhook");
|
||||
tracing::info!(url = %origin, kind, "hook: posting webhook");
|
||||
std::thread::spawn(move || {
|
||||
post_webhook(&url, &json, secret_file.as_deref());
|
||||
drop(permit);
|
||||
@@ -777,13 +900,7 @@ fn fire_webhook(
|
||||
/// legitimate self-hosting config. A best-effort textual + IP-literal check (no DNS resolution, so
|
||||
/// not a full anti-rebinding defense; the operator-gated config already limits the threat).
|
||||
fn webhook_host_is_internal(url: &str) -> bool {
|
||||
// scheme://[userinfo@]host[:port]/... → the bare host.
|
||||
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
|
||||
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
|
||||
let hostport = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(authority);
|
||||
let hostport = webhook_authority(url);
|
||||
let host = if let Some(rest) = hostport.strip_prefix('[') {
|
||||
rest.split(']').next().unwrap_or("") // [::1]:443 → ::1
|
||||
} else {
|
||||
@@ -808,7 +925,30 @@ fn webhook_host_is_internal(url: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `scheme://[userinfo@]host[:port]/...` → the bare `host[:port]`. Textual, no DNS.
|
||||
fn webhook_authority(url: &str) -> &str {
|
||||
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
|
||||
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
|
||||
authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(authority)
|
||||
}
|
||||
|
||||
/// What the `url` field of a log line carries about a webhook: `scheme://host[:port]` plus the
|
||||
/// URL's [`short_id`]. The path, query and any userinfo are dropped — for Slack, Discord, ntfy,
|
||||
/// Teams, Zapier and Home Assistant the token IS a path segment, and these lines land in the
|
||||
/// tracing ring `GET /api/v1/logs` serves verbatim (security review 2026-08-24).
|
||||
fn webhook_origin(url: &str) -> String {
|
||||
let scheme = url
|
||||
.split_once("://")
|
||||
.map(|(s, _)| format!("{s}://"))
|
||||
.unwrap_or_default();
|
||||
format!("{scheme}{} {}", webhook_authority(url), short_id(url))
|
||||
}
|
||||
|
||||
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
||||
let origin = webhook_origin(url);
|
||||
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
|
||||
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
@@ -843,11 +983,13 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
||||
}
|
||||
}
|
||||
match req.send(json) {
|
||||
Ok(resp) => tracing::debug!(url, status = resp.status().as_u16(), "webhook delivered"),
|
||||
Err(ureq::Error::StatusCode(code)) => {
|
||||
tracing::warn!(url, status = code, "webhook rejected by receiver")
|
||||
Ok(resp) => {
|
||||
tracing::debug!(url = %origin, status = resp.status().as_u16(), "webhook delivered")
|
||||
}
|
||||
Err(e) => tracing::warn!(url, error = %e, "webhook delivery failed"),
|
||||
Err(ureq::Error::StatusCode(code)) => {
|
||||
tracing::warn!(url = %origin, status = code, "webhook rejected by receiver")
|
||||
}
|
||||
Err(e) => tracing::warn!(url = %origin, error = %e, "webhook delivery failed"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,17 +1032,18 @@ pub fn run_prep(cmds: &[PrepCmd], env: &[(String, String)]) -> PrepGuard {
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let label = cmd_label(cmd);
|
||||
if let Err(e) = exec_path_check(cmd) {
|
||||
tracing::error!(cmd = %cmd, "REFUSING prep command — {e}");
|
||||
tracing::error!(cmd = %label, "REFUSING prep command — {e}");
|
||||
continue;
|
||||
}
|
||||
tracing::info!(cmd = %cmd, "prep: running");
|
||||
tracing::info!(cmd = %label, "prep: running");
|
||||
if run_hook_process(cmd, "{}", env, timeout) {
|
||||
if let Some(u) = c.undo.as_deref().filter(|u| !u.trim().is_empty()) {
|
||||
undo.push(u.to_string());
|
||||
}
|
||||
} else if c.undo.is_some() {
|
||||
tracing::warn!(cmd = %cmd, "prep step failed — its undo is skipped");
|
||||
tracing::warn!(cmd = %label, "prep step failed — its undo is skipped");
|
||||
}
|
||||
}
|
||||
PrepGuard {
|
||||
@@ -922,11 +1065,12 @@ impl Drop for PrepGuard {
|
||||
// the one thread runs them sequentially.
|
||||
std::thread::spawn(move || {
|
||||
for cmd in undo.iter().rev() {
|
||||
let label = cmd_label(cmd);
|
||||
if let Err(e) = exec_path_check(cmd) {
|
||||
tracing::error!(cmd = %cmd, "REFUSING prep undo command — {e}");
|
||||
tracing::error!(cmd = %label, "REFUSING prep undo command — {e}");
|
||||
continue;
|
||||
}
|
||||
tracing::info!(cmd = %cmd, "prep: running undo");
|
||||
tracing::info!(cmd = %label, "prep: running undo");
|
||||
run_hook_process(cmd, "{}", &env, timeout);
|
||||
}
|
||||
});
|
||||
@@ -1267,4 +1411,119 @@ mod tests {
|
||||
assert!(exec_path_check("systemctl suspend").is_ok());
|
||||
assert!(exec_path_check("/nonexistent/definitely-not-here").is_ok());
|
||||
}
|
||||
|
||||
/// The two holes the check used to have: a path with a space in it (whitespace splitting made
|
||||
/// the check a no-op for exactly the paths the shell still runs), and a writable directory
|
||||
/// above an otherwise-fine script.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ownership_check_sees_quoted_paths_and_writable_parents() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::Permissions::from_mode;
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-hook-parent-{}-{:p}",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, mode(0o755)).unwrap();
|
||||
let script = dir.join("my hook.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\ntrue\n").unwrap();
|
||||
std::fs::set_permissions(&script, mode(0o700)).unwrap();
|
||||
let quoted = format!("\"{}\" arg", script.display());
|
||||
|
||||
assert!(exec_path_check("ed).is_ok(), "a sane quoted path runs");
|
||||
std::fs::set_permissions(&script, mode(0o777)).unwrap();
|
||||
assert!(
|
||||
exec_path_check("ed).is_err(),
|
||||
"world-writable script behind a quoted, space-bearing path must be refused"
|
||||
);
|
||||
assert!(
|
||||
exec_path_check(&format!("'{}'", script.display())).is_err(),
|
||||
"single quotes too"
|
||||
);
|
||||
assert!(
|
||||
exec_path_check(&script.display().to_string().replace(' ', "\\ ")).is_err(),
|
||||
"backslash-escaped spaces too"
|
||||
);
|
||||
|
||||
// A writable parent defeats a perfectly-owned script — the attacker replaces the file.
|
||||
std::fs::set_permissions(&script, mode(0o700)).unwrap();
|
||||
std::fs::set_permissions(&dir, mode(0o777)).unwrap();
|
||||
let err = exec_path_check("ed).expect_err("world-writable parent must be refused");
|
||||
assert!(err.contains(&dir.display().to_string()), "names it: {err}");
|
||||
std::fs::set_permissions(&dir, mode(0o755)).unwrap();
|
||||
assert!(exec_path_check("ed).is_ok(), "chmod go-w fixes it");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The `hmac_secret_file` warning the field doc promises.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn secret_file_permissions_are_complained_about() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pf-hook-secret-{}-{:p}.key",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
));
|
||||
std::fs::write(&path, b"s3cret").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(secret_file_complaint(&path).is_none(), "0600 is the ask");
|
||||
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let why = secret_file_complaint(&path).expect("a world-readable secret is warned about");
|
||||
assert!(why.contains("644"), "the complaint names the mode: {why}");
|
||||
|
||||
// Missing/unreadable is post_webhook's fail-closed case, not a permissions complaint.
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(secret_file_complaint(&path).is_none());
|
||||
}
|
||||
|
||||
/// `GET /api/v1/logs` serves the tracing ring verbatim, so no log line may carry a webhook URL's
|
||||
/// path (the bearer credential for Slack/Discord/ntfy) or a command's arguments — while still
|
||||
/// saying which hook fired.
|
||||
#[test]
|
||||
fn log_labels_drop_the_credential_and_stay_identifiable() {
|
||||
let slack = "https://hooks.slack.com/services/T0000/B0000/XXXXsecretXXXX";
|
||||
let shown = webhook_origin(slack);
|
||||
assert!(
|
||||
shown.starts_with("https://hooks.slack.com "),
|
||||
"origin: {shown}"
|
||||
);
|
||||
assert!(!shown.contains("XXXXsecretXXXX"), "token dropped: {shown}");
|
||||
assert_ne!(
|
||||
shown,
|
||||
webhook_origin("https://hooks.slack.com/services/T1/B1/OTHER"),
|
||||
"two hooks to one host stay distinguishable"
|
||||
);
|
||||
|
||||
// userinfo, path and query all go; the port stays (it names the receiver, not the secret).
|
||||
let creds = webhook_origin("https://user:pw@ha.local:8123/api/webhook/zzz?token=qqq");
|
||||
assert!(creds.starts_with("https://ha.local:8123 "), "{creds}");
|
||||
for secret in ["pw", "zzz", "qqq"] {
|
||||
assert!(!creds.contains(secret), "{secret} leaked: {creds}");
|
||||
}
|
||||
|
||||
// Command lines: the program's file name survives, its arguments don't.
|
||||
let cmd = "/usr/local/bin/notify.sh --token=SEKRIT-zz 'Living Room'";
|
||||
let label = cmd_label(cmd);
|
||||
assert!(
|
||||
label.starts_with("notify.sh #"),
|
||||
"says which script ran: {label}"
|
||||
);
|
||||
assert!(!label.contains("SEKRIT"), "arguments dropped: {label}");
|
||||
assert_eq!(
|
||||
label,
|
||||
cmd_label(cmd),
|
||||
"stable across one firing's log lines"
|
||||
);
|
||||
assert_ne!(
|
||||
label,
|
||||
cmd_label("/usr/local/bin/notify.sh --token=SEKRIT-yy")
|
||||
);
|
||||
assert!(cmd_label("curl -H \"Authorization: Bearer t\" https://x/y").starts_with("curl #"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,10 +238,23 @@ pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
|
||||
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
|
||||
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
|
||||
/// entries be served regardless of what their ids look like.
|
||||
///
|
||||
/// Gated on what the library actually collects ([`super::collect_games`]), so a source the operator
|
||||
/// switched OFF resolves to nothing here either. It used to resolve straight out of the catalog,
|
||||
/// which let `GET /library/art/<id>/<kind>` — on the paired-cert allowlist — serve covers for
|
||||
/// entries `GET /library` had already filtered away (security review 2026-08-25).
|
||||
///
|
||||
/// The per-entry **hide** is deliberately not applied: the console draws a hidden title's (dimmed)
|
||||
/// cover so the operator can bring it back, and this resolver cannot see the caller's lane. Keeping
|
||||
/// a hidden title's art off the paired-cert lane is a check for the route, which can.
|
||||
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
|
||||
load_custom()
|
||||
let entry = load_custom()
|
||||
.into_iter()
|
||||
.find(|e| library_id_for(e) == library_id)
|
||||
.find(|e| library_id_for(e) == library_id)?;
|
||||
super::collect_games()
|
||||
.iter()
|
||||
.any(|g| g.id == library_id)
|
||||
.then_some(entry)
|
||||
}
|
||||
|
||||
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
|
||||
@@ -374,8 +387,9 @@ 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.
|
||||
/// The **operator-privileged field** in a library payload, if the payload carries one: `prep`, or a
|
||||
/// launch kind that is not on [`UNPRIVILEGED_LAUNCH_KINDS`] — the fields whose contents the host
|
||||
/// could later execute 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
|
||||
@@ -386,10 +400,8 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||
/// 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`, `playnite`,
|
||||
/// `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.
|
||||
/// Every launch kind on [`UNPRIVILEGED_LAUNCH_KINDS`] stays open to every lane — a provider plugin
|
||||
/// can still publish its whole catalogue, it just cannot hand the host a program to run.
|
||||
pub fn privileged_field(
|
||||
launch: Option<&LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
@@ -397,12 +409,39 @@ pub fn privileged_field(
|
||||
if !prep.is_empty() {
|
||||
return Some("prep");
|
||||
}
|
||||
if launch.is_some_and(|l| l.kind == "command") {
|
||||
return Some("launch.kind = \"command\"");
|
||||
if launch.is_some_and(|l| !UNPRIVILEGED_LAUNCH_KINDS.contains(&l.kind.as_str())) {
|
||||
return Some("launch.kind");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The launch kinds any lane may publish: the host owns the whole command line and builds it from a
|
||||
/// value it validates per kind (`launch.rs`), so the entry NAMES a title rather than carrying a
|
||||
/// program to run. `plugin` is here for a different reason — its command is never stored at all: the
|
||||
/// host asks the live plugin for one at launch time ([`crate::library::ask_plugin_launch`]), so the
|
||||
/// stored entry on its own executes nothing.
|
||||
///
|
||||
/// An **allowlist**, deliberately, and the reason [`privileged_field`] reads the way round it does:
|
||||
/// a kind added to `launch.rs` and forgotten here is operator-only until someone lists it on
|
||||
/// purpose, which is the safe way to be wrong. It used to be a two-entry blocklist, and `gog` — an
|
||||
/// exe plus arguments — sat outside it as a standing exec primitive for the plugin lane (security
|
||||
/// review 2026-08-25). `gog` is listed now because `launch::gog_spawn` confines its exe to a GOG
|
||||
/// install the host enumerates itself; `command` never is, because `cmd.exe /c` / `sh -c` is the
|
||||
/// primitive this whole gate exists to withhold.
|
||||
const UNPRIVILEGED_LAUNCH_KINDS: &[&str] = &[
|
||||
"steam_appid",
|
||||
"steam_ui",
|
||||
"launcher_ui",
|
||||
"lutris_id",
|
||||
"heroic",
|
||||
"epic",
|
||||
"gog",
|
||||
"aumid",
|
||||
"xbox",
|
||||
"playnite",
|
||||
"plugin",
|
||||
];
|
||||
|
||||
/// 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> {
|
||||
@@ -1120,9 +1159,9 @@ 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.
|
||||
/// The field-authority rule behind the 2026-08-05 review's H-1: the fields the host later hands
|
||||
/// to a shell are operator-only. 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 {
|
||||
@@ -1138,10 +1177,7 @@ mod tests {
|
||||
undo: None,
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
privileged_field(Some(&cmd), &[]),
|
||||
Some("launch.kind = \"command\"")
|
||||
);
|
||||
assert_eq!(privileged_field(Some(&cmd), &[]), Some("launch.kind"));
|
||||
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.
|
||||
@@ -1149,6 +1185,50 @@ mod tests {
|
||||
assert_eq!(privileged_field(None, &[]), None);
|
||||
}
|
||||
|
||||
/// The gate is an ALLOWLIST, and this is the test that keeps it one: a launch kind nobody listed
|
||||
/// is operator-privileged, so a kind added to `launch.rs` and forgotten in
|
||||
/// `UNPRIVILEGED_LAUNCH_KINDS` fails closed instead of shipping as an open primitive. `gog` was
|
||||
/// exactly that miss — an exe plus arguments that the two-entry blocklist never named (security
|
||||
/// review 2026-08-25).
|
||||
///
|
||||
/// The listed set is pinned as well as the rule, so WIDENING it is a deliberate edit to this
|
||||
/// test rather than a line that slips through in someone's diff.
|
||||
#[test]
|
||||
fn an_unlisted_launch_kind_is_operator_privileged() {
|
||||
let kind = |k: &str| {
|
||||
let spec = LaunchSpec {
|
||||
kind: k.into(),
|
||||
value: "x".into(),
|
||||
};
|
||||
privileged_field(Some(&spec), &[])
|
||||
};
|
||||
assert_eq!(
|
||||
UNPRIVILEGED_LAUNCH_KINDS,
|
||||
&[
|
||||
"steam_appid",
|
||||
"steam_ui",
|
||||
"launcher_ui",
|
||||
"lutris_id",
|
||||
"heroic",
|
||||
"epic",
|
||||
"gog",
|
||||
"aumid",
|
||||
"xbox",
|
||||
"playnite",
|
||||
"plugin",
|
||||
],
|
||||
"widening this set hands the plugin lane a new launch kind — do it on purpose"
|
||||
);
|
||||
for k in UNPRIVILEGED_LAUNCH_KINDS {
|
||||
assert_eq!(kind(k), None, "`{k}` is on the allowlist");
|
||||
}
|
||||
// A kind this host has never heard of — a newer host's vocabulary, or a plugin's invention.
|
||||
assert_eq!(kind("brand_new_store"), Some("launch.kind"));
|
||||
assert_eq!(kind(""), Some("launch.kind"));
|
||||
// Casing is not a way in: the launch resolvers match the exact string.
|
||||
assert_eq!(kind("GOG"), Some("launch.kind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_name_and_payload_validation() {
|
||||
assert!(validate_provider_name("romm").is_ok());
|
||||
|
||||
@@ -339,8 +339,9 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
|
||||
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
|
||||
"epic" => epic_launch_uri(&spec.value)
|
||||
.map(|uri| WinRecipe::handoff(format!("explorer.exe \"{uri}\""))),
|
||||
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
|
||||
// ...and the one store recipe that is NOT a hand-off: the resolved exe is the game itself.
|
||||
// GOG: spawn the game's own exe directly (no Galaxy) — the one store recipe that is NOT a
|
||||
// hand-off. The triple comes from the plugin, so `gog_spawn` re-confines it to a GOG install
|
||||
// the host finds itself.
|
||||
"gog" => gog_spawn(&spec.value).map(|(cmdline, workdir)| WinRecipe::game(cmdline, workdir)),
|
||||
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
|
||||
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
|
||||
@@ -893,15 +894,40 @@ pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
|
||||
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
|
||||
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
|
||||
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple a GOG
|
||||
/// library plugin derives from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games
|
||||
/// are spawned directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
|
||||
///
|
||||
/// The exe (and the working dir) is re-confined here to an install directory GOG's own registry
|
||||
/// lists ([`gog_install_dirs`]). The plugin already confines it while parsing the manifest
|
||||
/// (plugin-kit's `confinedJoin`, the port of the in-host scanner's `confined_join`) — but that runs
|
||||
/// outside this host's trust boundary: the triple arrives over the provider API, and unchecked this
|
||||
/// kind is an arbitrary exe-plus-arguments primitive for any lane that may publish an entry
|
||||
/// (security review 2026-08-25). `None` ⇒ no GOG install owns that exe, so the entry has no recipe
|
||||
/// and the launch fails the way any unresolvable one does.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
gog_spawn_in(value, &gog_install_dirs())
|
||||
}
|
||||
|
||||
/// The pure core of [`gog_spawn`] (unit-testable without a GOG install): `installs` is the set of
|
||||
/// directories the exe and the working dir must sit inside.
|
||||
#[cfg(windows)]
|
||||
fn gog_spawn_in(value: &str, installs: &[String]) -> Option<(String, Option<PathBuf>)> {
|
||||
let under = |p: &str| installs.iter().any(|dir| path_under(dir, p));
|
||||
let mut parts = value.split('\t');
|
||||
let exe = parts.next().filter(|s| !s.is_empty())?;
|
||||
if !under(exe) {
|
||||
tracing::warn!(
|
||||
exe,
|
||||
"gog launch: the exe is in no GOG install — refusing it"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let args = parts.next().unwrap_or("");
|
||||
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
|
||||
// An out-of-bounds working dir is dropped rather than refused: it only decides where the
|
||||
// (confined) exe starts, and an entry that omits it already spawns without one.
|
||||
let workdir = parts.next().filter(|s| under(s)).map(PathBuf::from);
|
||||
let cmdline = if args.trim().is_empty() {
|
||||
format!("\"{exe}\"")
|
||||
} else {
|
||||
@@ -910,6 +936,52 @@ pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
|
||||
Some((cmdline, workdir))
|
||||
}
|
||||
|
||||
/// Windows: every directory GOG's own registry names as an installed game's root
|
||||
/// (`HKLM\SOFTWARE\WOW6432Node\GOG.com\Games\<productId>\PATH` — GOG is 32-bit, so the WOW view is
|
||||
/// where it writes). This is the host's OWN enumeration of the store, which is the whole point: it
|
||||
/// is what a `gog` launch value is checked against, and it is the same key the in-host scanner read
|
||||
/// before the store moved to a plugin. Empty when GOG isn't installed, which refuses every `gog`
|
||||
/// launch on that box.
|
||||
#[cfg(windows)]
|
||||
fn gog_install_dirs() -> Vec<String> {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
|
||||
let Ok(games) =
|
||||
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(r"SOFTWARE\WOW6432Node\GOG.com\Games")
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
games
|
||||
.enum_keys()
|
||||
.flatten()
|
||||
.filter_map(|sub| {
|
||||
let path: String = games.open_subkey(&sub).ok()?.get_value("PATH").ok()?;
|
||||
let path = path.trim().to_string();
|
||||
(!path.is_empty()).then_some(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `path` is `dir` itself or something inside it, compared the way Windows compares paths:
|
||||
/// case-insensitively, either separator, and with `..` refused outright (it would climb straight
|
||||
/// back out of the directory the prefix test just accepted — the component the in-host scanner's
|
||||
/// `confined_join` refused for the same reason). A string test rather than [`Path::starts_with`],
|
||||
/// which compares components case-SENSITIVELY: the install path a plugin sends need not be spelled
|
||||
/// the way the registry spells it.
|
||||
#[cfg(windows)]
|
||||
fn path_under(dir: &str, path: &str) -> bool {
|
||||
let norm = |s: &str| s.replace('/', "\\").trim_end_matches('\\').to_lowercase();
|
||||
let (dir, path) = (norm(dir), norm(path));
|
||||
if dir.is_empty() || path.split('\\').any(|c| c == "..") {
|
||||
return false;
|
||||
}
|
||||
path == dir
|
||||
|| path
|
||||
.strip_prefix(&dir)
|
||||
.is_some_and(|rest| rest.starts_with('\\'))
|
||||
}
|
||||
|
||||
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
||||
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||
/// through the compositor-aware [`launch_session_command`] instead.
|
||||
@@ -1341,13 +1413,38 @@ mod tests {
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_parses_and_guards() {
|
||||
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
let installs = ["C:\\Games\\W3".to_string(), "C:\\".to_string()];
|
||||
let spawn = |v: &str| gog_spawn_in(v, &installs);
|
||||
let (cmd, wd) = spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
|
||||
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
|
||||
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
|
||||
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
|
||||
let (cmd2, wd2) = spawn("C:\\g.exe").unwrap();
|
||||
assert_eq!(cmd2, "\"C:\\g.exe\"");
|
||||
assert!(wd2.is_none());
|
||||
assert!(gog_spawn("").is_none());
|
||||
assert!(spawn("").is_none());
|
||||
}
|
||||
|
||||
/// The `gog` kind is an exe PLUS ARGUMENTS, and the triple reaches the host over the provider
|
||||
/// API — so the exe has to be one the host itself found, not one the caller named (security
|
||||
/// review 2026-08-25). Without this the kind is `cmd.exe /c <anything>` by another spelling.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn gog_spawn_refuses_an_exe_outside_every_gog_install() {
|
||||
let installs = ["C:\\Games\\W3".to_string()];
|
||||
let spawn = |v: &str| gog_spawn_in(v, &installs);
|
||||
assert!(spawn("C:\\Windows\\System32\\cmd.exe\t/c calc\tC:\\Games\\W3").is_none());
|
||||
// A sibling whose name merely starts with the install path is not inside it.
|
||||
assert!(spawn("C:\\Games\\W3x\\evil.exe").is_none());
|
||||
// ...nor is anything that climbs back out of one.
|
||||
assert!(spawn("C:\\Games\\W3\\..\\..\\Windows\\System32\\cmd.exe").is_none());
|
||||
// No GOG install at all ⇒ nothing is launchable, rather than everything.
|
||||
assert!(gog_spawn_in("C:\\Games\\W3\\witcher3.exe", &[]).is_none());
|
||||
// Windows paths are case-insensitive and take either separator, so a plugin that spells the
|
||||
// install dir differently to the registry still launches its own games.
|
||||
assert!(spawn("c:/games/w3/bin/game.exe").is_some());
|
||||
// An out-of-bounds working dir costs the working dir, not the launch.
|
||||
let (_, wd) = spawn("C:\\Games\\W3\\witcher3.exe\t\tC:\\Windows\\System32").unwrap();
|
||||
assert!(wd.is_none());
|
||||
}
|
||||
|
||||
/// Moved here with `xbox_pfn` when the built-in scanners were removed: reducing a
|
||||
|
||||
@@ -15,16 +15,32 @@
|
||||
//! the owning plugin at the moment of an actual launch. What that buys over letting the plugin write
|
||||
//! `kind = "command"` straight into the library:
|
||||
//!
|
||||
//! * **A stolen plugin token is no longer command execution.** Planting an entry is not enough — the
|
||||
//! host asks the *live registered plugin* what to run, authenticated with the per-boot secret only
|
||||
//! that process knows. A plugin asked about an entry it never published answers 404 (this is why
|
||||
//! the ask names the entry rather than trusting the payload), so a forged entry launches nothing.
|
||||
//! * **An entry planted under someone ELSE'S provider launches nothing.** The provider is stamped by
|
||||
//! the host from the reconcile URL, so the plugin asked is the entry's owner, and a plugin asked
|
||||
//! about an entry it never published answers 404 (this is why the ask names the entry rather than
|
||||
//! trusting the payload).
|
||||
//! * **Nothing executable is ever persisted or served.** No command lands in `library.json`, and
|
||||
//! `GET /library` has none to redact for a paired client.
|
||||
//! * **No stale recipes.** The same reasoning as the `xbox` kind resolving its AUMID at launch time:
|
||||
//! an emulator that moved, or a config the operator has since edited, is picked up on the next
|
||||
//! launch instead of leaving an unlaunchable tile behind.
|
||||
//!
|
||||
//! What it does **not** buy — and this doc claimed it did until the 2026-08-25 review (H-1) — is a
|
||||
//! barrier against a stolen plugin token. That one credential also reaches `PUT /api/v1/plugins/{id}`
|
||||
//! for *any* id, and a registration names the loopback port and the per-boot secret the host will
|
||||
//! dial: a holder stands up its own listener, registers a provider around it, reconciles a `plugin`
|
||||
//! entry under that provider, and answers the ask with whatever it likes. The per-boot secret proves
|
||||
//! the host reached whoever registered that id, not that they were entitled to it.
|
||||
//!
|
||||
//! There is no per-plugin credential to bind it to, either: the runner hosts every plugin as a fiber
|
||||
//! in ONE bun process (`sdk/src/runner.ts` `import()`s each unit), reading one shared `plugin-token`,
|
||||
//! so no token can name a single plugin. Nor would one help — this kind exists so that a plugin MAY
|
||||
//! choose a command the host runs, so an id proven beyond doubt buys the same primitive. What is
|
||||
//! load-bearing is the principal gap: on Windows the runner is LocalService and the host is SYSTEM,
|
||||
//! so whatever can read the LocalService-readable `plugin-token` escalates through here. Closing it
|
||||
//! means running the answer as the runner's principal (which gives up the session placement below)
|
||||
//! or a process per plugin — a runner redesign, not a change to this transport.
|
||||
//!
|
||||
//! The host still *runs* the command, because only the host can put the process where the stream can
|
||||
//! see it: on Linux the line is either gamescope's own argv (a bare-spawn session nests it) or a
|
||||
//! spawn carrying the session's compositor env, and the returned child is what
|
||||
@@ -319,8 +335,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_404_means_the_plugin_disowns_the_entry() {
|
||||
// The forged-entry case: planting a library row is not enough, because the plugin that would
|
||||
// have to answer for it never published one.
|
||||
// The cross-provider case: a row planted under someone else's provider launches nothing,
|
||||
// because that provider's plugin is the one asked and it never published the key.
|
||||
let (port, server) = stub_plugin(404, r#"{"error":"no launchable entry \"forged\""}"#);
|
||||
crate::mgmt::register_ui_for_test("stub-disowner", port, "s");
|
||||
|
||||
|
||||
@@ -113,8 +113,15 @@ pub(crate) async fn require_auth(
|
||||
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
|
||||
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
|
||||
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
|
||||
// `effective`, not `is_paired`: the expiry-blind verb answers "is this device LISTED", which
|
||||
// is the device roster's question, not an admission gate's (see the `native_pairing` module
|
||||
// header's two-verbs contract). Authorizing on the listing would leave a guest whose access
|
||||
// lapsed hours ago holding this lane for as long as the record sits in the store.
|
||||
if cert_may_access(req.method(), req.uri().path())
|
||||
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
|
||||
&& st
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.effective(fp, unix_now()).is_some())
|
||||
{
|
||||
return forward(req, next, AuthLane::Cert).await;
|
||||
}
|
||||
@@ -190,6 +197,12 @@ pub(crate) async fn require_auth(
|
||||
/// 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.
|
||||
/// - **the host's log ring** (`GET /logs`) — it serves the host's own tracing at DEBUG and above,
|
||||
/// unredacted, which is the *same* webhook credentials (for Slack, Discord, ntfy, Teams, Zapier
|
||||
/// and Home Assistant the URL IS the bearer token) plus every command line the hook runner has
|
||||
/// spawned. Withholding `/hooks` while handing the ring over left the carve-out above decorative
|
||||
/// (2026-08-25 review H-2). A plugin still WRITES its own output — `POST /plugins/logs` below —
|
||||
/// which is the direction it actually needs.
|
||||
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
|
||||
/// *which devices may stream*; a plugin defect must not be able to admit an attacker's device
|
||||
/// or eject the operator's.
|
||||
@@ -205,6 +218,14 @@ pub(crate) async fn require_auth(
|
||||
/// 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.
|
||||
///
|
||||
/// That field refusal is **not** a command-execution boundary today, and this doc used to read as
|
||||
/// though it were: `PUT /plugins/{}` below lets this lane register *any* plugin id together with the
|
||||
/// loopback port and per-boot secret the host will dial, and a `launch.kind == "plugin"` entry under
|
||||
/// that id makes the host run whatever that listener answers
|
||||
/// ([`crate::library::ask_plugin_launch`], 2026-08-25 review H-1). No narrowing here can fix that:
|
||||
/// the runner hosts every plugin in ONE process on ONE shared token, so this gate cannot tell which
|
||||
/// plugin is calling, and a plugin that proved its id would still be entitled to that primitive.
|
||||
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
// (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is.
|
||||
const ALLOWED: &[(&Method, &str)] = &[
|
||||
@@ -215,7 +236,6 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
(&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"),
|
||||
@@ -323,3 +343,27 @@ pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
pub(crate) fn token_eq(presented: &str, expected: &str) -> bool {
|
||||
Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes())
|
||||
}
|
||||
|
||||
/// Host wall clock, unix seconds — the clock every stored access deadline is expressed in, sampled
|
||||
/// at each check (per-client-access design §4), same as `mgmt::native`'s copy.
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The log ring is the `/hooks` carve-out's back door: it carries the hook runner's webhook URLs
|
||||
/// (which ARE the bearer credential for Slack/Discord/ntfy/Teams/Zapier/Home Assistant) and the
|
||||
/// command lines it spawned, unredacted, to anyone who can `GET /logs`. A plugin only ever needs
|
||||
/// the other direction — pin both halves so neither drifts back (2026-08-25 review H-2).
|
||||
#[test]
|
||||
fn the_plugin_lane_writes_logs_but_never_reads_them() {
|
||||
assert!(!plugin_may_access(&Method::GET, "/api/v1/logs"));
|
||||
assert!(plugin_may_access(&Method::POST, "/api/v1/plugins/logs"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ use axum::Extension;
|
||||
///
|
||||
/// 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.
|
||||
/// scanner plugin — while `prep` / a `launch.kind` outside the host-resolved set inside that payload
|
||||
/// are the operator's authority alone. Route reachability and field authority are separate questions.
|
||||
///
|
||||
/// `Some((reason, 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
|
||||
@@ -60,11 +60,10 @@ fn check_privileged_fields(
|
||||
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, xbox, lutris_id, \
|
||||
heroic, playnite) \
|
||||
instead"
|
||||
"`{field}` can become a command the host runs as the host user, so it 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, xbox, lutris_id, heroic, playnite) or `plugin` instead"
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
|
||||
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
|
||||
//! other mutation. LAN clients have no business here.
|
||||
//!
|
||||
//! What that does NOT establish is *which* plugin is calling. `plugin-token` is one shared credential
|
||||
//! for the whole runner, so a registration's id is asserted, never proven: any holder can claim any
|
||||
//! id — including a live one's, whose port and secret it then replaces — and
|
||||
//! [`PluginRegistry::upsert`] has no ownership check to apply. Harmless while this stays a phone
|
||||
//! book, but the launch path reads it as an authority ([`ui_credential`] →
|
||||
//! [`crate::library::ask_plugin_launch`] dials the registered port and runs what it answers), which
|
||||
//! makes the shared token command execution (2026-08-25 review H-1). An ownership check here has
|
||||
//! nothing to check against and cannot be given one: the runner imports every plugin into ONE bun
|
||||
//! process (`sdk/src/runner.ts`), so there is no per-plugin process to mint a per-plugin token for,
|
||||
//! and a plugin that proved its id would still be entitled to answer its own launch asks. The gap
|
||||
//! that matters is the principal one (Windows: runner LocalService, host SYSTEM), and closing it is
|
||||
//! a runner redesign — a process per plugin — not a guard in this registry.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::events::{emit, EventKind};
|
||||
|
||||
@@ -1515,7 +1515,9 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("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),
|
||||
// The ring is unredacted host tracing — webhook URLs and hook command lines. Serving it to
|
||||
// the plugin lane made the `/hooks` carve-out decorative (2026-08-25 review H-2).
|
||||
("GET", "/api/v1/logs", false, false),
|
||||
// ---- diagnostics: OPERATOR ONLY, both lanes denied. The verdicts name the host's user,
|
||||
// its group layout and the state of its device nodes — a paired streaming client has no
|
||||
// business enumerating any of that, and a plugin that wanted to would be asking for a map
|
||||
|
||||
@@ -160,7 +160,10 @@ pub(super) fn resolve_compositor(
|
||||
to get dedicated game sessions."
|
||||
);
|
||||
} else {
|
||||
let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(
|
||||
Compositor::Gamescope,
|
||||
));
|
||||
let route = crate::vdisplay::resolve_gamescope_route(Compositor::Gamescope, true);
|
||||
tracing::info!(
|
||||
?route,
|
||||
"dedicated game session — routing to a headless gamescope spawn at the client \
|
||||
@@ -200,19 +203,18 @@ pub(super) fn resolve_compositor(
|
||||
pf_host_config::config().compositor.as_deref(),
|
||||
));
|
||||
}
|
||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). The
|
||||
// route travels back to the caller as a VALUE and is carried on the backend instance — an
|
||||
// operator pin skips the input retarget but still needs a route resolved, or `create` would
|
||||
// fall through to a bare spawn on a box that was pinned to the managed session.
|
||||
let route = if !overridden {
|
||||
crate::vdisplay::apply_input_env(chosen, false)
|
||||
} else {
|
||||
// An operator pin deliberately leaves PUNKTFUNK_INPUT_BACKEND alone, but still needs a
|
||||
// route resolved — otherwise `create` falls through to a bare spawn on a box pinned to
|
||||
// the managed session.
|
||||
crate::vdisplay::resolve_gamescope_route(chosen, false)
|
||||
};
|
||||
// Point input at the same backend the video landed on — as a published VALUE, not a
|
||||
// `PUNKTFUNK_INPUT_BACKEND` `set_var`. An operator pin skips this, which is what leaves the
|
||||
// operator's own knob in charge on a pinned box.
|
||||
if !overridden {
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(chosen));
|
||||
}
|
||||
// The gamescope sub-mode (managed where the session infra exists, attach to a foreign
|
||||
// gamescope, else per-session bare spawn). Resolved on BOTH paths and travelling back to the
|
||||
// caller as a value carried on the backend instance: a pin skips the input retarget above
|
||||
// but still needs a route, or `create` falls through to a bare spawn on a box that was
|
||||
// pinned to the managed session.
|
||||
let route = crate::vdisplay::resolve_gamescope_route(chosen, false);
|
||||
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
||||
match Compositor::from_pref(pref) {
|
||||
Some(want) if want == chosen => {
|
||||
|
||||
@@ -63,7 +63,8 @@ pub(super) async fn run(
|
||||
clip: pf_clipboard::ClipCoord,
|
||||
// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
|
||||
// same atomic the datagram filter reads; the deadline/watch task folds console edits into
|
||||
// it, so a `ClipControl` arriving after a mid-session revoke resolves against the new mask.
|
||||
// it, so a `ClipControl` or `ClipOffer` arriving after a mid-session revoke resolves against
|
||||
// the new mask.
|
||||
session_grants: Arc<AtomicU32>,
|
||||
// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
|
||||
// grant edits) — this task is the control stream's sole writer, so they cross here.
|
||||
@@ -77,6 +78,10 @@ pub(super) async fn run(
|
||||
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
|
||||
// stops firing on a perpetually-ready `None`.
|
||||
let mut clip_offer_closed = false;
|
||||
// Per-client-access enforcement drops for the messages this task gates (design §5.5): counted
|
||||
// per class with one `warn!` on the first, so a client spamming a revoked plane can't turn the
|
||||
// log into the DoS.
|
||||
let denied = GrantDrops::new();
|
||||
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
|
||||
// on every healthy path.
|
||||
let mut shard_change_closed = false;
|
||||
@@ -353,16 +358,27 @@ pub(super) async fn run(
|
||||
} else if let Ok(offer) = ClipOffer::decode(&msg) {
|
||||
// The client copied: hand its lazy format list to the coordinator, which
|
||||
// installs a host-side source that fetches from the client on host paste.
|
||||
tracing::debug!(
|
||||
seq = offer.seq,
|
||||
kinds = offer.kinds.len(),
|
||||
"clipboard offer from client"
|
||||
);
|
||||
let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect();
|
||||
let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer {
|
||||
seq: offer.seq,
|
||||
mimes,
|
||||
});
|
||||
// Gated like the `ClipControl` above, against the LIVE mask: this is the
|
||||
// WRITE half of the same permission — it puts a client-owned selection on the
|
||||
// host's real desktop clipboard — so an offer arriving after a mid-session
|
||||
// revoke is dropped, not installed.
|
||||
if clip_offer_permitted(
|
||||
session_grants.load(Ordering::Relaxed),
|
||||
clip_enabled.load(Ordering::SeqCst),
|
||||
) {
|
||||
tracing::debug!(
|
||||
seq = offer.seq,
|
||||
kinds = offer.kinds.len(),
|
||||
"clipboard offer from client"
|
||||
);
|
||||
let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect();
|
||||
let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer {
|
||||
seq: offer.seq,
|
||||
mimes,
|
||||
});
|
||||
} else {
|
||||
denied.note(GrantClass::Clipboard);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("unknown control message — ignoring");
|
||||
}
|
||||
@@ -403,6 +419,13 @@ pub(super) async fn run(
|
||||
// branch, the `clip_offer_closed` pattern.
|
||||
match update {
|
||||
Some(u) => {
|
||||
// An edit that took CLIPBOARD away leaves the session up, so tell the
|
||||
// coordinator too: the lifecycle task clearing `clip_enabled` only stops
|
||||
// the host→client direction, while the selection it installed for this
|
||||
// device stays on the host clipboard until `SetEnabled(false)` drops it.
|
||||
if u.grants & punktfunk_core::quic::GRANT_CLIPBOARD == 0 {
|
||||
let _ = clip_cmd_tx.send(ClipCoordCmd::SetEnabled(false));
|
||||
}
|
||||
if io::write_msg(&mut ctrl_send, &u.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -515,13 +538,21 @@ fn resolve_clip_control(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a client's [`ClipOffer`] may be installed on the host's real desktop clipboard: the
|
||||
/// device's LIVE `CLIPBOARD` grant, ANDed with the sync state its last [`ClipControl`] resolved
|
||||
/// (which already folded in the operator policy and backend availability). Both are read when the
|
||||
/// offer arrives, so a revoke mid-session closes this direction as it closes the other one.
|
||||
fn clip_offer_permitted(grants: u32, clip_enabled: bool) -> bool {
|
||||
grants & punktfunk_core::quic::GRANT_CLIPBOARD != 0 && clip_enabled
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::{
|
||||
CLIP_FLAG_FILES, CLIP_POLICY_FILES, CLIP_POLICY_TEXT, CLIP_REASON_BACKEND_UNAVAILABLE,
|
||||
CLIP_REASON_NOT_PERMITTED, CLIP_REASON_NO_FILES, CLIP_REASON_OK,
|
||||
CLIP_REASON_POLICY_DISABLED,
|
||||
CLIP_REASON_POLICY_DISABLED, GRANT_ALL, GRANT_CLIPBOARD,
|
||||
};
|
||||
|
||||
const ON: ClipControl = ClipControl {
|
||||
@@ -578,4 +609,23 @@ mod tests {
|
||||
(true, both, CLIP_REASON_OK)
|
||||
);
|
||||
}
|
||||
|
||||
/// The client→host direction is gated by the same grant as the host→client one: an offer from
|
||||
/// a device that never had CLIPBOARD is dropped, and so is one that arrives after a
|
||||
/// mid-session revoke — the console edit lands in the live mask (and clears the enable flag
|
||||
/// with it), and the very next offer resolves against the new one.
|
||||
#[test]
|
||||
fn clip_offer_needs_the_live_grant() {
|
||||
// The normal case: granted and sync on.
|
||||
assert!(clip_offer_permitted(GRANT_ALL, true));
|
||||
|
||||
// Never granted — nothing to install, whatever the client claims about sync.
|
||||
assert!(!clip_offer_permitted(GRANT_ALL & !GRANT_CLIPBOARD, true));
|
||||
assert!(!clip_offer_permitted(0, true));
|
||||
|
||||
// Revoked mid-session: the lifecycle task stored the edited mask and cleared the enable
|
||||
// flag; both halves of that state refuse the offer on their own.
|
||||
assert!(!clip_offer_permitted(GRANT_ALL & !GRANT_CLIPBOARD, false));
|
||||
assert!(!clip_offer_permitted(GRANT_ALL, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,62 +595,72 @@ struct PadAudioSlots {
|
||||
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
|
||||
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
|
||||
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
|
||||
/// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent
|
||||
/// arrival, so without a ceiling the client decides how many WASAPI captures the host opens.
|
||||
restarts: [u8; MAX_WIRE_PADS],
|
||||
/// Streamer starts spent per pad this session (R3) — the FIRST one included, because every
|
||||
/// start is client-triggered (an arrival declares the kinds), so without a ceiling the client
|
||||
/// decides how many WASAPI captures the host opens.
|
||||
starts: [u8; MAX_WIRE_PADS],
|
||||
}
|
||||
|
||||
/// R3: how many times one pad may change its declared audio kinds before the host stops
|
||||
/// obliging. A real controller declares once at open and never again; the re-sent arrivals are
|
||||
/// identical and take the no-op path above, so this is only reached by a client that keeps
|
||||
/// changing its mind.
|
||||
const MAX_PAD_AUDIO_RESTARTS: u8 = 8;
|
||||
/// R3: how many pad-audio captures one pad may open in a session before the host stops obliging.
|
||||
/// A real controller declares once at open and never again; the re-sent arrivals are identical
|
||||
/// and take the no-op path above, so this is only reached by a client that keeps cycling — by
|
||||
/// changing its declared kinds, or by alternating a declaring arrival with anything that stops
|
||||
/// the streamer (a `want == 0` re-declare, a `GamepadRemove`), which spawns and tears down just
|
||||
/// the same while never touching the kind-change arm.
|
||||
const MAX_PAD_AUDIO_STARTS: u8 = 8;
|
||||
|
||||
impl PadAudioSlots {
|
||||
fn new() -> PadAudioSlots {
|
||||
PadAudioSlots {
|
||||
slots: std::array::from_fn(|_| None),
|
||||
restarts: [0; MAX_WIRE_PADS],
|
||||
starts: [0; MAX_WIRE_PADS],
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
|
||||
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
|
||||
/// retries, since arrivals are only re-sent a few times per slot open). `edge` picks the
|
||||
/// DualSense Edge identity for the Linux sink (ignored on Windows — endpoints are
|
||||
/// pre-stamped).
|
||||
/// retries, since arrivals are only re-sent a few times per slot open). Every capture that
|
||||
/// actually opens spends from [`MAX_PAD_AUDIO_STARTS`]. `edge` picks the DualSense Edge
|
||||
/// identity for the Linux sink (ignored on Windows — endpoints are pre-stamped).
|
||||
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8, edge: bool) {
|
||||
let idx = pad as usize;
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
if let Some((have, _)) = &self.slots[idx] {
|
||||
if *have == kinds {
|
||||
return; // identical re-arrival — keep the running streamer
|
||||
}
|
||||
// R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven.
|
||||
// Nothing bounded it: a client alternating its declared kinds could make the host
|
||||
// tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a
|
||||
// thread spawn and an endpoint activation. Cheap to bound, and a pad that has already
|
||||
// changed its mind this many times in one session is not doing anything legitimate.
|
||||
if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS {
|
||||
tracing::warn!(
|
||||
pad = idx,
|
||||
"pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \
|
||||
ignoring; the streamer keeps its current kinds for this session"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.restarts[idx] += 1;
|
||||
let running = self.slots[idx].as_ref().map(|(have, _)| *have);
|
||||
if running == Some(kinds) {
|
||||
return; // identical re-arrival — keep the running streamer
|
||||
}
|
||||
// R3: the trigger is a CLIENT-sent arrival, so the count is client-driven. Nothing
|
||||
// bounded it: a client alternating its declared kinds — or alternating a declaring
|
||||
// arrival with a stop (`want == 0`, `GamepadRemove`) and then declaring again, which
|
||||
// never reaches the kind-change arm at all — could make the host tear down and re-spawn a
|
||||
// WASAPI loopback capture indefinitely, each cycle paying a thread spawn and an endpoint
|
||||
// activation. Cheap to bound, and a pad that has opened this many captures in one session
|
||||
// is not doing anything legitimate. Gated BEFORE the stop below, so a pad at the ceiling
|
||||
// keeps the streamer it has rather than losing it to the last request.
|
||||
if self.starts[idx] >= MAX_PAD_AUDIO_STARTS {
|
||||
tracing::warn!(
|
||||
pad = idx,
|
||||
"pad-audio streamer already started {MAX_PAD_AUDIO_STARTS} times — ignoring; the \
|
||||
pad keeps whatever streamer it has for this session"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if running.is_some() {
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
restarts = self.restarts[idx],
|
||||
starts = self.starts[idx],
|
||||
"pad-audio kinds changed — restarting the streamer"
|
||||
);
|
||||
self.stop(idx);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, edge, stop) {
|
||||
// Charged only for a capture that actually opened: a slot with no provisioned
|
||||
// endpoint spawns nothing, and the arrival re-sends against it must not spend the
|
||||
// ceiling.
|
||||
self.starts[idx] += 1;
|
||||
self.slots[idx] = Some((kinds, h));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +50,23 @@ pub(super) async fn pair_ceremony(
|
||||
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
|
||||
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&PairChallenge {
|
||||
spake_b,
|
||||
confirm: confirms.host,
|
||||
}
|
||||
.encode(),
|
||||
// Bounded: this write completes only when the CLIENT grants flow-control credit, so a client
|
||||
// that advertises a tiny `stream_receive_window` could otherwise park the ceremony here for as
|
||||
// long as it liked — on a host that serves pairings one at a time, and past the armed window's
|
||||
// TTL (2026-08-25 review).
|
||||
tokio::time::timeout(
|
||||
PAIRING_TIMEOUT,
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&PairChallenge {
|
||||
spake_b,
|
||||
confirm: confirms.host,
|
||||
}
|
||||
.encode(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|_| anyhow!("pairing timed out sending the challenge"))??;
|
||||
|
||||
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
|
||||
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
|
||||
@@ -71,9 +79,9 @@ pub(super) async fn pair_ceremony(
|
||||
// signal to scope this to failures only (the client just disconnects).
|
||||
//
|
||||
// The armed window carries the operator's access choice for whoever completes this ceremony
|
||||
// (design §5.7) — read it BEFORE the consume below wipes it with the rest of the window.
|
||||
let access = np.armed_access();
|
||||
np.disarm();
|
||||
// (design §5.7) — read it BEFORE the consume wipes it with the rest of the window, and refuse
|
||||
// outright if the window lapsed while we were writing.
|
||||
let access = consume_window(np, pin)?;
|
||||
|
||||
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
|
||||
.await
|
||||
@@ -92,7 +100,13 @@ pub(super) async fn pair_ceremony(
|
||||
} else {
|
||||
tracing::warn!(name = %name, "pairing rejected (wrong PIN) — fingerprint not stored");
|
||||
}
|
||||
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
|
||||
// Bounded for the same reason as the challenge write above.
|
||||
tokio::time::timeout(
|
||||
PAIRING_TIMEOUT,
|
||||
io::write_msg(&mut send, &PairResult { ok }.encode()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("pairing timed out sending the result"))??;
|
||||
let _ = send.finish();
|
||||
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
|
||||
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
|
||||
@@ -101,3 +115,89 @@ pub(super) async fn pair_ceremony(
|
||||
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume the armed window this ceremony is running against: its access choice, plus the proof
|
||||
/// that the window is STILL the one whose PIN we started with. `Err` ⇒ it lapsed while the ceremony
|
||||
/// was in flight (expired, disarmed, or the operator re-armed) — mint nothing, and leave whatever
|
||||
/// window is armed *now* untouched, so a stalling client can't wipe the operator's next one.
|
||||
///
|
||||
/// The order is the security property: the access choice is read BEFORE the PIN is re-checked, so
|
||||
/// an expiry landing between the two reads fails CLOSED (no choice AND no PIN ⇒ refused). Reading
|
||||
/// them the other way round would fail OPEN — [`NativePairing::add_with_access`] reads an absent
|
||||
/// choice as the full/permanent default, which is exactly what an expired "controller only, 4
|
||||
/// hours" window must never become.
|
||||
fn consume_window(np: &NativePairing, pin: &str) -> Result<Option<crate::native_pairing::Access>> {
|
||||
let access = np.armed_access();
|
||||
anyhow::ensure!(
|
||||
np.current_pin().as_deref() == Some(pin),
|
||||
"the pairing window lapsed while the ceremony was running"
|
||||
);
|
||||
np.disarm();
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::native_pairing::Access;
|
||||
use std::time::Duration;
|
||||
|
||||
fn temp(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"pf-native-ceremony-{tag}-{}.json",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn controller_4h() -> Access {
|
||||
Access {
|
||||
grants: punktfunk_core::quic::GRANT_PRESET_CONTROLLER_ONLY,
|
||||
expires_unix: Some(4 * 3600),
|
||||
}
|
||||
}
|
||||
|
||||
/// A window that lapses mid-ceremony mints NOTHING. Without the re-check the ceremony read an
|
||||
/// expired window as "no access choice", and `add_with_access(.., None)` turns that into full,
|
||||
/// permanent control for a new fingerprint — the operator's "controller only, 4 h" silently
|
||||
/// upgraded by a client that stalled the host's write past the TTL (2026-08-25 review).
|
||||
#[test]
|
||||
fn expired_window_mints_no_grant() {
|
||||
let p = temp("expired");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||
|
||||
// Live window: the operator's choice comes through, and the window is consumed.
|
||||
let pin = np.arm_for(Duration::from_secs(60), None, Some(controller_4h()));
|
||||
assert_eq!(consume_window(&np, &pin).unwrap(), Some(controller_4h()));
|
||||
assert!(np.current_pin().is_none(), "single-use: window consumed");
|
||||
|
||||
// Lapsed window (a zero TTL is already past by the time it is read): refuse, rather than
|
||||
// fall through to the full/permanent default.
|
||||
let pin = np.arm_for(Duration::ZERO, None, Some(controller_4h()));
|
||||
assert!(consume_window(&np, &pin).is_err());
|
||||
|
||||
// A window the operator re-armed while the ceremony ran is somebody else's: refuse, and
|
||||
// do NOT disarm it — otherwise a stalling client wipes every window that follows.
|
||||
let stale = np.arm_for(Duration::ZERO, None, Some(controller_4h()));
|
||||
// The PIN is random, so re-arm until it actually differs (1-in-10 000 otherwise).
|
||||
let mut fresh = np.arm_for(Duration::from_secs(60), None, Some(controller_4h()));
|
||||
while fresh == stale {
|
||||
fresh = np.arm_for(Duration::from_secs(60), None, Some(controller_4h()));
|
||||
}
|
||||
assert!(consume_window(&np, &stale).is_err());
|
||||
assert_eq!(np.current_pin().as_deref(), Some(fresh.as_str()));
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// The CLI `--allow-pairing` window carries no access choice and no expiry: `None` there is the
|
||||
/// legitimate full/permanent default, not an expired window, and must still pair.
|
||||
#[test]
|
||||
fn choiceless_window_still_pairs() {
|
||||
let p = temp("choiceless");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = NativePairing::load_with(Some(p.clone()), Some("4321".into()), true).unwrap();
|
||||
assert_eq!(consume_window(&np, "4321").unwrap(), None);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2402,8 +2402,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
if sw.compositor != compositor {
|
||||
tracing::info!(from = compositor.id(), to = sw.compositor.id(), kind = ?sw.kind,
|
||||
"session switch — rebuilding backend in place");
|
||||
// Retarget the process env at the new session BEFORE opening the new backend (this
|
||||
// thread is the only env writer; the watcher only snapshots).
|
||||
// Retarget the process env at the new session BEFORE opening the new backend. Being
|
||||
// the only WRITER is not safety: `setenv` races every concurrent `getenv` in the
|
||||
// process — glibc's own internals, zbus, wayland-client, the Mesa loader — and none
|
||||
// of them takes pf-vdisplay's `ENV_LOCK`. The four variables this still writes are
|
||||
// the ones whose readers can only take them from the environment; see that lock's
|
||||
// doc for what it does and does not buy (security-review 2026-08-25).
|
||||
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
|
||||
kind: sw.kind,
|
||||
env: sw.env,
|
||||
@@ -2411,7 +2415,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
});
|
||||
// A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the
|
||||
// switched-to backend's normal sub-mode.
|
||||
let switched_route = crate::vdisplay::apply_input_env(sw.compositor, false);
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(sw.compositor));
|
||||
let switched_route = crate::vdisplay::resolve_gamescope_route(sw.compositor, false);
|
||||
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
|
||||
// point at the old session, so input would silently not land until a reconnect.
|
||||
// Settle it (env push + KWin portal restart) before the injector reopens against it.
|
||||
@@ -3290,7 +3295,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
// Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
|
||||
let rebuilt_route = crate::vdisplay::apply_input_env(c, false);
|
||||
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(c));
|
||||
let rebuilt_route = crate::vdisplay::resolve_gamescope_route(c, false);
|
||||
if c != compositor {
|
||||
if matches!(
|
||||
c,
|
||||
|
||||
@@ -120,15 +120,18 @@ pub fn main(args: &[String]) -> Result<()> {
|
||||
/// stdout/stderr are redirected to `host.log` in the same dir.
|
||||
pub fn service_log_path() -> PathBuf {
|
||||
let dir = pf_paths::config_dir().join("logs");
|
||||
// DACL-locked (Users read-only, no create) so a local user can't pre-plant SYSTEM log files as
|
||||
// reparse points / hardlinks to redirect the SYSTEM service's writes (security-review #11).
|
||||
let _ = pf_paths::create_private_dir(&dir);
|
||||
// DACL-locked (no create) so a local user can't pre-plant SYSTEM log files as reparse points /
|
||||
// hardlinks to redirect the SYSTEM service's writes (security-review #11). `create_secret_dir`,
|
||||
// not `create_private_dir`: the config dir's inheritable `BUILTIN\Users:(RX)` reached these
|
||||
// files too, and a host log carries webhook URLs and launched command lines — the operator
|
||||
// reads them through the console, not off disk (security-review 2026-08-25).
|
||||
let _ = pf_paths::create_secret_dir(&dir);
|
||||
dir.join("service.log")
|
||||
}
|
||||
|
||||
fn host_log_path() -> PathBuf {
|
||||
let dir = pf_paths::config_dir().join("logs");
|
||||
let _ = pf_paths::create_private_dir(&dir);
|
||||
let _ = pf_paths::create_secret_dir(&dir);
|
||||
dir.join("host.log")
|
||||
}
|
||||
|
||||
@@ -769,7 +772,7 @@ fn open_log_handle(path: &std::path::Path) -> Result<HANDLE> {
|
||||
/// reason.
|
||||
fn web_log_path() -> PathBuf {
|
||||
let dir = pf_paths::config_dir().join("logs");
|
||||
let _ = pf_paths::create_private_dir(&dir);
|
||||
let _ = pf_paths::create_secret_dir(&dir);
|
||||
dir.join("web.log")
|
||||
}
|
||||
|
||||
@@ -1894,8 +1897,17 @@ fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
|
||||
);
|
||||
return;
|
||||
};
|
||||
// Validity-only Authenticode check: the failed update's manifest pins are gone with it,
|
||||
// and the cached file was fully verified (manifest sha256 + pins) when first downloaded.
|
||||
// Validity-only Authenticode check — and validity is ALL it proves: that the file carries a
|
||||
// cryptographically intact signature, not whose. It is deliberately not more than that, and the
|
||||
// earlier claim here (that the cached file had been verified against "manifest sha256 + pins")
|
||||
// overstated it: releases sign through Azure Artifact Signing, which mints a fresh leaf per
|
||||
// request, so the manifest carries NO `authenticode_sha256` pins to check against and never
|
||||
// has (see `update::windows`'s module docs). What actually binds these bytes is the SHA-256 in
|
||||
// the Ed25519-signed manifest, checked when this installer was downloaded, plus the config-dir
|
||||
// DACL (Users read-only, no create) that keeps `updates\` un-plantable by a local user.
|
||||
// Pinning the publisher here needs `verify_authenticode` to compare something stable across
|
||||
// leaf rotation — the signing subject or the issuing intermediate — which is a change to
|
||||
// `update::windows`, not to this call site (security-review 2026-08-25).
|
||||
if let Err(e) = crate::update::windows::verify_authenticode(&previous, &[]) {
|
||||
tracing::error!(
|
||||
installer = %previous.display(),
|
||||
|
||||
@@ -90,10 +90,15 @@ The document is validated as a whole, and **one bad entry disables every hook**
|
||||
hostname — are fine, so Home Assistant on another box on your network works as written.
|
||||
- `timeout_s` must be 1–600.
|
||||
- If `hmac_secret_file` is set but unreadable, the host **skips** that POST rather than sending it
|
||||
unsigned.
|
||||
unsigned. It also *warns* (and still signs) when that file isn't owned by you or is readable by
|
||||
anyone else — `chmod 600` it.
|
||||
|
||||
So check the log after editing the file: `journalctl --user -u punktfunk-host` on Linux, or the web
|
||||
console's **Logs** page on either platform.
|
||||
console's **Logs** page on either platform. Those lines name a hook by its webhook's
|
||||
`scheme://host` or its command's program name, plus a short id — the URL path and the command's
|
||||
arguments are left out, because that's where a Slack or ntfy token and an `Authorization:` header
|
||||
live, and the **Logs** page is served over the API verbatim. The id is the same on every line about
|
||||
one hook, so two hooks sharing a program or a webhook host stay apart.
|
||||
|
||||
A `run` command's shell one-liner vocabulary — the event flattened to env, values sanitized:
|
||||
|
||||
@@ -122,9 +127,12 @@ ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)
|
||||
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra firings
|
||||
are dropped with a log line, never queued), and a command that outlives its timeout is killed.
|
||||
Hook commands run as the host user, so `hooks.json` is operator-privileged config. On Linux, when a
|
||||
command starts with an **absolute path** to a script, the host checks that file is owned by you (or
|
||||
root) and not group/world-writable, and refuses to run it — loudly, in the log — if it isn't. Write
|
||||
the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if you want that
|
||||
command names a script by **absolute path**, the host checks that file *and every directory above
|
||||
it* is owned by you (or root) and not group/world-writable, and refuses to run it — loudly, in the
|
||||
log — if it isn't: whoever can rename an entry in a directory chooses what runs out of it. (A
|
||||
world-writable directory with the sticky bit, like `/tmp`, passes — there only an entry's own owner
|
||||
can replace it.) Quoting is understood, so a path with a space in it is checked like any other.
|
||||
Write the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if you want that
|
||||
check: the shell expands `~` and looks up PATH names like `makoctl` only afterwards, so those are
|
||||
never checked. On Windows there is no per-script check — the ACL on the config directory is the
|
||||
boundary.
|
||||
|
||||
@@ -162,7 +162,7 @@ if you have a desktop client you already have it:
|
||||
|
||||
```sh
|
||||
punktfunk hosts list --probe # saved hosts, each with a live reachability check
|
||||
punktfunk pair <host>[:port] --pin 1234 # enrol this device with a host
|
||||
punktfunk pair <host>[:port] --pin - # enrol this device with a host (PIN on stdin)
|
||||
punktfunk library <host-ref> --json # the host's games, machine-readable
|
||||
punktfunk launch <host-ref> --game <id> # stream, waking the host first if it's asleep
|
||||
punktfunk open 'punktfunk://connect/<host-ref>'
|
||||
@@ -174,6 +174,14 @@ link takes. There is also `hosts add` / `hosts forget`,
|
||||
[`wake`](/docs/wake-on-lan#from-the-command-line), `reachable`, `profiles list` and `reset`; run
|
||||
`punktfunk help <command>` for a verb's flags.
|
||||
|
||||
`--pin -` reads the PIN from stdin (`echo 1234 | punktfunk pair …`), which is the form to script.
|
||||
`--pin 1234` still works, but the value sits on the command line, where every local user can read it
|
||||
(`/proc/*/cmdline`); with neither flag, an interactive run asks for it.
|
||||
|
||||
`punktfunk open` connects on its own only when the link names its host by the stable record id. A
|
||||
link naming it by label or address is a guess anything could make, so it asks first — and with no
|
||||
terminal to ask on it refuses (exit 6) unless you pass `--yes`.
|
||||
|
||||
Exit codes are stable, so a script can branch without parsing prose: **0** ok, **2** connect failed,
|
||||
**3** trust rejected (re-pair), **4** the renderer couldn't start, **5** nothing matched what you
|
||||
named, **6** it needs a person (pairing, or an unknown host).
|
||||
|
||||
@@ -102,7 +102,9 @@ punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>][
|
||||
```
|
||||
|
||||
`<host-ref>` is a saved host's stable record id, its name (unique, ignoring case), or `addr[:port]`,
|
||||
resolved in that order; a name matching two saved hosts is refused rather than guessed.
|
||||
resolved in that order; a name matching two saved hosts is refused rather than guessed. Only the
|
||||
record id opens without asking — a name or an address is something any web page could guess, so a
|
||||
link built on one confirms first.
|
||||
|
||||
| Parameter | Means |
|
||||
|---|---|
|
||||
@@ -132,6 +134,9 @@ punktfunk://connect/Living%20Room%20PC?launch=steam:570
|
||||
punktfunk://connect/Living%20Room%20PC?profile=Work
|
||||
```
|
||||
|
||||
All three name the host by label, so they ask before connecting. Put the record id in place of the
|
||||
name — which is what **Copy link** writes — for a link that opens in one click.
|
||||
|
||||
## What a link can and can't do
|
||||
|
||||
The rule the grammar keeps: **a link may only do what clicking a card you already have could do,
|
||||
@@ -141,12 +146,17 @@ minus every trust decision.**
|
||||
page cannot shape your session beyond choosing among your own configurations.
|
||||
- There is no `pair` route and never will be. `punktfunk://pair/...` is refused outright;
|
||||
[pairing](/docs/pairing) stays something you do with the fingerprint on screen.
|
||||
- Only the **stable record id** connects unattended. A link naming the host by its label or its
|
||||
address — including through `host=` — reaches the same host, but behind an *Open this link?*
|
||||
confirmation naming the host and anything it asks to launch: "Gaming PC" and a LAN address are
|
||||
guesses anything that can open a URL could make. **Copy link** always writes the id, so a shortcut
|
||||
you made keeps opening in one click.
|
||||
- A link naming a host you don't know is never connected. When it carries an address — as
|
||||
`<host-ref>` or `host=` — Linux and Android open the app's normal trust prompt, pre-filled with
|
||||
that address and any `fp` the link carried, so the first connect is verified rather than blind.
|
||||
Windows and the Apple apps show a notice naming the host, and you pair from the host list
|
||||
yourself. A link with no address to fall back on — a bare name or a stale record id — is refused
|
||||
with a notice.
|
||||
`<host-ref>` or `host=` — Linux, Android and Windows open the app's normal trust prompt, pre-filled
|
||||
with that address and any `fp` the link carried, so the first connect is verified rather than
|
||||
blind. The Apple apps show a notice naming the host, and you add it from the host list yourself.
|
||||
A link with no address to fall back on — a bare name or a stale record id — is refused with a
|
||||
notice.
|
||||
- An `fp` that contradicts the fingerprint already pinned for that host is a hard refusal with a
|
||||
notice. Nothing connects.
|
||||
- A `profile=` that names nothing on this device, or two profiles at once, refuses **before**
|
||||
@@ -183,9 +193,12 @@ box, use the `punktfunk` CLI:
|
||||
|
||||
```bash
|
||||
punktfunk profiles list # ids, names, how many settings each overrides
|
||||
punktfunk open 'punktfunk://connect/Desk?profile=Work'
|
||||
punktfunk open --yes 'punktfunk://connect/Desk?profile=Work'
|
||||
```
|
||||
|
||||
`--yes` answers the confirmation a label- or address-referenced link raises; a link carrying the
|
||||
record id needs no flag. Without a terminal to ask on and without `--yes`, `open` refuses (exit 6).
|
||||
|
||||
It ships in the Linux client packages and the Windows MSIX. The Flatpak has it too, inside the
|
||||
sandbox — `flatpak run --command=punktfunk io.unom.Punktfunk`. See [Clients](/docs/clients) for the
|
||||
rest of its verbs.
|
||||
|
||||
@@ -733,7 +733,9 @@ themselves within a minute. The service commands need an **elevated** PowerShell
|
||||
3. **The console page never loads.** The service restarts the console on any failure, so give it a
|
||||
minute first. If it stays down, the console's own log says why — check
|
||||
`%ProgramData%\punktfunk\logs\web.log` (and `service.log` next to it, which records every console
|
||||
start and exit), then restart the service:
|
||||
start and exit). Those files are readable only by Administrators and SYSTEM, so open them from an
|
||||
**elevated** PowerShell — `Get-Content -Tail 50 $env:ProgramData\punktfunk\logs\web.log` — then
|
||||
restart the service:
|
||||
|
||||
```powershell
|
||||
punktfunk-host service restart
|
||||
@@ -782,7 +784,10 @@ The same output also lands outside the console — on Linux in the journal
|
||||
(`journalctl --user -u punktfunk-host`), on Windows in `%ProgramData%\punktfunk\logs\host.log` (plus
|
||||
`service.log` for the service that supervises it). Those *do* follow the log level: raise it with
|
||||
`RUST_LOG=debug` in [`host.env`](/docs/configuration) and restart the host. `RUST_LOG=info` is
|
||||
already the default, so setting it changes nothing.
|
||||
already the default, so setting it changes nothing. The Windows files are readable only by
|
||||
Administrators and SYSTEM — a normal editor is refused even on an admin account, because Windows
|
||||
hands it a filtered token — so open them from an **elevated** PowerShell, or stay on the **Logs**
|
||||
page above, which needs no elevation.
|
||||
|
||||
None of that covers the **client** side. If the picture, the decoder or the presenter is what
|
||||
failed, the Windows client keeps its own log at `%LOCALAPPDATA%\punktfunk\logs\client.log` (rotated
|
||||
|
||||
@@ -79,13 +79,24 @@ sudo punktfunk-sysext install --from-file ~/punktfunk-known-good.raw # to go b
|
||||
against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches the
|
||||
image built for the new base.
|
||||
- **If it refuses the feed.** `refusing to install from an unsigned feed` means that Fedora major's
|
||||
feed predates signing; it gets sealed on the next publish. To install from it anyway, accepting
|
||||
unauthenticated images, `sudo env PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 bash punktfunk-sysext.sh install`.
|
||||
The other message, `the feed's SHA256SUMS is NOT signed by packages@unom.io`, is not the same
|
||||
thing — don't install; re-download the script and try again.
|
||||
feed predates signing; it gets sealed on the next publish. `signed but UNBOUND` is the same kind
|
||||
of thing one step on — a feed sealed before manifests carried their `# FEED`/`# SERIAL` header —
|
||||
and it too is fixed by the next publish. To install from either anyway, accepting unauthenticated
|
||||
images, `sudo env PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 bash punktfunk-sysext.sh install`.
|
||||
The other three messages are not the same thing — don't install; re-download the script and try
|
||||
again: `the feed's SHA256SUMS is NOT signed by packages@unom.io`, `this manifest was signed for
|
||||
the feed '…'` (another channel's or another Fedora major's feed served as yours), and
|
||||
`manifest serial … is older than the last accepted …` (a real but superseded manifest replayed).
|
||||
- **If it refuses to downgrade.** `the feed's newest image (…) is OLDER than the installed …`
|
||||
means the feed lost a build — `update` won't walk you back onto a superseded release without
|
||||
being asked. For a deliberate rollback,
|
||||
`sudo PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE=1 punktfunk-sysext update`, or re-install the image file
|
||||
you kept above, which never consults the feed.
|
||||
- The feed's checksum manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the
|
||||
same one that signs the RPMs) and `punktfunk-sysext` verifies it against a key baked into the
|
||||
script, so it needs `gpg` on the box.
|
||||
script, so it needs `gpg` on the box. The manifest also names the feed it was signed for and
|
||||
carries a publish serial, both inside the signed bytes, so a signed manifest from another
|
||||
channel — or an older one put back — can't be replayed at yours.
|
||||
|
||||
### Restart after a Linux package update
|
||||
|
||||
@@ -121,8 +132,9 @@ silently — the service restarts at the end and the page reconnects by itself.
|
||||
you're warned first: updating drops it.
|
||||
|
||||
Every attempt leaves a result in the card (and an installer log under
|
||||
`C:\ProgramData\punktfunk\logs\update-<version>.log`) — including across the restart, so a failed
|
||||
update is never silent.
|
||||
`C:\ProgramData\punktfunk\logs\update-<version>.log` — readable only by Administrators and
|
||||
SYSTEM, so open it from an **elevated** PowerShell) — including across the restart, so a
|
||||
failed update is never silent.
|
||||
|
||||
If the newly installed host crash-loops, the service puts the previous installer back on its own
|
||||
(the last two are kept) and says so in the card — you end up on the version you started from, not
|
||||
|
||||
@@ -219,7 +219,9 @@ reach *Stopped*, and starts again); `punktfunk-host service status` shows the cu
|
||||
|
||||
The host writes to `%ProgramData%\punktfunk\logs\` — `service.log` for the service supervisor and
|
||||
`host.log` for the streaming host itself. Each is rotated to `.old` at the next start once it passes
|
||||
10 MB, one generation kept. The web console's **Logs** tab shows the same stream live. For more
|
||||
10 MB, one generation kept. Both are readable only by Administrators and SYSTEM — a normal editor
|
||||
can't open them even on an admin account — so read them from an **elevated** PowerShell, or use the
|
||||
web console's **Logs** tab, which shows the same stream live and needs no elevation. For more
|
||||
detail, set `RUST_LOG=debug` in `host.env` and restart the service.
|
||||
|
||||
### Updating
|
||||
|
||||
@@ -41,6 +41,15 @@ that's the walkthrough to hand a user. Packager-side facts:
|
||||
OpenPGP signature from `packages@unom.io` (`AF245C506F4E4763`, the RPM signing key). The public
|
||||
key is baked into `punktfunk-sysext.sh`; the script refuses a feed it can't verify
|
||||
(`PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1` is the documented escape hatch for pre-signing feeds).
|
||||
- `SHA256SUMS` opens with `# FEED <name>` and `# SERIAL <unix-ts>` **inside the signed bytes**, so
|
||||
the signature says which feed and which publish it covers — a write:package token without the
|
||||
signing key can otherwise copy a canary manifest into the stable path, or put last month's back,
|
||||
and every box verifies it happily. `punktfunk-sysext` refuses a manifest that is unbound, is
|
||||
bound to a different feed, or whose serial is below the highest it has accepted (persisted per
|
||||
feed in `/var/lib/extensions/.punktfunk.serial-floor`, which survives `remove` on purpose).
|
||||
`publish-sysext-feed.sh` stamps both on every publish; a feed published before binding existed
|
||||
is bound by its next publish, or now with
|
||||
`TOKEN=… bash packaging/bazzite/publish-sysext-feed.sh --seal f<ver>[-canary]`.
|
||||
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
|
||||
major rebase the old image is refused instead of merging soname-broken binaries; feeds exist
|
||||
per Fedora major, from the same CI matrix as the RPM groups.
|
||||
|
||||
@@ -65,6 +65,31 @@ read_manifest() {
|
||||
grep -E '^[0-9a-f]{64} [^ ]+$' "$FETCHED" > "$SUMS" || :
|
||||
}
|
||||
|
||||
# bind_manifest — stamp the feed name and a publish serial INTO the bytes about to be signed.
|
||||
#
|
||||
# A bare checksum list binds nothing: the signature proves "we signed these bytes", never "…for the
|
||||
# stable feed, this month". So anyone who can WRITE the registry without holding the key — a leaked
|
||||
# write:package token, the kind that sits in half our CI jobs — can copy this feed's manifest,
|
||||
# signature and images into another channel's path, or put an old pair back, and every box verifies
|
||||
# them happily and merges the result over /usr. punktfunk-sysext(8) refuses a manifest whose FEED is
|
||||
# not the feed it fetched and one whose SERIAL is below the highest it has accepted, which is the
|
||||
# same channel-binding + monotonic-serial pair the Rust updater enforces on its own manifest
|
||||
# (crates/pf-update-check/src/manifest.rs). Comment lines, so a `#`-skipping checksum reader and the
|
||||
# clients' image-line parsers are both unaffected by them.
|
||||
bind_manifest() {
|
||||
local serial prev
|
||||
serial="$(date +%s)"
|
||||
# Monotonic per feed, and never below what the feed already claims. Clients keep the highest
|
||||
# serial they have accepted and refuse anything lower, so one runner with a fast clock would
|
||||
# otherwise lock every box out of this feed until wall-clock caught up.
|
||||
prev="$(sed -n 's/^# SERIAL //p' "$FETCHED" | head -n1)"
|
||||
case "$prev" in ''|*[!0-9]*) prev=0 ;; esac
|
||||
[ "$serial" -gt "$prev" ] || serial=$((prev + 1))
|
||||
{ printf '# FEED %s\n# SERIAL %s\n' "$FEED" "$serial"; cat "$SUMS"; } > "$WORK/bound"
|
||||
mv -f "$WORK/bound" "$SUMS"
|
||||
echo "bound manifest to feed $FEED, serial $serial"
|
||||
}
|
||||
|
||||
# sign_manifest — detached-sign $SUMS into $SIG with RPM_GPG_PRIVATE_KEY. Prints nothing and
|
||||
# returns 1 if no key is available; the caller decides whether that is survivable.
|
||||
sign_manifest() {
|
||||
@@ -119,22 +144,23 @@ if [ "$SEAL" = 1 ]; then
|
||||
echo "$BASE/SHA256SUMS lists no images — refusing to seal it (feed needs a republish)" >&2
|
||||
exit 1
|
||||
fi
|
||||
IMAGES="$(grep -c '' <"$SUMS")"
|
||||
cmp -s "$SUMS" "$FETCHED" \
|
||||
|| echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $IMAGES kept"
|
||||
bind_manifest
|
||||
if ! sign_manifest; then
|
||||
require_signature # non-release: warn and leave the live feed exactly as it was
|
||||
exit 0
|
||||
fi
|
||||
# The signature must cover the bytes a client actually downloads, so a manifest that normalizing
|
||||
# changed gets re-published with it — otherwise the .asc would describe a file the registry does
|
||||
# not have, which is the very failure this is repairing. Manifest first, signature second, and
|
||||
# neither when the stored copy was already clean.
|
||||
if ! cmp -s "$SUMS" "$FETCHED"; then
|
||||
echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $(grep -c '' <"$SUMS") kept"
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
|
||||
fi
|
||||
# The signature must cover the bytes a client actually downloads, and bind_manifest has just
|
||||
# rewritten the header, so the manifest ALWAYS goes back up with its new signature — an .asc
|
||||
# describing a file the registry does not have is the very failure this mode repairs. Manifest
|
||||
# first, signature second: a client caught in the window sees an unsigned feed and refuses.
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
|
||||
echo "sealed $BASE ($(grep -c '' <"$SUMS") image(s))"
|
||||
echo "sealed $BASE ($IMAGES image(s))"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -161,8 +187,11 @@ if [ "$KEEP" -gt 0 ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
IMAGES="$(grep -c '' <"$SUMS")"
|
||||
|
||||
# Sign the finished manifest BEFORE anything is uploaded — a signing failure on a release must
|
||||
# abort while the live feed is still whole, not halfway through being replaced.
|
||||
bind_manifest
|
||||
sign_manifest || require_signature
|
||||
|
||||
# Upload order keeps consumers consistent: image first, then the manifest referencing it, then its
|
||||
@@ -182,4 +211,4 @@ fi
|
||||
for f in "${PRUNE[@]:-}"; do
|
||||
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
|
||||
done
|
||||
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"
|
||||
echo "published $FNAME -> $BASE ($IMAGES image(s) in the feed)"
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
# images they describe, so anything able to replace an image could replace its checksum too. The
|
||||
# public key is baked in below rather than fetched, because a key fetched from the thing you are
|
||||
# authenticating authenticates nothing.
|
||||
# A signature alone still only says "we signed these bytes, once" — not which feed they were
|
||||
# signed for, nor whether they are the current ones. So the manifest also carries a `# FEED` and a
|
||||
# `# SERIAL` header INSIDE the signed bytes, and fetch_manifest refuses one whose FEED is not the
|
||||
# feed it fetched, or whose SERIAL is below the highest this box has accepted. Without both, anyone
|
||||
# who can write the registry WITHOUT the key (a leaked write:package token) copies the canary
|
||||
# manifest + images into the stable path, or puts last month's back, and every box verifies it
|
||||
# happily. Same two rules the Rust updater enforces (crates/pf-update-check/src/manifest.rs).
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
|
||||
@@ -34,6 +41,7 @@ CONF=/etc/punktfunk-sysext.conf
|
||||
EXT_DIR=/var/lib/extensions
|
||||
IMG="$EXT_DIR/punktfunk.raw"
|
||||
SIDECAR="$EXT_DIR/.punktfunk.version"
|
||||
FLOOR_FILE="$EXT_DIR/.punktfunk.serial-floor"
|
||||
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
|
||||
ETC_SRC=/usr/share/punktfunk/etc
|
||||
PF_TMP="$(mktemp -d)"; trap 'rm -rf "$PF_TMP"' EXIT
|
||||
@@ -65,10 +73,33 @@ need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; };
|
||||
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
|
||||
channel() { # shellcheck disable=SC1090
|
||||
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
|
||||
feed_url() {
|
||||
# feed_name -> the feed this box reads: f43, f43-canary, … (Fedora major x channel). The publisher
|
||||
# stamps this same name into the signed manifest, which is what makes the two comparable.
|
||||
feed_name() {
|
||||
local suffix=""
|
||||
[ "$(channel)" = canary ] && suffix="-canary"
|
||||
echo "$REGISTRY/f$(os_version_id)$suffix"
|
||||
echo "f$(os_version_id)$suffix"
|
||||
}
|
||||
feed_url() { echo "$REGISTRY/$(feed_name)"; }
|
||||
|
||||
# The highest manifest serial ever accepted for a feed — the anti-rollback floor, persisted the way
|
||||
# the Rust updater persists its own per-channel `serial_floor` (crates/punktfunk-host/src/update.rs).
|
||||
# Per FEED, never global: serials are publish timestamps, so a canary publish would otherwise raise
|
||||
# the floor above the next stable manifest and lock the stable channel out.
|
||||
serial_floor() {
|
||||
local v
|
||||
v="$(sed -n "s/^$(feed_name) //p" "$FLOOR_FILE" 2>/dev/null | head -n1)"
|
||||
case "$v" in ''|*[!0-9]*) echo 0 ;; *) echo "$v" ;; esac
|
||||
}
|
||||
# Raise it (never lower — the caller compares first). Best-effort and quiet on purpose: `status`
|
||||
# runs unprivileged, and before the extensions dir exists at all, so it must report the feed rather
|
||||
# than die — or nag — over not being able to record it. `2>/dev/null` comes BEFORE the redirect it
|
||||
# is there to silence: redirections are set up left to right, so the other order still prints the
|
||||
# shell's own "No such file or directory" to the terminal.
|
||||
raise_serial_floor() {
|
||||
local f; f="$(feed_name)"
|
||||
{ grep -v "^$f " "$FLOOR_FILE" 2>/dev/null || :; echo "$f $1"; } 2>/dev/null > "$FLOOR_FILE.new" \
|
||||
&& mv -f "$FLOOR_FILE.new" "$FLOOR_FILE" 2>/dev/null || :
|
||||
}
|
||||
|
||||
# verify_manifest SUMS SIG -> 0 iff SIG is a good detached signature over SUMS by FEED_KEY.
|
||||
@@ -91,7 +122,7 @@ verify_manifest() {
|
||||
# Returns non-zero (having said why) rather than exiting, so `status` can report a bad feed
|
||||
# instead of dying on it; install/update turn that into a hard stop.
|
||||
fetch_manifest() {
|
||||
local feed sums sig
|
||||
local feed sums sig want got serial floor
|
||||
feed="$(feed_url)"
|
||||
sums="$PF_TMP/SHA256SUMS"; sig="$PF_TMP/SHA256SUMS.asc"
|
||||
curl -fsSL -o "$sums" "$feed/SHA256SUMS" || { echo "cannot reach the feed $feed" >&2; return 1; }
|
||||
@@ -117,6 +148,31 @@ fetch_manifest() {
|
||||
echo "!! older than the rotation. Do not install; re-download punktfunk-sysext.sh and retry." >&2
|
||||
return 1
|
||||
fi
|
||||
# Signed — by us, at some point, for something. The two facts a signature cannot carry on its own
|
||||
# are stamped inside the document (see the Trust note at the top) and checked here.
|
||||
want="$(feed_name)"
|
||||
got="$(sed -n 's/^# FEED //p' "$sums" | head -n1)"
|
||||
serial="$(sed -n 's/^# SERIAL //p' "$sums" | head -n1)"
|
||||
case "$serial" in ''|*[!0-9]*) serial="" ;; esac
|
||||
if [ -z "$got" ] || [ -z "$serial" ]; then
|
||||
echo "!! the feed $feed is signed but UNBOUND: its manifest carries no '# FEED'/'# SERIAL'" >&2
|
||||
echo "!! header, so the signature says nothing about which feed or which publish it covers." >&2
|
||||
echo "!! (a feed published before binding existed; it is bound on the next publish, or now with" >&2
|
||||
echo "!! TOKEN=… bash packaging/bazzite/publish-sysext-feed.sh --seal $want)" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "!! this manifest was signed for the feed '$got', but it is being served as '$want' —" >&2
|
||||
echo "!! another channel's (or another OS release's) feed is being replayed here. Refusing." >&2
|
||||
return 1
|
||||
fi
|
||||
floor="$(serial_floor)"
|
||||
if [ "$serial" -lt "$floor" ]; then
|
||||
echo "!! manifest serial $serial is older than the last accepted $floor — refusing rollback." >&2
|
||||
echo "!! An old but validly-signed manifest is being replayed at $feed." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$serial" -gt "$floor" ]; then raise_serial_floor "$serial"; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -312,6 +368,21 @@ cmd_update() {
|
||||
post_merge
|
||||
return
|
||||
fi
|
||||
# Even a correctly bound, in-date manifest can offer only OLDER images (a mistaken republish, an
|
||||
# over-eager prune). `latest` reports the newest the feed HAS, not the newest that ever shipped,
|
||||
# so without this the box walks backwards onto a superseded — possibly known-vulnerable —
|
||||
# release, silently. Rolling back stays possible; it just has to be asked for.
|
||||
case "$cur" in
|
||||
[0-9]*)
|
||||
if [ "$ver" != "$cur" ] \
|
||||
&& [ "$(printf '%s\n%s\n' "$ver" "$cur" | sort -V | tail -n1)" = "$cur" ] \
|
||||
&& [ "${PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE:-0}" != 1 ]; then
|
||||
echo "!! the feed's newest image ($ver) is OLDER than the installed $cur — refusing to" >&2
|
||||
echo "!! downgrade. For a deliberate rollback:" >&2
|
||||
echo "!! sudo PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE=1 punktfunk-sysext update" >&2
|
||||
exit 1
|
||||
fi ;;
|
||||
esac
|
||||
echo "updating: ${cur:-<none>} -> $ver"
|
||||
# shellcheck disable=SC2086
|
||||
do_install $l
|
||||
@@ -347,6 +418,8 @@ cmd_remove() {
|
||||
fi
|
||||
fi
|
||||
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
|
||||
# $FLOOR_FILE deliberately survives: it is anti-rollback state, not installation state, and a
|
||||
# remove/re-install cycle is the obvious way to hand a box a replayed manifest it already refused.
|
||||
rm -f "$IMG" "$SIDECAR" "$CONF"
|
||||
systemd-sysext refresh 2>/dev/null || :
|
||||
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
|
||||
|
||||
@@ -194,6 +194,12 @@ One-time setup (mirrors any new unom DMZ service — see the deploy-infra notes)
|
||||
|
||||
1. **Secret** `FLATPAK_GPG_PRIVATE_KEY` on this repo = base64 of the armored private key
|
||||
(`gpg --armor --export-secret-keys <fpr> | base64 -w0`). `DEPLOY_*` + `REGISTRY_TOKEN` already exist.
|
||||
Also **`DEPLOY_KNOWN_HOSTS`** — unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT"
|
||||
"$DEPLOY_HOST"` — which the deploy `ssh`es against with `StrictHostKeyChecking=yes`. Every run
|
||||
starts with an empty `known_hosts`, so `accept-new` made *every* run a first contact, handing the
|
||||
deploy key and the GPG-signed repo to whatever won the race for the address. It gates the step
|
||||
like the others: until it is set the deploy skips with a warning. Same secret and same host as
|
||||
the Nix cache publish (see `packaging/nix/README.md`), so configuring either satisfies both.
|
||||
2. **Edge Caddy** on home-reverse-proxy-1 (`/home/caddy/caddy/Caddyfile`, apply by hand + `./reload.sh`):
|
||||
`flatpak.unom.io { reverse_proxy 192.168.50.50:3230 }`
|
||||
3. **Port allowlist:** add `3230` to `caddy_target_ports` in `unom/infra` (proxmox/unom-1) + terraform apply.
|
||||
|
||||
@@ -68,11 +68,22 @@ elif [ -f packaging/flatpak/cargo-sources.json ] && [ "${FORCE_GEN:-0}" != "1" ]
|
||||
echo "==> reusing existing packaging/flatpak/cargo-sources.json (FORCE_GEN=1 to regenerate)"
|
||||
else
|
||||
echo "==> generating offline cargo-sources.json from Cargo.lock"
|
||||
# PINNED to a commit and checked by SHA-256 — same ref+sum as .gitea/workflows/flatpak.yml, so
|
||||
# the local build path and CI vendor crate sources with the identical script. `master` is a
|
||||
# mutable ref, and this is third-party python that chooses which crate sources the build (a
|
||||
# SIGNED one, in CI) vendors. Bump both together, here and in the workflow:
|
||||
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
|
||||
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
|
||||
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
|
||||
GEN=/tmp/flatpak-cargo-generator.py
|
||||
if [ ! -f "$GEN" ]; then
|
||||
curl -fsSL -o "$GEN" \
|
||||
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
|
||||
fi
|
||||
# Verified on EVERY run, not just after a download: the branch above reuses whatever is already
|
||||
# at that /tmp path — which on a shared box is a file this script did not write.
|
||||
echo "$GEN_SHA $GEN" | sha256sum -c - \
|
||||
|| { echo "error: $GEN does not match the pin for $GEN_REF — rm it and re-run" >&2; exit 1; }
|
||||
# Needs python3 + aiohttp + tomlkit. On a host that lacks them (e.g. the Deck), generate on the
|
||||
# Mac / a dev box instead and rsync the result next to the manifest (reused by the branch above).
|
||||
# Prune the microsoft/windows-rs git crates first (punktfunk-client-windows only) — otherwise
|
||||
|
||||
+24
-3
@@ -466,8 +466,8 @@ server; there is no cache daemon to run.
|
||||
|
||||
**One-time setup — in this order.** The publish step ends by fetching `nix.unom.io` to prove the
|
||||
cache really answers (and answers **404**, not 403, for a path it does not hold), so stand the
|
||||
service up *before* you set the secret that switches publishing on. The secret is the last step for
|
||||
exactly that reason: until it exists the publish no-ops with a warning and `main` stays green,
|
||||
service up *before* you set the secrets that switch publishing on. They are the last steps for
|
||||
exactly that reason: until they exist the publish no-ops with a warning and `main` stays green,
|
||||
the same way flatpak.yml's repo deploy does.
|
||||
|
||||
1. **Ingress — both halves live in `unom/infra`, and they must move together.** Neither the DNS
|
||||
@@ -518,7 +518,28 @@ the same way flatpak.yml's repo deploy does.
|
||||
docker run --rm nixos/nix nix --extra-experimental-features nix-command \
|
||||
key generate-secret --key-name punktfunk-cache-1 # or anywhere with docker
|
||||
```
|
||||
4. Push to `main` touching the flake. The publish step also writes the public key to
|
||||
4. **Deploy host key.** `DEPLOY_KNOWN_HOSTS` is a repo Actions secret holding unom-1's SSH host
|
||||
key — `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — and the publish `ssh`es with
|
||||
`StrictHostKeyChecking=yes` against it. Every run starts with an empty `known_hosts`, so
|
||||
`accept-new` would make *every* run a first contact, handing `DEPLOY_SSH_KEY` and the signed
|
||||
publish to whatever won the race for the address. It gates the step alongside
|
||||
`NIX_CACHE_SIGNING_KEY`/`DEPLOY_HOST`: until it is set the publish skips with a warning.
|
||||
Re-keyscan and re-set it if unom-1's host key ever changes, or the deploy fails closed.
|
||||
|
||||
Key it **port-agnostically**, because `ssh` looks a host key up by the exact string it dialled
|
||||
and `DEPLOY_PORT` is a secret nobody re-reads when re-keying — a plain entry silently fails to
|
||||
match once the port is not 22, and the failure looks like a host-key error rather than a
|
||||
formatting one. One line covers both forms:
|
||||
|
||||
```
|
||||
<DEPLOY_HOST>,[<DEPLOY_HOST>]:* ssh-ed25519 AAAA…
|
||||
```
|
||||
|
||||
Pin **ed25519 only**. Pinning every type `ssh-keyscan` prints lets a host offering just RSA
|
||||
satisfy the check on an RSA line, so the weakest pinned key is the one that decides; one modern
|
||||
key is both stronger and shorter. `DEPLOY_HOST` is unom-1's public IP (Hetzner, since the
|
||||
2026-07-12 cutover off proxmox), so scan that address — not a private one from an SSH config.
|
||||
5. Push to `main` touching the flake. The publish step also writes the public key to
|
||||
`https://nix.unom.io/punktfunk-cache.pub`, so users can always check the docs against the cache.
|
||||
|
||||
`scripts/setup-nix-cache.sh` walks through it interactively, and each stage detects work already
|
||||
|
||||
@@ -30,6 +30,24 @@ fi
|
||||
|
||||
BASE="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}"
|
||||
|
||||
# Download ONE release asset and refuse to unpack it unless its bytes match the pinned SHA-256.
|
||||
# This binary becomes RUSTC_WRAPPER — every compiler invocation in the release builds, including the
|
||||
# jobs that hold the RPM/Flatpak signing keys, runs through it — and a GitHub release asset is
|
||||
# MUTABLE at a fixed URL, so the version in the path vouches for nothing on its own.
|
||||
# Usage: fetch <arch-triple> <sha256>; leaves the verified tarball in $TARBALL.
|
||||
fetch() {
|
||||
TARBALL="$(mktemp)"
|
||||
curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-$1.tar.gz" -o "$TARBALL"
|
||||
# macOS has shasum but no sha256sum; the Linux images have both.
|
||||
got="$(sha256sum "$TARBALL" 2>/dev/null || shasum -a 256 "$TARBALL")"
|
||||
got="${got%% *}"
|
||||
if [ "$got" != "$2" ]; then
|
||||
echo "sccache $1 sha256 mismatch: got $got, pinned $2" >&2
|
||||
echo "(bumping SCCACHE_VERSION? update the pins below it from the release's .sha256 files)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
# The macOS runner is a LaunchAgent in the user's Aqua session, not root — install into the
|
||||
@@ -38,12 +56,15 @@ case "$(uname -s)" in
|
||||
DEST="$HOME/.local/bin"
|
||||
mkdir -p "$DEST"
|
||||
case "$(uname -m)" in
|
||||
arm64|aarch64) ARCH=aarch64-apple-darwin ;;
|
||||
*) ARCH=x86_64-apple-darwin ;;
|
||||
arm64|aarch64) ARCH=aarch64-apple-darwin
|
||||
SHA=5aba39252e2efa26bd76144f87ac59787d60fe567ab785e27e2a8c8190892eac ;;
|
||||
*) ARCH=x86_64-apple-darwin
|
||||
SHA=6d4a77802ec83607478df7b6338be28171e65e58a38a49497ebec1fbb300fce4 ;;
|
||||
esac
|
||||
fetch "$ARCH" "$SHA"
|
||||
# bsdtar globs by default and does not accept --wildcards.
|
||||
curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-${ARCH}.tar.gz" \
|
||||
| tar -xz --strip-components=1 -C "$DEST" '*/sccache'
|
||||
tar -xz --strip-components=1 -C "$DEST" -f "$TARBALL" '*/sccache'
|
||||
rm -f "$TARBALL"
|
||||
chmod 0755 "$DEST/sccache"
|
||||
PATH="$DEST:$PATH"
|
||||
export PATH
|
||||
@@ -56,8 +77,10 @@ case "$(uname -s)" in
|
||||
# dance is needed. The musl build is static — one binary serves the Ubuntu, Fedora and Arch
|
||||
# images alike.
|
||||
DEST=/usr/local/bin
|
||||
curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C "$DEST" '*/sccache'
|
||||
fetch x86_64-unknown-linux-musl \
|
||||
1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
|
||||
tar -xz --wildcards --strip-components=1 -C "$DEST" -f "$TARBALL" '*/sccache'
|
||||
rm -f "$TARBALL"
|
||||
chmod 0755 "$DEST/sccache"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
"pairing_approve_known_note": "Dieses Gerät war schon gekoppelt — sein bisheriger Zugriff ist vorausgefüllt.",
|
||||
"pairing_approve_guest": "Als Gast freigeben",
|
||||
"pairing_approve_guest_hint": "Nur Controller, läuft nach 4 Stunden ab.",
|
||||
"pairing_password_help": "Ein gekoppeltes Gerät kann diesen Rechner steuern, deshalb wird das Passwort erneut gebraucht — eine Browser-Sitzung allein kann nichts koppeln.",
|
||||
"access_level_label": "Zugriffsstufe",
|
||||
"access_level_full": "Vollzugriff",
|
||||
"access_level_controller": "Nur Controller",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user