Compare commits
17 Commits
75627c8afe
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ed54f22997 | |||
| 031ee86ed5 | |||
| 7591425f6f | |||
| d1d2ca293d | |||
| 705a8fa94e | |||
| 4ba63b7da6 | |||
| bee1f0416d | |||
| 54d9246ca7 | |||
| 91bb955d0c | |||
| 36259b264f | |||
| 6f903f79bc | |||
| 3532e35b75 | |||
| 6b846913f5 | |||
| 26c6c939a2 | |||
| b6e6f2bff5 | |||
| e3034958ee | |||
| 8672026e97 |
+5
-2
@@ -5,8 +5,11 @@
|
||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||
#
|
||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||
# `unmaintained` warnings (audiopus_sys / paste / rustls-pemfile). Those are left visible on purpose
|
||||
# so we keep getting the maintenance signal — they do not fail CI.
|
||||
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||
# their latest published version with no successor, so there's nothing to bump — left visible on
|
||||
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
|
||||
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
|
||||
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
|
||||
@@ -32,6 +32,25 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
|
||||
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
|
||||
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
|
||||
# CMake must be on PATH; install it self-healing on a fresh runner.
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PunktfunkCore.xcframework
|
||||
run: bash scripts/build-xcframework.sh
|
||||
|
||||
@@ -71,6 +90,22 @@ jobs:
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
|
||||
@@ -118,6 +118,23 @@ jobs:
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
|
||||
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export
|
||||
# .def with llvm-dlltool (no GPU/SDK at build time).
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN gpl-shared
|
||||
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
|
||||
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
|
||||
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
|
||||
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
|
||||
name: windows-host
|
||||
|
||||
@@ -80,7 +81,7 @@ jobs:
|
||||
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
|
||||
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# FFMPEG_DIR: the same BtbN gpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
|
||||
# by scripts/ci/setup-windows-runner.ps1). The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Contributing to punktfunk
|
||||
|
||||
Thanks for your interest in contributing!
|
||||
|
||||
## Licensing of contributions (inbound = outbound)
|
||||
|
||||
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
|
||||
|
||||
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
|
||||
> Apache-2.0**, without any additional terms or conditions.
|
||||
|
||||
By opening a pull request you agree to license your contribution under these terms. This is the
|
||||
standard Rust-ecosystem "inbound = outbound" model; it keeps the project's licensing unambiguous
|
||||
(including the Apache-2.0 §5 contributor patent grant) and any future relicensing clean. You retain
|
||||
the copyright to your contributions.
|
||||
|
||||
### Do not paste copyleft (or otherwise incompatibly-licensed) code
|
||||
|
||||
The single thing that could poison the permissive license is **copied source from a copyleft
|
||||
project**. Several adjacent projects (Sunshine, Apollo, Moonlight) are GPL-3.0. You may study them
|
||||
and reimplement a *technique*, protocol, or wire format — those are not copyrightable — but **never
|
||||
paste their code**, and do not translate a GPL implementation line-by-line. When a comment credits
|
||||
prior art, make clear it is an independent reimplementation, not a copy. The same applies to any
|
||||
third party's code under a license incompatible with MIT/Apache.
|
||||
|
||||
If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0 / BSD / ISC / Zlib /
|
||||
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
|
||||
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
|
||||
|
||||
## Before you push
|
||||
|
||||
```sh
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
|
||||
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
|
||||
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants.
|
||||
Generated
+89
-293
@@ -137,18 +137,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
@@ -161,13 +152,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.13.11"
|
||||
version = "0.13.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
|
||||
checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-util",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
@@ -358,23 +349,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"either",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
@@ -476,9 +462,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
@@ -520,9 +506,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.29.3"
|
||||
version = "0.29.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d"
|
||||
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"heck",
|
||||
@@ -539,9 +525,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
version = "1.2.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -906,9 +892,6 @@ name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
@@ -1127,9 +1110,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1142,12 +1125,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1376,15 +1353,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1595,9 +1570,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1623,22 +1598,13 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1647,7 +1613,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1858,12 +1824,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2014,9 +1974,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.100"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -2035,7 +1995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2046,12 +2006,6 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libadwaita"
|
||||
version = "0.9.1"
|
||||
@@ -2167,13 +2121,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2201,9 +2155,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "mdns-sd"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e"
|
||||
checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"flume",
|
||||
@@ -2216,15 +2170,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.10"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -2716,16 +2670,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -2765,7 +2709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2779,7 +2723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2799,7 +2743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2819,7 +2763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2849,7 +2793,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2885,7 +2829,6 @@ dependencies = [
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rusty_enet",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2914,7 +2857,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -2943,9 +2886,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
@@ -2963,9 +2906,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastbloom",
|
||||
@@ -3000,9 +2943,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -3156,9 +3099,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -3179,9 +3122,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reis"
|
||||
@@ -3309,9 +3252,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3335,15 +3278,6 @@ dependencies = [
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
@@ -3740,19 +3674,19 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket-pktinfo"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f"
|
||||
checksum = "3e8e43b4bdce7cff8a4d3f8025ee38fce5ca138fab868ebbf9529c81328fbf9d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"socket2",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3828,9 +3762,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3880,7 +3814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -3937,12 +3871,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
version = "0.3.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
@@ -3952,15 +3885,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
version = "0.2.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
@@ -4259,12 +4192,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -4372,9 +4299,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.2"
|
||||
version = "1.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
@@ -4445,27 +4372,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.123"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -4476,9 +4394,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.123"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -4486,9 +4404,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.123"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -4499,47 +4417,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.123"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.15"
|
||||
@@ -4567,9 +4451,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.12"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wayland-backend",
|
||||
@@ -4635,9 +4519,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -5195,100 +5079,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
@@ -5419,18 +5215,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.50"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.50"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5460,9 +5256,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.1"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -155,4 +155,31 @@ tools/ latency-probe · loss-harness (measurement)
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
Licensed under either of
|
||||
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||
<https://www.apache.org/licenses/LICENSE-2.0>)
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <https://opensource.org/licenses/MIT>)
|
||||
|
||||
at your option — `SPDX-License-Identifier: MIT OR Apache-2.0`.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
||||
the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any
|
||||
additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Third-party components
|
||||
|
||||
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
|
||||
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
|
||||
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
|
||||
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
|
||||
notice ship in the installed `licenses/` folder).
|
||||
|
||||
### Trademarks
|
||||
|
||||
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
|
||||
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
|
||||
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
|
||||
here only to describe interoperability.
|
||||
|
||||
+16154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
THIRD-PARTY SOFTWARE NOTICES
|
||||
============================================================================
|
||||
|
||||
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
|
||||
The binaries it ships statically/dynamically link the third-party Rust crates below.
|
||||
Each is distributed under its own permissive license; full texts follow.
|
||||
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
|
||||
|
||||
Overview:
|
||||
{{#each overview}}
|
||||
{{name}} ({{id}}): {{count}} crate(s)
|
||||
{{/each}}
|
||||
|
||||
{{#each licenses}}
|
||||
----------------------------------------------------------------------------
|
||||
{{name}} ({{id}})
|
||||
Used by:
|
||||
{{#each used_by}} - {{crate.name}} {{crate.version}}{{#if crate.repository}} ({{crate.repository}}){{/if}}
|
||||
{{/each}}
|
||||
----------------------------------------------------------------------------
|
||||
{{text}}
|
||||
|
||||
{{/each}}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# cargo-about config — full-fidelity third-party license harvest for CI.
|
||||
#
|
||||
# cargo install cargo-about
|
||||
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
|
||||
#
|
||||
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
|
||||
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
|
||||
# dependency silently entering the linked set. All entries
|
||||
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
|
||||
#
|
||||
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
|
||||
# which is what produced the committed baseline when cargo-about is unavailable offline.
|
||||
|
||||
accepted = [
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"0BSD",
|
||||
"BSL-1.0",
|
||||
"Unicode-3.0",
|
||||
"Unicode-DFS-2016",
|
||||
"CDLA-Permissive-2.0",
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"WTFPL",
|
||||
"OpenSSL",
|
||||
]
|
||||
|
||||
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
|
||||
# (its generated header is not a derivative work), so it is excluded from the notices rather than
|
||||
# accepted as a linked license.
|
||||
ignore-build-dependencies = true
|
||||
ignore-dev-dependencies = true
|
||||
|
||||
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also
|
||||
# UEFI-target-gated out of every shipped build.)
|
||||
[r-efi.clarify]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[ring.clarify]
|
||||
license = "MIT AND ISC AND OpenSSL"
|
||||
|
||||
[aws-lc-sys.clarify]
|
||||
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,10 +74,31 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.kit.security.obtainIdentity
|
||||
import io.unom.punktfunk.models.HostStatus
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
|
||||
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than
|
||||
* timing the client out first. Mirrors the Linux client's 185 s.
|
||||
*/
|
||||
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
|
||||
|
||||
/**
|
||||
* A no-PIN "request access" connect in flight — the host being requested (drives the cancelable
|
||||
* "Waiting for approval…" dialog) and a per-attempt flag the Cancel button trips. The connect is a
|
||||
* blocking call with no abort, so Cancel returns the UI immediately and a late result checks
|
||||
* [cancelled] and tears the (possibly just-approved) session down silently rather than navigating.
|
||||
*/
|
||||
private class RequestAccessState(val target: PendingTrust) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
@@ -128,8 +149,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
.onSuccess { identity = it }
|
||||
.onFailure { status = "Identity unavailable: ${it.message} — re-pair may be required" }
|
||||
}
|
||||
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing).
|
||||
// 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 no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
|
||||
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
|
||||
// A saved host whose label is being edited (the Rename dialog).
|
||||
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
|
||||
|
||||
@@ -163,7 +187,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels,
|
||||
hdrEnabled, settings.audioChannels, CONNECT_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
connecting = false
|
||||
@@ -182,10 +206,66 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs PIN pairing before connecting. Trust state is
|
||||
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
|
||||
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
|
||||
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
|
||||
// reconnect), so on success we record the host as PAIRED — the operator's approval IS the pairing.
|
||||
// The connect can't be aborted, so Cancel returns the UI immediately and a late result is torn
|
||||
// down silently via the per-attempt flag (mirrors the Linux client's request-access flow).
|
||||
fun requestAccess(target: PendingTrust) {
|
||||
val id = identity
|
||||
if (id == null) {
|
||||
status = "Identity not ready yet — try again in a moment"
|
||||
return
|
||||
}
|
||||
val req = RequestAccessState(target)
|
||||
awaiting = req
|
||||
connecting = true
|
||||
status = null
|
||||
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
|
||||
scope.launch {
|
||||
val hdrEnabled = displaySupportsHdr(context)
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
|
||||
// we wait); a manually-typed host has none, so trust-on-first-use.
|
||||
val pinHex = target.advertisedFp ?: ""
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
target.host, target.port, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||
// don't touch UI a fresh action may now own.
|
||||
if (req.cancelled.get()) {
|
||||
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
|
||||
return@launch
|
||||
}
|
||||
awaiting = null
|
||||
connecting = false
|
||||
if (handle != 0L) {
|
||||
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
|
||||
// future connects are silent (exactly like after a PIN ceremony).
|
||||
val fp = NativeBridge.nativeHostFingerprint(handle)
|
||||
if (fp.isNotEmpty()) {
|
||||
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
onConnected(handle)
|
||||
} else {
|
||||
status = "Request timed out — approve this device in the host's console, then retry."
|
||||
discovery.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide pinned-reconnect vs fp-changed vs TOFU vs pairing before connecting. Trust state is
|
||||
// keyed by address:port, so a discovered and a manually-typed connection to the same host share
|
||||
// one record. Trust-on-first-use is permitted ONLY when the host advertised pair=optional; a
|
||||
// pair=required host, or a manual/unknown-policy host, must pair by PIN.
|
||||
// pair=required host, or a manual/unknown-policy host, must pair — either by no-PIN request
|
||||
// access (approve in the console) or by the SPAKE2 PIN ceremony.
|
||||
fun connect(
|
||||
targetHost: String,
|
||||
targetPort: Int,
|
||||
@@ -208,9 +288,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||
dh?.pairingRequired == false -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
||||
// pair=required, or a manual/unknown-policy host → PIN pairing is mandatory.
|
||||
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
|
||||
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
|
||||
else -> pendingTrust =
|
||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.PAIR)
|
||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +552,33 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
// A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request
|
||||
// access" is the no-PIN path — connect and wait for the operator to click Approve in the
|
||||
// host's console; "Use a PIN…" switches to the SPAKE2 ceremony.
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog(
|
||||
onDismissRequest = { pendingTrust = null },
|
||||
title = { Text("Pairing required") },
|
||||
text = {
|
||||
Column {
|
||||
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
|
||||
Text(
|
||||
"Request access and approve this device in the host's console (or web " +
|
||||
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
|
||||
Text("Use a PIN…")
|
||||
}
|
||||
TextButton({ pendingTrust = null }) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> {
|
||||
var pin by remember(pt) { mutableStateOf("") }
|
||||
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
|
||||
@@ -537,6 +645,44 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// The no-PIN "request access" wait: the connect is parked on the host until the operator
|
||||
// approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a
|
||||
// late approval is torn down silently (see requestAccess) and resumes discovery.
|
||||
awaiting?.let { req ->
|
||||
fun cancel() {
|
||||
req.cancelled.set(true)
|
||||
awaiting = null
|
||||
connecting = false
|
||||
discovery.start() // the request may still be pending on the host; keep scanning
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { cancel() },
|
||||
title = { Text("Waiting for approval") },
|
||||
text = {
|
||||
val deviceName = Build.MODEL ?: "this device"
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Approve this device on ${req.target.name}.")
|
||||
}
|
||||
Text(
|
||||
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { cancel() }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
|
||||
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
|
||||
renameTarget?.let { kh ->
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
|
||||
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
|
||||
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
|
||||
*/
|
||||
@Composable
|
||||
fun LicensesScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val notices = remember {
|
||||
runCatching {
|
||||
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
|
||||
}.getOrDefault("Third-party notices unavailable.")
|
||||
}
|
||||
val version = remember {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
|
||||
if (version != null) {
|
||||
Text(
|
||||
"punktfunk $version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
|
||||
"components below, each under its own license.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
notices,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -44,6 +45,7 @@ import androidx.core.content.ContextCompat
|
||||
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
val context = LocalContext.current
|
||||
var showLicenses by remember { mutableStateOf(false) }
|
||||
fun update(next: Settings) {
|
||||
s = next
|
||||
onChange(next)
|
||||
@@ -56,6 +58,11 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> update(s.copy(micEnabled = granted)) }
|
||||
|
||||
if (showLicenses) {
|
||||
LicensesScreen(onBack = { showLicenses = false })
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -143,6 +150,14 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
|
||||
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsGroup("About") {
|
||||
ClickableRow(
|
||||
title = "Open-source licenses",
|
||||
subtitle = "Third-party notices and credits",
|
||||
onClick = { showLicenses = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +202,24 @@ private fun ToggleRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** A title + subtitle on the left; the whole row is clickable (opens a sub-screen). */
|
||||
@Composable
|
||||
private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
||||
@@ -14,8 +14,10 @@ enum class Tab(val label: String, val icon: ImageVector) {
|
||||
/**
|
||||
* A trust decision awaiting the user before a connect proceeds. [name] is the label to save the
|
||||
* host under. Trust-on-first-use ([Kind.TRUST_NEW]) is only ever offered when the host ADVERTISED
|
||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host goes straight to PIN
|
||||
* pairing ([Kind.PAIR]), and a changed fingerprint forces re-pairing — never a silent re-trust.
|
||||
* pair=optional; a pair=required host or a manually-typed/unknown-policy host is offered the
|
||||
* two ways in ([Kind.REQUEST_ACCESS]): a no-PIN "request access" connect the operator approves in
|
||||
* the host's console, or the SPAKE2 PIN ceremony ([Kind.PAIR]). A changed fingerprint forces
|
||||
* re-pairing by PIN ([Kind.FP_CHANGED]) — never a silent re-trust.
|
||||
*/
|
||||
data class PendingTrust(
|
||||
val host: String,
|
||||
@@ -24,7 +26,7 @@ data class PendingTrust(
|
||||
val advertisedFp: String?,
|
||||
val kind: Kind,
|
||||
) {
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR }
|
||||
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
|
||||
}
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
|
||||
@@ -29,8 +29,10 @@ object NativeBridge {
|
||||
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
|
||||
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
|
||||
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
|
||||
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0`
|
||||
* on failure. Pair with exactly one [nativeClose].
|
||||
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). [timeoutMs] is the handshake budget — the
|
||||
* normal path passes a short value, the no-PIN "request access" path a long one (≥ the host's
|
||||
* approval-park window) so a slow operator approval lands on this same parked connection. Returns
|
||||
* an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
|
||||
*/
|
||||
external fun nativeConnect(
|
||||
host: String,
|
||||
@@ -46,6 +48,7 @@ object NativeBridge {
|
||||
gamepadPref: Int,
|
||||
hdrEnabled: Boolean,
|
||||
audioChannels: Int,
|
||||
timeoutMs: Int,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
|
||||
@@ -140,13 +140,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels): Long`. `certPem`/`keyPem` empty =
|
||||
/// anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, timeoutMs): Long`. `certPem`/`keyPem`
|
||||
/// empty = anonymous, else presented as the persistent identity. `pinHex` empty = TOFU (read
|
||||
/// `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). `bitrateKbps`
|
||||
/// 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` wire bytes
|
||||
/// (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8; normalized,
|
||||
/// anything else → stereo) — the host clamps it and the resolved count drives playback.
|
||||
/// Returns an opaque handle, or 0 on failure (logged).
|
||||
/// anything else → stereo) — the host clamps it and the resolved count drives playback. `timeoutMs`
|
||||
/// is the handshake budget: the normal path passes a short value, the no-PIN "request access" path a
|
||||
/// long one (≥ the host's approval-park window) so a slow operator approval lands on this same parked
|
||||
/// connection rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||
@@ -165,6 +167,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
gamepad_pref: jint,
|
||||
hdr_enabled: jboolean,
|
||||
audio_channels: jint,
|
||||
timeout_ms: jint,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -224,7 +227,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
None, // launch: default app
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
Duration::from_secs(10),
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
Duration::from_millis(timeout_ms.max(0) as u64),
|
||||
) {
|
||||
Ok(client) => {
|
||||
let handle = SessionHandle {
|
||||
|
||||
@@ -16,6 +16,15 @@ let package = Package(
|
||||
.target(
|
||||
name: "PunktfunkKit",
|
||||
dependencies: ["PunktfunkCore"],
|
||||
// OSS attribution shown by the app's Acknowledgements screen. Bundled here (not in the
|
||||
// app target) so it rides along via Bundle.module in both `swift build` and the Xcode
|
||||
// app, which links the PunktfunkKit product. Refresh with
|
||||
// scripts/gen-third-party-notices.sh (it copies the generated file into Resources/).
|
||||
resources: [
|
||||
.copy("Resources/THIRD-PARTY-NOTICES.txt"),
|
||||
.copy("Resources/LICENSE-MIT.txt"),
|
||||
.copy("Resources/LICENSE-APACHE.txt"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
.linkedFramework("Security"),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||
struct AcknowledgementsView: View {
|
||||
private var version: String? {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.title2).bold()
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Licenses.thirdPartyNotices)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
.frame(maxWidth: 900, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
#if os(tvOS)
|
||||
.padding(40)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("Acknowledgements")
|
||||
}
|
||||
}
|
||||
|
||||
/// `textSelection(.enabled)` is unavailable on tvOS, so apply it only where it exists.
|
||||
private struct SelectableText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
content.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
|
||||
// their own files.
|
||||
//
|
||||
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing
|
||||
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
|
||||
// pinned, reconnects are silent and a changed host identity refuses to connect.
|
||||
// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||
// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional),
|
||||
// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing —
|
||||
// delegated approval ("Request Access": a plain identified connect the host parks until the operator
|
||||
// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed
|
||||
// host identity refuses to connect.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -31,6 +33,12 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@State private var showAddHost = false
|
||||
@State private var pairingTarget: StoredHost?
|
||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||
/// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
|
||||
@State private var approvalChoice: ApprovalRequest?
|
||||
/// A delegated-approval connect is in flight (host parks it until the operator approves):
|
||||
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||||
@State private var awaitingApproval: ApprovalRequest?
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@State private var libraryTarget: StoredHost?
|
||||
#if !os(macOS)
|
||||
@@ -55,10 +63,27 @@ struct ContentView: View {
|
||||
autoConnectIfAsked()
|
||||
}
|
||||
.onChange(of: model.phase) { _, phase in
|
||||
// A session actually started — remember it on the card ("Connected … ago"
|
||||
// plus the accent ring on the most recent host).
|
||||
if case .streaming = phase, let host = model.activeHost {
|
||||
switch phase {
|
||||
case .streaming:
|
||||
// A session actually started — remember it on the card ("Connected … ago"
|
||||
// plus the accent ring on the most recent host).
|
||||
guard let host = model.activeHost else { break }
|
||||
store.markConnected(host.id)
|
||||
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||
// host's observed fingerprint and remember it as paired — future connects are then
|
||||
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||
if awaitingApproval?.host.id == host.id {
|
||||
if let fp = model.connection?.hostFingerprint {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
awaitingApproval = nil
|
||||
}
|
||||
case .idle:
|
||||
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||
// wait prompt (SessionModel surfaces any error via `errorMessage`).
|
||||
if awaitingApproval != nil { awaitingApproval = nil }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||||
@@ -90,6 +115,47 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
|
||||
// alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
|
||||
// delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on
|
||||
// presentation is deferred a tick so this dialog is fully dismissed first.
|
||||
.confirmationDialog(
|
||||
"Pairing required",
|
||||
isPresented: Binding(
|
||||
get: { approvalChoice != nil },
|
||||
set: { if !$0 { approvalChoice = nil } }),
|
||||
titleVisibility: .visible,
|
||||
presenting: approvalChoice
|
||||
) { req in
|
||||
Button("Request Access") {
|
||||
DispatchQueue.main.async { requestAccess(req) }
|
||||
}
|
||||
Button("Pair with PIN…") {
|
||||
DispatchQueue.main.async { pairingTarget = req.host }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { req in
|
||||
Text("\(req.host.displayName) requires pairing. Request access and approve this "
|
||||
+ "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
|
||||
+ "pair with the 4-digit PIN it can display.")
|
||||
}
|
||||
// The delegated-approval wait: the host holds the connection open until the operator
|
||||
// approves it. Cancel returns the UI at once; the in-flight connect is left to time out
|
||||
// and its late result is discarded by SessionModel's connect guard (disconnect resets the
|
||||
// phase/host it checks).
|
||||
.alert(
|
||||
"Waiting for approval",
|
||||
isPresented: Binding(
|
||||
get: { awaitingApproval != nil },
|
||||
set: { if !$0 { awaitingApproval = nil } }),
|
||||
presenting: awaitingApproval
|
||||
) { _ in
|
||||
Button("Cancel", role: .cancel) { model.disconnect() }
|
||||
} message: { req in
|
||||
Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
|
||||
+ "console (port 3000 → Pairing). This device connects automatically once you "
|
||||
+ "approve it — no need to reconnect.")
|
||||
}
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
@@ -230,19 +296,32 @@ struct ContentView: View {
|
||||
// A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
|
||||
// the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
|
||||
// know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
|
||||
// an unpinned host with no matching `pair=optional` advert routes to PIN pairing instead
|
||||
// of silently entering the trust prompt (rules 3b + 4). A pinned host ignores all of this.
|
||||
// an unpinned host with no matching `pair=optional` advert routes to the approval choice
|
||||
// (request access / pair with PIN) instead of silently entering the trust prompt (rules
|
||||
// 3b + 4). A pinned host ignores all of this.
|
||||
if host.pinnedSHA256 == nil {
|
||||
let tofuOK = allowTofu ?? discovery.hosts.contains {
|
||||
host.matches($0) && $0.allowsTofu
|
||||
}
|
||||
if !tofuOK {
|
||||
pairingTarget = host
|
||||
// pair=required / unknown policy / manual entry (rule 3b): never a silent
|
||||
// connect — offer no-PIN delegated approval or the PIN ceremony.
|
||||
approvalChoice = ApprovalRequest(
|
||||
host: host, advertisedFingerprint: advertisedFingerprint(for: host))
|
||||
return
|
||||
}
|
||||
}
|
||||
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
||||
// controller): the host's virtual pad backend is fixed per session.
|
||||
startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
|
||||
}
|
||||
|
||||
/// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
|
||||
/// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the
|
||||
/// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
|
||||
/// delegated-approval connect (host parks it until the operator approves).
|
||||
private func startSession(
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false
|
||||
) {
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
@@ -255,7 +334,22 @@ struct ContentView: View {
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
launchID: launchID,
|
||||
allowTofu: host.pinnedSHA256 == nil)
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
}
|
||||
|
||||
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||||
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||||
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||
/// as paired (see the `.streaming` branch of `onChange`).
|
||||
private func requestAccess(_ req: ApprovalRequest) {
|
||||
guard !model.isBusy else { return }
|
||||
awaitingApproval = req
|
||||
// Pin the advertised certificate for a discovered host (impostor defence during the long
|
||||
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
var host = req.host
|
||||
host.pinnedSHA256 = req.advertisedFingerprint
|
||||
startSession(host, allowTofu: false, requestAccess: true)
|
||||
}
|
||||
|
||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||
@@ -268,8 +362,9 @@ struct ContentView: View {
|
||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||
/// persists), then connect or pair per the host's advertised policy. The host is the policy
|
||||
/// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
|
||||
/// a `pair=required` host, or one with no/unknown `pair` field, goes straight to the PIN
|
||||
/// pairing ceremony (rule 3b). (A pinned discovered host connects silently inside `connect`.)
|
||||
/// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice
|
||||
/// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently
|
||||
/// inside `connect`.)
|
||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||
@@ -277,7 +372,9 @@ struct ContentView: View {
|
||||
if d.allowsTofu {
|
||||
connect(host, allowTofu: true)
|
||||
} else {
|
||||
pairingTarget = host
|
||||
// pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN.
|
||||
approvalChoice = ApprovalRequest(
|
||||
host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +388,30 @@ struct ContentView: View {
|
||||
connect(pinned)
|
||||
}
|
||||
|
||||
/// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see
|
||||
/// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently
|
||||
/// advertising or advertised no/invalid `fp`.
|
||||
private func advertisedFingerprint(for host: StoredHost) -> Data? {
|
||||
pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex)
|
||||
}
|
||||
|
||||
/// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect
|
||||
/// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls
|
||||
/// back to trust-on-first-use rather than failing the connect closed.
|
||||
private func pinFingerprint(_ hex: String?) -> Data? {
|
||||
guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil }
|
||||
return data
|
||||
}
|
||||
|
||||
/// How the host lists this device in its approval prompt (matches PairSheet's client name).
|
||||
private var localDeviceName: String {
|
||||
#if os(macOS)
|
||||
Host.current().localizedName ?? "Mac"
|
||||
#else
|
||||
UIDevice.current.name
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - First-run + dev hooks
|
||||
|
||||
/// First run on iOS: default the stream mode to this device's native screen so the
|
||||
@@ -378,3 +499,31 @@ private struct FullscreenController: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
|
||||
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
|
||||
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
|
||||
private struct ApprovalRequest {
|
||||
let host: StoredHost
|
||||
let advertisedFingerprint: Data?
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
/// Parse an even-length hex string into bytes; nil on any non-hex character or odd length.
|
||||
/// Used to turn an mDNS-advertised cert fingerprint into a connect pin.
|
||||
init?(hexString: String) {
|
||||
let chars = Array(hexString)
|
||||
guard chars.count.isMultiple(of: 2) else { return nil }
|
||||
var bytes = [UInt8]()
|
||||
bytes.reserveCapacity(chars.count / 2)
|
||||
var i = 0
|
||||
while i < chars.count {
|
||||
guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else {
|
||||
return nil
|
||||
}
|
||||
bytes.append(UInt8(hi << 4 | lo))
|
||||
i += 2
|
||||
}
|
||||
self = Data(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,13 @@ final class SessionModel: ObservableObject {
|
||||
/// field — TOFU is forbidden (rule 3b): the connect refuses rather than offering trust, and
|
||||
/// the user is routed to PIN pairing by the caller. (A pinned host connects regardless: its
|
||||
/// stored fingerprint is the trust decision.)
|
||||
///
|
||||
/// `requestAccess` is the no-PIN delegated-approval path: open an identified connect the host
|
||||
/// PARKS until the operator clicks Approve in its console, then admits the SAME connection (no
|
||||
/// reconnect). The handshake budget is widened to exceed the host's park window, and a
|
||||
/// successful connect streams directly (the approval IS the trust decision) — the caller pins
|
||||
/// the observed fingerprint as paired. `host.pinnedSHA256`, when set, pins the advertised cert
|
||||
/// for the wait; nil = trust-on-first-use.
|
||||
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
|
||||
compositor: PunktfunkConnection.Compositor = .auto,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
@@ -103,7 +110,8 @@ final class SessionModel: ObservableObject {
|
||||
hdrEnabled: Bool = true,
|
||||
launchID: String? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false) {
|
||||
autoTrust: Bool = false,
|
||||
requestAccess: Bool = false) {
|
||||
guard phase == .idle else { return }
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
@@ -138,7 +146,11 @@ final class SessionModel: ObservableObject {
|
||||
width: width, height: height, refreshHz: hz,
|
||||
pinSHA256: pin, identity: identity, compositor: compositor,
|
||||
gamepad: gamepad, bitrateKbps: bitrateKbps, videoCaps: videoCaps,
|
||||
audioChannels: audioChannels, launchID: launchID) }
|
||||
audioChannels: audioChannels, launchID: launchID,
|
||||
// Delegated approval: the host holds this connect open until the operator approves
|
||||
// it (~180 s) — outwait that window so a slow approval still lands here. Normal
|
||||
// connects keep the snappy default.
|
||||
timeoutMs: requestAccess ? 185_000 : 10_000) }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
// The user may have abandoned this attempt (window closed, another host
|
||||
@@ -152,7 +164,9 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
switch result {
|
||||
case .success(let conn):
|
||||
if pin != nil || autoTrust {
|
||||
if pin != nil || autoTrust || requestAccess {
|
||||
// requestAccess: the operator approved this device on the host, so the
|
||||
// session is trusted — stream directly (the caller pins it as paired).
|
||||
self.connection = conn
|
||||
self.startStatsTimer()
|
||||
self.beginStreaming()
|
||||
@@ -174,16 +188,25 @@ final class SessionModel: ObservableObject {
|
||||
case .failure:
|
||||
self.phase = .idle
|
||||
self.activeHost = nil
|
||||
self.errorMessage = pin != nil
|
||||
? "Could not connect to \(host.displayName) — host unreachable, "
|
||||
+ "not running, its identity no longer matches the pinned "
|
||||
+ "fingerprint, or it requires pairing and no longer "
|
||||
+ "recognizes this Mac (right-click the host card to pair "
|
||||
+ "again)."
|
||||
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
||||
+ "running on \(host.address):\(host.port)? If it requires "
|
||||
+ "pairing, right-click the host card and pair with its PIN "
|
||||
+ "first."
|
||||
if requestAccess {
|
||||
// The delegated-approval connect ended without being admitted: the
|
||||
// operator didn't approve it before the host's park window elapsed (or
|
||||
// the host was unreachable).
|
||||
self.errorMessage = "\(host.displayName) didn't let this device in. "
|
||||
+ "Approve it in the host's web console (port 3000 → Pairing), then "
|
||||
+ "request access again — the request expires after a few minutes."
|
||||
} else {
|
||||
self.errorMessage = pin != nil
|
||||
? "Could not connect to \(host.displayName) — host unreachable, "
|
||||
+ "not running, its identity no longer matches the pinned "
|
||||
+ "fingerprint, or it requires pairing and no longer "
|
||||
+ "recognizes this Mac (right-click the host card to pair "
|
||||
+ "again)."
|
||||
: "Could not connect to \(host.displayName) — is punktfunk-host "
|
||||
+ "running on \(host.address):\(host.port)? If it requires "
|
||||
+ "pairing, right-click the host card and pair with its PIN "
|
||||
+ "first."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ struct SettingsView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||
|
||||
AcknowledgementsView()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 480, height: 460)
|
||||
}
|
||||
@@ -115,6 +118,9 @@ struct SettingsView: View {
|
||||
statisticsSection
|
||||
experimentalSection
|
||||
controllersSection
|
||||
Section {
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
@@ -217,6 +223,8 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Open-source license / attribution text bundled with PunktfunkKit (see `Resources/`).
|
||||
///
|
||||
/// Exposed from the kit so the app shell can show an Acknowledgements screen. The text files are
|
||||
/// bundled as SwiftPM resources and read via `Bundle.module`, which works both for `swift build`
|
||||
/// and for the Xcode app (it links the PunktfunkKit product, so the resource bundle rides along).
|
||||
public enum Licenses {
|
||||
private static func resource(_ name: String) -> String {
|
||||
guard let url = Bundle.module.url(forResource: name, withExtension: "txt"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
else { return "" }
|
||||
return text
|
||||
}
|
||||
|
||||
/// punktfunk's own license — MIT OR Apache-2.0, at your option.
|
||||
public static var appLicense: String {
|
||||
let mit = resource("LICENSE-MIT")
|
||||
let apache = resource("LICENSE-APACHE")
|
||||
if mit.isEmpty && apache.isEmpty {
|
||||
return "punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
||||
}
|
||||
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
||||
+ "================================ MIT ================================\n\n"
|
||||
+ mit
|
||||
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
||||
+ apache
|
||||
}
|
||||
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// `scripts/gen-third-party-notices.sh`).
|
||||
public static var thirdPartyNotices: String {
|
||||
let text = resource("THIRD-PARTY-NOTICES")
|
||||
return text.isEmpty ? "Third-party notices unavailable." : text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 unom
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
+157
-6
@@ -295,19 +295,21 @@ fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||||
tofu_dialog(app, req);
|
||||
} else {
|
||||
// Rule 3b: pair=required or unknown policy — PIN pairing is mandatory.
|
||||
pin_dialog(app, req);
|
||||
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||||
// (request access → approve in the console) or the PIN ceremony.
|
||||
approval_dialog(app, req);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Manual entry (no advertised fingerprint). A known address connects silently
|
||||
// on its stored pin (rule 1); an unknown one must pair — never silent TOFU.
|
||||
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||||
// the console) or use a PIN; never silent TOFU.
|
||||
match known
|
||||
.find_by_addr(&req.addr, req.port)
|
||||
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex))
|
||||
{
|
||||
Some(pin) => start_session(app, req, Some(pin)),
|
||||
None => pin_dialog(app, req), // rule 3b
|
||||
None => approval_dialog(app, req), // rule 3b
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,6 +420,83 @@ fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||||
fn approval_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pairing Required"),
|
||||
Some(&format!(
|
||||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pin", "Use a PIN instead…"),
|
||||
("request", "Request Access"),
|
||||
]);
|
||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("request"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = app.window.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"request" => request_access(app.clone(), req.clone()),
|
||||
"pin" => pin_dialog(app.clone(), req.clone()),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||||
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||
let cancel = Rc::new(std::cell::Cell::new(false));
|
||||
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waiting for Approval"),
|
||||
Some(&format!(
|
||||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||
connects automatically once you approve it.",
|
||||
glib::host_name(),
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let app = app.clone();
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||||
// down silently by the event loop (see StartOpts::cancel).
|
||||
cancel.set(true);
|
||||
app.busy.set(false);
|
||||
app.toast("Cancelled — the request may still be pending on the host.");
|
||||
});
|
||||
}
|
||||
waiting.present(Some(&app.window));
|
||||
|
||||
start_session_with(
|
||||
app,
|
||||
req,
|
||||
pin,
|
||||
StartOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||
// approval still lands on this connection rather than timing the client out first.
|
||||
connect_timeout: std::time::Duration::from_secs(185),
|
||||
persist_paired: true,
|
||||
waiting: Some(waiting),
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||||
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||||
@@ -556,7 +635,42 @@ fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
||||
mode
|
||||
}
|
||||
|
||||
/// Tunables for a session start that differ between the normal connect and the "request access"
|
||||
/// (delegated-approval) flow. `Default` is the normal connect.
|
||||
struct StartOpts {
|
||||
/// Handshake budget. The request-access flow uses a long one because the host PARKS the
|
||||
/// connection until the operator clicks Approve (see the host's `PENDING_APPROVAL_WAIT`).
|
||||
connect_timeout: std::time::Duration,
|
||||
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||
persist_paired: bool,
|
||||
/// A "waiting for approval" dialog to dismiss on the first session event (request-access only).
|
||||
waiting: Option<adw::AlertDialog>,
|
||||
/// Set by the waiting dialog's Cancel button. `NativeClient::connect` is a blocking call with
|
||||
/// no abort, so Cancel returns the UI immediately (clears busy, closes the dialog) and leaves
|
||||
/// the in-flight connect to time out; when it finally resolves, the event loop sees this flag
|
||||
/// and tears down silently (drops the connector → closes the connection) without touching the
|
||||
/// UI a new session may already own.
|
||||
cancel: Option<Rc<std::cell::Cell<bool>>>,
|
||||
}
|
||||
|
||||
impl Default for StartOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: std::time::Duration::from_secs(15),
|
||||
persist_paired: false,
|
||||
waiting: None,
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
start_session_with(app, req, pin, StartOpts::default());
|
||||
}
|
||||
|
||||
fn start_session_with(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>, opts: StartOpts) {
|
||||
if app.busy.replace(true) {
|
||||
return;
|
||||
}
|
||||
@@ -577,10 +691,14 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
audio_channels: s.audio_channels,
|
||||
pin,
|
||||
identity: app.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
};
|
||||
let inhibit = s.inhibit_shortcuts;
|
||||
drop(s);
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let mut waiting = opts.waiting;
|
||||
let cancel = opts.cancel;
|
||||
|
||||
let mut handle = crate::session::start(params);
|
||||
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||
@@ -588,14 +706,41 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
let mut frames = Some(frames);
|
||||
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||||
while let Ok(event) = handle.events.recv().await {
|
||||
// A cancelled request-access connect resolved late: tear down silently. Don't touch
|
||||
// app.busy — Cancel already cleared it, and a fresh session may now own it.
|
||||
if cancel.as_ref().is_some_and(|c| c.get()) {
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
mode,
|
||||
fingerprint,
|
||||
} => {
|
||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||
if tofu {
|
||||
// Dismiss the "waiting for approval" dialog (request-access flow), if any.
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
if persist_paired {
|
||||
// Request-access: the operator approved this device, so record the host as
|
||||
// a trusted PAIRED host (pinning the fingerprint we observed) — future
|
||||
// connects are then silent (rule 1), exactly like after a PIN ceremony.
|
||||
let fp_hex = crate::trust::hex(&fingerprint);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex,
|
||||
paired: true,
|
||||
});
|
||||
let _ = known.save();
|
||||
app.toast("Approved — connecting…");
|
||||
} else if tofu {
|
||||
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||
let fp_hex = crate::trust::hex(&fingerprint);
|
||||
let mut known = KnownHosts::load();
|
||||
known.upsert(KnownHost {
|
||||
@@ -644,6 +789,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => {
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
tracing::warn!(%msg, trust_rejected, "connect failed");
|
||||
app.busy.set(false);
|
||||
// A pinned connect rejected on trust grounds means the host's cert no
|
||||
@@ -658,6 +806,9 @@ fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||
break;
|
||||
}
|
||||
SessionEvent::Ended(err) => {
|
||||
if let Some(w) = waiting.take() {
|
||||
w.close();
|
||||
}
|
||||
app.gamepad.detach();
|
||||
app.nav.pop_to_tag("hosts");
|
||||
if let Some(e) = err {
|
||||
|
||||
@@ -27,6 +27,11 @@ pub struct SessionParams {
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
pub pin: Option<[u8; 32]>,
|
||||
pub identity: (String, String),
|
||||
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
@@ -139,7 +144,7 @@ fn pump(
|
||||
None, // launch: the Linux client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
Duration::from_secs(15),
|
||||
params.connect_timeout,
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
|
||||
@@ -19,6 +19,49 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
|
||||
"================================ MIT ================================\n\n",
|
||||
include_str!("../../../LICENSE-MIT"),
|
||||
"\n\n=============================== Apache-2.0 ===============================\n\n",
|
||||
include_str!("../../../LICENSE-APACHE"),
|
||||
);
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// scripts/gen-third-party-notices.sh; shown as a Legal section in the About dialog).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
/// Show the About dialog (app license + the third-party-software Legal section).
|
||||
fn show_about(parent: &impl IsA<gtk::Widget>) {
|
||||
let about = adw::AboutDialog::builder()
|
||||
.application_name("punktfunk")
|
||||
.developer_name("unom")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.website("https://git.unom.io/unom/punktfunk")
|
||||
.license_type(gtk::License::Custom)
|
||||
.license(APP_LICENSE)
|
||||
.build();
|
||||
// The native (FFmpeg/GTK/PipeWire/SDL3) components are dynamically linked under their own
|
||||
// (LGPL/Zlib/MIT) licenses; the Rust crate notices are the substantive attribution set.
|
||||
about.add_legal_section(
|
||||
"Third-party software (Rust crates)",
|
||||
None,
|
||||
gtk::License::Custom,
|
||||
Some(THIRD_PARTY_NOTICES),
|
||||
);
|
||||
about.add_legal_section(
|
||||
"Third-party software (system libraries)",
|
||||
None,
|
||||
gtk::License::Custom,
|
||||
Some(
|
||||
"This application dynamically links system libraries under their own licenses, \
|
||||
including FFmpeg (LGPL v2.1+), GTK 4 and libadwaita (LGPL v2.1+), PipeWire (MIT), \
|
||||
and SDL 3 (Zlib). Their full license texts are available from each project.",
|
||||
),
|
||||
);
|
||||
about.present(Some(parent));
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
@@ -156,9 +199,23 @@ pub fn show(
|
||||
.build();
|
||||
audio.add(&mic_row);
|
||||
|
||||
let about = adw::PreferencesGroup::builder().title("About").build();
|
||||
let licenses_row = adw::ActionRow::builder()
|
||||
.title("Third-party licenses")
|
||||
.subtitle("Open-source software used by punktfunk")
|
||||
.activatable(true)
|
||||
.build();
|
||||
licenses_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
{
|
||||
let about_parent: gtk::Widget = parent.clone().upcast();
|
||||
licenses_row.connect_activated(move |_| show_about(&about_parent));
|
||||
}
|
||||
about.add(&licenses_row);
|
||||
|
||||
page.add(&stream);
|
||||
page.add(&input);
|
||||
page.add(&audio);
|
||||
page.add(&about);
|
||||
|
||||
// Seed from the current settings.
|
||||
{
|
||||
|
||||
@@ -76,11 +76,29 @@ foreach ($f in $required) {
|
||||
Copy-Item $src (Join-Path $layout $f) -Force
|
||||
}
|
||||
|
||||
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct)
|
||||
# FFmpeg runtime DLLs (the exe link-imports the decode set; copy them all — small and correct).
|
||||
# These are unmodified BtbN *lgpl-shared* builds, linked dynamically (replaceable DLLs) — FFmpeg is
|
||||
# used under the LGPL v2.1+; the license text + notice ship in licenses\ below.
|
||||
$ff = Get-ChildItem -Path $FfmpegBin -Filter *.dll -ErrorAction SilentlyContinue
|
||||
if (-not $ff) { throw "no FFmpeg DLLs in $FfmpegBin" }
|
||||
$ff | ForEach-Object { Copy-Item $_.FullName (Join-Path $layout $_.Name) -Force }
|
||||
|
||||
# license/attribution payload (MSIX has no installer EULA page, so ship them as files): FFmpeg's LGPL
|
||||
# notice + license text, the project's own MIT/Apache texts, and the generated third-party notices.
|
||||
$licDir = Join-Path $layout 'licenses'
|
||||
New-Item -ItemType Directory -Force -Path $licDir | Out-Null
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
|
||||
Copy-Item (Join-Path $repoRoot 'packaging\windows\licenses\FFmpeg-LGPL-NOTICE.txt') $licDir -Force -ErrorAction SilentlyContinue
|
||||
foreach ($n in @('THIRD-PARTY-NOTICES.txt', 'LICENSE-MIT', 'LICENSE-APACHE')) {
|
||||
$p = Join-Path $repoRoot $n
|
||||
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||
}
|
||||
$ffRoot = Split-Path $FfmpegBin -Parent
|
||||
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
|
||||
$p = Join-Path $ffRoot $lic
|
||||
if (Test-Path $p) { Copy-Item $p $licDir -Force }
|
||||
}
|
||||
|
||||
# tile/store assets
|
||||
Copy-Item (Join-Path $assets '*') (Join-Path $layout 'Assets') -Force
|
||||
|
||||
|
||||
+301
-15
@@ -20,7 +20,9 @@ use crate::video::{DecodedFrame, DecoderPref};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use windows_reactor::*;
|
||||
|
||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
@@ -43,12 +45,27 @@ const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
|
||||
/// capture; the resolved count drives the decoder + WASAPI render layout.
|
||||
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
include_str!("../../../LICENSE-MIT"),
|
||||
"\n\n================================ Apache-2.0 ================================\n\n",
|
||||
include_str!("../../../LICENSE-APACHE"),
|
||||
);
|
||||
/// Third-party software notices for the linked Rust crates (generated by
|
||||
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
|
||||
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Screen {
|
||||
Hosts,
|
||||
Connecting,
|
||||
/// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
|
||||
/// until the operator approves this device in its console. Cancelable.
|
||||
RequestAccess,
|
||||
Stream,
|
||||
Settings,
|
||||
/// Open-source / third-party license notices (reached from Settings).
|
||||
Licenses,
|
||||
Pair,
|
||||
}
|
||||
|
||||
@@ -132,6 +149,11 @@ struct Shared {
|
||||
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
|
||||
/// by the stream page's HUD poll thread to drive the overlay.
|
||||
stats: Mutex<Stats>,
|
||||
/// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per
|
||||
/// request: the waiting screen's Cancel button reads it back from here and sets it, and that
|
||||
/// request's event loop (which captured the same `Arc` at spawn) then tears down silently when
|
||||
/// the parked connect finally resolves. `None` outside a request-access flow.
|
||||
cancel: Mutex<Option<Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
pub struct AppCtx {
|
||||
@@ -376,8 +398,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into()
|
||||
}
|
||||
// request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline
|
||||
// is sound — it only wires a Cancel button to the shared cancel flag + navigation.
|
||||
Screen::RequestAccess => request_access_page(ctx, &set_screen),
|
||||
// settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
|
||||
Screen::Settings => settings_page(ctx, &set_screen),
|
||||
// licenses_page is a static text screen (no hooks), so inline is sound.
|
||||
Screen::Licenses => licenses_page(&set_screen),
|
||||
Screen::Pair => component(pair_page, svc),
|
||||
Screen::Stream => component(stream_page, StreamProps { svc, stats }),
|
||||
}
|
||||
@@ -569,12 +596,61 @@ fn initiate(
|
||||
}
|
||||
}
|
||||
|
||||
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
|
||||
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
|
||||
/// plain "Connecting" screen.
|
||||
struct ConnectOpts {
|
||||
/// Handshake budget. Request-access uses a long one because the host PARKS the connection
|
||||
/// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`).
|
||||
connect_timeout: Duration,
|
||||
/// Persist the host as *paired* on a successful connect. Set for request-access, where the
|
||||
/// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
|
||||
/// persists the host *unpaired* (pinned, but not PIN/approval-verified).
|
||||
persist_paired: bool,
|
||||
/// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access).
|
||||
awaiting_approval: bool,
|
||||
/// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no
|
||||
/// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time
|
||||
/// out; this request's event loop then sees the flag and tears down silently (drops the
|
||||
/// connector → closes the connection) without touching a screen a new session may already own.
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Default for ConnectOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(15),
|
||||
persist_paired: false,
|
||||
awaiting_approval: false,
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: &Target,
|
||||
pin: Option<[u8; 32]>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
connect_with(
|
||||
ctx,
|
||||
target,
|
||||
pin,
|
||||
set_screen,
|
||||
set_status,
|
||||
ConnectOpts::default(),
|
||||
);
|
||||
}
|
||||
|
||||
fn connect_with(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: &Target,
|
||||
pin: Option<[u8; 32]>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
opts: ConnectOpts,
|
||||
) {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
let mode = if s.width != 0 && s.refresh_hz != 0 {
|
||||
@@ -607,29 +683,54 @@ fn connect(
|
||||
decoder: DecoderPref::from_name(&s.decoder),
|
||||
pin,
|
||||
identity: ctx.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
});
|
||||
set_status.call(String::new());
|
||||
set_screen.call(Screen::Connecting);
|
||||
set_screen.call(if opts.awaiting_approval {
|
||||
Screen::RequestAccess
|
||||
} else {
|
||||
Screen::Connecting
|
||||
});
|
||||
|
||||
let tofu = pin.is_none();
|
||||
let persist_paired = opts.persist_paired;
|
||||
let cancel = opts.cancel;
|
||||
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
|
||||
let (ss, st) = (set_screen.clone(), set_status.clone());
|
||||
let target = target.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
match handle.events.recv_blocking() {
|
||||
Ok(SessionEvent::Connected {
|
||||
let event = match handle.events.recv_blocking() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
};
|
||||
// A cancelled request-access connect that resolved late (the host approved or the park
|
||||
// timed out after the user walked away): tear down silently. Cancel already returned the
|
||||
// UI to the host list; dropping `event` (and with it any connector) closes the connection
|
||||
// without popping a stream or a stray error over the screen a new session may own.
|
||||
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
SessionEvent::Connected {
|
||||
connector,
|
||||
fingerprint,
|
||||
..
|
||||
}) => {
|
||||
if tofu {
|
||||
} => {
|
||||
if persist_paired || tofu {
|
||||
// Request-access: the operator approved this device, so record the host as a
|
||||
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
|
||||
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: false,
|
||||
paired: persist_paired,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
@@ -638,10 +739,10 @@ fn connect(
|
||||
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
Ok(SessionEvent::Failed {
|
||||
SessionEvent::Failed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
} => {
|
||||
st.call(msg);
|
||||
gamepad.detach();
|
||||
if trust_rejected {
|
||||
@@ -653,22 +754,100 @@ fn connect(
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(SessionEvent::Ended(err)) => {
|
||||
SessionEvent::Ended(err) => {
|
||||
st.call(err.unwrap_or_else(|| "Session ended".into()));
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
Ok(SessionEvent::Stats(s)) => *shared.stats.lock().unwrap() = s,
|
||||
Err(_) => {
|
||||
gamepad.detach();
|
||||
ss.call(Screen::Hosts);
|
||||
break;
|
||||
}
|
||||
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||||
/// operator approves this device in its console (or web UI), showing a cancelable "waiting"
|
||||
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
|
||||
/// saved as paired, so later connects are silent.
|
||||
fn request_access(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: &Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||||
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||||
let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
// A fresh cancel flag per request, installed where the waiting screen's Cancel button can read
|
||||
// it back; this request's event loop captures the same `Arc` (via ConnectOpts) below.
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
*ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
|
||||
connect_with(
|
||||
ctx,
|
||||
target,
|
||||
pin,
|
||||
set_screen,
|
||||
set_status,
|
||||
ConnectOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||||
// approval still lands on this connection rather than timing the client out first.
|
||||
connect_timeout: Duration::from_secs(185),
|
||||
persist_paired: true,
|
||||
awaiting_approval: true,
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while
|
||||
/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and
|
||||
/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the
|
||||
/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks.
|
||||
fn request_access_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let target_name = ctx.shared.target.lock().unwrap().name.clone();
|
||||
let headline = if target_name.is_empty() {
|
||||
"Waiting for approval\u{2026}".to_string()
|
||||
} else {
|
||||
format!("Waiting for {target_name} to approve\u{2026}")
|
||||
};
|
||||
let cancel_btn = {
|
||||
let (ctx, ss) = (ctx.clone(), set_screen.clone());
|
||||
button("Cancel")
|
||||
.icon(SymbolGlyph::Cancel)
|
||||
.on_click(move || {
|
||||
// Return the UI immediately; the parked connect is blocking with no abort, so trip
|
||||
// the flag this request's event loop captured — it then tears down silently when
|
||||
// the connect finally resolves (see ConnectOpts::cancel).
|
||||
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
|
||||
c.store(true, Ordering::SeqCst);
|
||||
}
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
};
|
||||
vstack((
|
||||
ProgressRing::indeterminate()
|
||||
.width(48.0)
|
||||
.height(48.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
text_block(headline)
|
||||
.font_size(18.0)
|
||||
.semibold()
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
text_block(
|
||||
"Approve this device in the host's console or web UI \u{2014} it connects automatically \
|
||||
once you approve it. No PIN needed.",
|
||||
)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center),
|
||||
cancel_btn,
|
||||
))
|
||||
.spacing(16.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
let ctx = &props.ctx;
|
||||
let set_screen = &props.set_screen;
|
||||
@@ -728,6 +907,20 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
.icon(SymbolGlyph::Cancel)
|
||||
.on_click(move || ss.call(Screen::Hosts))
|
||||
};
|
||||
// The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that
|
||||
// the host parks until the operator approves this device in its console (delegated approval).
|
||||
let request_btn = {
|
||||
let (ctx2, ss, st, target2) = (
|
||||
ctx.clone(),
|
||||
set_screen.clone(),
|
||||
set_status.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
button("Request access without a PIN")
|
||||
.icon(SymbolGlyph::Send)
|
||||
.on_click(move || request_access(&ctx2, &target2, &ss, &st))
|
||||
.horizontal_alignment(HorizontalAlignment::Stretch)
|
||||
};
|
||||
|
||||
let content = card(vstack((
|
||||
grid((
|
||||
@@ -760,6 +953,13 @@ fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
.font_size(28.0)
|
||||
.on_changed(move |s| set_code.call(s)),
|
||||
hstack((pair_btn, cancel_btn)).spacing(8.0),
|
||||
text_block(
|
||||
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
|
||||
(its console or web UI) \u{2014} no PIN needed.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
request_btn,
|
||||
))
|
||||
.spacing(16.0))
|
||||
.max_width(480.0)
|
||||
@@ -967,6 +1167,21 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
||||
.spacing(10.0),
|
||||
);
|
||||
|
||||
let licenses_button = {
|
||||
let ss = set_screen.clone();
|
||||
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
|
||||
};
|
||||
let about_card = card(
|
||||
vstack((
|
||||
text_block("About").font_size(15.0).semibold(),
|
||||
text_block("punktfunk is licensed under MIT OR Apache-2.0.")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
licenses_button,
|
||||
))
|
||||
.spacing(10.0),
|
||||
);
|
||||
|
||||
page(vec![
|
||||
header.into(),
|
||||
section("DISPLAY"),
|
||||
@@ -975,6 +1190,77 @@ fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Eleme
|
||||
video_card.into(),
|
||||
section("AUDIO"),
|
||||
audio_card.into(),
|
||||
section("ABOUT"),
|
||||
about_card.into(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Static screen: the app's own license + the third-party software notices (reached from Settings).
|
||||
fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
|
||||
let header = grid((
|
||||
text_block("Third-party licenses")
|
||||
.font_size(30.0)
|
||||
.bold()
|
||||
.grid_column(0)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
button("Back")
|
||||
.accent()
|
||||
.icon(SymbolGlyph::Back)
|
||||
.on_click({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Settings)
|
||||
})
|
||||
.grid_column(1)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
))
|
||||
.columns([GridLength::Star(1.0), GridLength::Auto])
|
||||
.margin(edges(0.0, 0.0, 0.0, 6.0));
|
||||
|
||||
let app_card = card(
|
||||
vstack((
|
||||
text_block("punktfunk").font_size(15.0).semibold(),
|
||||
text_block("Licensed under MIT OR Apache-2.0, at your option.")
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
text_block(APP_LICENSE)
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(8.0),
|
||||
);
|
||||
|
||||
let natives_card = card(
|
||||
vstack((
|
||||
text_block("Bundled components").font_size(15.0).semibold(),
|
||||
text_block(
|
||||
"FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
|
||||
license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
|
||||
Windows App SDK (Microsoft) are also linked.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(8.0),
|
||||
);
|
||||
|
||||
let notices_card = card(
|
||||
vstack((
|
||||
text_block("Rust crates").font_size(15.0).semibold(),
|
||||
text_block(THIRD_PARTY_NOTICES)
|
||||
.font_size(11.0)
|
||||
.foreground(ThemeRef::SecondaryText),
|
||||
))
|
||||
.spacing(8.0),
|
||||
);
|
||||
|
||||
page(vec![
|
||||
header.into(),
|
||||
section("PUNKTFUNK"),
|
||||
app_card.into(),
|
||||
section("BUNDLED"),
|
||||
natives_card.into(),
|
||||
section("OPEN SOURCE"),
|
||||
notices_card.into(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -177,11 +177,16 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
compositor: CompositorPref::Auto,
|
||||
gamepad: GamepadPref::Auto,
|
||||
bitrate_kbps,
|
||||
// Headless CLI path (test/scripting) — stereo baseline; the GUI sources this from settings.
|
||||
audio_channels: 2,
|
||||
mic_enabled: flag("--mic"),
|
||||
hdr_enabled: !flag("--no-hdr"),
|
||||
decoder,
|
||||
pin,
|
||||
identity,
|
||||
// Headless CLI uses the normal (short) handshake budget; the long request-access wait is a
|
||||
// GUI-only flow.
|
||||
connect_timeout: Duration::from_secs(15),
|
||||
});
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
|
||||
@@ -34,6 +34,11 @@ pub struct SessionParams {
|
||||
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||
pub pin: Option<[u8; 32]>,
|
||||
pub identity: (String, String),
|
||||
/// How long to wait for the handshake. The normal path uses a short budget; the
|
||||
/// "request access" (delegated-approval) path uses a long one, because the host PARKS the
|
||||
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
@@ -164,7 +169,7 @@ fn pump(
|
||||
None, // launch: the Windows client has no library picker yet
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
Duration::from_secs(15),
|
||||
params.connect_timeout,
|
||||
) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
|
||||
@@ -35,9 +35,11 @@ base64 = "0.22"
|
||||
ureq = "2"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
|
||||
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
|
||||
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
|
||||
axum-server = "0.8"
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2"
|
||||
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
||||
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
||||
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
||||
@@ -217,6 +219,7 @@ bytemuck = { version = "1.19", features = ["derive"] }
|
||||
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`.
|
||||
nvenc = ["dep:nvidia-video-codec-sdk"]
|
||||
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
|
||||
# `FFMPEG_DIR` (BtbN gpl-shared, includes `*_amf`/`*_qsv`) at build time and bundles the FFmpeg
|
||||
# DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
||||
# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used,
|
||||
# so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the
|
||||
# FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
|
||||
amf-qsv = ["dep:ffmpeg-next"]
|
||||
|
||||
@@ -188,7 +188,8 @@ pub(crate) unsafe fn make_device(
|
||||
let device = device.context("null D3D11 device")?;
|
||||
let context = context.context("null D3D11 context")?;
|
||||
|
||||
// Apollo-style GPU scheduling hardening (Sunshine display_base.cpp:599-709). Our capture+encode
|
||||
// GPU scheduling hardening — the same approach Sunshine/Apollo use, reimplemented here via the
|
||||
// documented D3DKMT/DXGI APIs (no GPL source copied). Our capture+encode
|
||||
// shares the GPU with the streamed game; when the game saturates the GPU our process is starved of
|
||||
// GPU time slices, so NVENC sits near-idle yet `lock_bitstream` waits ~20 ms for our context to be
|
||||
// scheduled — capping the stream (~47 fps measured at 5K@240) and stuttering. Per-frame copy/convert
|
||||
@@ -197,7 +198,7 @@ pub(crate) unsafe fn make_device(
|
||||
// GPU thread priority and a 1-frame latency cap.
|
||||
elevate_process_gpu_priority();
|
||||
if let Ok(dxgi_dev) = device.cast::<IDXGIDevice>() {
|
||||
// Apollo's absolute max GPU thread priority (0x4000001E); fall back to relative +7.
|
||||
// The absolute max GPU thread priority (0x4000001E; the same value Sunshine/Apollo use); fall back to relative +7.
|
||||
if dxgi_dev.SetGPUThreadPriority(0x4000_001E).is_err()
|
||||
&& dxgi_dev.SetGPUThreadPriority(7).is_err()
|
||||
{
|
||||
@@ -291,7 +292,8 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||
Some(f(process, prio))
|
||||
}
|
||||
|
||||
/// Apollo-style GPU scheduling-priority hardening (Sunshine `display_base.cpp:599-709`). On a
|
||||
/// GPU scheduling-priority hardening — the same approach as Sunshine/Apollo, independently
|
||||
/// implemented via the documented D3DKMT APIs (no GPL source copied). On a
|
||||
/// GPU-saturated game our capture+encode process is starved of GPU time slices — NVENC sits ~idle but
|
||||
/// `lock_bitstream` waits ~20 ms for our context to be scheduled. Elevating the PROCESS GPU scheduling
|
||||
/// priority class (the strong cross-process lever — far more effective than `SetGPUThreadPriority`
|
||||
@@ -532,7 +534,9 @@ const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
||||
|
||||
/// Replacement for `win32u.dll!NtGdiDdDDIGetCachedHybridQueryValue`: always report
|
||||
/// `D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED` (3). We fully replace the function (never call the
|
||||
/// original), so no trampoline is needed. (Ported verbatim from Apollo's MinHook hook.)
|
||||
/// original), so no trampoline is needed. (Independent reimplementation of the same technique Apollo
|
||||
/// uses: Apollo installs its hook via the MinHook library; this is an original inline byte-patch and
|
||||
/// copies no Apollo/GPL source.)
|
||||
unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
||||
HYBRID_HOOK_HITS.fetch_add(1, Ordering::Relaxed);
|
||||
if gpu_preference.is_null() {
|
||||
@@ -542,7 +546,8 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
||||
0 // STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// Apollo's win32u GPU-preference hook, ported. On a HYBRID-GPU box DXGI resolves a GPU preference
|
||||
/// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the
|
||||
/// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference
|
||||
/// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render
|
||||
/// GPU — which constantly invalidates Desktop Duplication (DXGI_ERROR_ACCESS_LOST 0x887A0026, the
|
||||
/// freeze/churn observed on the RTX 4090 + AMD iGPU box; `SET_RENDER_ADAPTER` is ignored there). Faking
|
||||
@@ -555,7 +560,7 @@ pub(crate) fn install_gpu_pref_hook() {
|
||||
// SAFETY: this one-time hook install only touches a region it has just validated.
|
||||
// `LoadLibraryA("win32u.dll")` + `GetProcAddress("NtGdiDdDDIGetCachedHybridQueryValue")` yield the
|
||||
// live base of the real exported function, so `target` is a valid executable code pointer to at
|
||||
// least the 12 bytes the patch overwrites (an x64 prologue, per Apollo's verified hook). The two
|
||||
// least the 12 bytes the patch overwrites (an x64 prologue). The two
|
||||
// `ptr::copy_nonoverlapping`s each move exactly 12 bytes between the 12-byte stack arrays
|
||||
// (`patch`/`readback`) and `target`, which `VirtualProtect(target, 12, PAGE_EXECUTE_READWRITE, …)`
|
||||
// has just made writable (and is restored to `old` after) — source and dest never overlap (stack
|
||||
|
||||
@@ -230,6 +230,14 @@ pub fn open_video(
|
||||
chroma: ChromaFormat,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
validate_dimensions(codec, width, height)?;
|
||||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||||
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
|
||||
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
|
||||
// regardless of which backend opens (security-review 2026-06-28 S5).
|
||||
if fps == 0 || fps > 1000 {
|
||||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||||
}
|
||||
// 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
||||
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
||||
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
||||
|
||||
@@ -166,7 +166,7 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
/// validated hardware to build + verify the 4:4:4 surface/profile path against. Returning `false`
|
||||
/// keeps the negotiation honest: a VAAPI host resolves every session to 4:2:0 before the Welcome, so
|
||||
/// the client never builds a 4:4:4 decoder it would only get 4:2:0 frames for. (Follow-up: implement
|
||||
/// + validate on an Intel Arc / RDNA4-class box that advertises a HEVC 4:4:4 encode entrypoint.)
|
||||
/// and validate on an Intel Arc / RDNA4-class box that advertises a HEVC 4:4:4 encode entrypoint.)
|
||||
pub fn probe_can_encode_444(_codec: Codec) -> bool {
|
||||
tracing::info!("VAAPI HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
|
||||
false
|
||||
|
||||
@@ -58,8 +58,8 @@ pub struct NvencD3d11Encoder {
|
||||
/// Encoded bit depth (8 or 10). 10 → HEVC Main10 (NVENC upconverts the 8-bit ARGB input).
|
||||
bit_depth: u8,
|
||||
/// Full-chroma 4:4:4 (HEVC Range Extensions, `chroma_format_idc = 3`) requested for this session.
|
||||
/// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally — the `FREXT` profile
|
||||
/// + `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
|
||||
/// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally; the `FREXT` profile
|
||||
/// and `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
|
||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
|
||||
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
|
||||
chroma_444: bool,
|
||||
|
||||
@@ -56,6 +56,9 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
.spawn(move || {
|
||||
// GCM scheme detected from the first authenticating packet; reused thereafter.
|
||||
let mut detected: Option<Scheme> = None;
|
||||
// Consecutive control-decrypt failures for this peer — throttles the warn log so a
|
||||
// junk-packet flood can't spam unbounded lines (security-review 2026-06-28 #10).
|
||||
let mut decrypt_fails: u64 = 0;
|
||||
// Decoded keyboard/mouse is forwarded to a dedicated host-lifetime injector thread —
|
||||
// NEVER injected inline, so a slow Wayland/libei/SendInput call can't head-block ENet
|
||||
// keepalive/retransmit servicing on this thread. The injector owns non-Send compositor
|
||||
@@ -77,6 +80,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
Event::Disconnect { .. } => {
|
||||
tracing::info!("control: client disconnected");
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
peer = None;
|
||||
// Unplug the session's virtual pads.
|
||||
pads = GamepadManager::new();
|
||||
@@ -89,6 +93,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
channel_id,
|
||||
packet.data(),
|
||||
&mut detected,
|
||||
&mut decrypt_fails,
|
||||
&inj_tx,
|
||||
&mut pads,
|
||||
);
|
||||
@@ -163,6 +168,7 @@ fn on_receive(
|
||||
_channel_id: u8,
|
||||
d: &[u8],
|
||||
detected: &mut Option<Scheme>,
|
||||
decrypt_fails: &mut u64,
|
||||
inj_tx: &Sender<InputEvent>,
|
||||
pads: &mut GamepadManager,
|
||||
) {
|
||||
@@ -180,10 +186,20 @@ fn on_receive(
|
||||
tracing::info!(?scheme, "control: GCM scheme locked in");
|
||||
}
|
||||
*detected = Some(scheme);
|
||||
*decrypt_fails = 0;
|
||||
pt
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(len = d.len(), "control: GCM decrypt failed");
|
||||
// Throttle: a junk-packet flood must not spam one warn line per packet. Log the first
|
||||
// failure, then only at exponentially-spaced counts (1, 2, 4, 8, …).
|
||||
*decrypt_fails += 1;
|
||||
if decrypt_fails.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
len = d.len(),
|
||||
fails = *decrypt_fails,
|
||||
"control: GCM decrypt failed"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,6 +90,11 @@ pub struct LaunchSession {
|
||||
pub fps: u32,
|
||||
/// `/launch?appid=N` — selects the app-catalog entry (session recipe).
|
||||
pub appid: u32,
|
||||
/// Source IP of the paired HTTPS client that issued `/launch`. The unauthenticated RTSP/UDP
|
||||
/// media plane binds to this so only the launching peer can start/own the stream — an
|
||||
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
|
||||
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
|
||||
pub peer_ip: Option<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
/// Shared control-plane state used as the axum app state.
|
||||
@@ -262,9 +267,10 @@ pub(crate) fn config_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Create `dir` (and parents) owner-private — **0700** on Unix (so the host's secrets aren't readable
|
||||
/// by other local users via a traversable config path). Best-effort on Windows: the dir inherits the
|
||||
/// (Users-readable) `%ProgramData%` ACL, so secret *files* are individually locked down by
|
||||
/// [`write_secret_file`]. Tightens an already-existing dir too.
|
||||
/// by other local users via a traversable config path). On Windows, applies a restrictive DACL
|
||||
/// ([`restrict_dir_to_system_admins`]) so a local unprivileged user can't pre-create / plant files in
|
||||
/// the config tree (the default `%ProgramData%` ACL grants Users *create*; security-review
|
||||
/// 2026-06-28 #3/#11). Tightens (and re-owns) an already-existing dir too.
|
||||
pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -281,7 +287,60 @@ pub(crate) fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::create_dir_all(dir)
|
||||
let r = std::fs::create_dir_all(dir);
|
||||
#[cfg(windows)]
|
||||
restrict_dir_to_system_admins(dir);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort Windows DACL lockdown of the config *directory* (the companion to
|
||||
/// [`restrict_to_system_admins`] for files). The default `%ProgramData%` ACL lets `BUILTIN\Users`
|
||||
/// create subfolders/files (and become `CREATOR OWNER`), so a non-admin could pre-create the
|
||||
/// `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
|
||||
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
|
||||
#[cfg(windows)]
|
||||
fn restrict_dir_to_system_admins(dir: &std::path::Path) {
|
||||
let icacls = std::env::var("SystemRoot")
|
||||
.map(|r| format!("{r}\\System32\\icacls.exe"))
|
||||
.unwrap_or_else(|_| "icacls".to_string());
|
||||
// Reset ownership of the directory object to Administrators first, so a dir a non-admin may have
|
||||
// pre-created can't keep OWNER control (an owner can always rewrite the DACL). No `/T` — re-owning
|
||||
// the dir itself is what defeats the pre-creation; recursing a large captures tree each call is
|
||||
// needless churn (secret files are individually owner-locked by `write_secret_file`).
|
||||
let _ = std::process::Command::new(&icacls)
|
||||
.arg(dir.as_os_str())
|
||||
.args(["/setowner", "*S-1-5-32-544"]) // BUILTIN\Administrators
|
||||
.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
|
||||
"/grant:r",
|
||||
"*S-1-3-4:(OI)(CI)(F)", // OWNER RIGHTS
|
||||
"/grant:r",
|
||||
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
_ => tracing::warn!(
|
||||
dir = %dir.display(),
|
||||
"config-dir DACL hardening did not fully succeed — a local user may be able to plant config files"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
|
||||
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a punktfunk-only
|
||||
//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is
|
||||
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`. Over HTTPS the client is
|
||||
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
|
||||
//!
|
||||
//! The pairing PIN is delivered out-of-band ONLY through the bearer-authenticated management
|
||||
//! API (`POST /api/v1/pair/pin`): the operator reads the PIN off the Moonlight client and
|
||||
//! types it into the host console. There is deliberately NO unauthenticated nvhttp PIN
|
||||
//! endpoint — one would let a network client submit its own displayed PIN and drive the whole
|
||||
//! ceremony to a pinned cert with no operator consent (security-review 2026-06-28 #1).
|
||||
|
||||
use super::tls::PeerCertFingerprint;
|
||||
use super::tls::{PeerAddr, PeerCertFingerprint};
|
||||
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
@@ -58,7 +63,6 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
Router::new()
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
.route("/pair", get(h_pair))
|
||||
.route("/pin", get(h_pin))
|
||||
.route("/applist", get(h_applist))
|
||||
.route("/launch", get(h_launch))
|
||||
.route("/resume", get(h_resume))
|
||||
@@ -82,19 +86,6 @@ async fn h_serverinfo(
|
||||
xml(serverinfo::serverinfo_xml(&st.host, https, paired))
|
||||
}
|
||||
|
||||
async fn h_pin(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
match q.get("pin").filter(|p| !p.is_empty()) {
|
||||
Some(pin) => {
|
||||
st.pairing.pin.submit(pin.clone());
|
||||
"PIN accepted\n".to_string()
|
||||
}
|
||||
None => "usage: GET /pin?pin=NNNN\n".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_applist(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
@@ -110,6 +101,7 @@ async fn h_applist(
|
||||
async fn h_launch(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
addr: Option<Extension<PeerAddr>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
if !peer_is_paired(&peer, &st) {
|
||||
@@ -117,7 +109,9 @@ async fn h_launch(
|
||||
return xml(error_xml());
|
||||
}
|
||||
match launch(&st, &q) {
|
||||
Ok(session) => {
|
||||
Ok(mut session) => {
|
||||
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
|
||||
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
|
||||
*st.launch.lock().unwrap() = Some(session);
|
||||
tracing::info!(
|
||||
w = session.width,
|
||||
@@ -193,6 +187,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
|
||||
height,
|
||||
fps,
|
||||
appid,
|
||||
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it
|
||||
/// (via the management API's `POST /api/v1/pair/pin` or nvhttp's `GET /pin?pin=NNNN`).
|
||||
/// `getservercert` parks until a PIN arrives.
|
||||
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the operator submits it
|
||||
/// via the bearer-authenticated management API (`POST /api/v1/pair/pin`) only — there is no
|
||||
/// unauthenticated nvhttp delivery path (a network client must never be able to submit its
|
||||
/// own PIN; security-review 2026-06-28 #1). `getservercert` parks until a PIN arrives.
|
||||
/// Max pairing handshakes parked in [`PinGate::take`] at once (each holds a slot for up to
|
||||
/// 300s), bounding a pre-auth waiter flood. Real pairing is one operator-driven client at a time.
|
||||
const MAX_PARKED_WAITERS: usize = 4;
|
||||
|
||||
pub struct PinGate {
|
||||
pin: Mutex<Option<String>>,
|
||||
notify: Notify,
|
||||
@@ -48,7 +53,20 @@ impl PinGate {
|
||||
}
|
||||
|
||||
async fn take(&self, timeout: Duration) -> Option<String> {
|
||||
self.waiters.fetch_add(1, Ordering::SeqCst);
|
||||
// Bound the number of pairing handshakes parked at once: each `getservercert` is
|
||||
// pre-auth and parks for up to 300s, so without a cap an unpaired LAN peer could pin
|
||||
// unbounded tasks + keep `awaiting_pin` asserted (security-review 2026-06-28 #12).
|
||||
// Reserve a slot atomically; refuse (treated as "no PIN") once the cap is reached.
|
||||
if self
|
||||
.waiters
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
|
||||
(n < MAX_PARKED_WAITERS).then_some(n + 1)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("pairing: too many handshakes awaiting a PIN — refusing");
|
||||
return None;
|
||||
}
|
||||
// Decrement on every exit path (PIN delivered, timeout, or future cancellation).
|
||||
struct WaiterGuard<'a>(&'a AtomicUsize);
|
||||
impl Drop for WaiterGuard<'_> {
|
||||
@@ -117,7 +135,8 @@ impl Pairing {
|
||||
|
||||
tracing::info!(
|
||||
uniqueid,
|
||||
"pairing phase 1 (getservercert) — awaiting PIN: submit `GET /pin?pin=NNNN`"
|
||||
"pairing phase 1 (getservercert) — awaiting PIN: deliver it via the management \
|
||||
API `POST /api/v1/pair/pin` (operator reads the PIN off the Moonlight client)"
|
||||
);
|
||||
let pin = self
|
||||
.pin
|
||||
@@ -304,4 +323,28 @@ mod tests {
|
||||
assert_eq!(pairing.pin.take(Duration::from_millis(10)).await, None);
|
||||
assert!(!pairing.pin.awaiting_pin());
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[tokio::test]
|
||||
async fn pin_gate_caps_parked_waiters() {
|
||||
let pairing = Arc::new(Pairing::new());
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..MAX_PARKED_WAITERS {
|
||||
let p = pairing.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
p.pin.take(Duration::from_secs(5)).await
|
||||
}));
|
||||
}
|
||||
// Wait until all the slots are taken.
|
||||
while pairing.pin.waiters.load(Ordering::SeqCst) < MAX_PARKED_WAITERS {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
// One more is refused right away (no parking), even with a long timeout.
|
||||
assert_eq!(pairing.pin.take(Duration::from_secs(5)).await, None);
|
||||
for h in handles {
|
||||
h.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::encode::Codec;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -102,13 +102,12 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
|
||||
"RTSP {} | {}", req.head.replace("\r\n", " | "),
|
||||
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
|
||||
);
|
||||
let resp = handle_request(&req, &state);
|
||||
let resp = handle_request(&req, &state, peer);
|
||||
stream.write_all(resp.as_bytes()).context("RTSP write")?;
|
||||
stream.flush().ok();
|
||||
// Close (FIN after the flushed response) so the client detects end-of-response.
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
let _ = peer;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ fn parse_request(head: &str, body: String) -> Request {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
|
||||
match req.method.as_str() {
|
||||
"OPTIONS" => response(
|
||||
&req.cseq,
|
||||
@@ -216,16 +215,30 @@ fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
response(&req.cseq, &[], None)
|
||||
}
|
||||
"PLAY" => {
|
||||
// The RTSP/UDP media plane is UNAUTHENTICATED. A stream may start only for the paired
|
||||
// client that completed the pairing-gated `/launch` (which set `state.launch`), and —
|
||||
// when the launching IP is known — only from that same source IP. So an unpaired RTSP
|
||||
// peer can neither start a stream on an idle host nor ride a paired client's active
|
||||
// launch (security-review 2026-06-28 #4). `nvhttp` gates `/launch` on a pinned cert.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
let Some(ls) = launch else {
|
||||
tracing::warn!(?peer, "RTSP PLAY — refused: no paired `/launch` session");
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
};
|
||||
if let (Some(want), Some(got)) = (ls.peer_ip, peer.map(|p| p.ip())) {
|
||||
if want != got {
|
||||
tracing::warn!(
|
||||
%want, %got,
|
||||
"RTSP PLAY — refused: peer IP does not match the launching client"
|
||||
);
|
||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||
}
|
||||
}
|
||||
let cfg = *state.stream.lock().unwrap();
|
||||
match cfg {
|
||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
||||
// Resolve the launched catalog entry (session recipe) for the stream.
|
||||
let app = state
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap()
|
||||
.map(|l| l.appid)
|
||||
.and_then(super::apps::by_id);
|
||||
let app = super::apps::by_id(ls.appid);
|
||||
tracing::info!(app = ?app.as_ref().map(|a| &a.title), "RTSP PLAY — starting video stream");
|
||||
stream::start(
|
||||
cfg,
|
||||
@@ -243,18 +256,15 @@ fn handle_request(req: &Request, state: &AppState) -> String {
|
||||
// Audio runs independently (Opus on UDP 48000, stereo or 5.1/7.1 multistream per
|
||||
// the ANNOUNCE); it needs the launch key for the AES-CBC payload encryption the
|
||||
// client expects.
|
||||
let launch = *state.launch.lock().unwrap();
|
||||
if let Some(ls) = launch {
|
||||
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
|
||||
tracing::info!("RTSP PLAY — starting audio stream");
|
||||
audio::start(
|
||||
state.audio_streaming.clone(),
|
||||
ls.gcm_key,
|
||||
ls.rikeyid,
|
||||
*state.audio_params.lock().unwrap(),
|
||||
state.audio_cap.clone(),
|
||||
);
|
||||
}
|
||||
if !state.audio_streaming.swap(true, Ordering::SeqCst) {
|
||||
tracing::info!("RTSP PLAY — starting audio stream");
|
||||
audio::start(
|
||||
state.audio_streaming.clone(),
|
||||
ls.gcm_key,
|
||||
ls.rikeyid,
|
||||
*state.audio_params.lock().unwrap(),
|
||||
state.audio_cap.clone(),
|
||||
);
|
||||
}
|
||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
//! fingerprint ([`PeerCertFingerprint`]) to each request, and the nvhttp/mgmt handlers reject
|
||||
//! callers whose fingerprint is not pinned (mirroring Apollo's post-handshake `get_verified_cert`).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
use rustls::client::danger::HandshakeSignatureValid;
|
||||
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
|
||||
use rustls::pki_types::{CertificateDer, UnixTime};
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, UnixTime};
|
||||
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
|
||||
use rustls::{DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme};
|
||||
use std::net::SocketAddr;
|
||||
@@ -24,6 +25,12 @@ use std::sync::Arc;
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PeerCertFingerprint(pub Option<String>);
|
||||
|
||||
/// The TCP source address of an HTTPS request, injected per-connection by [`serve_https`]. Used by
|
||||
/// `/launch` to record which paired client owns the session so the unauthenticated RTSP/UDP media
|
||||
/// plane can bind to that peer's IP (security-review 2026-06-28 #4).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct PeerAddr(pub SocketAddr);
|
||||
|
||||
/// HTTPS server that surfaces the verified client cert to handlers. `axum_server` can't expose the
|
||||
/// peer cert, so this runs the rustls handshake itself (tokio-rustls), reads the peer certificate,
|
||||
/// and serves the axum `Router` over hyper with the peer's fingerprint attached to every request as
|
||||
@@ -39,7 +46,7 @@ pub(crate) async fn serve_https(
|
||||
.await
|
||||
.with_context(|| format!("bind HTTPS {bind}"))?;
|
||||
loop {
|
||||
let (tcp, _peer) = match listener.accept().await {
|
||||
let (tcp, peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "HTTPS accept failed");
|
||||
@@ -63,14 +70,16 @@ pub(crate) async fn serve_https(
|
||||
.peer_certificates()
|
||||
.and_then(|c| c.first())
|
||||
.map(|c| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(c.as_ref())));
|
||||
let peer = PeerCertFingerprint(fp);
|
||||
let fp = PeerCertFingerprint(fp);
|
||||
let addr = PeerAddr(peer);
|
||||
let svc =
|
||||
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
|
||||
let app = app.clone();
|
||||
let peer = peer.clone();
|
||||
let fp = fp.clone();
|
||||
async move {
|
||||
let mut req = req.map(axum::body::Body::new);
|
||||
req.extensions_mut().insert(peer);
|
||||
req.extensions_mut().insert(fp);
|
||||
req.extensions_mut().insert(addr);
|
||||
app.oneshot(req).await // Router error is Infallible
|
||||
}
|
||||
});
|
||||
@@ -169,12 +178,12 @@ fn build_server_config(
|
||||
mandatory: bool,
|
||||
) -> Result<Arc<ServerConfig>> {
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
||||
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
|
||||
// so we don't pull the unmaintained `rustls-pemfile`.
|
||||
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.context("parse host cert PEM")?;
|
||||
let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
|
||||
.context("parse host key PEM")?
|
||||
.ok_or_else(|| anyhow!("no private key in host key PEM"))?;
|
||||
let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes()).context("parse host key PEM")?;
|
||||
|
||||
let verifier = Arc::new(AcceptAnyClientCert {
|
||||
provider: provider.clone(),
|
||||
|
||||
@@ -76,9 +76,7 @@ pub fn open(backend: Backend) -> Result<Box<dyn InputInjector>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(Box::new(libei::LibeiInjector::open_with(
|
||||
libei::EiSource::SocketPathFile(
|
||||
crate::vdisplay::gamescope_ei_socket_file().into(),
|
||||
),
|
||||
libei::EiSource::SocketPathFile(crate::vdisplay::gamescope_ei_socket_file()),
|
||||
)?))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
|
||||
@@ -305,6 +305,19 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(15);
|
||||
let mut logged = String::new();
|
||||
loop {
|
||||
// Defense-in-depth: never follow a symlinked relay file. It lives under `$XDG_RUNTIME_DIR`
|
||||
// (per-user 0700) so a cross-user plant is already blocked, but refuse a symlink outright
|
||||
// rather than read through one to an attacker-chosen target (a rogue EIS server would
|
||||
// keylog/deny the session's input; security-review 2026-06-28 #6).
|
||||
if std::fs::symlink_metadata(file)
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"EIS relay file {} is a symlink — refusing to follow it",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
if let Ok(s) = std::fs::read_to_string(file) {
|
||||
let name = s.trim();
|
||||
if !name.is_empty() {
|
||||
|
||||
@@ -577,10 +577,11 @@ impl LibraryProvider for EpicProvider {
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("item") {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
|
||||
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(g) = epic_entry(&v, &art) {
|
||||
@@ -650,6 +651,23 @@ fn epic_entry(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
|
||||
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
|
||||
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
|
||||
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
|
||||
/// Linux lutris-art reader's 1 MiB cap.
|
||||
#[cfg(windows)]
|
||||
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if meta.len() == 0 || meta.len() > max {
|
||||
if meta.len() > max {
|
||||
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
|
||||
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
|
||||
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
|
||||
@@ -657,7 +675,8 @@ fn epic_entry(
|
||||
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
|
||||
use base64::Engine as _;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Ok(raw) = std::fs::read(catcache) else {
|
||||
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
|
||||
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
|
||||
|
||||
@@ -121,7 +121,8 @@ fn real_main() -> Result<()> {
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
|
||||
// Install Apollo's win32u GPU-preference hook BEFORE anything touches DXGI (the SudoVDA
|
||||
// Install the win32u GPU-preference hook (same technique as Apollo, reimplemented — no GPL source
|
||||
// copied) BEFORE anything touches DXGI (the virtual-display
|
||||
// render-adapter selection creates a DXGI factory during virtual-display setup, well before
|
||||
// capture). On a hybrid-GPU box this stops DXGI from reparenting the virtual output off the
|
||||
// capture GPU — the ACCESS_LOST churn fix. Idempotent (Once); harmless on non-hybrid boxes.
|
||||
|
||||
@@ -1680,6 +1680,7 @@ mod tests {
|
||||
height: 1440,
|
||||
fps: 120,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
});
|
||||
state.streaming.store(true, Ordering::SeqCst);
|
||||
|
||||
@@ -1805,6 +1806,7 @@ mod tests {
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
appid: 1,
|
||||
peer_ip: None,
|
||||
});
|
||||
|
||||
let del = axum::http::Request::delete("/api/v1/session")
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngCore;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::Path;
|
||||
|
||||
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
|
||||
@@ -38,9 +35,11 @@ pub fn load_or_generate() -> Result<String> {
|
||||
rand::thread_rng().fill_bytes(&mut buf);
|
||||
let token = hex::encode(buf);
|
||||
let dir = crate::gamestream::config_dir();
|
||||
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
// Owner-private dir (0700 Unix / DACL-locked Windows) so the token can't leak via the config path.
|
||||
crate::gamestream::create_private_dir(&dir)
|
||||
.with_context(|| format!("create {}", dir.display()))?;
|
||||
write_token(&path, &token)?;
|
||||
tracing::info!(path = %path.display(), "generated and persisted management API token (0600)");
|
||||
tracing::info!(path = %path.display(), "generated and persisted management API token (owner-only)");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
@@ -55,19 +54,15 @@ fn parse_token(contents: &str) -> Option<String> {
|
||||
(!tok.is_empty()).then(|| tok.to_string())
|
||||
}
|
||||
|
||||
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path`, mode 0600 (never briefly world-readable).
|
||||
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path` as an owner-only secret — 0600 on Unix AND
|
||||
/// DACL-locked to SYSTEM/Administrators on Windows. Routes through the shared `write_secret_file` so
|
||||
/// the mgmt bearer token (full admin authority) gets the SAME Windows lockdown as the host key; the
|
||||
/// bespoke `cfg(unix)`-only writer used to leave it readable by any local user (security-review
|
||||
/// 2026-06-28 #2).
|
||||
fn write_token(path: &Path, token: &str) -> Result<()> {
|
||||
let mut opts = fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
opts.mode(0o600);
|
||||
let mut f = opts
|
||||
.open(path)
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
writeln!(f, "PUNKTFUNK_MGMT_TOKEN={token}")?;
|
||||
#[cfg(unix)]
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
Ok(())
|
||||
let line = format!("PUNKTFUNK_MGMT_TOKEN={token}\n");
|
||||
crate::gamestream::write_secret_file(path, line.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -95,6 +90,7 @@ mod tests {
|
||||
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
||||
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
||||
@@ -76,6 +77,18 @@ pub struct PendingRequest {
|
||||
pub age_secs: u64,
|
||||
}
|
||||
|
||||
/// The outcome of [`NativePairing::wait_for_decision`] — what an operator did with a parked,
|
||||
/// unpaired knock (delegated approval, roadmap §8b-1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PairingDecision {
|
||||
/// The operator clicked Approve (the fingerprint is now paired) — admit the session.
|
||||
Approved,
|
||||
/// The operator denied, or the pending entry was otherwise dropped without pairing — reject.
|
||||
Denied,
|
||||
/// No decision within the wait window — reject; the device can knock again.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
||||
/// approvable days later when the operator no longer remembers the context).
|
||||
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
@@ -88,6 +101,11 @@ pub struct NativePairing {
|
||||
arm: Mutex<Armed>,
|
||||
paired: Mutex<PairedState>,
|
||||
pending: Mutex<PendingState>,
|
||||
/// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock
|
||||
/// denied/dropped), so a QUIC connection parked in [`NativePairing::wait_for_decision`] wakes
|
||||
/// the instant an operator acts in the console — the substrate for delegated approval admitting
|
||||
/// a session with no client reconnect.
|
||||
changed: Notify,
|
||||
}
|
||||
|
||||
/// A snapshot for the management API / web console.
|
||||
@@ -199,6 +217,7 @@ impl NativePairing {
|
||||
arm: Mutex::new(arm),
|
||||
paired: Mutex::new(PairedState { path, clients }),
|
||||
pending: Mutex::new(PendingState::default()),
|
||||
changed: Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,10 +295,17 @@ impl NativePairing {
|
||||
}
|
||||
}
|
||||
// A device that knocked and is now paired shouldn't linger in the approval list.
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.items
|
||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||
{
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
pending
|
||||
.items
|
||||
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||
}
|
||||
// Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just
|
||||
// completed (console approve or the PIN ceremony), so it can admit the session with no
|
||||
// reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the
|
||||
// fully settled state (paired = true, no longer pending) — see `wait_for_decision`.
|
||||
self.changed.notify_waiters();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -372,6 +398,17 @@ impl NativePairing {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped
|
||||
/// first, so this also reports whether a parked knock is still live.)
|
||||
pub fn pending_contains(&self, fp_hex: &str) -> bool {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
pending
|
||||
.items
|
||||
.iter()
|
||||
.any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||
}
|
||||
|
||||
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
|
||||
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
|
||||
/// (or expired) id.
|
||||
@@ -380,29 +417,78 @@ impl NativePairing {
|
||||
id: u32,
|
||||
name_override: Option<&str>,
|
||||
) -> Result<Option<PairedClient>> {
|
||||
let entry = {
|
||||
// Read (do NOT pre-remove) the entry: `add()` pins the fingerprint and THEN clears its
|
||||
// pending entry — an order `wait_for_decision` relies on so a parked waiter never observes
|
||||
// the device as "neither pending nor paired" (which would read as a denial). Removing here
|
||||
// first would open exactly that window.
|
||||
let (knock_name, fp_hex) = {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
Self::expire_pending(&mut pending);
|
||||
let Some(at) = pending.items.iter().position(|p| p.id == id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
pending.items.remove(at)
|
||||
}; // pending lock released — add() takes the paired lock
|
||||
let name = name_override.unwrap_or(&entry.name);
|
||||
self.add(name, &entry.fp_hex)?;
|
||||
match pending.items.iter().find(|p| p.id == id) {
|
||||
Some(p) => (p.name.clone(), p.fp_hex.clone()),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}; // pending lock released — add() takes the paired then pending locks
|
||||
let name = name_override.unwrap_or(&knock_name).to_string();
|
||||
self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters
|
||||
Ok(Some(PairedClient {
|
||||
name: name.to_string(),
|
||||
fingerprint: entry.fp_hex,
|
||||
name,
|
||||
fingerprint: fp_hex,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
|
||||
/// re-creates an entry — deny is "not now", not a blocklist.
|
||||
pub fn deny_pending(&self, id: u32) -> bool {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let before = pending.items.len();
|
||||
pending.items.retain(|p| p.id != id);
|
||||
pending.items.len() != before
|
||||
let removed = {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let before = pending.items.len();
|
||||
pending.items.retain(|p| p.id != id);
|
||||
pending.items.len() != before
|
||||
};
|
||||
if removed {
|
||||
// Wake a parked waiter so it returns `Denied` at once instead of holding the
|
||||
// connection open until the approval window lapses.
|
||||
self.changed.notify_waiters();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
|
||||
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
|
||||
/// approve or a concurrent PIN ceremony), [`PairingDecision::Denied`] if its pending entry is
|
||||
/// dropped without pairing, or [`PairingDecision::TimedOut`] if the window lapses. Holds no
|
||||
/// lock across the await. The QUIC accept path calls this right after [`Self::note_pending`]
|
||||
/// to keep the knocking connection open until a human clicks Approve — so the device pairs and
|
||||
/// streams with no reconnect (delegated approval, roadmap §8b-1).
|
||||
pub async fn wait_for_decision(&self, fp_hex: &str, timeout: Duration) -> PairingDecision {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
|
||||
// lands between the state check and the await still wakes us (no lost notification).
|
||||
let notified = self.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
|
||||
if self.is_paired(fp_hex) {
|
||||
return PairingDecision::Approved;
|
||||
}
|
||||
if !self.pending_contains(fp_hex) {
|
||||
// Neither pending nor paired. This is almost always a denial — but it can also be
|
||||
// the tiny interval inside `add()` between pinning and clearing the pending entry.
|
||||
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
|
||||
// cleared-pending observation that is really an approval will now read as paired.
|
||||
if self.is_paired(fp_hex) {
|
||||
return PairingDecision::Approved;
|
||||
}
|
||||
return PairingDecision::Denied;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut notified => {}
|
||||
_ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,4 +647,60 @@ mod tests {
|
||||
assert!(np.current_pin().is_none());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_decision_approve_deny_timeout() {
|
||||
use std::sync::Arc;
|
||||
let p = temp();
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
|
||||
|
||||
// TimedOut: a parked knock with no decision returns TimedOut; the entry survives.
|
||||
np.note_pending("Knocker", "ab01");
|
||||
let d = np
|
||||
.wait_for_decision("ab01", Duration::from_millis(80))
|
||||
.await;
|
||||
assert_eq!(d, PairingDecision::TimedOut);
|
||||
assert!(np.pending_contains("ab01"));
|
||||
|
||||
// Approved: approving WHILE parked wakes the waiter with Approved.
|
||||
let np2 = np.clone();
|
||||
let waiter =
|
||||
tokio::spawn(
|
||||
async move { np2.wait_for_decision("ab01", Duration::from_secs(5)).await },
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let id = np
|
||||
.pending()
|
||||
.into_iter()
|
||||
.find(|x| x.fingerprint == "ab01")
|
||||
.unwrap()
|
||||
.id;
|
||||
np.approve_pending(id, Some("Approved")).unwrap().unwrap();
|
||||
assert_eq!(waiter.await.unwrap(), PairingDecision::Approved);
|
||||
assert!(np.is_paired("ab01"));
|
||||
|
||||
// Denied: denying WHILE parked wakes the waiter with Denied (not held until timeout).
|
||||
np.note_pending("Knock2", "cd02");
|
||||
let np3 = np.clone();
|
||||
let waiter =
|
||||
tokio::spawn(
|
||||
async move { np3.wait_for_decision("cd02", Duration::from_secs(5)).await },
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let id = np
|
||||
.pending()
|
||||
.into_iter()
|
||||
.find(|x| x.fingerprint == "cd02")
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(np.deny_pending(id));
|
||||
assert_eq!(waiter.await.unwrap(), PairingDecision::Denied);
|
||||
assert!(!np.is_paired("cd02"));
|
||||
|
||||
// Already paired before the call → immediate Approved (no waiting).
|
||||
let d = np.wait_for_decision("ab01", Duration::from_secs(5)).await;
|
||||
assert_eq!(d, PairingDecision::Approved);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ pub struct Punktfunk1Options {
|
||||
}
|
||||
|
||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||
use crate::native_pairing::NativePairing;
|
||||
use crate::native_pairing::{NativePairing, PairingDecision};
|
||||
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
|
||||
/// and the GameStream loop; threaded into each session's `SessionContext`.
|
||||
use crate::stats_recorder::StatsRecorder;
|
||||
@@ -290,8 +290,11 @@ pub(crate) async fn serve(
|
||||
let stats = stats.clone();
|
||||
let inj_tx = injector.sender();
|
||||
let mic_tx = mic_service.sender();
|
||||
// The session permit + the pool it came from are handed to serve_session, which owns the
|
||||
// permit's lifetime: it's released while a knock is parked for delegated approval and
|
||||
// re-acquired on approval, so the hold is no longer a simple closure-scoped binding.
|
||||
let sem_session = sem.clone();
|
||||
sessions.spawn(async move {
|
||||
let _permit = permit; // held for the session's lifetime; frees a slot on completion
|
||||
match serve_session(
|
||||
conn,
|
||||
&opts,
|
||||
@@ -302,6 +305,8 @@ pub(crate) async fn serve(
|
||||
&np,
|
||||
&last_pairing,
|
||||
stats,
|
||||
permit,
|
||||
sem_session,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -410,6 +415,14 @@ type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
|
||||
/// client), so its budget is far larger than the machine-speed session handshake.
|
||||
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// How long the host keeps an unpaired knock PARKED — connection held open — waiting for the
|
||||
/// operator to click Approve in the console (delegated approval, roadmap §8b-1). The QUIC
|
||||
/// keep-alive (4 s, under the 8 s idle timeout) holds the path warm meanwhile, so on approval the
|
||||
/// device pairs and streams with NO reconnect. Bounded well under the pending entry's TTL (10 min);
|
||||
/// the client uses a comparable connect timeout, and a client that gives up first closes the
|
||||
/// connection (the host stops waiting at once).
|
||||
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
|
||||
|
||||
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
|
||||
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
|
||||
/// client's key-confirmation MAC (its single online guess), and persist the client's
|
||||
@@ -497,11 +510,16 @@ async fn serve_session(
|
||||
opts: &Punktfunk1Options,
|
||||
audio_cap: &AudioCapSlot,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
mic_tx: std::sync::mpsc::Sender<Vec<u8>>,
|
||||
mic_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
host_fp: &[u8; 32],
|
||||
np: &NativePairing,
|
||||
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
||||
stats: Arc<StatsRecorder>,
|
||||
// The session slot. Owned here (not just held by the spawning task) because an unpaired knock
|
||||
// RELEASES it while parked for delegated approval, then RE-ACQUIRES one on approval — so a
|
||||
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
|
||||
mut permit: tokio::sync::OwnedSemaphorePermit,
|
||||
sem: Arc<tokio::sync::Semaphore>,
|
||||
) -> Result<()> {
|
||||
let peer = conn.remote_address();
|
||||
|
||||
@@ -531,6 +549,79 @@ async fn serve_session(
|
||||
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
|
||||
}
|
||||
|
||||
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
|
||||
// `handshake` future below for two reasons: (1) the approval wait must not be bound by the
|
||||
// short HANDSHAKE_TIMEOUT — a human reads the console and clicks Approve; (2) the NVENC session
|
||||
// permit is released while parked, so a knock awaiting approval can't hold a streaming slot.
|
||||
// On approval the device is now paired, so the handshake proceeds and the session starts with
|
||||
// NO client reconnect (delegated approval, roadmap §8b-1).
|
||||
if opts.require_pairing {
|
||||
// Decode just enough to gate (the Hello carries the device name for the pending label);
|
||||
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
|
||||
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
anyhow::ensure!(
|
||||
gate_hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||
"ABI mismatch: client {} host {}",
|
||||
gate_hello.abi_version,
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
let fp = endpoint::peer_fingerprint(&conn);
|
||||
let known = fp
|
||||
.as_ref()
|
||||
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
|
||||
.unwrap_or(false);
|
||||
if !known {
|
||||
// An anonymous client (no certificate) has no identity to approve — reject outright
|
||||
// (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks.
|
||||
let Some(fp) = fp else {
|
||||
anyhow::bail!(
|
||||
"unpaired anonymous client rejected (this host requires pairing — present a \
|
||||
client identity and approve it in the console, or run the PIN ceremony)"
|
||||
);
|
||||
};
|
||||
let fp_hex = fingerprint_hex(&fp);
|
||||
// Sanitize the wire-supplied name before it reaches the log / console (untrusted: an
|
||||
// unpaired device could embed terminal escapes / bidi overrides); note_pending stores
|
||||
// the same sanitized form and derives a fingerprint label when empty.
|
||||
let label = crate::native_pairing::sanitize_device_name(
|
||||
gate_hello.name.as_deref().unwrap_or(""),
|
||||
&fp_hex,
|
||||
);
|
||||
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||
"unpaired device knocked — parking connection for delegated approval in the console");
|
||||
np.note_pending(&label, &fp_hex);
|
||||
// Free the session slot while a human decides — a parked knock must not hold an NVENC
|
||||
// permit (a handful of parked knocks would otherwise block every real session).
|
||||
drop(permit);
|
||||
let decision = tokio::select! {
|
||||
d = np.wait_for_decision(&fp_hex, PENDING_APPROVAL_WAIT) => d,
|
||||
// The client gave up (closed the connection) before a decision — stop waiting.
|
||||
_ = conn.closed() => anyhow::bail!("client disconnected before pairing approval"),
|
||||
};
|
||||
match decision {
|
||||
PairingDecision::Approved => {
|
||||
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||
"device approved in console — admitting session (no reconnect)");
|
||||
}
|
||||
PairingDecision::Denied => anyhow::bail!("pairing request denied in the console"),
|
||||
PairingDecision::TimedOut => anyhow::bail!(
|
||||
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
|
||||
— the device can knock again"
|
||||
),
|
||||
}
|
||||
// Re-acquire a session slot for the now-approved session (waits if all slots are busy,
|
||||
// exactly like any freshly accepted client).
|
||||
permit = sem
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("session semaphore is never closed");
|
||||
}
|
||||
}
|
||||
// Held for the rest of the session (RAII frees the slot on return). For an already-paired
|
||||
// client this is the original permit; for a just-approved knock it's the re-acquired one.
|
||||
let _permit = permit;
|
||||
|
||||
let source = opts.source;
|
||||
let frames = opts.frames;
|
||||
let handshake = async {
|
||||
@@ -541,36 +632,8 @@ async fn serve_session(
|
||||
hello.abi_version,
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
if opts.require_pairing {
|
||||
let fp = endpoint::peer_fingerprint(&conn);
|
||||
let known = fp
|
||||
.as_ref()
|
||||
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
|
||||
.unwrap_or(false);
|
||||
if !known {
|
||||
// Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
|
||||
// request the operator can approve from the console — no PIN fetched out of band.
|
||||
// The label is the client's Hello name, else fingerprint-derived. An anonymous
|
||||
// client (no certificate) has no identity to approve, so nothing is recorded.
|
||||
if let Some(fp) = &fp {
|
||||
let fp_hex = fingerprint_hex(fp);
|
||||
// Sanitize the wire-supplied name before it reaches the log (untrusted: an
|
||||
// unpaired device could embed terminal escapes / bidi overrides); note_pending
|
||||
// stores the same sanitized form and derives a fingerprint label when empty.
|
||||
let label = crate::native_pairing::sanitize_device_name(
|
||||
hello.name.as_deref().unwrap_or(""),
|
||||
&fp_hex,
|
||||
);
|
||||
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||
"unpaired device knocked — held for approval in the console");
|
||||
np.note_pending(&label, &fp_hex);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"unpaired client rejected (this host requires pairing — approve the device \
|
||||
in the console, or run the PIN ceremony)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
|
||||
// before this future, so a client reaching here is paired (or the host is `--open`).
|
||||
crate::encode::validate_dimensions(
|
||||
crate::encode::Codec::H265,
|
||||
hello.mode.width,
|
||||
@@ -597,9 +660,11 @@ async fn serve_session(
|
||||
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
|
||||
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
|
||||
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
|
||||
// path — safe under today's ONE-session-at-a-time model; when concurrent native sessions land
|
||||
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
|
||||
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
|
||||
// path; the write is serialized via `vdisplay::with_env_lock` so concurrent native-session
|
||||
// handshakes can't race the `set_var` (security-review 2026-06-28 #7). The remaining
|
||||
// cross-session *value* confusion (B's launch id stomping A's pending gamescope spawn) wants
|
||||
// the command resolved into the per-session VirtualDisplay via `set_launch_command` (as the
|
||||
// GameStream path does) — a follow-up; the data-race UB is closed here.
|
||||
if let Some(id) = hello.launch.as_deref() {
|
||||
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
|
||||
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
|
||||
@@ -609,7 +674,9 @@ async fn serve_session(
|
||||
match crate::library::launch_command(id) {
|
||||
Some(cmd) => {
|
||||
tracing::info!(launch_id = id, command = %cmd, "launching library title");
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd);
|
||||
crate::vdisplay::with_env_lock(|| {
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)
|
||||
});
|
||||
}
|
||||
None => tracing::warn!(
|
||||
launch_id = id,
|
||||
@@ -907,8 +974,9 @@ async fn serve_session(
|
||||
while let Ok(d) = input_conn.read_datagram().await {
|
||||
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
|
||||
mic_count += 1;
|
||||
// Host-lifetime mic service; a send error just means the host is shutting down.
|
||||
let _ = mic_tx.send(opus.to_vec());
|
||||
// Host-lifetime mic service (bounded queue): `try_send` drops the frame when the
|
||||
// service is full or gone, never blocking this datagram loop (security-review S6).
|
||||
let _ = mic_tx.try_send(opus.to_vec());
|
||||
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
|
||||
rich_count += 1;
|
||||
if rich_tx.send(rich).is_err() {
|
||||
@@ -1185,6 +1253,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
|
||||
|
||||
/// Mic is 48 kHz stereo — matches the Opus stereo decoder and the host→client audio layout.
|
||||
const MIC_CHANNELS: u32 = 2;
|
||||
/// Bound for the shared mic frame queue (drop-newest when full). See [`MicService::start`].
|
||||
const MIC_QUEUE_CAP: usize = 64;
|
||||
|
||||
/// Host-lifetime virtual microphone, shared across punktfunk/1 sessions (mirror of
|
||||
/// [`InjectorService`]). One thread owns the PipeWire `Audio/Source` + an Opus decoder; sessions
|
||||
@@ -1192,12 +1262,16 @@ const MIC_CHANNELS: u32 = 2;
|
||||
/// feeds the source. Opened lazily on the first frame, the source node persists across sessions
|
||||
/// (no per-session registration churn), and reopens after a backoff if the source/decoder fails.
|
||||
struct MicService {
|
||||
tx: std::sync::mpsc::Sender<Vec<u8>>,
|
||||
tx: std::sync::mpsc::SyncSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MicService {
|
||||
fn start() -> MicService {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
// Bounded so the host-lifetime mic queue (shared across all concurrent sessions) can't grow
|
||||
// without limit under a near-line-rate flood; the producer drops the newest frame when full
|
||||
// (audio is lossy by design) rather than buffering unboundedly (security-review 2026-06-28
|
||||
// S6). 64 × 5–10 ms frames ≈ 0.3–0.6 s of slack, far more than the decode loop ever lags.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(MIC_QUEUE_CAP);
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk1-mic".into())
|
||||
.spawn(move || mic_service_thread(rx))
|
||||
@@ -1209,7 +1283,7 @@ impl MicService {
|
||||
|
||||
/// A sender a session forwards the client's Opus mic frames to. Cloned per session; dropping a
|
||||
/// clone does NOT stop the service (it holds the original sender for the host life).
|
||||
fn sender(&self) -> std::sync::mpsc::Sender<Vec<u8>> {
|
||||
fn sender(&self) -> std::sync::mpsc::SyncSender<Vec<u8>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
@@ -1224,14 +1298,17 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
|
||||
/// The host-lifetime mic worker: lazily open the virtual mic + decoder, then Opus-decode each
|
||||
/// forwarded frame and push the PCM into the source. Reopen (after [`INJECTOR_REOPEN_BACKOFF`])
|
||||
/// on open failure or a decode error. Exits when every session sender and the service's own
|
||||
/// sender drop (host shutdown), tearing the virtual mic down. Linux = PipeWire `Audio/Source`;
|
||||
/// Windows = a virtual audio device's render endpoint (see `audio::wasapi_mic`).
|
||||
/// only on a backend OPEN failure; a per-frame Opus DECODE error is just a dropped frame (it must
|
||||
/// not tear down this mic, which is shared across every concurrent session — otherwise one paired
|
||||
/// client's junk frames would deny everyone's mic; security-review 2026-06-28 S2). Exits when every
|
||||
/// session sender and the service's own sender drop (host shutdown), tearing the virtual mic down.
|
||||
/// Linux = PipeWire `Audio/Source`; Windows = a virtual audio device's render endpoint.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
let mut mic: Option<Box<dyn crate::audio::VirtualMic>> = None;
|
||||
let mut decoder: Option<opus::Decoder> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
let mut decode_fails: u64 = 0;
|
||||
let mut pcm = vec![0f32; 5760 * MIC_CHANNELS as usize]; // up to 120 ms scratch
|
||||
for opus_frame in rx {
|
||||
if opus_frame.is_empty() {
|
||||
@@ -1267,12 +1344,16 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
Ok(samples_per_ch) => {
|
||||
let total = (samples_per_ch * MIC_CHANNELS as usize).min(pcm.len());
|
||||
m.push(&pcm[..total]);
|
||||
decode_fails = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mic opus decode failed — reopening");
|
||||
mic = None;
|
||||
decoder = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
// Malformed/garbage frame: drop it and keep the (shared) mic + decoder open. The
|
||||
// next valid frame decodes normally; only a backend OPEN failure reopens. Throttle
|
||||
// the log (1, 2, 4, … fails) so a junk flood can't spam.
|
||||
decode_fails += 1;
|
||||
if decode_fails.is_power_of_two() {
|
||||
tracing::warn!(error = %e, fails = decode_fails, "mic opus decode failed — dropping frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1454,8 +1535,14 @@ fn input_thread(
|
||||
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
|
||||
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
|
||||
// see the release loop after the `break`.
|
||||
let mut held_buttons: Vec<u32> = Vec::new();
|
||||
let mut held_keys: Vec<u32> = Vec::new();
|
||||
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
|
||||
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
|
||||
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
|
||||
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
|
||||
// key on a pathological disconnect, which the injector's own state still bounds).
|
||||
const MAX_HELD: usize = 256;
|
||||
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
loop {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
||||
Ok(ev) => match ev.kind {
|
||||
@@ -1473,14 +1560,18 @@ fn input_thread(
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
InputKind::MouseButtonDown if !held_buttons.contains(&ev.code) => {
|
||||
held_buttons.push(ev.code)
|
||||
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
|
||||
held_buttons.insert(ev.code);
|
||||
}
|
||||
InputKind::MouseButtonUp => held_buttons.retain(|&c| c != ev.code),
|
||||
InputKind::KeyDown if !held_keys.contains(&ev.code) => {
|
||||
held_keys.push(ev.code)
|
||||
InputKind::MouseButtonUp => {
|
||||
held_buttons.remove(&ev.code);
|
||||
}
|
||||
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
|
||||
held_keys.insert(ev.code);
|
||||
}
|
||||
InputKind::KeyUp => {
|
||||
held_keys.remove(&ev.code);
|
||||
}
|
||||
InputKind::KeyUp => held_keys.retain(|&c| c != ev.code),
|
||||
_ => {}
|
||||
}
|
||||
// Pointer/keyboard → the host-lifetime injector service (one persistent
|
||||
@@ -4082,10 +4173,11 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
||||
}
|
||||
|
||||
/// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's
|
||||
/// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
|
||||
/// the connector sends no Hello name); approving it pairs the fingerprint, and the same
|
||||
/// identity then gets a session with no PIN ceremony.
|
||||
/// Delegated approval (§8b-1) end to end in-process, the SEAMLESS flow: an
|
||||
/// identified-but-unpaired client's knock on a pairing-required host is PARKED (connection held
|
||||
/// open) and shows up as a pending request (fingerprint-derived label — the connector sends no
|
||||
/// Hello name); the operator approves it WHILE the client waits, and the SAME connection is
|
||||
/// admitted to a session with no PIN and no reconnect.
|
||||
#[test]
|
||||
fn delegated_approval_admits_after_knock() {
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -4108,7 +4200,7 @@ mod tests {
|
||||
source: Punktfunk1Source::Synthetic,
|
||||
seconds: 0,
|
||||
frames: 25,
|
||||
max_sessions: 2, // the knock + the post-approval session
|
||||
max_sessions: 1, // the single parked-then-approved session (no reconnect)
|
||||
max_concurrent: 1,
|
||||
require_pairing: true,
|
||||
allow_pairing: false,
|
||||
@@ -4122,49 +4214,47 @@ mod tests {
|
||||
))
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let timeout = std::time::Duration::from_secs(10);
|
||||
let (cert, key) = endpoint::generate_identity().unwrap();
|
||||
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
|
||||
let mode = punktfunk_core::Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
};
|
||||
|
||||
// 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
|
||||
assert!(
|
||||
NativeClient::connect(
|
||||
"127.0.0.1",
|
||||
19779,
|
||||
mode,
|
||||
CompositorPref::Auto,
|
||||
GamepadPref::Auto,
|
||||
0,
|
||||
0, // video_caps
|
||||
2, // audio_channels (stereo)
|
||||
None, // launch
|
||||
None,
|
||||
Some((cert.clone(), key.clone())),
|
||||
timeout
|
||||
)
|
||||
.is_err(),
|
||||
"unpaired knock must still be rejected"
|
||||
);
|
||||
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
|
||||
let pend = np.pending();
|
||||
assert_eq!(pend.len(), 1, "the knock must be held for approval");
|
||||
assert_eq!(pend[0].fingerprint, expected_fp);
|
||||
assert!(
|
||||
pend[0].name.starts_with("device "),
|
||||
"no Hello name → fingerprint-derived label, got {:?}",
|
||||
pend[0].name
|
||||
);
|
||||
// Approver thread: wait for the parked knock to register, assert its label, then APPROVE it
|
||||
// WHILE the client is still parked — the console "click accept" flow.
|
||||
let np_approve = np.clone();
|
||||
let expect_fp = expected_fp.clone();
|
||||
let approver = std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
|
||||
let pend = loop {
|
||||
if let Some(p) = np_approve
|
||||
.pending()
|
||||
.into_iter()
|
||||
.find(|p| p.fingerprint == expect_fp)
|
||||
{
|
||||
break p;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the knock must register while the client is parked"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(40));
|
||||
};
|
||||
assert!(
|
||||
pend.name.starts_with("device "),
|
||||
"no Hello name → fingerprint-derived label, got {:?}",
|
||||
pend.name
|
||||
);
|
||||
np_approve
|
||||
.approve_pending(pend.id, Some("Approved Device"))
|
||||
.unwrap()
|
||||
.expect("pending id must approve");
|
||||
});
|
||||
|
||||
// 2: approve (with an operator label) → the same identity now gets a session, no PIN.
|
||||
let approved = np
|
||||
.approve_pending(pend[0].id, Some("Approved Device"))
|
||||
.unwrap()
|
||||
.expect("pending id must approve");
|
||||
assert_eq!(approved.fingerprint, expected_fp);
|
||||
// The knock: a SINGLE connect that parks until approved, then streams — no reconnect. The
|
||||
// timeout is generous (it covers the park + the approver's poll latency).
|
||||
let client = NativeClient::connect(
|
||||
"127.0.0.1",
|
||||
19779,
|
||||
@@ -4175,11 +4265,17 @@ mod tests {
|
||||
0, // video_caps
|
||||
2, // audio_channels (stereo)
|
||||
None, // launch
|
||||
None,
|
||||
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
|
||||
Some((cert, key)),
|
||||
timeout,
|
||||
std::time::Duration::from_secs(15),
|
||||
)
|
||||
.expect("approved identity gets a session");
|
||||
.expect("approved mid-park → session admitted with no reconnect");
|
||||
approver.join().unwrap();
|
||||
assert!(
|
||||
np.is_paired(&expected_fp),
|
||||
"approval must pin the knocking fingerprint"
|
||||
);
|
||||
assert_eq!(np.list()[0].name, "Approved Device");
|
||||
drop(client);
|
||||
let _ = std::fs::remove_file(&store);
|
||||
host.join().unwrap().unwrap();
|
||||
|
||||
@@ -358,13 +358,30 @@ fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
||||
cands.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// 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; it is NOT a full fix
|
||||
/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the
|
||||
/// GameStream/Windows path already does via `set_launch_command`).
|
||||
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.
|
||||
pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
/// 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. The host serves one session at a
|
||||
/// time, so a process-global write is sound; the next connect re-detects and re-applies. Same
|
||||
/// `set_var` discipline already used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
|
||||
/// `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
|
||||
/// re-applies. Same `set_var` discipline used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
|
||||
#[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;
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||
@@ -455,6 +472,7 @@ pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_input_env(chosen: Compositor) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backend = match chosen {
|
||||
Compositor::Gamescope => "gamescope",
|
||||
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
||||
@@ -587,10 +605,10 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
|
||||
/// (gamescope's EIS server) for the input injector.
|
||||
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn gamescope_ei_socket_file() -> &'static str {
|
||||
gamescope::EI_SOCKET_FILE
|
||||
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
|
||||
gamescope::ei_socket_file()
|
||||
}
|
||||
|
||||
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||
|
||||
@@ -670,11 +670,11 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
}
|
||||
|
||||
/// Point the libei injector at the running gamescope's EIS socket (it reads the relay file
|
||||
/// [`EI_SOCKET_FILE`]). Best-effort — video still works without it (input just won't reach the
|
||||
/// [`ei_socket_file`]). Best-effort — video still works without it (input just won't reach the
|
||||
/// session). Shared by the attach and host-managed-session paths.
|
||||
fn point_injector_at_eis() {
|
||||
match find_gamescope_eis_socket() {
|
||||
Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) {
|
||||
Some(sock) => match std::fs::write(ei_socket_file(), &sock) {
|
||||
Ok(()) => {
|
||||
tracing::info!(socket = %sock, "gamescope: pointed injector at the session's EIS socket")
|
||||
}
|
||||
@@ -770,18 +770,31 @@ fn stop_session(unit_name: &str) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "stop", unit_name])
|
||||
.status();
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE);
|
||||
let _ = std::fs::remove_file(ei_socket_file());
|
||||
}
|
||||
|
||||
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket),
|
||||
/// read by the libei injector to drive input into the nested app. See [`crate::inject`].
|
||||
pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
|
||||
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket), read by
|
||||
/// the libei injector to drive input into the nested app. See [`crate::inject`].
|
||||
///
|
||||
/// Placed under `$XDG_RUNTIME_DIR` (a per-user, 0700 directory) — NOT a world-writable `/tmp` —
|
||||
/// so a second unprivileged local user can neither read the relayed socket path nor pre-plant the
|
||||
/// file to redirect the host's injector to a rogue EIS server (which would let them keylog or deny
|
||||
/// the remote session's keyboard/mouse input; security-review 2026-06-28 #6). Falls back to `/tmp`
|
||||
/// only if `XDG_RUNTIME_DIR` is unset (gamescope itself requires it, so this is rare); the reader
|
||||
/// ([`crate::inject`]) additionally rejects a symlinked relay file as defense-in-depth.
|
||||
pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
let runtime = crate::vdisplay::with_env_lock(|| std::env::var_os("XDG_RUNTIME_DIR"));
|
||||
match runtime {
|
||||
Some(rt) if !rt.is_empty() => std::path::PathBuf::from(rt).join("punktfunk-gamescope-ei"),
|
||||
_ => std::path::PathBuf::from("/tmp/punktfunk-gamescope-ei"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
||||
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
||||
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`]
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||
@@ -791,10 +804,15 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
let app = cmd
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())
|
||||
// Read the env fallback under the shared env lock so it can't race a concurrent session's
|
||||
// `set_var` of the same key (security-review 2026-06-28 #7).
|
||||
.or_else(|| {
|
||||
crate::vdisplay::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok())
|
||||
})
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session
|
||||
let relay = ei_socket_file();
|
||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||
let mut cmd = Command::new("gamescope");
|
||||
cmd.args(["--backend", "headless"])
|
||||
.args(["-W", &w.to_string()])
|
||||
@@ -804,7 +822,10 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result<Child> {
|
||||
.args([
|
||||
"sh",
|
||||
"-c",
|
||||
&format!("printf %s \"$LIBEI_SOCKET\" > {EI_SOCKET_FILE}; exec \"$@\""),
|
||||
&format!(
|
||||
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
|
||||
relay.display()
|
||||
),
|
||||
"sh",
|
||||
])
|
||||
.args(app.split_whitespace())
|
||||
@@ -997,7 +1018,7 @@ impl Drop for GamescopeProc {
|
||||
let _ = self.0.wait();
|
||||
// Clear the relayed EIS socket name so the host-lifetime injector can't reconnect to this
|
||||
// now-dead session's socket between sessions (the stale path is the "Connection refused").
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE);
|
||||
let _ = std::fs::remove_file(ei_socket_file());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,8 +271,11 @@ fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
|
||||
}
|
||||
});
|
||||
if let Some(pw) = password {
|
||||
if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() {
|
||||
eprintln!("warning: could not write {}", pw_path.display());
|
||||
// Create the file EMPTY first, lock its DACL, THEN write the secret — so the cleartext
|
||||
// password is never present at the inherited (Users-readable) %ProgramData% ACL, even for
|
||||
// the brief window before icacls runs (security-review 2026-06-28 #8).
|
||||
if std::fs::write(pw_path, b"").is_err() {
|
||||
eprintln!("warning: could not create {}", pw_path.display());
|
||||
return;
|
||||
}
|
||||
// Lock down: drop inheritance, grant only Administrators (S-1-5-32-544) + SYSTEM (S-1-5-18).
|
||||
@@ -287,6 +290,10 @@ fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
|
||||
"*S-1-5-18:F",
|
||||
],
|
||||
);
|
||||
// Now write the secret into the already-locked file (truncate keeps the explicit DACL).
|
||||
if std::fs::write(pw_path, format!("PUNKTFUNK_UI_PASSWORD={pw}\n")).is_err() {
|
||||
eprintln!("warning: could not write {}", pw_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,13 +114,15 @@ 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 = crate::gamestream::config_dir().join("logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
// 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 _ = crate::gamestream::create_private_dir(&dir);
|
||||
dir.join("service.log")
|
||||
}
|
||||
|
||||
fn host_log_path() -> PathBuf {
|
||||
let dir = crate::gamestream::config_dir().join("logs");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = crate::gamestream::create_private_dir(&dir);
|
||||
dir.join("host.log")
|
||||
}
|
||||
|
||||
@@ -684,7 +686,9 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
// DACL-lock the config dir on creation so a local user can't pre-create it and plant a
|
||||
// host.env (which feeds the SYSTEM service's env + command line) — security-review #3.
|
||||
crate::gamestream::create_private_dir(dir).ok();
|
||||
}
|
||||
let default = "# punktfunk host configuration (read by the Windows service).\n\
|
||||
# KEY=VALUE per line; '#' comments. Restart the service after editing:\n\
|
||||
@@ -707,7 +711,11 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
\n\
|
||||
# Force a specific render GPU by name substring (multi-GPU boxes only):\n\
|
||||
# PUNKTFUNK_RENDER_ADAPTER=4090\n";
|
||||
std::fs::write(&path, default).with_context(|| format!("write {}", path.display()))?;
|
||||
// Write host.env DACL-locked to SYSTEM/Administrators: it controls the SYSTEM service's
|
||||
// environment + launched command line, so a local user must not be able to read or tamper with
|
||||
// it (security-review 2026-06-28 #3).
|
||||
crate::gamestream::write_secret_file(&path, default.as_bytes())
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
println!("Wrote default config: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
# punktfunk host — security audit (2026-06-28, follow-up)
|
||||
|
||||
> **Status:** AUDIT COMPLETE (2026-06-28). Follow-up to the 2026-06-21 whole-project review
|
||||
> ([`security-review.md`](security-review.md)), scoped to the privileged streaming **host**
|
||||
> (`crates/punktfunk-host`) — re-verifying the prior 12 findings and hunting the code added since
|
||||
> (`library.rs` + store providers, `stats_recorder.rs`, `kwin_fake_input.rs`, session-watch /
|
||||
> Desktop↔Game follow, "launch apps on Windows/Linux non-gamescope hosts", "driver/web install into
|
||||
> the host exe"). Method: a multi-agent fan-out over **18 attack surfaces** (13 in pass 1 + 5
|
||||
> gap-driven in pass 2), every candidate finding **adversarially double-verified** from two
|
||||
> independent lenses (reachability/attacker-control + existing-mitigation/correctness), plus a
|
||||
> coverage-gap critic. **15 confirmed + 9 partial** issues carried; **8 refuted** recorded for
|
||||
> completeness. No memory-unsafety or RCE on attacker wire bytes was found; the residual risk is in
|
||||
> dependency hygiene, the opt-in GameStream surface, and Windows local-privilege ACLs.
|
||||
|
||||
## Remediation status (2026-06-28)
|
||||
|
||||
Fixes landed on `main` in `3532e35` (Linux/cross-platform, cargo check/clippy/test green here) and
|
||||
`6f903f7` (Windows `#[cfg(windows)]` DACL paths — verify in CI / on the RTX box; this Linux dev VM
|
||||
can't compile MSVC). Items whose fix would risk a validated pipeline, or that have no upstream
|
||||
remedy, are deferred/accepted with a reason.
|
||||
|
||||
| # | Sev | Status |
|
||||
|---|-----|--------|
|
||||
| S1 | High | **FIXED** (`3532e35`) — `quinn-proto` → 0.11.15 (RUSTSEC-2026-0185) |
|
||||
| #1 | High | **FIXED** (`3532e35`) — unauthenticated nvhttp `GET /pin` removed; PIN only via bearer mgmt API |
|
||||
| #2 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — mgmt token written via `write_secret_file` (SYSTEM/Admins DACL) |
|
||||
| #3 | High | **FIXED** (`6f903f7`, *Win CI/box pending*) — config dir DACL-locked + re-owned; `host.env` locked. Residual: a host.env planted before the very first DACL apply is still loaded (an owner-check on load is a noted follow-up) |
|
||||
| #4 | High→Med | **FIXED** (`3532e35`) — RTSP/PLAY gated on a paired `/launch` + bound to the launching peer's IP |
|
||||
| #5 | Med | **DEFERRED** — the shared-section SDDL is permissive for a restricted-token UMDF driver; scoping it needs on-box validation to avoid breaking the live-validated gamepad/IDD pipeline |
|
||||
| #6 | Med | **FIXED** (`3532e35`) — EIS relay moved to `$XDG_RUNTIME_DIR` (0700) + symlink reject |
|
||||
| #7 | Med→Low | **FIXED** (`3532e35`) — `vdisplay::ENV_LOCK` serializes setup-path env mutation (data-race UB closed); full per-session `SessionContext` threading for value-confusion is a follow-up |
|
||||
| #8 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — web-password file created empty → locked → written |
|
||||
| #9 | Low | **ACCEPTED** — disarm-on-any-attempt IS the documented single-online-guess (prior-fix #2); the delegated-approval flow is structurally immune. Steer hostile LANs to it |
|
||||
| #10 | Low | **FIXED** (`3532e35`) — ENet decrypt-failed warn throttled (exponential) |
|
||||
| #11 | Low | **FIXED** (`6f903f7`, *Win CI/box pending*) — logs dir DACL-locked (subsumed by #3) |
|
||||
| #12 | Low/Info | **FIXED** (`3532e35`) — parked pairing-waiter cap (+regression test) |
|
||||
| #13 | Info | **ACCEPTED** — `PENDING_CAP` + LRU + `requested_at` refresh make an actively-retrying device non-evictable |
|
||||
| S2 | Low–Med | **FIXED** (`3532e35`) — a malformed Opus frame drops the frame, keeps the shared mic open |
|
||||
| S3 | Low | **FIXED** (`3532e35`) — held buttons/keys are capped `HashSet`s |
|
||||
| S4 | Low | **FIXED** (`3532e35`) — Epic launcher-cache reads size-capped |
|
||||
| S5 | Low→Info | **FIXED** (`3532e35`) — `fps==0`/absurd rejected at the `open_video` chokepoint |
|
||||
| S6 | Low→Info | **FIXED** (`3532e35`) — shared mic mpsc bounded (drop-newest) |
|
||||
| S7 | Low→Info | **ACKNOWLEDGED** — `rsa 0.9` Marvin has no fixed upstream release; GameStream is off by default and this is a signing (not decryption-oracle) path. Migrate the GameStream identity to Ed25519/ECDSA when feasible |
|
||||
|
||||
**Net:** 14 of 18 fixed (5 Linux-verified clusters + 4 Windows DACL paths awaiting CI/box); #5
|
||||
deferred pending on-box validation; #9/#13 accepted-with-rationale; S7 acknowledged (no upstream fix).
|
||||
|
||||
## Consolidated overview & top priorities
|
||||
|
||||
The host's **core trust architecture remains sound**: native SPAKE2 pairing (single-use
|
||||
disarm-before-verify, CSPRNG PIN, sanitized device names, atomic+rollback persist), post-pair
|
||||
cert-pinning that verifies the real `CertificateVerify` signature, the management API authn/authz
|
||||
split (read-only-cert allowlist vs. bearer-gated mutations), uniformly bounds-checked client→host
|
||||
wire decoders (no reachable parse panic/OOB), memory-safe client-geometry→encoder/FFI paths, a clean
|
||||
driver-IPC ABI, and a fail-closed app-layer pairing gate. The new library/launch surface is notably
|
||||
well-defended against the network adversary (client ids resolve against the host's own catalog,
|
||||
argv-only, no shell, **no SSRF**). Most prior fixes are present and not regressed.
|
||||
|
||||
The real risk clusters in **three** places: (1) a **vulnerable QUIC dependency on the always-on
|
||||
default listener**, (2) the **opt-in GameStream/Moonlight compatibility surface** (two pre-auth
|
||||
boundary bypasses), and (3) **Windows `%ProgramData%` ACLs** (the prior secret-file fix did not cover
|
||||
the directory or two newer writers).
|
||||
|
||||
**Fix promptly (priority order):**
|
||||
|
||||
| P | Finding | Sev | Auth | Surface |
|
||||
|---|---------|-----|------|---------|
|
||||
| 1 | **S1** `quinn-proto 0.11.14` (RUSTSEC-2026-0185) → pre-auth remote memory-exhaustion DoS on the **default** `serve` QUIC listener | High | pre-auth | dep / native QUIC |
|
||||
| 2 | **#1** Unauthenticated GameStream `GET /pin` → full pre-auth self-pairing (consent bypass) → capture + input injection | High | pre-auth | GameStream (opt-in) |
|
||||
| 3 | **#2** Windows mgmt bearer token written without DACL — any local user reads the admin credential | High | local | secrets |
|
||||
| 4 | **#3** `%ProgramData%\punktfunk` dir + `host.env` not DACL-locked → local user → SYSTEM env/arg injection (LPE) | High | local | Windows service |
|
||||
| 5 | **#4** Pre-auth RTSP/UDP media plane has no pairing gate → desktop disclosure (portal) + stream-slot DoS | High→Med | pre-auth | GameStream (opt-in) |
|
||||
|
||||
**Medium:** **#5** Windows gamepad/IDD shared sections `Everyone:GENERIC_ALL` (local input-inject /
|
||||
screen read) · **#6** gamescope EIS socket via predictable `/tmp` relay (local keylog / input DoS) ·
|
||||
**#7** process-global env retargeting unsound under default concurrent sessions (`set_var`/`getenv`
|
||||
data-race UB → host-wide DoS; the live form of deferred prior-fix #7) · **S2** malformed client Opus
|
||||
frame tears down the shared host-lifetime virtual mic (cross-session DoS).
|
||||
|
||||
**Low / info:** **#8** `web-password` write-then-`icacls` TOCTOU · **#9** pairing-window-burn DoS ·
|
||||
**#10** ENet control-flood warn-log spam · **#11** SYSTEM `host.log` link-redirection (sub-case of
|
||||
#3) · **#12** legacy pairing no rate-limit · **#13** pending-approval queue flood · **S3** unbounded
|
||||
held-button/key `Vec` growth · **S4** unbounded read of Epic launcher caches · **S5** refresh/fps
|
||||
lower-bound unvalidated on the Hello path (self-inflicted single-session panic) · **S6** unbounded
|
||||
mpsc into the shared mic service · **S7** `rsa 0.9` Marvin advisory on the opt-in GameStream signing
|
||||
path (not practically reachable).
|
||||
|
||||
**Highest-leverage remediations** (each closes a cluster): (a) `cargo update -p quinn-proto
|
||||
--precise 0.11.15` + wire `cargo audit` into CI as a failing gate; (b) delete the unauthenticated
|
||||
nvhttp `/pin` and bind RTSP/PLAY to a paired `/launch` session; (c) DACL-lock the Windows config
|
||||
directory and route **all** config/secret writes through `write_secret_file`; (d) thread per-session
|
||||
launch/compositor/input env through `SessionContext` instead of process-global `std::env`.
|
||||
|
||||
---
|
||||
|
||||
The two passes' full verified detail follows verbatim (pass 1 = the 13-surface report; pass 2 = the
|
||||
supplement completing the native-protocol/unsafe-FFI surfaces + coverage-critic gaps), then the
|
||||
coverage-gap appendix.
|
||||
|
||||
---
|
||||
|
||||
# Pass 1 — 13-surface report
|
||||
|
||||
# punktfunk host — security audit (2026-06-28, follow-up)
|
||||
|
||||
**Status:** Follow-up audit of the privileged streaming host (`crates/punktfunk-host`), focused on code added since the 2026-06-21 review (`library.rs` + store providers, `stats_recorder.rs`, `kwin_fake_input.rs`, session-watch/Desktop-Game follow, the "launch apps on Windows/Linux non-gamescope hosts" path, and the "move driver/web install into the host exe" path), plus a regression re-verification of the prior twelve findings. Thirteen surface areas reviewed; every candidate finding was adversarially double-verified. **9 confirmed + 4 partial** issues are carried; **6 refuted** items are recorded for completeness.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The host's core trust architecture remains sound: the native SPAKE2 pairing ceremony, the post-pair mTLS cert-pinning model, the management API authn/authz split (read-only cert allowlist vs. bearer-gated mutations), and the RTSP/input/gamepad wire parsers are all carefully hardened and, where re-verified, the prior fixes are present and not regressed. The new game-library/launch surface is notably well-defended against the network adversary — client-supplied launch ids are resolved against the host's own scanned catalog, numeric/charset-validated, and spawned argv-based (no shell) on every non-operator path.
|
||||
|
||||
The real risks cluster in two places. **First, the opt-in GameStream/Moonlight compatibility surface (`serve --gamestream`) deviates from its own trust boundary in two pre-auth ways:** the legacy nvhttp `GET /pin` endpoint is completely unauthenticated, letting an unpaired LAN peer drive the *entire* pairing ceremony with no operator consent and obtain a persistent paired identity with full capture + input injection (Finding 1, the single highest-leverage issue); and the RTSP/UDP media plane performs no pairing/launch check at all, so an unpaired peer can start capture/encode and receive the desktop stream (Finding 4). Both are gated only by the opt-in `--gamestream` flag and the documented "trusted-LAN-only" posture — but within that supported mode they are genuine pre-auth bypasses of the pairing boundary that `/launch` otherwise enforces.
|
||||
|
||||
**Second, the Windows LocalSystem service has three local-privilege gaps rooted in one cause — the prior fix #1 hardened secret *files* but not the `%ProgramData%\punktfunk` *directory* or two newer files written into it.** The management bearer token is written with no Windows DACL (Finding 2), and `host.env` — which feeds the SYSTEM service's environment and command-line arguments — is neither DACL-locked nor is its directory (Finding 3). These give a local unprivileged user a path to the admin management plane and, via directory pre-creation / env injection, toward SYSTEM. On Linux/gamescope, a world-readable `/tmp` EIS-socket relay lets a second local user keylog or deny the remote session's input (Finding 6). The remaining items are lower-severity local IPC ACL over-breadth (gamepad shared memory), a concurrency-introduced `std::env::set_var` data race that is now reachable because concurrent native sessions became the default (Finding 7, the live form of deferred prior-fix #7), and pre-auth DoS edges.
|
||||
|
||||
Overall posture is good and improving; the GameStream pairing/media pre-auth bypasses and the Windows config-directory ACL gap are the items that warrant prompt remediation.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Surface | Title | Status |
|
||||
|---|----------|---------|-------|--------|
|
||||
| 1 | High | GameStream pairing | Unauthenticated nvhttp `GET /pin` → full pre-auth GameStream self-pairing (consent bypass) | Confirmed |
|
||||
| 2 | High | Secrets / mgmt | Windows mgmt bearer token written without DACL — local-user disclosure of host admin credential | Confirmed |
|
||||
| 3 | High | Windows service / config | `%ProgramData%\punktfunk` directory + `host.env` not DACL-locked → local user → SYSTEM env/arg injection | Confirmed (apps.json sub-vector: Partial) |
|
||||
| 4 | High→Med | GameStream RTSP/media | Pre-auth RTSP ANNOUNCE+PLAY starts capture/encode with no pairing gate (desktop disclosure + stream-slot DoS) | Partial |
|
||||
| 5 | Medium | Input injection | Windows host↔UMDF gamepad shared sections are `Everyone:GENERIC_ALL` — local cross-session input injection/tamper | Confirmed |
|
||||
| 6 | Medium | Session lifecycle (gamescope) | EIS socket path relayed via predictable world-accessible `/tmp` file — local keylog / input DoS | Confirmed |
|
||||
| 7 | Medium→Low | Session lifecycle | Process-global env retargeting unsound under now-default concurrent native sessions (data race + cross-session confusion) | Confirmed |
|
||||
| 8 | Low | Secrets | `web-password` written world-readable then `icacls`'d — brief TOCTOU disclosure | Confirmed |
|
||||
| 9 | Low | Native pairing | Unpaired LAN peer can burn the operator's single-use pairing window (pairing-ceremony DoS) | Confirmed |
|
||||
| 10 | Low | GameStream control | ENet control flood → unbounded per-packet warn-log spam (+ transient CPU) | Confirmed |
|
||||
| 11 | Low→Info | Windows service | SYSTEM `host.log` predictable name in Users-writable dir (link-redirection of SYSTEM appends) | Partial |
|
||||
| 12 | Low→Info | GameStream pairing | Legacy pairing has no rate-limit; parks unbounded 300 s waiters | Partial |
|
||||
| 13 | Info | Native pairing | Pending-approval queue floodable by LAN cert flood (eviction of a genuine knock) | Confirmed |
|
||||
|
||||
---
|
||||
|
||||
## Finding details (confirmed & partial)
|
||||
|
||||
### 1. [High] Unauthenticated nvhttp `GET /pin` enables full pre-auth GameStream self-pairing — *Confirmed*
|
||||
|
||||
- **Surface:** GameStream pairing ceremony / nvhttp.
|
||||
- **Refs:** `gamestream/nvhttp.rs:61`, `nvhttp.rs:85-96` (`h_pin`, plain-HTTP router), `gamestream/pairing.rs:40-43` (`PinGate::submit`), `pairing.rs:102-150` (`getservercert`), `pairing.rs:226-234` (phase 4 / `save_paired`), `crypto.rs:35-40` (`pin_key`).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `serve --gamestream`.
|
||||
- **Mechanism:** The GameStream PIN is the sole proof of operator consent (`aes_key = SHA-256(salt ‖ pin)`), and the host has no independent knowledge of the correct PIN — it derives the key from whatever is delivered to `PinGate::submit`. The operator-channel (`mgmt` `POST /api/v1/pair/pin`) is bearer-gated for exactly this reason, **but the host also exposes `GET /pin?pin=NNNN` on the unauthenticated nvhttp router with no auth and no `awaiting_pin` guard**, on `0.0.0.0:47989` (plain HTTP) and `:47984`. Because the attacker controls both the `getservercert` request (its own salt + cert) *and* can submit the PIN itself, it supplies both sides of the ceremony. There is no operator "arm pairing" gate for the legacy GameStream path (unlike native SPAKE2).
|
||||
- **Attack scenario:** (1) Attacker sends `GET /pair?phrase=getservercert&uniqueid=X&salt=<32hex>&clientcert=<own-cert-hex>` → parks on `pin.take(300s)`. (2) Attacker sends unauthenticated `GET /pin?pin=4242` → the parked `take()` returns it; host computes `aes_key = SHA-256(attacker_salt ‖ "4242")`, which the attacker also knows. (3) Attacker completes `clientchallenge`/`serverchallengeresp`/`clientpairingsecret` (all derivable — it knows the key and owns its cert); phase 4 pins the attacker cert via `save_paired`. (4) Attacker reconnects over HTTPS:47984 with its now-pinned cert; `peer_is_paired()` is true → `/launch` + `/applist` succeed → desktop capture and keyboard/mouse/gamepad injection on the privileged host. **No operator action at any step.**
|
||||
- **Existing mitigations:** GameStream is opt-in and documented "trusted-LAN only"; default `serve` does not start nvhttp. The post-pair launch surface is correctly gated by `peer_is_paired` — it just gets satisfied because the attacker self-pairs. None of these is a control on `/pin`.
|
||||
- **Verifier adjudication:** Both verifiers **confirmed reachable + attacker-controlled**, downgrading the original *critical* to **high** only because the surface is the opt-in, documented-weaker `--gamestream` mode (smaller affected population than the always-on native listener). This is **not** subsumed by accepted-risk #9 (which covers `/pair` being plain HTTP / a MITM brute-force, not unauthenticated PIN self-delivery).
|
||||
- **Recommendation:** Remove the unauthenticated nvhttp `GET /pin` endpoint entirely; PIN delivery must come only from the bearer-gated mgmt API. If a nvhttp delivery path must remain, require an explicit operator "arm GameStream pairing" step (mirror native `native_pairing` arm-on-demand) and bind the submitted PIN to that armed window. Ideally have GameStream pairing display a *host-generated* PIN the operator confirms, rather than accepting an arbitrary client-side PIN.
|
||||
|
||||
---
|
||||
|
||||
### 2. [High] Windows mgmt bearer token written without DACL lockdown — *Confirmed*
|
||||
|
||||
- **Surface:** Secret-file permissions / management authz. (Reported independently by two surface auditors; same defect.)
|
||||
- **Refs:** `mgmt_token.rs:59-71` (`write_token`), `mgmt_token.rs:40-44` (dir via `fs::create_dir_all`), `gamestream/mod.rs:251-261` (`config_dir` = `%ProgramData%\punktfunk`), `gamestream/mod.rs:282-285` (`create_private_dir` is a no-op for ACLs on Windows), `gamestream/mod.rs:293-347` (`write_secret_file`/`restrict_to_system_admins`).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows host only (Unix is correctly `O_CREAT 0600`).
|
||||
- **Mechanism:** The mgmt bearer token grants full admin authority over the management API. It is persisted by `write_token`, the **only** host secret writer that does not route through `write_secret_file → restrict_to_system_admins`; it applies a Unix `0600` mode but has **no `#[cfg(windows)]` arm**. On the LocalSystem service, `config_dir()` is `%ProgramData%\punktfunk`, whose inherited default DACL grants `BUILTIN\Users` read; `create_private_dir` applies no DACL on Windows and explicitly relies on each secret file being individually locked by `write_secret_file`. The token file is therefore left Users-readable. (The host key, cert, and both trust stores *are* locked — the token is the regressed outlier; the `write_secret_file` doc comment ironically claims it "Mirrors the mgmt-token hardening.")
|
||||
- **Attack scenario:** A local unprivileged user reads `C:\ProgramData\punktfunk\mgmt-token`, then presents `Authorization: Bearer <token>` to the loopback mgmt HTTPS API (default `127.0.0.1:47990`; self-signed cert trivially ignored). They now hold full admin authority: arm native pairing and read the PIN, approve their own device into the paired trust store, unpair/add clients, control sessions, and `POST /library/custom` with a `command` LaunchSpec that the host subsequently executes — a plausible path to code execution beyond the user's own privileges.
|
||||
- **Existing mitigations:** Default bind is loopback; API still requires HTTPS+bearer — but that bearer is exactly what leaks. The sibling `web-password` *is* `icacls`-hardened (`install.rs:280-289`), confirming this is a missed file, not a design choice.
|
||||
- **Verifier adjudication:** Both verifiers (across two surfaces) **confirmed at high**; this is the same class/severity as prior HIGH #1 (host key readable by any local user) and a genuine regression of that principle. `attacker_controlled=false` correctly reflects that this is a credential disclosure, not value injection.
|
||||
- **Recommendation:** Route the mgmt-token write through `gamestream::write_secret_file` (or call `restrict_to_system_admins` on the path after writing) and create the dir with `create_private_dir`'s Windows DACL. Re-tighten any pre-existing token file on startup.
|
||||
|
||||
---
|
||||
|
||||
### 3. [High] Windows config directory and `host.env` are not DACL-locked → local user → SYSTEM env/arg injection — *Confirmed* (apps.json sub-vector *Partial*)
|
||||
|
||||
- **Surface:** Windows LocalSystem service / config & discovery. (Merges the `host.env` finding and the config-directory finding — same root cause.)
|
||||
- **Refs:** `windows/service.rs:681-713` (`ensure_default_host_env` plain `std::fs::write`, skips if file `exists()`), `service.rs:159-180` (`load_host_env` `set_var`s *every* KEY=VALUE, not just `PUNKTFUNK_*`), `service.rs:301-302` (`format!("\"{}\" {host_cmd}", exe)` from `PUNKTFUNK_HOST_CMD`), `gamestream/mod.rs:264-286` (config dir never DACL-locked; `create_private_dir` no-op on Windows), `gamestream/apps.rs:40-95` + `stream.rs:140-145` (apps.json `cmd`).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows host only.
|
||||
- **Mechanism:** Secret *files* are individually `icacls`-locked, but the `%ProgramData%\punktfunk` *directory* is never DACL-restricted and `host.env` is written with a bare `std::fs::write`. Under the default `%ProgramData%` ACL, `BUILTIN\Users` inherit a container "create folders" right (and become `CREATOR OWNER` of subfolders they create). A non-admin who pre-creates the `punktfunk` subfolder before the elevated installer/service populates it owns it with full control and can plant `host.env`/`apps.json`; `ensure_default_host_env` then skips writing because the file already `exists()`. On service start, `load_host_env` injects every line of `host.env` into the SYSTEM process environment, and `supervise()` builds the SYSTEM child command line verbatim from `PUNKTFUNK_HOST_CMD`.
|
||||
- **Attack scenario / impact:** The surviving primitives (after verifier scrutiny) are: (a) **arbitrary SYSTEM-process environment injection** — e.g. set `PATH`/DLL-search vars in `host.env` to an attacker-writable directory and plant a hijackable DLL the SYSTEM host loads by name; (b) **attacker-controlled SYSTEM argv** to the fixed signed `punktfunk-host.exe`; (c) config-dir/trust-store tampering. Each independently sustains a **local privilege escalation toward NT AUTHORITY\SYSTEM**. The planted-`apps.json` `cmd` vector is weaker than originally stated: `launch_gamestream_command` → `interactive::spawn_in_active_session` runs the cmd under the **interactive console user** token (`WTSQueryUserToken`+`CreateProcessAsUserW`), not SYSTEM — so apps.json planting yields code execution *as the interactive user*, not SYSTEM.
|
||||
- **Verifier corrections:** The literal `PUNKTFUNK_HOST_CMD=... & malware.exe` shell-injection payload does **not** work — `spawn_host` uses `CreateProcessAsUserW` with no shell, so `&` is an inert argv token. Exploitation is gated on **directory pre-creation** (the punktfunk subfolder must be absent at attack time — fresh install before first launch, or a removed dir); on a normally installed box the elevated installer/SYSTEM service owns the dir and the default ACL grants Users *create-subdirectory* but not *create-file*, blocking overwrite of an existing admin-owned `host.env`. One verifier adjusted to **medium** on these grounds; the other held **high**. Carried at **high** because the env/arg-injection LPE primitives are real and the directory is genuinely never re-secured.
|
||||
- **Existing mitigations:** Secret files are DACL-locked individually; the elevated installer creates the dir in normal flows. GameStream/apps.json launch is opt-in and additionally needs a launch to occur.
|
||||
- **Recommendation:** Apply a restrictive DACL to the config directory at creation on Windows (`SYSTEM`/`Administrators` full + `CREATOR OWNER`, strip inheritance) inside `create_private_dir`; write `host.env` through `write_secret_file`; and refuse to load `host.env`/honor `PUNKTFUNK_HOST_CMD` (and trust `apps.json` `cmd`) unless the file/dir is owned by SYSTEM/Administrators.
|
||||
|
||||
---
|
||||
|
||||
### 4. [High→Medium] Pre-auth RTSP/UDP media plane has no pairing gate — *Partial*
|
||||
|
||||
- **Surface:** GameStream RTSP / video stream.
|
||||
- **Refs:** `gamestream/rtsp.rs:91` (`handle_conn`, no auth), `rtsp.rs:204-216` (ANNOUNCE writes `state.stream` unauthenticated), `rtsp.rs:218-239` (PLAY starts video on `Some(cfg) && !streaming.swap(true)`, never checks `state.paired`/`state.launch`), `gamestream/stream.rs:90-108` (UDP 47998 binds and `connect()`s the first pinger), `gamestream/mod.rs:214` (`rtsp::spawn` only under `--gamestream`).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `serve --gamestream`.
|
||||
- **Mechanism:** nvhttp gates `/launch`/`/applist`/`/resume`/`/cancel` on `peer_is_paired()`, but the RTSP listener (TCP 48010) and the UDP media planes are unauthenticated. `ANNOUNCE` stores a client-chosen `StreamConfig` (width/height/fps/codec/packetSize) with no auth; `PLAY` starts the video stream consulting neither `state.paired` nor `state.launch` (only the optional audio sub-stream requires the launch `gcm_key`). Video is sent in plaintext, so no key is needed. There is no per-launch session token and no binding between the paired nvhttp client and the RTSP/UDP peer (unlike Sunshine, which validates the launch session).
|
||||
- **Attack scenario:** Unpaired attacker sends a minimal ANNOUNCE SDP → host stores a config; sends PLAY → host spawns the video pipeline, detects the compositor, creates a virtual output / opens the encoder; sends any UDP datagram to 47998 → host `connect()`s there and streams. Net effects: (a) **pre-auth desktop disclosure** — full real-monitor leak on the `PUNKTFUNK_VIDEO_SOURCE=portal` path; on the recommended `virtual` source the attacker captures a *fresh blank* virtual output (no app, since `/launch` is pairing-gated), and the default source is a synthetic test pattern; (b) **unconditional pre-auth resource consumption** (forces virtual-output creation + GPU encode); (c) **stream-slot DoS** — `streaming.swap` allows only one stream, so an attacker can grab and hold the slot against legitimate clients (an in-progress legit session cannot be concurrently hijacked).
|
||||
- **Existing mitigations:** Opt-in `--gamestream`; documented trusted-LAN-only; `streaming.swap` single-stream lock; packetSize bounded `[64,2048]`; `encode::validate_dimensions` bounds ANNOUNCE width/height. **None is an authentication check on the media plane.**
|
||||
- **Verifier adjudication:** Both verifiers confirmed the bypass is real and unconditional; severity split **high vs. medium** turning on the capture source (portal = real-desktop leak → high; virtual/default = blank/test-pattern, leaving DoS + boundary bypass → medium). Carried at **high→medium**: the pairing authz boundary is unconditionally bypassed and the portal path leaks the real desktop, but the most-common `virtual` configuration limits disclosure.
|
||||
- **Recommendation:** Require a valid recent `/launch` session (set by a paired HTTPS client) before ANNOUNCE/PLAY will start a stream, and bind the RTSP/UDP peer to the launching client's address / a per-launch session secret (as Sunshine does). At minimum, refuse PLAY when `state.launch` is `None` and no paired client has an active session.
|
||||
|
||||
---
|
||||
|
||||
### 5. [Medium] Windows host↔UMDF gamepad shared sections are world-writable (`Everyone:GENERIC_ALL`) — *Confirmed*
|
||||
|
||||
- **Surface:** Input injection (Windows virtual-pad IPC).
|
||||
- **Refs:** `inject/windows/gamepad_raii.rs:43` (SDDL literal `D:(A;;GA;;;WD)`), `gamepad_raii.rs:37-81` (`Shm::create`), `inject/windows/dualsense_windows.rs:239`, `dualshock4_windows.rs:40`, `gamepad_windows.rs:158`; same SDDL at `capture/windows/idd_push.rs:245` (`Global\pfvd-*` frame textures).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows host only.
|
||||
- **Mechanism:** Every virtual-pad backend creates its host↔driver section in the kernel `Global\` namespace with a SECURITY_ATTRIBUTES built from `D:(A;;GA;;;WD)` — `WD` = Everyone (S-1-1-0), `GA` = GENERIC_ALL — and **no mandatory integrity label** (so the SYSTEM-created object defaults to medium IL / `NO_WRITE_UP` only). The host writes the live HID input report into `OFF_INPUT`; the privileged UMDF driver streams those exact bytes to games as virtual-controller input. The DACL grants full access to Everyone, so any interactive medium-IL local user can `OpenFileMapping("Global\pfds-shm-0", FILE_MAP_WRITE)` while a session has a pad active.
|
||||
- **Attack scenario:** A separate unprivileged local account (different session / fast-user-switch / RDP) opens the named section and overwrites `OFF_INPUT` with attacker-chosen button/stick/trigger values → the driver injects them into the streaming user's game. It can also corrupt the magic/`device_type` (DoS / device confusion) and observe the streaming user's input. The identical SDDL on `idd_push.rs` additionally lets any local user **read captured screen frames**.
|
||||
- **Existing mitigations:** `Global\` creation needs `SeCreateGlobalPrivilege`, preventing pre-creation/squatting — but **opening** an existing object only needs DACL access. The section exists only while a pad is active; keyboard/mouse use `SendInput` (not this channel), so injection is gamepad-only.
|
||||
- **Verifier adjudication:** Both verifiers **confirmed at medium** — genuine cross-session/cross-privilege input injection + IPC tamper + (via the shared SDDL) screen-content disclosure; bounded below high by being local-only, needing a concurrent local account and a live pad.
|
||||
- **Recommendation:** Scope the section DACL to exactly the principal the WUDFHost runs as (grant SYSTEM and the specific WUDF/driver service SID) instead of `Everyone`, and add a mandatory label / deny lower-IL writers (e.g. replace `WD` with the WUDFHost service account SID + `S:(ML;;NW;;;ME)`). Apply the identical fix to the `Global\pfvd-*` frame-texture sections in `capture/windows/idd_push.rs`.
|
||||
|
||||
---
|
||||
|
||||
### 6. [Medium] Gamescope EIS socket path relayed through a predictable, world-accessible `/tmp` file — *Confirmed*
|
||||
|
||||
- **Surface:** Session lifecycle / libei input injection (gamescope backend).
|
||||
- **Refs:** `vdisplay/linux/gamescope.rs:778` (`EI_SOCKET_FILE = /tmp/punktfunk-gamescope-ei`), `gamescope.rs:797` (`remove_file`, error ignored), `gamescope.rs:807` (`printf %s "$LIBEI_SOCKET" > /tmp/...`), `gamescope.rs:677` (`fs::write`), `inject/linux/libei.rs:298-345` (`connect_socket_file`: `read_to_string` + `UnixStream::connect`, no ownership/symlink/stat check), `libei.rs:193` (wiring).
|
||||
- **Threat actor:** Local unprivileged user (#4). Gamescope hosts only (Steam Deck / Bazzite gaming mode, or `PUNKTFUNK_COMPOSITOR=gamescope`). KWin/Mutter/Sway use D-Bus `ConnectToEIS` and are unaffected.
|
||||
- **Mechanism:** The nested session writes gamescope's `LIBEI_SOCKET` path to the fixed world-readable `/tmp/punktfunk-gamescope-ei`. The libei injector reads that file and `UnixStream::connect`s to whatever absolute path it contains — **with no verification that the file or target socket is owned by the host uid** — then streams the remote client's keyboard/mouse events to it as a libei client. EIS has no peer authentication, so a fake server captures the input stream. On sticky `/tmp` (1777), if a different uid pre-creates the relay file (owner=attacker, mode 0644), the host's `remove_file` and `> file`/`fs::write` truncate both fail (EPERM/EACCES, errors ignored), so the attacker's content survives and the host connects to the attacker's socket. `stop_session` removes the host-owned file on each teardown, giving a recurring re-plant window.
|
||||
- **Attack scenario:** Local attacker runs `echo /home/attacker/evil.sock > /tmp/punktfunk-gamescope-ei` (0644) and listens on `evil.sock` as an EIS server. When a remote client streams, the injector connects there instead of gamescope's real EIS; every keystroke/pointer event the remote user sends (game/Steam input, typed credentials) is delivered to the attacker, and gamescope receives no input (input DoS).
|
||||
- **Existing mitigations:** The real EIS socket lives under `XDG_RUNTIME_DIR` (0700) — but only its *path* is leaked/overridable via the `/tmp` relay. `protected_symlinks` does not help (regular file, not symlink). The injector retries only `ConnectionRefused`/`NotFound`; a live attacker socket returns `Ok` and is trusted.
|
||||
- **Verifier adjudication:** Both verifiers **confirmed at medium**. Impact is high (full remote keystroke capture incl. credentials, plus input DoS) but local-only, gamescope-backend-only, and most gamescope deployments are single-user, capping practical likelihood.
|
||||
- **Recommendation:** Relay the EIS path through a host-private location (a file in `XDG_RUNTIME_DIR`, 0700, created `O_EXCL`) instead of `/tmp`, and/or `stat` the relay file and reject it unless owned by the host uid, mode ≤0644, not a symlink, before reading. Apply the same hardening to the predictable world-readable `/tmp/punktfunk-gamescope.log`.
|
||||
|
||||
---
|
||||
|
||||
### 7. [Medium→Low] Process-global env retargeting is unsound under now-default concurrent native sessions — *Confirmed*
|
||||
|
||||
- **Surface:** Session lifecycle / library-launch. (Merges the "native concurrent launch-env race" and the "apply_session_env/apply_input_env" findings — one root cause; the live, generalized form of deferred prior-fix #7.)
|
||||
- **Refs:** `punktfunk1.rs:150` (`DEFAULT_MAX_CONCURRENT=4`), `punktfunk1.rs:254` (Semaphore), `punktfunk1.rs:612` (`std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)`), `punktfunk1.rs:1871`/`1885` (`apply_session_env`/`apply_input_env` calls), `vdisplay.rs:367-397`/`457-485` (env setters), `vdisplay/linux/gamescope.rs:791-794` (reads the global env), `punktfunk1.rs:600`/`vdisplay.rs:363-365` (stale "ONE-session-at-a-time" comments).
|
||||
- **Threat actor:** Malicious network client, **post-auth** (#2, paired/trusted-tier).
|
||||
- **Mechanism:** The native host now serves up to 4 concurrent sessions by default, yet the per-session handshake mutates *process-global* environment via `std::env::set_var` (resolved launch id into `PUNKTFUNK_GAMESCOPE_APP`; plus `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS`/`PUNKTFUNK_INPUT_BACKEND`/etc. via `apply_session_env`/`apply_input_env`). These run inside `spawn_blocking` for each concurrent handshake and are then read by backends/injectors at open time. The in-code invariant ("the host serves one session at a time, so a process-global write is sound") is now false. Two effects: (1) **concurrent `set_var` while another thread `getenv`s is documented UB in Rust** (glibc `environ` realloc) → potential host-wide crash taking down all live sessions; (2) session B's handshake overwrites the env session A's gamescope-spawn/injector is about to read → A launches B's (operator-approved) title or routes input to B's backend.
|
||||
- **Attack scenario:** Two paired clients connect concurrently (or one reconnects in a tight loop while another session is active). The racing `set_var`/`getenv` can abort the host (DoS affecting all sessions); concurrently A's session can be mispointed.
|
||||
- **Verifier adjudication:** Both **confirmed** the technical defect; severity split **medium vs. low**. The cross-session *launch/input misrouting* grants no new authority (both peers are already authorized to view/inject on the shared desktop; the `uid` filter prevents cross-user selection), so under "only NEW authority counts" it is a correctness bug. The surviving security impact is the **`set_var`/`getenv` data-race UB → non-deterministic host-wide DoS**, triggerable by an already-paired device. Carried at **medium→low** accordingly.
|
||||
- **Existing mitigations:** Pairing gate runs before `resolve_compositor` (post-auth). `detect_active_session` filters `/proc` by the host's own uid (no cross-user selection). Most env writes are gated to auto-detect mode (skipped when `PUNKTFUNK_COMPOSITOR` is set). No lock serializes the env writes, and there is no per-session config object for these knobs (unlike the Windows/GameStream `SessionContext.launch`).
|
||||
- **Recommendation:** Stop using process-global env on the per-session path. Thread launch command, compositor, input-backend, and session env into the per-session `VirtualDisplay`/`SessionContext` (as GameStream already does via `set_launch_command`) and pass them as explicit args to backend/injector open calls. At minimum serialize all `set_var` writes + dependent backend-open under one mutex, or force `max_concurrent=1` while the auto env-retargeting state machine is active.
|
||||
|
||||
---
|
||||
|
||||
### 8. [Low] `web-password` written world-readable then `icacls`'d — brief TOCTOU disclosure — *Confirmed*
|
||||
|
||||
- **Surface:** Secret-file permissions (Windows install).
|
||||
- **Refs:** `windows/install.rs:273-290`.
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows, install/upgrade time only.
|
||||
- **Mechanism:** `set_web_password` writes the cleartext `PUNKTFUNK_UI_PASSWORD=<pw>` via `std::fs::write` (creating the file at the inherited Users-readable `%ProgramData%` ACL) and only *afterward* strips inheritance with `icacls`. Between the write and the `icacls` child-process completion (a full process spawn = a race-winnable window) the web-console login password is readable by any local user.
|
||||
- **Attack scenario:** A local user polling `%ProgramData%\punktfunk` during a fresh install reads `web-password` before `icacls` applies, obtaining the web-console login credential.
|
||||
- **Existing mitigations:** Window is fresh-install-only (on upgrade the existing file's locked DACL is preserved across a truncating write, so no window reopens — the "upgrade rewrites the password" sub-claim does not hold); install is operator-initiated and one-time; `icacls` locks immediately after; impact limited to web-console access.
|
||||
- **Verifier adjudication:** One verifier **confirmed low**; the other adjusted to **info**, noting the write-then-`icacls` pattern is the established Windows secret pattern (used by `write_secret_file` for far higher-value secrets), so the "anomalously non-atomic" framing is overstated and this is the lowest-value secret affected. Carried at **low**.
|
||||
- **Recommendation:** Create the file with a restrictive DACL atomically (`CreateFile` with a SECURITY_DESCRIPTOR, or write to a per-process temp under an already-locked dir then rename), or write empty + `icacls` before writing the secret bytes.
|
||||
|
||||
---
|
||||
|
||||
### 9. [Low] Unpaired LAN peer can burn the operator's single-use pairing window — *Confirmed*
|
||||
|
||||
- **Surface:** Native SPAKE2 pairing.
|
||||
- **Refs:** `punktfunk1.rs:459` (`np.disarm()` before proof verification), `punktfunk1.rs:438` (`pake.finish` accepts a wrong-PIN message), `punktfunk1.rs:517-531` (cooldown / `current_pin()`), `native_pairing.rs:216-218` (`disarm`), `quic.rs:1581` (`AcceptAnyClientCert`).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Native path, while pairing is armed.
|
||||
- **Mechanism:** The single-use design disarms the PIN on *any* well-formed pairing attempt, **before** verifying the guess (the disarm-before-verify behavior is exactly prior-fix #2, which gives the single-online-guess guarantee). `pake.finish()` does not reject a wrong-PIN `spake_a` (only malformed messages), so an unpaired peer with a self-signed cert and a SPAKE2 message built from any random PIN guess reaches `disarm` and consumes the window without knowing the PIN.
|
||||
- **Attack scenario:** Operator arms pairing; an attacker polling the QUIC port every ~2 s (the `PAIRING_COOLDOWN`) lands an attempt inside the ~120 s armed window; the host disarms. The legitimate device then submits the real PIN and is told "pairing not armed." Repeat indefinitely.
|
||||
- **Existing mitigations:** Availability-only (1/10000 chance a blind guess actually pairs — the documented single online guess). The attack only works *while a window is armed* (outside it, `current_pin()` is `None` and the handshake bails before touching disarm), so it cannot permanently disable pairing — it races an open window. **The delegated-approval flow (knock → console approve) is structurally immune** and remains usable on hostile LANs.
|
||||
- **Verifier adjudication:** One verifier **confirmed low**; the other adjusted to **info** as a self-acknowledged, in-code-documented design tradeoff with an immune fallback. Carried at **low**.
|
||||
- **Recommendation:** Prefer the delegated-approval flow on hostile LANs (already immune). Document that PIN arming should be brief. If retaining PIN arming, consider only consuming the window on a key-confirmation match when the failure is observable (trading some brute-force resistance for availability).
|
||||
|
||||
---
|
||||
|
||||
### 10. [Low] ENet control flood → unbounded per-packet warn-log spam — *Confirmed*
|
||||
|
||||
- **Surface:** GameStream ENet control plane.
|
||||
- **Refs:** `gamestream/control.rs:84`/`161` (`on_receive`), `control.rs:186` (per-packet `tracing::warn!`, no throttle), `control.rs:316`/`347-378` (decrypt + scheme sweep), `control.rs:79` (`detected` reset on Disconnect).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `--gamestream` and an active paired session.
|
||||
- **Mechanism:** The ENet control host (UDP 47999, `peer_limit=4`) accepts unauthenticated connections. Once a paired client has launched (global `gcm_key` set), any `0x0001`-prefixed packet with a ≥16-byte payload that fails to authenticate emits one `tracing::warn` per packet with **no rate limit or sampling**. The full ~72-candidate GCM scheme-sweep runs only while `detected` is `None` (a transient window; the attacker can reset it via its own Disconnect but steady state is one GCM op + one warn per packet).
|
||||
- **Attack scenario:** With a paired session active, an attacker ENet-connects and floods junk `0x0001` packets → unbounded warn-log lines (disk/observability pressure) + intermittent CPU.
|
||||
- **Existing mitigations:** `peer_limit=4`; the expensive sweep is `detected`-gated; AES-GCM open on tiny buffers is microseconds; input injection itself stays cryptographically gated on the HTTPS-delivered `gcm_key` (no forgery). Opt-in, trusted-LAN.
|
||||
- **Verifier adjudication:** **Confirmed**; the "per-packet GCM brute-force" framing is largely neutralized by the `detected` fast-path, but the **unthrottled per-packet warn log** is genuinely unmitigated. Low severity (DoS/observability only, no injection or memory unsafety).
|
||||
- **Recommendation:** Throttle/aggregate the "GCM decrypt failed" warning (sampled, not per-packet) and drop a peer after N consecutive auth failures; optionally skip the scheme-sweep for a peer that has produced no authenticating packet.
|
||||
|
||||
---
|
||||
|
||||
### 11. [Low→Info] SYSTEM `host.log` opened with predictable name in a Users-writable directory — *Partial*
|
||||
|
||||
- **Surface:** Windows service / logging.
|
||||
- **Refs:** `windows/service.rs:121-125` (logs dir via plain `create_dir_all`), `service.rs:574-602` (`open_log_handle`, `OPEN_ALWAYS`, append-only, inheritable, `FILE_SHARE_READ|WRITE`).
|
||||
- **Threat actor:** Local unprivileged user (#4). Windows.
|
||||
- **Mechanism:** The SYSTEM service opens `%ProgramData%\punktfunk\logs\host.log` and redirects the host child's stdout/stderr to it. The logs dir lives under the non-DACL-locked config tree (Finding 3). A local user able to create files there could pre-create `host.log` as an NTFS hardlink to an attacker-chosen target, causing SYSTEM's appends to land on that target.
|
||||
- **Impact:** Limited integrity: SYSTEM appends *attacker-uncontrolled* log text (append-only handle — no truncation, no chosen-offset writes) to an attacker-chosen file. No content control → no realistic code-exec path; a log-tamper/nuisance/DoS primitive at most.
|
||||
- **Verifier adjudication:** Both verifiers found the redirect-*target* control hinges on a non-admin holding `FILE_ADD_FILE` on a SYSTEM-created subdir, which the default `%ProgramData%` ACL does **not** grant (Users get create-subfolder, not create-file). The only residual is the same **pre-install directory-squatting** edge as Finding 3, and even then the writes are append-only uncontrolled text. One verifier **partial/low**, one **partial/info**. Effectively a sub-case of Finding 3.
|
||||
- **Recommendation:** Fixing Finding 3 (DACL-lock the config/logs dir to SYSTEM+Administrators) fixes this. Optionally open the log rejecting reparse points / create the dir with a restrictive DACL before first write.
|
||||
|
||||
---
|
||||
|
||||
### 12. [Low→Info] Legacy GameStream pairing has no rate-limit and parks unbounded 300 s waiters — *Partial*
|
||||
|
||||
- **Surface:** GameStream pairing.
|
||||
- **Refs:** `gamestream/pairing.rs:102-127` (`getservercert` parks `pin.take(300s)`), `pairing.rs:50-60` (`WaiterGuard`), `nvhttp.rs:215-244` (unauthenticated `/pair` route, no rate limit / connection cap).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1). Requires `--gamestream`.
|
||||
- **Mechanism:** `/pair?phrase=getservercert` is reachable pre-auth with an attacker-chosen `uniqueid` and no per-IP/global rate limit; each parks a tokio task for up to 300 s and keeps `awaiting_pin` asserted. The HTTP server has no connection cap (bare `axum_server::bind`).
|
||||
- **Verifier adjudication:** Both verifiers **confirmed the no-rate-limit/parked-waiter core but refuted the alarming "unbounded never-evicted HashMap"** sub-claim — the `sessions` insert is downstream of a successful `pin.take()`, which requires an operator-delivered PIN, so the map grows at most one entry per PIN submission (not attacker-driven). The residual is a bounded (300 s self-heal, cheap tasks), opt-in slow-loris + `awaiting_pin` nuisance on a surface already covered by accepted-risk #5/#9, plus a minor enlargement of the Finding-9-class PIN race. Both adjusted to **info**.
|
||||
- **Recommendation:** Add a per-source-IP / concurrent-handshake cap on pairing attempts and evict the per-`uniqueid` session on success/timeout (not only on failure).
|
||||
|
||||
---
|
||||
|
||||
### 13. [Info] Pending-approval queue floodable by a LAN cert flood — *Confirmed*
|
||||
|
||||
- **Surface:** Native pairing / delegated-approval queue.
|
||||
- **Refs:** `native_pairing.rs:336-357` (`note_pending`, `PENDING_CAP=32` eviction of least-recently-active), `native_pairing.rs:81-83` (cap + 10-min TTL), `punktfunk1.rs:566` (called per unpaired knock, no per-source rate limit).
|
||||
- **Threat actor:** Malicious network client, **pre-auth** (#1).
|
||||
- **Mechanism:** `note_pending` is called for every unpaired-but-identified knock with no per-source rate limit; past 32 entries the least-recently-active is evicted. An attacker minting >32 distinct self-signed certs can churn the queue, potentially evicting a quiet legitimate knock before the operator approves it.
|
||||
- **Verifier adjudication:** One **confirmed info**, one **refuted** — the in-place refresh resets `requested_at` on every same-fingerprint re-knock, so an actively-retrying legitimate device is structurally non-evictable; only a one-shot knock-and-wait device is at risk and it recovers instantly by re-knocking. Each junk slot costs a full QUIC handshake; no trust-store/PIN/key impact. Carried at **info** (transient self-healing availability nuisance on the convenience queue only).
|
||||
- **Recommendation:** Optionally cap pending entries per source IP/subnet, or surface a "pending overflow" indicator. Low priority.
|
||||
|
||||
---
|
||||
|
||||
## Prior-fix verification (#1–#12)
|
||||
|
||||
- **#1 — HIGH (secret-file perms 0600/0700 Unix; SYSTEM+Admins DACL Windows): PRESENT but INCOMPLETE — regressed for two newer files.** The core helpers `create_private_dir` (0700 Unix) and `write_secret_file`/`restrict_to_system_admins` (Unix 0600 + Windows `icacls` SYSTEM/Admins/OWNER) are correct and used for `key.pem`, `cert.pem`, GameStream `paired.json`, native `punktfunk1-paired.json`, and `web-password` (all atomic temp+rename, no world-readable window, never logged). **Gaps:** the mgmt-token writer (`mgmt_token.rs:write_token`) hardens only `cfg(unix)` and never applies the Windows DACL (**Finding 2**); `host.env` is written with a bare `std::fs::write` and the Windows config *directory* is never DACL-locked (**Finding 3**); `web-password` has a brief write-then-`icacls` TOCTOU window (**Finding 8**). Non-secret files (`uniqueid`, `library.json`, art cache, stats captures) carry no key material — acceptable.
|
||||
- **#2 — HIGH (native SPAKE2 PIN single-use): VERIFIED INTACT.** `np.disarm()` runs unconditionally before reading the client proof (`punktfunk1.rs:459`); a malformed `spake_a` errors earlier but makes no guess. The global `PAIRING_COOLDOWN` (2 s) + per-attempt `current_pin()` close the concurrency TOCTOU; CSPRNG PIN; CLI arm-at-start is also consumed. No path leaves a static reusable PIN. (The single-use design's only side effect is the availability edge of **Finding 9**.) *Caveat:* the **legacy GameStream** `PinGate` is a separate mechanism — `PinGate::take()` consumes the PIN and the mgmt path guards `awaiting_pin()`, but the nvhttp `/pin` path does **not** guard and is unauthenticated (**Finding 1**).
|
||||
- **#3 — MED (RTSP packetSize clamp + saturating packetizer): VERIFIED PRESENT.** `rtsp.rs:330-339` rejects packetSize outside `64..=2048`; `video.rs:63` clamps `payload_per_shard` so all divisors are ≥1 (regression test `degenerate_packet_size_does_not_panic`).
|
||||
- **#4 — LOW (mgmt mTLS cert restricted to read-only allowlist): VERIFIED COMPLETE.** `cert_may_access` (`mgmt.rs:514-528`) is GET-only over an exact-path set excluding every state-changing/pairing/stats route; all `/api/v1` routes share `route_layer(require_auth)`; cert branch additionally requires `native.is_paired(fp)`. No streaming cert can read the PIN, self-approve, mutate the library, or reach `/stats/*`. Not regressed by any newly-added route.
|
||||
- **#5 — LOW ACCEPTED (legacy control-stream GCM nonce reuse): UNCHANGED.** Still legacy/Moonlight-compat (`control.rs:108-117`); not reachable on the default `serve` path. Not re-flagged.
|
||||
- **#6 — LOW (RTSP header/Content-Length caps + read timeout + connection cap): VERIFIED PRESENT.** `MAX_RTSP_CONNS=8`, `RTSP_READ_TIMEOUT=15s`, 16K header / 64K body / 128K message caps enforced; `ConnGuard` releases the slot on panic.
|
||||
- **#7 — LOW PARTIAL (per-session launch command; native path used a process-global env): STILL UNRESOLVED and now REGRESSED IN IMPACT.** The native path still does `std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd)` and the gamescope backend reads that global; the in-code "ONE-session-at-a-time" justification is invalidated by `DEFAULT_MAX_CONCURRENT=4`. The GameStream/Windows path correctly threads launch into a per-session `SessionContext`. This is now **Finding 7** (generalized to the whole env-retargeting state machine + a `set_var`/`getenv` data race).
|
||||
- **#8 — INFO (GameStream phase-4 hash compare constant-time): VERIFIED PRESENT.** `pairing.rs:228` uses `crypto::ct_eq`, a proper no-early-exit fold; `hash_ok` and `sig_ok` are both computed before branching. Mgmt `token_eq` similarly SHA-256-hashes both sides.
|
||||
- **#9 — INFO ACCEPTED (/pair over plain HTTP): UNCHANGED** as a transport matter. **Note:** the *unauthenticated `/pin` self-delivery* (Finding 1) is a distinct, newly-surfaced defect, **not** subsumed by #9.
|
||||
- **#10 — INFO (fixed ALPN `pkf1` on QUIC): VERIFIED PRESENT.**
|
||||
- **#11 — INFO (FEC reconstruct failure = drop not fatal): VERIFIED PRESENT.** Host encode uses `encode(...).unwrap_or_default()`; audio returns `None` to skip a block; no fatal path.
|
||||
- **#12 — LOW DEFERRED (web `NODE_TLS_REJECT_UNAUTHORIZED`): out of host scope, not examined.**
|
||||
|
||||
## Refuted / investigated — not vulnerabilities
|
||||
|
||||
- **PinGate PIN not bound to uniqueid/cert (confused-deputy PIN theft) — *refuted.*** The global PIN-slot race is real (enables a pairing DoS, folded into Finding 9's class), but the escalation is cryptographically impossible: in GameStream the PIN is *generated and displayed by the Moonlight client* and the host never echoes it, so a racing attacker consumes the PIN-submission *event* but never learns the PIN *value*; without it the phase-2/4 hash + RSA checks fail closed. No paired identity gained.
|
||||
- **Attacker-chosen device name in the approval queue (trusted-device impersonation) — *refuted.*** The unpaired knock is hard-rejected; the fingerprint (the value actually pinned) is displayed alongside the sanitized name, and bidi/control/homoglyph chars are stripped. Approval requires a bearer-authenticated human; "approving on the label without reading the fingerprint" is social engineering inherent to any human-in-the-loop pairing, with the standard mitigation already present.
|
||||
- **Lutris cover-art slug path traversal — *refuted.*** The `..`-joined read is real, but `slug` originates from the host user's own `~/.local/share/lutris/pga.db` (a same-user local file), not controllable by any in-scope network/MITM/local-unpriv adversary; the disclosure recipient is an already-paired client with strictly greater authority, and the read is `.jpg`-only, ≤1 MiB. Charset-validating the slug is worthwhile defense-in-depth.
|
||||
- **Privileged install invokes system tools by bare name (PATH/CWD hijack) — *refuted.*** Premise is wrong for the Rust toolchain: `std::process::Command` resolves the executable itself, searches `System32` *before* the CWD, and never searches the spawning process's directory. All cited tools are System32 binaries, so a planted CWD copy loses. Using absolute `%SystemRoot%\System32\…` paths is reasonable consistency hardening but addresses no reachable threat.
|
||||
- **`uniqueid`/mgmt-token create the config dir with `create_dir_all` (brief 0755) — *refuted.*** Every secret file is written 0600/DACL-locked regardless of directory mode; the only non-secret file (`uniqueid`) is a public serverinfo identifier; on Linux the dir is under the owning user's per-user home; `create_private_dir` later tightens it to 0700. Code-consistency cleanup, no disclosure.
|
||||
- **Unbounded on-disk stats capture files — *refuted.*** Every `/stats/*` route is bearer-token-gated (excluded from the cert allowlist); the captures dir is 0700; the file id is host-generated. No pre-auth, post-auth, MITM, or local-unpriv path can create captures — only the trusted operator over their own disk. Pruning/streamed `list()` parsing is a reasonable operational improvement, not a security fix.
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
1. **GameStream/Moonlight compatibility is the soft underbelly.** Both pre-auth bypasses (Findings 1, 4) and the control-plane DoS (Finding 10) live exclusively on the opt-in `--gamestream` surface, whose authz model is weaker by design (accepted-risk #5/#9). The native punktfunk/1 plane is markedly stronger. The two genuinely new pre-auth issues — unauthenticated `/pin` self-pairing and the ungated RTSP media plane — are *bypasses of GameStream's own `peer_is_paired` boundary*, not inherent-protocol weaknesses, and are fixable without breaking stock-Moonlight compatibility.
|
||||
2. **Prior-fix #1 hardened secret *files* but not the Windows config *directory* or two files added since.** Findings 2, 3, 8, 11 all trace to the `%ProgramData%\punktfunk` ACL gap plus the bespoke `write_token`/`std::fs::write` paths that bypass `write_secret_file`. A single remediation — DACL-lock the config directory and route *all* config writes through `write_secret_file` — closes most of the Windows local-privilege surface.
|
||||
3. **Concurrency outgrew single-session assumptions.** Finding 7 (and the regressed prior-fix #7) is the codebase shipping default `max_concurrent=4` while per-session state still uses process-global `std::env` mutation written under a one-session invariant. The `SessionContext`/`set_launch_command` pattern already used on the Windows/GameStream path is the correct fix to generalize.
|
||||
4. **Local IPC and temp-file trust.** The Windows gamepad/IDD shared sections (`Everyone:GENERIC_ALL`, Finding 5) and the Linux gamescope EIS `/tmp` relay (Finding 6) both trust a local channel that a second unprivileged account can read/write. Scope DACLs to the consuming principal and move relays into owner-private runtime dirs.
|
||||
|
||||
## Security controls done right (positives)
|
||||
|
||||
- **Native SPAKE2 pairing is well-hardened:** single-use disarm-before-verify, global cooldown, atomic+rollback persist, fail-closed load, CSPRNG PIN, device-name sanitization (C0/C1 + bidi/format stripped, 64-char cap) at every sink, with regression tests. No path lets an unpaired peer self-approve, read the PIN, or poison the trust store.
|
||||
- **Post-pair cert-pinning is sound:** the TLS layer verifies the `CertificateVerify` signature (key ownership) even though it "accepts any" cert at handshake, and `peer_is_paired` pins SHA-256(DER) against the saved cert — a stolen public cert cannot impersonate a paired client.
|
||||
- **Management authz is solid:** every `/api/v1` route gated (even on loopback), `run` refuses to start without a token, loopback-default bind, constant-time (SHA-256-hashed) token compare, 256-bit token entropy, no cookie/CSRF surface, and a correct read-only-cert vs. bearer-mutation split.
|
||||
- **The new library/launch surface is strong against the network adversary:** client ids resolve against the host's own scanned catalog (never client-supplied launch strings), Steam appids are digit-validated, Heroic/Epic/AUMID values charset-validated, all non-operator spawns are argv-based with no shell, and the only `cmd.exe /c`/`sh -c` sinks consume operator-typed input only. No SSRF in the cover-art warmer (fixed trusted hosts, ids in the path component only). XML/JSON/VDF parsers are entity-expansion-safe.
|
||||
- **Wire parsers are memory-safe:** RTSP has connection caps, read timeouts, header/body/message caps, and clamps every attacker-controlled numeric; the video packetizer is structurally panic-proof; input/gamepad decoders are fully `.get()`-bounded with `idx < MAX_PADS` checks; DualSense/DS4 output-report parsers bounds-check before indexed reads.
|
||||
- **The stats-capture surface is clean:** bearer-only routes, host-generated path-safe ids with traversal rejection (tested), 0700 captures dir, bounded samples, lock-serialized hot-path feed, and host-derived (non-free-form) metadata fields.
|
||||
- **Session/cross-user isolation holds:** the Desktop↔Game follow watcher and `detect_active_session` filter `/proc` strictly by the host's own uid, so a session can never follow or expose a different user's compositor; per-session virtual-output/encoder teardown is sound RAII (no monitor/FD/zombie leaks); `--max-concurrent` genuinely caps concurrency.
|
||||
- **Windows service launch hygiene:** fully-quoted `current_exe` binPath with fixed args (no unquoted-service-path), correct token scoping (drops to the user token for store launchers/WGC, retains SYSTEM only for our own streamer), anonymous inherited pipes for the host↔helper channel, and no command line built from network input.
|
||||
|
||||
---
|
||||
|
||||
## Supplement (2026-06-28, follow-up pass 2 — completed surfaces + coverage-critic gaps)
|
||||
|
||||
### (a) Summary
|
||||
|
||||
This pass closes the two finders that failed in the main audit (native protocol; unsafe FFI — here split into control-plane, data-plane, encode/capture, and driver-IPC) and the three coverage-critic gaps (mic/Opus → virtual mic + cross-session isolation; `main.rs` default-security posture + dependency RUSTSEC; cover-art outbound egress/SSRF). The headline answers: **the native control plane is fail-closed for unpaired peers at the application layer** — `serve_session` rejects anonymous/unpaired clients before any session machinery (`punktfunk1.rs:544-573`) — **but the QUIC *transport* underneath is not**, and it is the only genuinely pre-auth crown-jewel-adjacent exposure found here: `quinn-proto 0.11.14` (RUSTSEC-2026-0185, CVSS 7.5 unbounded out-of-order reassembly) is reachable by any unpaired peer who completes the 1-RTT handshake with a throwaway cert *before* the pairing gate runs → remote memory-exhaustion DoS of the always-on default listener. **Client geometry is well bounds-checked** (W/H caps applied on Hello, Reconfigure, and ANNOUNCE; Opus mic buffer math is exact; gamepad/touchpad indices clamped) with one consistent gap: the **refresh/fps lower bound is unvalidated on the initial Hello path** (the Reconfigure path guards it), yielding at worst a self-inflicted single-session divide-by-zero panic. **Cover-art egress is SSRF-safe against every in-scope adversary** (hardcoded hosts, id only in the path segment, TLS verification on); the only residual is an out-of-scope supply-chain redirect-follow. **The rsa 0.9 Marvin oracle is not practically reachable** — it is a signing path (not the classic PKCS#1v1.5 decryption oracle), on the opt-in trusted-LAN-only GameStream plane. The mic/Opus surface adds one real cross-session defect: a malformed Opus frame tears down the single host-lifetime virtual mic shared by all concurrent sessions. The driver-IPC surface is **memory-safe and clean** (the only weakness is the already-reported world-writable section ACL).
|
||||
|
||||
### (b) Confirmed and partial findings
|
||||
|
||||
#### S1 — Pre-auth remote memory exhaustion via vulnerable `quinn-proto 0.11.14` on the always-on native QUIC control plane (RUSTSEC-2026-0185) — **CONFIRMED, severity HIGH**
|
||||
- **Surface:** cli-posture-deps / native QUIC transport. **Files:** `Cargo.lock` (quinn-proto 0.11.14, line ~2966), `crates/punktfunk-core/src/quic.rs:1540-1589,1580-1581,1723-1740`, `crates/punktfunk-host/src/punktfunk1.rs:176-181,503`.
|
||||
- **Threat actor / auth:** malicious network client, **pre-auth** (unpaired, unauthenticated).
|
||||
- **Mechanism:** `serve` (the secure default) always builds the native QUIC listener bound to `0.0.0.0:9777`. The rustls `ServerConfig` uses `AcceptAnyClientCert` and defers *all* identity/pairing verification to a post-handshake app-layer fingerprint check. An unpaired peer therefore presents any self-signed cert, completes the QUIC 1-RTT handshake, and reaches `quinn-proto`'s stream-reassembly path **before** the `--require-pairing` gate. RUSTSEC-2026-0185: unbounded out-of-order STREAM-frame buffering → remote memory exhaustion.
|
||||
- **Scenario:** attacker on the LAN sends a ClientHello, finishes the handshake with a throwaway cert, opens a stream, floods out-of-order STREAM frames with large gaps; the privileged host buffers unboundedly → OOM, killing streaming for all paired clients and possibly the box.
|
||||
- **Existing mitigations:** `--max-concurrent` bounds session *count* but not per-connection reassembly memory; the pairing gate runs after the vulnerable transport layer; `stream_transport()` sets only idle-timeout/keep-alive, not receive-window limits. None neutralize this.
|
||||
- **Recommendation:** `cargo update -p quinn-proto --precise 0.11.15` (or bump `quinn`), and wire `cargo audit` into CI as a failing gate on the QUIC path.
|
||||
- **Verifiers:** both confirmed, **adjusted_severity HIGH** (availability-only — no key/trust-store impact — so high, not critical). Exploit path corroborated end-to-end: `main.rs:503` always-on default → `server_with_identity` → `AcceptAnyClientCert` accepts any cert → handshake reaches quinn-proto reassembly pre-pairing.
|
||||
|
||||
#### S2 — Malformed client Opus mic frame tears down the shared host-lifetime virtual mic (cross-session DoS) — **CONFIRMED, severity LOW–MEDIUM**
|
||||
- **Surface:** audio-mic-decode. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:1231-1280` (esp. 1266-1277), `:221,:292/:300`; `crates/punktfunk-core/src/quic.rs:1210`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth**.
|
||||
- **Mechanism:** `mic_service_thread` treats *any* `opus::Decoder::decode_float` error as a backend failure: it sets `mic=None; decoder=None; last_failed=now`, tearing down the PipeWire/WASAPI virtual mic and forcing a 2s `INJECTOR_REOPEN_BACKOFF`. The Opus payload is raw attacker bytes (`decode_mic_datagram` checks only `len>=13` and forwards `b[13..]` verbatim), and libopus returns `OPUS_INVALID_PACKET` on a malformed TOC, so a single crafted ≥14-byte datagram triggers it. Critically, the `MicService` is **one host-lifetime resource shared by every concurrent session** (created once in `serve()`, sender cloned per session).
|
||||
- **Scenario:** paired client #2 (a second concurrent session) sends one garbage Opus frame every ~2s; the shared mic thread repeatedly drops the virtual mic and re-enters backoff, keeping the microphone unavailable for session #1's recording/voice-chat app — a **cross-session** denial of an optional feature beyond the offender's own tier.
|
||||
- **Existing mitigations:** pairing-gated; 2s backoff bounds reopen churn; DTX/empty frames skipped; no memory blow-up. None prevent the cross-session denial because there is no per-session decoder/mic isolation.
|
||||
- **Recommendation:** treat a codec decode error as a per-frame drop (rate-limited log), keeping decoder+mic open; only tear down on an actual backend `push` error; reset (not destroy) decoder state; ideally use a per-session decoder.
|
||||
- **Verifiers:** both confirmed; **adjusted_severity split MEDIUM / LOW** — medium because a low-effort paired client denies an honest concurrent session's mic (genuine new authority via the shared resource); low because the impact is confined to one optional feature, churn-bounded, no crash/disclosure/exec, and all paired clients already share one desktop at a high mutual-trust tier. Net: treat as **LOW–MEDIUM**, fix is cheap and warranted.
|
||||
|
||||
#### S3 — Unbounded held-button/held-key tracking `Vec` grows on attacker-chosen input codes (per-session DoS) — **CONFIRMED, severity LOW**
|
||||
- **Surface:** native-data-plane. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:1457-1483` (esp. 1476-1483); `crates/punktfunk-core/src/input.rs:136-149`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth**.
|
||||
- **Mechanism:** every `MouseButtonDown`/`KeyDown` whose 32-bit `ev.code` (read straight off the wire at `input.rs:144`, no range/validity check) is not already present is pushed into the per-session `held_buttons`/`held_keys` `Vec`, with no cap and a linear `Vec::contains` presence test (O(n) per event, O(n²) over a run). Entries are removed only by a matching Up. The upstream mpsc is also unbounded with no per-packet throttle.
|
||||
- **Scenario:** paired client floods `MouseButtonDown`/`KeyDown` with monotonically increasing `code`s and never sends Up → the `Vec` grows unbounded and the quadratic scan spikes the session's input-thread CPU for the session lifetime.
|
||||
- **Existing mitigations:** per-session `Vec`s dropped on disconnect; input injection is in-scope-by-design (the *only* new harm is the unbounded *tracking* state); QUIC intake is receive-buffer bounded.
|
||||
- **Recommendation:** bound the held-state sets with a `HashSet` keyed by `code` (removes the O(n²) scan) and/or reject codes outside valid button/key ranges before tracking; cap the number of distinct held codes.
|
||||
- **Verifiers:** both confirmed, **adjusted_severity LOW** — self-confined to one session thread, no host crash, inverted amplification (wire bytes > memory), but a real unnecessary unbounded-growth defect.
|
||||
|
||||
#### S4 — Unbounded read of local launcher caches (Epic `catcache.bin` / `.item` manifests) — memory-exhaustion DoS — **CONFIRMED, severity LOW**
|
||||
- **Surface:** cover-art-egress / library enumeration. **Files:** `crates/punktfunk-host/src/library.rs:657-665` (esp. `std::fs::read` at ~660 + base64 decode ~663), `:580` (`read_to_string`).
|
||||
- **Threat actor / auth:** local unprivileged user (Windows host), **post-auth N/A** (local).
|
||||
- **Mechanism:** `epic_art_index` reads the entire `%ProgramData%\Epic\EpicGamesLauncher\Data\Catalog\catcache.bin` with **no size cap**, then base64-decodes it (a second ~0.75× allocation), then `serde_json` parses — stacked unbounded allocations in the LocalSystem host. Each `.item` manifest is likewise read whole. Default ProgramData ACLs commonly let a standard user create/replace files in app subfolders (Epic itself grants Users modify so its user-mode launcher can rewrite the cache).
|
||||
- **Scenario:** local user plants a multi-GB `catcache.bin`; the next library enumeration (mgmt list / GameStream serverinfo-applist / art warmer `all_games()`) loads it plus its decoded copy into the privileged host → OOM.
|
||||
- **Existing mitigations:** best-effort (failures return empty map, no crash); triggered per-enumeration, not continuously; Windows-only. Notably the Linux `lutris_image` reader (`library.rs:372-377`) **already caps at 1 MiB** — the pattern is known and simply not applied here.
|
||||
- **Recommendation:** `fs::metadata` size check or a `take()`-limited reader (a few MB for `catcache.bin`, tens of KB per `.item`) before read/decode; skip oversize files.
|
||||
- **Verifiers:** both confirmed, **adjusted_severity LOW** — DoS only, ACL-precondition reduces exploitability but not the verdict; the author's own Linux cap proves the omission.
|
||||
|
||||
#### S5 — Client refresh/fps lower bound not validated before encoder open (Hello path; folded across two finders) — **PARTIAL, severity LOW→INFO**
|
||||
- **Surface:** native-control-plane + unsafe-encode-capture (these two finders are the **same defect** at different depths; reported once here). **Files:** `crates/punktfunk-host/src/encode.rs:195-211` (`validate_dimensions`), `crates/punktfunk-host/src/punktfunk1.rs:574-579,804,3659-3663`, `crates/punktfunk-host/src/encode/linux/mod.rs:247-248,474`, `crates/punktfunk-host/src/encode/linux/vaapi.rs:98,184`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth** (pre-auth only on opt-in `--open`/`--allow-tofu`).
|
||||
- **Mechanism:** `validate_dimensions` caps W/H but ignores refresh. The mid-stream **Reconfigure** path explicitly checks `req.mode.refresh_hz > 0` (`punktfunk1.rs:804`) — proving the invariant is known — but the **initial Hello** path does not. On the common Linux backends (gamescope/wlroots/mutter) `preferred_mode` echoes the requested refresh, so `effective_hz`'s `.filter(|hz| hz>0).unwrap_or(mode.refresh_hz)` collapses a requested `refresh_hz=0` back to 0, reaching `open_video(fps=0)` → `time_base = Rational(1,0)` and the unchecked `pts * 1e9 / self.fps` divide at `encode/linux/mod.rs:474` (and `vaapi.rs:184`).
|
||||
- **Scenario:** a paired client sends `Hello{mode: WxHx0}`; on a Mutter/wlroots/gamescope host either `avcodec_open2` rejects the `1/0` time_base (clean Err) or the first packet triggers a divide-by-zero panic on the encode thread.
|
||||
- **Impact / mitigations:** at worst a **single-session-thread panic** isolated by `spawn_blocking`/`panic=unwind` (surfaces as a JoinError at `punktfunk1.rs:1092-1094`; the persistent listener and sibling sessions survive). KWin reports a real achieved Hz and dodges it. The **GameStream half is refuted**: `rtsp.rs:340-342` floors `maxFPS` with `.filter(|&f| f>0).unwrap_or(60)`, so `cfg.fps` is never 0.
|
||||
- **Recommendation:** fold a refresh lower-bound (`>0`, ideally clamp `1..=480`) into `validate_dimensions` so Hello and Reconfigure enforce the same invariant; defensively use `self.fps.max(1)` at the two division sites.
|
||||
- **Verifiers:** all four lenses PARTIAL; **adjusted_severity INFO–LOW** — a real validation asymmetry and reachable divide-by-zero, but the outcome is a self-inflicted teardown of the attacker's *own* isolated session granting no new authority (post-auth) or reducing to the already-accepted stream-slot DoS (on opt-in `--open` hosts). Worth the trivial fix; not a boundary crossing.
|
||||
|
||||
#### S6 — Unbounded mpsc into the host-lifetime shared `MicService` (0xCB) — **PARTIAL (leaning info), severity LOW→INFO**
|
||||
- **Surface:** native-data-plane / audio. **Files:** `crates/punktfunk-host/src/punktfunk1.rs:905-911,1200,1231-1280`; sinks `audio/linux/mod.rs:151-153`, `audio/windows/wasapi_mic.rs:107-120`.
|
||||
- **Threat actor / auth:** malicious paired client, **post-auth**.
|
||||
- **Mechanism (as filed):** each session forwards every 0xCB frame into an unbounded host-lifetime `std::sync::mpsc` shared across all sessions, with no backpressure/cap; the single consumer does an Opus decode + virtual-mic push per iteration.
|
||||
- **Verifier correction:** the filed DoS mechanism — "the push blocks on the audio backend, so the queue grows without bound" — is **factually wrong**. Both `VirtualMic::push` impls are non-blocking and self-bounded: Linux uses `try_send` (drops when behind); Windows takes a quick mutex with a drop-oldest `MAX_QUEUE_BYTES` cap. The consumer is therefore CPU-throughput-limited (decode-only), runs on its own thread, and never stalls; the producer is QUIC-receive-rate bounded doing comparable per-item work. Items are only the ~sub-1KB Opus payload.
|
||||
- **Residual:** a genuine but minor robustness gap — an unbounded shared channel with no explicit cap/rate-limit; under a sustained near-line-rate flood exceeding decode throughput, a small producer>consumer gap could accumulate.
|
||||
- **Recommendation:** use a bounded (drop-oldest) channel for the mic forward, or rate-limit/coalesce per-session before the shared service.
|
||||
- **Verifiers:** both PARTIAL, **adjusted_severity INFO–LOW** — structural claim holds, stated stall mechanism refuted by the non-blocking sinks.
|
||||
|
||||
#### S7 — GameStream RSA pairing uses `rsa 0.9` (RUSTSEC-2023-0071 Marvin timing side-channel) — **PARTIAL (leaning info), severity LOW→INFO**
|
||||
- **Surface:** cli-posture-deps. **Files:** `Cargo.toml` (rsa 0.9.10), `crates/punktfunk-host/src/gamestream/cert.rs:54-55`, `crates/punktfunk-host/src/gamestream/pairing.rs:200`.
|
||||
- **Threat actor / auth:** network adversary on the GameStream pairing flow, **pre-auth** (the pairing flow itself; the consent bypass is already tracked in the main audit).
|
||||
- **Mechanism:** the host's persistent RSA-2048 identity (the trust root: pinned TLS cert + pairing signer) is loaded into a PKCS1v15 `SigningKey` and used to `sign(&serversecret)` during the unauthenticated nvhttp pairing ceremony. `rsa 0.9.10` carries RUSTSEC-2023-0071 (variable-time private-key op, no fixed upstream release), so signing-response timing is data-dependent on the secret key. Recovery would defeat client cert-pinning (host impersonation).
|
||||
- **Existing mitigations:** GameStream is **off by default and documented trusted-LAN-only** (#5/#9 inherent caveat); the native plane uses Ed25519/SPAKE2 and is unaffected. Crucially this is the **signing** path, not the PKCS#1v1.5 **decryption** oracle Marvin classically targets, and `serversecret` is host-generated random (not attacker-chosen) — so a remote network-timed RSA-2048 key recovery over a jittery LAN is theoretical, requiring enormous high-precision sampling.
|
||||
- **Recommendation:** track the advisory; when a blinded/constant-time `rsa` release lands, upgrade; consider migrating the GameStream identity to ECDSA/Ed25519; keep GameStream gated off by default.
|
||||
- **Verifiers:** both PARTIAL, **adjusted_severity INFO–LOW** — claim technically accurate and no code-level fix exists upstream, but the off-by-default posture, signing-not-decryption distinction, and lack of any demonstrated practical remote key recovery reduce this to a transitive-advisory exposure.
|
||||
|
||||
### (c) Refuted / not vulnerabilities
|
||||
|
||||
- **Single shared virtual mic + stateful Opus decoder across concurrent sessions (no isolation)** — *refuted (downgraded to info).* Concurrent sessions are co-tenancy of ONE desktop by design (`punktfunk1.rs:244-246`); a paired client already injects keystrokes/captures that desktop via the identically-shared input service, so sharing the mic grants no new authority. Decoder "corruption" is self-healing (reopen) audio-quality, not security. Document the limitation alongside the known gamescope multi-user gap.
|
||||
- **Cover-art warmer follows HTTP redirects → blind SSRF** — *refuted under the in-scope threat model.* URLs are hardcoded `https://api.gog.com` / `https://displaycatalog.mp.microsoft.com` constants reached over verified TLS (ureq 2.x → rustls + webpki-roots); the id is only a path segment. No in-scope adversary (network client, on-path MITM with no host key, local user) can emit the 30x `Location` — that requires a genuine compromise/cert-hijack of those domains (supply-chain, out of scope). A local user can only poison the path segment of a request still sent to the real upstream over TLS. Defense-in-depth: still set `.redirects(0)`.
|
||||
- **GameStream RSA signing direct attacker-control** — partial-leaning: the adversary observes a timing side-channel, not a value flowing to a sink; see S7.
|
||||
|
||||
### (d) Positives confirmed on these surfaces
|
||||
|
||||
- **Native control plane is fail-closed at the app layer:** `serve_session` (`punktfunk1.rs:544-573`) rejects unpaired/anonymous clients before `validate_dimensions`, compositor resolution, the `can_encode_444` GPU probe, encoder open, and vdisplay create.
|
||||
- **Client→host wire decoders are uniformly bounds-checked, no reachable parse panic/OOB:** `Hello.decode` uses checked `.get()` for every trailing field (the one `u32at` is gated by `len>=20`); `RichInput` (`quic.rs:1271`), `InputEvent` (`input.rs:136`), and `decode_mic_datagram` all length-check before indexed reads; unknown datagram tags are a non-fatal drop.
|
||||
- **No client field reaches HDR SEI:** the 0xCE/HDR, 0xCA rumble, 0xCD HidOut datagrams are host→client only; the SEI builders are fed only host-derived values.
|
||||
- **Geometry → unsafe FFI is memory-safe:** W/H caps applied on Hello, Reconfigure, and ANNOUNCE; CPU upload paths re-derive `src_row` from the encoder's own width and bound-check `bytes.len() >= src_row*h` before `sws_scale`/copy; encoder fully rebuilds on size change (no stale-size OOB); CUDA pitch math driver-bounded; Drop SAFETY contracts hold (no UAF/double-free); pf_vdisplay/SudoVDA ioctls use `size_of`-sized buffers with no attacker-controlled length.
|
||||
- **Driver-IPC ABI is clean:** `pf-driver-proto` pins all offsets/sizes via compile-time `offset_of!`/`size_of` asserts; gamepad output reports, XUSB rumble, IDD-push publish token, and the WGC AU pipe all bounds-check before indexed reads and never use an attacker byte as offset/length/index; the only residual is the already-reported world-writable section ACL.
|
||||
- **Opus mic buffer math exact:** 5760×2 f32 = the 120 ms max stereo frame; the safe `opus` crate returns `BufferTooSmall` rather than overflowing; `(samples×2).min(pcm.len())`.
|
||||
- **Gamepad accumulation clamped at every layer:** `idx < MAX_WIRE_PADS(16)`, `idx >= MAX_PADS(4)` rejects, finger/touchpad/stick/trigger clamps.
|
||||
- **No production GameStream client→host Opus decode path:** the only `MSDecoder::new(..., client_mapping)` call sites are inside `#[cfg(test)]` (the prompt's G1 premise corrected) — that attack surface does not exist in shipped code.
|
||||
- **CLI default-security posture sound:** `require_pairing` / `open` use exact-string scans (malformed/quoted args can't flip them); the mgmt token is mandatory on every bind including loopback (`mgmt.rs:86-92,471-507`); empty `--mgmt-token` rejected; dev subcommands expose no weaker-trust default listener.
|
||||
- **Cover-art direct SSRF safe:** hardcoded hosts, id only in path, TLS verification on, body capped at ureq's 10 MB; catalog art URLs flow only to clients, never re-fetched by the host.
|
||||
- **Concurrency/probe bounds:** `max_concurrent` via owned semaphore permit before `accept()`; probe duration/rate clamped (`MAX_PROBE_MS=5s`, `MAX_PROBE_KBPS=10Gbps`); `ClockProbe` answered 1:1 (no amplification).
|
||||
|
||||
---
|
||||
|
||||
## Appendix — coverage-gap critic (pass 1) and how pass 2 addressed it
|
||||
|
||||
# Coverage gaps & follow-up
|
||||
|
||||
I enumerated all 82 host source files and mapped them to the 13 audit surfaces. Below are files / data-paths / cross-cutting concerns that **no surface clearly owns**, ranked for a follow-up pass.
|
||||
|
||||
## Gaps in per-file coverage
|
||||
|
||||
### G1 — Client mic-uplink Opus decode → privileged virtual mic (MED)
|
||||
Files: `src/audio.rs`, `src/audio/linux/mod.rs`, `src/audio/windows/wasapi_cap.rs`+`wasapi_mic.rs`, decode sinks at `punktfunk1.rs:1233-1266` and `gamestream/audio.rs:610-732`.
|
||||
The `native-protocol` surface covers the *demux* (0xCB → `mic_tx`) and `gamestream-wire` covers RTP framing, but the **Opus decode itself and the PCM injection into a host-wide virtual microphone** is owned by no surface. This is an attacker-controlled byte stream (`opus::Decoder::decode_float` on raw network bytes, `punktfunk1.rs:1266`) decoded into a system-visible recording device. Worse on the GameStream path: `gamestream/audio.rs:637/724` builds an `opus::MSDecoder` from a **client-derived channel mapping/layout** (`layout.streams`, `layout.coupled`, `client_mapping`) — verify those are bounds-checked before reaching libopus, and that decode errors can't panic/DoS the host-lifetime mic thread. Native path is post-auth; the GameStream mic path rides weaker GameStream trust. No audio-decode surface existed.
|
||||
|
||||
### G2 — Shared host-lifetime mic/input services across concurrent sessions (MED)
|
||||
`punktfunk1.rs:219-300` (`mic_service` / `mic_tx` shared, host-lifetime). With `--max-concurrent` sessions sharing **one** virtual mic and input service, a paired client's mic stream / input can bleed into a *different* concurrent session's desktop. This spans `audio` + `session-lifecycle` + `input-injection` and no single surface would catch the cross-session isolation question. Adversary: post-auth client #2 against session #1 (multi-user isolation, explicitly listed as "remaining piece" in CLAUDE.md for gamescope).
|
||||
|
||||
### G3 — `main.rs` CLI parsing & default-security posture (MED)
|
||||
`src/main.rs` (734 LOC) is owned by no surface. It decides the crown-jewel default: `require_pairing: !args.iter().any(|a| a == "--allow-tofu")` (`main.rs:388`) — a substring/exact-match flag scan that gates whether unpaired clients are accepted. Also hosts the `spike` and `punktfunk1-host` dev subcommands shipped in the production binary, and the `--mgmt-bind` parse (`main.rs:516`, non-loopback requires a token — good, but verify the loopback check can't be bypassed by `0.0.0.0`/IPv6-mapped forms). A default-posture/flag-parsing regression here silently disables pairing. Cross-cutting; no surface re-derives it.
|
||||
|
||||
### G4 — Cover-art warmer outbound egress + parse (LOW-MED)
|
||||
`library.rs:1004-1090` (`fetch_gog_art`, `fetch_xbox_art`, host-lifetime warmer over `ureq`) and Epic `catcache.bin` base64 decode. `library-launch` likely covered launch-command construction, but the **outbound HTTP egress** (host as SSRF client fetching URLs influenced by on-disk store files / operator custom entries, `library.rs:481-697`) and the base64/JSON parse of attacker-influenceable launcher caches are a distinct trust boundary. Confirm `library-launch` actually traced the fetch side, not just launch exec.
|
||||
|
||||
### G5 — `hdr.rs` metadata path (LOW)
|
||||
`src/hdr.rs` (168 LOC) — HDR/color-info construction. If any field derives from client `ColorInfo` (0xCE / connect_ex5 caps), it's attacker-influenced metadata fed to the encoder SEI. No surface names it.
|
||||
|
||||
### G6 — Glue/init files unmapped (LOW)
|
||||
`pipeline.rs`, `pwinit.rs`, `session_tuning.rs`, `linux/dmabuf_fence.rs`, `linux/drm_sync.rs` — mostly internal glue, but the dmabuf/drm-sync FFI files border `unsafe-ffi`; confirm that surface's scope included them (they were not in its cited list of zerocopy/encode/capture).
|
||||
|
||||
## Cross-cutting concerns no per-surface review can catch
|
||||
|
||||
### X1 — Dependency / RUSTSEC posture (MED)
|
||||
`Cargo.toml` is owned by no surface. Notable: **`rsa = "0.9"`** is subject to RUSTSEC-2023-0071 (Marvin timing side-channel) and is used directly by the **GameStream RSA pairing** ceremony — a network-adjacent oracle concern for `gamestream/crypto.rs`+`pairing.rs`. `ureq = "2"` backs the cover-art egress (G4). Run `cargo audit` against the workspace lock as a follow-up; per-surface reviewers won't.
|
||||
|
||||
### X2 — Secret-file create→chmod TOCTOU across modules (LOW)
|
||||
`secrets-perms` verifies final perms, but the create-then-restrict ordering window is implemented independently in `gamestream/cert.rs`, `mgmt_token.rs`, `native_pairing.rs`, and the captures/art writers (`stats_recorder.rs`, `library.rs`). A single helper vs N call-sites is a cross-module check: confirm every secret is created with restrictive perms atomically (O_CREAT mode), not world-readable-then-chmod, on **every** path including ones added since the prior audit.
|
||||
|
||||
### X3 — On-disk capture / cache write paths (LOW)
|
||||
`stats_recorder.rs` captures (`~/.config/punktfunk/captures/*.json`) and `library.rs` art cache are operator-readable artifacts; `stats-capture` covered the endpoints but confirm the **filename derivation** for saved captures can't be influenced by a network field (path traversal into the captures dir).
|
||||
|
||||
### X4 — `windows/install.rs` driver/web install moved into host exe (MED — verify owned)
|
||||
`windows/install.rs` + `windows/interactive.rs` should be under `windows-service-priv`, but given commit 125a51d is new, explicitly confirm that surface traced: the source of bundled driver paths (pnputil install), any download/verify of the web bundle, and that `CreateProcessAsUserW`/scheduled-task launch can't be redirected by an unprivileged local user (adversary #4) writing into a host-readable staging dir.
|
||||
|
||||
Net: G1 (mic decode → virtual mic) and G3 (main.rs default posture) are the most likely real-blind-spots; X1 (rsa 0.9 in GameStream pairing) is the cleanest cross-cutting follow-up.
|
||||
@@ -43,6 +43,10 @@ install -Dm0644 scripts/99-punktfunk-client-net.conf \
|
||||
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
|
||||
install -Dm0644 README.md "$DOCDIR/README.md"
|
||||
# Third-party crate attributions (regenerate with scripts/gen-third-party-notices.sh).
|
||||
if [ -f THIRD-PARTY-NOTICES.txt ]; then
|
||||
install -Dm0644 THIRD-PARTY-NOTICES.txt "$DOCDIR/THIRD-PARTY-NOTICES.txt"
|
||||
fi
|
||||
|
||||
cat > "$DOCDIR/copyright" <<EOF
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
@@ -50,7 +54,7 @@ Upstream-Name: punktfunk
|
||||
Source: https://git.unom.io/unom/punktfunk
|
||||
|
||||
Files: *
|
||||
Copyright: punktfunk contributors
|
||||
Copyright: unom and the punktfunk contributors
|
||||
License: MIT or Apache-2.0
|
||||
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
|
||||
/usr/share/doc/$PKG/LICENSE-APACHE.
|
||||
|
||||
@@ -68,6 +68,10 @@ install -Dm0644 api/openapi.json "$SHAREDIR/openapi.json"
|
||||
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
|
||||
install -Dm0644 README.md "$DOCDIR/README.md"
|
||||
# Third-party crate attributions (regenerate with scripts/gen-third-party-notices.sh).
|
||||
if [ -f THIRD-PARTY-NOTICES.txt ]; then
|
||||
install -Dm0644 THIRD-PARTY-NOTICES.txt "$DOCDIR/THIRD-PARTY-NOTICES.txt"
|
||||
fi
|
||||
|
||||
# Debian copyright + changelog (cheap, keeps the package well-formed).
|
||||
cat > "$DOCDIR/copyright" <<EOF
|
||||
@@ -76,7 +80,7 @@ Upstream-Name: punktfunk
|
||||
Source: https://git.unom.io/unom/punktfunk
|
||||
|
||||
Files: *
|
||||
Copyright: punktfunk contributors
|
||||
Copyright: unom and the punktfunk contributors
|
||||
License: MIT or Apache-2.0
|
||||
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
|
||||
/usr/share/doc/$PKG/LICENSE-APACHE.
|
||||
|
||||
@@ -261,7 +261,7 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
%endif
|
||||
|
||||
%files
|
||||
%license LICENSE-MIT LICENSE-APACHE
|
||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||
%doc README.md design/implementation-plan.md packaging/README.md
|
||||
%{_bindir}/punktfunk-host
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
@@ -276,7 +276,7 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
%{_datadir}/%{name}/*
|
||||
|
||||
%files client
|
||||
%license LICENSE-MIT LICENSE-APACHE
|
||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||
%{_bindir}/punktfunk-client
|
||||
%{_datadir}/applications/io.unom.Punktfunk.desktop
|
||||
%{_udevrulesdir}/70-punktfunk-client.rules
|
||||
@@ -284,7 +284,7 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
||||
|
||||
%if %{with web}
|
||||
%files web
|
||||
%license LICENSE-MIT LICENSE-APACHE
|
||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||
%{_bindir}/punktfunk-web-server
|
||||
%dir %{_datadir}/punktfunk-web
|
||||
%{_datadir}/punktfunk-web/.output
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 unom
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,41 @@
|
||||
FFmpeg — third-party component notice
|
||||
=====================================
|
||||
|
||||
This product bundles unmodified shared libraries from the FFmpeg project
|
||||
(avcodec / avutil / avformat / swscale / swresample and their dependencies) as
|
||||
separate dynamic-link libraries (DLLs). punktfunk uses them only for hardware
|
||||
video encode (AMD AMF / Intel QSV) on the host and hardware/software video
|
||||
decode on the client.
|
||||
|
||||
License
|
||||
-------
|
||||
The bundled FFmpeg libraries are distributed under the GNU Lesser General Public
|
||||
License (LGPL), version 2.1 or later. The bundled builds are the "lgpl-shared"
|
||||
configuration — they do NOT include any GPL-licensed components (notably they do
|
||||
not include libx264 or libx265; punktfunk does not use them). The full text of
|
||||
the LGPL accompanies this notice (see the COPYING.LGPLv2.1 / LICENSE files in
|
||||
this directory; if absent, see https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html).
|
||||
|
||||
How punktfunk complies (dynamic linking)
|
||||
----------------------------------------
|
||||
punktfunk links FFmpeg only dynamically: the FFmpeg DLLs are shipped as separate
|
||||
files alongside the application and are not statically combined into the
|
||||
punktfunk executable. You may replace these DLLs with your own ABI-compatible
|
||||
build of FFmpeg, which satisfies LGPL section 6 (the right to relink the work
|
||||
against a modified version of the library).
|
||||
|
||||
Source code
|
||||
-----------
|
||||
The bundled binaries are unmodified builds produced by the BtbN/FFmpeg-Builds
|
||||
project. The exact source for the FFmpeg release used is available from:
|
||||
|
||||
* FFmpeg project source: https://ffmpeg.org/download.html (release n7.1)
|
||||
* Exact build recipe: https://github.com/BtbN/FFmpeg-Builds
|
||||
|
||||
A copy of the corresponding FFmpeg source for the version shipped here is
|
||||
available on request from the punktfunk maintainers (https://git.unom.io/unom/punktfunk).
|
||||
|
||||
Trademark
|
||||
---------
|
||||
FFmpeg is a trademark of Fabrice Bellard, originator of the FFmpeg project.
|
||||
punktfunk is not affiliated with or endorsed by the FFmpeg project.
|
||||
@@ -132,12 +132,25 @@ Copy-Item -LiteralPath $hostEnvSrc -Destination $hostEnv -Force
|
||||
Copy-Item -LiteralPath $readmeSrc -Destination $readme -Force
|
||||
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
|
||||
|
||||
# License/attribution payload bundled into {app}\licenses: the project's own MIT/Apache texts and the
|
||||
# generated third-party crate notices. The FFmpeg LGPL notice + license text are added to this same
|
||||
# dir below when the AMF/QSV FFmpeg DLLs are bundled. (THIRD-PARTY-NOTICES.txt is committed; CI may
|
||||
# regenerate it via scripts/gen-third-party-notices.sh before packaging.)
|
||||
$licStage = Join-Path $OutDir 'licenses'
|
||||
New-Item -ItemType Directory -Force -Path $licStage | Out-Null
|
||||
foreach ($n in @('LICENSE-MIT', 'LICENSE-APACHE', 'THIRD-PARTY-NOTICES.txt')) {
|
||||
$p = Join-Path $repoRoot $n
|
||||
if (Test-Path $p) { Copy-Item $p -Destination $licStage -Force }
|
||||
else { Write-Warning "license payload missing (skipped): $p" }
|
||||
}
|
||||
|
||||
$defines = @(
|
||||
"/DMyAppVersion=$Version",
|
||||
"/DBinDir=$TargetDir",
|
||||
"/DOutputDir=$OutDir",
|
||||
"/DHostEnv=$hostEnv",
|
||||
"/DReadme=$readme"
|
||||
"/DReadme=$readme",
|
||||
"/DLicensesDir=$licStage"
|
||||
)
|
||||
|
||||
# --- build (from source) + stage the pf-vdisplay virtual-display driver -----------------------
|
||||
@@ -179,7 +192,7 @@ if (-not $NoDriver) {
|
||||
# --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------
|
||||
# A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs
|
||||
# MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin - the same
|
||||
# BtbN gpl-shared tree the build linked against. A nvenc/software-only build doesn't import them, so
|
||||
# BtbN lgpl-shared tree the build linked against. A nvenc/software-only build doesn't import them, so
|
||||
# this is a harmless extra there; skipped entirely when $FfmpegDir is unset.
|
||||
$ffmpegBinSrc = if ($FfmpegDir) { Join-Path $FfmpegDir 'bin' } else { $null }
|
||||
if ($ffmpegBinSrc -and (Test-Path $ffmpegBinSrc)) {
|
||||
@@ -190,6 +203,16 @@ if ($ffmpegBinSrc -and (Test-Path $ffmpegBinSrc)) {
|
||||
$dlls | ForEach-Object { Copy-Item $_.FullName -Destination $ffmpegStage -Force }
|
||||
$defines += "/DFfmpegBin=$ffmpegStage"
|
||||
Write-Host "bundling $($dlls.Count) FFmpeg DLL(s) from $ffmpegBinSrc"
|
||||
# LGPL compliance: add FFmpeg's own license text (preserved in the BtbN tree root) + our
|
||||
# attribution notice to the {app}\licenses payload so the conveyed installer carries the
|
||||
# LGPLv2.1+ terms. FFmpeg is linked dynamically (separate, user-replaceable DLLs), which
|
||||
# satisfies the LGPL relink requirement.
|
||||
Copy-Item (Join-Path $here 'licenses\FFmpeg-LGPL-NOTICE.txt') -Destination $licStage -Force -ErrorAction SilentlyContinue
|
||||
foreach ($lic in @('LICENSE.txt', 'LICENSE', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.txt')) {
|
||||
$p = Join-Path $FfmpegDir $lic
|
||||
if (Test-Path $p) { Copy-Item $p -Destination (Join-Path $licStage "FFmpeg-$lic") -Force }
|
||||
}
|
||||
Write-Host "added FFmpeg license/notice to $licStage"
|
||||
}
|
||||
}
|
||||
else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" }
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 unom
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 unom
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -102,10 +102,17 @@ Name: "startservice"; Description: "Start the punktfunk host service now (also s
|
||||
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#HostEnv}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#Readme}"; DestDir: "{app}"; DestName: "README.txt"; Flags: ignoreversion
|
||||
#ifdef LicensesDir
|
||||
; License/attribution payload -> {app}\licenses: the project's MIT/Apache texts, the generated
|
||||
; THIRD-PARTY-NOTICES (permissive crate attributions), and (on an amf-qsv build) the FFmpeg LGPL
|
||||
; notice + license text. Staged by pack-host-installer.ps1.
|
||||
Source: "{#LicensesDir}\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
||||
#endif
|
||||
#ifdef WithFfmpeg
|
||||
; FFmpeg shared DLLs (avcodec/avutil/swscale/...) laid down next to the exe - the AMD/Intel
|
||||
; (AMF/QSV) encode backend link-imports them, so the exe won't start without them. NVENC/software-
|
||||
; only builds simply omit this block.
|
||||
; only builds simply omit this block. These are unmodified BtbN *lgpl-shared* builds, linked
|
||||
; dynamically (replaceable DLLs) - FFmpeg is used under the LGPL v2.1+; see {app}\licenses.
|
||||
Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
#endif
|
||||
#ifdef WithWeb
|
||||
|
||||
@@ -102,22 +102,35 @@ if (Test-Path $rustup) {
|
||||
& $rustup target add aarch64-pc-windows-msvc
|
||||
} else { Write-Warning "rustup not found - install rustup then re-run (needed for the aarch64 target)." }
|
||||
|
||||
$ffArm = "C:\Users\Public\ffmpeg-arm64"
|
||||
if (-not (Test-Path (Join-Path $ffArm 'lib\avcodec.lib'))) {
|
||||
# BtbN winarm64 shared, FFmpeg 7.x (avcodec-61) to match the x64 tree's ABI. MSVC-linkable .lib
|
||||
# import libs + headers + bin\*.dll — exactly what ffmpeg-sys-next + pack-msix.ps1 consume.
|
||||
Write-Host "==> fetching ARM64 FFmpeg (BtbN winarm64 shared)"
|
||||
$ffUrl = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-winarm64-gpl-shared-7.1.zip'
|
||||
$ffZip = "C:\Users\Public\ffmpeg-arm64.zip"
|
||||
$ffTmp = "C:\Users\Public\ffmpeg-arm64-extract"
|
||||
Invoke-WebRequest -Uri $ffUrl -OutFile $ffZip -UseBasicParsing
|
||||
if (Test-Path $ffTmp) { Remove-Item -Recurse -Force $ffTmp }
|
||||
Expand-Archive -Path $ffZip -DestinationPath $ffTmp -Force # BtbN zips have one top-level folder
|
||||
$inner = Get-ChildItem $ffTmp -Directory | Select-Object -First 1
|
||||
if (Test-Path $ffArm) { Remove-Item -Recurse -Force $ffArm }
|
||||
Move-Item -Path $inner.FullName -Destination $ffArm
|
||||
Remove-Item -Force $ffZip; Remove-Item -Recurse -Force $ffTmp -ErrorAction SilentlyContinue
|
||||
# FFmpeg shared trees for the host (amf-qsv encode) + clients (decode). We use BtbN **lgpl-shared**
|
||||
# builds: the AMD/Intel AMF + Intel QSV encoders, swscale, and the HEVC decoder are all present in the
|
||||
# LGPL build, and punktfunk never calls the GPL-only encoders (x264/x265 — software encode is the
|
||||
# separate BSD-2 openh264 crate; NVENC is the direct NVIDIA SDK). lgpl-shared keeps the bundled DLLs
|
||||
# LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the shipped installer/MSIX
|
||||
# stay consistent with punktfunk's MIT OR Apache-2.0 posture.
|
||||
# MIGRATION: a runner previously provisioned with the old *gpl-shared* trees must be re-provisioned —
|
||||
# delete C:\Users\Public\ffmpeg and C:\Users\Public\ffmpeg-arm64, then re-run this script.
|
||||
function Get-BtbnFfmpeg {
|
||||
param([string]$Dir, [string]$ZipTag) # ZipTag: 'win64' (x64) or 'winarm64' (ARM64 cross tree)
|
||||
if (Test-Path (Join-Path $Dir 'lib\avcodec.lib')) { return }
|
||||
# FFmpeg 7.x (avcodec-61); MSVC-linkable .lib import libs + headers + bin\*.dll — exactly what
|
||||
# ffmpeg-sys-next + pack-host-installer.ps1 + pack-msix.ps1 consume. The extracted top-level folder
|
||||
# also carries FFmpeg's own LICENSE/COPYING text, preserved in $Dir for the packagers to bundle.
|
||||
Write-Host "==> fetching FFmpeg ($ZipTag, BtbN lgpl-shared)"
|
||||
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-$ZipTag-lgpl-shared-7.1.zip"
|
||||
$zip = "$Dir.zip"; $tmp = "$Dir-extract"
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
|
||||
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
|
||||
Expand-Archive -Path $zip -DestinationPath $tmp -Force # BtbN zips have one top-level folder
|
||||
$inner = Get-ChildItem $tmp -Directory | Select-Object -First 1
|
||||
if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir }
|
||||
Move-Item -Path $inner.FullName -Destination $Dir
|
||||
Remove-Item -Force $zip; Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
# x64 host+client tree (the workflow's default FFMPEG_DIR = C:\Users\Public\ffmpeg) and the ARM64 cross
|
||||
# tree (the aarch64 leg points FFMPEG_DIR at C:\Users\Public\ffmpeg-arm64).
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg" -ZipTag 'win64'
|
||||
Get-BtbnFfmpeg -Dir "C:\Users\Public\ffmpeg-arm64" -ZipTag 'winarm64'
|
||||
|
||||
# Inno Setup (ISCC.exe) for the host installer build (windows-host.yml). pack-host-installer.ps1
|
||||
# locates it at its fixed Program Files path, so it need not be on PATH — just present.
|
||||
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate THIRD-PARTY-NOTICES.txt for the Rust workspace.
|
||||
|
||||
Offline, dependency-free attribution generator. It reads `cargo metadata`, then for every
|
||||
third-party crate (everything that is NOT a first-party workspace member) it pulls the crate's
|
||||
*actual* LICENSE/COPYING/NOTICE text out of the local cargo registry cache (or the in-tree
|
||||
vendored source for path deps), deduplicates identical license texts, and emits a single
|
||||
notices file: a per-crate manifest followed by the verbatim license texts.
|
||||
|
||||
This satisfies the binary-distribution attribution duty for the permissive (MIT/BSD/ISC/Zlib/
|
||||
Apache/Unicode/etc.) crates linked into shipped punktfunk artifacts. `cargo about` (see
|
||||
about.toml) produces an equivalent, network-augmented result in CI; this is the dependency-free
|
||||
fallback that also runs locally and is committed as a baseline.
|
||||
|
||||
Usage: python3 scripts/gen-third-party-notices.py [--out THIRD-PARTY-NOTICES.txt]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
LICENSE_GLOBS = ("license", "licence", "copying", "notice", "unlicense", "copyright")
|
||||
|
||||
|
||||
def find_license_files(pkg_dir):
|
||||
out = []
|
||||
try:
|
||||
names = sorted(os.listdir(pkg_dir))
|
||||
except OSError:
|
||||
return out
|
||||
for n in names:
|
||||
low = n.lower()
|
||||
if any(low == g or low.startswith(g + ".") or low.startswith(g + "-") or g in low for g in LICENSE_GLOBS):
|
||||
p = os.path.join(pkg_dir, n)
|
||||
if os.path.isfile(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
||||
txt = f.read().strip()
|
||||
if txt:
|
||||
out.append((n, txt))
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default="THIRD-PARTY-NOTICES.txt")
|
||||
ap.add_argument("--manifest", default="Cargo.toml")
|
||||
args = ap.parse_args()
|
||||
|
||||
meta = json.loads(subprocess.check_output(
|
||||
["cargo", "metadata", "--format-version", "1", "--offline", "--manifest-path", args.manifest],
|
||||
text=True))
|
||||
ws_members = set(meta.get("workspace_members", []))
|
||||
|
||||
pkgs = []
|
||||
for p in meta["packages"]:
|
||||
if p["id"] in ws_members:
|
||||
continue # first-party (covered by the root LICENSE-MIT / LICENSE-APACHE)
|
||||
pkgs.append(p)
|
||||
pkgs.sort(key=lambda p: (p["name"].lower(), p["version"]))
|
||||
|
||||
# Group license texts: text-hash -> {text, name, crates[]}
|
||||
texts = {}
|
||||
no_text = []
|
||||
for p in pkgs:
|
||||
pkg_dir = os.path.dirname(p["manifest_path"])
|
||||
files = find_license_files(pkg_dir)
|
||||
label = f'{p["name"]} {p["version"]}'
|
||||
if not files:
|
||||
no_text.append(p)
|
||||
continue
|
||||
for fname, txt in files:
|
||||
h = hashlib.sha256(txt.encode("utf-8", "replace")).hexdigest()
|
||||
ent = texts.setdefault(h, {"text": txt, "filename": fname, "crates": set()})
|
||||
ent["crates"].add(label)
|
||||
|
||||
lines = []
|
||||
w = lines.append
|
||||
w("THIRD-PARTY SOFTWARE NOTICES")
|
||||
w("=" * 76)
|
||||
w("")
|
||||
w("punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.")
|
||||
w("The binaries it ships statically/dynamically link the third-party Rust crates listed")
|
||||
w("below. Each is distributed under its own permissive license; the full license texts")
|
||||
w("follow the manifest. This file is generated by scripts/gen-third-party-notices.py")
|
||||
w("(or `cargo about`, see about.toml) — do not edit by hand.")
|
||||
w("")
|
||||
w(f"Total third-party crates: {len(pkgs)}")
|
||||
w("")
|
||||
w("-" * 76)
|
||||
w("MANIFEST (crate version — SPDX license — source)")
|
||||
w("-" * 76)
|
||||
for p in pkgs:
|
||||
lic = p.get("license") or (("file: " + p["license_file"]) if p.get("license_file") else "UNKNOWN")
|
||||
repo = p.get("repository") or ""
|
||||
w(f' {p["name"]} {p["version"]} — {lic}' + (f' — {repo}' if repo else ""))
|
||||
w("")
|
||||
|
||||
if no_text:
|
||||
w("-" * 76)
|
||||
w("Crates whose package did not embed a license file (SPDX + source only)")
|
||||
w("-" * 76)
|
||||
for p in no_text:
|
||||
lic = p.get("license") or "UNKNOWN"
|
||||
repo = p.get("repository") or ""
|
||||
w(f' {p["name"]} {p["version"]} — {lic}' + (f' — {repo}' if repo else ""))
|
||||
w("")
|
||||
|
||||
w("=" * 76)
|
||||
w("FULL LICENSE TEXTS (deduplicated)")
|
||||
w("=" * 76)
|
||||
# Stable order: by first crate name covered.
|
||||
for h, ent in sorted(texts.items(), key=lambda kv: sorted(kv[1]["crates"])[0].lower()):
|
||||
crates = ", ".join(sorted(ent["crates"]))
|
||||
w("")
|
||||
w("-" * 76)
|
||||
w(f"The following license ({ent['filename']}) applies to: {crates}")
|
||||
w("-" * 76)
|
||||
w(ent["text"])
|
||||
w("")
|
||||
|
||||
text = "\n".join(lines) + "\n"
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
print(f"wrote {args.out}: {len(pkgs)} crates, {len(texts)} distinct license texts, "
|
||||
f"{len(no_text)} without embedded text", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate THIRD-PARTY-NOTICES.txt for the Rust workspace.
|
||||
#
|
||||
# Prefers `cargo about` (full, network-augmented license harvest; see about.toml) and falls back to
|
||||
# the dependency-free offline generator (scripts/gen-third-party-notices.py, reads the cargo registry
|
||||
# cache). Run this when the dependency tree changes; CI also runs it before packaging.
|
||||
#
|
||||
# Usage: scripts/gen-third-party-notices.sh [output-file]
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT="${1:-THIRD-PARTY-NOTICES.txt}"
|
||||
|
||||
if command -v cargo-about >/dev/null 2>&1; then
|
||||
echo "==> cargo about generate -> $OUT" >&2
|
||||
cargo about generate about.hbs --output-file "$OUT"
|
||||
else
|
||||
echo "==> cargo-about not installed; using offline fallback" >&2
|
||||
echo " (install the full generator with: cargo install cargo-about)" >&2
|
||||
python3 scripts/gen-third-party-notices.py --out "$OUT"
|
||||
fi
|
||||
echo "==> wrote $OUT" >&2
|
||||
|
||||
# Keep the per-client in-tree copies in sync (the GUI apps bundle these as resources/assets and
|
||||
# show them on their Acknowledgements / Open-source-licenses screen). The Linux/Windows Rust clients
|
||||
# embed the root file directly via include_str!, so they need no copy.
|
||||
if [ "$OUT" = "THIRD-PARTY-NOTICES.txt" ]; then
|
||||
for dest in \
|
||||
clients/apple/Sources/PunktfunkKit/Resources/THIRD-PARTY-NOTICES.txt \
|
||||
clients/android/app/src/main/assets/THIRD-PARTY-NOTICES.txt; do
|
||||
if [ -d "$(dirname "$dest")" ]; then
|
||||
cp "$OUT" "$dest"
|
||||
echo "==> synced $dest" >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
Reference in New Issue
Block a user