From bdb571799a295c912095c69cfba43d865a94bdb6 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 21:40:00 +0200 Subject: [PATCH] feat(net): accept relay hostnames PLAN.md describes the first client flow as entering a relay domain and room code, but the client and gateway CLIs only accepted socket-address literals. Add a small shared RelayEndpoint parser so bare hosts default to UDP/443 while IP literals and explicit host:port values stay supported. The runtime configs still store resolved SocketAddr values. That keeps the Windows route-pinning path on a concrete relay IP before TAP activation while avoiding duplicated endpoint grammar between client and gateway. The relay listen config reuses the same default port constant so UDP/443 has one source. README examples now use lanparty-relay.local and document the shared endpoint syntax. Test Plan: - cargo fmt --check - cargo test -p lanparty-net - cargo test -p lanparty-client-win \ accepts_relay_domain_with_default_port -- --nocapture - cargo test -p lanparty-gateway \ accepts_iface_alias_for_gateway_interface -- --nocapture - cargo test -p lanparty-net -p lanparty-client-win -p lanparty-gateway - cargo clippy -p lanparty-net -p lanparty-client-win -p lanparty-gateway \ --all-targets -- -D warnings - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md --- Cargo.lock | 10 ++ Cargo.toml | 1 + README.md | 28 +++- crates/lanparty-client-win/Cargo.toml | 1 + crates/lanparty-client-win/src/main.rs | 38 +++-- crates/lanparty-gateway/Cargo.toml | 1 + crates/lanparty-gateway/src/lib.rs | 19 ++- crates/lanparty-net/Cargo.toml | 7 + crates/lanparty-net/src/lib.rs | 217 +++++++++++++++++++++++++ crates/lanparty-relay/Cargo.toml | 1 + crates/lanparty-relay/src/config.rs | 3 +- 11 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 crates/lanparty-net/Cargo.toml create mode 100644 crates/lanparty-net/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7169e97..6035328 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,6 +474,7 @@ dependencies = [ "lanparty-client-route", "lanparty-client-tap", "lanparty-ctrl", + "lanparty-net", "lanparty-obs", "lanparty-proto", "tokio", @@ -498,6 +499,7 @@ dependencies = [ "bytes", "clap", "lanparty-ctrl", + "lanparty-net", "lanparty-obs", "lanparty-proto", "libc", @@ -507,6 +509,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "lanparty-net" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "lanparty-obs" version = "0.1.0" @@ -531,6 +540,7 @@ dependencies = [ "bytes", "clap", "lanparty-ctrl", + "lanparty-net", "lanparty-obs", "lanparty-proto", "quinn", diff --git a/Cargo.toml b/Cargo.toml index 54706aa..dc8a897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/lanparty-client-win", "crates/lanparty-ctrl", "crates/lanparty-gateway", + "crates/lanparty-net", "crates/lanparty-obs", "crates/lanparty-proto", "crates/lanparty-relay", diff --git a/README.md b/README.md index e3d2a9d..d9e2314 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Monorepo for a Layer 2 over QUIC LAN party bridge. - `lanparty-proto`: shared frame format, MAC validation, MTU helpers. - `lanparty-ctrl`: control-plane messages (join/hello/role/version). +- `lanparty-net`: shared relay endpoint parsing and resolution. - `lanparty-obs`: shared diagnostics/logging event models. - `lanparty-client-core`: platform-agnostic client session state. - `lanparty-client-route`: Windows relay-route inspection. @@ -41,6 +42,14 @@ Shared diagnostics and structured logging vocabulary: - tunnel counters shared by control messages and runtime diagnostics - client connectivity/TAP diagnostics and user-facing status messages +### `lanparty-net` + +Shared network address handling for tunnel binaries: + +- relay DNS name, IP literal, and socket-address parsing +- UDP/443 default for bare relay hosts +- relay address resolution before tunnel interface activation + ### `lanparty-client-core` Platform-neutral remote client relay session: @@ -139,7 +148,7 @@ and the LAN gateway. ```bash cargo run -p lanparty-gateway -- \ - --relay 203.0.113.10:443 \ + --relay lanparty-relay.local \ --server-name lanparty-relay.local \ --relay-ca-cert relay-cert.der \ --room ROOM1 \ @@ -149,7 +158,8 @@ cargo run -p lanparty-gateway -- \ The gateway connects to the relay as `role = gateway`, completes the control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN interface with promiscuous packet membership, and bridges Ethernet frames -between the relay and wired LAN until shutdown. It tracks remote-client source +between the relay and wired LAN until shutdown. `--relay` accepts a DNS name or +socket address; bare hosts default to UDP/443. It tracks remote-client source MACs seen from relay traffic and periodically emits small CAM refresh frames so the physical switch keeps those MACs associated with the gateway port. Gateway frame logs include direction, peer id when present, MACs, ethertype/length, @@ -163,7 +173,7 @@ control message before closing QUIC so the relay can report the intended reason. ```bash cargo run -p lanparty-client-win -- \ - --relay 203.0.113.10:443 \ + --relay lanparty-relay.local \ --server-name lanparty-relay.local \ --relay-ca-cert relay-cert.der \ --room ROOM1 @@ -171,11 +181,13 @@ cargo run -p lanparty-client-win -- \ The Windows client binary currently connects to the relay as `role = client` with a generated locally administered virtual MAC persisted in -`lanparty-client-identity.json`, completes the control-stream hello/welcome -handshake, pins a host route for the relay IP on the current pre-TAP interface, -verifies that the relay route still uses that pinned host route after TAP -activation, and then bridges Ethernet frames between the relay and the first -TAP-Windows6 adapter until shutdown. Before opening the adapter, it writes the +`lanparty-client-identity.json`, resolves the relay DNS name before TAP +activation, completes the control-stream hello/welcome handshake, pins a host +route for the resolved relay IP on the current pre-TAP interface, verifies that +the relay route still uses that pinned host route after TAP activation, and then +bridges Ethernet frames between the relay and the first TAP-Windows6 adapter +until shutdown. `--relay` accepts a DNS name or socket address; bare hosts +default to UDP/443. Before opening the adapter, it writes the generated tunnel MAC to the TAP driver's `NetworkAddress` registry setting. The startup status reports whether the relay already has a LAN gateway for the room. diff --git a/crates/lanparty-client-win/Cargo.toml b/crates/lanparty-client-win/Cargo.toml index 7b79099..bf2d620 100644 --- a/crates/lanparty-client-win/Cargo.toml +++ b/crates/lanparty-client-win/Cargo.toml @@ -8,6 +8,7 @@ anyhow.workspace = true clap.workspace = true lanparty-client-core = { path = "../lanparty-client-core" } lanparty-ctrl = { path = "../lanparty-ctrl" } +lanparty-net = { path = "../lanparty-net" } lanparty-obs = { path = "../lanparty-obs" } lanparty-proto = { path = "../lanparty-proto" } tokio.workspace = true diff --git a/crates/lanparty-client-win/src/main.rs b/crates/lanparty-client-win/src/main.rs index 3505990..f3712d2 100644 --- a/crates/lanparty-client-win/src/main.rs +++ b/crates/lanparty-client-win/src/main.rs @@ -1,9 +1,4 @@ -use std::{ - collections::BTreeMap, - fs, - net::{IpAddr, SocketAddr}, - path::PathBuf, -}; +use std::{collections::BTreeMap, fs, net::IpAddr, path::PathBuf}; #[cfg(windows)] use std::{ sync::{Arc, mpsc}, @@ -26,6 +21,7 @@ use lanparty_client_route::{ #[cfg(windows)] use lanparty_client_tap::TapAdapter; use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode}; +use lanparty_net::RelayEndpoint; use lanparty_obs::{ClientDiagnostics, RelayDiagnostics, TapDiagnostics}; use lanparty_proto::MacAddr; @@ -42,9 +38,9 @@ const CLIENT_DIAGNOSTICS_INTERVAL: Duration = Duration::from_secs(10); about = "Windows TAP client for the LAN party L2 tunnel" )] struct ClientArgs { - /// Relay UDP socket address, for example 203.0.113.10:443. - #[arg(long)] - relay: SocketAddr, + /// Relay DNS name or UDP socket address; bare hosts default to UDP/443. + #[arg(long, value_name = "HOST[:PORT]")] + relay: RelayEndpoint, /// TLS server name expected in the relay certificate. #[arg(long, default_value = "lanparty-relay.local")] @@ -89,8 +85,13 @@ impl ClientArgs { None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?, }; + let relay_addr = self + .relay + .resolve() + .with_context(|| format!("failed to resolve relay endpoint {}", self.relay))?; + ClientSessionConfig::new( - self.relay, + relay_addr, self.server_name, relay_ca_cert, self.room, @@ -778,6 +779,7 @@ fn open_tap_adapter(_session: &ClientSession) { mod tests { use super::*; use lanparty_ctrl::{DisconnectReason, PeerInfo}; + use lanparty_net::DEFAULT_RELAY_PORT; use lanparty_obs::{QuicDiagnostics, TunnelStats}; #[test] @@ -845,6 +847,22 @@ mod tests { assert_eq!(refreshed.ip().unwrap().to_string(), "10.73.42.51"); } + #[test] + fn accepts_relay_domain_with_default_port() { + let args = ClientArgs::parse_from([ + "lanparty-client-win", + "--relay", + "relay.example.test", + "--relay-ca-cert", + "relay-cert.der", + "--room", + "ROOM1", + ]); + + assert_eq!(args.relay.host(), "relay.example.test"); + assert_eq!(args.relay.port(), DEFAULT_RELAY_PORT); + } + #[test] fn formats_relay_lifecycle_events() { let gateway = ControlMessage::PeerJoined(PeerInfo::new(1, Role::Gateway, None).unwrap()); diff --git a/crates/lanparty-gateway/Cargo.toml b/crates/lanparty-gateway/Cargo.toml index 195db59..d502e94 100644 --- a/crates/lanparty-gateway/Cargo.toml +++ b/crates/lanparty-gateway/Cargo.toml @@ -8,6 +8,7 @@ anyhow.workspace = true bytes.workspace = true clap.workspace = true lanparty-ctrl = { path = "../lanparty-ctrl" } +lanparty-net = { path = "../lanparty-net" } lanparty-obs = { path = "../lanparty-obs" } lanparty-proto = { path = "../lanparty-proto" } libc.workspace = true diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 68ed13f..e3afce1 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -27,6 +27,7 @@ use lanparty_ctrl::{ MAX_CONTROL_MESSAGE_LEN, PeerInfo, RELAY_ALPN, Role, RoomCode, ServerWelcome, decode_control_frame, encode_control_message, }; +use lanparty_net::RelayEndpoint; use lanparty_obs::TunnelStats; #[cfg(target_os = "linux")] use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; @@ -62,9 +63,9 @@ const MIN_ETHERNET_FRAME_WITHOUT_FCS: usize = 60; about = "Linux LAN gateway for the LAN party L2 tunnel" )] pub struct GatewayArgs { - /// Relay UDP socket address, for example 203.0.113.10:443. - #[arg(long)] - relay: SocketAddr, + /// Relay DNS name or UDP socket address; bare hosts default to UDP/443. + #[arg(long, value_name = "HOST[:PORT]")] + relay: RelayEndpoint, /// TLS server name expected in the relay certificate. #[arg(long, default_value = "lanparty-relay.local")] @@ -96,8 +97,13 @@ impl GatewayArgs { ) })?; + let relay_addr = self + .relay + .resolve() + .with_context(|| format!("failed to resolve relay endpoint {}", self.relay))?; + GatewayConfig::new( - self.relay, + relay_addr, self.server_name, relay_ca_cert, self.room, @@ -776,6 +782,7 @@ mod tests { use std::time::Duration; use bytes::Bytes; + use lanparty_net::DEFAULT_RELAY_PORT; use quinn::{ServerConfig, TransportConfig, crypto::rustls::QuicServerConfig}; use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; @@ -837,7 +844,7 @@ mod tests { let args = GatewayArgs::parse_from([ "lanparty-gateway", "--relay", - "127.0.0.1:443", + "relay.example.test", "--relay-ca-cert", "relay-cert.der", "--room", @@ -846,6 +853,8 @@ mod tests { "eth0", ]); + assert_eq!(args.relay.host(), "relay.example.test"); + assert_eq!(args.relay.port(), DEFAULT_RELAY_PORT); assert_eq!(args.interface, "eth0"); } diff --git a/crates/lanparty-net/Cargo.toml b/crates/lanparty-net/Cargo.toml new file mode 100644 index 0000000..150e748 --- /dev/null +++ b/crates/lanparty-net/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "lanparty-net" +version.workspace = true +edition.workspace = true + +[dependencies] +thiserror.workspace = true diff --git a/crates/lanparty-net/src/lib.rs b/crates/lanparty-net/src/lib.rs new file mode 100644 index 0000000..a469b1d --- /dev/null +++ b/crates/lanparty-net/src/lib.rs @@ -0,0 +1,217 @@ +//! Shared network endpoint parsing for LAN party tunnel binaries. + +use std::{ + fmt, + net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs}, + str::FromStr, +}; + +use thiserror::Error; + +pub const DEFAULT_RELAY_PORT: u16 = 443; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RelayEndpoint { + host: String, + port: u16, +} + +impl RelayEndpoint { + pub fn new(host: impl Into, port: u16) -> Result { + let host = host.into(); + let host = host.trim(); + if host.is_empty() { + return Err(RelayEndpointError::EmptyHost); + } + if port == 0 { + return Err(RelayEndpointError::InvalidPort { + value: port.to_string(), + }); + } + + Ok(Self { + host: host.to_owned(), + port, + }) + } + + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + #[must_use] + pub const fn port(&self) -> u16 { + self.port + } + + pub fn resolve(&self) -> Result { + let mut addrs = (self.host.as_str(), self.port) + .to_socket_addrs() + .map_err(|source| RelayEndpointError::ResolveFailed { + endpoint: self.clone(), + source, + })?; + + addrs + .next() + .ok_or_else(|| RelayEndpointError::NoResolvedAddress { + endpoint: self.clone(), + }) + } +} + +impl fmt::Display for RelayEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.host.parse::().is_ok() || self.host.contains(':') { + write!(f, "[{}]:{}", self.host, self.port) + } else { + write!(f, "{}:{}", self.host, self.port) + } + } +} + +impl FromStr for RelayEndpoint { + type Err = RelayEndpointError; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(RelayEndpointError::Empty); + } + + if let Ok(socket_addr) = value.parse::() { + return Self::new(socket_addr.ip().to_string(), socket_addr.port()); + } + + if let Ok(ip_addr) = value.parse::() { + return Self::new(ip_addr.to_string(), DEFAULT_RELAY_PORT); + } + + if let Some(bracketed) = value.strip_prefix('[') + && let Some((host, rest)) = bracketed.split_once(']') + { + let port = match rest.strip_prefix(':') { + Some(port) => parse_port(port)?, + None if rest.is_empty() => DEFAULT_RELAY_PORT, + None => { + return Err(RelayEndpointError::InvalidSyntax { + value: value.to_owned(), + }); + } + }; + + return Self::new(host, port); + } + + if value.matches(':').count() == 1 { + let (host, port) = value.rsplit_once(':').expect("one colon must split"); + return Self::new(host, parse_port(port)?); + } + + if value.contains(':') { + return Err(RelayEndpointError::InvalidSyntax { + value: value.to_owned(), + }); + } + + Self::new(value, DEFAULT_RELAY_PORT) + } +} + +fn parse_port(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| RelayEndpointError::InvalidPort { + value: value.to_owned(), + }) +} + +#[derive(Debug, Error)] +pub enum RelayEndpointError { + #[error("relay endpoint cannot be empty")] + Empty, + #[error("relay endpoint host cannot be empty")] + EmptyHost, + #[error("relay endpoint must be HOST, HOST:PORT, IP, or [IPv6]:PORT: {value}")] + InvalidSyntax { value: String }, + #[error("relay endpoint port must be between 1 and 65535: {value}")] + InvalidPort { value: String }, + #[error("relay endpoint did not resolve to any addresses: {endpoint}")] + NoResolvedAddress { endpoint: RelayEndpoint }, + #[error("failed to resolve relay endpoint {endpoint}: {source}")] + ResolveFailed { + endpoint: RelayEndpoint, + source: std::io::Error, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_host_with_default_relay_port() { + let endpoint: RelayEndpoint = "relay.example.test".parse().unwrap(); + + assert_eq!(endpoint.host(), "relay.example.test"); + assert_eq!(endpoint.port(), DEFAULT_RELAY_PORT); + assert_eq!(endpoint.to_string(), "relay.example.test:443"); + } + + #[test] + fn parses_host_with_explicit_port() { + let endpoint: RelayEndpoint = "relay.example.test:8443".parse().unwrap(); + + assert_eq!(endpoint.host(), "relay.example.test"); + assert_eq!(endpoint.port(), 8443); + assert_eq!(endpoint.to_string(), "relay.example.test:8443"); + } + + #[test] + fn parses_ip_literals() { + let ipv4: RelayEndpoint = "203.0.113.10".parse().unwrap(); + let ipv4_with_port: RelayEndpoint = "203.0.113.10:8443".parse().unwrap(); + let ipv6: RelayEndpoint = "2001:db8::1".parse().unwrap(); + let ipv6_ending_with_443: RelayEndpoint = "2001:db8::1:443".parse().unwrap(); + let ipv6_with_port: RelayEndpoint = "[2001:db8::1]:8443".parse().unwrap(); + + assert_eq!(ipv4.host(), "203.0.113.10"); + assert_eq!(ipv4.port(), DEFAULT_RELAY_PORT); + assert_eq!(ipv4_with_port.host(), "203.0.113.10"); + assert_eq!(ipv4_with_port.port(), 8443); + assert_eq!(ipv6.host(), "2001:db8::1"); + assert_eq!(ipv6.port(), DEFAULT_RELAY_PORT); + assert_eq!(ipv6.to_string(), "[2001:db8::1]:443"); + assert_eq!(ipv6_ending_with_443.host(), "2001:db8::1:443"); + assert_eq!(ipv6_ending_with_443.port(), DEFAULT_RELAY_PORT); + assert_eq!(ipv6_with_port.host(), "2001:db8::1"); + assert_eq!(ipv6_with_port.port(), 8443); + } + + #[test] + fn resolves_ip_literal_without_dns() { + let endpoint: RelayEndpoint = "127.0.0.1:443".parse().unwrap(); + + assert_eq!( + endpoint.resolve().unwrap(), + "127.0.0.1:443".parse::().unwrap() + ); + } + + #[test] + fn rejects_invalid_relay_endpoints() { + assert!("".parse::().is_err()); + assert!(":443".parse::().is_err()); + assert!("relay.example.test:0".parse::().is_err()); + assert!( + "relay.example.test:not-a-port" + .parse::() + .is_err() + ); + assert!("[2001:db8::1]extra".parse::().is_err()); + assert!("relay:example:443".parse::().is_err()); + } +} diff --git a/crates/lanparty-relay/Cargo.toml b/crates/lanparty-relay/Cargo.toml index 91871e9..206ae85 100644 --- a/crates/lanparty-relay/Cargo.toml +++ b/crates/lanparty-relay/Cargo.toml @@ -8,6 +8,7 @@ anyhow.workspace = true bytes.workspace = true clap.workspace = true lanparty-ctrl = { path = "../lanparty-ctrl" } +lanparty-net = { path = "../lanparty-net" } lanparty-obs = { path = "../lanparty-obs" } lanparty-proto = { path = "../lanparty-proto" } quinn.workspace = true diff --git a/crates/lanparty-relay/src/config.rs b/crates/lanparty-relay/src/config.rs index df0ca51..db92ee5 100644 --- a/crates/lanparty-relay/src/config.rs +++ b/crates/lanparty-relay/src/config.rs @@ -6,12 +6,11 @@ use std::{ }; use clap::Parser; +pub use lanparty_net::DEFAULT_RELAY_PORT; use thiserror::Error; use crate::DEFAULT_MAX_CLIENTS_PER_ROOM; -pub const DEFAULT_RELAY_PORT: u16 = 443; - #[derive(Debug, Clone, PartialEq, Eq, Parser)] #[command( name = "lanparty-relay",