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
This commit is contained in:
2026-05-21 17:32:47 +02:00
parent b3d1a9c046
commit be9596c188
7 changed files with 380 additions and 2 deletions
Generated
+149
View File
@@ -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"
+1
View File
@@ -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"
+9
View File
@@ -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.
+2
View File
@@ -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" }
+199
View File
@@ -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, ConfigError> {
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<Self, ConfigError> {
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<Self, Self::Err> {
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::<SocketAddr>() {
return Ok(Self(addr));
}
}
if let Ok(addr) = value.parse::<SocketAddr>() {
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::<ListenEndpoint>().unwrap_err(),
ConfigError::InvalidListen { .. }
));
}
#[test]
fn rejects_bare_port_without_udp_suffix() {
assert!(matches!(
"443".parse::<ListenEndpoint>().unwrap_err(),
ConfigError::InvalidListen { .. }
));
}
#[test]
fn rejects_zero_max_clients() {
assert_eq!(
RelayConfig::new(ListenEndpoint::default(), 0).unwrap_err(),
ConfigError::ZeroMaxClientsPerRoom
);
}
}
+4
View File
@@ -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)]
+16 -2
View File
@@ -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(())
}