From 34ba2f2375135eed4e225d887ff34db1343246d0 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 18:24:47 +0200 Subject: [PATCH] feat(relay): write development certificate The gateway and Windows client now pin a relay certificate, but local relay runs generated an ephemeral self-signed certificate only in memory. That made the development trust flow awkward because there was no stable DER artifact to feed into the new CLIs. Add `--dev-cert-der-out` to write the generated development certificate before the relay binds its endpoint. The file is DER-encoded and parent directories are created when needed. This keeps the production certificate/key path explicit future work while making the current pinned-trust flow usable. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md relay/client trust bootstrap --- README.md | 7 ++-- crates/lanparty-relay/src/config.rs | 40 +++++++++++++++++++- crates/lanparty-relay/src/server.rs | 58 ++++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0b79169..d00b83d 100644 --- a/README.md +++ b/README.md @@ -68,15 +68,16 @@ cargo check --workspace ## Relay ```bash -cargo run -p lanparty-relay -- --listen 443/udp +cargo run -p lanparty-relay -- --listen 443/udp --dev-cert-der-out relay-cert.der ``` `--listen` accepts either a socket address or a UDP port shorthand such as `443/udp`. The relay binds a QUIC endpoint, accepts a control-stream `hello`, replies with `welcome` or `reject`, and forwards live Ethernet QUIC datagrams between accepted peers in the same room. It currently uses a generated -self-signed development certificate; production certificate and client trust -handling remain future work. +self-signed development certificate; `--dev-cert-der-out` writes that +certificate so the gateway and client can pin it in development. Production +certificate handling remains future work. ## Gateway diff --git a/crates/lanparty-relay/src/config.rs b/crates/lanparty-relay/src/config.rs index f64530e..df0ca51 100644 --- a/crates/lanparty-relay/src/config.rs +++ b/crates/lanparty-relay/src/config.rs @@ -1,6 +1,7 @@ use std::{ fmt, net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, str::FromStr, }; @@ -23,11 +24,18 @@ pub struct RelayArgs { /// 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, + /// Write the generated development relay certificate in DER format. + #[arg(long = "dev-cert-der-out", value_name = "PATH")] + dev_cert_der_out: Option, } impl RelayArgs { pub fn into_config(self) -> Result { - RelayConfig::new(self.listen, self.max_clients_per_room) + RelayConfig::with_dev_cert_der_out( + self.listen, + self.max_clients_per_room, + self.dev_cert_der_out, + ) } } @@ -35,10 +43,19 @@ impl RelayArgs { pub struct RelayConfig { listen: ListenEndpoint, max_clients_per_room: usize, + dev_cert_der_out: Option, } impl RelayConfig { pub fn new(listen: ListenEndpoint, max_clients_per_room: usize) -> Result { + Self::with_dev_cert_der_out(listen, max_clients_per_room, None) + } + + pub fn with_dev_cert_der_out( + listen: ListenEndpoint, + max_clients_per_room: usize, + dev_cert_der_out: Option, + ) -> Result { if max_clients_per_room == 0 { return Err(ConfigError::ZeroMaxClientsPerRoom); } @@ -46,6 +63,7 @@ impl RelayConfig { Ok(Self { listen, max_clients_per_room, + dev_cert_der_out, }) } @@ -58,6 +76,11 @@ impl RelayConfig { pub const fn max_clients_per_room(&self) -> usize { self.max_clients_per_room } + + #[must_use] + pub fn dev_cert_der_out(&self) -> Option<&Path> { + self.dev_cert_der_out.as_deref() + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -196,4 +219,19 @@ mod tests { ConfigError::ZeroMaxClientsPerRoom ); } + + #[test] + fn parses_development_certificate_output_path() { + let args = RelayArgs::parse_from([ + "lanparty-relay", + "--dev-cert-der-out", + "target/relay-cert.der", + ]); + let config = args.into_config().unwrap(); + + assert_eq!( + config.dev_cert_der_out(), + Some(Path::new("target/relay-cert.der")) + ); + } } diff --git a/crates/lanparty-relay/src/server.rs b/crates/lanparty-relay/src/server.rs index 0e24560..c284059 100644 --- a/crates/lanparty-relay/src/server.rs +++ b/crates/lanparty-relay/src/server.rs @@ -1,4 +1,4 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{fs, net::SocketAddr, path::Path, sync::Arc}; use anyhow::{Context, Result, anyhow}; use bytes::Bytes; @@ -59,7 +59,10 @@ struct PeerSession { impl RelayServer { pub fn bind(config: &RelayConfig) -> Result { - let server_config = development_server_config()?; + let (server_config, certificate) = development_server_config_with_certificate()?; + if let Some(path) = config.dev_cert_der_out() { + write_development_certificate(path, &certificate)?; + } let endpoint = Endpoint::server(server_config, config.listen().socket_addr()) .context("failed to bind QUIC relay endpoint")?; @@ -440,10 +443,22 @@ async fn leave_peer( Ok(()) } -fn development_server_config() -> Result { - let (server_config, _) = development_server_config_with_certificate()?; +fn write_development_certificate(path: &Path, certificate: &CertificateDer<'_>) -> Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create certificate directory {}", + parent.display() + ) + })?; + } + fs::write(path, certificate.as_ref()) + .with_context(|| format!("failed to write development certificate {}", path.display()))?; - Ok(server_config) + Ok(()) } fn development_server_config_with_certificate() -> Result<(ServerConfig, CertificateDer<'static>)> { @@ -477,7 +492,7 @@ fn development_server_config_with_certificate() -> Result<(ServerConfig, Certifi mod tests { use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use bytes::Bytes; @@ -558,6 +573,25 @@ mod tests { assert_eq!(rooms.lock().await.room_count(), 0); } + #[tokio::test] + async fn writes_development_certificate_when_configured() { + let cert_path = unique_temp_cert_path(); + let config = RelayConfig::with_dev_cert_der_out( + ListenEndpoint::new(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)), + DEFAULT_MAX_CLIENTS_PER_ROOM, + Some(cert_path.clone()), + ) + .unwrap(); + + let server = RelayServer::bind(&config).unwrap(); + let cert = std::fs::read(&cert_path).unwrap(); + + assert!(!cert.is_empty()); + + server.shutdown("test complete").await; + std::fs::remove_file(cert_path).unwrap(); + } + #[tokio::test] async fn forwards_ethernet_datagrams_between_joined_peers() { let (server, certificate) = bind_test_server(DEFAULT_MAX_CLIENTS_PER_ROOM); @@ -709,4 +743,16 @@ mod tests { frame.extend_from_slice(b"payload"); frame } + + fn unique_temp_cert_path() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + + std::env::temp_dir().join(format!( + "lanparty-relay-dev-cert-{}-{nanos}.der", + std::process::id() + )) + } }