feat(relay): bind QUIC endpoint

Make the relay binary bind a real Quinn endpoint instead of only printing its
configuration. This is the next runtime step toward the public relay while still
keeping connection handling out of this commit.

The relay now builds a self-signed development TLS configuration, advertises the
lanparty ALPN, enables QUIC datagram buffers, binds the configured UDP address,
prints the actual local address, and waits for Ctrl-C before closing the
endpoint. The generated certificate is explicitly a development placeholder;
production certificate and client trust handling remain future work.

The rustls dependency is pinned to the ring provider to match Quinn's selected
crypto backend and avoid process-level provider ambiguity at runtime.

Test Plan:
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- timeout 2s cargo run -p lanparty-relay -- --listen 127.0.0.1:0 || test $? -eq 124

Refs: PLAN.md public relay QUIC data path
Refs: https://docs.rs/quinn/0.11.9
This commit is contained in:
2026-05-21 17:38:56 +02:00
parent be9596c188
commit 77894c4706
7 changed files with 1380 additions and 13 deletions
+2
View File
@@ -4,6 +4,7 @@
//! separate makes the important relay invariants testable without sockets.
mod config;
mod server;
use std::collections::HashMap;
@@ -15,6 +16,7 @@ use lanparty_proto::{EthernetFrame, MacAddr, recommended_tap_mtu};
use thiserror::Error;
pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig};
pub use server::RelayServer;
pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16;
+9 -6
View File
@@ -1,17 +1,20 @@
use clap::Parser;
use lanparty_relay::{RelayArgs, RelayConfig};
use lanparty_relay::{RelayArgs, RelayConfig, RelayServer};
fn main() -> anyhow::Result<()> {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = RelayArgs::parse().into_config()?;
run(config)
run(config).await
}
fn run(config: RelayConfig) -> anyhow::Result<()> {
async 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(())
let server = RelayServer::bind(&config)?;
println!("lanparty-relay listening on {}", server.local_addr()?);
println!("connection handling is not wired yet; press Ctrl-C to stop");
server.wait_for_shutdown().await
}
+97
View File
@@ -0,0 +1,97 @@
use std::{net::SocketAddr, sync::Arc};
use anyhow::{Context, Result};
use quinn::{Endpoint, ServerConfig, TransportConfig, crypto::rustls::QuicServerConfig};
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
use crate::RelayConfig;
const RELAY_ALPN: &[u8] = b"lanparty-l2/1";
const DATAGRAM_BUFFER_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug)]
pub struct RelayServer {
endpoint: Endpoint,
}
impl RelayServer {
pub fn bind(config: &RelayConfig) -> Result<Self> {
let server_config = development_server_config()?;
let endpoint = Endpoint::server(server_config, config.listen().socket_addr())
.context("failed to bind QUIC relay endpoint")?;
Ok(Self { endpoint })
}
pub fn local_addr(&self) -> Result<SocketAddr> {
self.endpoint
.local_addr()
.context("failed to read relay local address")
}
pub async fn wait_for_shutdown(self) -> Result<()> {
tokio::signal::ctrl_c()
.await
.context("failed to wait for Ctrl-C")?;
self.shutdown("relay shutting down").await;
Ok(())
}
pub async fn shutdown(self, reason: &str) {
self.endpoint.close(0_u32.into(), reason.as_bytes());
self.endpoint.wait_idle().await;
}
}
fn development_server_config() -> Result<ServerConfig> {
let certified_key = rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()])
.context("failed to generate development relay certificate")?;
let cert_chain = vec![certified_key.cert.der().clone()];
let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(
certified_key.signing_key.serialize_der(),
));
let mut tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, private_key)
.context("failed to build relay TLS config")?;
tls_config.alpn_protocols = vec![RELAY_ALPN.to_vec()];
let mut server_config = ServerConfig::with_crypto(Arc::new(
QuicServerConfig::try_from(tls_config).context("failed to build QUIC TLS config")?,
));
let mut transport = TransportConfig::default();
transport.datagram_receive_buffer_size(Some(DATAGRAM_BUFFER_BYTES));
transport.datagram_send_buffer_size(DATAGRAM_BUFFER_BYTES);
server_config.transport_config(Arc::new(transport));
Ok(server_config)
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use crate::{DEFAULT_MAX_CLIENTS_PER_ROOM, ListenEndpoint};
use super::*;
#[tokio::test]
async fn binds_quic_endpoint_on_configured_address() {
let config = RelayConfig::new(
ListenEndpoint::new(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)),
DEFAULT_MAX_CLIENTS_PER_ROOM,
)
.unwrap();
let server = RelayServer::bind(&config).unwrap();
let local_addr = server.local_addr().unwrap();
assert_eq!(local_addr.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
assert_ne!(local_addr.port(), 0);
server.shutdown("test complete").await;
}
}