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:
2026-07-05 13:53:54 +00:00
parent 0c2808bb4c
commit 5c6ae43ed2
7 changed files with 238 additions and 37 deletions
+29
View File
@@ -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<u16> = 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 <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 —
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-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
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
pair=optional. Default: pairing REQUIRED — the host rejects
unpaired clients and logs a 4-digit pairing PIN at startup;