lets GOOOOOOOO

This commit is contained in:
lda
2026-04-28 18:04:07 +07:00 Verified
parent 10baa53e4c
commit aa698a9f4b
10 changed files with 250 additions and 23 deletions
+3
View File
@@ -16,11 +16,14 @@ cw = "c --target=x86_64-pc-windows-msvc"
bw = "b --target=x86_64-pc-windows-msvc"
tw = "t --target=x86_64-pc-windows-msvc"
check-all = "check --workspace --all-features --all-targets"
clippy-all = "clippy --workspace --all-features --all-targets"
test-all = "test --workspace --all-features --all-targets"
fmt-all = "fmt --all"
# idk below
ldar = "r -qrp"
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1"
tdebug = "test -- --nocapture --test-threads=1 --show-output"
+1 -1
View File
@@ -25,7 +25,7 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Command {
/// Show merged device inventory rows.
#[command(visible_alias = "status")]
#[command(visible_alias = "status", alias = "inv")]
Inventory(InventoryArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
+3 -2
View File
@@ -7,8 +7,9 @@ pub use wakey_linux;
pub use service::{
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
inventory, leases_without_state, merge_devices, resolve_devices, resolve_query,
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
inventory, leases_without_state, local_observation_to_fact, merge_devices,
merge_devices_with_observations, resolve_devices, resolve_query, resolve_selector,
resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
};
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
+140 -10
View File
@@ -1,7 +1,8 @@
use anyhow::Result;
use tracing::{debug, warn};
use wakey_core::{
Device, DeviceInventory, DhcpLease, DhcpLeaseWithState, InventoryQuery, NeighborEntry,
Presence, Query,
Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
NeighborEntry, Presence, Query,
};
use crate::service::leases::get_leases;
@@ -24,9 +25,25 @@ pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
include_state: false,
})
.await?;
Ok(DeviceInventory {
devices: merge_devices(neighbors, leases, &query),
})
let observations = match wakey_linux::dhcp::list_local_observations().await {
Ok(observations) => observations
.into_iter()
.filter_map(local_observation_to_fact)
.collect::<Vec<_>>(),
Err(err) => {
warn!(error = %err, "failed reading local hook observations for inventory");
Vec::new()
}
};
debug!(
neighbors = neighbors.len(),
leases = leases.len(),
observations = observations.len(),
"building device inventory"
);
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
debug!(devices = devices.len(), "merged device inventory");
Ok(DeviceInventory { devices })
}
/// Merge raw neighbor entries and DHCP leases into device aggregates.
@@ -37,26 +54,53 @@ pub fn merge_devices(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
query: &InventoryQuery,
) -> Vec<Device> {
merge_devices_with_observations(neighbors, leases, Vec::new(), query)
}
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
/// aggregates.
pub fn merge_devices_with_observations(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
observations: Vec<DeviceObservationFact>,
query: &InventoryQuery,
) -> Vec<Device> {
use std::collections::BTreeMap;
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
type DeviceParts = (
Vec<NeighborEntry>,
Vec<DhcpLease>,
Vec<DeviceObservationFact>,
);
let mut by_key: BTreeMap<String, DeviceParts> = BTreeMap::new();
for row in neighbors {
let key = row
.mac
.map(|m| m.to_string())
.unwrap_or_else(|| format!("ip:{}", row.ip));
by_mac.entry(key).or_default().0.push(row);
by_key.entry(key).or_default().0.push(row);
}
for lease in leases {
let key = lease.lease_line.mac.to_string();
by_mac.entry(key).or_default().1.push(lease.lease_line);
by_key.entry(key).or_default().1.push(lease.lease_line);
}
for observation in observations {
let key = observation
.mac
.map(|mac| mac.to_string())
.or_else(|| observation.ip.map(|ip| format!("ip:{ip}")))
.unwrap_or_else(|| "unknown".to_string());
by_key.entry(key).or_default().2.push(observation);
}
let mut devices: Vec<Device> = by_mac
let mut devices: Vec<Device> = by_key
.into_values()
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
.map(|(neighbors, leases, observations)| {
Device::from_parts_with_observations(neighbors, leases, observations)
})
.collect();
let mut texts = Vec::new();
@@ -90,6 +134,42 @@ pub fn merge_devices(
devices
}
pub fn local_observation_to_fact(
observation: wakey_linux::dhcp::LocalDeviceObservation,
) -> Option<DeviceObservationFact> {
let mac = match observation.mac.as_deref() {
Some(raw) => match raw.parse() {
Ok(mac) => Some(mac),
Err(err) => {
warn!(
mac = raw,
error = %err,
"ignoring invalid observed mac in inventory"
);
None
}
},
None => None,
};
if mac.is_none() && observation.ip.is_none() {
warn!(
kind = %observation.kind,
action = %observation.action,
"ignoring hook observation without mac or ip"
);
return None;
}
Some(DeviceObservationFact {
kind: observation.kind,
action: observation.action,
mac,
ip: observation.ip,
hostname: observation.hostname,
first_seen_unix: Some(observation.first_seen_unix),
last_seen_unix: Some(observation.last_seen_unix),
})
}
const fn presence_rank(presence: Presence) -> u8 {
match presence {
Presence::Online => 3,
@@ -175,4 +255,54 @@ mod tests {
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
);
}
#[test]
fn merge_devices_preserves_hook_observation_facts() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "dhcp".into(),
action: "update".into(),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
hostname: Some("lda".into()),
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].names, vec!["lda".to_string()]);
assert_eq!(
out[0].id,
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
);
assert_eq!(out[0].observations.len(), 1);
assert_eq!(out[0].presence, Presence::Unknown);
}
#[test]
fn merge_devices_marks_remove_only_observation_offline() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "neigh".into(),
action: "remove".into(),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
hostname: None,
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].presence, Presence::Offline);
}
}
+4 -1
View File
@@ -5,7 +5,10 @@ pub mod query;
pub mod wake;
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
pub use inventory::{inventory, merge_devices, resolve_devices};
pub use inventory::{
inventory, local_observation_to_fact, merge_devices, merge_devices_with_observations,
resolve_devices,
};
pub use leases::{get_leases, leases_without_state};
pub use query::{resolve_query, resolve_selector};
pub use wake::{
+27 -2
View File
@@ -1,5 +1,5 @@
use anyhow::Result;
use tracing::{debug, info, instrument};
use tracing::{debug, info, instrument, warn};
use crate::config::AgentConfig;
use crate::protocol::{
@@ -70,8 +70,33 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
&config.mac_name_cache_path,
)
.await?;
let observations = match wakey::wakey_linux::dhcp::list_local_observations_from_path(
&config.observation_store_path,
)
.await
{
Ok(observations) => observations
.into_iter()
.filter_map(wakey::local_observation_to_fact)
.collect::<Vec<_>>(),
Err(err) => {
warn!(error = %err, "failed reading local hook observations for inventory command");
Vec::new()
}
};
debug!(
neighbors = neighbors.len(),
leases = leases.len(),
observations = observations.len(),
"dispatching inventory with merged sources"
);
let inventory = wakey_core::DeviceInventory {
devices: wakey::merge_devices(neighbors, wakey::leases_without_state(leases), &query),
devices: wakey::merge_devices_with_observations(
neighbors,
wakey::leases_without_state(leases),
observations,
&query,
),
};
debug!(
rows = inventory.devices.len(),
+65
View File
@@ -44,6 +44,24 @@ pub enum DeviceId {
Ip(IpAddr),
}
/// One raw source fact used while building a device aggregate.
///
/// These are intentionally source-shaped and non-durable. They preserve details
/// from hooks and live inventory so higher layers can explain why a device looks
/// online, stale, or unknown without reverse-engineering flattened fields.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
pub struct DeviceObservationFact {
pub kind: String,
pub action: String,
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
pub ip: Option<IpAddr>,
pub hostname: Option<String>,
pub first_seen_unix: Option<u64>,
pub last_seen_unix: Option<u64>,
}
/// Merged view of one discovered network identity.
///
/// This aggregates facts from DHCP leases and neighbor-table rows into a more
@@ -59,12 +77,22 @@ pub struct Device {
pub interfaces: Vec<String>,
pub neighbors: Vec<NeighborEntry>,
pub leases: Vec<DhcpLease>,
pub observations: Vec<DeviceObservationFact>,
pub presence: Presence,
}
impl Device {
/// Merge raw neighbor and DHCP facts into one device aggregate.
pub fn from_parts(neighbors: Vec<NeighborEntry>, leases: Vec<DhcpLease>) -> Self {
Self::from_parts_with_observations(neighbors, leases, Vec::new())
}
/// Merge raw neighbor, DHCP, and hook observation facts into one aggregate.
pub fn from_parts_with_observations(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLease>,
observations: Vec<DeviceObservationFact>,
) -> Self {
use std::collections::BTreeSet;
let mut names = BTreeSet::new();
@@ -94,6 +122,34 @@ impl Device {
)
.into();
}
let mut observed_non_remove = false;
for observation in &observations {
if let Some(name) = observation.hostname.as_deref() {
names.insert(name);
}
if let Some(ip) = observation.ip {
ips.insert(ip);
}
if let Some(mac) = observation.mac {
macs.insert(mac);
}
if observation.action != "remove" {
observed_non_remove = true;
}
presence = std::cmp::max(
presence_rank(presence),
presence_rank(observation_presence(observation)),
)
.into();
}
if neighbors.is_empty()
&& leases.is_empty()
&& !observed_non_remove
&& !observations.is_empty()
{
presence = Presence::Offline;
}
let macs: Vec<MacAddr> = macs.into_iter().collect();
let ips: Vec<IpAddr> = ips.into_iter().collect();
@@ -110,11 +166,20 @@ impl Device {
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
neighbors,
leases,
observations,
presence,
}
}
}
fn observation_presence(observation: &DeviceObservationFact) -> Presence {
match (observation.kind.as_str(), observation.action.as_str()) {
(_, "remove") => Presence::Offline,
("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
_ => Presence::Unknown,
}
}
const fn presence_rank(presence: Presence) -> u8 {
match presence {
Presence::Online => 3,
+1 -1
View File
@@ -5,7 +5,7 @@ mod neighbor;
mod query;
mod wake;
pub use device::{Device, DeviceId, DeviceInventory, Presence};
pub use device::{Device, DeviceId, DeviceInventory, DeviceObservationFact, Presence};
pub use dhcp::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
pub use interface::{InterfaceAddr, InterfaceSummary};
pub use neighbor::{NeighborEntry, NeighborParseError, NeighborState, parse_neighbor_line};
+2 -2
View File
@@ -6,7 +6,8 @@ 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,
read_dhcp_leases_from_path, read_dhcp_leases_with_names,
read_dhcp_leases_with_names_from_paths,
};
pub use observations::{
LocalDeviceObservation, LocalObservationStore, ObservedDhcpClient, ObservedNeighbor,
@@ -22,7 +23,6 @@ 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)
+4 -4
View File
@@ -99,19 +99,19 @@ async fn save_observation_store(store: &LocalObservationStore) -> io::Result<()>
pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>> {
let store = load_observation_store().await?;
list_local_observations_from_store(store)
Ok(list_local_observations_from_store(store))
}
pub async fn list_local_observations_from_path(
path: impl AsRef<std::path::Path>,
) -> io::Result<Vec<LocalDeviceObservation>> {
let store = load_observation_store_from_path(path).await?;
list_local_observations_from_store(store)
Ok(list_local_observations_from_store(store))
}
fn list_local_observations_from_store(
store: LocalObservationStore,
) -> io::Result<Vec<LocalDeviceObservation>> {
) -> Vec<LocalDeviceObservation> {
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
out.extend(
store
@@ -148,7 +148,7 @@ fn list_local_observations_from_store(
.then(a.mac.cmp(&b.mac))
.then(a.ip.cmp(&b.ip))
});
Ok(out)
out
}
/// Observe a DHCP hotplug event and update the local MAC-to-name cache.