diff --git a/Cargo.lock b/Cargo.lock index 28daab1..6a5dd2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,6 +451,15 @@ dependencies = [ [[package]] name = "lanparty-gateway" version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "lanparty-ctrl", + "quinn", + "rcgen", + "rustls", + "tokio", +] [[package]] name = "lanparty-obs" diff --git a/README.md b/README.md index 46b4afa..c2307bc 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,18 @@ 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. + +## Gateway + +```bash +cargo run -p lanparty-gateway -- \ + --relay 203.0.113.10:443 \ + --server-name lanparty-relay.local \ + --relay-ca-cert relay-cert.der \ + --room ROOM1 \ + --interface eth0 +``` + +The gateway currently connects to the relay as `role = gateway`, completes the +control-stream hello/welcome handshake, and then waits for shutdown. AF_PACKET +capture and injection are not wired yet. diff --git a/crates/lanparty-ctrl/src/lib.rs b/crates/lanparty-ctrl/src/lib.rs index 98dc360..18dd7ad 100644 --- a/crates/lanparty-ctrl/src/lib.rs +++ b/crates/lanparty-ctrl/src/lib.rs @@ -16,6 +16,7 @@ pub use lanparty_obs::TunnelStats; use lanparty_proto::{MIN_USEFUL_TAP_MTU, MacAddr, MtuError, recommended_tap_mtu}; use thiserror::Error; +pub const RELAY_ALPN: &[u8] = b"lanparty-l2/1"; pub const CONTROL_PROTOCOL_VERSION: u16 = 1; pub const MIN_ROOM_CODE_LEN: usize = 1; pub const MAX_ROOM_CODE_LEN: usize = 64; diff --git a/crates/lanparty-gateway/Cargo.toml b/crates/lanparty-gateway/Cargo.toml index c96d654..520a166 100644 --- a/crates/lanparty-gateway/Cargo.toml +++ b/crates/lanparty-gateway/Cargo.toml @@ -4,3 +4,12 @@ version.workspace = true edition.workspace = true [dependencies] +anyhow.workspace = true +clap.workspace = true +lanparty-ctrl = { path = "../lanparty-ctrl" } +quinn.workspace = true +rustls.workspace = true +tokio.workspace = true + +[dev-dependencies] +rcgen.workspace = true diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs new file mode 100644 index 0000000..1948f0e --- /dev/null +++ b/crates/lanparty-gateway/src/lib.rs @@ -0,0 +1,387 @@ +//! Linux LAN gateway control-plane connection. +//! +//! This crate owns the gateway binary's relay connection and, in later slices, +//! will add the AF_PACKET capture/injection loop that feeds Ethernet frames into +//! this established QUIC session. + +use std::{ + fs, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + path::PathBuf, + sync::Arc, +}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use lanparty_ctrl::{ + CONTROL_LENGTH_PREFIX_LEN, ControlMessage, EndpointHello, MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN, + RoomCode, ServerWelcome, decode_control_frame, encode_control_message, +}; +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, Parser)] +#[command( + name = "lanparty-gateway", + about = "Linux LAN gateway for the LAN party L2 tunnel" +)] +pub struct GatewayArgs { + /// Relay UDP socket address, for example 203.0.113.10:443. + #[arg(long)] + relay: SocketAddr, + + /// TLS server name expected in the relay certificate. + #[arg(long, default_value = "lanparty-relay.local")] + server_name: String, + + /// DER-encoded relay CA/certificate to trust. + #[arg(long, value_name = "PATH")] + relay_ca_cert: PathBuf, + + /// Room code to join as the LAN gateway. + #[arg(long)] + room: RoomCode, + + /// Wired LAN interface that will later be opened with AF_PACKET. + #[arg(long)] + interface: String, + + /// Gateway's advertised QUIC datagram budget before relay clamping. + #[arg(long, default_value_t = 1400)] + max_datagram_size: u16, +} + +impl GatewayArgs { + pub fn into_config(self) -> Result { + let relay_ca_cert = fs::read(&self.relay_ca_cert).with_context(|| { + format!( + "failed to read relay CA certificate {}", + self.relay_ca_cert.display() + ) + })?; + + GatewayConfig::new( + self.relay, + self.server_name, + relay_ca_cert, + self.room, + self.interface, + self.max_datagram_size, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayConfig { + relay_addr: SocketAddr, + server_name: String, + relay_ca_cert_der: Vec, + room: RoomCode, + interface: String, + max_datagram_size: u16, +} + +impl GatewayConfig { + pub fn new( + relay_addr: SocketAddr, + server_name: impl Into, + relay_ca_cert_der: Vec, + room: RoomCode, + interface: impl Into, + max_datagram_size: u16, + ) -> Result { + 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"); + } + + let interface = interface.into(); + if interface.trim().is_empty() { + bail!("gateway interface cannot be empty"); + } + + EndpointHello::gateway(room.clone(), max_datagram_size) + .context("invalid gateway datagram budget")?; + + Ok(Self { + relay_addr, + server_name, + relay_ca_cert_der, + room, + interface, + 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 fn interface(&self) -> &str { + &self.interface + } + + #[must_use] + pub const fn max_datagram_size(&self) -> u16 { + self.max_datagram_size + } +} + +#[derive(Debug)] +pub struct GatewayConnection { + endpoint: Endpoint, + connection: quinn::Connection, + config: GatewayConfig, + welcome: ServerWelcome, +} + +impl GatewayConnection { + #[must_use] + pub const fn config(&self) -> &GatewayConfig { + &self.config + } + + #[must_use] + pub const fn welcome(&self) -> &ServerWelcome { + &self.welcome + } + + 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_gateway(config: GatewayConfig) -> Result { + 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 gateway 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::gateway(config.room().clone(), hello_datagram_size) + .context("failed to build gateway hello")?; + let response = request_control_message(&connection, ControlMessage::Hello(hello)).await?; + + match response { + ControlMessage::Welcome(welcome) => Ok(GatewayConnection { + endpoint, + connection, + config, + welcome, + }), + ControlMessage::Reject(reject) => bail!( + "relay rejected gateway hello: {:?}: {}", + reject.reason(), + reject.message() + ), + other => bail!("relay sent unexpected gateway handshake response: {other:?}"), + } +} + +fn relay_client_config(relay_ca_cert_der: &[u8]) -> Result { + 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 { + 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 lanparty_ctrl::Role; + use quinn::{ServerConfig, TransportConfig, crypto::rustls::QuicServerConfig}; + use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; + + use super::*; + + #[test] + fn validates_gateway_config() { + let room = RoomCode::new("ROOM1").unwrap(); + let cert = vec![1, 2, 3]; + + assert!( + GatewayConfig::new( + "127.0.0.1:443".parse().unwrap(), + "relay.local", + cert.clone(), + room.clone(), + "eth0", + 1400, + ) + .is_ok() + ); + assert!( + GatewayConfig::new( + "127.0.0.1:443".parse().unwrap(), + "", + cert.clone(), + room.clone(), + "eth0", + 1400, + ) + .is_err() + ); + assert!( + GatewayConfig::new( + "127.0.0.1:443".parse().unwrap(), + "relay.local", + Vec::new(), + room.clone(), + "eth0", + 1400, + ) + .is_err() + ); + assert!( + GatewayConfig::new( + "127.0.0.1:443".parse().unwrap(), + "relay.local", + cert, + room, + "", + 1400, + ) + .is_err() + ); + } + + #[tokio::test] + async fn connects_to_relay_control_stream_as_gateway() { + 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 gateway hello"); + }; + + assert_eq!(hello.role(), Role::Gateway); + assert_eq!(hello.room().as_str(), "ROOM1"); + + let response = encode_control_message(&ControlMessage::Welcome( + ServerWelcome::new(7, 1, 1200).unwrap(), + )) + .unwrap(); + send.write_all(&response).await.unwrap(); + send.finish().unwrap(); + + connection.closed().await; + endpoint.close(0_u32.into(), b"test complete"); + endpoint.wait_idle().await; + }); + let config = GatewayConfig::new( + server_addr, + "lanparty-relay.local", + certificate.as_ref().to_vec(), + RoomCode::new("ROOM1").unwrap(), + "eth0", + 1400, + ) + .unwrap(); + + let gateway = connect_gateway(config).await.unwrap(); + + assert_eq!(gateway.config().interface(), "eth0"); + assert_eq!(gateway.welcome().room_id(), 7); + assert_eq!(gateway.welcome().peer_id(), 1); + + gateway.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) + } +} diff --git a/crates/lanparty-gateway/src/main.rs b/crates/lanparty-gateway/src/main.rs index e7a11a9..4a1932d 100644 --- a/crates/lanparty-gateway/src/main.rs +++ b/crates/lanparty-gateway/src/main.rs @@ -1,3 +1,27 @@ -fn main() { - println!("Hello, world!"); +use clap::Parser; +use lanparty_gateway::{GatewayArgs, connect_gateway}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config = GatewayArgs::parse().into_config()?; + println!( + "lanparty-gateway connecting interface {} to relay {} room {}", + config.interface(), + config.relay_addr(), + config.room() + ); + + let gateway = connect_gateway(config).await?; + println!( + "lanparty-gateway connected as peer {} in room id {} with TAP MTU {}", + gateway.welcome().peer_id(), + gateway.welcome().room_id(), + gateway.welcome().effective_tap_mtu() + ); + println!("AF_PACKET bridging is not wired yet; press Ctrl-C to stop"); + + tokio::signal::ctrl_c().await?; + gateway.shutdown("gateway shutting down").await; + + Ok(()) } diff --git a/crates/lanparty-relay/src/server.rs b/crates/lanparty-relay/src/server.rs index 254311a..0e24560 100644 --- a/crates/lanparty-relay/src/server.rs +++ b/crates/lanparty-relay/src/server.rs @@ -4,8 +4,8 @@ use anyhow::{Context, Result, anyhow}; use bytes::Bytes; use lanparty_ctrl::{ CONTROL_LENGTH_PREFIX_LEN, ControlCodecError, ControlMessage, EndpointHello, - MAX_CONTROL_MESSAGE_LEN, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome, - decode_control_frame, encode_control_message, + MAX_CONTROL_MESSAGE_LEN, PeerInfo, RELAY_ALPN, Reject, RejectReason, Role, RoomCode, + ServerWelcome, decode_control_frame, encode_control_message, }; use lanparty_proto::{FrameType, decode_datagram, encode_datagram}; use quinn::crypto::rustls::QuicServerConfig; @@ -16,7 +16,6 @@ use tokio::sync::Mutex; use crate::{RelayConfig, RoomRegistry}; -const RELAY_ALPN: &[u8] = b"lanparty-l2/1"; const DATAGRAM_BUFFER_BYTES: usize = 4 * 1024 * 1024; const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;