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 = [
|
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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user