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 677a4f4cf5
commit 62e0367f4b
7 changed files with 238 additions and 37 deletions
+18 -2
View File
@@ -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<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)?;
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];