Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87e7c82cbc | |||
| b2e3d1b540 | |||
| d36bec6e9d | |||
| abc54a7d13 | |||
| 309e37f1e1 | |||
| 56d21f9445 | |||
| f36d13e371 | |||
| aacd610b68 | |||
| 081ff64087 |
+24
-30
@@ -6,17 +6,12 @@
|
|||||||
#
|
#
|
||||||
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
|
||||||
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
|
||||||
# We build the frontend with pnpm and assemble the store-layout zip by hand:
|
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
|
||||||
#
|
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
|
||||||
# punktfunk.zip
|
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
|
||||||
# punktfunk/ <- single top-level dir == plugin.json "name"
|
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
|
||||||
# plugin.json [required]
|
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
|
||||||
# package.json [required; CI stamps "version" — Decky reads the installed version here]
|
# top: the {channel, manifest} pointer the plugin's self-update check polls.
|
||||||
# main.py [required: python backend]
|
|
||||||
# dist/index.js [required: rollup output]
|
|
||||||
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
|
|
||||||
# README.md (recommended)
|
|
||||||
# LICENSE [required by the plugin store]
|
|
||||||
#
|
#
|
||||||
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
|
||||||
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
|
||||||
@@ -90,28 +85,27 @@ jobs:
|
|||||||
- name: Assemble store-layout zip
|
- name: Assemble store-layout zip
|
||||||
working-directory: ${{ gitea.workspace }}
|
working-directory: ${{ gitea.workspace }}
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
|
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
|
||||||
STAGE="$RUNNER_TEMP/decky"
|
# so an image change can't silently break the build.
|
||||||
DEST="$STAGE/$PLUGIN"
|
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
|
||||||
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
|
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
|
||||||
cp clients/decky/plugin.json "$DEST/"
|
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
|
||||||
cp clients/decky/package.json "$DEST/"
|
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
|
||||||
cp clients/decky/main.py "$DEST/"
|
bash clients/decky/scripts/package.sh
|
||||||
cp clients/decky/dist/index.js "$DEST/dist/"
|
DEST="clients/decky/out/$PLUGIN"
|
||||||
cp clients/decky/README.md "$DEST/"
|
# CI-only addition: the self-update channel pointer the backend reads (main.py
|
||||||
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
|
# check_update). It points at THIS channel's manifest.json (published below); that
|
||||||
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
|
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
|
||||||
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
|
# across future alias re-uploads.
|
||||||
chmod 0755 "$DEST/bin/punktfunkrun.sh"
|
|
||||||
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
|
|
||||||
cp LICENSE-MIT "$DEST/LICENSE"
|
|
||||||
# Self-update channel pointer the backend reads (main.py check_update). It points at
|
|
||||||
# THIS channel's manifest.json (published below); that manifest in turn points at the
|
|
||||||
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
|
|
||||||
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
|
||||||
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
|
||||||
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
ls -lh "$RUNNER_TEMP/punktfunk.zip"
|
||||||
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
unzip -l "$RUNNER_TEMP/punktfunk.zip"
|
||||||
|
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
|
||||||
|
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
|
||||||
|
controller_config/punktfunk.vdf update.json; do
|
||||||
|
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
|
||||||
|
done
|
||||||
# The update manifest the plugin polls: the immutable per-version artifact + its
|
# The update manifest the plugin polls: the immutable per-version artifact + its
|
||||||
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
# sha256 (Decky's installer verifies the download against this hash, aborting on
|
||||||
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
# mismatch — so it MUST be the per-version URL, never the mutable alias).
|
||||||
|
|||||||
Generated
+64
-27
@@ -656,6 +656,30 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20poly1305"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"chacha20",
|
||||||
|
"cipher",
|
||||||
|
"poly1305",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ciborium"
|
name = "ciborium"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -691,6 +715,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
"inout",
|
"inout",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2159,7 +2184,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2264,7 +2289,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2299,7 +2324,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2788,7 +2813,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2808,7 +2833,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2832,7 +2857,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2850,7 +2875,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2871,7 +2896,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2895,7 +2920,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2904,7 +2929,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2916,7 +2941,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2930,11 +2955,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2962,14 +2987,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2984,7 +3009,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3014,7 +3039,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3026,7 +3051,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3137,6 +3162,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "poly1305"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3222,7 +3258,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3238,7 +3274,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3254,7 +3290,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3269,7 +3305,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3288,11 +3324,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cbindgen",
|
"cbindgen",
|
||||||
|
"chacha20poly1305",
|
||||||
"criterion",
|
"criterion",
|
||||||
"fec-rs",
|
"fec-rs",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -3319,7 +3356,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3403,7 +3440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3417,7 +3454,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3440,7 +3477,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.17.1"
|
version = "0.17.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.17.1"
|
"version": "0.17.2"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
|
||||||
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
|
||||||
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
|
||||||
|
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
|
||||||
|
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||||
#
|
#
|
||||||
@@ -61,10 +63,17 @@ if [ -z "${PF_HOST:-}" ]; then
|
|||||||
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
|
||||||
|
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
|
||||||
|
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
|
||||||
|
set -- --fullscreen
|
||||||
|
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||||
|
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
|
||||||
|
fi
|
||||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||||
fi
|
fi
|
||||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
|
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ function setShortcutHidden(appId: number, hidden: boolean): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
// Bump when the shipped artwork changes so existing shortcuts re-apply it once (per appId).
|
||||||
const ART_VERSION = 2;
|
// v3: CI zips through 0.17.1 shipped no assets/ at all, yet v2 was still recorded as applied
|
||||||
|
// on those installs — the bump makes them re-apply once on the first build that has the files.
|
||||||
|
const ART_VERSION = 3;
|
||||||
function artKey(appId: number): string {
|
function artKey(appId: number): string {
|
||||||
return `punktfunk:shortcutArt:${appId}`;
|
return `punktfunk:shortcutArt:${appId}`;
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,7 @@ function artKey(appId: number): string {
|
|||||||
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
* Apply the plugin's grid/hero/logo/icon to a shortcut (idempotent, once per ART_VERSION per
|
||||||
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
* appId). Cosmetic and fully best-effort: any failure is swallowed and retried on the next call.
|
||||||
*/
|
*/
|
||||||
async function applyArtwork(appId: number): Promise<void> {
|
async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
if (localStorage.getItem(artKey(appId)) === `${ART_VERSION}`) {
|
||||||
return;
|
return;
|
||||||
@@ -91,16 +93,29 @@ async function applyArtwork(appId: number): Promise<void> {
|
|||||||
[art.logo, 2],
|
[art.logo, 2],
|
||||||
[art.gridwide, 3],
|
[art.gridwide, 3],
|
||||||
];
|
];
|
||||||
|
let applied = false;
|
||||||
for (const [data, assetType] of assets) {
|
for (const [data, assetType] of assets) {
|
||||||
if (data) {
|
if (data) {
|
||||||
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
await SteamClient.Apps.SetCustomArtworkForApp(appId, data, "png", assetType);
|
||||||
|
applied = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (art.icon_path) {
|
if (art.icon_path) {
|
||||||
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
SteamClient.Apps.SetShortcutIcon(appId, art.icon_path);
|
||||||
|
applied = true;
|
||||||
|
}
|
||||||
|
// Only record "done" when something actually landed — a plugin build whose assets/ is
|
||||||
|
// missing/empty must keep retrying on later mounts instead of poisoning the marker.
|
||||||
|
if (applied) {
|
||||||
|
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
||||||
}
|
}
|
||||||
localStorage.setItem(artKey(appId), `${ART_VERSION}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// A shortcut fresh out of AddShortcut may not be registered yet (the same race
|
||||||
|
// setShortcutHidden defers around) — one deferred second attempt, then leave it to
|
||||||
|
// the next mount.
|
||||||
|
if (!isRetry) {
|
||||||
|
setTimeout(() => void applyArtwork(appId, true), 2500);
|
||||||
|
}
|
||||||
console.warn("punktfunk: shortcut artwork not applied", e);
|
console.warn("punktfunk: shortcut artwork not applied", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +172,9 @@ async function ensureControllerConfig(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r = await applyControllerConfig(SHORTCUT_NAME);
|
const r = await applyControllerConfig(SHORTCUT_NAME);
|
||||||
if (r?.ok) {
|
// `ok` alone isn't done: with zero account configset dirs (fresh Steam) the backend
|
||||||
|
// succeeds without pointing any account at the template — keep retrying until one lands.
|
||||||
|
if (r?.ok && (r.applied ?? []).some((a) => a.startsWith("configset:"))) {
|
||||||
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
localStorage.setItem(CONFIG_KEY, `${CONFIG_VERSION}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn("punktfunk: controller config not fully applied", r);
|
console.warn("punktfunk: controller config not fully applied", r);
|
||||||
@@ -283,13 +300,21 @@ export async function launchStream(
|
|||||||
opts: LaunchOpts = {},
|
opts: LaunchOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||||
// so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame.
|
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
|
||||||
|
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
|
||||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||||
const { appId, runner } = await ensureStreamShortcut();
|
const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||||
const env = [`PF_HOST=${target}`];
|
const env = [`PF_HOST=${target}`];
|
||||||
|
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||||
|
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||||
|
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||||
|
// already-awake host the connect still lands in under a second, so this costs nothing.
|
||||||
|
if (woke.ok) {
|
||||||
|
env.push("PF_CONNECT_TIMEOUT=75");
|
||||||
|
}
|
||||||
if (opts.browse) {
|
if (opts.browse) {
|
||||||
env.push("PF_BROWSE=1");
|
env.push("PF_BROWSE=1");
|
||||||
if (opts.mgmt) {
|
if (opts.mgmt) {
|
||||||
@@ -303,9 +328,9 @@ export async function launchStream(
|
|||||||
env.push(`PF_LAUNCH=${opts.launchId}`);
|
env.push(`PF_LAUNCH=${opts.launchId}`);
|
||||||
}
|
}
|
||||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||||
// script rides behind it as an argument and reads PF_* from the environment.
|
// script rides behind it as an argument and reads PF_* from the environment. The wake was
|
||||||
|
// awaited above, so the magic packet is out before the connect attempt.
|
||||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||||
await waking; // ensure the magic packet is out before the connect attempt
|
|
||||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,13 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
if std::env::var_os("PUNKTFUNK_CLIENT_444").is_some() {
|
||||||
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||||
}
|
}
|
||||||
|
// PUNKTFUNK_CLIENT_CHACHA20=1 advertises VIDEO_CAP_CHACHA20 — drives the
|
||||||
|
// host's ChaCha20-Poly1305 session-cipher resolution (the soft-AES armv7
|
||||||
|
// negotiation, design/chacha20-session-cipher.md §7) without a webOS build;
|
||||||
|
// the negotiated cipher is reported in the welcome log line below.
|
||||||
|
if std::env::var_os("PUNKTFUNK_CLIENT_CHACHA20").is_some() {
|
||||||
|
caps |= punktfunk_core::quic::VIDEO_CAP_CHACHA20;
|
||||||
|
}
|
||||||
caps
|
caps
|
||||||
},
|
},
|
||||||
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
// `--audio-channels` (default stereo); the probe multistream-decodes + validates the
|
||||||
@@ -535,6 +542,11 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
chroma_444 = welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||||
chroma_format_idc = welcome.chroma_format,
|
chroma_format_idc = welcome.chroma_format,
|
||||||
codec = codec_ext(welcome.codec),
|
codec = codec_ext(welcome.codec),
|
||||||
|
cipher = if welcome.cipher == punktfunk_core::quic::CIPHER_CHACHA20_POLY1305 {
|
||||||
|
"chacha20-poly1305"
|
||||||
|
} else {
|
||||||
|
"aes-128-gcm"
|
||||||
|
},
|
||||||
"session offer"
|
"session offer"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ pub struct HostConfig {
|
|||||||
/// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0.
|
/// deliver full chroma, and the GPU/driver passed the encode probe — otherwise 4:2:0.
|
||||||
/// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth).
|
/// `PUNKTFUNK_444=0`/`false`/`off`/`no` disables. Independent of `ten_bit` (chroma vs depth).
|
||||||
pub four_four_four: bool,
|
pub four_four_four: bool,
|
||||||
|
/// `PUNKTFUNK_CHACHA20` — host policy gate for the negotiated ChaCha20-Poly1305 session
|
||||||
|
/// cipher (design/chacha20-session-cipher.md). **Default ON** (pure rollout safety — perf-only,
|
||||||
|
/// both AEADs are full-strength): the host merely *allows* it — a session only seals with
|
||||||
|
/// ChaCha when the client advertised `VIDEO_CAP_CHACHA20` (set by soft-AES armv7 clients,
|
||||||
|
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
|
||||||
|
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
|
||||||
|
pub chacha20: bool,
|
||||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||||
pub perf: bool,
|
pub perf: bool,
|
||||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||||
@@ -147,6 +154,16 @@ impl HostConfig {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
|
||||||
|
// per-session switch; see the field doc).
|
||||||
|
chacha20: val("PUNKTFUNK_CHACHA20")
|
||||||
|
.map(|s| {
|
||||||
|
!matches!(
|
||||||
|
s.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"0" | "false" | "off" | "no"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(true),
|
||||||
perf: flag("PUNKTFUNK_PERF"),
|
perf: flag("PUNKTFUNK_PERF"),
|
||||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the w
|
|||||||
# NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause).
|
# NOT interoperable.) See vendor/fec-rs/LICENSE (BSD-2-Clause).
|
||||||
fec-rs = { path = "vendor/fec-rs" }
|
fec-rs = { path = "vendor/fec-rs" }
|
||||||
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
|
aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
|
||||||
|
# ChaCha20-Poly1305 session crypto, negotiated by clients without hardware AES (the soft-AES
|
||||||
|
# armv7 targets — webOS TVs — where GCM caps decrypt at ~100 Mbps; ARX runs 4-7x faster there).
|
||||||
|
# Same RustCrypto `aead 0.5` generation as aes-gcm: identical trait/nonce/tag shapes, pure Rust,
|
||||||
|
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
zerocopy = { version = "0.8", features = ["derive"] }
|
zerocopy = { version = "0.8", features = ["derive"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
socket2 = { version = "0.6", features = [
|
socket2 = { version = "0.6", features = [
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI.
|
//! Tier-1 microbenchmarks for the punktfunk/1 hot path — GPU-free, so they run in normal CI.
|
||||||
//!
|
//!
|
||||||
//! Two layers:
|
//! Two layers:
|
||||||
//! - `crypto/*` — the isolated AES-128-GCM primitives on one ~MTU shard.
|
//! - `crypto/*` — the isolated AEAD primitives (AES-128-GCM + the negotiated
|
||||||
|
//! ChaCha20-Poly1305) on one ~MTU shard.
|
||||||
//! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process
|
//! - `pipeline/*`— a whole frame through the real per-frame path end to end over the in-process
|
||||||
//! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble →
|
//! loopback transport: FEC encode → AES-GCM seal → packetize → (loopback) → reassemble →
|
||||||
//! FEC decode → open. This is what a throughput/latency regression in the core would show up in.
|
//! FEC decode → open. This is what a throughput/latency regression in the core would show up in.
|
||||||
@@ -11,11 +12,11 @@
|
|||||||
|
|
||||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||||
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||||
use punktfunk_core::crypto::SessionCrypto;
|
use punktfunk_core::crypto::{SessionCrypto, SessionKey};
|
||||||
use punktfunk_core::session::Session;
|
use punktfunk_core::session::Session;
|
||||||
use punktfunk_core::transport::loopback_pair;
|
use punktfunk_core::transport::loopback_pair;
|
||||||
|
|
||||||
const TAG_LEN: usize = 16; // AES-GCM authentication tag
|
const TAG_LEN: usize = 16; // AEAD authentication tag (GCM and Poly1305 share the size)
|
||||||
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
|
const SHARD: usize = punktfunk_core::config::mtu1500_shard_payload(); // one MTU-safe data shard
|
||||||
|
|
||||||
fn cfg(role: Role, scheme: FecScheme) -> Config {
|
fn cfg(role: Role, scheme: FecScheme) -> Config {
|
||||||
@@ -38,48 +39,57 @@ fn cfg(role: Role, scheme: FecScheme) -> Config {
|
|||||||
shard_payload: SHARD,
|
shard_payload: SHARD,
|
||||||
max_frame_bytes: 8 * 1024 * 1024,
|
max_frame_bytes: 8 * 1024 * 1024,
|
||||||
encrypt: true, // bench the real path — crypto is always on for punktfunk/1
|
encrypt: true, // bench the real path — crypto is always on for punktfunk/1
|
||||||
key: [7u8; 16],
|
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||||
salt: [1, 2, 3, 4],
|
salt: [1, 2, 3, 4],
|
||||||
loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery)
|
loopback_drop_period: 0, // throughput run: no induced loss (loss-harness covers recovery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bench_crypto(c: &mut Criterion) {
|
fn bench_crypto(c: &mut Criterion) {
|
||||||
let host = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Host);
|
|
||||||
let client = SessionCrypto::new(&[7u8; 16], [1, 2, 3, 4], Role::Client);
|
|
||||||
let payload = vec![0xABu8; SHARD];
|
|
||||||
let sealed = host.seal(0, &payload).unwrap();
|
|
||||||
|
|
||||||
let mut g = c.benchmark_group("crypto");
|
let mut g = c.benchmark_group("crypto");
|
||||||
g.throughput(Throughput::Bytes(SHARD as u64));
|
g.throughput(Throughput::Bytes(SHARD as u64));
|
||||||
g.bench_function("seal", |b| {
|
// Both negotiated session AEADs. On the x86 / Apple Silicon this runs on, both must be
|
||||||
let mut seq = 0u64;
|
// line-rate-trivial — the chacha20 series is the host-side sealing-cost check for the
|
||||||
b.iter(|| {
|
// negotiated soft-AES-armv7 path (design/chacha20-session-cipher.md §7). The AES series
|
||||||
let ct = host.seal(seq, black_box(&payload)).unwrap();
|
// keeps its unsuffixed names so the CI regression compare retains its history.
|
||||||
seq += 1;
|
for (suffix, key) in [
|
||||||
black_box(ct)
|
("", SessionKey::Aes128Gcm([7u8; 16])),
|
||||||
})
|
("_chacha20", SessionKey::ChaCha20Poly1305([7u8; 32])),
|
||||||
});
|
] {
|
||||||
g.bench_function("seal_in_place", |b| {
|
let host = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Host);
|
||||||
let mut seq = 0u64;
|
let client = SessionCrypto::new(&key, [1, 2, 3, 4], Role::Client);
|
||||||
let mut buf = vec![0xABu8; SHARD + TAG_LEN];
|
let payload = vec![0xABu8; SHARD];
|
||||||
b.iter(|| {
|
let sealed = host.seal(0, &payload).unwrap();
|
||||||
host.seal_in_place(seq, black_box(&mut buf)).unwrap();
|
|
||||||
seq += 1;
|
g.bench_function(format!("seal{suffix}"), |b| {
|
||||||
})
|
let mut seq = 0u64;
|
||||||
});
|
b.iter(|| {
|
||||||
g.bench_function("open", |b| {
|
let ct = host.seal(seq, black_box(&payload)).unwrap();
|
||||||
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
|
seq += 1;
|
||||||
});
|
black_box(ct)
|
||||||
g.bench_function("open_in_place", |b| {
|
})
|
||||||
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
|
});
|
||||||
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
|
g.bench_function(format!("seal_in_place{suffix}"), |b| {
|
||||||
let mut buf = sealed.clone();
|
let mut seq = 0u64;
|
||||||
b.iter(|| {
|
let mut buf = vec![0xABu8; SHARD + TAG_LEN];
|
||||||
buf.copy_from_slice(black_box(&sealed));
|
b.iter(|| {
|
||||||
black_box(client.open_in_place(0, &mut buf).unwrap());
|
host.seal_in_place(seq, black_box(&mut buf)).unwrap();
|
||||||
})
|
seq += 1;
|
||||||
});
|
})
|
||||||
|
});
|
||||||
|
g.bench_function(format!("open{suffix}"), |b| {
|
||||||
|
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
|
||||||
|
});
|
||||||
|
g.bench_function(format!("open_in_place{suffix}"), |b| {
|
||||||
|
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
|
||||||
|
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
|
||||||
|
let mut buf = sealed.clone();
|
||||||
|
b.iter(|| {
|
||||||
|
buf.copy_from_slice(black_box(&sealed));
|
||||||
|
black_box(client.open_in_place(0, &mut buf).unwrap());
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
g.finish();
|
g.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`.
|
//! - Panics never cross the boundary: every entry point is wrapped in `catch_unwind`.
|
||||||
|
|
||||||
use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
use crate::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||||
|
use crate::crypto::SessionKey;
|
||||||
use crate::error::PunktfunkStatus;
|
use crate::error::PunktfunkStatus;
|
||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
use crate::reanchor::{GateVerdict, ReanchorGate};
|
use crate::reanchor::{GateVerdict, ReanchorGate};
|
||||||
@@ -94,7 +95,10 @@ impl PunktfunkConfig {
|
|||||||
shard_payload: self.shard_payload as usize,
|
shard_payload: self.shard_payload as usize,
|
||||||
max_frame_bytes,
|
max_frame_bytes,
|
||||||
encrypt: self.encrypt != 0,
|
encrypt: self.encrypt != 0,
|
||||||
key: self.key,
|
// The C ABI keeps its fixed 16-byte key and always selects AES-128-GCM — no
|
||||||
|
// ABI_VERSION bump. Raw-`Config` C embedders can't negotiate ChaCha; the Swift/
|
||||||
|
// Kotlin clients are aarch64 with AES CE and never want it.
|
||||||
|
key: SessionKey::Aes128Gcm(self.key),
|
||||||
salt: self.salt,
|
salt: self.salt,
|
||||||
loopback_drop_period: self.loopback_drop_period,
|
loopback_drop_period: self.loopback_drop_period,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ impl NativeClient {
|
|||||||
launch,
|
launch,
|
||||||
pin,
|
pin,
|
||||||
identity,
|
identity,
|
||||||
|
connect_timeout: timeout,
|
||||||
frames: frame_chan_w,
|
frames: frame_chan_w,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
|
|||||||
@@ -32,21 +32,65 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
|||||||
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
||||||
);
|
);
|
||||||
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||||
let conn = ep
|
// Dial with retry across the connect budget, not a single attempt: one quinn dial gives
|
||||||
.connect(remote, "punktfunk")
|
// up after the transport's idle window (~8 s of silence), which is shorter than a
|
||||||
.map_err(|_| PunktfunkError::InvalidArg("connect"))?
|
// suspend-to-RAM resume — the Steam Deck flow fires Wake-on-LAN and connects
|
||||||
.await
|
// immediately, so the host is still waking while the first Initials go out, and a
|
||||||
.map_err(|e| {
|
// single-shot dial died just before the host came up. Short attempts keep the Initial
|
||||||
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
|
// cadence dense (quinn's per-attempt retransmits back off toward multi-second gaps), so
|
||||||
// the embedder can distinguish "wrong host identity" from plain IO trouble.
|
// the connect lands within ~a second of the host's network returning. Only SILENCE is
|
||||||
let fp_mismatch =
|
// retried: a host that answers and rejects us (pin mismatch, ALPN/version, typed close)
|
||||||
pin.is_some() && observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
// must surface immediately, and the embedder's shutdown flag (budget expiry in
|
||||||
if fp_mismatch {
|
// `connect`, or a user cancel) stops the loop between attempts.
|
||||||
PunktfunkError::Crypto
|
const DIAL_ATTEMPT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
} else {
|
// Redial headroom: leave room for the control handshake (Hello/Welcome/clock sync)
|
||||||
PunktfunkError::Io(std::io::Error::other(e.to_string()))
|
// after a late dial success, so it still completes inside the embedder's budget.
|
||||||
|
const CONTROL_HEADROOM: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
let start = tokio::time::Instant::now();
|
||||||
|
let deadline = start + args.connect_timeout;
|
||||||
|
let redial_until = start + args.connect_timeout.saturating_sub(CONTROL_HEADROOM);
|
||||||
|
let conn = loop {
|
||||||
|
let connecting = ep
|
||||||
|
.connect(remote, "punktfunk")
|
||||||
|
.map_err(|_| PunktfunkError::InvalidArg("connect"))?;
|
||||||
|
// Cap the attempt to the remaining budget so a success never lands after the
|
||||||
|
// embedder's `ready_rx` wait has already given up and flagged a teardown.
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let attempt = DIAL_ATTEMPT.min(deadline.saturating_duration_since(now));
|
||||||
|
let gave_up = || {
|
||||||
|
tokio::time::Instant::now() >= redial_until
|
||||||
|
|| shutdown.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
|
};
|
||||||
|
match tokio::time::timeout(attempt, connecting).await {
|
||||||
|
Ok(Ok(conn)) => break conn,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
|
||||||
|
// the embedder can distinguish "wrong host identity" from plain IO trouble.
|
||||||
|
let fp_mismatch = pin.is_some()
|
||||||
|
&& observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
||||||
|
if fp_mismatch {
|
||||||
|
return Err(PunktfunkError::Crypto);
|
||||||
|
}
|
||||||
|
// The transport's own idle expiry — the host never answered — is the one
|
||||||
|
// retryable outcome; everything else is a real answer or a local failure.
|
||||||
|
let host_silent = matches!(e, quinn::ConnectionError::TimedOut);
|
||||||
|
if !host_silent {
|
||||||
|
return Err(PunktfunkError::Io(std::io::Error::other(e.to_string())));
|
||||||
|
}
|
||||||
|
if gave_up() {
|
||||||
|
return Err(PunktfunkError::Timeout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})?;
|
// Attempt window elapsed with the host still silent; dropping `connecting`
|
||||||
|
// abandoned that dial — go again unless the budget is spent.
|
||||||
|
Err(_) => {
|
||||||
|
if gave_up() {
|
||||||
|
return Err(PunktfunkError::Timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::debug!(%remote, "host silent — re-dialing (wake/resume tolerant connect)");
|
||||||
|
};
|
||||||
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
||||||
// The rest of the handshake runs in an inner future so a failure can consult
|
// The rest of the handshake runs in an inner future so a failure can consult
|
||||||
// `conn.close_reason()`: a host that turned us away with a typed application close
|
// `conn.close_reason()`: a host that turned us away with a typed application close
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ pub(crate) struct WorkerArgs {
|
|||||||
pub(crate) launch: Option<String>,
|
pub(crate) launch: Option<String>,
|
||||||
pub(crate) pin: Option<[u8; 32]>,
|
pub(crate) pin: Option<[u8; 32]>,
|
||||||
pub(crate) identity: Option<(String, String)>,
|
pub(crate) identity: Option<(String, String)>,
|
||||||
|
/// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the
|
||||||
|
/// dial loop re-dials a silent host within it, so a host still resuming from Wake-on-LAN
|
||||||
|
/// is caught the moment its network comes back instead of failing on the first attempt.
|
||||||
|
pub(crate) connect_timeout: std::time::Duration,
|
||||||
pub(crate) frames: Arc<FrameChannel>,
|
pub(crate) frames: Arc<FrameChannel>,
|
||||||
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
||||||
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Session configuration and protocol/FEC parameters.
|
//! Session configuration and protocol/FEC parameters.
|
||||||
|
|
||||||
|
use crate::crypto::SessionKey;
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES};
|
use crate::packet::{CRYPTO_OVERHEAD, HEADER_LEN, MAX_DATAGRAM_BYTES};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
@@ -355,9 +356,11 @@ pub struct Config {
|
|||||||
/// hostile/corrupt headers; see [`Session`](crate::session::Session)).
|
/// hostile/corrupt headers; see [`Session`](crate::session::Session)).
|
||||||
pub max_frame_bytes: usize,
|
pub max_frame_bytes: usize,
|
||||||
pub encrypt: bool,
|
pub encrypt: bool,
|
||||||
/// AES-128 session key established during pairing. MUST be unique per session when
|
/// The negotiated session AEAD + its key, established during pairing/handshake —
|
||||||
|
/// AES-128-GCM for every peer by default, ChaCha20-Poly1305 when the client negotiated it
|
||||||
|
/// (soft-AES armv7 targets; see [`SessionKey`]). MUST be unique per session when
|
||||||
/// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]).
|
/// `encrypt` is set (see the nonce-uniqueness contract in [`crate::crypto`]).
|
||||||
pub key: [u8; 16],
|
pub key: SessionKey,
|
||||||
/// Per-session nonce salt, established alongside `key` during pairing. MUST be
|
/// Per-session nonce salt, established alongside `key` during pairing. MUST be
|
||||||
/// unique per (key, session).
|
/// unique per (key, session).
|
||||||
pub salt: [u8; 4],
|
pub salt: [u8; 4],
|
||||||
@@ -382,7 +385,8 @@ impl std::fmt::Debug for Config {
|
|||||||
.field("shard_payload", &self.shard_payload)
|
.field("shard_payload", &self.shard_payload)
|
||||||
.field("max_frame_bytes", &self.max_frame_bytes)
|
.field("max_frame_bytes", &self.max_frame_bytes)
|
||||||
.field("encrypt", &self.encrypt)
|
.field("encrypt", &self.encrypt)
|
||||||
.field("key", &"<redacted>")
|
// SessionKey's own Debug redacts the material but keeps the cipher choice visible.
|
||||||
|
.field("key", &self.key)
|
||||||
.field("salt", &"<redacted>")
|
.field("salt", &"<redacted>")
|
||||||
.field("loopback_drop_period", &self.loopback_drop_period)
|
.field("loopback_drop_period", &self.loopback_drop_period)
|
||||||
.finish()
|
.finish()
|
||||||
@@ -426,7 +430,7 @@ impl Config {
|
|||||||
"max_frame_bytes too large for this shard/block configuration (block count overflows u16)",
|
"max_frame_bytes too large for this shard/block configuration (block count overflows u16)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.encrypt && self.key == [0u8; 16] {
|
if self.encrypt && self.key.is_zero() {
|
||||||
return Err(PunktfunkError::InvalidArg(
|
return Err(PunktfunkError::InvalidArg(
|
||||||
"encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)",
|
"encrypt requires a non-zero session key (see crypto nonce-uniqueness contract)",
|
||||||
));
|
));
|
||||||
@@ -449,7 +453,7 @@ impl Config {
|
|||||||
shard_payload: 1024,
|
shard_payload: 1024,
|
||||||
max_frame_bytes: 64 * 1024 * 1024,
|
max_frame_bytes: 64 * 1024 * 1024,
|
||||||
encrypt: false,
|
encrypt: false,
|
||||||
key: [0u8; 16],
|
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||||
salt: [0u8; 4],
|
salt: [0u8; 4],
|
||||||
loopback_drop_period: 0,
|
loopback_drop_period: 0,
|
||||||
}
|
}
|
||||||
@@ -465,7 +469,12 @@ mod tests {
|
|||||||
let mut c = Config::p1_defaults(Role::Host);
|
let mut c = Config::p1_defaults(Role::Host);
|
||||||
c.encrypt = true; // key is still all-zero
|
c.encrypt = true; // key is still all-zero
|
||||||
assert!(c.validate().is_err());
|
assert!(c.validate().is_err());
|
||||||
c.key = [1u8; 16];
|
c.key = SessionKey::Aes128Gcm([1u8; 16]);
|
||||||
|
assert!(c.validate().is_ok());
|
||||||
|
// The rejection follows whichever cipher variant is active.
|
||||||
|
c.key = SessionKey::ChaCha20Poly1305([0u8; 32]);
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
c.key = SessionKey::ChaCha20Poly1305([1u8; 32]);
|
||||||
assert!(c.validate().is_ok());
|
assert!(c.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+266
-106
@@ -1,9 +1,10 @@
|
|||||||
//! AES-128-GCM session sealing, matching GameStream's video crypto in P1.
|
//! Session sealing with the negotiated AEAD — AES-128-GCM (matching GameStream's video
|
||||||
|
//! crypto in P1) by default, ChaCha20-Poly1305 (RFC 8439) for clients without hardware AES.
|
||||||
//!
|
//!
|
||||||
//! ## Nonce uniqueness (the GCM safety requirement)
|
//! ## Nonce uniqueness (the AEAD safety requirement)
|
||||||
//!
|
//!
|
||||||
//! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a
|
//! The 96-bit nonce is `salt (4 bytes) || sequence (8 bytes, big-endian)`. Reusing a
|
||||||
//! `(key, nonce)` pair under AES-GCM is catastrophic, so two precautions apply:
|
//! `(key, nonce)` pair is catastrophic under either AEAD, so two precautions apply:
|
||||||
//!
|
//!
|
||||||
//! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each
|
//! 1. **Per-direction salts.** Host and client share one `key` and `salt`, and each
|
||||||
//! counts its sequence from 0. To stop the host's video stream and the client's input
|
//! counts its sequence from 0. To stop the host's video stream and the client's input
|
||||||
@@ -17,17 +18,96 @@
|
|||||||
//! The sequence number is also passed as AEAD associated data, so tampering with the
|
//! The sequence number is also passed as AEAD associated data, so tampering with the
|
||||||
//! on-wire sequence is detected (the tag check fails) rather than silently shifting the
|
//! on-wire sequence is detected (the tag check fails) rather than silently shifting the
|
||||||
//! nonce. Note: this layer does not provide anti-replay — see `Session`.
|
//! nonce. Note: this layer does not provide anti-replay — see `Session`.
|
||||||
|
//!
|
||||||
|
//! ## Why two ciphers
|
||||||
|
//!
|
||||||
|
//! Both AEADs are full-strength; the choice (negotiated via `Welcome::cipher`) is purely a
|
||||||
|
//! performance one. On targets without hardware AES — the soft-AES armv7 clients (webOS TVs) —
|
||||||
|
//! GCM's fixsliced AES + software GHASH costs ~50–100 cycles/byte and caps decrypt at
|
||||||
|
//! ~100 Mbps, while ChaCha20-Poly1305's ARX construction runs ~10–17 cycles/byte in portable
|
||||||
|
//! software (design/chacha20-session-cipher.md). Same 96-bit nonce, 16-byte tag, and AAD
|
||||||
|
//! shape, so the entire nonce discipline above carries over verbatim.
|
||||||
|
|
||||||
use crate::config::Role;
|
use crate::config::Role;
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
|
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
|
||||||
use aes_gcm::{Aes128Gcm, Key, Nonce};
|
use aes_gcm::{Aes128Gcm, Key, Nonce};
|
||||||
|
use chacha20poly1305::ChaCha20Poly1305;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
/// 16-byte AEAD authentication tag appended by GCM.
|
/// 16-byte AEAD authentication tag appended by either session cipher.
|
||||||
pub const TAG_LEN: usize = 16;
|
pub const TAG_LEN: usize = 16;
|
||||||
|
|
||||||
|
// The wire (CRYPTO_OVERHEAD) and every in-place split assume both negotiated AEADs append
|
||||||
|
// exactly TAG_LEN bytes — a different-tag cipher can never slip in behind this constant.
|
||||||
|
const _: () = assert!(std::mem::size_of::<aes_gcm::Tag>() == TAG_LEN);
|
||||||
|
const _: () = assert!(std::mem::size_of::<chacha20poly1305::Tag>() == TAG_LEN);
|
||||||
|
|
||||||
|
/// The negotiated session AEAD together with its key material — merged so the invalid state
|
||||||
|
/// (a ChaCha cipher with an AES-sized key, or vice versa) is unrepresentable. AES-128-GCM is
|
||||||
|
/// the default every peer speaks; ChaCha20-Poly1305 is granted to clients that advertised
|
||||||
|
/// [`VIDEO_CAP_CHACHA20`](crate::quic::VIDEO_CAP_CHACHA20) (the soft-AES armv7 targets —
|
||||||
|
/// see the module docs). 256 bits for ChaCha is what RFC 8439 requires.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SessionKey {
|
||||||
|
Aes128Gcm([u8; 16]),
|
||||||
|
ChaCha20Poly1305([u8; 32]),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionKey {
|
||||||
|
/// Canonical lowercase cipher name for session-start logs.
|
||||||
|
pub fn cipher_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SessionKey::Aes128Gcm(_) => "aes-128-gcm",
|
||||||
|
SessionKey::ChaCha20Poly1305(_) => "chacha20-poly1305",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the key material is all zeros — the pairing-layer footgun `Config::validate`
|
||||||
|
/// rejects when encryption is on (see the nonce-uniqueness contract in the module docs).
|
||||||
|
pub fn is_zero(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
SessionKey::Aes128Gcm(k) => k == &[0u8; 16],
|
||||||
|
SessionKey::ChaCha20Poly1305(k) => k == &[0u8; 32],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key material never appears in logs, whichever variant is active — only the cipher choice
|
||||||
|
/// (`Config`'s hand-written `Debug` relies on this).
|
||||||
|
impl std::fmt::Debug for SessionKey {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SessionKey::Aes128Gcm(_) => f.write_str("Aes128Gcm(<redacted>)"),
|
||||||
|
SessionKey::ChaCha20Poly1305(_) => f.write_str("ChaCha20Poly1305(<redacted>)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same zeroize-on-drop discipline the raw key array had (`Config`'s `Drop`).
|
||||||
|
impl Zeroize for SessionKey {
|
||||||
|
fn zeroize(&mut self) {
|
||||||
|
match self {
|
||||||
|
SessionKey::Aes128Gcm(k) => k.zeroize(),
|
||||||
|
SessionKey::ChaCha20Poly1305(k) => k.zeroize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two negotiated AEADs behind one seal/open surface. Both are the same RustCrypto
|
||||||
|
/// `aead 0.5` generation (identical trait shapes, nonce/tag types), so each call below is a
|
||||||
|
/// two-arm match right next to the cipher work itself.
|
||||||
|
// AES's precomputed round keys (~0.7 KB) dwarf ChaCha's 32-byte state, but there is exactly
|
||||||
|
// one long-lived `SessionCrypto` per session — boxing the variant would trade that one-off
|
||||||
|
// slack for a pointer chase on every per-datagram seal/open.
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum Cipher {
|
||||||
|
Aes128Gcm(Aes128Gcm),
|
||||||
|
ChaCha20Poly1305(ChaCha20Poly1305),
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SessionCrypto {
|
pub struct SessionCrypto {
|
||||||
cipher: Aes128Gcm,
|
cipher: Cipher,
|
||||||
/// Salt for nonces we seal with (our direction).
|
/// Salt for nonces we seal with (our direction).
|
||||||
send_salt: [u8; 4],
|
send_salt: [u8; 4],
|
||||||
/// Salt for nonces we open with (the peer's direction).
|
/// Salt for nonces we open with (the peer's direction).
|
||||||
@@ -35,11 +115,18 @@ pub struct SessionCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionCrypto {
|
impl SessionCrypto {
|
||||||
pub fn new(key: &[u8; 16], salt: [u8; 4], role: Role) -> Self {
|
pub fn new(key: &SessionKey, salt: [u8; 4], role: Role) -> Self {
|
||||||
let key = Key::<Aes128Gcm>::from_slice(key);
|
let cipher = match key {
|
||||||
|
SessionKey::Aes128Gcm(k) => {
|
||||||
|
Cipher::Aes128Gcm(Aes128Gcm::new(Key::<Aes128Gcm>::from_slice(k)))
|
||||||
|
}
|
||||||
|
SessionKey::ChaCha20Poly1305(k) => Cipher::ChaCha20Poly1305(ChaCha20Poly1305::new(
|
||||||
|
Key::<ChaCha20Poly1305>::from_slice(k),
|
||||||
|
)),
|
||||||
|
};
|
||||||
let own = direction(role);
|
let own = direction(role);
|
||||||
SessionCrypto {
|
SessionCrypto {
|
||||||
cipher: Aes128Gcm::new(key),
|
cipher,
|
||||||
send_salt: dir_salt(salt, own),
|
send_salt: dir_salt(salt, own),
|
||||||
recv_salt: dir_salt(salt, own ^ 1),
|
recv_salt: dir_salt(salt, own ^ 1),
|
||||||
}
|
}
|
||||||
@@ -49,15 +136,16 @@ impl SessionCrypto {
|
|||||||
/// authenticated as associated data.
|
/// authenticated as associated data.
|
||||||
pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
|
pub fn seal(&self, seq: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||||
let nonce = nonce(self.send_salt, seq);
|
let nonce = nonce(self.send_salt, seq);
|
||||||
self.cipher
|
let aad = seq.to_be_bytes();
|
||||||
.encrypt(
|
let payload = Payload {
|
||||||
Nonce::from_slice(&nonce),
|
msg: plaintext,
|
||||||
Payload {
|
aad: &aad,
|
||||||
msg: plaintext,
|
};
|
||||||
aad: &seq.to_be_bytes(),
|
match &self.cipher {
|
||||||
},
|
Cipher::Aes128Gcm(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
|
||||||
)
|
Cipher::ChaCha20Poly1305(c) => c.encrypt(Nonce::from_slice(&nonce), payload),
|
||||||
.map_err(|_| PunktfunkError::Crypto)
|
}
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the
|
/// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the
|
||||||
@@ -69,10 +157,16 @@ impl SessionCrypto {
|
|||||||
let nonce = nonce(self.send_salt, seq);
|
let nonce = nonce(self.send_salt, seq);
|
||||||
let split = buf.len() - TAG_LEN;
|
let split = buf.len() - TAG_LEN;
|
||||||
let (plaintext, tag_slot) = buf.split_at_mut(split);
|
let (plaintext, tag_slot) = buf.split_at_mut(split);
|
||||||
let tag = self
|
let aad = seq.to_be_bytes();
|
||||||
.cipher
|
let tag = match &self.cipher {
|
||||||
.encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext)
|
Cipher::Aes128Gcm(c) => {
|
||||||
.map_err(|_| PunktfunkError::Crypto)?;
|
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
|
||||||
|
}
|
||||||
|
Cipher::ChaCha20Poly1305(c) => {
|
||||||
|
c.encrypt_in_place_detached(Nonce::from_slice(&nonce), &aad, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)?;
|
||||||
tag_slot.copy_from_slice(&tag);
|
tag_slot.copy_from_slice(&tag);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -80,20 +174,21 @@ impl SessionCrypto {
|
|||||||
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
|
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
|
||||||
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
|
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||||
let nonce = nonce(self.recv_salt, seq);
|
let nonce = nonce(self.recv_salt, seq);
|
||||||
self.cipher
|
let aad = seq.to_be_bytes();
|
||||||
.decrypt(
|
let payload = Payload {
|
||||||
Nonce::from_slice(&nonce),
|
msg: ciphertext,
|
||||||
Payload {
|
aad: &aad,
|
||||||
msg: ciphertext,
|
};
|
||||||
aad: &seq.to_be_bytes(),
|
match &self.cipher {
|
||||||
},
|
Cipher::Aes128Gcm(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
|
||||||
)
|
Cipher::ChaCha20Poly1305(c) => c.decrypt(Nonce::from_slice(&nonce), payload),
|
||||||
.map_err(|_| PunktfunkError::Crypto)
|
}
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
|
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
|
||||||
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
|
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
|
||||||
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
|
/// — byte-identical to `open`, just written in place. Both AEADs verify the tag *before*
|
||||||
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
|
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
|
||||||
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
|
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
|
||||||
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
|
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
|
||||||
@@ -105,14 +200,22 @@ impl SessionCrypto {
|
|||||||
let nonce = nonce(self.recv_salt, seq);
|
let nonce = nonce(self.recv_salt, seq);
|
||||||
let split = buf.len() - TAG_LEN;
|
let split = buf.len() - TAG_LEN;
|
||||||
let (ciphertext, tag) = buf.split_at_mut(split);
|
let (ciphertext, tag) = buf.split_at_mut(split);
|
||||||
self.cipher
|
let aad = seq.to_be_bytes();
|
||||||
.decrypt_in_place_detached(
|
match &self.cipher {
|
||||||
|
Cipher::Aes128Gcm(c) => c.decrypt_in_place_detached(
|
||||||
Nonce::from_slice(&nonce),
|
Nonce::from_slice(&nonce),
|
||||||
&seq.to_be_bytes(),
|
&aad,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
aes_gcm::Tag::from_slice(tag),
|
aes_gcm::Tag::from_slice(tag),
|
||||||
)
|
),
|
||||||
.map_err(|_| PunktfunkError::Crypto)?;
|
Cipher::ChaCha20Poly1305(c) => c.decrypt_in_place_detached(
|
||||||
|
Nonce::from_slice(&nonce),
|
||||||
|
&aad,
|
||||||
|
ciphertext,
|
||||||
|
chacha20poly1305::Tag::from_slice(tag),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)?;
|
||||||
Ok(split)
|
Ok(split)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +248,13 @@ pub fn random_key() -> [u8; 16] {
|
|||||||
k
|
k
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh random ChaCha20-Poly1305 session key (RFC 8439's 256-bit size).
|
||||||
|
pub fn random_key32() -> [u8; 32] {
|
||||||
|
let mut k = [0u8; 32];
|
||||||
|
rand::RngCore::fill_bytes(&mut rand::rng(), &mut k);
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate a fresh random per-session nonce salt.
|
/// Generate a fresh random per-session nonce salt.
|
||||||
pub fn random_salt() -> [u8; 4] {
|
pub fn random_salt() -> [u8; 4] {
|
||||||
let mut s = [0u8; 4];
|
let mut s = [0u8; 4];
|
||||||
@@ -156,93 +266,143 @@ pub fn random_salt() -> [u8; 4] {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// One fresh key per negotiated cipher — every sealing test below must hold for both.
|
||||||
|
fn both_keys() -> [SessionKey; 2] {
|
||||||
|
[
|
||||||
|
SessionKey::Aes128Gcm(random_key()),
|
||||||
|
SessionKey::ChaCha20Poly1305(random_key32()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seal_open_roundtrip_cross_direction() {
|
fn seal_open_roundtrip_cross_direction() {
|
||||||
let key = random_key();
|
for key in both_keys() {
|
||||||
let salt = random_salt();
|
let salt = random_salt();
|
||||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||||
|
|
||||||
let msg = b"the quick brown fox";
|
let msg = b"the quick brown fox";
|
||||||
let sealed = host.seal(42, msg).unwrap(); // host -> client (video direction)
|
let sealed = host.seal(42, msg).unwrap(); // host -> client (video direction)
|
||||||
assert_ne!(&sealed[..msg.len()], &msg[..]); // actually encrypted
|
assert_ne!(&sealed[..msg.len()], &msg[..]); // actually encrypted
|
||||||
assert_eq!(sealed.len(), msg.len() + TAG_LEN);
|
assert_eq!(sealed.len(), msg.len() + TAG_LEN);
|
||||||
assert_eq!(client.open(42, &sealed).unwrap(), msg);
|
assert_eq!(client.open(42, &sealed).unwrap(), msg);
|
||||||
|
|
||||||
// Wrong sequence (nonce + AAD) → authentication failure.
|
// Wrong sequence (nonce + AAD) → authentication failure.
|
||||||
assert!(client.open(43, &sealed).is_err());
|
assert!(client.open(43, &sealed).is_err());
|
||||||
// Direction separation: the host opens with the peer (client) salt, so it cannot
|
// Direction separation: the host opens with the peer (client) salt, so it cannot
|
||||||
// open its own outbound packet → distinct nonce spaces per direction.
|
// open its own outbound packet → distinct nonce spaces per direction.
|
||||||
assert!(host.open(42, &sealed).is_err());
|
assert!(host.open(42, &sealed).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn directions_use_distinct_nonce_spaces() {
|
fn directions_use_distinct_nonce_spaces() {
|
||||||
let key = random_key();
|
for key in both_keys() {
|
||||||
let salt = [0u8; 4]; // even an all-zero base salt must separate the directions
|
let salt = [0u8; 4]; // even an all-zero base salt must separate the directions
|
||||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||||
// Same seq, same key, opposite directions → different ciphertext (no reuse).
|
// Same seq, same key, opposite directions → different ciphertext (no reuse).
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
host.seal(0, b"abc").unwrap(),
|
host.seal(0, b"abc").unwrap(),
|
||||||
client.seal(0, b"abc").unwrap()
|
client.seal(0, b"abc").unwrap()
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_in_place_matches_open_and_rejects_tampering() {
|
fn open_in_place_matches_open_and_rejects_tampering() {
|
||||||
let key = random_key();
|
for key in both_keys() {
|
||||||
let salt = random_salt();
|
let salt = random_salt();
|
||||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||||
for msg in [
|
for msg in [
|
||||||
&b""[..],
|
&b""[..],
|
||||||
b"x",
|
b"x",
|
||||||
b"the quick brown fox jumps over 13 lazy dogs!!",
|
b"the quick brown fox jumps over 13 lazy dogs!!",
|
||||||
] {
|
] {
|
||||||
let sealed = host.seal(9, msg).unwrap();
|
let sealed = host.seal(9, msg).unwrap();
|
||||||
let mut buf = sealed.clone();
|
let mut buf = sealed.clone();
|
||||||
let n = client.open_in_place(9, &mut buf).unwrap();
|
let n = client.open_in_place(9, &mut buf).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
&buf[..n],
|
&buf[..n],
|
||||||
msg,
|
msg,
|
||||||
"in-place open must be byte-identical to open"
|
"in-place open must be byte-identical to open"
|
||||||
);
|
);
|
||||||
// Wrong sequence (nonce + AAD) → authentication failure, like `open`.
|
// Wrong sequence (nonce + AAD) → authentication failure, like `open`.
|
||||||
let mut buf = sealed.clone();
|
let mut buf = sealed.clone();
|
||||||
assert!(client.open_in_place(8, &mut buf).is_err());
|
assert!(client.open_in_place(8, &mut buf).is_err());
|
||||||
// A flipped ciphertext/tag bit → authentication failure.
|
// A flipped ciphertext/tag bit → authentication failure.
|
||||||
let mut buf = sealed.clone();
|
let mut buf = sealed.clone();
|
||||||
let last = buf.len() - 1;
|
let last = buf.len() - 1;
|
||||||
buf[last] ^= 1;
|
buf[last] ^= 1;
|
||||||
assert!(client.open_in_place(9, &mut buf).is_err());
|
assert!(client.open_in_place(9, &mut buf).is_err());
|
||||||
|
}
|
||||||
|
// Shorter than a tag can't be a sealed packet at all.
|
||||||
|
let mut runt = vec![0u8; TAG_LEN - 1];
|
||||||
|
assert!(client.open_in_place(0, &mut runt).is_err());
|
||||||
}
|
}
|
||||||
// Shorter than a tag can't be a sealed packet at all.
|
|
||||||
let mut runt = vec![0u8; TAG_LEN - 1];
|
|
||||||
assert!(client.open_in_place(0, &mut runt).is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seal_in_place_matches_seal_and_opens() {
|
fn seal_in_place_matches_seal_and_opens() {
|
||||||
let key = random_key();
|
for key in both_keys() {
|
||||||
let salt = random_salt();
|
let salt = random_salt();
|
||||||
let host = SessionCrypto::new(&key, salt, Role::Host);
|
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||||
let client = SessionCrypto::new(&key, salt, Role::Client);
|
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||||
for msg in [
|
for msg in [
|
||||||
&b""[..],
|
&b""[..],
|
||||||
b"x",
|
b"x",
|
||||||
b"the quick brown fox jumps over 13 lazy dogs!!",
|
b"the quick brown fox jumps over 13 lazy dogs!!",
|
||||||
] {
|
] {
|
||||||
let reference = host.seal(7, msg).unwrap(); // ciphertext || tag
|
let reference = host.seal(7, msg).unwrap(); // ciphertext || tag
|
||||||
// In-place: [plaintext .. ][TAG_LEN scratch].
|
// In-place: [plaintext .. ][TAG_LEN scratch].
|
||||||
let mut buf = msg.to_vec();
|
let mut buf = msg.to_vec();
|
||||||
buf.resize(msg.len() + TAG_LEN, 0);
|
buf.resize(msg.len() + TAG_LEN, 0);
|
||||||
host.seal_in_place(7, &mut buf).unwrap();
|
host.seal_in_place(7, &mut buf).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf, reference,
|
buf, reference,
|
||||||
"in-place seal must be byte-identical to seal"
|
"in-place seal must be byte-identical to seal"
|
||||||
);
|
);
|
||||||
assert_eq!(client.open(7, &buf).unwrap(), msg);
|
assert_eq!(client.open(7, &buf).unwrap(), msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ciphers_are_not_interchangeable() {
|
||||||
|
// A packet sealed under one AEAD must not open under the other — negotiation skew has
|
||||||
|
// to fail loudly (a tag mismatch), never decode garbage. The ChaCha key repeats the AES
|
||||||
|
// key bytes so even overlapping key material can't accidentally interoperate.
|
||||||
|
let salt = random_salt();
|
||||||
|
let aes = SessionKey::Aes128Gcm([7u8; 16]);
|
||||||
|
let chacha = SessionKey::ChaCha20Poly1305([7u8; 32]);
|
||||||
|
let sealed = SessionCrypto::new(&aes, salt, Role::Host)
|
||||||
|
.seal(1, b"cross-cipher")
|
||||||
|
.unwrap();
|
||||||
|
assert!(SessionCrypto::new(&chacha, salt, Role::Client)
|
||||||
|
.open(1, &sealed)
|
||||||
|
.is_err());
|
||||||
|
let sealed = SessionCrypto::new(&chacha, salt, Role::Host)
|
||||||
|
.seal(1, b"cross-cipher")
|
||||||
|
.unwrap();
|
||||||
|
assert!(SessionCrypto::new(&aes, salt, Role::Client)
|
||||||
|
.open(1, &sealed)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_key_zero_check_and_debug_redaction() {
|
||||||
|
assert!(SessionKey::Aes128Gcm([0u8; 16]).is_zero());
|
||||||
|
assert!(SessionKey::ChaCha20Poly1305([0u8; 32]).is_zero());
|
||||||
|
assert!(!SessionKey::Aes128Gcm([1u8; 16]).is_zero());
|
||||||
|
assert!(!SessionKey::ChaCha20Poly1305([1u8; 32]).is_zero());
|
||||||
|
// Key bytes must never reach a log, whichever variant — only the cipher choice.
|
||||||
|
for key in both_keys() {
|
||||||
|
let dbg = format!("{key:?}");
|
||||||
|
assert!(dbg.contains("<redacted>"), "{dbg}");
|
||||||
|
}
|
||||||
|
let mut k = SessionKey::ChaCha20Poly1305([9u8; 32]);
|
||||||
|
k.zeroize();
|
||||||
|
assert!(k.is_zero());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::reassemble::LOSS_WINDOW_NS;
|
use super::reassemble::LOSS_WINDOW_NS;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{Config, FecScheme};
|
use crate::config::{Config, FecScheme};
|
||||||
|
use crate::crypto::SessionKey;
|
||||||
use crate::fec::coder_for;
|
use crate::fec::coder_for;
|
||||||
use crate::stats::StatsCounters;
|
use crate::stats::StatsCounters;
|
||||||
use zerocopy::{FromBytes, IntoBytes};
|
use zerocopy::{FromBytes, IntoBytes};
|
||||||
@@ -182,7 +183,7 @@ fn explicit_frame_index_is_stamped_and_internal_counter_untouched() {
|
|||||||
shard_payload: 16,
|
shard_payload: 16,
|
||||||
max_frame_bytes: 4096,
|
max_frame_bytes: 4096,
|
||||||
encrypt: false,
|
encrypt: false,
|
||||||
key: [0u8; 16],
|
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||||
salt: [0u8; 4],
|
salt: [0u8; 4],
|
||||||
loopback_drop_period: 0,
|
loopback_drop_period: 0,
|
||||||
};
|
};
|
||||||
@@ -292,7 +293,7 @@ fn e2e_config(scheme: FecScheme, fec_percent: u8) -> Config {
|
|||||||
shard_payload: 16,
|
shard_payload: 16,
|
||||||
max_frame_bytes: 4096,
|
max_frame_bytes: 4096,
|
||||||
encrypt: false,
|
encrypt: false,
|
||||||
key: [0u8; 16],
|
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||||
salt: [0u8; 4],
|
salt: [0u8; 4],
|
||||||
loopback_drop_period: 0,
|
loopback_drop_period: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10;
|
|||||||
/// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
|
/// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
|
||||||
/// the fallback is zero-risk.
|
/// the fallback is zero-risk.
|
||||||
pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
|
pub const VIDEO_CAP_STREAMED_AU: u8 = 0x20;
|
||||||
|
/// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
|
||||||
|
/// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
|
||||||
|
/// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
|
||||||
|
/// construction runs 4–7× faster in portable code (design/chacha20-session-cipher.md).
|
||||||
|
/// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
|
||||||
|
/// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
|
||||||
|
/// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
|
||||||
|
/// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
|
||||||
|
/// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||||
|
/// control channel, so there is no downgrade surface.
|
||||||
|
pub const VIDEO_CAP_CHACHA20: u8 = 0x40;
|
||||||
|
|
||||||
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||||
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||||
@@ -225,6 +236,8 @@ mod tests {
|
|||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: CODEC_HEVC,
|
codec: CODEC_HEVC,
|
||||||
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
|
||||||
|
cipher: 0,
|
||||||
|
key_chacha: None,
|
||||||
};
|
};
|
||||||
let got = Welcome::decode(&w.encode()).unwrap();
|
let got = Welcome::decode(&w.encode()).unwrap();
|
||||||
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
assert_eq!(got.host_caps & HOST_CAP_CLIPBOARD, HOST_CAP_CLIPBOARD);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use super::*;
|
|||||||
use crate::config::{
|
use crate::config::{
|
||||||
CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role,
|
CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role,
|
||||||
};
|
};
|
||||||
|
use crate::crypto::SessionKey;
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
|
|
||||||
/// `client → host`: open the session, requesting a display mode (the host creates its
|
/// `client → host`: open the session, requesting a display mode (the host creates its
|
||||||
@@ -107,6 +108,13 @@ pub const HELLO_NAME_MAX: usize = 64;
|
|||||||
/// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
|
/// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
|
||||||
pub const HELLO_LAUNCH_MAX: usize = 128;
|
pub const HELLO_LAUNCH_MAX: usize = 128;
|
||||||
|
|
||||||
|
/// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
|
||||||
|
/// only one pre-cipher builds know).
|
||||||
|
pub const CIPHER_AES_128_GCM: u8 = 0;
|
||||||
|
/// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
|
||||||
|
/// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
|
||||||
|
pub const CIPHER_CHACHA20_POLY1305: u8 = 1;
|
||||||
|
|
||||||
/// `host → client`: the complete session offer.
|
/// `host → client`: the complete session offer.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Welcome {
|
pub struct Welcome {
|
||||||
@@ -173,6 +181,22 @@ pub struct Welcome {
|
|||||||
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an
|
/// per-transition events otherwise). Appended after `codec` as a single trailing byte; an
|
||||||
/// older host that omits it decodes to `0` (no capabilities — legacy events only).
|
/// older host that omits it decodes to `0` (no capabilities — legacy events only).
|
||||||
pub host_caps: u8,
|
pub host_caps: u8,
|
||||||
|
/// The session AEAD the data plane seals with — [`CIPHER_AES_128_GCM`] (`0`, the default
|
||||||
|
/// every peer speaks) or [`CIPHER_CHACHA20_POLY1305`] (`1`). The host sets `1` ONLY toward
|
||||||
|
/// a client that advertised [`VIDEO_CAP_CHACHA20`] (the soft-AES armv7 targets). Appended
|
||||||
|
/// after `host_caps` at offset 68 and — unlike the earlier trailing fields — emitted only
|
||||||
|
/// when non-zero, so an AES session's Welcome stays **byte-identical** to the pre-cipher
|
||||||
|
/// wire form; an older host omits it (→ `0`, AES). Decode is fail-closed: an unknown id is
|
||||||
|
/// an `Err`, never a silent AES fallback — the host only picks a cipher this client
|
||||||
|
/// advertised, so an unknown id reaching us is a bug, and falling back would yield an
|
||||||
|
/// undecryptable session with a confusing failure signature.
|
||||||
|
pub cipher: u8,
|
||||||
|
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
|
||||||
|
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
|
||||||
|
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
|
||||||
|
/// ever observes an all-zero key. Decode rejects `cipher == 1` with fewer than 32 key
|
||||||
|
/// bytes following.
|
||||||
|
pub key_chacha: Option<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `client → host`: data plane is bound, begin streaming.
|
/// `client → host`: data plane is bound, begin streaming.
|
||||||
@@ -366,6 +390,21 @@ impl Welcome {
|
|||||||
b.push(self.codec);
|
b.push(self.codec);
|
||||||
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
|
// Host input caps at offset 67 — older clients stop before this → 0 (legacy input only).
|
||||||
b.push(self.host_caps);
|
b.push(self.host_caps);
|
||||||
|
// Session cipher at offset 68 + the 32-byte ChaCha key at 69..101 — emitted ONLY when a
|
||||||
|
// non-default cipher was negotiated, so an AES session's Welcome stays byte-identical
|
||||||
|
// to the pre-cipher wire form. The host only sets cipher toward a client that
|
||||||
|
// advertised VIDEO_CAP_CHACHA20, so an old client never sees these bytes at all.
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.cipher == CIPHER_CHACHA20_POLY1305,
|
||||||
|
self.key_chacha.is_some(),
|
||||||
|
"key_chacha present iff cipher == 1"
|
||||||
|
);
|
||||||
|
if self.cipher != CIPHER_AES_128_GCM {
|
||||||
|
b.push(self.cipher);
|
||||||
|
if let Some(k) = &self.key_chacha {
|
||||||
|
b.extend_from_slice(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +413,10 @@ impl Welcome {
|
|||||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||||
// chroma_format[64] audio_channels[65] codec[66] (everything from compositor on is an
|
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
|
||||||
// optional trailing byte; an older host stops earlier).
|
// key_chacha[69..101] (everything from compositor on is an optional trailing byte; an
|
||||||
|
// older host stops earlier; cipher/key_chacha are present only when ChaCha was
|
||||||
|
// negotiated).
|
||||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||||
}
|
}
|
||||||
@@ -385,6 +426,24 @@ impl Welcome {
|
|||||||
key.copy_from_slice(&b[29..45]);
|
key.copy_from_slice(&b[29..45]);
|
||||||
let mut salt = [0u8; 4];
|
let mut salt = [0u8; 4];
|
||||||
salt.copy_from_slice(&b[45..49]);
|
salt.copy_from_slice(&b[45..49]);
|
||||||
|
// Session cipher at 68 — absent on an older host → AES-128-GCM. Fail-closed on
|
||||||
|
// anything else: `cipher == 1` with fewer than 32 key bytes must be an error (a silent
|
||||||
|
// AES fallback would yield an undecryptable session with a confusing failure
|
||||||
|
// signature), and an unknown id (≥ 2) reaching us is a bug — a host only picks a
|
||||||
|
// cipher this client advertised — never a legitimate negotiation.
|
||||||
|
let cipher = b.get(68).copied().unwrap_or(CIPHER_AES_128_GCM);
|
||||||
|
let key_chacha = match cipher {
|
||||||
|
CIPHER_AES_128_GCM => None,
|
||||||
|
CIPHER_CHACHA20_POLY1305 => {
|
||||||
|
let bytes = b
|
||||||
|
.get(69..101)
|
||||||
|
.ok_or(PunktfunkError::InvalidArg("bad Welcome"))?;
|
||||||
|
let mut k = [0u8; 32];
|
||||||
|
k.copy_from_slice(bytes);
|
||||||
|
Some(k)
|
||||||
|
}
|
||||||
|
_ => return Err(PunktfunkError::InvalidArg("bad Welcome")),
|
||||||
|
};
|
||||||
Ok(Welcome {
|
Ok(Welcome {
|
||||||
abi_version: u32at(4),
|
abi_version: u32at(4),
|
||||||
udp_port: u16at(8),
|
udp_port: u16at(8),
|
||||||
@@ -452,6 +511,8 @@ impl Welcome {
|
|||||||
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
// Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state
|
||||||
// snapshots; the client keeps sending legacy per-transition events).
|
// snapshots; the client keeps sending legacy per-transition events).
|
||||||
host_caps: b.get(67).copied().unwrap_or(0),
|
host_caps: b.get(67).copied().unwrap_or(0),
|
||||||
|
cipher,
|
||||||
|
key_chacha,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +523,12 @@ impl Welcome {
|
|||||||
c.fec = self.fec;
|
c.fec = self.fec;
|
||||||
c.shard_payload = self.shard_payload as usize;
|
c.shard_payload = self.shard_payload as usize;
|
||||||
c.encrypt = self.encrypt;
|
c.encrypt = self.encrypt;
|
||||||
c.key = self.key;
|
// The negotiated AEAD: the ChaCha key when cipher == 1 (guaranteed present by decode —
|
||||||
|
// the `(1, None)` shape is unreachable off the wire), the legacy AES key otherwise.
|
||||||
|
c.key = match (self.cipher, self.key_chacha) {
|
||||||
|
(CIPHER_CHACHA20_POLY1305, Some(k)) => SessionKey::ChaCha20Poly1305(k),
|
||||||
|
_ => SessionKey::Aes128Gcm(self.key),
|
||||||
|
};
|
||||||
c.salt = self.salt;
|
c.salt = self.salt;
|
||||||
// Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is
|
// Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is
|
||||||
// ~10x larger than any real access unit. Derive it from the negotiated rate instead:
|
// ~10x larger than any real access unit. Derive it from the negotiated rate instead:
|
||||||
@@ -531,6 +597,8 @@ mod tests {
|
|||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
cipher: 0,
|
||||||
|
key_chacha: None,
|
||||||
};
|
};
|
||||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||||
|
|
||||||
@@ -564,6 +632,81 @@ mod tests {
|
|||||||
assert!(derived > (8 << 20) && derived < (64 << 20));
|
assert!(derived > (8 << 20) && derived < (64 << 20));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn welcome_cipher_negotiation_wire_and_back_compat() {
|
||||||
|
use crate::crypto::SessionKey;
|
||||||
|
let base = Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 7000,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [7u8; 16],
|
||||||
|
salt: [9, 8, 7, 6],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 50_000,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: 0,
|
||||||
|
cipher: CIPHER_AES_128_GCM,
|
||||||
|
key_chacha: None,
|
||||||
|
};
|
||||||
|
// An AES session's Welcome is byte-identical to the pre-cipher wire form (68 bytes) —
|
||||||
|
// the old-client × new-host interop guarantee.
|
||||||
|
let enc = base.encode();
|
||||||
|
assert_eq!(enc.len(), 68);
|
||||||
|
assert_eq!(Welcome::decode(&enc).unwrap(), base);
|
||||||
|
|
||||||
|
// ChaCha roundtrip: cipher byte at 68, the 32-byte key at 69..101.
|
||||||
|
let k32: [u8; 32] = core::array::from_fn(|i| i as u8 + 1);
|
||||||
|
let cha = Welcome {
|
||||||
|
cipher: CIPHER_CHACHA20_POLY1305,
|
||||||
|
key_chacha: Some(k32),
|
||||||
|
..base
|
||||||
|
};
|
||||||
|
let cenc = cha.encode();
|
||||||
|
assert_eq!(cenc.len(), 68 + 1 + 32);
|
||||||
|
assert_eq!(Welcome::decode(&cenc).unwrap(), cha);
|
||||||
|
|
||||||
|
// A truncated old-host Welcome (no cipher byte) decodes to the AES default.
|
||||||
|
let old_host = Welcome::decode(&cenc[..68]).unwrap();
|
||||||
|
assert_eq!(old_host.cipher, CIPHER_AES_128_GCM);
|
||||||
|
assert_eq!(old_host.key_chacha, None);
|
||||||
|
|
||||||
|
// cipher == 1 with a missing / short key → Err, fail-closed (a silent AES fallback
|
||||||
|
// would yield an undecryptable session with a confusing failure signature).
|
||||||
|
assert!(Welcome::decode(&cenc[..69]).is_err());
|
||||||
|
assert!(Welcome::decode(&cenc[..100]).is_err());
|
||||||
|
|
||||||
|
// An unknown cipher id (≥ 2) → Err: the host only picks a cipher we advertised, so an
|
||||||
|
// unknown id reaching us is a bug, never a legitimate negotiation.
|
||||||
|
let mut bad = cenc.clone();
|
||||||
|
bad[68] = 2;
|
||||||
|
assert!(Welcome::decode(&bad).is_err());
|
||||||
|
|
||||||
|
// session_config maps both variants onto the data-plane key, and both validate.
|
||||||
|
let aes_cfg = base.session_config(Role::Client);
|
||||||
|
assert_eq!(aes_cfg.key, SessionKey::Aes128Gcm([7u8; 16]));
|
||||||
|
aes_cfg.validate().expect("AES config validates");
|
||||||
|
let cha_cfg = cha.session_config(Role::Client);
|
||||||
|
assert_eq!(cha_cfg.key, SessionKey::ChaCha20Poly1305(k32));
|
||||||
|
cha_cfg.validate().expect("ChaCha config validates");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codec_negotiation_and_back_compat() {
|
fn codec_negotiation_and_back_compat() {
|
||||||
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
|
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
|
||||||
@@ -656,6 +799,8 @@ mod tests {
|
|||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: CODEC_PYROWAVE,
|
codec: CODEC_PYROWAVE,
|
||||||
host_caps: 0,
|
host_caps: 0,
|
||||||
|
cipher: 0,
|
||||||
|
key_chacha: None,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -726,6 +871,8 @@ mod tests {
|
|||||||
audio_channels: 2,
|
audio_channels: 2,
|
||||||
codec: CODEC_H264,
|
codec: CODEC_H264,
|
||||||
host_caps: 0,
|
host_caps: 0,
|
||||||
|
cipher: 0,
|
||||||
|
key_chacha: None,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -831,6 +978,8 @@ mod tests {
|
|||||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||||
codec: CODEC_HEVC,
|
codec: CODEC_HEVC,
|
||||||
host_caps: HOST_CAP_GAMEPAD_STATE,
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
cipher: 0,
|
||||||
|
key_chacha: None,
|
||||||
};
|
};
|
||||||
let wenc = w.encode();
|
let wenc = w.encode();
|
||||||
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||||
|
|||||||
@@ -781,6 +781,7 @@ impl Session {
|
|||||||
mod wire_equivalence_tests {
|
mod wire_equivalence_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
|
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
|
||||||
|
use crate::crypto::SessionKey;
|
||||||
use crate::transport::loopback_pair;
|
use crate::transport::loopback_pair;
|
||||||
|
|
||||||
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
|
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
|
||||||
@@ -798,7 +799,7 @@ mod wire_equivalence_tests {
|
|||||||
shard_payload: 64,
|
shard_payload: 64,
|
||||||
max_frame_bytes: 8 * 1024 * 1024,
|
max_frame_bytes: 8 * 1024 * 1024,
|
||||||
encrypt,
|
encrypt,
|
||||||
key: [7u8; 16],
|
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||||
salt: [3, 1, 4, 1],
|
salt: [3, 1, 4, 1],
|
||||||
loopback_drop_period: 0,
|
loopback_drop_period: 0,
|
||||||
}
|
}
|
||||||
@@ -930,7 +931,7 @@ mod wire_equivalence_tests {
|
|||||||
shard_payload: 1024,
|
shard_payload: 1024,
|
||||||
max_frame_bytes: 8 * 1024 * 1024,
|
max_frame_bytes: 8 * 1024 * 1024,
|
||||||
encrypt: false,
|
encrypt: false,
|
||||||
key: [0u8; 16],
|
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||||
salt: [0u8; 4],
|
salt: [0u8; 4],
|
||||||
loopback_drop_period: 0,
|
loopback_drop_period: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||||
|
use punktfunk_core::crypto::SessionKey;
|
||||||
use punktfunk_core::fec::coder_for;
|
use punktfunk_core::fec::coder_for;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::session::Session;
|
use punktfunk_core::session::Session;
|
||||||
@@ -25,7 +26,7 @@ fn config(role: Role, scheme: FecScheme, encrypt: bool, drop_period: u32) -> Con
|
|||||||
shard_payload: 1024,
|
shard_payload: 1024,
|
||||||
max_frame_bytes: 8 * 1024 * 1024,
|
max_frame_bytes: 8 * 1024 * 1024,
|
||||||
encrypt,
|
encrypt,
|
||||||
key: [7u8; 16],
|
key: SessionKey::Aes128Gcm([7u8; 16]),
|
||||||
salt: [1, 2, 3, 4],
|
salt: [1, 2, 3, 4],
|
||||||
loopback_drop_period: drop_period,
|
loopback_drop_period: drop_period,
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,30 @@ fn encrypted_stream_recovers_under_loss() {
|
|||||||
assert_eq!(stats.frames_completed, frames.len() as u64);
|
assert_eq!(stats.frames_completed, frames.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The negotiated ChaCha20-Poly1305 session cipher through the same lossy full-stream path:
|
||||||
|
/// loss/replay behavior is cipher-independent (the replay window keys off the authenticated
|
||||||
|
/// seq), so recovery must be byte-identical to the AES run above.
|
||||||
|
#[test]
|
||||||
|
fn chacha20_encrypted_stream_recovers_under_loss() {
|
||||||
|
let frames = sample_frames();
|
||||||
|
let mk = |role| {
|
||||||
|
let mut c = config(role, FecScheme::Gf16, true, 8);
|
||||||
|
c.key = SessionKey::ChaCha20Poly1305([7u8; 32]);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
let (host_tp, client_tp) = loopback_pair(8, 0);
|
||||||
|
let mut host = Session::new(mk(Role::Host), Box::new(host_tp)).unwrap();
|
||||||
|
let mut client = Session::new(mk(Role::Client), Box::new(client_tp)).unwrap();
|
||||||
|
for (i, frame) in frames.iter().enumerate() {
|
||||||
|
host.submit_frame(frame, i as u64 * 1_000_000, 0).unwrap();
|
||||||
|
let got = client
|
||||||
|
.poll_frame()
|
||||||
|
.expect("frame should recover despite loss");
|
||||||
|
assert_eq!(&got.data, frame, "frame {i} mismatched after recovery");
|
||||||
|
}
|
||||||
|
assert!(client.stats().fec_recovered_shards > 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lossless_stream_is_exact() {
|
fn lossless_stream_is_exact() {
|
||||||
let frames = sample_frames();
|
let frames = sample_frames();
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ pub fn start(
|
|||||||
crate::events::emit(crate::events::EventKind::ClientConnected {
|
crate::events::emit(crate::events::EventKind::ClientConnected {
|
||||||
client: event_client.clone(),
|
client: event_client.clone(),
|
||||||
});
|
});
|
||||||
|
// GPU clock pin (Linux, opt-in `PUNKTFUNK_PIN_CLOCKS`): hold the box-wide vendor clock
|
||||||
|
// floor while this compat-plane stream runs, refcounted with every other live session
|
||||||
|
// across both planes. Released when the closure exits (stream stopped) — so idle clocks
|
||||||
|
// aren't pinned between Moonlight sessions. No-op off Linux / when the flag is unset.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _clock_pin = crate::gpuclocks::session_pin();
|
||||||
let result = run(
|
let result = run(
|
||||||
cfg,
|
cfg,
|
||||||
app.as_ref(),
|
app.as_ref(),
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
//! plan Tier 1B): the driver's adaptive P-state ramps clocks down between bursty encode frames,
|
//! plan Tier 1B): the driver's adaptive P-state ramps clocks down between bursty encode frames,
|
||||||
//! so every frame re-pays a spin-up. This is NOT theoretical — measured on the 780M (VCN 4),
|
//! so every frame re-pays a spin-up. This is NOT theoretical — measured on the 780M (VCN 4),
|
||||||
//! a 1440p HEVC encode takes ~4.4 ms/frame with the clocks hot (120 fps pacing) but ~8 ms/frame
|
//! a 1440p HEVC encode takes ~4.4 ms/frame with the clocks hot (120 fps pacing) but ~8 ms/frame
|
||||||
//! at a 60 fps duty cycle: the sag doubles per-frame encode latency.
|
//! at a 60 fps duty cycle: the sag doubles per-frame encode latency. So the pin is worth holding
|
||||||
|
//! only while a client is actively streaming: it is armed on the first live client and released
|
||||||
|
//! when the last one disconnects (refcounted across both streaming planes — see [`session_pin`]),
|
||||||
|
//! leaving the driver's idle power management alone the rest of the time.
|
||||||
//!
|
//!
|
||||||
//! **AMD** (`PUNKTFUNK_PIN_CLOCKS=1`, root-gated by sysfs ownership): write `high` into each
|
//! **AMD** (`PUNKTFUNK_PIN_CLOCKS=1`, root-gated by sysfs ownership): write `high` into each
|
||||||
//! amdgpu card's `power_dpm_force_performance_level` for the host lifetime, restoring the prior
|
//! amdgpu card's `power_dpm_force_performance_level` while a client is streaming, restoring the
|
||||||
//! value on exit. Non-root gets EACCES → logged once with the privilege recipe. Deliberately
|
//! prior value when the last client disconnects. Non-root gets EACCES → logged once with the
|
||||||
|
//! privilege recipe. Deliberately
|
||||||
//! opt-in: it defeats power management box-wide and is wrong on battery (Steam Deck!).
|
//! opt-in: it defeats power management box-wide and is wrong on battery (Steam Deck!).
|
||||||
//!
|
//!
|
||||||
//! **NVIDIA** — two independent halves, both no-ops off NVIDIA:
|
//! **NVIDIA** — two independent halves, both no-ops off NVIDIA:
|
||||||
@@ -28,14 +32,16 @@
|
|||||||
//! while leaving boost headroom — NVIDIA's own latency guidance is "raise the floor, don't pin
|
//! while leaving boost headroom — NVIDIA's own latency guidance is "raise the floor, don't pin
|
||||||
//! the max" (locking above base just gets throttled; a max pin only burns idle watts). Non-root
|
//! the max" (locking above base just gets throttled; a max pin only burns idle watts). Non-root
|
||||||
//! callers get `NVML_ERROR_NO_PERMISSION` — logged once with the privilege recipe, then the
|
//! callers get `NVML_ERROR_NO_PERMISSION` — logged once with the privilege recipe, then the
|
||||||
//! host runs unpinned. The pin is undone on drop (host exit); after a crash it persists until
|
//! host runs unpinned. The pin is undone on drop (when the last client disconnects); after a
|
||||||
//! driver reload/reboot, which the reset-before-pin on the next start self-heals. Deliberately
|
//! crash it persists until driver reload/reboot, which the reset-before-pin on the next arm
|
||||||
|
//! self-heals. Deliberately
|
||||||
//! NOT default-on: it defeats idle downclocking for the whole box and is wrong on
|
//! NOT default-on: it defeats idle downclocking for the whole box and is wrong on
|
||||||
//! battery-powered hosts.
|
//! battery-powered hosts.
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
/// `nvmlDevice_t` — an opaque driver handle.
|
/// `nvmlDevice_t` — an opaque driver handle.
|
||||||
type NvmlDevice = *mut c_void;
|
type NvmlDevice = *mut c_void;
|
||||||
@@ -133,8 +139,9 @@ struct AmdPin {
|
|||||||
restore: String,
|
restore: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host-lifetime guard: holds the armed clock pins (NVML floor and/or amdgpu perf level) and
|
/// Holds the armed clock pins (NVML floor and/or amdgpu perf level) and undoes them on drop. Owned
|
||||||
/// undoes them on drop.
|
/// by the [`session_pin`] refcount: constructed when the first live client arms the pin, dropped
|
||||||
|
/// (clocks restored) when the last one disconnects.
|
||||||
pub struct ClockGuard {
|
pub struct ClockGuard {
|
||||||
nvml: Option<NvmlPin>,
|
nvml: Option<NvmlPin>,
|
||||||
amd: Vec<AmdPin>,
|
amd: Vec<AmdPin>,
|
||||||
@@ -142,9 +149,10 @@ pub struct ClockGuard {
|
|||||||
|
|
||||||
// SAFETY: `ClockGuard` holds opaque NVML device handles + resolved fn pointers from the loaded
|
// SAFETY: `ClockGuard` holds opaque NVML device handles + resolved fn pointers from the loaded
|
||||||
// driver library (plus plain sysfs paths/strings). NVML is documented thread-safe, the handles are
|
// driver library (plus plain sysfs paths/strings). NVML is documented thread-safe, the handles are
|
||||||
// plain driver tokens with no thread affinity, and the guard is only ever *moved* (held in `main`,
|
// plain driver tokens with no thread affinity, and the guard is only ever *moved* (into the
|
||||||
// dropped once at exit) and used through `&mut`/ownership — never shared. Transfer across threads
|
// `pin_refcount` mutex when armed, taken back out and dropped when the last client disconnects) and
|
||||||
// is therefore sound.
|
// used through exclusive ownership behind that mutex — never shared. Transfer across threads is
|
||||||
|
// therefore sound.
|
||||||
unsafe impl Send for ClockGuard {}
|
unsafe impl Send for ClockGuard {}
|
||||||
|
|
||||||
impl Drop for ClockGuard {
|
impl Drop for ClockGuard {
|
||||||
@@ -182,22 +190,89 @@ impl Drop for ClockGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Startup hook for the host subcommands (`serve` / `punktfunk1-host`): install the NVIDIA P2-cap
|
/// Startup hook for the host subcommands (`serve` / `punktfunk1-host`): install the NVIDIA P2-cap
|
||||||
/// application profile and, when `PUNKTFUNK_PIN_CLOCKS` is set, arm the vendor clock pin (NVML
|
/// application profile — the process-scoped, no-root half of the NVIDIA lever, which the driver
|
||||||
/// core-clock floor / amdgpu `high` performance level). Returns the guard keeping the pins for
|
/// only acts on once the host holds a live CUDA/NVENC context (i.e. during a session).
|
||||||
/// the host lifetime. `None` when nothing was armed.
|
///
|
||||||
pub fn on_host_start() -> Option<ClockGuard> {
|
/// The vendor clock *pin* is deliberately NOT armed here anymore: held for the whole host lifetime
|
||||||
|
/// it kept the box's clocks hot even with no client connected. It is now refcounted per live client
|
||||||
|
/// via [`session_pin`] (armed on both streaming planes), so idle clocks are left to the driver's
|
||||||
|
/// power management until someone actually streams.
|
||||||
|
pub fn on_host_start() {
|
||||||
if nvidia_present() {
|
if nvidia_present() {
|
||||||
ensure_cuda_perf_profile();
|
ensure_cuda_perf_profile();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The box-wide clock-pin refcount, shared across BOTH streaming planes (native + GameStream): the
|
||||||
|
/// vendor pin is a single global GPU setting, so N concurrent sessions share ONE pin — armed when
|
||||||
|
/// the first client goes live, released when the last one leaves.
|
||||||
|
struct PinRefcount {
|
||||||
|
/// Number of live [`SessionClockPin`] handles.
|
||||||
|
live: usize,
|
||||||
|
/// The armed pins, present iff `live > 0` and something was actually pinnable.
|
||||||
|
guard: Option<ClockGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pin_refcount() -> &'static Mutex<PinRefcount> {
|
||||||
|
static STATE: OnceLock<Mutex<PinRefcount>> = OnceLock::new();
|
||||||
|
STATE.get_or_init(|| {
|
||||||
|
Mutex::new(PinRefcount {
|
||||||
|
live: 0,
|
||||||
|
guard: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII handle that keeps the box-wide clock pin armed while it is alive. Obtain one per live client
|
||||||
|
/// session on either plane via [`session_pin`]; when the last outstanding handle drops, the pin is
|
||||||
|
/// released and the driver's idle downclocking resumes. A no-op handle when `PUNKTFUNK_PIN_CLOCKS`
|
||||||
|
/// is unset (the opt-in gate) — the refcount only ticks for sessions that asked for pinning.
|
||||||
|
pub struct SessionClockPin {
|
||||||
|
/// Whether this handle actually incremented the refcount (false = opt-in gate off → no-op).
|
||||||
|
counted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm the box-wide clock pin for one live client session, refcounted across every plane. Returns
|
||||||
|
/// an RAII handle; the pin is released when the last handle drops. A no-op (returns immediately,
|
||||||
|
/// touches no GPU state) unless `PUNKTFUNK_PIN_CLOCKS` is set.
|
||||||
|
pub fn session_pin() -> SessionClockPin {
|
||||||
if !flag_truthy("PUNKTFUNK_PIN_CLOCKS") {
|
if !flag_truthy("PUNKTFUNK_PIN_CLOCKS") {
|
||||||
return None;
|
return SessionClockPin { counted: false };
|
||||||
}
|
}
|
||||||
let nvml = if nvidia_present() { pin_nvidia() } else { None };
|
let mut state = pin_refcount().lock().unwrap();
|
||||||
let amd = pin_amdgpu();
|
state.live += 1;
|
||||||
if nvml.is_none() && amd.is_empty() {
|
if state.live == 1 {
|
||||||
return None;
|
// 0 → 1: the first live client — arm the vendor pin (reset-before-pin inside `pin_nvidia`
|
||||||
|
// heals a stale pin from a crashed previous run).
|
||||||
|
let nvml = if nvidia_present() { pin_nvidia() } else { None };
|
||||||
|
let amd = pin_amdgpu();
|
||||||
|
state.guard = if nvml.is_none() && amd.is_empty() {
|
||||||
|
None // nothing pinnable (no perms / no supported GPU) — the session streams unpinned
|
||||||
|
} else {
|
||||||
|
Some(ClockGuard { nvml, amd })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SessionClockPin { counted: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SessionClockPin {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.counted {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Take the guard out under the lock but drop it *outside* — releasing the pin does NVML +
|
||||||
|
// sysfs I/O we don't want to hold the refcount lock across.
|
||||||
|
let release = {
|
||||||
|
let mut state = pin_refcount().lock().unwrap();
|
||||||
|
state.live = state.live.saturating_sub(1);
|
||||||
|
if state.live == 0 {
|
||||||
|
state.guard.take()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(release);
|
||||||
}
|
}
|
||||||
Some(ClockGuard { nvml, amd })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force every amdgpu card's DPM performance level to `high` for the session — the encode-latency
|
/// Force every amdgpu card's DPM performance level to `high` for the session — the encode-latency
|
||||||
@@ -238,7 +313,7 @@ fn pin_amdgpu() -> Vec<AmdPin> {
|
|||||||
card = %name,
|
card = %name,
|
||||||
was = %prev,
|
was = %prev,
|
||||||
"amdgpu performance level pinned to high (encode clock sag removed) — \
|
"amdgpu performance level pinned to high (encode clock sag removed) — \
|
||||||
restored on host exit"
|
restored when the last client disconnects"
|
||||||
);
|
);
|
||||||
pins.push(AmdPin {
|
pins.push(AmdPin {
|
||||||
path,
|
path,
|
||||||
@@ -327,7 +402,8 @@ fn pin_nvidia() -> Option<NvmlPin> {
|
|||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
devices = pinned.len(),
|
devices = pinned.len(),
|
||||||
"NVIDIA core-clock floor armed (min=TDP/base, max=boost) — released on host exit"
|
"NVIDIA core-clock floor armed (min=TDP/base, max=boost) — released when the last \
|
||||||
|
client disconnects"
|
||||||
);
|
);
|
||||||
Some(NvmlPin { nvml, pinned })
|
Some(NvmlPin { nvml, pinned })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,14 +246,17 @@ fn real_main() -> Result<()> {
|
|||||||
crate::capture::dxgi::install_gpu_pref_hook();
|
crate::capture::dxgi::install_gpu_pref_hook();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile and,
|
// NVIDIA clock hygiene (Linux, host subcommands only): install the P2-cap driver profile. The
|
||||||
// under PUNKTFUNK_PIN_CLOCKS, hold the NVML core-clock floor for the host lifetime (reset on
|
// vendor clock *pin* (PUNKTFUNK_PIN_CLOCKS) is no longer held for the host lifetime — it is
|
||||||
// exit via the guard's Drop). No-op off NVIDIA / on the tool subcommands.
|
// armed per live client via `gpuclocks::session_pin()` on both streaming planes, so idle clocks
|
||||||
|
// are left alone while nobody is connected. No-op off NVIDIA / on the tool subcommands.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let _nv_clocks = match args.first().map(String::as_str) {
|
if matches!(
|
||||||
Some("serve") | Some("punktfunk1-host") => gpuclocks::on_host_start(),
|
args.first().map(String::as_str),
|
||||||
_ => None,
|
Some("serve") | Some("punktfunk1-host")
|
||||||
};
|
) {
|
||||||
|
gpuclocks::on_host_start();
|
||||||
|
}
|
||||||
|
|
||||||
match args.first().map(String::as_str) {
|
match args.first().map(String::as_str) {
|
||||||
// The host: the native punktfunk/1 plane + management API by default (secure), and — with
|
// The host: the native punktfunk/1 plane + management API by default (secure), and — with
|
||||||
|
|||||||
@@ -439,7 +439,11 @@ pub(crate) async fn serve(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => tracing::info!(%peer, "session complete"),
|
Ok(Served::Session) => tracing::info!(%peer, "session complete"),
|
||||||
|
Ok(Served::ProbeClose) => tracing::debug!(
|
||||||
|
%peer,
|
||||||
|
"closed before the control handshake (reachability probe)"
|
||||||
|
),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
|
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error")
|
||||||
}
|
}
|
||||||
@@ -629,6 +633,15 @@ type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
|
|||||||
/// connection (the host stops waiting at once).
|
/// connection (the host stops waiting at once).
|
||||||
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
|
const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180);
|
||||||
|
|
||||||
|
/// How a served connection ended. A peer that completes the QUIC handshake and closes cleanly
|
||||||
|
/// (code 0) without ever opening the control stream is a reachability probe (the clients'
|
||||||
|
/// hosts-page "online" pips / `--reachable`) or an abandoned connect — routine, and logged
|
||||||
|
/// quietly: as a WARN it buried the real failures in a wake-on-LAN triage log.
|
||||||
|
enum Served {
|
||||||
|
Session,
|
||||||
|
ProbeClose,
|
||||||
|
}
|
||||||
|
|
||||||
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
|
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
|
||||||
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
|
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
|
||||||
/// A connection whose first message is a PairRequest runs the pairing ceremony instead.
|
/// A connection whose first message is a PairRequest runs the pairing ceremony instead.
|
||||||
@@ -651,14 +664,23 @@ async fn serve_session(
|
|||||||
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
|
// parked knock can't hold a streaming slot. `sem` is the pool it re-acquires from.
|
||||||
mut permit: tokio::sync::OwnedSemaphorePermit,
|
mut permit: tokio::sync::OwnedSemaphorePermit,
|
||||||
sem: Arc<tokio::sync::Semaphore>,
|
sem: Arc<tokio::sync::Semaphore>,
|
||||||
) -> Result<()> {
|
) -> Result<Served> {
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
|
|
||||||
// First message decides what this connection is: a pairing ceremony or a session.
|
// First message decides what this connection is: a pairing ceremony or a session.
|
||||||
let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
|
let (mut send, mut recv) = match tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("control stream timeout"))?
|
.map_err(|_| anyhow!("control stream timeout"))?
|
||||||
.context("accept control stream")?;
|
{
|
||||||
|
// A clean close before any control stream: a reachability probe / abandoned connect,
|
||||||
|
// not a failed session (see [`Served::ProbeClose`]).
|
||||||
|
Err(quinn::ConnectionError::ApplicationClosed(ref ac))
|
||||||
|
if ac.error_code == quinn::VarInt::from_u32(0) =>
|
||||||
|
{
|
||||||
|
return Ok(Served::ProbeClose);
|
||||||
|
}
|
||||||
|
r => r.context("accept control stream")?,
|
||||||
|
};
|
||||||
let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv))
|
let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("first message timeout"))??;
|
.map_err(|_| anyhow!("first message timeout"))??;
|
||||||
@@ -709,7 +731,9 @@ async fn serve_session(
|
|||||||
}
|
}
|
||||||
*last = Some(std::time::Instant::now());
|
*last = Some(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin).await;
|
return pair_ceremony(&conn, send, recv, req, host_fp, np, &pin)
|
||||||
|
.await
|
||||||
|
.map(|()| Served::Session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
|
// Pairing gate for a session Hello (a PairRequest was handled above). Lifted OUT of the
|
||||||
@@ -1175,6 +1199,13 @@ async fn serve_session(
|
|||||||
launch: hello.launch.clone(),
|
launch: hello.launch.clone(),
|
||||||
plane: crate::events::Plane::Native,
|
plane: crate::events::Plane::Native,
|
||||||
});
|
});
|
||||||
|
// GPU clock pin (Linux, opt-in `PUNKTFUNK_PIN_CLOCKS`): hold the box-wide vendor clock floor for
|
||||||
|
// as long as THIS session streams, refcounted with every other live session across both planes.
|
||||||
|
// RAII like the marker above — armed on the first live client, released when the last one
|
||||||
|
// disconnects, so idle clocks aren't pinned while nobody is connected. No-op off Linux / when
|
||||||
|
// the flag is unset.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let _clock_pin = crate::gpuclocks::session_pin();
|
||||||
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
|
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
|
||||||
// (spawned into the interactive user session once capture is live); other hosts resolve the id
|
// (spawned into the interactive user session once capture is live); other hosts resolve the id
|
||||||
// to its shell command HERE against the host's own library — a client can only ever pick an
|
// to its shell command HERE against the host's own library — a client can only ever pick an
|
||||||
@@ -1392,7 +1423,7 @@ async fn serve_session(
|
|||||||
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
|
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
|
||||||
// TV's gaming session back so it's the default when no one is streaming.
|
// TV's gaming session back so it's the default when no one is streaming.
|
||||||
crate::vdisplay::restore_managed_session();
|
crate::vdisplay::restore_managed_session();
|
||||||
result
|
result.map(|()| Served::Session)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
/// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails
|
||||||
|
|||||||
@@ -354,6 +354,28 @@ pub(super) async fn negotiate(
|
|||||||
// just follow.
|
// just follow.
|
||||||
let mut salt = [0u8; 4];
|
let mut salt = [0u8; 4];
|
||||||
rand::thread_rng().fill_bytes(&mut salt);
|
rand::thread_rng().fill_bytes(&mut salt);
|
||||||
|
// Session AEAD: ChaCha20-Poly1305 when the client asked for it (VIDEO_CAP_CHACHA20 — the
|
||||||
|
// soft-AES armv7 targets, whose GCM decrypt caps at ~100 Mbps) and the operator
|
||||||
|
// kill-switch allows (PUNKTFUNK_CHACHA20, default on — pure rollout safety; perf-only,
|
||||||
|
// both AEADs are full-strength). The fresh-per-session discipline above applies to this
|
||||||
|
// key identically; the legacy 16-byte `key` stays independently random so nothing
|
||||||
|
// downstream ever observes an all-zero key.
|
||||||
|
let client_wants_chacha = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_CHACHA20 != 0;
|
||||||
|
let chacha = client_wants_chacha && pf_host_config::config().chacha20;
|
||||||
|
let key_chacha = chacha.then(|| {
|
||||||
|
let mut k = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut k);
|
||||||
|
k
|
||||||
|
});
|
||||||
|
tracing::info!(
|
||||||
|
cipher = if chacha {
|
||||||
|
"chacha20-poly1305"
|
||||||
|
} else {
|
||||||
|
"aes-128-gcm"
|
||||||
|
},
|
||||||
|
client_wants_chacha,
|
||||||
|
"session cipher"
|
||||||
|
);
|
||||||
let welcome = Welcome {
|
let welcome = Welcome {
|
||||||
abi_version: punktfunk_core::WIRE_VERSION,
|
abi_version: punktfunk_core::WIRE_VERSION,
|
||||||
udp_port,
|
udp_port,
|
||||||
@@ -423,6 +445,16 @@ pub(super) async fn negotiate(
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
|
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||||
|
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||||
|
// pre-cipher wire form. The host's own data plane picks the cipher up via
|
||||||
|
// `welcome.session_config` — no other host change.
|
||||||
|
cipher: if chacha {
|
||||||
|
punktfunk_core::quic::CIPHER_CHACHA20_POLY1305
|
||||||
|
} else {
|
||||||
|
punktfunk_core::quic::CIPHER_AES_128_GCM
|
||||||
|
},
|
||||||
|
key_chacha,
|
||||||
};
|
};
|
||||||
io::write_msg(send, &welcome.encode()).await?;
|
io::write_msg(send, &welcome.encode()).await?;
|
||||||
bringup.mark("welcome");
|
bringup.mark("welcome");
|
||||||
|
|||||||
@@ -168,12 +168,29 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
|
|||||||
## Recipe: full controller passthrough (VirtualHere)
|
## Recipe: full controller passthrough (VirtualHere)
|
||||||
|
|
||||||
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive
|
||||||
triggers, USB rumble — instead of the emulated pad, share the physical device from the couch with
|
triggers, USB rumble — instead of the emulated pad, hand the physical device from the couch to the
|
||||||
[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) and bind it to the host only while a
|
host over [VirtualHere](https://www.virtualhere.com/) (USB-over-IP), bound only while a client is
|
||||||
client is connected. The couch runs the VirtualHere **server** (sharing the pad); the host runs
|
connected so the couch keeps its own controller the rest of the time.
|
||||||
the VirtualHere **client** and this automation drives its `-t` IPC.
|
|
||||||
|
|
||||||
Zero-code, bracketed on the stream with two hooks:
|
**The two sides.** VirtualHere is a server/client pair, and you run both:
|
||||||
|
|
||||||
|
- **Server — on the couch** (where the pad is physically plugged in). Run the VirtualHere USB
|
||||||
|
Server there; it shares the pad on the LAN. Leave it running.
|
||||||
|
- **Client — on the host** (where the game and this automation run). Install the VirtualHere
|
||||||
|
Client (as a service, or the tray app). It auto-discovers the couch's shared pad on the same
|
||||||
|
LAN; across subnets, add it once with `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>:7575"`. The
|
||||||
|
client binary is `vhclientx86_64` on Linux, `vhui64.exe` on Windows, `vhclientosx` on macOS,
|
||||||
|
`vhclientarm64` on ARM Linux.
|
||||||
|
|
||||||
|
The client's `-t` flag is a one-shot IPC to the already-running client: `-t LIST` prints every
|
||||||
|
visible device with its address (`server.port`, e.g. `couch-deck.11`); `-t "USE,<addr>"` mounts it
|
||||||
|
onto the host; `-t "STOP USING,<addr>"` hands it back. The automation just brackets
|
||||||
|
`USE` / `STOP USING` around a session.
|
||||||
|
|
||||||
|
### Zero-code: two hooks
|
||||||
|
|
||||||
|
Bracket it on the stream with two [hooks](#hooks-hooksjson) — mount when video starts, release
|
||||||
|
when it stops. This is all most setups need:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -184,8 +201,42 @@ Zero-code, bracketed on the stream with two hooks:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`couch-deck.11` is the device's VirtualHere address (`vhclientx86_64 -t LIST`); same-LAN setups
|
`couch-deck.11` is the device's VirtualHere address from `vhclientx86_64 -t LIST`.
|
||||||
auto-discover it, otherwise `MANUAL HUB ADD,<couch-ip>:7575` once. For a version that resolves the
|
|
||||||
device by name, filters to one couch, and releases the pad on a clean shutdown, see the
|
### Scripted: resolve by name, release on shutdown
|
||||||
|
|
||||||
|
The hooks above hard-code the address, and can strand the pad on the host if it's stopped
|
||||||
|
mid-stream (the `stream.stopped` hook never fires). The
|
||||||
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
|
[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts)
|
||||||
SDK example.
|
SDK example is the robust version: it resolves the device by **name substring** (survives the
|
||||||
|
address changing), can filter to **one couch** in a multi-client setup, and **releases the pad on
|
||||||
|
a clean shutdown** (`systemctl stop`, ^C) so the couch always gets its controller back.
|
||||||
|
|
||||||
|
It's a standalone [`@punktfunk/host` script](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
|
||||||
|
— run it in its own directory. The one edit is its import: the in-repo copy imports from
|
||||||
|
`../src/index.js`; outside the repo it's the published package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir ~/punktfunk-scripts && cd ~/punktfunk-scripts
|
||||||
|
bun init -y
|
||||||
|
bun add @punktfunk/host # point the @punktfunk scope at the registry first — see the SDK README
|
||||||
|
# save the example as virtualhere-dualsense.ts, and change its first import to the package:
|
||||||
|
# - import { connect } from "../src/index.js";
|
||||||
|
# + import { connect } from "@punktfunk/host";
|
||||||
|
VH_DEVICE=DualSense bun virtualhere-dualsense.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`VH_DEVICE` is required — a VirtualHere address (`couch-deck.11`) or a device-name substring
|
||||||
|
(`DualSense`). Optional: `VH_CLIENT` overrides the client binary (default `vhclientx86_64`);
|
||||||
|
`VH_ONLY_CLIENT` binds only for one punktfunk client label. Running on the host box the SDK needs
|
||||||
|
no token or URL — it reads the host's loopback credentials itself (see
|
||||||
|
[Connection resolution](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#connection-resolution)).
|
||||||
|
|
||||||
|
Keep it running as a
|
||||||
|
[systemd user unit](https://git.unom.io/unom/punktfunk/src/branch/main/sdk#running-a-single-script-as-a-service)
|
||||||
|
(its default `SIGTERM` triggers the script's own release step — so `systemctl stop` hands the pad
|
||||||
|
back), or drop it under the [scripting runner](/docs/plugins) with your other units.
|
||||||
|
|
||||||
|
> The example brackets on `client.connected` / `client.disconnected` — the pad returns to the
|
||||||
|
> couch the moment they disconnect. Switch to `stream.started` / `stream.stopped` if you'd rather
|
||||||
|
> pass it through only while video is actually flowing; both are noted in the file's header.
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
|
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
|
||||||
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Linux **virtual displays** (native protocol, GameStream default) stay 8-bit: Mutter's virtual-monitor screencast is SDR-only upstream. |
|
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. Windows host, plus the Linux **GNOME 50+ GameStream desktop mirror** (`PUNKTFUNK_VIDEO_SOURCE=portal`, mirrored monitor in HDR mode — check with `punktfunk-host hdr-probe`). Linux **virtual displays** (native protocol, GameStream default) stay 8-bit: Mutter's virtual-monitor screencast is SDR-only upstream. |
|
||||||
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
|
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
|
||||||
|
| `PUNKTFUNK_CHACHA20` | `1` · `0` *(default on)* | ChaCha20-Poly1305 session encryption for clients without hardware AES (old ARM TVs, e.g. webOS), lifting their ~100 Mbps software-AES decrypt ceiling. **On by default** on the host; a session uses it only when the client requests it — everyone else stays on AES-GCM. Purely a performance choice (both ciphers are full-strength); set `0` to force AES-GCM for all sessions. |
|
||||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
||||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ It is idempotent — safe to re-run. In one pass it:
|
|||||||
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
|
1. creates the `pf2` Debian-trixie distrobox and installs the build toolchain,
|
||||||
2. builds `punktfunk-host` (and the web console),
|
2. builds `punktfunk-host` (and the web console),
|
||||||
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
||||||
4. raises the UDP socket buffers to 32 MB and adds you to the `input` group (needs `sudo`; skipped
|
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
||||||
with a warning if unavailable),
|
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
||||||
|
and seeds the KDE RemoteDesktop grant for Desktop-mode input — this step **prompts for your `sudo`
|
||||||
|
password** (a stock Steam Deck requires one; without it gamepad passthrough and the UDP tuning are skipped),
|
||||||
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
||||||
so they run without a login session).
|
so they run without a login session).
|
||||||
|
|
||||||
@@ -82,6 +84,14 @@ When it finishes it prints the web-console URL and how to pair.
|
|||||||
> If you only ever use native clients, install with `--no-gamestream` for a host with no GameStream
|
> If you only ever use native clients, install with `--no-gamestream` for a host with no GameStream
|
||||||
> surface at all.
|
> surface at all.
|
||||||
|
|
||||||
|
> **First install — reboot once before streaming.** KWin only authorizes Desktop-mode screen capture
|
||||||
|
> on a fresh session, and the new `input` group (native Steam Deck controller passthrough) only takes
|
||||||
|
> effect on a new login — so after the **first** install, **reboot the Deck** (a re-run that changes
|
||||||
|
> nothing doesn't need it). Streaming **Game Mode** with a generic Xbox pad works right away; **Desktop
|
||||||
|
> capture and the native Steam Deck controller need the reboot.** If a client connects and every
|
||||||
|
> session ends with `KWin does not expose zkde_screencast_unstable_v1` or the pad shows up as an Xbox
|
||||||
|
> 360 controller, you haven't rebooted yet.
|
||||||
|
|
||||||
## 3. Pair a device
|
## 3. Pair a device
|
||||||
|
|
||||||
By default the host **requires PIN pairing** (secure). Two ways to pair:
|
By default the host **requires PIN pairing** (secure). Two ways to pair:
|
||||||
@@ -128,6 +138,12 @@ bash ~/punktfunk/scripts/steamdeck/update.sh
|
|||||||
thrash the managed session. Pick one mode per session.
|
thrash the managed session. Pick one mode per session.
|
||||||
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
|
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
|
||||||
the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
|
the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
|
||||||
|
- **Native Steam Deck controller passthrough** presents the client's pad as a real Steam Deck
|
||||||
|
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` group and the
|
||||||
|
`vhci-hcd` module live, so it only works **after the first-install reboot** above; until then the pad
|
||||||
|
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
||||||
|
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
||||||
|
[Stream to a Steam Deck](/docs/steam-deck).
|
||||||
- **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to
|
- **It survives OS updates**, but a major SteamOS bump can move library versions; if the host fails to
|
||||||
start after an update, just re-run `update.sh` to rebuild against the new base.
|
start after an update, just re-run `update.sh` to rebuild against the new base.
|
||||||
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
|
- Deeper reference (services, container, manual steps): [`scripts/steamdeck/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/scripts/steamdeck/README.md).
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
#define INBOUND_REQ_FLAG 2147483648
|
#define INBOUND_REQ_FLAG 2147483648
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 16-byte AEAD authentication tag appended by GCM.
|
// 16-byte AEAD authentication tag appended by either session cipher.
|
||||||
#define TAG_LEN 16
|
#define TAG_LEN 16
|
||||||
|
|
||||||
// Wire tag distinguishing an input datagram from a video packet.
|
// Wire tag distinguishing an input datagram from a video packet.
|
||||||
@@ -465,6 +465,20 @@
|
|||||||
#define VIDEO_CAP_STREAMED_AU 32
|
#define VIDEO_CAP_STREAMED_AU 32
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_caps`] bit: the client can open **ChaCha20-Poly1305**-sealed session datagrams
|
||||||
|
// AND requests them — set by clients without hardware AES (the soft-AES armv7 targets, e.g.
|
||||||
|
// webOS TVs), where GCM's software AES + GHASH caps decrypt at ~100 Mbps while ChaCha's ARX
|
||||||
|
// construction runs 4–7× faster in portable code (design/chacha20-session-cipher.md).
|
||||||
|
// Support-plus-request in one bit mirrors [`VIDEO_CAP_444`]'s "capable AND turned on"
|
||||||
|
// precedent. The host grants it only when its `PUNKTFUNK_CHACHA20` kill-switch (default on)
|
||||||
|
// allows, answering with [`Welcome::cipher`] `= 1` + the 32-byte [`Welcome::key_chacha`];
|
||||||
|
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
|
||||||
|
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
|
||||||
|
// control channel, so there is no downgrade surface.
|
||||||
|
#define VIDEO_CAP_CHACHA20 64
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||||
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||||
@@ -855,6 +869,18 @@
|
|||||||
#define HELLO_LAUNCH_MAX 128
|
#define HELLO_LAUNCH_MAX 128
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
|
||||||
|
// only one pre-cipher builds know).
|
||||||
|
#define CIPHER_AES_128_GCM 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
|
||||||
|
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
|
||||||
|
#define CIPHER_CHACHA20_POLY1305 1
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`PairRequest`].
|
// Type byte of [`PairRequest`].
|
||||||
#define MSG_PAIR_REQUEST 16
|
#define MSG_PAIR_REQUEST 16
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ BIN="$TARGET_DIR/release/punktfunk-host"
|
|||||||
CONFIG="$HOME/.config/punktfunk"
|
CONFIG="$HOME/.config/punktfunk"
|
||||||
UNITS="$HOME/.config/systemd/user"
|
UNITS="$HOME/.config/systemd/user"
|
||||||
XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||||
|
# Set when this run does something that only a fresh login picks up (input-group add, first-time
|
||||||
|
# KWin .desktop grant). Drives the loud "reboot before streaming" note in the summary.
|
||||||
|
NEED_RELOGIN=0
|
||||||
|
|
||||||
# --- 0. preflight ----------------------------------------------------------
|
# --- 0. preflight ----------------------------------------------------------
|
||||||
log "Preflight"
|
log "Preflight"
|
||||||
@@ -66,6 +69,33 @@ fi
|
|||||||
DISTROBOX="$(command -v distrobox)" # baked into the web unit (may be /usr/bin or ~/.local/bin)
|
DISTROBOX="$(command -v distrobox)" # baked into the web unit (may be /usr/bin or ~/.local/bin)
|
||||||
ok "distrobox: $DISTROBOX"
|
ok "distrobox: $DISTROBOX"
|
||||||
|
|
||||||
|
# --- acquire sudo up front (before the ~15-min build) ----------------------
|
||||||
|
# Steps 4-5 (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger) need root. Prompt NOW,
|
||||||
|
# not after the build — so you authorize once and walk away, and a non-interactive run fails LOUDLY
|
||||||
|
# here instead of silently skipping the tuning at the very end. A stock SteamOS 'deck' has no
|
||||||
|
# password, so sudo can't work until you set one.
|
||||||
|
SUDO_OK=0
|
||||||
|
if sudo -n true 2>/dev/null; then
|
||||||
|
SUDO_OK=1
|
||||||
|
elif [ -t 0 ]; then
|
||||||
|
warn "sudo is needed once (UDP buffers, gamepad udev rule, vhci-hcd, input group, linger):"
|
||||||
|
if sudo -v; then
|
||||||
|
SUDO_OK=1
|
||||||
|
# keep the sudo timestamp warm across the long build so steps 4-5 don't re-prompt / expire
|
||||||
|
( while sudo -n -v 2>/dev/null; do sleep 50; done ) &
|
||||||
|
_pf_sudo_keepalive=$!
|
||||||
|
trap '[ -n "${_pf_sudo_keepalive:-}" ] && kill "$_pf_sudo_keepalive" 2>/dev/null || true' EXIT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$SUDO_OK" != 1 ]; then
|
||||||
|
if [ -t 0 ]; then
|
||||||
|
warn "No sudo — a stock SteamOS 'deck' account has no password. Set one and re-run: passwd"
|
||||||
|
else
|
||||||
|
warn "No TTY for the sudo prompt (non-interactive run) — system tuning + linger will be SKIPPED."
|
||||||
|
warn "Run in Konsole or an interactive 'ssh -t' session (or pre-authorize sudo) to enable them."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 1. build container + toolchain ---------------------------------------
|
# --- 1. build container + toolchain ---------------------------------------
|
||||||
log "Build container '$BOX' ($BOX_IMAGE)"
|
log "Build container '$BOX' ($BOX_IMAGE)"
|
||||||
if distrobox list 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qx "$BOX"; then
|
if distrobox list 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qx "$BOX"; then
|
||||||
@@ -84,7 +114,7 @@ set -e
|
|||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y -qq --no-install-recommends \
|
sudo apt-get install -y -qq --no-install-recommends \
|
||||||
build-essential pkg-config clang curl git ca-certificates \
|
build-essential pkg-config clang cmake curl git ca-certificates \
|
||||||
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libswscale-dev libavdevice-dev \
|
libavcodec-dev libavformat-dev libavutil-dev libavfilter-dev libswscale-dev libavdevice-dev \
|
||||||
libpipewire-0.3-dev libspa-0.2-dev \
|
libpipewire-0.3-dev libspa-0.2-dev \
|
||||||
libgbm-dev libegl-dev libgl-dev libdrm-dev libva-dev \
|
libgbm-dev libegl-dev libgl-dev libdrm-dev libva-dev \
|
||||||
@@ -148,11 +178,29 @@ fi
|
|||||||
# the restricted Wayland globals it lists (see packaging/linux/io.unom.Punktfunk.Host.desktop).
|
# the restricted Wayland globals it lists (see packaging/linux/io.unom.Punktfunk.Host.desktop).
|
||||||
# Exec must therefore be THIS install's binary path, not the packaged /usr/bin one. KWin reads
|
# Exec must therefore be THIS install's binary path, not the packaged /usr/bin one. KWin reads
|
||||||
# grants at session start — after first install, restart the Desktop session (Game Mode and back).
|
# grants at session start — after first install, restart the Desktop session (Game Mode and back).
|
||||||
|
DESKTOP_DST="$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||||
|
# First-time install of the grant: KWin only reads it at session start, so a fresh login is required
|
||||||
|
# before Desktop-mode capture works. A re-run that just rewrites it needs no relogin.
|
||||||
|
[ -f "$DESKTOP_DST" ] || NEED_RELOGIN=1
|
||||||
mkdir -p "$HOME/.local/share/applications"
|
mkdir -p "$HOME/.local/share/applications"
|
||||||
sed "s|^Exec=.*|Exec=$BIN|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
sed "s|^Exec=.*|Exec=$BIN|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" > "$DESKTOP_DST"
|
||||||
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
|
||||||
ok "KWin desktop-capture authorization (io.unom.Punktfunk.Host.desktop → $BIN)"
|
ok "KWin desktop-capture authorization (io.unom.Punktfunk.Host.desktop → $BIN)"
|
||||||
|
|
||||||
|
# KDE Desktop-mode INPUT: a normal Plasma login lacks the RemoteDesktop portal grant the host's libei
|
||||||
|
# input path needs, so it would pop an "Allow remote control?" dialog a headless host can't answer.
|
||||||
|
# Seed it once (per-user, no root) — mirrors packaging/bazzite/kde-desktop-setup.sh. Game Mode
|
||||||
|
# (gamescope) needs none of this; the .desktop above already grants org_kde_kwin_fake_input.
|
||||||
|
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
|
||||||
|
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
|
||||||
|
if [ -s "$GRANT_DST" ]; then
|
||||||
|
ok "KDE RemoteDesktop grant already present"
|
||||||
|
elif [ -s "$GRANT_SRC" ]; then
|
||||||
|
mkdir -p "$(dirname "$GRANT_DST")"
|
||||||
|
install -m644 "$GRANT_SRC" "$GRANT_DST"
|
||||||
|
systemctl --user restart xdg-permission-store 2>/dev/null || true
|
||||||
|
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
|
if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
|
||||||
# Random login password + session secret for the web console, generated once.
|
# Random login password + session secret for the web console, generated once.
|
||||||
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
|
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
|
||||||
@@ -168,9 +216,11 @@ else
|
|||||||
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
|
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 4. system tuning (needs sudo; skipped gracefully if unavailable) ------
|
# --- 4. system tuning (needs sudo: UDP buffers + gamepad udev rule + vhci-hcd + input group) --------
|
||||||
log "System tuning (UDP buffers + input group) — needs sudo"
|
log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input group)"
|
||||||
if sudo -n true 2>/dev/null; then
|
# sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
|
||||||
|
# skip here (no password / no TTY) was already reported loudly there.
|
||||||
|
if [ "$SUDO_OK" = 1 ]; then
|
||||||
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' \
|
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' \
|
||||||
| sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
| sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||||
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||||
@@ -178,13 +228,34 @@ if sudo -n true 2>/dev/null; then
|
|||||||
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
||||||
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
||||||
sudo udevadm control --reload-rules && sudo udevadm trigger || true
|
sudo udevadm control --reload-rules && sudo udevadm trigger || true
|
||||||
ok "installed udev rule (virtual gamepads)"
|
ok "installed udev rule (virtual gamepads + native Steam Deck controller)"
|
||||||
|
fi
|
||||||
|
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a *real* USB device so Steam
|
||||||
|
# Input adopts it (else it degrades to plain UHID, which Steam ignores — "no controller appears").
|
||||||
|
# Persist the autoload AND load it now so passthrough works without waiting for a reboot.
|
||||||
|
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
|
||||||
|
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
|
||||||
|
sudo modprobe vhci-hcd 2>/dev/null || warn "could not load vhci-hcd now (loads on next boot) — needed for the native Steam Deck pad"
|
||||||
|
ok "vhci-hcd autoload installed (native Steam Deck controller transport)"
|
||||||
|
fi
|
||||||
|
if id -nG "$USER" | grep -qw input; then
|
||||||
|
ok "already in the 'input' group"
|
||||||
|
else
|
||||||
|
sudo usermod -aG input "$USER"
|
||||||
|
NEED_RELOGIN=1
|
||||||
|
warn "added $USER to the 'input' group (applies on next login)"
|
||||||
fi
|
fi
|
||||||
id -nG "$USER" | grep -qw input || { sudo usermod -aG input "$USER"; warn "added $USER to 'input' group — log out/in (or reboot) for gamepad support"; }
|
|
||||||
else
|
else
|
||||||
warn "passwordless sudo unavailable — skipping UDP-buffer + udev tuning."
|
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
||||||
warn "Without it, high-bitrate streaming drops packets. Apply manually later:"
|
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
||||||
warn " echo -e 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf && sudo sysctl --system"
|
warn "A stock SteamOS 'deck' account has NO password, so sudo can't work until you set one:"
|
||||||
|
warn " passwd # set a sudo password once, then re-run this script"
|
||||||
|
warn "Or apply it by hand (then reboot):"
|
||||||
|
warn " sudo install -m644 $SRC/scripts/60-punktfunk.rules /etc/udev/rules.d/ &&"
|
||||||
|
warn " sudo install -m644 $SRC/scripts/punktfunk-modules.conf /etc/modules-load.d/punktfunk.conf &&"
|
||||||
|
warn " sudo usermod -aG input $USER &&"
|
||||||
|
warn " printf 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432\\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf &&"
|
||||||
|
warn " sudo sysctl --system && sudo udevadm control --reload-rules && sudo udevadm trigger"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 5. systemd user services ---------------------------------------------
|
# --- 5. systemd user services ---------------------------------------------
|
||||||
@@ -265,3 +336,10 @@ else
|
|||||||
echo " • Pairing required (secure default). From a client, pick this host and enter the PIN the host shows."
|
echo " • Pairing required (secure default). From a client, pick this host and enter the PIN the host shows."
|
||||||
fi
|
fi
|
||||||
echo " • Update later: bash $SRC/scripts/steamdeck/update.sh"
|
echo " • Update later: bash $SRC/scripts/steamdeck/update.sh"
|
||||||
|
if [ "$NEED_RELOGIN" = 1 ]; then
|
||||||
|
echo
|
||||||
|
warn "ONE MORE STEP before streaming — reboot the Deck (or fully log out and back in)."
|
||||||
|
echo " KWin only authorizes Desktop-mode screen capture on a fresh session, and the new 'input'"
|
||||||
|
echo " group (native Steam Deck controller passthrough) only applies to a new login. Streaming"
|
||||||
|
echo " Game Mode with a generic Xbox pad works now; Desktop capture + the native Deck pad need the reboot."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -44,6 +44,55 @@ sed "s|^Exec=.*|Exec=$TARGET_DIR/release/punktfunk-host|" "$SRC/packaging/linux/
|
|||||||
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||||
ok "KWin desktop-capture authorization refreshed"
|
ok "KWin desktop-capture authorization refreshed"
|
||||||
|
|
||||||
|
# Retrofit the system bits install.sh now sets up but older installs predate (idempotent). vhci-hcd =
|
||||||
|
# usbip transport for the native Steam Deck pad; 60-punktfunk.rules = /dev/uhid + vhci access; input
|
||||||
|
# group = uhid write; the kde-authorized grant (per-user, no root) = Desktop-mode input. A stock Deck
|
||||||
|
# needs a sudo PASSWORD, so PROMPT for it rather than silently skipping (skipping = gamepads stay dead).
|
||||||
|
SUDO_OK=0
|
||||||
|
if sudo -n true 2>/dev/null; then
|
||||||
|
SUDO_OK=1
|
||||||
|
elif [ -t 0 ]; then
|
||||||
|
warn "sudo needs your password to (re)apply the gamepad udev rule, vhci-hcd, input group, and UDP buffers:"
|
||||||
|
sudo -v && SUDO_OK=1 || true
|
||||||
|
fi
|
||||||
|
if [ "$SUDO_OK" = 1 ]; then
|
||||||
|
if [ -f "$SRC/scripts/60-punktfunk.rules" ]; then
|
||||||
|
sudo install -m644 "$SRC/scripts/60-punktfunk.rules" /etc/udev/rules.d/60-punktfunk.rules
|
||||||
|
sudo udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||||
|
sudo udevadm trigger >/dev/null 2>&1 || true
|
||||||
|
ok "gamepad udev rule ensured"
|
||||||
|
fi
|
||||||
|
if [ -f "$SRC/scripts/punktfunk-modules.conf" ]; then
|
||||||
|
sudo install -m644 "$SRC/scripts/punktfunk-modules.conf" /etc/modules-load.d/punktfunk.conf
|
||||||
|
sudo modprobe vhci-hcd 2>/dev/null || true
|
||||||
|
ok "vhci-hcd autoload ensured (native Steam Deck controller)"
|
||||||
|
fi
|
||||||
|
# UDP buffers: older installs (or sudo-skipped ones) still run the stock 416 KB cap.
|
||||||
|
if [ ! -f /etc/sysctl.d/99-punktfunk-net.conf ]; then
|
||||||
|
printf 'net.core.wmem_max=33554432\nnet.core.rmem_max=33554432\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf >/dev/null
|
||||||
|
sudo sysctl -q -p /etc/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||||
|
ok "UDP socket buffers raised to 32 MB (persisted)"
|
||||||
|
fi
|
||||||
|
if id -nG "$USER" | grep -qw input; then :; else
|
||||||
|
sudo usermod -aG input "$USER"
|
||||||
|
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "no usable sudo — SKIPPED gamepad/udev/vhci/UDP tuning (all root-only; no user-space alternative)."
|
||||||
|
warn "A stock SteamOS 'deck' account has NO password — set one with 'passwd', then re-run. Gamepads stay"
|
||||||
|
warn "Xbox-360 until this runs and you reboot."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
warn "If the controller still shows as an Xbox 360 pad, REBOOT the Deck once — the 'input' group and the"
|
||||||
|
warn "vhci-hcd module only become live for the host service on a fresh login."
|
||||||
|
GRANT_SRC="$SRC/scripts/headless/kde-authorized"
|
||||||
|
GRANT_DST="$HOME/.local/share/flatpak/db/kde-authorized"
|
||||||
|
if [ ! -s "$GRANT_DST" ] && [ -s "$GRANT_SRC" ]; then
|
||||||
|
mkdir -p "$(dirname "$GRANT_DST")"
|
||||||
|
install -m644 "$GRANT_SRC" "$GRANT_DST"
|
||||||
|
ok "seeded KDE RemoteDesktop grant (Desktop-mode input)"
|
||||||
|
fi
|
||||||
|
|
||||||
log "Restarting services"
|
log "Restarting services"
|
||||||
systemctl --user restart punktfunk-host.service
|
systemctl --user restart punktfunk-host.service
|
||||||
ok "punktfunk-host restarted"
|
ok "punktfunk-host restarted"
|
||||||
|
|||||||
+4
-1
@@ -89,7 +89,10 @@ A complexity ladder in [`examples/`](./examples) — start at the top:
|
|||||||
4. [`couch-preset.effect.ts`](./examples/couch-preset.effect.ts) — **advanced, Effect-native**: only if you're composing Effect programs.
|
4. [`couch-preset.effect.ts`](./examples/couch-preset.effect.ts) — **advanced, Effect-native**: only if you're composing Effect programs.
|
||||||
|
|
||||||
Examples 1–3 are the plain Promise facade and cover most automation; you only need example 4's
|
Examples 1–3 are the plain Promise facade and cover most automation; you only need example 4's
|
||||||
Effect surface for composed, interruptible programs. Run any with `bun examples/<file>.ts`.
|
Effect surface for composed, interruptible programs. Run any **in the repo** with
|
||||||
|
`bun examples/<file>.ts`. To **deploy** one on a host, install the package into its own directory
|
||||||
|
(`bun add @punktfunk/host`) and change its `../src/…` import to `@punktfunk/host` — see
|
||||||
|
[Running a single script as a service](#running-a-single-script-as-a-service).
|
||||||
|
|
||||||
Plus a real-world recipe:
|
Plus a real-world recipe:
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
// VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST`
|
// VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST`
|
||||||
// VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring
|
// VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring
|
||||||
//
|
//
|
||||||
|
// To run this *outside* the repo (the normal case), drop it in its own dir, `bun add
|
||||||
|
// @punktfunk/host`, and change the import below from `../src/index.js` to `@punktfunk/host`; then
|
||||||
|
// keep it alive as a systemd user unit (its SIGTERM release hands the pad back on `systemctl
|
||||||
|
// stop`). Full walkthrough: docs → Events & hooks → "full controller passthrough (VirtualHere)".
|
||||||
|
//
|
||||||
// Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring.
|
// Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring.
|
||||||
// VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`,
|
// VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`,
|
||||||
// macOS `vhclientosx`, ARM Linux `vhclientarm64`.
|
// macOS `vhclientosx`, ARM Linux `vhclientarm64`.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! harness adds `tc netem` jitter/reorder on the UDP path.
|
//! harness adds `tc netem` jitter/reorder on the UDP path.
|
||||||
|
|
||||||
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
use punktfunk_core::config::{Config, FecConfig, FecScheme, ProtocolPhase, Role};
|
||||||
|
use punktfunk_core::crypto::SessionKey;
|
||||||
use punktfunk_core::error::PunktfunkError;
|
use punktfunk_core::error::PunktfunkError;
|
||||||
use punktfunk_core::session::Session;
|
use punktfunk_core::session::Session;
|
||||||
use punktfunk_core::transport::loopback_pair;
|
use punktfunk_core::transport::loopback_pair;
|
||||||
@@ -25,7 +26,7 @@ fn config(role: Role, scheme: FecScheme, drop_period: u32) -> Config {
|
|||||||
shard_payload: 1024,
|
shard_payload: 1024,
|
||||||
max_frame_bytes: 8 * 1024 * 1024,
|
max_frame_bytes: 8 * 1024 * 1024,
|
||||||
encrypt: false,
|
encrypt: false,
|
||||||
key: [0u8; 16],
|
key: SessionKey::Aes128Gcm([0u8; 16]),
|
||||||
salt: [0u8; 4],
|
salt: [0u8; 4],
|
||||||
loopback_drop_period: drop_period,
|
loopback_drop_period: drop_period,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user