From 62e0367f4b02f01feafc3f7fb61f9f1e7d94af57 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:53:54 +0000 Subject: [PATCH] feat(punktfunk1): configurable data-plane UDP port (--data-port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native data plane used a random ephemeral UDP port (hole-punched), which a strict firewall can't pre-open — so remote clients behind one couldn't connect. Add an optional fixed data port: - `Punktfunk1Options`/`NativeServe` gain `data_port`; `bind_data_socket` binds the fixed port (→ direct, no hole-punch) or falls back to a random port + hole-punch when unset or the fixed port is busy (a concurrent session already holds it). - `UdpTransport::from_socket`/`from_socket_punch` adopt an already-bound socket, so the host keeps the SAME data socket from handshake through streaming — no drop-then-rebind window in which a concurrent session could steal a fixed port. - `main.rs` wires the CLI flag through to `NativeServe`. - Firewall docs updated (troubleshooting.md + apt/pacman/bazzite READMEs): control plane is the fixed UDP 9777; the data plane is a separate random port that usually needs no rule, with the fixed-port option for strict firewalls. Unit-tested: default random+hole-punch, and fixed-port-then-fallback-when-busy. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-core/src/transport/udp.rs | 20 +++- crates/punktfunk-host/src/main.rs | 29 +++++ crates/punktfunk-host/src/punktfunk1.rs | 126 +++++++++++++++++---- docs-site/content/docs/troubleshooting.md | 51 ++++++++- packaging/arch/README.md | 19 +++- packaging/bazzite/README.md | 11 +- packaging/debian/README.md | 19 +++- 7 files changed, 238 insertions(+), 37 deletions(-) diff --git a/crates/punktfunk-core/src/transport/udp.rs b/crates/punktfunk-core/src/transport/udp.rs index 30843b3..0589edd 100644 --- a/crates/punktfunk-core/src/transport/udp.rs +++ b/crates/punktfunk-core/src/transport/udp.rs @@ -416,7 +416,14 @@ impl UdpTransport { /// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the /// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract. pub fn connect(local: &str, peer: &str) -> std::io::Result { - let socket = UdpSocket::bind(local)?; + Self::from_socket(UdpSocket::bind(local)?, peer) + } + + /// Adopt an already-bound socket for the data plane: `connect` it to `peer`, tune buffers + + /// QoS, go non-blocking. Lets the host bind the data port up front (e.g. a fixed `--data-port`) + /// and keep the *same* socket from handshake through streaming — no drop-then-rebind window in + /// which a concurrent session could steal a fixed port. + pub fn from_socket(socket: UdpSocket, peer: &str) -> std::io::Result { socket.connect(peer)?; super::qos::grow_socket_buffers(&socket); // The native data plane is video-dominant — tag it as the video class (opt-in via @@ -438,7 +445,16 @@ impl UdpTransport { fallback_peer: &str, punch_timeout: std::time::Duration, ) -> std::io::Result<(Self, bool)> { - let socket = UdpSocket::bind(local)?; + Self::from_socket_punch(UdpSocket::bind(local)?, fallback_peer, punch_timeout) + } + + /// [`connect_via_punch`](Self::connect_via_punch) on an already-bound socket — see + /// [`from_socket`](Self::from_socket) for why the host binds the data port up front. + pub fn from_socket_punch( + socket: UdpSocket, + fallback_peer: &str, + punch_timeout: std::time::Duration, + ) -> std::io::Result<(Self, bool)> { socket.set_read_timeout(Some(punch_timeout))?; let deadline = std::time::Instant::now() + punch_timeout; let mut buf = [0u8; 64]; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index bb859fd..dc45d41 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -418,6 +418,13 @@ fn real_main() -> Result<()> { allow_pairing: true, pairing_pin: None, 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 + + // hole-punch. Also honors PUNKTFUNK_DATA_PORT. + data_port: get("--data-port") + .map(str::to_string) + .or_else(|| std::env::var("PUNKTFUNK_DATA_PORT").ok()) + .and_then(|s| s.parse().ok()), }) } // Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point. @@ -501,6 +508,12 @@ fn input_test() -> Result<()> { fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> { let mut opts = mgmt::Options::default(); let mut native_port: u16 = 9777; // the native plane always runs now + // Fixed data-plane UDP port: `Some(p)` binds p and streams direct (no hole-punch, no ~2.5 s + // punch-timeout on a firewalled host); `None` (default) = a random port + hole-punch. Env + // default, `--data-port` overrides. + let mut data_port: Option = std::env::var("PUNKTFUNK_DATA_PORT") + .ok() + .and_then(|s| s.parse().ok()); 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 @@ -541,6 +554,13 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ .parse() .map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))? } + "--data-port" => { + data_port = Some( + next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --data-port (want a port number)"))?, + ) + } // Opt into the GameStream/Moonlight-compat planes (off by default — they carry the // inherent on-path #5/#9 weaknesses; only for a trusted LAN). "--gamestream" | "--moonlight" => gamestream = true, @@ -576,6 +596,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ // 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(), + data_port, }; Ok((opts, native, gamestream)) } @@ -703,6 +724,10 @@ SERVE OPTIONS: reuse, security-review #5/#9); enable only on a TRUSTED LAN --native no-op (the native punktfunk/1 plane always runs in `serve` now) --native-port native QUIC port (default 9777) + --data-port pin the per-session video data plane to this fixed UDP port and + stream direct (no hole-punch) — open exactly this port in a host + firewall to avoid the ~2.5 s punch-timeout. Default (unset) or + 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) @@ -714,6 +739,10 @@ PUNKTFUNK1-HOST OPTIONS: --max-sessions exit after N sessions; 0 = serve forever (default: 0) --max-concurrent stream at most N sessions at once (NVENC bound); overflow waits in the accept queue; 0 = unlimited (default: 4) + --data-port pin the video data plane to this fixed UDP port and stream direct + (no hole-punch; open exactly this port to skip the ~2.5 s punch- + timeout). Default or PUNKTFUNK_DATA_PORT: random port + hole-punch. + A fixed port fits one session; concurrent ones fall back to random --allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise pair=optional. Default: pairing REQUIRED — the host rejects unpaired clients and logs a 4-digit pairing PIN at startup; diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 8feba5a..a8485f9 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -75,6 +75,35 @@ pub struct Punktfunk1Options { pub pairing_pin: Option, /// Paired-clients store path override (tests); `None` = the default config path. pub paired_store: Option, + /// Fixed data-plane UDP port. `None`/`Some(0)` (default): bind a random ephemeral port and + /// **hole-punch** — wait ~2.5 s for the client's punch, then fall back to its reported address + /// (traverses NAT / a stateful inter-VLAN firewall with no forwarded port, at the cost of the + /// punch-timeout on a firewall that drops the punch). `Some(p)`: bind that fixed port and + /// stream **directly** to the client's reported address with no punch-wait — for a host whose + /// data port is fixed + firewall-opened/forwarded, this removes the punch-timeout delay. A + /// fixed port only fits one data plane at a time, so a concurrent session finding it busy + /// falls back to random + hole-punch (see [`bind_data_socket`]). + pub data_port: Option, +} + +/// Bind the per-session data-plane UDP socket, honoring [`Punktfunk1Options::data_port`]. Returns +/// `(socket, direct)`: `direct = true` (a successfully-bound fixed port) means "stream straight to +/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means +/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind +/// window in which a concurrent session could steal a fixed port. +fn bind_data_socket(data_port: Option) -> std::io::Result<(std::net::UdpSocket, bool)> { + if let Some(p) = data_port.filter(|p| *p != 0) { + match std::net::UdpSocket::bind(("0.0.0.0", p)) { + Ok(sock) => return Ok((sock, true)), + Err(e) => tracing::warn!( + data_port = p, + error = %e, + "fixed --data-port is busy (a concurrent session already holds it?) — \ + falling back to a random port + hole-punch for this session" + ), + } + } + Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false)) } /// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API. @@ -143,6 +172,9 @@ pub(crate) struct NativeServe { /// 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, + /// 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, } /// Options for the native host when the unified `serve --native` runs it: real virtual capture, @@ -165,6 +197,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { allow_pairing: false, pairing_pin: None, paired_store: None, + data_port: cfg.data_port, } } @@ -656,6 +689,7 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; + let data_port = opts.data_port; let handshake = async { let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; anyhow::ensure!( @@ -846,10 +880,12 @@ async fn serve_session( "encode chroma" ); - // Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport). - let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; - let udp_port = probe.local_addr()?.port(); - drop(probe); + // Reserve the data-plane UDP socket up front and HOLD it through streaming (no + // bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed + // `--data-port` yields `direct = true` (stream straight to the client's reported address, + // no punch-wait); otherwise a random ephemeral port + hole-punch. + let (data_sock, direct) = bind_data_socket(data_port)?; + let udp_port = data_sock.local_addr()?.port(); let mut key = [0u8; 16]; rand::thread_rng().fill_bytes(&mut key); @@ -909,9 +945,9 @@ async fn serve_session( let start = Start::decode(&io::read_msg(&mut recv).await?) .map_err(|e| anyhow!("Start decode: {e:?}"))?; - Ok::<_, anyhow::Error>((hello, welcome, udp_port, start, compositor)) + Ok::<_, anyhow::Error>((hello, welcome, udp_port, data_sock, direct, start, compositor)) }; - let (hello, welcome, udp_port, start, compositor) = + let (hello, welcome, udp_port, data_sock, direct, start, compositor) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) .await .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; @@ -1233,29 +1269,41 @@ async fn serve_session( .unwrap_or_else(|| conn.remote_address().ip().to_string()); let result: Result<()> = async { tokio::task::spawn_blocking(move || -> Result<()> { - // Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED - // source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host - // can be on different subnets; control + side planes ride the client-initiated QUIC, but - // the raw video UDP needs the client to open the path first). Falls back to the - // client-reported address for clients that don't punch (flat-LAN, unchanged). - let (transport, punched) = match UdpTransport::connect_via_punch( - &format!("0.0.0.0:{udp_port}"), - &client_udp.to_string(), - std::time::Duration::from_millis(2500), - ) { + // Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly + // for the client's punch, then stream to its OBSERVED source, so video traverses a + // NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated + // QUIC, but the raw video UDP needs the client to open the path first); falls back to + // the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed + // `--data-port` (`direct`), skip the punch-wait and stream straight to the reported + // address — the operator declared a reachable, firewall-opened port, so there's no + // punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side + // NAT that remaps it.) + let bound = if direct { + UdpTransport::from_socket(data_sock, &client_udp.to_string()).map(|t| (t, false)) + } else { + UdpTransport::from_socket_punch( + data_sock, + &client_udp.to_string(), + std::time::Duration::from_millis(2500), + ) + }; + let (transport, punched) = match bound { Ok(v) => v, Err(e) => { // Surface the failure here directly: a data-plane bind error would otherwise be // reported only after teardown (and a teardown stall could swallow it entirely). - tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket bind/hole-punch failed"); + tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket setup failed"); return Err(anyhow::Error::new(e)).context("bind data plane"); } }; tracing::info!( %client_udp, + udp_port, + direct, punched, - "data plane bound (punched=true → streaming to the client's observed source; \ - false → no hole-punch seen, using the reported address)" + "data plane bound (direct=true → fixed --data-port, streaming to the reported \ + address with no hole-punch; else punched=true → the client's observed source, \ + false → no punch seen, the reported address)" ); let mut session = Session::new(cfg, Box::new(transport)) .map_err(|e| anyhow!("host session: {e:?}"))?; @@ -3650,6 +3698,43 @@ mod tests { assert!(adapt_fec(u32::MAX) <= FEC_MAX); } + #[test] + fn data_socket_defaults_to_random_hole_punch() { + // No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the + // caller hole-punches. + for req in [None, Some(0)] { + let (sock, direct) = bind_data_socket(req).expect("bind random data socket"); + assert!(!direct, "req={req:?} must hole-punch, not stream direct"); + assert_ne!(sock.local_addr().unwrap().port(), 0); + } + } + + #[test] + fn data_socket_fixed_binds_direct_then_falls_back_when_busy() { + // Learn a currently-free port (bind :0, read it, drop — the same reserve-then-rebind the + // host itself uses; a race here would only make the assert below flaky, not wrong). + let free = std::net::UdpSocket::bind("0.0.0.0:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + + // A free fixed port binds exactly it, in DIRECT mode (no hole-punch). + let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket"); + assert!(direct, "a fixed --data-port must stream direct"); + assert_eq!(held.local_addr().unwrap().port(), free); + + // While it's held, a second session on the same fixed port can't bind it → it must fall + // back to a random port + hole-punch rather than fail (so concurrency never regresses). + let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back"); + assert!(!direct2, "a busy fixed port must fall back to hole-punch"); + assert_ne!( + fallback.local_addr().unwrap().port(), + free, + "the fallback must not reuse the busy fixed port" + ); + } + #[test] fn compositor_resolution_precedence() { use crate::vdisplay::Compositor::*; @@ -3847,6 +3932,7 @@ mod tests { allow_pairing: false, pairing_pin: None, paired_store: None, + data_port: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); @@ -4041,6 +4127,7 @@ mod tests { allow_pairing: false, pairing_pin: None, paired_store: None, // unused: the shared `np` IS the store handle + data_port: None, }, 0, // no mgmt API in this test → advertise no `mgmt` mDNS port np_host, @@ -4139,6 +4226,7 @@ mod tests { allow_pairing: false, pairing_pin: Some("4321".into()), paired_store: Some(test_paired_path()), + data_port: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 5238f00..94792e4 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -10,11 +10,52 @@ description: Common problems setting up or using a punktfunk host, and how to fi - Host and client must be on the **same network/subnet**. Discovery uses mDNS, which doesn't cross routed subnets or most VPNs-without-multicast. As a fallback, add the host by **IP address** in your client. -- A firewall on the host can block it. The native protocol's control plane uses UDP port **9777**. The - per-session **data plane** uses an *ephemeral* UDP port negotiated at connect time (currently - random) — for a strict firewall, open a UDP range or move the data port. GameStream/Moonlight uses - TCP **47984/47989/48010** + UDP **47998–48010** + ENet UDP **47999**. Allow them on the host's - firewall. +- A firewall on the host can block it. The native protocol's **control plane** is a fixed UDP port, + **9777** — open this one. The per-session **data plane** rides a *separate, random* UDP port and + usually needs **no** firewall rule (see [Video is slow to start, or fails across + subnets](#video-is-slow-to-start-or-fails-across-subnets) for why, and the one case where opening it + helps). GameStream/Moonlight (only with `--gamestream`) uses TCP **47984/47989/48010** + UDP + **47998–48010** (video/FEC 47998, ENet control 47999, audio 48000) + mDNS UDP **5353**. Allow those + on the host's firewall. + +## Video is slow to start, or fails across subnets + +The native **data plane** (the raw UDP that carries video, separate from the 9777 control plane) uses +a **random, per-session UDP port** — the host binds `0.0.0.0:0`, then tells the client which port it +got during the connect handshake. There is no fixed data port. + +Video flows host → client, but the **client sends the first packet**: a small *hole-punch* datagram to +that port. This is deliberate. It lets the host learn the client's real (possibly NAT-translated) +source address and stream back to it, so a session can cross a NAT or a stateful inter-VLAN firewall +**without** a forwarded data port. What it means for a host firewall: + +- **Same LAN, no host firewall (or the port allowed):** the punch arrives immediately and video starts + at once. Nothing to configure. +- **Same LAN, host firewall that denies inbound** (ufw/nftables/firewalld default): the punch is + dropped, so the host waits **~2.5 s**, then falls back to the address the client reported and streams + anyway — a stateful firewall admits the return traffic because the host sent first. **Net effect: it + works, but each session takes ~2.5 s longer to start.** That slow start is the symptom of a + data-plane rule you're missing. +- **Across subnets / NAT:** the same punch-then-fallback applies, as long as the host's outbound video + can reach the client (the path's stateful firewall then admits the return). If the host itself is + behind NAT reached only via a forwarded control port, the data path may not establish — this is the + case a fixed, forwardable data port would solve. + +To remove the ~2.5 s fallback delay, **pin the data port** with `--data-port` (or the +`PUNKTFUNK_DATA_PORT` env in `host.env`) and open exactly that one port. The host then binds that +fixed port, skips the punch-wait, and streams straight to the client — no timeout to pay: + +```sh +punktfunk-host serve --data-port 9778 # or PUNKTFUNK_DATA_PORT=9778 in host.env +sudo ufw allow 9778/udp # open exactly that one port +``` + +Two caveats. A fixed data port serves **one session at a time**; a second concurrent session finds it +busy and transparently falls back to a random port + hole-punch (logged). And `--data-port` streams +to the client's *reported* address, so use it only where that address is reachable — a flat LAN, or a +port-forward that doesn't remap the client's source. Leave it **off** (the default) to keep the +NAT-crossing hole-punch. On a normal single-LAN setup you can also just leave the data port closed and +accept the one-time ~2.5 s punch-timeout, or not run a host firewall on a trusted LAN at all. ## `nvidia-smi` says it can't communicate with the driver diff --git a/packaging/arch/README.md b/packaging/arch/README.md index e3880b8..f21df2d 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -142,8 +142,16 @@ so it's a much lighter sysext than the host. If the host box runs a firewall, open the ports it listens on. The **native `punktfunk/1`** plane: - **QUIC control plane: UDP 9777** (`serve --native-port N` to change). -- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to - open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one). +- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and + tells the client which port it got. Video flows host → client, but the **client sends the first + packet** (a hole-punch), so the host learns the client's real source and streams back — this + traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound + firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a + stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay, + pin it with **`serve --data-port `** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed + port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one + session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the + client's reported address to be reachable (flat LAN / a non-remapping port-forward). And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with `serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these: @@ -166,7 +174,9 @@ sudo ufw allow 9777/udp # punktfunk/1 control pl sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp sudo ufw allow 47998:48010/udp sudo ufw allow 5353/udp -# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it. +# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches +# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port +# 9778` and `ufw allow 9778/udp`. ``` With raw `nftables` (add to your `inet filter input` chain): @@ -175,7 +185,8 @@ With raw `nftables` (add to your `inet filter input` chain): udp dport 9777 accept # punktfunk/1 control plane tcp dport { 47984, 47989, 48010 } accept udp dport { 47998-48010, 5353 } accept -# plus the ephemeral punktfunk/1 data port (a reserved UDP range). +# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s +# fallback). Pin it with `serve --data-port ` to open exactly one instead. ``` ## Files diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index f189df2..c704489 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -361,9 +361,14 @@ sudo firewall-cmd --reload default unit): - **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`). -- **Data plane: an *ephemeral* UDP port** — `punktfunk1-host` binds `0.0.0.0:0` and tells the client which - port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to - allow the ephemeral UDP range; the repo does not pin one. +- **Data plane: a separate UDP port** — by default *random* (`0.0.0.0:0`), so there is **no fixed + port to open**. Video flows host → client, but the client sends the first packet (a hole-punch): if + firewalld drops it, the host waits ~2.5 s and falls back to the client-reported address and streams + anyway, so you normally **leave the data port closed**. To skip that ~2.5 s fallback, pin it with + `serve --data-port ` (or `PUNKTFUNK_DATA_PORT`) and open exactly that one port with + `firewall-cmd --add-port=/udp`. A fixed port serves one session at a time (concurrent ones + fall back to random + hole-punch) and streams to the client's reported address (flat LAN / + non-remapping forward only). ```sh # Only if you run `punktfunk1-host`: diff --git a/packaging/debian/README.md b/packaging/debian/README.md index f5e48d3..a144b84 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -55,8 +55,16 @@ journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' Open the ports the host listens on. The **native `punktfunk/1`** plane: - **QUIC control plane: UDP 9777** (`serve --native-port N` to change). -- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to - open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one). +- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and + tells the client which port it got. Video flows host → client, but the **client sends the first + packet** (a hole-punch), so the host learns the client's real source and streams back — this + traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound + firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a + stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay, + pin it with **`serve --data-port `** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed + port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one + session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the + client's reported address to be reachable (flat LAN / a non-remapping port-forward). And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with `serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these: @@ -79,7 +87,9 @@ sudo ufw allow 9777/udp # punktfunk/1 control pl sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp sudo ufw allow 47998:48010/udp sudo ufw allow 5353/udp -# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it. +# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches +# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port +# 9778` and `ufw allow 9778/udp`. ``` With raw `nftables` (add to your `inet filter input` chain): @@ -88,7 +98,8 @@ With raw `nftables` (add to your `inet filter input` chain): udp dport 9777 accept # punktfunk/1 control plane tcp dport { 47984, 47989, 48010 } accept udp dport { 47998-48010, 5353 } accept -# plus the ephemeral punktfunk/1 data port (a reserved UDP range). +# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s +# fallback). Pin it with `serve --data-port ` to open exactly one instead. ``` ## Updates