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
+3
View File
@@ -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
+164 -1
View File
@@ -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<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)]
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()
))
}
}