feat(punktfunk1): configurable data-plane UDP port (--data-port)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -416,7 +416,14 @@ impl UdpTransport {
|
|||||||
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the
|
/// 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.
|
/// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
|
||||||
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
|
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
|
||||||
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<Self> {
|
||||||
socket.connect(peer)?;
|
socket.connect(peer)?;
|
||||||
super::qos::grow_socket_buffers(&socket);
|
super::qos::grow_socket_buffers(&socket);
|
||||||
// The native data plane is video-dominant — tag it as the video class (opt-in via
|
// 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,
|
fallback_peer: &str,
|
||||||
punch_timeout: std::time::Duration,
|
punch_timeout: std::time::Duration,
|
||||||
) -> std::io::Result<(Self, bool)> {
|
) -> 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))?;
|
socket.set_read_timeout(Some(punch_timeout))?;
|
||||||
let deadline = std::time::Instant::now() + punch_timeout;
|
let deadline = std::time::Instant::now() + punch_timeout;
|
||||||
let mut buf = [0u8; 64];
|
let mut buf = [0u8; 64];
|
||||||
|
|||||||
@@ -418,6 +418,13 @@ fn real_main() -> Result<()> {
|
|||||||
allow_pairing: true,
|
allow_pairing: true,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: 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.
|
// 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)> {
|
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
|
||||||
let mut opts = mgmt::Options::default();
|
let mut opts = mgmt::Options::default();
|
||||||
let mut native_port: u16 = 9777; // the native plane always runs now
|
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<u16> = std::env::var("PUNKTFUNK_DATA_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
let mut open = false;
|
let mut open = false;
|
||||||
let mut gamestream = false;
|
let mut gamestream = false;
|
||||||
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
|
// 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()
|
.parse()
|
||||||
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
|
.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
|
// Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
|
||||||
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
|
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
|
||||||
"--gamestream" | "--moonlight" => gamestream = true,
|
"--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
|
// 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.
|
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
|
||||||
mgmt_port: opts.bind.port(),
|
mgmt_port: opts.bind.port(),
|
||||||
|
data_port,
|
||||||
};
|
};
|
||||||
Ok((opts, native, gamestream))
|
Ok((opts, native, gamestream))
|
||||||
}
|
}
|
||||||
@@ -703,6 +724,10 @@ SERVE OPTIONS:
|
|||||||
reuse, security-review #5/#9); enable only on a TRUSTED LAN
|
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 no-op (the native punktfunk/1 plane always runs in `serve` now)
|
||||||
--native-port <PORT> native QUIC port (default 9777)
|
--native-port <PORT> native QUIC port (default 9777)
|
||||||
|
--data-port <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 —
|
--open disable mandatory native pairing (default: pairing REQUIRED —
|
||||||
an open host any LAN device can stream from is insecure)
|
an open host any LAN device can stream from is insecure)
|
||||||
|
|
||||||
@@ -714,6 +739,10 @@ PUNKTFUNK1-HOST OPTIONS:
|
|||||||
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
||||||
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
|
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
|
||||||
in the accept queue; 0 = unlimited (default: 4)
|
in the accept queue; 0 = unlimited (default: 4)
|
||||||
|
--data-port <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
|
--allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
|
||||||
pair=optional. Default: pairing REQUIRED — the host rejects
|
pair=optional. Default: pairing REQUIRED — the host rejects
|
||||||
unpaired clients and logs a 4-digit pairing PIN at startup;
|
unpaired clients and logs a 4-digit pairing PIN at startup;
|
||||||
|
|||||||
@@ -75,6 +75,35 @@ pub struct Punktfunk1Options {
|
|||||||
pub pairing_pin: Option<String>,
|
pub pairing_pin: Option<String>,
|
||||||
/// Paired-clients store path override (tests); `None` = the default config path.
|
/// Paired-clients store path override (tests); `None` = the default config path.
|
||||||
pub paired_store: Option<std::path::PathBuf>,
|
pub paired_store: Option<std::path::PathBuf>,
|
||||||
|
/// 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<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u16>) -> 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.
|
/// 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 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).
|
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
|
||||||
pub mgmt_port: u16,
|
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<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
|
/// 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,
|
allow_pairing: false,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None,
|
paired_store: None,
|
||||||
|
data_port: cfg.data_port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +689,7 @@ async fn serve_session(
|
|||||||
|
|
||||||
let source = opts.source;
|
let source = opts.source;
|
||||||
let frames = opts.frames;
|
let frames = opts.frames;
|
||||||
|
let data_port = opts.data_port;
|
||||||
let handshake = async {
|
let handshake = async {
|
||||||
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -846,10 +880,12 @@ async fn serve_session(
|
|||||||
"encode chroma"
|
"encode chroma"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
|
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||||
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
|
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||||
let udp_port = probe.local_addr()?.port();
|
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||||
drop(probe);
|
// 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];
|
let mut key = [0u8; 16];
|
||||||
rand::thread_rng().fill_bytes(&mut key);
|
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?)
|
let start = Start::decode(&io::read_msg(&mut recv).await?)
|
||||||
.map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
.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)
|
tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
.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());
|
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED
|
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||||
// source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host
|
// for the client's punch, then stream to its OBSERVED source, so video traverses a
|
||||||
// can be on different subnets; control + side planes ride the client-initiated QUIC, but
|
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
|
||||||
// the raw video UDP needs the client to open the path first). Falls back to the
|
// QUIC, but the raw video UDP needs the client to open the path first); falls back to
|
||||||
// client-reported address for clients that don't punch (flat-LAN, unchanged).
|
// the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
|
||||||
let (transport, punched) = match UdpTransport::connect_via_punch(
|
// `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
|
||||||
&format!("0.0.0.0:{udp_port}"),
|
// 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(),
|
&client_udp.to_string(),
|
||||||
std::time::Duration::from_millis(2500),
|
std::time::Duration::from_millis(2500),
|
||||||
) {
|
)
|
||||||
|
};
|
||||||
|
let (transport, punched) = match bound {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Surface the failure here directly: a data-plane bind error would otherwise be
|
// 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).
|
// 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");
|
return Err(anyhow::Error::new(e)).context("bind data plane");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
%client_udp,
|
%client_udp,
|
||||||
|
udp_port,
|
||||||
|
direct,
|
||||||
punched,
|
punched,
|
||||||
"data plane bound (punched=true → streaming to the client's observed source; \
|
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
|
||||||
false → no hole-punch seen, using the reported address)"
|
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))
|
let mut session = Session::new(cfg, Box::new(transport))
|
||||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||||
@@ -3650,6 +3698,43 @@ mod tests {
|
|||||||
assert!(adapt_fec(u32::MAX) <= FEC_MAX);
|
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]
|
#[test]
|
||||||
fn compositor_resolution_precedence() {
|
fn compositor_resolution_precedence() {
|
||||||
use crate::vdisplay::Compositor::*;
|
use crate::vdisplay::Compositor::*;
|
||||||
@@ -3847,6 +3932,7 @@ mod tests {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None,
|
paired_store: None,
|
||||||
|
data_port: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
@@ -4041,6 +4127,7 @@ mod tests {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: None,
|
pairing_pin: None,
|
||||||
paired_store: None, // unused: the shared `np` IS the store handle
|
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
|
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
|
||||||
np_host,
|
np_host,
|
||||||
@@ -4139,6 +4226,7 @@ mod tests {
|
|||||||
allow_pairing: false,
|
allow_pairing: false,
|
||||||
pairing_pin: Some("4321".into()),
|
pairing_pin: Some("4321".into()),
|
||||||
paired_store: Some(test_paired_path()),
|
paired_store: Some(test_paired_path()),
|
||||||
|
data_port: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|||||||
@@ -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
|
- 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
|
routed subnets or most VPNs-without-multicast. As a fallback, add the host by **IP address** in your
|
||||||
client.
|
client.
|
||||||
- A firewall on the host can block it. The native protocol's control plane uses UDP port **9777**. The
|
- A firewall on the host can block it. The native protocol's **control plane** is a fixed UDP port,
|
||||||
per-session **data plane** uses an *ephemeral* UDP port negotiated at connect time (currently
|
**9777** — open this one. The per-session **data plane** rides a *separate, random* UDP port and
|
||||||
random) — for a strict firewall, open a UDP range or move the data port. GameStream/Moonlight uses
|
usually needs **no** firewall rule (see [Video is slow to start, or fails across
|
||||||
TCP **47984/47989/48010** + UDP **47998–48010** + ENet UDP **47999**. Allow them on the host's
|
subnets](#video-is-slow-to-start-or-fails-across-subnets) for why, and the one case where opening it
|
||||||
firewall.
|
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
|
## `nvidia-smi` says it can't communicate with the driver
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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).
|
- **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
|
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
|
||||||
open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one).
|
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 <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
|
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:
|
`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 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
|
||||||
sudo ufw allow 47998:48010/udp
|
sudo ufw allow 47998:48010/udp
|
||||||
sudo ufw allow 5353/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):
|
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
|
udp dport 9777 accept # punktfunk/1 control plane
|
||||||
tcp dport { 47984, 47989, 48010 } accept
|
tcp dport { 47984, 47989, 48010 } accept
|
||||||
udp dport { 47998-48010, 5353 } 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 <PORT>` to open exactly one instead.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|||||||
@@ -361,9 +361,14 @@ sudo firewall-cmd --reload
|
|||||||
default unit):
|
default unit):
|
||||||
|
|
||||||
- **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`).
|
- **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
|
- **Data plane: a separate UDP port** — by default *random* (`0.0.0.0:0`), so there is **no fixed
|
||||||
port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to
|
port to open**. Video flows host → client, but the client sends the first packet (a hole-punch): if
|
||||||
allow the ephemeral UDP range; the repo does not pin one.
|
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 <PORT>` (or `PUNKTFUNK_DATA_PORT`) and open exactly that one port with
|
||||||
|
`firewall-cmd --add-port=<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
|
```sh
|
||||||
# Only if you run `punktfunk1-host`:
|
# Only if you run `punktfunk1-host`:
|
||||||
|
|||||||
@@ -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:
|
Open the ports the host listens on. The **native `punktfunk/1`** plane:
|
||||||
|
|
||||||
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
|
- **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
|
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
|
||||||
open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one).
|
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 <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
|
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:
|
`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 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
|
||||||
sudo ufw allow 47998:48010/udp
|
sudo ufw allow 47998:48010/udp
|
||||||
sudo ufw allow 5353/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):
|
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
|
udp dport 9777 accept # punktfunk/1 control plane
|
||||||
tcp dport { 47984, 47989, 48010 } accept
|
tcp dport { 47984, 47989, 48010 } accept
|
||||||
udp dport { 47998-48010, 5353 } 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 <PORT>` to open exactly one instead.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|||||||
Reference in New Issue
Block a user