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
This commit is contained in:
2026-05-21 18:24:47 +02:00
parent 93f0a17f79
commit 34ba2f2375
3 changed files with 95 additions and 10 deletions
+39 -1
View File
@@ -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<PathBuf>,
}
impl RelayArgs {
pub fn into_config(self) -> Result<RelayConfig, ConfigError> {
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<PathBuf>,
}
impl RelayConfig {
pub fn new(listen: ListenEndpoint, max_clients_per_room: usize) -> Result<Self, ConfigError> {
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<PathBuf>,
) -> Result<Self, ConfigError> {
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"))
);
}
}
+52 -6
View File
@@ -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<Self> {
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<ServerConfig> {
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()
))
}
}