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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user