From 917cbb0cca7dfd3b6848b2a12a92eb2c09319252 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 12 Jun 2026 10:37:12 +0000 Subject: [PATCH] feat(discovery): native-protocol LAN auto-discovery over mDNS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the unified host (serve --native) and standalone m3-host now advertise the native punktfunk/1 service over mDNS (_punktfunk._udp) — the analogue of the GameStream _nvstream._tcp advert. TXT records carry proto, the host cert fingerprint (fp, the value clients pin), the pairing requirement (pair=required|optional), and the host id. New crate::discovery module, wired into m3::serve so both host entry points get it; best-effort, never blocks streaming (--connect always works). Client gains `punktfunk-client-rs --discover [SECS]`: browses the LAN and prints each host (name, addr:port, pairing, fingerprint), then exits. Apple clients browse the same service natively via NWBrowser (service type + TXT keys are the contract). Validated cross-LAN: the dev box discovered the GNOME-box appliance (pair=required) and a standalone synthetic host (pair=optional); fingerprint and pairing state correct in both. Also refresh the now-stale sendmmsg caveat in the bitrate doc (batched/paced send landed + validated to 1 Gbps) and mark the encode|send thread split done in §12. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 ++ Cargo.lock | 1 + crates/punktfunk-client-rs/Cargo.toml | 3 + crates/punktfunk-client-rs/src/main.rs | 88 +++++++++++++++++++++++++- crates/punktfunk-host/src/discovery.rs | 65 +++++++++++++++++++ crates/punktfunk-host/src/m3.rs | 29 +++++++-- crates/punktfunk-host/src/main.rs | 5 +- 7 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 crates/punktfunk-host/src/discovery.rs diff --git a/CLAUDE.md b/CLAUDE.md index c0293a2b..c6e564c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,10 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc no offline dictionary attack) establishes mutual trust: clients present persistent identities via QUIC client auth, the host stores paired fingerprints (`punktfunk1-paired.json`) and can gate sessions with `--require-pairing`. + **LAN auto-discovery**: both `serve --native` and `m3-host` advertise the native service over + mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to + pin)/`pair`(required|optional)/`id`; `punktfunk-client-rs --discover` lists hosts, Apple clients + browse the same service via NWBrowser (validated cross-LAN 2026-06-12). **Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on (validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms** diff --git a/Cargo.lock b/Cargo.lock index 83b48173..d2eb78f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1953,6 +1953,7 @@ name = "punktfunk-client-rs" version = "0.0.1" dependencies = [ "anyhow", + "mdns-sd", "opus", "punktfunk-core", "quinn", diff --git a/crates/punktfunk-client-rs/Cargo.toml b/crates/punktfunk-client-rs/Cargo.toml index cb248b54..cb31c774 100644 --- a/crates/punktfunk-client-rs/Cargo.toml +++ b/crates/punktfunk-client-rs/Cargo.toml @@ -15,6 +15,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# LAN host discovery (`--discover`): browse the native `_punktfunk._udp` mDNS service the host +# advertises (same crate/version the host advertises with). +mdns-sd = "0.20" # Linux-only: --mic-test's Opus encoder (libopus). The mic UPLINK itself is portable — # only this synthetic-tone test rig needs the encoder. diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index c3dc4c60..d452f999 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -31,8 +31,12 @@ //! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360, //! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`). //! +//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises +//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and +//! exits without connecting. +//! //! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test] -//! [--pin HEX] [--compositor NAME] [--gamepad NAME]` +//! [--pin HEX] [--compositor NAME] [--gamepad NAME] | --discover [SECS]` //! (M4 adds VAAPI decode + wgpu present on this skeleton.) use anyhow::{anyhow, Context, Result}; @@ -75,6 +79,9 @@ struct Args { /// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond /// bandwidth probe burst at `KBPS`, then report measured throughput + loss. speed_test: Option<(u32, u32)>, + /// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS` + /// seconds (default 4), print what's found, and exit. No connection is made. + discover: Option, } fn parse_mode(m: &str) -> Option { @@ -191,6 +198,12 @@ fn parse_args() -> Args { let (kbps, ms) = s.split_once(':')?; Some((kbps.parse().ok()?, ms.parse().ok()?)) }), + // `--discover` may be a bare flag or carry a seconds value (`--discover 8`); only treat + // the following token as a count when it parses as a number (else it's the next flag). + discover: argv + .iter() + .any(|a| a == "--discover") + .then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)), } } @@ -215,6 +228,10 @@ fn main() { } fn run(args: Args) -> Result<()> { + // Discovery mode: browse the LAN for native hosts, print them, and exit (no connection). + if let Some(secs) = args.discover { + return discover(secs); + } // Pairing mode: run the PIN ceremony and print the fingerprint to pin, then exit. if let Some(pin) = &args.pair { let (host, port) = args @@ -245,6 +262,75 @@ fn run(args: Args) -> Result<()> { rt.block_on(session(args)) } +/// Browse the LAN for native (`_punktfunk._udp`) hosts for `secs` seconds and print them, then +/// exit — the discovery side of the host's mDNS advert (host crate `discovery.rs`). TXT keys: +/// `fp` (host cert fingerprint to pin), `pair` (required|optional), `id` (stable host id). +fn discover(secs: u64) -> Result<()> { + use mdns_sd::{ServiceDaemon, ServiceEvent}; + use std::collections::BTreeMap; + use std::time::{Duration, Instant}; + + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; + let receiver = daemon + .browse("_punktfunk._udp.local.") + .context("browse _punktfunk._udp")?; + tracing::info!(secs, "browsing for native punktfunk/1 hosts (_punktfunk._udp)…"); + // One row per host, keyed by the stable uniqueid (falls back to the fullname) so the same + // host re-advertising or answering on several interfaces collapses to a single entry. + let mut hosts: BTreeMap = BTreeMap::new(); + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + // Timeout == time left to the deadline: an event returns immediately, otherwise the recv + // returns Err exactly at the deadline (or if the daemon channel closes) and we stop. + match receiver.recv_timeout(remaining) { + Ok(ServiceEvent::ServiceResolved(info)) => { + let props = info.get_properties(); + let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string(); + let addr = info + .get_addresses() + .iter() + .next() + .map(|a| a.to_string()) + .unwrap_or_else(|| "?".into()); + let fp = val("fp"); + let fp_short = fp.get(..16).unwrap_or(fp.as_str()); + let name = info.get_fullname().split('.').next().unwrap_or("?").to_string(); + let id = val("id"); + let key = if id.is_empty() { + info.get_fullname().to_string() + } else { + id + }; + let row = format!( + " {name:<24} {addr}:{:<6} pair={:<9} fp={fp_short}…", + info.get_port(), + val("pair"), + ); + hosts.insert(key, row); + } + Ok(_) => {} // SearchStarted / ServiceFound / removals — ignore + Err(_) => break, + } + } + let _ = daemon.shutdown(); + if hosts.is_empty() { + println!("no native punktfunk/1 hosts found on the LAN ({secs}s)"); + } else { + println!("native punktfunk/1 hosts ({}):", hosts.len()); + for row in hosts.values() { + println!("{row}"); + } + println!( + "\nconnect with: punktfunk-client-rs --connect [--pin | --pair ]" + ); + } + Ok(()) +} + async fn session(args: Args) -> Result<()> { let remote: std::net::SocketAddr = args.connect.parse().context("--connect host:port")?; let identity = load_or_create_identity()?; diff --git a/crates/punktfunk-host/src/discovery.rs b/crates/punktfunk-host/src/discovery.rs new file mode 100644 index 00000000..d612226b --- /dev/null +++ b/crates/punktfunk-host/src/discovery.rs @@ -0,0 +1,65 @@ +//! mDNS advertisement of the native punktfunk/1 service so native clients auto-discover the +//! host — the native-protocol analogue of the GameStream `_nvstream._tcp` advert +//! ([`crate::gamestream::mdns`]). +//! +//! The service type is **`_punktfunk._udp.local.`** (UDP because punktfunk/1 is QUIC, and the +//! advertised port is the QUIC control/data port a client `--connect`s). TXT records carry: +//! - `proto` — the wire protocol id ([`NATIVE_PROTO`]), so a future incompatible revision is +//! distinguishable by discovery alone; +//! - `fp` — the host certificate SHA-256 (lowercase hex), the exact value a client pins. mDNS is +//! unauthenticated, so this is advisory — TOFU/pinning still verifies it on connect — but it +//! 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). + +use anyhow::{Context, Result}; +use mdns_sd::{ServiceDaemon, ServiceInfo}; +use std::collections::HashMap; +use std::net::IpAddr; + +/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts. +pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local."; + +/// Wire protocol id advertised in the `proto` TXT record. +pub const NATIVE_PROTO: &str = "punktfunk/1"; + +/// Holds the mDNS daemon; dropping it unregisters the service. +pub struct Advert { + _daemon: ServiceDaemon, +} + +/// 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. +pub fn advertise_native( + hostname: &str, + ip: IpAddr, + port: u16, + fingerprint: &str, + require_pairing: bool, + uniqueid: &str, +) -> Result { + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; + let host_name = format!("{hostname}.local."); + let mut props: HashMap = HashMap::new(); + props.insert("proto".into(), NATIVE_PROTO.into()); + props.insert("fp".into(), fingerprint.to_string()); + props.insert( + "pair".into(), + if require_pairing { "required" } else { "optional" }.into(), + ); + props.insert("id".into(), uniqueid.to_string()); + let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props) + .context("build native mDNS ServiceInfo")?; + daemon + .register(service) + .context("register native mDNS service")?; + tracing::info!( + service = "_punktfunk._udp", + port, + host = %host_name, + pair = if require_pairing { "required" } else { "optional" }, + "native punktfunk/1 mDNS advertising" + ); + Ok(Advert { _daemon: daemon }) +} diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index e33791e9..6e452193 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -159,6 +159,27 @@ pub(crate) async fn serve(opts: M3Options, np: Arc) -> Result<()> "punktfunk/1 host listening (QUIC) — clients pin this fingerprint" ); + // mDNS: advertise the native service so clients auto-discover this host (the analogue of the + // GameStream _nvstream advert; both run in the unified host). Held for the host's lifetime — + // dropping `_advert` unregisters. Best-effort: a discovery failure must not stop streaming + // (manual `--connect HOST:PORT` always works), so we log and continue. + let _advert = match crate::gamestream::Host::detect() { + Ok(h) => crate::discovery::advertise_native( + &h.hostname, + h.local_ip, + opts.port, + &fingerprint_hex(&fingerprint), + opts.require_pairing, + &h.uniqueid, + ) + .map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)")) + .ok(), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "host detect for mDNS failed (continuing)"); + None + } + }; + // One audio capturer for the whole host lifetime, handed from session to session // (avoids a PipeWire stream setup per session — see AudioCapSlot). let audio_cap: AudioCapSlot = Arc::new(std::sync::Mutex::new(None)); @@ -236,10 +257,10 @@ const DEFAULT_BITRATE_KBPS: u32 = 20_000; /// above unusable, and a **2 Gbps** ceiling is generous headroom over the 1 Gbps+ target that /// GF(2¹⁶) Leopard FEC was built to reach — it lifts the GF(2⁸)/~1 Gbps wall, and at 1 Gbps a frame /// is only a few-hundred shards in one block (far under the 65535 limit). Enough for 5K@240 with -/// margin. Resolved value is echoed in `Welcome::bitrate_kbps`. CAVEAT: the native data plane still -/// does one `send()` syscall per packet (no `sendmmsg` batching / paced send thread yet), so -/// sustained multi-hundred-Mbps can show send-buffer drops (counted as `packets_send_dropped`) -/// until that lands — see the 1 Gbps work in the roadmap. +/// margin. Resolved value is echoed in `Welcome::bitrate_kbps`. The native data plane batches sends +/// (`sendmmsg`) and paces each frame on a dedicated send thread (microburst cap), validated to a +/// clean 1 Gbps with zero send-buffer drops; sustained overruns are still counted as +/// `packets_send_dropped`. const MIN_BITRATE_KBPS: u32 = 500; const MAX_BITRATE_KBPS: u32 = 2_000_000; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 04326030..77f33e2c 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -15,6 +15,7 @@ mod audio; mod capture; +mod discovery; mod encode; mod gamestream; mod inject; @@ -437,6 +438,8 @@ NOTES: 'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session (see docs/linux-setup.md). 'synthetic' needs no capture session and always runs. Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a - punktfunk_core host→client loopback that reassembles and byte-verifies each one." + punktfunk_core host→client loopback that reassembles and byte-verifies each one. + Both 'serve --native' and 'm3-host' advertise the native service over mDNS + (_punktfunk._udp) for client auto-discovery — 'punktfunk-client-rs --discover' lists them." ); }