woah
so what are these? auto gc and typed observation (because some flett shit is depending on ts) Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+1
-156
@@ -1,47 +1,20 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod leases;
|
||||
mod observations;
|
||||
|
||||
pub use leases::{
|
||||
enrich_leases_with_nud_state, parse_dhcp_lease_line, read_dhcp_leases,
|
||||
read_dhcp_leases_from_path, read_dhcp_leases_with_names,
|
||||
read_dhcp_leases_with_names_from_paths,
|
||||
};
|
||||
pub use observations::{
|
||||
LocalDeviceObservation, LocalObservationStore, ObservedDhcpClient, ObservedNeighbor,
|
||||
list_local_observations, list_local_observations_from_path, load_mac_name_cache,
|
||||
load_mac_name_cache_from_path, load_observation_store, load_observation_store_from_path,
|
||||
observe_dhcp_event, observe_neighbor_event,
|
||||
};
|
||||
|
||||
const DEFAULT_DHCP_LEASES: &str = "/tmp/dhcp.leases";
|
||||
const DEFAULT_MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||
const DEFAULT_OBSERVATION_STORE: &str = "/tmp/wakey_observations.json";
|
||||
const DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
||||
const MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||
const OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||
|
||||
pub(crate) fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn dhcp_leases_path() -> PathBuf {
|
||||
configured_path(DHCP_LEASES_ENV, DEFAULT_DHCP_LEASES)
|
||||
}
|
||||
|
||||
pub(crate) fn mac_name_cache_path() -> PathBuf {
|
||||
configured_path(MAC_NAME_CACHE_ENV, DEFAULT_MAC_NAME_CACHE)
|
||||
}
|
||||
|
||||
pub(crate) fn observation_store_path() -> PathBuf {
|
||||
configured_path(OBSERVATION_STORE_ENV, DEFAULT_OBSERVATION_STORE)
|
||||
}
|
||||
|
||||
fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
||||
std::env::var_os(env_key)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -53,6 +26,7 @@ fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct EnvGuard {
|
||||
keys: Vec<&'static str>,
|
||||
@@ -107,133 +81,4 @@ mod tests {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn observation_and_name_cache_paths_can_be_overridden() {
|
||||
let observation_path = temp_file("observations");
|
||||
let cache_path = temp_file("names");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let _cache_guard = EnvGuard::set(MAC_NAME_CACHE_ENV, &cache_path);
|
||||
|
||||
let changed = observe_dhcp_event(
|
||||
"add",
|
||||
"aa:bb:cc:dd:ee:ff".parse().expect("mac should parse"),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
Some("lda"),
|
||||
)
|
||||
.await
|
||||
.expect("observation should write");
|
||||
assert!(changed);
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:ff"));
|
||||
|
||||
let cache = load_mac_name_cache().await.expect("name cache should read");
|
||||
assert_eq!(cache.get("aa:bb:cc:dd:ee:ff"), Some(&"lda".to_string()));
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
let _ = tokio::fs::remove_file(cache_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn neighbor_observations_are_keyed_by_mac_ip_pair() {
|
||||
let observation_path = temp_file("neighbor-observations");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let mac = "aa:bb:cc:dd:ee:ff".parse().expect("mac should parse");
|
||||
|
||||
observe_neighbor_event(
|
||||
"add",
|
||||
Some(mac),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
)
|
||||
.await
|
||||
.expect("first observation should write");
|
||||
observe_neighbor_event(
|
||||
"update",
|
||||
Some(mac),
|
||||
Some("192.168.1.3".parse().expect("ip should parse")),
|
||||
)
|
||||
.await
|
||||
.expect("second observation should write");
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(
|
||||
store
|
||||
.neighbors
|
||||
.contains_key("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.neighbors
|
||||
.contains_key("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.3")
|
||||
);
|
||||
assert_eq!(
|
||||
store.neighbors["mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2"].last_action,
|
||||
"remove"
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn neighbor_observation_migrates_coarse_mac_key_to_mac_ip_pair() {
|
||||
let observation_path = temp_file("neighbor-observations-migrate");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let mut neighbors = std::collections::BTreeMap::new();
|
||||
neighbors.insert(
|
||||
"mac:aa:bb:cc:dd:ee:ff".to_string(),
|
||||
ObservedNeighbor {
|
||||
key: "mac:aa:bb:cc:dd:ee:ff".to_string(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".to_string()),
|
||||
ip: None,
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix: 1,
|
||||
last_action: "add".to_string(),
|
||||
},
|
||||
);
|
||||
let fixture = LocalObservationStore {
|
||||
dhcp_clients: Default::default(),
|
||||
neighbors,
|
||||
};
|
||||
tokio::fs::write(
|
||||
&observation_path,
|
||||
serde_json::to_string(&fixture).expect("fixture should serialize"),
|
||||
)
|
||||
.await
|
||||
.expect("fixture should write");
|
||||
|
||||
observe_neighbor_event(
|
||||
"update",
|
||||
Some("aa:bb:cc:dd:ee:ff".parse().expect("mac should parse")),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
)
|
||||
.await
|
||||
.expect("observation should write");
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(!store.neighbors.contains_key("mac:aa:bb:cc:dd:ee:ff"));
|
||||
let row = store
|
||||
.neighbors
|
||||
.get("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2")
|
||||
.expect("coarse key should migrate to pair key");
|
||||
assert_eq!(row.first_seen_unix, 1);
|
||||
assert_eq!(row.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
assert_eq!(
|
||||
row.ip
|
||||
.expect("ip should be carried into migrated row")
|
||||
.to_string(),
|
||||
"192.168.1.2"
|
||||
);
|
||||
assert_eq!(row.last_action, "update");
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ use std::net::IpAddr;
|
||||
|
||||
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||
|
||||
use super::{dhcp_leases_path, mac_name_cache_path, observation_store_path};
|
||||
use crate::dhcp::observations::{
|
||||
load_mac_name_cache_from_path, load_observation_store_from_path, save_mac_name_cache_to_path,
|
||||
use super::dhcp_leases_path;
|
||||
use crate::observations::{
|
||||
load_mac_name_cache_from_path, load_observation_store_from_path, mac_name_cache_path,
|
||||
observation_store_path, save_mac_name_cache_to_path,
|
||||
};
|
||||
|
||||
/// Parse one `dnsmasq`-style DHCP lease line.
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
pub mod devices;
|
||||
pub mod dhcp;
|
||||
pub mod observations;
|
||||
pub mod wake;
|
||||
|
||||
pub use devices::*;
|
||||
pub use dhcp::*;
|
||||
pub use observations::*;
|
||||
pub use wake::*;
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{mac_name_cache_path, now_unix, observation_store_path};
|
||||
const DEFAULT_MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||
const DEFAULT_OBSERVATION_STORE: &str = "/tmp/wakey_observations.json";
|
||||
const MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||
const OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn mac_name_cache_path() -> PathBuf {
|
||||
configured_path(MAC_NAME_CACHE_ENV, DEFAULT_MAC_NAME_CACHE)
|
||||
}
|
||||
|
||||
pub(crate) fn observation_store_path() -> PathBuf {
|
||||
configured_path(OBSERVATION_STORE_ENV, DEFAULT_OBSERVATION_STORE)
|
||||
}
|
||||
|
||||
fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
||||
std::env::var_os(env_key)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(default))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LocalObservationStore {
|
||||
@@ -51,7 +78,7 @@ pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<Stri
|
||||
}
|
||||
|
||||
pub async fn load_mac_name_cache_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
path: impl AsRef<Path>,
|
||||
) -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||
@@ -66,7 +93,7 @@ async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -
|
||||
}
|
||||
|
||||
pub(super) async fn save_mac_name_cache_to_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
path: impl AsRef<Path>,
|
||||
map: &std::collections::BTreeMap<String, String>,
|
||||
) -> io::Result<()> {
|
||||
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
||||
@@ -83,7 +110,7 @@ pub async fn load_observation_store() -> io::Result<LocalObservationStore> {
|
||||
}
|
||||
|
||||
pub async fn load_observation_store_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
path: impl AsRef<Path>,
|
||||
) -> io::Result<LocalObservationStore> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||
@@ -93,8 +120,15 @@ pub async fn load_observation_store_from_path(
|
||||
}
|
||||
|
||||
async fn save_observation_store(store: &LocalObservationStore) -> io::Result<()> {
|
||||
save_observation_store_to_path(observation_store_path(), store).await
|
||||
}
|
||||
|
||||
async fn save_observation_store_to_path(
|
||||
path: impl AsRef<Path>,
|
||||
store: &LocalObservationStore,
|
||||
) -> io::Result<()> {
|
||||
let s = serde_json::to_string(store).map_err(io::Error::other)?;
|
||||
tokio::fs::write(observation_store_path(), s).await
|
||||
tokio::fs::write(path, s).await
|
||||
}
|
||||
|
||||
pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>> {
|
||||
@@ -103,12 +137,30 @@ pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>
|
||||
}
|
||||
|
||||
pub async fn list_local_observations_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
path: impl AsRef<Path>,
|
||||
) -> io::Result<Vec<LocalDeviceObservation>> {
|
||||
let store = load_observation_store_from_path(path).await?;
|
||||
Ok(list_local_observations_from_store(store))
|
||||
}
|
||||
|
||||
pub async fn prune_removed_observations_from_path(path: impl AsRef<Path>) -> io::Result<usize> {
|
||||
let path = path.as_ref();
|
||||
let mut store = load_observation_store_from_path(path).await?;
|
||||
let before = store.dhcp_clients.len() + store.neighbors.len();
|
||||
store
|
||||
.dhcp_clients
|
||||
.retain(|_, row| !row.last_action.eq_ignore_ascii_case("remove"));
|
||||
store
|
||||
.neighbors
|
||||
.retain(|_, row| !row.last_action.eq_ignore_ascii_case("remove"));
|
||||
let after = store.dhcp_clients.len() + store.neighbors.len();
|
||||
let removed = before.saturating_sub(after);
|
||||
if removed > 0 {
|
||||
save_observation_store_to_path(path, &store).await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> {
|
||||
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
||||
out.extend(
|
||||
@@ -353,3 +405,175 @@ fn same_ip_family(a: IpAddr, b: IpAddr) -> bool {
|
||||
(IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
struct EnvGuard {
|
||||
keys: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: &std::path::Path) -> Self {
|
||||
let guard = Self { keys: vec![key] };
|
||||
// SAFETY: these tests are serialized and do not spawn work that reads these
|
||||
// environment variables outside the test body.
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
guard
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
for key in &self.keys {
|
||||
// SAFETY: these tests are serialized and do not spawn work that reads these
|
||||
// environment variables outside the test body.
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_file(name: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
std::env::temp_dir().join(format!("wakey-linux-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn observation_and_name_cache_paths_can_be_overridden() {
|
||||
let observation_path = temp_file("observations");
|
||||
let cache_path = temp_file("names");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let _cache_guard = EnvGuard::set(MAC_NAME_CACHE_ENV, &cache_path);
|
||||
|
||||
let changed = observe_dhcp_event(
|
||||
"add",
|
||||
"aa:bb:cc:dd:ee:ff".parse().expect("mac should parse"),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
Some("lda"),
|
||||
)
|
||||
.await
|
||||
.expect("observation should write");
|
||||
assert!(changed);
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:ff"));
|
||||
|
||||
let cache = load_mac_name_cache().await.expect("name cache should read");
|
||||
assert_eq!(cache.get("aa:bb:cc:dd:ee:ff"), Some(&"lda".to_string()));
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
let _ = tokio::fs::remove_file(cache_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn neighbor_observations_are_keyed_by_mac_ip_pair() {
|
||||
let observation_path = temp_file("neighbor-observations");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let mac = "aa:bb:cc:dd:ee:ff".parse().expect("mac should parse");
|
||||
|
||||
observe_neighbor_event(
|
||||
"add",
|
||||
Some(mac),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
)
|
||||
.await
|
||||
.expect("first observation should write");
|
||||
observe_neighbor_event(
|
||||
"update",
|
||||
Some(mac),
|
||||
Some("192.168.1.3".parse().expect("ip should parse")),
|
||||
)
|
||||
.await
|
||||
.expect("second observation should write");
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(
|
||||
store
|
||||
.neighbors
|
||||
.contains_key("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.neighbors
|
||||
.contains_key("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.3")
|
||||
);
|
||||
assert_eq!(
|
||||
store.neighbors["mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2"].last_action,
|
||||
"remove"
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn neighbor_observation_migrates_coarse_mac_key_to_mac_ip_pair() {
|
||||
let observation_path = temp_file("neighbor-observations-migrate");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let mut neighbors = std::collections::BTreeMap::new();
|
||||
neighbors.insert(
|
||||
"mac:aa:bb:cc:dd:ee:ff".to_string(),
|
||||
ObservedNeighbor {
|
||||
key: "mac:aa:bb:cc:dd:ee:ff".to_string(),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".to_string()),
|
||||
ip: None,
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix: 1,
|
||||
last_action: "add".to_string(),
|
||||
},
|
||||
);
|
||||
let fixture = LocalObservationStore {
|
||||
dhcp_clients: Default::default(),
|
||||
neighbors,
|
||||
};
|
||||
tokio::fs::write(
|
||||
&observation_path,
|
||||
serde_json::to_string(&fixture).expect("fixture should serialize"),
|
||||
)
|
||||
.await
|
||||
.expect("fixture should write");
|
||||
|
||||
observe_neighbor_event(
|
||||
"update",
|
||||
Some("aa:bb:cc:dd:ee:ff".parse().expect("mac should parse")),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
)
|
||||
.await
|
||||
.expect("observation should write");
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(!store.neighbors.contains_key("mac:aa:bb:cc:dd:ee:ff"));
|
||||
let row = store
|
||||
.neighbors
|
||||
.get("mac:aa:bb:cc:dd:ee:ff:ip:192.168.1.2")
|
||||
.expect("coarse key should migrate to pair key");
|
||||
assert_eq!(row.first_seen_unix, 1);
|
||||
assert_eq!(row.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
assert_eq!(
|
||||
row.ip
|
||||
.expect("ip should be carried into migrated row")
|
||||
.to_string(),
|
||||
"192.168.1.2"
|
||||
);
|
||||
assert_eq!(row.last_action, "update");
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user