Files
softlan-vpn/crates/lanparty-relay/src/config.rs
T
ddidderr be9596c188 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
2026-05-21 17:32:47 +02:00

200 lines
5.0 KiB
Rust

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
);
}
}