From be9596c1884245f46fd68750183805ce2934a309 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 17:32:47 +0200 Subject: [PATCH] feat(relay): add runtime CLI config Replace the placeholder relay binary with a typed command-line configuration entry point. This gives the future QUIC server loop the listen endpoint and room limit configuration it needs without mixing command parsing into networking or room-state code. The relay now accepts --listen as either a socket address or an explicit UDP shorthand such as 443/udp, defaults to 0.0.0.0:443/udp, and validates that the per-room client limit is positive. The binary currently reports the parsed configuration and clearly states that the QUIC server loop is not wired yet, so this commit does not pretend to provide a working relay. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - cargo run -p lanparty-relay -- --listen 443/udp Refs: PLAN.md public relay --listen requirement --- Cargo.lock | 149 +++++++++++++++++++++ Cargo.toml | 1 + README.md | 9 ++ crates/lanparty-relay/Cargo.toml | 2 + crates/lanparty-relay/src/config.rs | 199 ++++++++++++++++++++++++++++ crates/lanparty-relay/src/lib.rs | 4 + crates/lanparty-relay/src/main.rs | 18 ++- 7 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 crates/lanparty-relay/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index a0286f6..b8449da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,120 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -51,6 +165,8 @@ dependencies = [ name = "lanparty-relay" version = "0.1.0" dependencies = [ + "anyhow", + "clap", "lanparty-ctrl", "lanparty-obs", "lanparty-proto", @@ -63,6 +179,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -124,6 +246,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.117" @@ -161,6 +289,27 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 7b379e8..2846941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ edition = "2024" [workspace.dependencies] anyhow = "1" bytes = "1" +clap = { version = "4.6.1", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/README.md b/README.md index 88b9489..160f9ef 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,12 @@ Public relay binary and relay-owned room state: ```bash cargo check --workspace ``` + +## Relay + +```bash +cargo run -p lanparty-relay -- --listen 443/udp +``` + +`--listen` accepts either a socket address or a UDP port shorthand such as +`443/udp`. The relay QUIC server loop is not wired yet. diff --git a/crates/lanparty-relay/Cargo.toml b/crates/lanparty-relay/Cargo.toml index 3119434..99cc7f1 100644 --- a/crates/lanparty-relay/Cargo.toml +++ b/crates/lanparty-relay/Cargo.toml @@ -4,6 +4,8 @@ version.workspace = true edition.workspace = true [dependencies] +anyhow.workspace = true +clap.workspace = true lanparty-ctrl = { path = "../lanparty-ctrl" } lanparty-obs = { path = "../lanparty-obs" } lanparty-proto = { path = "../lanparty-proto" } diff --git a/crates/lanparty-relay/src/config.rs b/crates/lanparty-relay/src/config.rs new file mode 100644 index 0000000..f64530e --- /dev/null +++ b/crates/lanparty-relay/src/config.rs @@ -0,0 +1,199 @@ +use std::{ + fmt, + net::{IpAddr, Ipv4Addr, SocketAddr}, + str::FromStr, +}; + +use clap::Parser; +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", + about = "Public QUIC relay for the LAN party L2 tunnel" +)] +pub struct RelayArgs { + /// UDP listen address. Accepts a socket address or a port shorthand like 443/udp. + #[arg(long, default_value_t = ListenEndpoint::default())] + listen: ListenEndpoint, + /// Maximum number of remote clients allowed in one room. + #[arg(long = "max-clients-per-room", default_value_t = DEFAULT_MAX_CLIENTS_PER_ROOM)] + max_clients_per_room: usize, +} + +impl RelayArgs { + pub fn into_config(self) -> Result { + RelayConfig::new(self.listen, self.max_clients_per_room) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayConfig { + listen: ListenEndpoint, + max_clients_per_room: usize, +} + +impl RelayConfig { + pub fn new(listen: ListenEndpoint, max_clients_per_room: usize) -> Result { + if max_clients_per_room == 0 { + return Err(ConfigError::ZeroMaxClientsPerRoom); + } + + Ok(Self { + listen, + max_clients_per_room, + }) + } + + #[must_use] + pub const fn listen(&self) -> ListenEndpoint { + self.listen + } + + #[must_use] + pub const fn max_clients_per_room(&self) -> usize { + self.max_clients_per_room + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ListenEndpoint(SocketAddr); + +impl ListenEndpoint { + #[must_use] + pub const fn new(addr: SocketAddr) -> Self { + Self(addr) + } + + #[must_use] + pub const fn socket_addr(self) -> SocketAddr { + self.0 + } +} + +impl Default for ListenEndpoint { + fn default() -> Self { + Self(SocketAddr::new( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + DEFAULT_RELAY_PORT, + )) + } +} + +impl fmt::Display for ListenEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/udp", self.0) + } +} + +impl FromStr for ListenEndpoint { + type Err = ConfigError; + + fn from_str(value: &str) -> Result { + if let Some(value_without_udp) = value.strip_suffix("/udp") { + if let Ok(port) = value_without_udp.parse() { + return Ok(Self(SocketAddr::new( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + port, + ))); + } + + if let Ok(addr) = value_without_udp.parse::() { + return Ok(Self(addr)); + } + } + + if let Ok(addr) = value.parse::() { + return Ok(Self(addr)); + } + + Err(ConfigError::InvalidListen { + value: value.to_owned(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ConfigError { + #[error("listen endpoint must be a socket address or a port shorthand like 443/udp: {value}")] + InvalidListen { value: String }, + #[error("max clients per room must be greater than zero")] + ZeroMaxClientsPerRoom, +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn defaults_to_udp_443_on_all_interfaces() { + let args = RelayArgs::parse_from(["lanparty-relay"]); + let config = args.into_config().unwrap(); + + assert_eq!( + config.listen().socket_addr(), + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DEFAULT_RELAY_PORT) + ); + assert_eq!(config.max_clients_per_room(), DEFAULT_MAX_CLIENTS_PER_ROOM); + } + + #[test] + fn parses_port_shorthand() { + let endpoint: ListenEndpoint = "8443/udp".parse().unwrap(); + + assert_eq!( + endpoint.socket_addr(), + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8443) + ); + } + + #[test] + fn parses_socket_addr() { + let endpoint: ListenEndpoint = "127.0.0.1:9443".parse().unwrap(); + + assert_eq!( + endpoint.socket_addr(), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9443) + ); + } + + #[test] + fn parses_socket_addr_with_udp_suffix() { + let endpoint: ListenEndpoint = "127.0.0.1:9443/udp".parse().unwrap(); + + assert_eq!( + endpoint.socket_addr(), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9443) + ); + } + + #[test] + fn rejects_non_udp_shorthand() { + assert!(matches!( + "443/tcp".parse::().unwrap_err(), + ConfigError::InvalidListen { .. } + )); + } + + #[test] + fn rejects_bare_port_without_udp_suffix() { + assert!(matches!( + "443".parse::().unwrap_err(), + ConfigError::InvalidListen { .. } + )); + } + + #[test] + fn rejects_zero_max_clients() { + assert_eq!( + RelayConfig::new(ListenEndpoint::default(), 0).unwrap_err(), + ConfigError::ZeroMaxClientsPerRoom + ); + } +} diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 972e4d0..d8bb928 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -3,6 +3,8 @@ //! QUIC accept loops will sit above this crate layer. Keeping room admission //! separate makes the important relay invariants testable without sockets. +mod config; + use std::collections::HashMap; use lanparty_ctrl::{ @@ -12,6 +14,8 @@ use lanparty_obs::{DropReason, FrameAction}; use lanparty_proto::{EthernetFrame, MacAddr, recommended_tap_mtu}; use thiserror::Error; +pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig}; + pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/lanparty-relay/src/main.rs b/crates/lanparty-relay/src/main.rs index e7a11a9..ad734d6 100644 --- a/crates/lanparty-relay/src/main.rs +++ b/crates/lanparty-relay/src/main.rs @@ -1,3 +1,17 @@ -fn main() { - println!("Hello, world!"); +use clap::Parser; +use lanparty_relay::{RelayArgs, RelayConfig}; + +fn main() -> anyhow::Result<()> { + let config = RelayArgs::parse().into_config()?; + run(config) +} + +fn run(config: RelayConfig) -> anyhow::Result<()> { + println!( + "lanparty-relay configured for {} with max {} clients per room", + config.listen(), + config.max_clients_per_room() + ); + println!("QUIC server loop is not wired yet"); + Ok(()) }