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:
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MacAddr>,
|
||||
|
||||
/// 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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user