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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user