feat(client): add relay session core

lanparty-client-core now owns the platform-neutral remote client relay session.
It validates relay trust input, room, virtual MAC identity, and datagram budget,
then connects to the relay, sends a client hello, and stores the assigned
welcome metadata.

The session exposes Ethernet datagram send and receive helpers that stamp
outgoing frames with the relay-assigned room and peer ids, ignore frames for
other rooms or from itself, and reject malformed Ethernet payloads before
handing them to the future TAP bridge.

The loopback Quinn test verifies the full client-side control and datagram path
without requiring Windows TAP access: pinned certificate trust, role = client
hello, announced virtual MAC, welcome parsing, and Ethernet datagrams in both
directions.

Test Plan:
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings

Refs: PLAN.md Windows client relay connection and QUIC datagrams
This commit is contained in:
2026-05-21 18:19:11 +02:00
parent 63c829183f
commit 914bd48346
4 changed files with 454 additions and 5 deletions
Generated
+10
View File
@@ -432,6 +432,16 @@ dependencies = [
[[package]]
name = "lanparty-client-core"
version = "0.1.0"
dependencies = [
"anyhow",
"bytes",
"lanparty-ctrl",
"lanparty-proto",
"quinn",
"rcgen",
"rustls",
"tokio",
]
[[package]]
name = "lanparty-client-win"
+9
View File
@@ -38,6 +38,15 @@ Shared diagnostics and structured logging vocabulary:
- tunnel counters shared by control messages and runtime diagnostics
- client connectivity/TAP diagnostics and user-facing status messages
### `lanparty-client-core`
Platform-neutral remote client relay session:
- relay QUIC connection with pinned relay certificate trust
- client hello with room, virtual MAC, and datagram budget
- welcome/reject handling with assigned peer id and effective TAP MTU
- Ethernet frame send/receive helpers over QUIC DATAGRAM
### `lanparty-relay`
Public relay binary and relay-owned room state:
+10
View File
@@ -4,3 +4,13 @@ version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
bytes.workspace = true
lanparty-ctrl = { path = "../lanparty-ctrl" }
lanparty-proto = { path = "../lanparty-proto" }
quinn.workspace = true
rustls.workspace = true
[dev-dependencies]
rcgen.workspace = true
tokio.workspace = true
+425 -5
View File
@@ -1,14 +1,434 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
//! Platform-neutral remote client relay session.
//!
//! The Windows binary will own TAP discovery, routing, and adapter I/O. This
//! crate owns the shared relay-facing state machine: connect to the relay,
//! announce the client's virtual MAC, and exchange Ethernet frames as QUIC
//! datagrams after the control-plane welcome.
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::Arc,
};
use anyhow::{Context, Result, bail};
use bytes::Bytes;
use lanparty_ctrl::{
CONTROL_LENGTH_PREFIX_LEN, ControlMessage, EndpointHello, MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN,
RoomCode, ServerWelcome, decode_control_frame, encode_control_message,
};
use lanparty_proto::{EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientSessionConfig {
relay_addr: SocketAddr,
server_name: String,
relay_ca_cert_der: Vec<u8>,
room: RoomCode,
virtual_mac: MacAddr,
max_datagram_size: u16,
}
impl ClientSessionConfig {
pub fn new(
relay_addr: SocketAddr,
server_name: impl Into<String>,
relay_ca_cert_der: Vec<u8>,
room: RoomCode,
virtual_mac: MacAddr,
max_datagram_size: u16,
) -> Result<Self> {
let server_name = server_name.into();
if server_name.trim().is_empty() {
bail!("relay server name cannot be empty");
}
if relay_ca_cert_der.is_empty() {
bail!("relay CA certificate cannot be empty");
}
EndpointHello::client(room.clone(), virtual_mac, max_datagram_size)
.context("invalid client identity or datagram budget")?;
Ok(Self {
relay_addr,
server_name,
relay_ca_cert_der,
room,
virtual_mac,
max_datagram_size,
})
}
#[must_use]
pub const fn relay_addr(&self) -> SocketAddr {
self.relay_addr
}
#[must_use]
pub fn server_name(&self) -> &str {
&self.server_name
}
#[must_use]
pub fn relay_ca_cert_der(&self) -> &[u8] {
&self.relay_ca_cert_der
}
#[must_use]
pub const fn room(&self) -> &RoomCode {
&self.room
}
#[must_use]
pub const fn virtual_mac(&self) -> MacAddr {
self.virtual_mac
}
#[must_use]
pub const fn max_datagram_size(&self) -> u16 {
self.max_datagram_size
}
}
#[derive(Debug)]
pub struct ClientSession {
endpoint: Endpoint,
connection: quinn::Connection,
config: ClientSessionConfig,
welcome: ServerWelcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceivedEthernetFrame {
source_peer_id: u32,
payload: Bytes,
}
impl ReceivedEthernetFrame {
#[must_use]
pub const fn source_peer_id(&self) -> u32 {
self.source_peer_id
}
#[must_use]
pub fn payload(&self) -> &[u8] {
&self.payload
}
}
impl ClientSession {
#[must_use]
pub const fn config(&self) -> &ClientSessionConfig {
&self.config
}
#[must_use]
pub const fn welcome(&self) -> &ServerWelcome {
&self.welcome
}
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
EthernetFrame::parse(frame).context("client Ethernet frame is malformed")?;
let datagram = encode_datagram(
FrameType::Ethernet,
self.welcome.room_id(),
self.welcome.peer_id(),
0,
frame,
)
.context("failed to encode client Ethernet datagram")?;
self.connection
.send_datagram(Bytes::from(datagram))
.context("failed to send client Ethernet datagram")?;
Ok(())
}
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
loop {
let datagram = self.connection.read_datagram().await?;
let Ok(packet) = decode_datagram(&datagram) else {
continue;
};
let header = packet.header();
if header.frame_type() != FrameType::Ethernet
|| header.room_id() != self.welcome.room_id()
|| header.peer_id() == self.welcome.peer_id()
{
continue;
}
if EthernetFrame::parse(packet.payload()).is_err() {
continue;
}
return Ok(ReceivedEthernetFrame {
source_peer_id: header.peer_id(),
payload: Bytes::copy_from_slice(packet.payload()),
});
}
}
pub async fn shutdown(self, reason: &str) {
self.connection.close(0_u32.into(), reason.as_bytes());
self.endpoint.wait_idle().await;
}
}
pub async fn connect_client(config: ClientSessionConfig) -> Result<ClientSession> {
let client_config = relay_client_config(config.relay_ca_cert_der())?;
let mut endpoint = Endpoint::client(client_bind_addr(config.relay_addr()))
.context("failed to bind client QUIC endpoint")?;
endpoint.set_default_client_config(client_config);
let connection = endpoint
.connect(config.relay_addr(), config.server_name())?
.await
.with_context(|| format!("failed to connect to relay {}", config.relay_addr()))?;
let peer_datagram_size = connection
.max_datagram_size()
.context("relay did not negotiate QUIC DATAGRAM support")?;
let hello_datagram_size = usize::from(config.max_datagram_size())
.min(peer_datagram_size)
.min(usize::from(u16::MAX)) as u16;
let hello = EndpointHello::client(
config.room().clone(),
config.virtual_mac(),
hello_datagram_size,
)
.context("failed to build client hello")?;
let response = request_control_message(&connection, ControlMessage::Hello(hello)).await?;
match response {
ControlMessage::Welcome(welcome) => Ok(ClientSession {
endpoint,
connection,
config,
welcome,
}),
ControlMessage::Reject(reject) => bail!(
"relay rejected client hello: {:?}: {}",
reject.reason(),
reject.message()
),
other => bail!("relay sent unexpected client handshake response: {other:?}"),
}
}
fn relay_client_config(relay_ca_cert_der: &[u8]) -> Result<ClientConfig> {
let mut roots = rustls::RootCertStore::empty();
roots
.add(CertificateDer::from(relay_ca_cert_der.to_vec()))
.context("failed to trust relay CA certificate")?;
let mut client_crypto = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
client_crypto.alpn_protocols = vec![RELAY_ALPN.to_vec()];
Ok(ClientConfig::new(Arc::new(
QuicClientConfig::try_from(client_crypto).context("failed to build QUIC client config")?,
)))
}
fn client_bind_addr(relay_addr: SocketAddr) -> SocketAddr {
match relay_addr.ip() {
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
}
}
async fn request_control_message(
connection: &quinn::Connection,
message: ControlMessage,
) -> Result<ControlMessage> {
let (mut send, mut recv) = connection.open_bi().await?;
let request = encode_control_message(&message)?;
send.write_all(&request).await?;
send.finish()?;
let response = recv.read_to_end(MAX_CONTROL_FRAME_LEN).await?;
Ok(decode_control_frame(&response)?)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use bytes::Bytes;
use lanparty_ctrl::Role;
use quinn::{ServerConfig, TransportConfig, crypto::rustls::QuicServerConfig};
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
fn validates_client_config() {
let room = RoomCode::new("ROOM1").unwrap();
let cert = vec![1, 2, 3];
let mac = MacAddr::new([0x02, 0, 0, 0, 0, 1]);
assert!(
ClientSessionConfig::new(
"127.0.0.1:443".parse().unwrap(),
"relay.local",
cert.clone(),
room.clone(),
mac,
1400,
)
.is_ok()
);
assert!(
ClientSessionConfig::new(
"127.0.0.1:443".parse().unwrap(),
"",
cert.clone(),
room.clone(),
mac,
1400,
)
.is_err()
);
assert!(
ClientSessionConfig::new(
"127.0.0.1:443".parse().unwrap(),
"relay.local",
Vec::new(),
room.clone(),
mac,
1400,
)
.is_err()
);
assert!(
ClientSessionConfig::new(
"127.0.0.1:443".parse().unwrap(),
"relay.local",
cert,
room,
MacAddr::BROADCAST,
1400,
)
.is_err()
);
}
#[tokio::test]
async fn connects_to_relay_control_stream_as_client() {
let (server_config, certificate) = test_server_config();
let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let server_addr = endpoint.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let incoming = endpoint.accept().await.unwrap();
let connection = incoming.await.unwrap();
let (mut send, mut recv) = connection.accept_bi().await.unwrap();
let request = recv.read_to_end(MAX_CONTROL_FRAME_LEN).await.unwrap();
let message = decode_control_frame(&request).unwrap();
let ControlMessage::Hello(hello) = message else {
panic!("expected client hello");
};
assert_eq!(hello.role(), Role::Client);
assert_eq!(hello.room().as_str(), "ROOM1");
assert_eq!(
hello.announced_mac(),
Some(MacAddr::new([0x02, 0, 0, 0, 0, 1]))
);
let response = encode_control_message(&ControlMessage::Welcome(
ServerWelcome::new(7, 2, 1200).unwrap(),
))
.unwrap();
send.write_all(&response).await.unwrap();
send.finish().unwrap();
let datagram = connection.read_datagram().await.unwrap();
let packet = decode_datagram(&datagram).unwrap();
let header = packet.header();
assert_eq!(header.frame_type(), FrameType::Ethernet);
assert_eq!(header.room_id(), 7);
assert_eq!(header.peer_id(), 2);
assert_eq!(packet.payload(), ethernet_frame(b"to relay").as_slice());
let response =
encode_datagram(FrameType::Ethernet, 7, 1, 0, &ethernet_frame(b"from relay"))
.unwrap();
connection.send_datagram(Bytes::from(response)).unwrap();
connection.closed().await;
endpoint.close(0_u32.into(), b"test complete");
endpoint.wait_idle().await;
});
let config = ClientSessionConfig::new(
server_addr,
"lanparty-relay.local",
certificate.as_ref().to_vec(),
RoomCode::new("ROOM1").unwrap(),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
1400,
)
.unwrap();
let client = connect_client(config).await.unwrap();
assert_eq!(
client.config().virtual_mac(),
MacAddr::new([0x02, 0, 0, 0, 0, 1])
);
assert_eq!(client.welcome().room_id(), 7);
assert_eq!(client.welcome().peer_id(), 2);
client.send_ethernet(&ethernet_frame(b"to relay")).unwrap();
let received = tokio::time::timeout(Duration::from_secs(5), client.recv_ethernet())
.await
.unwrap()
.unwrap();
assert_eq!(received.source_peer_id(), 1);
assert_eq!(received.payload(), ethernet_frame(b"from relay").as_slice());
client.shutdown("test complete").await;
tokio::time::timeout(Duration::from_secs(5), server_task)
.await
.unwrap()
.unwrap();
}
fn test_server_config() -> (ServerConfig, CertificateDer<'static>) {
let certified_key =
rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap();
let certificate = certified_key.cert.der().clone();
let cert_chain = vec![certificate.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)
.unwrap();
tls_config.alpn_protocols = vec![RELAY_ALPN.to_vec()];
let mut server_config =
ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(tls_config).unwrap()));
let mut transport = TransportConfig::default();
transport.datagram_receive_buffer_size(Some(4 * 1024 * 1024));
transport.datagram_send_buffer_size(4 * 1024 * 1024);
server_config.transport_config(Arc::new(transport));
(server_config, certificate)
}
fn ethernet_frame(payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::new();
frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]);
frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
frame.extend_from_slice(&0x0800_u16.to_be_bytes());
frame.extend_from_slice(payload);
frame
}
}