From 5d9f1ccb6de1513c5a903af0756e990de7963f3b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 9 Jul 2026 15:56:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(host):=20--no-mdns/PUNKTFUNK=5FMDNS=20gate?= =?UTF-8?q?=20+=20punktfunk1-host=20--pairing-pin=20=E2=80=94=20docker-tes?= =?UTF-8?q?ting=20enablers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mDNS advertisement (native _punktfunk._udp AND GameStream _nvstream) can now be disabled with --no-mdns on serve/punktfunk1-host or PUNKTFUNK_MDNS=0 (the PUNKTFUNK_ZEROCOPY off-grammar) — for multicast-dead environments (bridged Docker, CI netns) where the advert reaches nobody or fails outright and aborts the GameStream plane; clients dial a manually-added host instead (mDNS-blind host-add works since the 0.8.4 dial-first fix). Unit tests no longer advertise on the LAN (mdns: false). punktfunk1-host gains --pairing-pin (the already-plumbed fixed-PIN test seam, now CLI-exposed; empty refused like --mgmt-token) so a CI harness runs a deterministic SPAKE2 ceremony instead of scraping the logged PIN. Verified live: --no-mdns + PUNKTFUNK_MDNS=0 skip the advert, --pairing-pin arms with the fixed PIN, and a punktfunk1-host --source synthetic + punktfunk-probe loopback run passes verification (60/60 frames, 0 mismatch). Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/discovery.rs | 38 +++++++++++++++++++++ crates/punktfunk-host/src/gamestream/mod.rs | 14 ++++++-- crates/punktfunk-host/src/main.rs | 23 ++++++++++++- crates/punktfunk-host/src/punktfunk1.rs | 21 +++++++++++- docs-site/content/docs/host-cli.md | 7 +++- scripts/host.env.example | 2 ++ 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/punktfunk-host/src/discovery.rs b/crates/punktfunk-host/src/discovery.rs index 2799dde6..7576c200 100644 --- a/crates/punktfunk-host/src/discovery.rs +++ b/crates/punktfunk-host/src/discovery.rs @@ -27,6 +27,27 @@ 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."; +/// mDNS advertisement gate — `PUNKTFUNK_MDNS`. Default ON; `0|false|off|no` (the +/// `PUNKTFUNK_ZEROCOPY` off-grammar) disables BOTH the native and GameStream adverts, for +/// environments where multicast is dead or unwanted (bridged Docker, CI network namespaces, +/// locked-down VLANs): the advert there reaches nobody — or fails outright and aborts the +/// GameStream plane — while clients can always dial a manually-added host (mDNS-blind +/// host-add works since the 0.8.4 dial-first fix). CLI `--no-mdns` sets the same knob. +pub(crate) fn mdns_enabled() -> bool { + !std::env::var("PUNKTFUNK_MDNS") + .map(|s| mdns_off_value(&s)) + .unwrap_or(false) +} + +/// `true` iff the `PUNKTFUNK_MDNS` value means "off". Split from the env read for testability +/// (env vars are process-global; tests must not race the parallel suite by setting them). +fn mdns_off_value(s: &str) -> bool { + matches!( + s.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) +} + /// Wire protocol id advertised in the `proto` TXT record. pub const NATIVE_PROTO: &str = "punktfunk/1"; @@ -92,3 +113,20 @@ pub fn advertise_native( ); Ok(Advert { _daemon: daemon }) } + +#[cfg(test)] +mod tests { + use super::mdns_off_value; + + #[test] + fn mdns_off_grammar() { + for off in ["0", "false", "off", "no", " OFF ", "False"] { + assert!(mdns_off_value(off), "{off:?} should disable mDNS"); + } + // Anything else — including set-but-empty — keeps the advert on (matches the + // PUNKTFUNK_ZEROCOPY grammar: only an explicit off-value turns it off). + for on in ["", "1", "true", "yes", "on", "banana"] { + assert!(!mdns_off_value(on), "{on:?} should keep mDNS on"); + } + } +} diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index ba626fb3..f4f00300 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -232,8 +232,18 @@ pub fn serve( let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let native_opts = crate::punktfunk1::native_serve_opts(&native); if gamestream { - // Unified host: GameStream compat planes + native + mgmt. - let _advert = mdns::advertise(&state.host).context("mDNS advertise")?; + // Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is + // fatal on failure when enabled (Moonlight clients can't find the host without it) — + // `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock + // Moonlight then needs a manually-added host). + let _advert = if native.mdns { + Some(mdns::advertise(&state.host).context("mDNS advertise")?) + } else { + tracing::info!( + "GameStream mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS)" + ); + None + }; rtsp::spawn(state.clone()).context("start RTSP server")?; control::spawn(state.clone()).context("start ENet control server")?; tracing::info!( diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 2928e3b8..098495e2 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -435,6 +435,13 @@ fn real_main() -> Result<()> { Some("virtual") => punktfunk1::Punktfunk1Source::Virtual, _ => punktfunk1::Punktfunk1Source::Synthetic, }; + // Fixed pairing PIN for test harnesses/CI (deterministic ceremony instead of scraping + // the logged random PIN). An empty value would arm SPAKE2 with an empty password — + // refuse it loudly, mirroring the --mgmt-token guard. + let pairing_pin = match get("--pairing-pin") { + Some(p) if p.trim().is_empty() => bail!("--pairing-pin must not be empty"), + p => p.map(str::to_string), + }; punktfunk1::run(punktfunk1::Punktfunk1Options { port: get("--port").and_then(|s| s.parse().ok()).unwrap_or(9777), source, @@ -453,7 +460,7 @@ fn real_main() -> Result<()> { // the default and accepted as no-ops for back-compat. require_pairing: !args.iter().any(|a| a == "--allow-tofu"), allow_pairing: true, - pairing_pin: None, + pairing_pin, paired_store: None, // Fixed data-plane port: bind it and stream direct (no hole-punch), removing the // ~2.5 s punch-timeout on a firewalled host. Default (absent) = a random port + @@ -469,6 +476,7 @@ fn real_main() -> Result<()> { .filter(|&ms| ms > 0) .map(std::time::Duration::from_millis) .or_else(punktfunk1::idle_timeout_from_env), + mdns: !args.iter().any(|a| a == "--no-mdns") && discovery::mdns_enabled(), }) } // Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point. @@ -561,6 +569,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ .and_then(|s| s.parse().ok()); let mut open = false; let mut gamestream = false; + let mut no_mdns = 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). @@ -612,6 +621,9 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ // Disable mandatory native pairing — any device can connect (trusted single-user // setups only). The default REQUIRES pairing. "--open" => open = true, + // Skip the mDNS adverts (native + GameStream) — multicast-dead environments + // (bridged Docker, CI netns); clients connect via a manually-added host. + "--no-mdns" => no_mdns = true, "-h" | "--help" => { print_usage(); std::process::exit(0); @@ -642,6 +654,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ // assuming the default). `opts.bind.port()` is the real port even if the operator moved it. mgmt_port: opts.bind.port(), data_port, + mdns: !no_mdns && discovery::mdns_enabled(), }; Ok((opts, native, gamestream)) } @@ -775,6 +788,9 @@ SERVE OPTIONS: PUNKTFUNK_DATA_PORT: a random port + hole-punch (crosses NAT) --open disable mandatory native pairing (default: pairing REQUIRED — an open host any LAN device can stream from is insecure) + --no-mdns skip the mDNS adverts (native + GameStream) — for multicast-dead + environments (bridged Docker, CI); clients connect via a manually + added host. Also PUNKTFUNK_MDNS=0 PUNKTFUNK1-HOST OPTIONS: --port QUIC listen port (default: 9777) @@ -792,6 +808,11 @@ PUNKTFUNK1-HOST OPTIONS: pair=optional. Default: pairing REQUIRED — the host rejects unpaired clients and logs a 4-digit pairing PIN at startup; TOFU without pairing is insecure on a LAN + --pairing-pin fixed pairing PIN instead of the random per-ceremony one — for + test harnesses/CI (deterministic `probe --pair`); do not use on + a real LAN (a guessable PIN defeats the ceremony's rate limit) + --no-mdns skip the _punktfunk._udp mDNS advert (multicast-dead environments; + clients use --connect HOST:PORT). Also PUNKTFUNK_MDNS=0 SPIKE OPTIONS: --source diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index f64f225b..1327cb70 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -92,6 +92,10 @@ pub struct Punktfunk1Options { /// `PUNKTFUNK_IDLE_TIMEOUT_MS`; clamped to a ≥1s floor with a keep-alive that scales to it so a /// live session never false-closes. pub idle_timeout: Option, + /// Advertise this host over mDNS (`_punktfunk._udp`). Default on; `--no-mdns` / + /// `PUNKTFUNK_MDNS=0` turns it off for multicast-dead environments (bridged Docker, CI netns) + /// — clients then connect via `--connect HOST:PORT` / a manually-added host, which always works. + pub mdns: bool, } /// Bind the per-session data-plane UDP socket, honoring [`Punktfunk1Options::data_port`]. Returns @@ -183,6 +187,10 @@ pub(crate) struct NativeServe { /// Fixed data-plane UDP port (`--data-port` / `PUNKTFUNK_DATA_PORT`); see /// [`Punktfunk1Options::data_port`]. `None` = random port + hole-punch (the default). pub data_port: Option, + /// Advertise over mDNS (`--no-mdns` / `PUNKTFUNK_MDNS=0` turns it off). Gates the native + /// `_punktfunk._udp` advert AND the GameStream `_nvstream` advert — the serve-level knob for + /// multicast-dead environments; see [`Punktfunk1Options::mdns`]. + pub mdns: bool, } /// Options for the native host when the unified `serve --native` runs it: real virtual capture, @@ -218,6 +226,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { paired_store: None, data_port: cfg.data_port, idle_timeout: idle_timeout_from_env(), + mdns: cfg.mdns, } } @@ -249,7 +258,13 @@ pub(crate) async fn serve( // 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() { + let _advert = if !opts.mdns { + tracing::info!( + "mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS) — clients connect by address" + ); + None + } else { + match crate::gamestream::Host::detect() { Ok(h) => crate::discovery::advertise_native( &h.hostname, h.local_ip, @@ -266,6 +281,7 @@ pub(crate) async fn serve( 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 @@ -4474,6 +4490,7 @@ mod tests { paired_store: None, data_port: None, idle_timeout: None, + mdns: false, // unit tests must not advertise on the LAN }) }); std::thread::sleep(std::time::Duration::from_millis(500)); @@ -4671,6 +4688,7 @@ mod tests { paired_store: None, // unused: the shared `np` IS the store handle data_port: None, idle_timeout: None, + mdns: false, }, 0, // no mgmt API in this test → advertise no `mgmt` mDNS port np_host, @@ -4772,6 +4790,7 @@ mod tests { paired_store: Some(test_paired_path()), data_port: None, idle_timeout: None, + mdns: false, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); diff --git a/docs-site/content/docs/host-cli.md b/docs-site/content/docs/host-cli.md index a7151a3a..26bea0d8 100644 --- a/docs-site/content/docs/host-cli.md +++ b/docs-site/content/docs/host-cli.md @@ -34,6 +34,7 @@ punktfunk-host serve --gamestream | `--open` | Don't require pairing — serve any device on the network. Off by default; only for trusted single-user setups. | | `--mgmt-bind ` | Management API address (default `0.0.0.0:47990` — all interfaces, so paired clients can browse the game library over mTLS; pass `127.0.0.1:47990` to keep it loopback-only). | | `--mgmt-token ` | Override the bearer token for the management API. | +| `--no-mdns` | Skip the mDNS adverts (native + GameStream) — for networks/containers where multicast doesn't work. Clients connect via a manually added host instead. Same as `PUNKTFUNK_MDNS=0`. | These are the only flags `serve` accepts. @@ -69,13 +70,17 @@ punktfunk-host punktfunk1-host --source virtual | `--max-sessions ` | Exit after N sessions (0 = serve forever). | | `--allow-pairing` | Accept PIN pairing; the host prints a PIN when a client pairs. | | `--require-pairing` | Only serve paired devices (implies `--allow-pairing`). | +| `--pairing-pin ` | Use a fixed pairing PIN instead of a fresh random one per ceremony. For test harnesses/CI only — a guessable PIN defeats the ceremony's rate limit. | +| `--no-mdns` | Skip the `_punktfunk._udp` advert; clients use `--connect HOST:PORT`. Same as `PUNKTFUNK_MDNS=0`. | `--max-concurrent`, `--allow-pairing`, and `--require-pairing` are **`punktfunk1-host`-only** — `serve` does not accept them. On `serve` you arm pairing from the web console instead, and concurrency is fixed at the built-in default (4 sessions) rather than settable from the command line. Both `serve` and `punktfunk1-host` advertise the host on the network so clients can discover it. List -hosts from another machine with `punktfunk-probe --discover`. +hosts from another machine with `punktfunk-probe --discover`. Where multicast doesn't work (some +Docker/VLAN setups), pass `--no-mdns` (or set `PUNKTFUNK_MDNS=0`) and add the host in the client by +address instead. ## Environment diff --git a/scripts/host.env.example b/scripts/host.env.example index 1bf1ee2f..6e42456f 100644 --- a/scripts/host.env.example +++ b/scripts/host.env.example @@ -49,6 +49,8 @@ PUNKTFUNK_VIDEO_SOURCE=virtual #PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput #PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent #PUNKTFUNK_PERF=1 # per-stage timing logs +#PUNKTFUNK_MDNS=0 # disable the mDNS adverts (native + GameStream) — for multicast- + # dead networks/containers; clients add the host by address instead # Display-management policy (keep-alive · topology · conflict · identity · layout · max) is set in the # web console (Host → Virtual displays) → ~/.config/punktfunk/display-settings.json, NOT here; a # settings file supersedes the legacy PUNKTFUNK_MONITOR_LINGER_MS / _NO_ISOLATE / *_VIRTUAL_PRIMARY