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:
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ðernet_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(ðernet_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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user