feat(gateway): connect to relay control plane
The gateway binary now has a real relay-facing configuration and QUIC control handshake. It accepts a relay socket address, expected TLS server name, pinned DER relay certificate, room code, LAN interface name, and advertised datagram budget, then connects as role = gateway and waits for a welcome response. The ALPN token moved into lanparty-ctrl so relay and gateway share the same protocol identifier instead of carrying duplicate private constants. The gateway still stops after the control-plane connection; AF_PACKET capture and injection remain a later slice. The connector test spins up a local Quinn server with a self-signed certificate, trusts that certificate explicitly, verifies the outgoing gateway hello, and checks the received welcome metadata. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings Refs: PLAN.md Linux gateway outbound relay connection
This commit is contained in:
@@ -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<GatewayConfig> {
|
||||
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<u8>,
|
||||
room: RoomCode,
|
||||
interface: String,
|
||||
max_datagram_size: u16,
|
||||
}
|
||||
|
||||
impl GatewayConfig {
|
||||
pub fn new(
|
||||
relay_addr: SocketAddr,
|
||||
server_name: impl Into<String>,
|
||||
relay_ca_cert_der: Vec<u8>,
|
||||
room: RoomCode,
|
||||
interface: impl Into<String>,
|
||||
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");
|
||||
}
|
||||
|
||||
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<GatewayConnection> {
|
||||
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<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 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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user