From a3d24a1173b32542bd2e680e906d6f44325298d6 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 18:35:20 +0200 Subject: [PATCH] feat(client): persist virtual MAC identity Remote clients need a stable locally administered MAC address so the relay, gateway, DHCP lease, and LAN peers keep seeing the same tunnel identity across runs. Requiring users to pass `--virtual-mac` made that responsibility manual. Add a platform-neutral client identity store that loads a JSON identity file or generates a new valid virtual MAC with OS randomness and persists it. The file stores the MAC in the same string form shown by the CLI. The Windows client now uses `lanparty-client-identity.json` by default while keeping `--virtual-mac` as a manual test override. TAP binding still remains future work; this slice only owns the client identity that will be assigned to the TAP adapter. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md MAC identity --- Cargo.lock | 3 + Cargo.toml | 1 + README.md | 11 +- crates/lanparty-client-core/Cargo.toml | 3 + crates/lanparty-client-core/src/lib.rs | 165 ++++++++++++++++++++++++- crates/lanparty-client-win/src/main.rs | 23 +++- 6 files changed, 196 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8eea590..a1c454f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,11 +435,14 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes", + "getrandom 0.3.4", "lanparty-ctrl", "lanparty-proto", "quinn", "rcgen", "rustls", + "serde", + "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index bb97e4b..f1796ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ edition = "2024" anyhow = "1" bytes = "1" clap = { version = "4.6.1", features = ["derive"] } +getrandom = "0.3.4" libc = "0.2" quinn = "0.11.9" rcgen = "0.14.8" diff --git a/README.md b/README.md index b089509..135d1f7 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,12 @@ cargo run -p lanparty-client-win -- \ --relay 203.0.113.10:443 \ --server-name lanparty-relay.local \ --relay-ca-cert relay-cert.der \ - --room ROOM1 \ - --virtual-mac 02:00:00:00:00:51 + --room ROOM1 ``` The Windows client binary currently connects to the relay as `role = client` -with the configured virtual MAC, completes the control-stream hello/welcome -handshake, and then waits for shutdown. TAP adapter binding and route pinning -are not wired yet. +with a generated locally administered virtual MAC persisted in +`lanparty-client-identity.json`, completes the control-stream hello/welcome +handshake, and then waits for shutdown. `--virtual-mac` can still override the +stored identity for manual testing. TAP adapter binding and route pinning are +not wired yet. diff --git a/crates/lanparty-client-core/Cargo.toml b/crates/lanparty-client-core/Cargo.toml index 4114daf..67c2a39 100644 --- a/crates/lanparty-client-core/Cargo.toml +++ b/crates/lanparty-client-core/Cargo.toml @@ -6,10 +6,13 @@ edition.workspace = true [dependencies] anyhow.workspace = true bytes.workspace = true +getrandom.workspace = true lanparty-ctrl = { path = "../lanparty-ctrl" } lanparty-proto = { path = "../lanparty-proto" } quinn.workspace = true rustls.workspace = true +serde.workspace = true +serde_json.workspace = true [dev-dependencies] rcgen.workspace = true diff --git a/crates/lanparty-client-core/src/lib.rs b/crates/lanparty-client-core/src/lib.rs index c5901f6..496c340 100644 --- a/crates/lanparty-client-core/src/lib.rs +++ b/crates/lanparty-client-core/src/lib.rs @@ -6,7 +6,10 @@ //! datagrams after the control-plane welcome. use std::{ + fs, + io::ErrorKind, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + path::{Path, PathBuf}, sync::Arc, }; @@ -22,6 +25,113 @@ use rustls::pki_types::CertificateDer; const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientIdentity { + virtual_mac: MacAddr, +} + +impl ClientIdentity { + pub fn new(virtual_mac: MacAddr) -> Result { + if !virtual_mac.is_valid_client_identity() { + bail!("client virtual MAC must be locally administered unicast"); + } + + Ok(Self { virtual_mac }) + } + + pub fn generate() -> Result { + let mut octets = [0_u8; 6]; + getrandom::fill(&mut octets).context("failed to generate client virtual MAC")?; + octets[0] = (octets[0] | 0x02) & 0xfe; + + Self::new(MacAddr::new(octets)) + } + + #[must_use] + pub const fn virtual_mac(self) -> MacAddr { + self.virtual_mac + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientIdentityStore { + path: PathBuf, +} + +impl ClientIdentityStore { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if path.as_os_str().is_empty() { + bail!("client identity path cannot be empty"); + } + + Ok(Self { path }) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load_or_create(&self) -> Result { + match fs::read(&self.path) { + Ok(bytes) => read_identity(&bytes) + .with_context(|| format!("failed to read client identity {}", self.path.display())), + Err(error) if error.kind() == ErrorKind::NotFound => { + let identity = ClientIdentity::generate()?; + write_identity(&self.path, identity)?; + Ok(identity) + } + Err(error) => Err(error) + .with_context(|| format!("failed to read client identity {}", self.path.display())), + } + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct ClientIdentityFile { + virtual_mac: String, +} + +fn read_identity(bytes: &[u8]) -> Result { + let file: ClientIdentityFile = + serde_json::from_slice(bytes).context("failed to parse client identity JSON")?; + let virtual_mac = file + .virtual_mac + .parse() + .context("failed to parse client identity virtual MAC")?; + + ClientIdentity::new(virtual_mac) +} + +fn write_identity(path: &Path, identity: ClientIdentity) -> Result<()> { + let file_name = path + .file_name() + .context("client identity path must include a file name")?; + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create identity directory {}", parent.display()))?; + } + + let mut temp_file_name = file_name.to_os_string(); + temp_file_name.push(".tmp"); + let temp_path = path.with_file_name(temp_file_name); + let contents = serde_json::to_vec_pretty(&ClientIdentityFile { + virtual_mac: identity.virtual_mac().to_string(), + }) + .context("failed to encode client identity JSON")?; + + fs::write(&temp_path, contents) + .with_context(|| format!("failed to write client identity {}", temp_path.display()))?; + fs::rename(&temp_path, path) + .with_context(|| format!("failed to persist client identity {}", path.display()))?; + + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientSessionConfig { relay_addr: SocketAddr, @@ -258,7 +368,7 @@ async fn request_control_message( #[cfg(test)] mod tests { - use std::time::Duration; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bytes::Bytes; use lanparty_ctrl::Role; @@ -319,6 +429,47 @@ mod tests { ); } + #[test] + fn generates_valid_client_identity() { + let identity = ClientIdentity::generate().unwrap(); + + assert!(identity.virtual_mac().is_valid_client_identity()); + } + + #[test] + fn identity_store_creates_and_reuses_mac() { + let path = unique_temp_identity_path(); + let store = ClientIdentityStore::new(&path).unwrap(); + + let first = store.load_or_create().unwrap(); + let second = store.load_or_create().unwrap(); + let stored = std::fs::read_to_string(&path).unwrap(); + + assert!(path.exists()); + assert!(first.virtual_mac().is_valid_client_identity()); + assert_eq!(first, second); + assert!(stored.contains(&first.virtual_mac().to_string())); + + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn identity_store_rejects_invalid_saved_mac() { + let path = unique_temp_identity_path(); + std::fs::write( + &path, + r#"{ + "virtual_mac": "ff:ff:ff:ff:ff:ff" +}"#, + ) + .unwrap(); + let store = ClientIdentityStore::new(&path).unwrap(); + + assert!(store.load_or_create().is_err()); + + std::fs::remove_file(path).unwrap(); + } + #[tokio::test] async fn connects_to_relay_control_stream_as_client() { let (server_config, certificate) = test_server_config(); @@ -431,4 +582,16 @@ mod tests { frame.extend_from_slice(payload); frame } + + fn unique_temp_identity_path() -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + + std::env::temp_dir().join(format!( + "lanparty-client-identity-{}-{nanos}.json", + std::process::id() + )) + } } diff --git a/crates/lanparty-client-win/src/main.rs b/crates/lanparty-client-win/src/main.rs index 544c51f..0bcf649 100644 --- a/crates/lanparty-client-win/src/main.rs +++ b/crates/lanparty-client-win/src/main.rs @@ -2,7 +2,9 @@ use std::{fs, net::SocketAddr, path::PathBuf}; use anyhow::{Context, Result}; use clap::Parser; -use lanparty_client_core::{ClientSessionConfig, connect_client}; +use lanparty_client_core::{ + ClientIdentity, ClientIdentityStore, ClientSessionConfig, connect_client, +}; use lanparty_ctrl::RoomCode; use lanparty_proto::MacAddr; @@ -28,9 +30,17 @@ struct ClientArgs { #[arg(long)] room: RoomCode, - /// Locally administered unicast MAC address assigned to the TAP adapter. + /// Identity JSON file used to persist the generated virtual MAC. + #[arg( + long = "identity-file", + value_name = "PATH", + default_value = "lanparty-client-identity.json" + )] + identity_file: PathBuf, + + /// Override the generated locally administered TAP MAC address. #[arg(long)] - virtual_mac: MacAddr, + virtual_mac: Option, /// Client's advertised QUIC datagram budget before relay clamping. #[arg(long, default_value_t = 1400)] @@ -46,12 +56,17 @@ impl ClientArgs { ) })?; + let identity = match self.virtual_mac { + Some(virtual_mac) => ClientIdentity::new(virtual_mac)?, + None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?, + }; + ClientSessionConfig::new( self.relay, self.server_name, relay_ca_cert, self.room, - self.virtual_mac, + identity.virtual_mac(), self.max_datagram_size, ) }