feat(web): consolidate paired devices, self-contained sections, docs + lint
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s

Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
  their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
  the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
  drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
  Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
  removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.

Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
  plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
  Bun entry, launchers, systemd units, and packaging.

Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
  2.5.1; all lint issues fixed cleanly.

Also includes this branch's in-progress host, Apple client, packaging, and CI changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:05:22 +02:00
parent e1bc9fda22
commit ba39b08e09
86 changed files with 2726 additions and 2019 deletions
+11 -2
View File
@@ -11,7 +11,10 @@
//! lets a picker show the fingerprint and pre-pin a chosen host;
//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN
//! pairing ceremony before it can stream;
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises).
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises);
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
@@ -30,7 +33,9 @@ pub struct Advert {
}
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
/// `require_pairing` tells a discovering client whether it must pair before it can stream.
/// `require_pairing` tells a discovering client whether it must pair before it can stream;
/// `mgmt_port` is the management API's port (`Some` when this host serves one — the client browses
/// the library there over mTLS on the advertised IP), `None` for a host with no mgmt API.
pub fn advertise_native(
hostname: &str,
ip: IpAddr,
@@ -38,6 +43,7 @@ pub fn advertise_native(
fingerprint: &str,
require_pairing: bool,
uniqueid: &str,
mgmt_port: Option<u16>,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let host_name = format!("{hostname}.local.");
@@ -54,6 +60,9 @@ pub fn advertise_native(
.into(),
);
props.insert("id".into(), uniqueid.to_string());
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
}
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?;
daemon
+2 -2
View File
@@ -238,7 +238,7 @@ pub fn serve(
tokio::try_join!(
nvhttp::run(state.clone()),
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
crate::punktfunk1::serve(native_opts, np, stats.clone()),
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
)?;
} else {
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
@@ -249,7 +249,7 @@ pub fn serve(
);
tokio::try_join!(
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
crate::punktfunk1::serve(native_opts, np, stats.clone()),
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
)?;
}
Ok(())
+31 -1
View File
@@ -350,7 +350,17 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
let fps = parse_u("x-nv-video[0].maxFPS")
.filter(|&f| f > 0)
.unwrap_or(60);
let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000);
// Bitrate: Moonlight caps the legacy `x-nv-vqos[0].bw.*` fields at 100 Mbps for old-GFE
// compatibility and carries the user's REAL (uncapped) configured bitrate in the moonlight-specific
// `x-ml-video.configuredBitrateKbps`. Read that first — exactly like Sunshine — so a 500 Mbps client
// setting isn't silently floored to 100. Fall back to the legacy max for clients that don't send it,
// then a conservative default; clamp to a sane ceiling (the RTSP ANNOUNCE is attacker-controlled).
const MAX_BITRATE_KBPS: u32 = 1_000_000; // 1 Gbps — well above Moonlight's 500 Mbps slider
let bitrate_kbps = parse_u("x-ml-video.configuredBitrateKbps")
.filter(|&b| b > 0)
.or_else(|| parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").filter(|&b| b > 0))
.unwrap_or(20_000)
.min(MAX_BITRATE_KBPS);
// Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1.
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
Some("1") => Codec::H265,
@@ -496,6 +506,26 @@ mod tests {
}
}
/// Bitrate precedence: the moonlight-specific `x-ml-video.configuredBitrateKbps` (the user's real,
/// uncapped setting) wins over the legacy `x-nv-vqos[0].bw.maximumBitrateKbps` (which Moonlight floors
/// at 100 Mbps for old-GFE compat). Without this a 500 Mbps client streamed at 100.
#[test]
fn announce_prefers_configured_bitrate() {
// Real Moonlight shape: legacy max floored at 100 Mbps, configured carrying the true 500 Mbps.
let map = announce(&[
("x-nv-vqos[0].bw.maximumBitrateKbps", "100000"),
("x-ml-video.configuredBitrateKbps", "500000"),
]);
assert_eq!(stream_config(&map).unwrap().bitrate_kbps, 500_000);
// No configured field (older client) → fall back to the legacy max (the base announce's 40 Mbps).
assert_eq!(stream_config(&announce(&[])).unwrap().bitrate_kbps, 40_000);
// A zero configured value is ignored (falls back), and an absurd value is clamped to the ceiling.
let zero = announce(&[("x-ml-video.configuredBitrateKbps", "0")]);
assert_eq!(stream_config(&zero).unwrap().bitrate_kbps, 40_000);
let huge = announce(&[("x-ml-video.configuredBitrateKbps", "9000000")]);
assert_eq!(stream_config(&huge).unwrap().bitrate_kbps, 1_000_000);
}
/// Missing required video keys → no config (the PLAY handler then refuses to stream).
#[test]
fn announce_missing_required_keys() {
+24 -4
View File
@@ -472,6 +472,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
let mut native_port: u16 = 9777; // the native plane always runs now
let mut open = false;
let mut gamestream = false;
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
// paired clients can browse the game library out of the box (the bearer admin surface stays
// loopback-gated in `mgmt::require_auth` regardless of the bind).
let mut mgmt_bind_explicit = false;
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
@@ -485,7 +489,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
"--mgmt-bind" => {
opts.bind = next()?
.parse()
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?;
mgmt_bind_explicit = true;
}
"--mgmt-token" => {
let token = next()?;
@@ -526,9 +531,20 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
if opts.token.is_none() {
opts.token = Some(crate::mgmt_token::load_or_generate()?);
}
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
if !mgmt_bind_explicit {
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
}
let native = punktfunk1::NativeServe {
port: native_port,
require_pairing: !open,
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
mgmt_port: opts.bind.port(),
};
Ok((opts, native, gamestream))
}
@@ -643,9 +659,13 @@ USAGE:
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
SERVE OPTIONS:
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990)
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN);
required when --mgmt-bind is not loopback
--mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
reach the read-only surface, incl. the game library, over mTLS;
the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
bind loopback only)
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
admin endpoints it guards are honored only from a loopback peer
(the co-located web console), never over the LAN
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
+101 -10
View File
@@ -9,15 +9,20 @@
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
//! cbindgen header).
//!
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires
//! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired
//! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` /
//! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The
//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
//! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route
//! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by
//! default** so a paired native client can reach the read-only surface (host/status/clients and the
//! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the
//! `cert_may_access` allowlist). The **bearer-token admin surface** (pairing, unpair, session
//! control, library mutation, stats) is honored **only from a loopback peer**, so it is never
//! LAN-exposed: the web console BFF — the sole token holder (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`,
//! else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`) — always connects over
//! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI
//! document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
use crate::encode::Codec;
use crate::gamestream::{
tls::{serve_https, PeerCertFingerprint},
tls::{serve_https, PeerAddr, PeerCertFingerprint},
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
};
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
@@ -474,8 +479,11 @@ where
// Auth
// ---------------------------------------------------------------------------------------
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required
/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes.
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open
@@ -493,8 +501,25 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
return next.run(req).await;
}
}
// Otherwise require the bearer token (the web console / admin). `run` always passes a token, so
// no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny.
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
// The token authorizes the full admin surface, so confining it to loopback keeps that surface off
// the LAN even though the listener now binds all interfaces by default (so paired clients can
// browse the library). The web console BFF — the sole token holder — always connects over
// loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is
// limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit
// test → treat as loopback so handler tests still authenticate by token.)
let from_loopback = req
.extensions()
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
if !from_loopback {
return api_error(
StatusCode::UNAUTHORIZED,
"the admin API is loopback-only — a LAN client must present a paired client certificate",
);
}
// `run` always passes a token, so no-token means a misconfigured caller (e.g. a test constructing
// `app` directly) — deny.
let Some(expected) = st.token.as_deref() else {
return api_error(StatusCode::UNAUTHORIZED, "authentication required");
};
@@ -1605,6 +1630,72 @@ mod tests {
);
}
/// The bearer-token (admin) path is honored only from a LOOPBACK peer: the same token from a LAN
/// peer is rejected, so binding the listener to all interfaces (so paired clients can browse the
/// library by default) never LAN-exposes the admin surface. A paired *cert*, by contrast, reaches
/// the read-only allowlist from anywhere.
#[tokio::test]
async fn bearer_admin_is_loopback_only() {
let lan: SocketAddr = "192.168.1.50:54321".parse().unwrap();
let loopback: SocketAddr = "127.0.0.1:33333".parse().unwrap();
let bearer = |peer: SocketAddr| {
let mut req = get_req("/api/v1/stats/recordings"); // a bearer-only (admin) route
req.extensions_mut().insert(PeerAddr(peer));
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer test-secret"),
);
req
};
let app = test_app(test_state(), None);
// A valid bearer from a LAN peer → rejected on the admin API.
assert_eq!(
app.clone()
.oneshot(bearer(lan))
.await
.expect("infallible")
.status(),
StatusCode::UNAUTHORIZED,
"a bearer token from a LAN peer must be rejected on the admin API"
);
// The SAME token from a loopback peer (the web console BFF) → accepted.
assert_ne!(
app.clone()
.oneshot(bearer(loopback))
.await
.expect("infallible")
.status(),
StatusCode::UNAUTHORIZED,
"the bearer token must be accepted from a loopback peer"
);
// A paired cert from a LAN peer still reaches the read-only library (the feature this enables).
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-lanlib-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let fp = "deadbeefcafe";
np.add("lan-client", fp).unwrap();
let app = test_app_native(test_state(), np);
let mut req = get_req("/api/v1/library");
req.extensions_mut().insert(PeerAddr(lan));
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
assert_ne!(
app.clone().oneshot(req).await.expect("infallible").status(),
StatusCode::UNAUTHORIZED,
"a paired cert must reach the library from a LAN peer"
);
}
#[tokio::test]
async fn health_is_open_and_versioned() {
let app = test_app(test_state(), None);
+9 -1
View File
@@ -121,7 +121,8 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> {
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
// recorder across mgmt + both streaming paths instead.
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
rt.block_on(serve(opts, np, stats))
// Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0).
rt.block_on(serve(opts, 0, np, stats))
}
fn fingerprint_hex(fp: &[u8; 32]) -> String {
@@ -139,6 +140,9 @@ pub(crate) struct NativeServe {
/// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on
/// demand from the web console (arm → PIN); paired devices persist.
pub require_pairing: bool,
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
pub mgmt_port: u16,
}
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
@@ -166,6 +170,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
pub(crate) async fn serve(
opts: Punktfunk1Options,
mgmt_port: u16,
np: Arc<NativePairing>,
stats: Arc<StatsRecorder>,
) -> Result<()> {
@@ -198,6 +203,8 @@ pub(crate) async fn serve(
&fingerprint_hex(&fingerprint),
opts.require_pairing,
&h.uniqueid,
// 0 = standalone `punktfunk1-host` (no mgmt API) → don't advertise an `mgmt` port.
(mgmt_port != 0).then_some(mgmt_port),
)
.map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)"))
.ok(),
@@ -3864,6 +3871,7 @@ mod tests {
pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle
},
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
np_host,
StatsRecorder::new(
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
+3 -2
View File
@@ -226,7 +226,8 @@ fn web_setup(args: &[String]) -> Result<()> {
bail!("web launcher missing: {}", cmd.display());
}
register_web_task(&cmd)?;
// 4. firewall: inbound TCP 3000
// 4. firewall: inbound TCP 3000. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
if !run_quiet(
"netsh",
&[
@@ -251,7 +252,7 @@ fn web_setup(args: &[String]) -> Result<()> {
std::thread::sleep(std::time::Duration::from_secs(1));
}
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
println!("web console set up + started (http://<host-ip>:3000)");
println!("web console set up + started (https://<host-ip>:3000)");
Ok(())
}