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
This commit is contained in:
2026-05-21 18:35:20 +02:00
parent 25157ad1a6
commit a3d24a1173
6 changed files with 196 additions and 10 deletions
Generated
+3
View File
@@ -435,11 +435,14 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"getrandom 0.3.4",
"lanparty-ctrl", "lanparty-ctrl",
"lanparty-proto", "lanparty-proto",
"quinn", "quinn",
"rcgen", "rcgen",
"rustls", "rustls",
"serde",
"serde_json",
"tokio", "tokio",
] ]
+1
View File
@@ -18,6 +18,7 @@ edition = "2024"
anyhow = "1" anyhow = "1"
bytes = "1" bytes = "1"
clap = { version = "4.6.1", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
getrandom = "0.3.4"
libc = "0.2" libc = "0.2"
quinn = "0.11.9" quinn = "0.11.9"
rcgen = "0.14.8" rcgen = "0.14.8"
+6 -5
View File
@@ -104,11 +104,12 @@ cargo run -p lanparty-client-win -- \
--relay 203.0.113.10:443 \ --relay 203.0.113.10:443 \
--server-name lanparty-relay.local \ --server-name lanparty-relay.local \
--relay-ca-cert relay-cert.der \ --relay-ca-cert relay-cert.der \
--room ROOM1 \ --room ROOM1
--virtual-mac 02:00:00:00:00:51
``` ```
The Windows client binary currently connects to the relay as `role = client` The Windows client binary currently connects to the relay as `role = client`
with the configured virtual MAC, completes the control-stream hello/welcome with a generated locally administered virtual MAC persisted in
handshake, and then waits for shutdown. TAP adapter binding and route pinning `lanparty-client-identity.json`, completes the control-stream hello/welcome
are not wired yet. 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.
+3
View File
@@ -6,10 +6,13 @@ edition.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
getrandom.workspace = true
lanparty-ctrl = { path = "../lanparty-ctrl" } lanparty-ctrl = { path = "../lanparty-ctrl" }
lanparty-proto = { path = "../lanparty-proto" } lanparty-proto = { path = "../lanparty-proto" }
quinn.workspace = true quinn.workspace = true
rustls.workspace = true rustls.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies] [dev-dependencies]
rcgen.workspace = true rcgen.workspace = true
+164 -1
View File
@@ -6,7 +6,10 @@
//! datagrams after the control-plane welcome. //! datagrams after the control-plane welcome.
use std::{ use std::{
fs,
io::ErrorKind,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::{Path, PathBuf},
sync::Arc, 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; 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<Self> {
if !virtual_mac.is_valid_client_identity() {
bail!("client virtual MAC must be locally administered unicast");
}
Ok(Self { virtual_mac })
}
pub fn generate() -> Result<Self> {
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<PathBuf>) -> Result<Self> {
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<ClientIdentity> {
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<ClientIdentity> {
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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientSessionConfig { pub struct ClientSessionConfig {
relay_addr: SocketAddr, relay_addr: SocketAddr,
@@ -258,7 +368,7 @@ async fn request_control_message(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::time::Duration; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use bytes::Bytes; use bytes::Bytes;
use lanparty_ctrl::Role; 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] #[tokio::test]
async fn connects_to_relay_control_stream_as_client() { async fn connects_to_relay_control_stream_as_client() {
let (server_config, certificate) = test_server_config(); let (server_config, certificate) = test_server_config();
@@ -431,4 +582,16 @@ mod tests {
frame.extend_from_slice(payload); frame.extend_from_slice(payload);
frame 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()
))
}
} }
+19 -4
View File
@@ -2,7 +2,9 @@ use std::{fs, net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; 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_ctrl::RoomCode;
use lanparty_proto::MacAddr; use lanparty_proto::MacAddr;
@@ -28,9 +30,17 @@ struct ClientArgs {
#[arg(long)] #[arg(long)]
room: RoomCode, 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)] #[arg(long)]
virtual_mac: MacAddr, virtual_mac: Option<MacAddr>,
/// Client's advertised QUIC datagram budget before relay clamping. /// Client's advertised QUIC datagram budget before relay clamping.
#[arg(long, default_value_t = 1400)] #[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( ClientSessionConfig::new(
self.relay, self.relay,
self.server_name, self.server_name,
relay_ca_cert, relay_ca_cert,
self.room, self.room,
self.virtual_mac, identity.virtual_mac(),
self.max_datagram_size, self.max_datagram_size,
) )
} }