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:
2026-05-21 18:06:22 +02:00
parent 956650ea8a
commit 763a55bfba
7 changed files with 449 additions and 5 deletions
+1
View File
@@ -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;
+9
View File
@@ -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
+387
View File
@@ -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)
}
}
+26 -2
View File
@@ -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(())
}
+2 -3
View File
@@ -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;