From 839d774a65b4cdba7e0bd2227eb69525c2cf4b9f Mon Sep 17 00:00:00 2001 From: shamough1792 Date: Tue, 27 Oct 2026 12:05:02 +0800 Subject: [PATCH 1/4] fix(net): reserve both host transports before announcing multiplayer sessions --- Cargo.lock | 1 + crates/omsi-lan/src/game.rs | 4 +- crates/omsi-lan/src/mods.rs | 10 +- crates/omsi-net/Cargo.toml | 3 + crates/omsi-net/src/host_ports.rs | 182 ++++++++++++++++++++++ crates/omsi-net/src/host_ports/windows.rs | 79 ++++++++++ crates/omsi-net/src/lib.rs | 35 +---- crates/omsi-net/tests/host_ports.rs | 172 ++++++++++++++++++++ docs/DEBUG_FLAGS.md | 2 +- 9 files changed, 448 insertions(+), 40 deletions(-) create mode 100644 crates/omsi-net/src/host_ports.rs create mode 100644 crates/omsi-net/src/host_ports/windows.rs create mode 100644 crates/omsi-net/tests/host_ports.rs diff --git a/Cargo.lock b/Cargo.lock index 9b1b8d23f..18d5db698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4149,6 +4149,7 @@ dependencies = [ "sha2", "tungstenite", "ureq", + "windows 0.62.2", ] [[package]] diff --git a/crates/omsi-lan/src/game.rs b/crates/omsi-lan/src/game.rs index deb74dd7d..9a065af0e 100644 --- a/crates/omsi-lan/src/game.rs +++ b/crates/omsi-lan/src/game.rs @@ -572,8 +572,8 @@ pub fn share_mods(args: &mut LanConfig, lan: &mut LanSession) { if !args.server { crate::mods::upload::accept(crate::mods::upload::a_hosting_game_takes_uploads()); } - if let (Some(port), Some(key)) = (lan.local_addr().map(|a| a.port()), lan.host_key()) { - crate::mods::serve(port, key, &mod_source(args)); + if let (Some(listener), Some(key)) = (lan.take_mods_listener(), lan.host_key()) { + crate::mods::serve(listener, key, &mod_source(args)); } } Role::Client => { diff --git a/crates/omsi-lan/src/mods.rs b/crates/omsi-lan/src/mods.rs index 662884ab8..e4ea1d3ba 100644 --- a/crates/omsi-lan/src/mods.rs +++ b/crates/omsi-lan/src/mods.rs @@ -449,15 +449,15 @@ pub fn share_maps(on: bool) { SHARE_MAPS.store(on, std::sync::atomic::Ordering::Relaxed); } -/// Serve the session's mods on TCP `port` (a thread; the list is made in the background). -pub fn serve(port: u16, key: Arc, args: &ModSource) { +/// Serve the session's mods on its already-reserved TCP listener. +pub fn serve(listener: TcpListener, key: Arc, args: &ModSource) { if omsi_cfg::flags::OMSI_NO_LAN_MODS.is_set() { return; } - let listener = match TcpListener::bind(("0.0.0.0", port)) { - Ok(l) => l, + let port = match listener.local_addr() { + Ok(address) => address.port(), Err(e) => { - log::warn!("LAN mods: cannot serve on TCP port {port}: {e} (joining players need the host's mods installed)"); + log::warn!("LAN mods: cannot inspect the reserved TCP listener: {e}"); return; } }; diff --git a/crates/omsi-net/Cargo.toml b/crates/omsi-net/Cargo.toml index 7e49c1cbe..988348045 100644 --- a/crates/omsi-net/Cargo.toml +++ b/crates/omsi-net/Cargo.toml @@ -14,3 +14,6 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha [target.'cfg(unix)'.dependencies] libc.workspace = true + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Networking_WinSock"] } diff --git a/crates/omsi-net/src/host_ports.rs b/crates/omsi-net/src/host_ports.rs new file mode 100644 index 000000000..20c89392b --- /dev/null +++ b/crates/omsi-net/src/host_ports.rs @@ -0,0 +1,182 @@ +//! Reserve the host's state and content transports before announcing the session. + +use super::{ + LanSession, PORT_RANGE, PROTOCOL, Role, WorldInfo, bridge, hostkey, local_addrs, session_hex, +}; +use std::io::{self, Read}; +use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, UdpSocket}; +use std::time::{Duration, Instant}; + +#[cfg(windows)] +mod windows; + +pub(super) fn bind(port: u16) -> io::Result<(UdpSocket, Option)> { + if omsi_cfg::flags::OMSI_NO_LAN_MODS.is_set() { + return Ok((udp_socket(port)?, None)); + } + // Let TCP choose an ephemeral port first; Windows excludes different ranges + // for each transport. The same chosen port must still be reserved for UDP. + let listener = tcp_listener(port) + .map_err(|e| io::Error::new(e.kind(), format!("TCP port {port}: {e}")))?; + let port = listener.local_addr()?.port(); + let udp = + udp_socket(port).map_err(|e| io::Error::new(e.kind(), format!("UDP port {port}: {e}")))?; + verify_loopback(&listener)?; + Ok((udp, Some(listener))) +} + +#[cfg(not(windows))] +fn udp_socket(port: u16) -> io::Result { + UdpSocket::bind(("0.0.0.0", port)) +} + +#[cfg(windows)] +fn udp_socket(port: u16) -> io::Result { + windows::udp_socket(port) +} + +#[cfg(not(windows))] +fn tcp_listener(port: u16) -> io::Result { + TcpListener::bind(("0.0.0.0", port)) +} + +#[cfg(windows)] +fn tcp_listener(port: u16) -> io::Result { + windows::tcp_listener(port) +} + +/// A wildcard bind alone can succeed while a more specific listener receives loopback. +/// Check the gateway's actual destination, without sending a protocol to another service. +fn verify_loopback(listener: &TcpListener) -> io::Result<()> { + let port = listener.local_addr()?.port(); + let target = SocketAddr::from(([127, 0, 0, 1], port)); + let probe = TcpStream::connect_timeout(&target, Duration::from_millis(250))?; + let source = probe.local_addr()?; + listener.set_nonblocking(true)?; + let deadline = Instant::now() + Duration::from_millis(250); + loop { + match listener.accept() { + Ok((mut stream, peer)) if peer == source => { + // Close the probe first so the host port does not enter TIME_WAIT. + probe.shutdown(Shutdown::Write)?; + stream.set_read_timeout(Some(Duration::from_millis(250)))?; + stream.read(&mut [0])?; + listener.set_nonblocking(false)?; + return Ok(()); + } + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} + Err(e) => return Err(e), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!("TCP {target} does not reach this session's listener"), + )); + } + std::thread::sleep(Duration::from_millis(1)); + } +} + +// TCP and UDP have separate exclusions. Repeated bind(0) can keep choosing +// adjacent TCP ports inside one UDP exclusion, so spread retries across the +// dynamic range instead of exhausting the retry budget in the same block. +fn candidate(port: u16, offset: u16, seed: u16) -> Option { + if port != 0 { + return port.checked_add(offset); + } + if offset == 0 { + return Some(0); + } + let stride = (16384 / PORT_RANGE) | 1; + Some(49152 + seed.wrapping_add(offset.wrapping_mul(stride)) % 16384) +} + +impl LanSession { + /// Host on a port reserved for both UDP state and TCP content transfers. + /// With `try_next`, a bounded range is tried when either transport is occupied. + pub fn host( + port: u16, + name: &str, + world: WorldInfo, + try_next: bool, + ) -> std::io::Result { + let mut last_err = None; + let tries = if try_next || port == 0 { PORT_RANGE } else { 1 }; + let seed = super::random_session_id() as u16; + for offset in 0..tries { + let Some(candidate) = candidate(port, offset, seed) else { + break; + }; + match bind(candidate) { + Ok((socket, listener)) => { + let p = socket.local_addr()?.port(); + socket.set_nonblocking(true)?; + socket.set_broadcast(true)?; + let mut s = LanSession::new(socket, Role::Host, name, world); + s.host_mods = listener; + let key = std::sync::Arc::new( + hostkey::HostKey::generate() + .ok_or_else(|| std::io::Error::other("no key for the session"))?, + ); + s.session = key.session(); + s.bridge = bridge::Bridge::start( + true, + s.session, + Some(key.clone()), + local_addrs(p), + p, + ); + s.host_key = Some(key); + log::info!( + "LAN: hosting session {} on UDP/TCP port {p} as '{name}' (protocol {PROTOCOL}), code {}", + session_hex(s.session), + s.code().map(|c| c.encode()).unwrap_or_default() + ); + if port != 0 && p != port { + log::info!("LAN: UDP/TCP port {port} is unavailable; using {p}"); + } + return Ok(s); + } + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied + ) => + { + log::info!("LAN: cannot reserve host port {candidate}: {e}"); + last_err = Some(e); + } + Err(e) => return Err(e), + } + } + Err(last_err.unwrap_or_else(|| std::io::Error::other("no host port available"))) + } + + /// Transfer the reserved TCP listener without releasing its port between services. + pub fn take_mods_listener(&mut self) -> Option { + self.host_mods.take() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ephemeral_retries_spread_beyond_one_exclusion_block() { + for seed in [0, 55230, u16::MAX] { + assert_eq!(candidate(0, 0, seed), Some(0)); + let ports: Vec<_> = (1..PORT_RANGE) + .map(|offset| candidate(0, offset, seed).unwrap()) + .collect(); + assert!(ports.iter().all(|&port| port >= 49152)); + assert!(ports.windows(2).all(|p| p[0].abs_diff(p[1]) > 1000)); + let unique: std::collections::BTreeSet<_> = ports.iter().collect(); + assert_eq!(unique.len(), ports.len()); + } + assert_eq!(candidate(27015, 1, 0), Some(27016)); + assert_eq!(candidate(u16::MAX, 0, 0), Some(u16::MAX)); + assert_eq!(candidate(u16::MAX, 1, 0), None); + } +} diff --git a/crates/omsi-net/src/host_ports/windows.rs b/crates/omsi-net/src/host_ports/windows.rs new file mode 100644 index 000000000..aca389d1f --- /dev/null +++ b/crates/omsi-net/src/host_ports/windows.rs @@ -0,0 +1,79 @@ +//! Windows wildcard sockets must exclude more specific sockets on the same port. + +use std::io; +use std::net::{TcpListener, UdpSocket}; +use std::os::windows::io::{AsRawSocket, FromRawSocket, OwnedSocket}; +use std::sync::OnceLock; +use windows::Win32::Networking::WinSock::{ + AF_INET, IPPROTO_TCP, IPPROTO_UDP, SO_EXCLUSIVEADDRUSE, SOCK_DGRAM, SOCK_STREAM, SOCKADDR_IN, + SOCKET, SOCKET_ERROR, SOL_SOCKET, WSA_FLAG_NO_HANDLE_INHERIT, WSA_FLAG_OVERLAPPED, WSADATA, + WSAGetLastError, WSASocketW, WSAStartup, bind, listen, setsockopt, +}; + +fn last_error() -> io::Error { + io::Error::from_raw_os_error(unsafe { WSAGetLastError() }.0) +} + +fn socket_on(port: u16, kind: i32, protocol: i32) -> io::Result { + // Keep one Winsock initialization for the process, not one per host or retry. + static STARTUP: OnceLock = OnceLock::new(); + let code = *STARTUP.get_or_init(|| { + let mut data = WSADATA::default(); + unsafe { WSAStartup(0x0202, &mut data) } + }); + if code != 0 { + return Err(io::Error::from_raw_os_error(code)); + } + let socket = unsafe { + WSASocketW( + AF_INET.0.into(), + kind, + protocol, + None, + 0, + WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT, + ) + } + .map_err(|_| last_error())?; + // Own the socket immediately so every failed reservation closes it, and do not + // inherit it in child tools. Exclusivity must be set before binding either transport. + let owned = unsafe { OwnedSocket::from_raw_socket(socket.0 as _) }; + let exclusive = 1i32.to_ne_bytes(); + if unsafe { setsockopt(socket, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, Some(&exclusive)) } + == SOCKET_ERROR + { + return Err(last_error()); + } + let address = SOCKADDR_IN { + sin_family: AF_INET, + sin_port: port.to_be(), + ..Default::default() + }; + if unsafe { + bind( + socket, + (&address as *const SOCKADDR_IN).cast(), + std::mem::size_of_val(&address) as i32, + ) + } == SOCKET_ERROR + { + return Err(last_error()); + } + Ok(owned) +} + +pub(super) fn udp_socket(port: u16) -> io::Result { + Ok(UdpSocket::from(socket_on( + port, + SOCK_DGRAM.0, + IPPROTO_UDP.0, + )?)) +} + +pub(super) fn tcp_listener(port: u16) -> io::Result { + let socket = socket_on(port, SOCK_STREAM.0, IPPROTO_TCP.0)?; + if unsafe { listen(SOCKET(socket.as_raw_socket() as _), 128) } == SOCKET_ERROR { + return Err(last_error()); + } + Ok(TcpListener::from(socket)) +} diff --git a/crates/omsi-net/src/lib.rs b/crates/omsi-net/src/lib.rs index 44aa225e9..63140f27c 100644 --- a/crates/omsi-net/src/lib.rs +++ b/crates/omsi-net/src/lib.rs @@ -111,6 +111,7 @@ mod ws_http; pub mod tunnel; pub mod official; pub mod hostkey; +mod host_ports; pub mod remote_text; pub use remote_text::is_fingerprint; @@ -1389,6 +1390,7 @@ pub struct LanSession { /// The code as a bridge over the internet (see `bridge`): STUN, UPnP, the rendezvous. bridge: Option, host_key: Option>, + host_mods: Option, /// Players who went lately (host): (nonce, name, id, when). One who comes back soon /// keeps their number and is "back", not a newcomer. gone_lately: Vec<(Option, String, u32, Instant)>, @@ -1514,6 +1516,7 @@ impl LanSession { world_received: 0, bridge: None, host_key: None, + host_mods: None, gone_lately: Vec::new(), banned: Vec::new(), hw_bans: Vec::new(), @@ -1525,38 +1528,6 @@ impl LanSession { } } - /// Host a session on `port` (all interfaces). With `try_next` the next few ports are - /// tried when that one is taken (another session runs on this machine). - pub fn host( - port: u16, - name: &str, - world: WorldInfo, - try_next: bool, - ) -> std::io::Result { - let mut last_err = None; - let tries = if try_next { PORT_RANGE } else { 1 }; - for p in port..port.saturating_add(tries) { - match UdpSocket::bind(("0.0.0.0", p)) { - Ok(socket) => { - socket.set_nonblocking(true)?; - socket.set_broadcast(true)?; - let mut s = LanSession::new(socket, Role::Host, name, world); - let key = std::sync::Arc::new(hostkey::HostKey::generate().ok_or_else(|| std::io::Error::other("no key for the session"))?); - s.session = key.session(); - s.bridge = bridge::Bridge::start(true, s.session, Some(key.clone()), local_addrs(p), p); - s.host_key = Some(key); - log::info!("LAN: hosting session {} on port {p} as '{name}' (protocol {PROTOCOL}), code {}", session_hex(s.session), s.code().map(|c| c.encode()).unwrap_or_default()); - if p != port { - log::info!("LAN: port {port} is taken (another session on this machine?), using {p}"); - } - return Ok(s); - } - Err(e) => last_err = Some(e), - } - } - Err(last_err.unwrap_or_else(|| std::io::Error::other("no port"))) - } - /// Join the host at one of `addrs` (tried all at once); `session` is the id from a /// session code (checked by the host). pub fn join_addr( diff --git a/crates/omsi-net/tests/host_ports.rs b/crates/omsi-net/tests/host_ports.rs new file mode 100644 index 000000000..c6859b686 --- /dev/null +++ b/crates/omsi-net/tests/host_ports.rs @@ -0,0 +1,172 @@ +use omsi_net::{LanSession, WorldInfo}; +use std::net::{TcpListener, UdpSocket}; + +fn host(port: u16, try_next: bool) -> std::io::Result { + LanSession::host(port, "port-test", WorldInfo::default(), try_next) +} + +fn udp_conflict(address: &str) -> UdpSocket { + // A UDP-assigned port may be excluded for TCP on Windows. Keep that unrelated + // restriction out of tests that specifically exercise another UDP listener. + for _ in 0..32 { + let tcp = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + if let Ok(udp) = UdpSocket::bind((address, tcp.local_addr().unwrap().port())) { + return udp; + } + } + panic!("no port available for both transports"); +} + +#[test] +fn host_rejects_a_tcp_only_conflict() { + let occupied = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = occupied.local_addr().unwrap().port(); + assert!( + host(port, false).is_err(), + "TCP is occupied even though UDP is free" + ); +} + +#[test] +fn host_skips_a_tcp_only_conflict() { + let occupied = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = occupied.local_addr().unwrap().port(); + let session = host(port, true).unwrap(); + assert_ne!(session.local_addr().unwrap().port(), port); +} + +#[test] +fn host_reserves_tcp_until_the_session_ends() { + let session = host(0, false).unwrap(); + let port = session.local_addr().unwrap().port(); + assert!( + TcpListener::bind(("127.0.0.1", port)).is_err(), + "another service must not take the reserved TCP port" + ); + drop(session); + assert!( + TcpListener::bind(("127.0.0.1", port)).is_ok(), + "ending the session releases its reservation" + ); +} + +#[test] +fn host_releases_udp_when_tcp_cannot_be_reserved() { + let occupied = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = occupied.local_addr().unwrap().port(); + let session = host(port, false); + assert!(session.is_err()); + assert!(UdpSocket::bind(("0.0.0.0", port)).is_ok()); +} + +#[test] +fn host_skips_a_udp_only_conflict() { + let occupied = udp_conflict("0.0.0.0"); + let port = occupied.local_addr().unwrap().port(); + let session = host(port, true).unwrap(); + assert_ne!(session.local_addr().unwrap().port(), port); + assert_eq!( + session.code().unwrap().port, + session.local_addr().unwrap().port() + ); +} + +#[test] +fn reserved_listener_is_transferred_without_rebinding() { + let mut session = host(0, false).unwrap(); + let port = session.local_addr().unwrap().port(); + let listener = session.take_mods_listener().unwrap(); + assert!(session.take_mods_listener().is_none()); + drop(session); + assert!(TcpListener::bind(("127.0.0.1", port)).is_err()); + let probe = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + let (_, peer) = listener.accept().unwrap(); + assert_eq!(peer, probe.local_addr().unwrap()); +} + +#[test] +fn host_fails_when_the_candidate_range_is_occupied() { + for _ in 0..32 { + let first = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = first.local_addr().unwrap().port(); + if port.checked_add(omsi_net::PORT_RANGE).is_none() { + continue; + } + let mut occupied = vec![first]; + for offset in 1..omsi_net::PORT_RANGE { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", port + offset)) { + occupied.push(listener); + } else { + break; + } + } + if occupied.len() == usize::from(omsi_net::PORT_RANGE) { + assert!( + host(port, true).is_err(), + "do not announce a partially usable session" + ); + assert!(UdpSocket::bind(("0.0.0.0", port)).is_ok()); + return; + } + } + panic!("could not reserve a test range"); +} + +#[test] +fn highest_port_is_checked_without_wrapping() { + let _occupied = TcpListener::bind(("127.0.0.1", u16::MAX)).ok(); + assert!(host(u16::MAX, true).is_err()); +} + +#[test] +fn simultaneous_hosts_keep_distinct_reserved_ports() { + let first = host(0, false).unwrap(); + let first_port = first.local_addr().unwrap().port(); + let second = host(first_port, true).unwrap(); + let second_port = second.local_addr().unwrap().port(); + assert_ne!(first_port, second_port); + assert!(TcpListener::bind(("127.0.0.1", first_port)).is_err()); + assert!(TcpListener::bind(("127.0.0.1", second_port)).is_err()); +} + +#[test] +fn explicitly_disabled_transfers_keep_udp_only_hosting() { + const CHILD: &str = "PORT_RESERVATION_TEST_CHILD"; + if std::env::var_os(CHILD).is_some() { + let occupied = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = occupied.local_addr().unwrap().port(); + let mut session = host(port, false).unwrap(); + assert_eq!(session.local_addr().unwrap().port(), port); + assert!(session.take_mods_listener().is_none()); + return; + } + // Isolate the startup-only flag from the rest of this test process. + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "explicitly_disabled_transfers_keep_udp_only_hosting", + ]) + .env(CHILD, "1") + .env("OMSI_NO_LAN_MODS", "1") + .status() + .unwrap(); + assert!(status.success()); +} + +#[test] +fn host_rejects_a_udp_loopback_conflict() { + let occupied = udp_conflict("127.0.0.1"); + let port = occupied.local_addr().unwrap().port(); + assert!( + host(port, false).is_err(), + "a wildcard UDP bind must not hide another loopback owner" + ); +} + +#[test] +fn host_releases_tcp_when_udp_cannot_be_reserved() { + let occupied = udp_conflict("0.0.0.0"); + let port = occupied.local_addr().unwrap().port(); + assert!(host(port, false).is_err()); + assert!(TcpListener::bind(("127.0.0.1", port)).is_ok()); +} diff --git a/docs/DEBUG_FLAGS.md b/docs/DEBUG_FLAGS.md index 3471a3cdb..04c0342d8 100644 --- a/docs/DEBUG_FLAGS.md +++ b/docs/DEBUG_FLAGS.md @@ -307,7 +307,7 @@ effect. On Android, `openOMSI/env.txt` holds `NAME=value` lines that are set bef | `OMSI_NO_HARDWARE_ID` | bool | off | once | lan | LAN: the game sends no hardware identifier (a host can then turn the player away, and cannot ban the machine). | | `OMSI_NO_INTERP` | bool | off | once | lan | The old way without interpolation, for comparing. | | `OMSI_NO_LAMP_SIGHT` | bool | off | use | render | Enhanced by night: every lamp near the line of sight dazzles, hidden or not. | -| `OMSI_NO_LAN_MODS` | bool | off | use | lan | LAN: do not exchange mods. | +| `OMSI_NO_LAN_MODS` | bool | off | use | lan, net | LAN: do not exchange mods. | | `OMSI_NO_LAN_UPLOADS` | bool | off | use | lan | LAN: a game hosting a session takes no buses from the players who join. | | `OMSI_NO_LIGHT_MAP` | bool | off | use | scene | No night light maps on the tiles (A/B). | | `OMSI_NO_MAIN_SPLIT` | bool | off | frame | render | Do not split the main pass's bundles in two. | From 5e8edc7e0b94f2cdfbac321d9dca8a705a773629 Mon Sep 17 00:00:00 2001 From: shamough1792 Date: Tue, 27 Oct 2026 12:42:02 +0800 Subject: [PATCH 2/4] fix(launcher): sync lockfile for host port reservation --- launcher/src-tauri/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/launcher/src-tauri/Cargo.lock b/launcher/src-tauri/Cargo.lock index d092237a5..ec9e982f5 100644 --- a/launcher/src-tauri/Cargo.lock +++ b/launcher/src-tauri/Cargo.lock @@ -3034,6 +3034,7 @@ dependencies = [ "sha2 0.11.0", "tungstenite", "ureq", + "windows", ] [[package]] From c63930ec3d0b40c35ccb18f4276eb2a36a4b4970 Mon Sep 17 00:00:00 2001 From: shamough1792 Date: Tue, 27 Oct 2026 21:47:28 +0800 Subject: [PATCH 3/4] fix(net): discover and join across reserved host port range Probe every automatic host port on broadcast and subnet targets and for portless IP/name joins, while preserving explicit destinations. Retry connection-level loopback probe failures without hiding resource exhaustion, and identify both transports in fixed-port startup errors. --- crates/omsi-app/src/lib.rs | 2 +- crates/omsi-net/src/discovery.rs | 72 ++++++++++++++++++++++++++++++ crates/omsi-net/src/host_ports.rs | 43 +++++++++++++++++- crates/omsi-net/src/join_target.rs | 66 +++++++++++++++++++++++++-- crates/omsi-net/src/lib.rs | 22 ++------- crates/omsi-net/src/tests.rs | 4 +- 6 files changed, 183 insertions(+), 26 deletions(-) create mode 100644 crates/omsi-net/src/discovery.rs diff --git a/crates/omsi-app/src/lib.rs b/crates/omsi-app/src/lib.rs index abb0e5f28..971760c2a 100644 --- a/crates/omsi-app/src/lib.rs +++ b/crates/omsi-app/src/lib.rs @@ -504,7 +504,7 @@ pub(crate) fn make_app(mut args: Args, server_cfg: Option) -> // a dedicated server without its session has nobody to serve: why, and the end (it went // on running alone, unreachable, with a warning among the log's lines) if let (Some(cfg), None) = (server_cfg.as_ref(), lan.as_ref()) { - eprintln!("server: cannot open the session on UDP port {} (the line above says why): another server or program uses it, or the system refuses it. Stop the other one, or choose another `port` in {}", cfg.port, args.server.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| "server.cfg".into())); + eprintln!("server: cannot open the session on UDP/TCP port {} (the line above says why): another server or program uses it, or the system refuses it. Stop the other one, or choose another `port` in {}", cfg.port, args.server.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| "server.cfg".into())); std::process::exit(3); } // turned away at the door (banned, full, another version): no game to play there diff --git a/crates/omsi-net/src/discovery.rs b/crates/omsi-net/src/discovery.rs new file mode 100644 index 000000000..f65ba088e --- /dev/null +++ b/crates/omsi-net/src/discovery.rs @@ -0,0 +1,72 @@ +//! Discovery covers the same bounded port range that hosts reserve. + +use super::{PORT_RANGE, addrs::LocalAddr}; +use std::net::{Ipv4Addr, SocketAddr}; + +pub(super) fn targets( + port: u16, + addresses: impl IntoIterator, +) -> Vec { + // Include each interface's subnet broadcast, including VPNs and platforms + // that do not route the limited broadcast to every interface. + let mut ips = vec![Ipv4Addr::BROADCAST, Ipv4Addr::LOCALHOST]; + for address in addresses { + let broadcast = address.broadcast.unwrap_or_else(|| { + let o = address.ip.octets(); + Ipv4Addr::new(o[0], o[1], o[2], 255) + }); + if !ips.contains(&broadcast) { + ips.push(broadcast); + } + } + ips.into_iter() + .flat_map(|ip| { + (0..PORT_RANGE).filter_map(move |offset| { + port.checked_add(offset) + .map(|port| SocketAddr::from((ip, port))) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DEFAULT_PORT, addrs::AddrKind}; + + #[test] + fn every_discovery_address_covers_the_host_range() { + let address = LocalAddr { + ip: Ipv4Addr::new(192, 168, 1, 20), + interface: "test".into(), + kind: AddrKind::Lan, + broadcast: Some(Ipv4Addr::new(192, 168, 3, 255)), + }; + let fallback = LocalAddr { + broadcast: None, + ..address.clone() + }; + let targets = targets(DEFAULT_PORT, [address.clone(), address, fallback]); + assert_eq!(targets.len(), 4 * usize::from(PORT_RANGE)); + for ip in [ + Ipv4Addr::BROADCAST, + Ipv4Addr::LOCALHOST, + Ipv4Addr::new(192, 168, 3, 255), + Ipv4Addr::new(192, 168, 1, 255), + ] { + for port in DEFAULT_PORT..DEFAULT_PORT + PORT_RANGE { + assert!( + targets.contains(&SocketAddr::from((ip, port))), + "{ip}:{port}" + ); + } + } + } + + #[test] + fn discovery_includes_the_highest_port_without_wrapping() { + let targets = targets(u16::MAX, []); + assert_eq!(targets.len(), 2); + assert!(targets.iter().all(|target| target.port() == u16::MAX)); + } +} diff --git a/crates/omsi-net/src/host_ports.rs b/crates/omsi-net/src/host_ports.rs index 20c89392b..44c6023ec 100644 --- a/crates/omsi-net/src/host_ports.rs +++ b/crates/omsi-net/src/host_ports.rs @@ -21,7 +21,7 @@ pub(super) fn bind(port: u16) -> io::Result<(UdpSocket, Option)> { let port = listener.local_addr()?.port(); let udp = udp_socket(port).map_err(|e| io::Error::new(e.kind(), format!("UDP port {port}: {e}")))?; - verify_loopback(&listener)?; + verify_loopback(&listener).map_err(probe_error)?; Ok((udp, Some(listener))) } @@ -78,6 +78,25 @@ fn verify_loopback(listener: &TcpListener) -> io::Result<()> { } } +// A refused connection or failed handshake means this candidate did not reach +// our listener. Retry another port, but do not hide socket resource exhaustion. +fn probe_error(error: io::Error) -> io::Error { + match error.kind() { + io::ErrorKind::ConnectionRefused + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::NotConnected + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + | io::ErrorKind::UnexpectedEof + | io::ErrorKind::BrokenPipe => io::Error::new( + io::ErrorKind::AddrInUse, + format!("TCP loopback probe failed: {error}"), + ), + _ => error, + } +} + // TCP and UDP have separate exclusions. Repeated bind(0) can keep choosing // adjacent TCP ports inside one UDP exclusion, so spread retries across the // dynamic range instead of exhausting the retry budget in the same block. @@ -163,6 +182,28 @@ impl LanSession { mod tests { use super::*; + #[test] + fn loopback_connection_failures_allow_the_next_candidate() { + for kind in [ + io::ErrorKind::ConnectionRefused, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::NotConnected, + io::ErrorKind::TimedOut, + io::ErrorKind::WouldBlock, + io::ErrorKind::UnexpectedEof, + io::ErrorKind::BrokenPipe, + ] { + assert_eq!( + probe_error(io::Error::from(kind)).kind(), + io::ErrorKind::AddrInUse + ); + } + for kind in [io::ErrorKind::OutOfMemory, io::ErrorKind::Other] { + assert_eq!(probe_error(io::Error::from(kind)).kind(), kind); + } + } + #[test] fn ephemeral_retries_spread_beyond_one_exclusion_block() { for seed in [0, 55230, u16::MAX] { diff --git a/crates/omsi-net/src/join_target.rs b/crates/omsi-net/src/join_target.rs index 6c1360636..99316b367 100644 --- a/crates/omsi-net/src/join_target.rs +++ b/crates/omsi-net/src/join_target.rs @@ -58,7 +58,10 @@ pub fn describe_join(text: &str) -> Result { return Ok(format!("the host at {a}")); } if let Ok(ip) = t.parse::() { - return Ok(format!("the host at {ip}, port {DEFAULT_PORT}")); + return Ok(format!( + "the host at {ip}, ports {DEFAULT_PORT}-{}", + DEFAULT_PORT + PORT_RANGE - 1 + )); } let (host, port) = match t.rsplit_once(':') { Some((h, p)) => match p.parse::() { @@ -84,7 +87,14 @@ pub fn describe_join(text: &str) -> Result { .bytes() .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())) { - return Ok(format!("the computer named {host}, port {port}")); + return Ok(if t.rsplit_once(':').is_some() { + format!("the computer named {host}, port {port}") + } else { + format!( + "the computer named {host}, ports {DEFAULT_PORT}-{}", + DEFAULT_PORT + PORT_RANGE - 1 + ) + }); } Err(format!("the entry '{t}' is neither a session code (OMSI-XXXX-…), an address (192.168.1.20 or 192.168.1.20:27015), a port, nor empty (search)")) } @@ -127,7 +137,7 @@ pub fn parse_join(text: &str) -> Result { } if let Ok(ip) = t.parse::() { return Ok(JoinTarget::Direct { - addrs: vec![SocketAddr::new(ip, DEFAULT_PORT)], + addrs: default_addresses(ip), session: None, protocol: None, }); @@ -153,9 +163,57 @@ pub fn parse_join(text: &str) -> Result { } match (host, port).to_socket_addrs() { Ok(mut it) => match it.find(|a| a.is_ipv4()) { - Some(a) => Ok(JoinTarget::Direct { addrs: vec![a], session: None, protocol: None }), + Some(a) => Ok(JoinTarget::Direct { + addrs: if t.rsplit_once(':').is_some() { + vec![a] + } else { + default_addresses(a.ip()) + }, + session: None, + protocol: None, + }), None => Err(format!("the computer {host} has no IPv4 address")), }, Err(e) => Err(format!("the entry '{t}' is neither a session code (OMSI-…), an address nor a port, and no computer of that name was found ({e})")), } } + +fn default_addresses(ip: std::net::IpAddr) -> Vec { + (DEFAULT_PORT..DEFAULT_PORT + PORT_RANGE) + .map(|port| SocketAddr::new(ip, port)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn portless_targets_cover_host_range() { + for target in ["192.168.1.20", "::1", "localhost"] { + let JoinTarget::Direct { addrs, .. } = parse_join(target).unwrap() else { + panic!("expected direct target"); + }; + assert_eq!(addrs.len(), usize::from(PORT_RANGE), "{target}"); + let ports: Vec<_> = addrs.iter().map(SocketAddr::port).collect(); + assert_eq!( + ports, + (DEFAULT_PORT..DEFAULT_PORT + PORT_RANGE).collect::>() + ); + assert!(describe_join(target).unwrap().contains("ports 27015-27024")); + } + } + + #[test] + fn explicit_targets_remain_exact() { + for target in [ + "27017", "127.0.0.1:27017", "[::1]:27017", "localhost:27017", + ] { + let JoinTarget::Direct { addrs, .. } = parse_join(target).unwrap() else { + panic!("expected direct target"); + }; + assert_eq!(addrs.len(), 1, "{target}"); + assert_eq!(addrs[0].port(), 27017); + } + } +} diff --git a/crates/omsi-net/src/lib.rs b/crates/omsi-net/src/lib.rs index 63140f27c..feabd40ab 100644 --- a/crates/omsi-net/src/lib.rs +++ b/crates/omsi-net/src/lib.rs @@ -112,6 +112,7 @@ pub mod tunnel; pub mod official; pub mod hostkey; mod host_ports; +mod discovery; pub mod remote_text; pub use remote_text::is_fingerprint; @@ -1584,7 +1585,7 @@ impl LanSession { match parse_join(target)? { JoinTarget::Discover => match Self::discover(DEFAULT_PORT, name, world, wait) { Ok(Some(s)) => Ok(s), - Ok(None) => Err(format!("no host answered on the local network within {:.0} s (is a session hosted? a firewall may block UDP port {DEFAULT_PORT})", wait.as_secs_f32())), + Ok(None) => Err(format!("no host answered on the local network within {:.0} s (is a session hosted? a firewall may block UDP ports {DEFAULT_PORT}-{})", wait.as_secs_f32(), DEFAULT_PORT + PORT_RANGE - 1)), Err(e) => Err(format!("searching the network failed: {e}")), }, JoinTarget::Direct { addrs, session, protocol } => { @@ -1610,24 +1611,7 @@ impl LanSession { socket.set_read_timeout(Some(Duration::from_millis(200)))?; let started = Instant::now(); let mut buf = [0u8; 1024]; - // the limited broadcast, the loopback (a host on this machine, on any port of its - // range), and this machine's own subnet broadcast (macOS does not always route - // 255.255.255.255) - let mut targets: Vec = vec![SocketAddr::from(([255, 255, 255, 255], port))]; - for p in port..port.saturating_add(PORT_RANGE) { - targets.push(SocketAddr::from(([127, 0, 0, 1], p))); - } - // every subnet of this machine (a Hamachi or ZeroTier network carries broadcasts too) - for a in addrs::joinable_addresses() { - let b = a.broadcast.unwrap_or_else(|| { - let o = a.ip.octets(); - Ipv4Addr::new(o[0], o[1], o[2], 255) - }); - let t = SocketAddr::from((b, port)); - if !targets.contains(&t) { - targets.push(t); - } - } + let targets = discovery::targets(port, addrs::joinable_addresses()); let msg = format!("DISCOVER|{PROTOCOL}"); while started.elapsed() < wait { for t in &targets { diff --git a/crates/omsi-net/src/tests.rs b/crates/omsi-net/src/tests.rs index 19d9562dc..8d34e9cdf 100644 --- a/crates/omsi-net/src/tests.rs +++ b/crates/omsi-net/src/tests.rs @@ -444,7 +444,9 @@ fn join_targets() { assert_eq!( parse_join("192.168.1.20").unwrap(), JoinTarget::Direct { - addrs: vec!["192.168.1.20:27015".parse().unwrap()], + addrs: (DEFAULT_PORT..DEFAULT_PORT + PORT_RANGE) + .map(|port| SocketAddr::from(([192, 168, 1, 20], port))) + .collect(), session: None, protocol: None } From 8ae26b2ae6744695d7b83b390de69c4121955bc0 Mon Sep 17 00:00:00 2001 From: shamough1792 Date: Thu, 29 Oct 2026 12:18:03 +0800 Subject: [PATCH 4/4] fix(net): report single-machine join rejections immediately Compare candidate IPs rather than candidate counts before deferring a refusal. Preserve retries across different machines and exact-port behavior, restore complete-port timeout hints, and add regression coverage for rejection and diagnostic paths. --- crates/omsi-net/src/lib.rs | 35 +++- crates/omsi-net/src/tests.rs | 2 + crates/omsi-net/src/tests/join_rejections.rs | 177 +++++++++++++++++++ 3 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 crates/omsi-net/src/tests/join_rejections.rs diff --git a/crates/omsi-net/src/lib.rs b/crates/omsi-net/src/lib.rs index feabd40ab..8cd93e801 100644 --- a/crates/omsi-net/src/lib.rs +++ b/crates/omsi-net/src/lib.rs @@ -2365,10 +2365,27 @@ impl LanSession { /// Why nobody may have answered, for the player. fn no_answer(&self, after: Duration) -> String { let tried: Vec = self.candidates.iter().map(|a| a.to_string()).collect(); - let port = self.candidates.first().map(|a| a.port()).unwrap_or(DEFAULT_PORT); - if self.refused >= 2 && self.candidates.len() == 1 { + let mut ports: Vec<_> = self.candidates.iter().map(|a| a.port()).collect(); + if ports.is_empty() { + ports.push(DEFAULT_PORT); + } + ports.sort_unstable(); + ports.dedup(); + let first = ports[0]; + let last = ports[ports.len() - 1]; + let ports = if first == last { + format!("port {first}") + } else if usize::from(last - first) + 1 == ports.len() { + format!("ports {first}-{last}") + } else { + format!("ports {}", ports.iter().map(u16::to_string).collect::>().join(", ")) + }; + let one_machine = self.candidates.first().is_some_and(|first| { + self.candidates.iter().all(|candidate| candidate.ip() == first.ip()) + }); + if self.refused >= 2 && one_machine { return format!( - "the address {} answers, but no session runs on port {port} there - check the port, or whether the host has started its game", + "the address {} answers, but no session runs on {ports} there - check the port, or whether the host has started its game", self.candidates[0].ip() ); } @@ -2376,7 +2393,7 @@ impl LanSession { return format!("the host did not answer (another computer did: {r})"); } format!( - "no answer from {} within {:.0} s. Is the session still running, and was the whole code copied? The host's firewall must let the game receive UDP on port {port} (on Windows allow it for public networks too). If both of you are behind a mobile or carrier network that changes the port for every connection, the way cannot be opened: the host can forward UDP port {port} on the router, or you both use a VPN", + "no answer from {} within {:.0} s. Is the session still running, and was the whole code copied? The host's firewall must let the game receive UDP on {ports} (on Windows allow it for public networks too). If both of you are behind a mobile or carrier network that changes the port for every connection, the way cannot be opened: the host can forward UDP {ports} on the router, or you both use a VPN", tried.join(" or "), after.as_secs_f32() ) @@ -2602,10 +2619,12 @@ impl LanSession { ("CHALLENGE", Role::Client) if self.host == Some(from) || (self.host.is_none() && self.candidates.contains(&from)) => self.on_challenge(&parts), ("REJECT", Role::Client) if self.host.is_none() || Some(from) == self.host => { let reason = clean_text(field(&parts, 2), 300); - // One of several addresses tried at once may belong to another computer - // (a LAN address like ours in the friend's house): its "no" is kept for - // the message, but the others may still answer. - if self.host.is_none() && self.candidates.len() > 1 { + // Different candidate IPs may belong to different computers. Keep + // trying those, but a refusal from the one machine whose ports we + // are scanning is final, just like an explicit-port refusal. + if self.host.is_none() + && self.candidates.iter().any(|candidate| candidate.ip() != from.ip()) + { if self.other_reject.is_none() { log::info!("LAN: {from} turned us away ({reason}); still trying the host's other addresses"); } diff --git a/crates/omsi-net/src/tests.rs b/crates/omsi-net/src/tests.rs index 8d34e9cdf..529287872 100644 --- a/crates/omsi-net/src/tests.rs +++ b/crates/omsi-net/src/tests.rs @@ -1519,3 +1519,5 @@ mod connections; mod lifecycle; mod traffic; mod voice; + +mod join_rejections; diff --git a/crates/omsi-net/src/tests/join_rejections.rs b/crates/omsi-net/src/tests/join_rejections.rs new file mode 100644 index 000000000..e77af7fc4 --- /dev/null +++ b/crates/omsi-net/src/tests/join_rejections.rs @@ -0,0 +1,177 @@ +use super::*; + +fn portless_client() -> LanSession { + LanSession::join("127.0.0.1", "client", world("m"), Duration::from_secs(10)).unwrap() +} + +fn deliver_rejection(client: &mut LanSession, sender: &UdpSocket, reason: &str) { + let target = SocketAddr::from((Ipv4Addr::LOCALHOST, client.local_addr().unwrap().port())); + sender + .send_to(format!("REJECT|{PROTOCOL}|{reason}").as_bytes(), target) + .unwrap(); + let received = client.received; + let deadline = Instant::now() + Duration::from_secs(1); + while client.received == received && Instant::now() < deadline { + client.receive(&mut Vec::new()); + std::thread::sleep(Duration::from_millis(1)); + } + assert!( + client.received > received, + "the rejection datagram was received" + ); +} + +#[test] +fn ten_ports_on_one_machine_report_rejections_immediately() { + let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + for reason in [ + "session full", + "different LAN protocol", + "banned", + "sent you away", + ] { + let mut client = portless_client(); + // Use an OS-assigned sender port instead of occupying a live game's port. + client.candidates[0] = sender.local_addr().unwrap(); + assert_eq!(client.candidates.len(), usize::from(PORT_RANGE)); + assert!(client.host.is_none()); + if reason == "sent you away" { + client.welcomes = 1; + } + deliver_rejection(&mut client, &sender, reason); + assert_eq!(client.rejected.as_deref(), Some(reason)); + assert_eq!(client.turned_away.as_deref(), Some(reason)); + assert!(client.other_reject.is_none()); + assert!(!client.connected && !client.timed_out); + assert!(client.take_events().iter().any(|event| matches!( + event, LanEvent::Notice(message) if message == &format!("the host turned us away: {reason}") + ))); + } +} + +#[test] +fn different_machines_keep_trying_after_one_rejects() { + let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let from = sender.local_addr().unwrap(); + let mut client = LanSession::join_addr( + vec![from, SocketAddr::from(([192, 0, 2, 1], from.port()))], + None, + "client", + world("m"), + ) + .unwrap(); + deliver_rejection(&mut client, &sender, "session full"); + assert!(client.host.is_none()); + assert!(client.rejected.is_none() && client.turned_away.is_none()); + assert!( + client + .other_reject + .as_deref() + .is_some_and(|reason| reason.contains("session full")) + ); + assert!(!client.timed_out); +} + +#[test] +fn explicit_port_rejections_remain_final() { + let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let mut client = LanSession::join( + &sender.local_addr().unwrap().to_string(), + "client", + world("m"), + Duration::from_secs(10), + ) + .unwrap(); + deliver_rejection(&mut client, &sender, "different LAN protocol"); + assert_eq!(client.rejected.as_deref(), Some("different LAN protocol")); + assert_eq!(client.turned_away, client.rejected); + assert!(client.other_reject.is_none()); +} + +#[test] +fn an_established_host_rejection_is_final_even_with_other_machine_candidates() { + let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let from = sender.local_addr().unwrap(); + let mut client = LanSession::join_addr( + vec![from, SocketAddr::from(([192, 0, 2, 1], from.port()))], + None, + "client", + world("m"), + ) + .unwrap(); + client.host = Some(from); + client.welcomes = 1; + deliver_rejection(&mut client, &sender, "sent you away"); + assert_eq!(client.turned_away.as_deref(), Some("sent you away")); + assert_eq!(client.rejected, client.turned_away); + assert!(client.other_reject.is_none()); +} + +#[test] +fn one_machine_without_a_session_reports_the_entire_port_range() { + let mut client = portless_client(); + client.refused = 2; + let hint = client.no_answer(Duration::from_secs(10)); + assert!(hint.contains("the address 127.0.0.1 answers"), "{hint}"); + assert!( + hint.contains("no session runs on ports 27015-27024"), + "{hint}" + ); + client.refused = 0; + assert!( + client + .no_answer(Duration::from_secs(10)) + .contains("UDP on ports 27015-27024") + ); +} + +#[test] +fn explicit_port_and_multi_machine_timeout_hints_keep_their_meaning() { + let mut client = LanSession::join_addr( + vec![SocketAddr::from(([127, 0, 0, 1], 27019))], + None, + "client", + world("m"), + ) + .unwrap(); + client.refused = 2; + assert!( + client + .no_answer(Duration::from_secs(10)) + .contains("no session runs on port 27019 there") + ); + client + .candidates + .push(SocketAddr::from(([192, 0, 2, 1], 27019))); + assert!( + !client + .no_answer(Duration::from_secs(10)) + .contains("but no session runs") + ); +} + +#[test] +fn sparse_ports_are_not_described_as_an_entire_range() { + let mut client = portless_client(); + client.candidates = vec![ + SocketAddr::from(([127, 0, 0, 1], 27024)), + SocketAddr::from(([127, 0, 0, 1], 27015)), + SocketAddr::from(([127, 0, 0, 1], 27015)), + ]; + client.refused = 2; + assert!( + client + .no_answer(Duration::from_secs(10)) + .contains("no session runs on ports 27015, 27024 there") + ); +} + +#[test] +fn no_candidates_do_not_claim_that_one_machine_answered() { + let mut client = portless_client(); + client.candidates.clear(); + client.refused = 2; + let hint = client.no_answer(Duration::from_secs(10)); + assert!(!hint.contains("but no session runs")); + assert!(hint.contains("UDP on port 27015")); +}