commit full of PRs
PRs full of commit basically i used the ENTIRE mimo grant to convert a system to another. hope this one is good. may fail.
This commit is contained in:
112 files changed
+2407
-4237
No files matched your search
@@ -25,11 +25,6 @@ pub struct DeviceIdentifierRequest {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AttachObservationIdentifierRequest {
|
||||
pub observation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceResponse {
|
||||
pub device_id: String,
|
||||
@@ -171,33 +166,6 @@ pub async fn attach_device_identifier(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn attach_observation_identifier(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<AttachObservationIdentifierRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.attach_observation_identifier(&device_id, &req.observation_key)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to attach observation identifier");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"attach_observation_identifier_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn detach_device_identifier(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((device_id, identifier_key)): AxumPath<(String, String)>,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::state::{AgentDeviceObservation, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
||||
use macaddr::MacAddr;
|
||||
use wakey_core::Presence;
|
||||
|
||||
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
|
||||
|
||||
use super::types::{FleetDevice, FleetDeviceAgent, FleetWakeRoute, ListFleetDevicesQuery};
|
||||
|
||||
@@ -22,20 +26,20 @@ struct FleetAccumulator {
|
||||
display_name: Option<String>,
|
||||
known_device: Option<KnownDeviceSummary>,
|
||||
pinned: bool,
|
||||
ips: BTreeSet<String>,
|
||||
macs: BTreeSet<String>,
|
||||
ips: BTreeSet<IpAddr>,
|
||||
macs: BTreeSet<MacAddr>,
|
||||
hostnames: BTreeSet<String>,
|
||||
sources: BTreeSet<String>,
|
||||
agents: BTreeMap<String, FleetDeviceAgent>,
|
||||
first_seen_unix: Option<u64>,
|
||||
last_seen_unix: Option<u64>,
|
||||
presence_rank: u8,
|
||||
presence: Presence,
|
||||
routes: BTreeMap<String, FleetWakeRoute>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_fleet_devices(
|
||||
known_devices: Vec<KnownDevice>,
|
||||
observations: Vec<AgentDeviceObservation>,
|
||||
agent_devices: Vec<AgentDeviceWithChildren>,
|
||||
context: &FleetBuildContext,
|
||||
) -> Vec<FleetDevice> {
|
||||
let mut by_key = BTreeMap::<String, FleetAccumulator>::new();
|
||||
@@ -49,23 +53,22 @@ pub(crate) fn build_fleet_devices(
|
||||
display_name: Some(device.display_name.clone()),
|
||||
known_device: Some(known_device_summary(&device)),
|
||||
pinned: device.pinned,
|
||||
presence_rank: 1,
|
||||
..Default::default()
|
||||
});
|
||||
for identifier in device.identifiers {
|
||||
add_identifier_to_entry(entry, &identifier);
|
||||
for identifier in &device.identifiers {
|
||||
add_identifier_to_entry(entry, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
for observation in observations {
|
||||
let key = observation_group_key(&observation, context);
|
||||
for agent_device in agent_devices {
|
||||
let key = device_group_key(&agent_device, context);
|
||||
let entry = by_key
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| FleetAccumulator {
|
||||
device_key: key,
|
||||
..Default::default()
|
||||
});
|
||||
add_observation_to_entry(entry, observation, context);
|
||||
add_agent_device_to_entry(entry, agent_device, context);
|
||||
}
|
||||
|
||||
let mut devices = by_key
|
||||
@@ -76,7 +79,7 @@ pub(crate) fn build_fleet_devices(
|
||||
b.pinned
|
||||
.cmp(&a.pinned)
|
||||
.then_with(|| b.known_device.is_some().cmp(&a.known_device.is_some()))
|
||||
.then_with(|| presence_rank(&b.presence).cmp(&presence_rank(&a.presence)))
|
||||
.then_with(|| b.presence.cmp(&a.presence))
|
||||
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
|
||||
.then_with(|| a.display_name.cmp(&b.display_name))
|
||||
});
|
||||
@@ -98,7 +101,7 @@ pub(crate) fn filter_fleet_devices(devices: &mut Vec<FleetDevice>, query: &ListF
|
||||
}
|
||||
if let Some(presence) = presence.as_deref()
|
||||
&& presence != "all"
|
||||
&& device.presence != presence
|
||||
&& device.presence.as_str() != presence
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -118,18 +121,18 @@ pub(crate) fn filter_fleet_devices(devices: &mut Vec<FleetDevice>, query: &ListF
|
||||
return false;
|
||||
}
|
||||
if let Some(search) = search.as_deref() {
|
||||
let mut haystack = vec![
|
||||
device.device_key.as_str(),
|
||||
device.display_name.as_str(),
|
||||
device.presence.as_str(),
|
||||
let mut haystack: Vec<String> = vec![
|
||||
device.device_key.clone(),
|
||||
device.display_name.clone(),
|
||||
device.presence.as_str().to_string(),
|
||||
];
|
||||
haystack.extend(device.ips.iter().map(String::as_str));
|
||||
haystack.extend(device.macs.iter().map(String::as_str));
|
||||
haystack.extend(device.hostnames.iter().map(String::as_str));
|
||||
haystack.extend(device.sources.iter().map(String::as_str));
|
||||
haystack.extend(device.agents.iter().map(|agent| agent.agent_id.as_str()));
|
||||
haystack.extend(device.ips.iter().map(|ip| ip.to_string()));
|
||||
haystack.extend(device.macs.iter().map(|mac| mac.to_string()));
|
||||
haystack.extend(device.hostnames.clone());
|
||||
haystack.extend(device.sources.clone());
|
||||
haystack.extend(device.agents.iter().map(|a| a.agent_id.clone()));
|
||||
if !haystack
|
||||
.into_iter()
|
||||
.iter()
|
||||
.any(|value| value.to_ascii_lowercase().contains(search))
|
||||
{
|
||||
return false;
|
||||
@@ -145,70 +148,73 @@ fn fleet_device_is_operator_noise(device: &FleetDevice) -> bool {
|
||||
&& device.hostnames.is_empty()
|
||||
&& device.recommended_route.is_none()
|
||||
&& device.ips.is_empty()
|
||||
&& device.presence == "offline"
|
||||
&& device.presence == Presence::Offline
|
||||
}
|
||||
|
||||
fn observation_group_key(
|
||||
observation: &AgentDeviceObservation,
|
||||
context: &FleetBuildContext,
|
||||
) -> String {
|
||||
if let Some(summary) = observation_known_device(observation, context) {
|
||||
fn device_group_key(agent_device: &AgentDeviceWithChildren, context: &FleetBuildContext) -> String {
|
||||
if let Some(summary) = device_known_device(agent_device, context) {
|
||||
return format!("known:{}", summary.device_id);
|
||||
}
|
||||
if let Some(mac) = observation.mac.as_deref() {
|
||||
if let Some(mac) = agent_device.macs.first() {
|
||||
return format!("mac:{mac}");
|
||||
}
|
||||
if let Some(ip) = observation.ip.as_deref() {
|
||||
if let Some(ip) = agent_device.ips.first() {
|
||||
return format!("ip:{ip}");
|
||||
}
|
||||
observation.observation_key.clone()
|
||||
agent_device.device.device_key.clone()
|
||||
}
|
||||
|
||||
fn add_observation_to_entry(
|
||||
fn add_agent_device_to_entry(
|
||||
entry: &mut FleetAccumulator,
|
||||
observation: AgentDeviceObservation,
|
||||
agent_device: AgentDeviceWithChildren,
|
||||
context: &FleetBuildContext,
|
||||
) {
|
||||
let observation_offline = observation_is_offline(&observation);
|
||||
if let Some(summary) = observation_known_device(&observation, context)
|
||||
let device_offline = agent_device.device.presence() == Presence::Offline;
|
||||
if let Some(summary) = device_known_device(&agent_device, context)
|
||||
&& entry.known_device.is_none()
|
||||
{
|
||||
entry.display_name = Some(summary.display_name.clone());
|
||||
if let Some(ref name) = agent_device.device.display_name {
|
||||
entry.display_name = Some(name.clone());
|
||||
}
|
||||
entry.pinned = summary.pinned;
|
||||
entry.known_device = Some(summary);
|
||||
}
|
||||
if let Some(mac) = observation.mac.as_deref() {
|
||||
entry.macs.insert(mac.to_string());
|
||||
for mac in &agent_device.macs {
|
||||
entry.macs.insert(*mac);
|
||||
}
|
||||
if !observation_offline && let Some(ip) = observation.ip.as_deref() {
|
||||
entry.ips.insert(ip.to_string());
|
||||
}
|
||||
if let Some(hostname) = observation.hostname.as_deref() {
|
||||
if entry.display_name.is_none() {
|
||||
entry.display_name = Some(hostname.to_string());
|
||||
if !device_offline {
|
||||
for ip in &agent_device.ips {
|
||||
entry.ips.insert(*ip);
|
||||
}
|
||||
entry.hostnames.insert(hostname.to_string());
|
||||
}
|
||||
entry.sources.insert(observation.kind.clone());
|
||||
for hostname in &agent_device.hostnames {
|
||||
if entry.display_name.is_none() {
|
||||
entry.display_name = Some(hostname.clone());
|
||||
}
|
||||
entry.hostnames.insert(hostname.clone());
|
||||
}
|
||||
entry.sources.insert("device".to_string());
|
||||
let first_seen = agent_device.device.first_seen();
|
||||
let last_seen = agent_device.device.last_seen();
|
||||
entry.first_seen_unix = Some(
|
||||
entry
|
||||
.first_seen_unix
|
||||
.map(|current| current.min(observation.first_seen_unix))
|
||||
.unwrap_or(observation.first_seen_unix),
|
||||
.map(|current| current.min(first_seen))
|
||||
.unwrap_or(first_seen),
|
||||
);
|
||||
entry.last_seen_unix = Some(
|
||||
entry
|
||||
.last_seen_unix
|
||||
.map(|current| current.max(observation.last_seen_unix))
|
||||
.unwrap_or(observation.last_seen_unix),
|
||||
.map(|current| current.max(last_seen))
|
||||
.unwrap_or(last_seen),
|
||||
);
|
||||
entry.presence_rank = entry
|
||||
.presence_rank
|
||||
.max(observation_presence_rank(&observation));
|
||||
let device_presence = agent_device.device.presence();
|
||||
entry.presence = std::cmp::max(entry.presence, device_presence);
|
||||
|
||||
let agent_id = agent_device.device.agent_id.clone();
|
||||
let status = context
|
||||
.agent_status
|
||||
.get(&observation.agent_id)
|
||||
.get(&agent_id)
|
||||
.cloned()
|
||||
.unwrap_or(AgentRuntimeStatus {
|
||||
nickname: None,
|
||||
@@ -216,71 +222,95 @@ fn add_observation_to_entry(
|
||||
});
|
||||
entry
|
||||
.agents
|
||||
.entry(observation.agent_id.clone())
|
||||
.entry(agent_id.clone())
|
||||
.and_modify(|agent| {
|
||||
agent.last_seen_unix = agent.last_seen_unix.max(observation.last_seen_unix);
|
||||
agent.last_seen_unix = agent.last_seen_unix.max(last_seen);
|
||||
agent.connected = status.connected;
|
||||
agent.nickname = status.nickname.clone();
|
||||
})
|
||||
.or_insert(FleetDeviceAgent {
|
||||
agent_id: observation.agent_id.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
last_seen_unix: last_seen,
|
||||
});
|
||||
|
||||
let route_id = route_id(
|
||||
&observation.agent_id,
|
||||
observation.mac.as_deref(),
|
||||
observation.ip.as_deref(),
|
||||
&observation.kind,
|
||||
);
|
||||
let wakeable = status.connected && observation.mac.is_some() && !observation_offline;
|
||||
entry.routes.insert(
|
||||
route_id.clone(),
|
||||
FleetWakeRoute {
|
||||
route_id: route_id.clone(),
|
||||
agent_id: observation.agent_id,
|
||||
nickname: status.nickname,
|
||||
connected: status.connected,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
source: observation.kind,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
wakeable,
|
||||
},
|
||||
);
|
||||
if let Some(route) = entry.routes.get_mut(&route_id) {
|
||||
route.wakeable = route.connected && route.mac.is_some() && !observation_offline;
|
||||
for mac in &agent_device.macs {
|
||||
let ip_for_mac = agent_device.ips.first().copied();
|
||||
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref(), "device");
|
||||
let wakeable = status.connected && !device_offline;
|
||||
entry.routes.insert(
|
||||
rid.clone(),
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
mac: Some(*mac),
|
||||
ip: ip_for_mac,
|
||||
hostname: hostname_for_mac,
|
||||
source: "device".to_string(),
|
||||
last_seen_unix: last_seen,
|
||||
wakeable,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if agent_device.macs.is_empty()
|
||||
&& let Some(ip) = agent_device.ips.first()
|
||||
{
|
||||
let hostname = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, None, Some(ip), "device");
|
||||
entry.routes.insert(
|
||||
rid.clone(),
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
mac: None,
|
||||
ip: Some(*ip),
|
||||
hostname,
|
||||
source: "device".to_string(),
|
||||
last_seen_unix: last_seen,
|
||||
wakeable: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_identifier_to_entry(entry: &mut FleetAccumulator, identifier: &DeviceIdentifier) {
|
||||
match identifier.kind.as_str() {
|
||||
"mac" => {
|
||||
entry.macs.insert(identifier.value.clone());
|
||||
if let Ok(mac) = identifier.value.parse::<MacAddr>() {
|
||||
entry.macs.insert(mac);
|
||||
}
|
||||
}
|
||||
"ip" => {
|
||||
entry.ips.insert(identifier.value.clone());
|
||||
if let Ok(ip) = identifier.value.parse::<IpAddr>() {
|
||||
entry.ips.insert(ip);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn observation_known_device(
|
||||
observation: &AgentDeviceObservation,
|
||||
fn device_known_device(
|
||||
agent_device: &AgentDeviceWithChildren,
|
||||
context: &FleetBuildContext,
|
||||
) -> Option<KnownDeviceSummary> {
|
||||
observation
|
||||
.mac
|
||||
.as_deref()
|
||||
.and_then(|mac| context.identifier_map.get(&format!("mac:{mac}")).cloned())
|
||||
agent_device
|
||||
.macs
|
||||
.first()
|
||||
.map(|mac| format!("mac:{}", mac.to_string().to_ascii_lowercase()))
|
||||
.and_then(|key| context.identifier_map.get(&key).cloned())
|
||||
.or_else(|| {
|
||||
observation
|
||||
.ip
|
||||
.as_deref()
|
||||
.and_then(|ip| context.identifier_map.get(&format!("ip:{ip}")).cloned())
|
||||
agent_device
|
||||
.ips
|
||||
.first()
|
||||
.map(|ip| format!("ip:{ip}"))
|
||||
.and_then(|key| context.identifier_map.get(&key).cloned())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -309,8 +339,8 @@ impl FleetAccumulator {
|
||||
let display_name = self
|
||||
.display_name
|
||||
.or_else(|| self.hostnames.iter().next().cloned())
|
||||
.or_else(|| self.macs.iter().next().cloned())
|
||||
.or_else(|| self.ips.iter().next().cloned())
|
||||
.or_else(|| self.macs.iter().next().map(|mac| mac.to_string()))
|
||||
.or_else(|| self.ips.iter().next().map(|ip| ip.to_string()))
|
||||
.unwrap_or_else(|| "(unknown device)".to_string());
|
||||
|
||||
FleetDevice {
|
||||
@@ -325,50 +355,20 @@ impl FleetAccumulator {
|
||||
sources: self.sources.into_iter().collect(),
|
||||
first_seen_unix: self.first_seen_unix,
|
||||
last_seen_unix: self.last_seen_unix,
|
||||
presence: rank_presence(self.presence_rank).to_string(),
|
||||
presence: self.presence,
|
||||
route_candidates,
|
||||
recommended_route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn observation_presence_rank(observation: &AgentDeviceObservation) -> u8 {
|
||||
match observation.last_action.as_str() {
|
||||
"remove" => 0,
|
||||
"add" | "old" | "update" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn observation_is_offline(observation: &AgentDeviceObservation) -> bool {
|
||||
observation.last_action == "remove"
|
||||
}
|
||||
|
||||
fn rank_presence(rank: u8) -> &'static str {
|
||||
match rank {
|
||||
3 => "online",
|
||||
2 => "likely_online",
|
||||
0 => "offline",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn presence_rank(presence: &str) -> u8 {
|
||||
match presence {
|
||||
"online" => 3,
|
||||
"likely_online" => 2,
|
||||
"offline" => 0,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn route_id(agent_id: &str, mac: Option<&str>, ip: Option<&str>, source: &str) -> String {
|
||||
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>, source: &str) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
agent_id,
|
||||
source,
|
||||
mac.unwrap_or(""),
|
||||
ip.unwrap_or("")
|
||||
mac.map(|m| m.to_string()).unwrap_or_default(),
|
||||
ip.map(|i| i.to_string()).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::state::AgentDeviceObservationInput;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryEnvelope {
|
||||
kind: String,
|
||||
devices: Vec<InventoryDevice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryDevice {
|
||||
#[serde(default)]
|
||||
names: Vec<String>,
|
||||
#[serde(default)]
|
||||
ips: Vec<IpAddr>,
|
||||
#[serde(default)]
|
||||
macs: Vec<String>,
|
||||
#[serde(default)]
|
||||
neighbors: Vec<InventoryNeighbor>,
|
||||
#[serde(default)]
|
||||
leases: Vec<InventoryLease>,
|
||||
#[serde(default)]
|
||||
observations: Vec<InventoryObservationFact>,
|
||||
#[serde(default)]
|
||||
presence: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryNeighbor {
|
||||
ip: IpAddr,
|
||||
#[serde(default)]
|
||||
mac: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryLease {
|
||||
ip: IpAddr,
|
||||
mac: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InventoryObservationFact {
|
||||
kind: String,
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
mac: Option<String>,
|
||||
#[serde(default)]
|
||||
ip: Option<IpAddr>,
|
||||
#[serde(default)]
|
||||
hostname: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn inventory_result_to_observations(
|
||||
result: serde_json::Value,
|
||||
) -> anyhow::Result<Vec<AgentDeviceObservationInput>> {
|
||||
let envelope: InventoryEnvelope = serde_json::from_value(result)?;
|
||||
if envelope.kind != "inventory" {
|
||||
anyhow::bail!("expected inventory result, got {}", envelope.kind);
|
||||
}
|
||||
|
||||
let now = now_unix();
|
||||
let mut out = Vec::new();
|
||||
for device in envelope.devices {
|
||||
let hostname = device
|
||||
.names
|
||||
.iter()
|
||||
.find(|name| !name.trim().is_empty())
|
||||
.cloned();
|
||||
let mut wrote_source_observation = false;
|
||||
for neighbor in &device.neighbors {
|
||||
if neighbor.mac.is_none() && device.macs.is_empty() {
|
||||
out.push(inventory_observation(
|
||||
inventory_action_for_neighbor(neighbor),
|
||||
None,
|
||||
Some(neighbor.ip.to_string()),
|
||||
hostname.clone(),
|
||||
now,
|
||||
));
|
||||
wrote_source_observation = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let macs = neighbor
|
||||
.mac
|
||||
.iter()
|
||||
.chain(device.macs.iter())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for mac in macs {
|
||||
out.push(inventory_observation(
|
||||
inventory_action_for_neighbor(neighbor),
|
||||
Some(mac.clone()),
|
||||
Some(neighbor.ip.to_string()),
|
||||
hostname.clone(),
|
||||
now,
|
||||
));
|
||||
wrote_source_observation = true;
|
||||
}
|
||||
}
|
||||
for observation in &device.observations {
|
||||
if observation.mac.is_none() && observation.ip.is_none() {
|
||||
continue;
|
||||
}
|
||||
out.push(inventory_observation(
|
||||
inventory_action_for_observation(observation),
|
||||
observation.mac.clone(),
|
||||
observation.ip.map(|ip| ip.to_string()),
|
||||
observation.hostname.clone().or_else(|| hostname.clone()),
|
||||
now,
|
||||
));
|
||||
wrote_source_observation = true;
|
||||
}
|
||||
for lease in &device.leases {
|
||||
out.push(inventory_observation(
|
||||
"update",
|
||||
Some(lease.mac.clone()),
|
||||
Some(lease.ip.to_string()),
|
||||
lease.name.clone().or_else(|| hostname.clone()),
|
||||
now,
|
||||
));
|
||||
wrote_source_observation = true;
|
||||
}
|
||||
if wrote_source_observation {
|
||||
continue;
|
||||
}
|
||||
|
||||
let action = if device.presence == "offline" {
|
||||
"remove"
|
||||
} else {
|
||||
"update"
|
||||
};
|
||||
if !device.macs.is_empty() {
|
||||
for mac in device.macs {
|
||||
out.push(inventory_observation(
|
||||
action,
|
||||
Some(mac),
|
||||
device.ips.first().map(ToString::to_string),
|
||||
hostname.clone(),
|
||||
now,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
for ip in &device.ips {
|
||||
out.push(inventory_observation(
|
||||
action,
|
||||
None,
|
||||
Some(ip.to_string()),
|
||||
hostname.clone(),
|
||||
now,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn inventory_observation(
|
||||
action: &str,
|
||||
mac: Option<String>,
|
||||
ip: Option<String>,
|
||||
hostname: Option<String>,
|
||||
now: u64,
|
||||
) -> AgentDeviceObservationInput {
|
||||
AgentDeviceObservationInput {
|
||||
kind: "inventory".into(),
|
||||
action: action.into(),
|
||||
mac,
|
||||
ip,
|
||||
hostname,
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn inventory_action_for_neighbor(neighbor: &InventoryNeighbor) -> &'static str {
|
||||
if neighbor
|
||||
.state
|
||||
.as_deref()
|
||||
.is_some_and(|state| state.eq_ignore_ascii_case("FAILED"))
|
||||
{
|
||||
"remove"
|
||||
} else {
|
||||
"update"
|
||||
}
|
||||
}
|
||||
|
||||
fn inventory_action_for_observation(observation: &InventoryObservationFact) -> &str {
|
||||
match observation.action.as_str() {
|
||||
"add" | "old" | "update" => "update",
|
||||
"remove" | "del" => "remove",
|
||||
_ if observation.kind == "neigh" => "update",
|
||||
_ => "update",
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -12,7 +12,6 @@ use crate::api::json_error;
|
||||
use crate::runtime::AppState;
|
||||
|
||||
mod build;
|
||||
mod inventory;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -22,7 +21,6 @@ use build::{
|
||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||
known_device_summary,
|
||||
};
|
||||
use inventory::inventory_result_to_observations;
|
||||
use types::{
|
||||
FleetDevice, ListFleetDevicesQuery, RefreshFleetAgentResult, RefreshFleetDevicesRequest,
|
||||
RefreshFleetDevicesResponse, WakeFleetDeviceRequest, WakeFleetDeviceResponse,
|
||||
@@ -87,28 +85,35 @@ pub async fn refresh_fleet_devices(
|
||||
});
|
||||
continue;
|
||||
};
|
||||
match inventory_result_to_observations(result) {
|
||||
Ok(observations) => match state
|
||||
.store
|
||||
.upsert_agent_observations_snapshot(&agent_id, "inventory", observations)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
total_accepted = total_accepted.saturating_add(accepted);
|
||||
results.push(RefreshFleetAgentResult {
|
||||
match serde_json::from_value::<Vec<wakey_core::Device>>(
|
||||
result
|
||||
.get("devices")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Array(vec![])),
|
||||
) {
|
||||
Ok(devices) => {
|
||||
match state
|
||||
.store
|
||||
.replace_agent_device_snapshot(&agent_id, &devices)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
total_accepted = total_accepted.saturating_add(accepted);
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
});
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
},
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
@@ -191,7 +196,7 @@ pub async fn wake_fleet_device(
|
||||
)
|
||||
})?;
|
||||
|
||||
let Some(mac) = route.mac.as_deref() else {
|
||||
let Some(mac) = route.mac else {
|
||||
return Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"wake_route_unavailable",
|
||||
@@ -213,33 +218,13 @@ pub async fn wake_fleet_device(
|
||||
));
|
||||
}
|
||||
|
||||
let mac = mac.parse().map_err(|err| {
|
||||
json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_wake_route",
|
||||
&format!("invalid route MAC: {err}"),
|
||||
)
|
||||
})?;
|
||||
let ip = route
|
||||
.ip
|
||||
.as_deref()
|
||||
.map(str::parse)
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_wake_route",
|
||||
&format!("invalid route IP: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let command = relay_agent_command(
|
||||
&state,
|
||||
&route.agent_id,
|
||||
AgentCommand::Wake(WakeRequest {
|
||||
query: None,
|
||||
mac: Some(mac),
|
||||
ip,
|
||||
ip: route.ip,
|
||||
}),
|
||||
req.timeout_ms,
|
||||
)
|
||||
@@ -256,10 +241,7 @@ async fn load_fleet_devices(
|
||||
query: &ListFleetDevicesQuery,
|
||||
) -> anyhow::Result<Vec<FleetDevice>> {
|
||||
let known_devices = state.store.list_known_devices().await?;
|
||||
let observations = state
|
||||
.store
|
||||
.list_agent_observations(None, query.limit.unwrap_or(1000).max(1))
|
||||
.await?;
|
||||
let agent_devices = state.store.list_agent_device_rows().await?;
|
||||
let connected = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<BTreeSet<_>>()
|
||||
@@ -291,7 +273,7 @@ async fn load_fleet_devices(
|
||||
agent_status,
|
||||
identifier_map,
|
||||
};
|
||||
let mut devices = build_fleet_devices(known_devices, observations, &context);
|
||||
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
|
||||
filter_fleet_devices(&mut devices, query);
|
||||
let limit = query.limit.unwrap_or(500).clamp(1, 1000);
|
||||
devices.truncate(limit);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use wakey_core::Presence;
|
||||
|
||||
use super::build::{
|
||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||
known_device_summary,
|
||||
};
|
||||
use super::inventory::inventory_result_to_observations;
|
||||
use super::types::ListFleetDevicesQuery;
|
||||
use crate::state::{AgentDeviceObservation, DeviceIdentifier, KnownDevice};
|
||||
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
||||
|
||||
fn context(connected: &[&str]) -> FleetBuildContext {
|
||||
FleetBuildContext {
|
||||
@@ -26,27 +29,83 @@ fn context(connected: &[&str]) -> FleetBuildContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(
|
||||
fn agent_device(
|
||||
agent_id: &str,
|
||||
device_key: &str,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
last_seen_unix: u64,
|
||||
) -> AgentDeviceObservation {
|
||||
AgentDeviceObservation {
|
||||
observation_key: format!(
|
||||
"agent:{agent_id}:dhcp:{}",
|
||||
mac.map(|mac| format!("mac:{mac}"))
|
||||
.or_else(|| ip.map(|ip| format!("ip:{ip}")))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
agent_id: agent_id.into(),
|
||||
kind: "dhcp".into(),
|
||||
mac: mac.map(str::to_string),
|
||||
ip: ip.map(str::to_string),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix,
|
||||
last_action: "update".into(),
|
||||
last_seen_unix: i64,
|
||||
) -> AgentDeviceWithChildren {
|
||||
AgentDeviceWithChildren {
|
||||
device: AgentDeviceRow {
|
||||
agent_id: agent_id.into(),
|
||||
device_key: device_key.into(),
|
||||
presence: "likely_online".into(),
|
||||
display_name: Some("lda".into()),
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix,
|
||||
},
|
||||
macs: mac
|
||||
.map(|m| m.parse::<MacAddr>().unwrap())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
ips: ip
|
||||
.map(|i| i.parse::<IpAddr>().unwrap())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hostnames: vec!["lda".to_string()],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn offline_agent_device(
|
||||
agent_id: &str,
|
||||
device_key: &str,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
last_seen_unix: i64,
|
||||
) -> AgentDeviceWithChildren {
|
||||
AgentDeviceWithChildren {
|
||||
device: AgentDeviceRow {
|
||||
agent_id: agent_id.into(),
|
||||
device_key: device_key.into(),
|
||||
presence: "offline".into(),
|
||||
display_name: Some("lda".into()),
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix,
|
||||
},
|
||||
macs: mac
|
||||
.map(|m| m.parse::<MacAddr>().unwrap())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
ips: ip
|
||||
.map(|i| i.parse::<IpAddr>().unwrap())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hostnames: vec!["lda".to_string()],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn offline_ip_only_unknown(
|
||||
agent_id: &str,
|
||||
device_key: &str,
|
||||
ip: &str,
|
||||
last_seen_unix: i64,
|
||||
) -> AgentDeviceWithChildren {
|
||||
AgentDeviceWithChildren {
|
||||
device: AgentDeviceRow {
|
||||
agent_id: agent_id.into(),
|
||||
device_key: device_key.into(),
|
||||
presence: "offline".into(),
|
||||
display_name: None,
|
||||
first_seen_unix: 1,
|
||||
last_seen_unix,
|
||||
},
|
||||
macs: vec![],
|
||||
ips: vec![ip.parse().unwrap()],
|
||||
hostnames: vec![],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +114,16 @@ fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
let devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![
|
||||
observation(
|
||||
agent_device(
|
||||
"agent-a",
|
||||
"mac:aa:bb:cc:dd:ee:ff",
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
Some("192.168.1.2"),
|
||||
10,
|
||||
),
|
||||
observation(
|
||||
agent_device(
|
||||
"agent-b",
|
||||
"mac:aa:bb:cc:dd:ee:ff",
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
Some("192.168.2.2"),
|
||||
20,
|
||||
@@ -71,8 +132,9 @@ fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
&context(&["agent-a", "agent-b"]),
|
||||
);
|
||||
|
||||
let expected_mac: MacAddr = "aa:bb:cc:dd:ee:ff".parse().unwrap();
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].macs, vec!["aa:bb:cc:dd:ee:ff"]);
|
||||
assert_eq!(devices[0].macs, vec![expected_mac]);
|
||||
assert_eq!(devices[0].agents.len(), 2);
|
||||
assert_eq!(
|
||||
devices[0]
|
||||
@@ -84,7 +146,7 @@ fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_device_with_two_macs_absorbs_both_observation_groups() {
|
||||
fn known_device_with_two_macs_absorbs_both_device_groups() {
|
||||
let known = KnownDevice {
|
||||
device_id: "dev-1".into(),
|
||||
display_name: "lda".into(),
|
||||
@@ -120,8 +182,20 @@ fn known_device_with_two_macs_absorbs_both_observation_groups() {
|
||||
let devices = build_fleet_devices(
|
||||
vec![known],
|
||||
vec![
|
||||
observation("agent-a", Some("aa:bb:cc:dd:ee:01"), None, 10),
|
||||
observation("agent-a", Some("aa:bb:cc:dd:ee:02"), None, 20),
|
||||
agent_device(
|
||||
"agent-a",
|
||||
"mac:aa:bb:cc:dd:ee:01",
|
||||
Some("aa:bb:cc:dd:ee:01"),
|
||||
None,
|
||||
10,
|
||||
),
|
||||
agent_device(
|
||||
"agent-a",
|
||||
"mac:aa:bb:cc:dd:ee:02",
|
||||
Some("aa:bb:cc:dd:ee:02"),
|
||||
None,
|
||||
20,
|
||||
),
|
||||
],
|
||||
&ctx,
|
||||
);
|
||||
@@ -135,44 +209,54 @@ fn known_device_with_two_macs_absorbs_both_observation_groups() {
|
||||
fn ip_only_unknown_is_visible_but_not_wakeable() {
|
||||
let devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![observation("agent-a", None, Some("192.168.1.2"), 10)],
|
||||
vec![agent_device(
|
||||
"agent-a",
|
||||
"ip:192.168.1.2",
|
||||
None,
|
||||
Some("192.168.1.2"),
|
||||
10,
|
||||
)],
|
||||
&context(&["agent-a"]),
|
||||
);
|
||||
|
||||
let expected_ip: IpAddr = "192.168.1.2".parse().unwrap();
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].ips, vec![expected_ip]);
|
||||
assert!(devices[0].recommended_route.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_device_has_offline_presence() {
|
||||
let devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![offline_agent_device(
|
||||
"agent-a",
|
||||
"mac:aa:bb:cc:dd:ee:ff",
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
Some("192.168.1.2"),
|
||||
20,
|
||||
)],
|
||||
&context(&["agent-a"]),
|
||||
);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].ips, vec!["192.168.1.2"]);
|
||||
assert!(devices[0].recommended_route.is_none());
|
||||
assert!(!devices[0].route_candidates[0].wakeable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_observation_does_not_advertise_current_ip_or_wake_route() {
|
||||
let mut offline = observation(
|
||||
"agent-a",
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
Some("192.168.1.2"),
|
||||
20,
|
||||
);
|
||||
offline.kind = "neigh".into();
|
||||
offline.last_action = "remove".into();
|
||||
|
||||
let devices = build_fleet_devices(Vec::new(), vec![offline], &context(&["agent-a"]));
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert!(devices[0].ips.is_empty());
|
||||
assert_eq!(devices[0].presence, "offline");
|
||||
assert_eq!(devices[0].presence, Presence::Offline);
|
||||
assert!(devices[0].recommended_route.is_none());
|
||||
assert!(!devices[0].route_candidates[0].wakeable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_ip_only_remove_observation_is_hidden() {
|
||||
let mut offline = observation("agent-a", None, Some("192.168.1.2"), 20);
|
||||
offline.kind = "neigh".into();
|
||||
offline.hostname = None;
|
||||
offline.last_action = "remove".into();
|
||||
|
||||
let mut devices = build_fleet_devices(Vec::new(), vec![offline], &context(&["agent-a"]));
|
||||
fn unknown_ip_only_offline_device_is_hidden_by_default() {
|
||||
let mut devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![offline_ip_only_unknown(
|
||||
"agent-a",
|
||||
"ip:192.168.1.2",
|
||||
"192.168.1.2",
|
||||
20,
|
||||
)],
|
||||
&context(&["agent-a"]),
|
||||
);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].display_name, "(unknown device)");
|
||||
@@ -181,13 +265,17 @@ fn unknown_ip_only_remove_observation_is_hidden() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_all_keeps_unknown_ip_only_remove_observation() {
|
||||
let mut offline = observation("agent-a", None, Some("192.168.1.2"), 20);
|
||||
offline.kind = "neigh".into();
|
||||
offline.hostname = None;
|
||||
offline.last_action = "remove".into();
|
||||
|
||||
let mut devices = build_fleet_devices(Vec::new(), vec![offline], &context(&["agent-a"]));
|
||||
fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
||||
let mut devices = build_fleet_devices(
|
||||
Vec::new(),
|
||||
vec![offline_ip_only_unknown(
|
||||
"agent-a",
|
||||
"ip:192.168.1.2",
|
||||
"192.168.1.2",
|
||||
20,
|
||||
)],
|
||||
&context(&["agent-a"]),
|
||||
);
|
||||
|
||||
filter_fleet_devices(
|
||||
&mut devices,
|
||||
@@ -201,7 +289,7 @@ fn visibility_all_keeps_unknown_ip_only_remove_observation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_ip_only_remove_observation_is_kept() {
|
||||
fn known_ip_only_offline_device_is_kept() {
|
||||
let known = KnownDevice {
|
||||
device_id: "dev-1".into(),
|
||||
display_name: "lda".into(),
|
||||
@@ -220,114 +308,22 @@ fn known_ip_only_remove_observation_is_kept() {
|
||||
let mut ctx = context(&["agent-a"]);
|
||||
ctx.identifier_map
|
||||
.insert("ip:192.168.1.2".into(), known_device_summary(&known));
|
||||
let mut offline = observation("agent-a", None, Some("192.168.1.2"), 20);
|
||||
offline.kind = "neigh".into();
|
||||
offline.hostname = None;
|
||||
offline.last_action = "remove".into();
|
||||
|
||||
let devices = build_fleet_devices(vec![known], vec![offline], &ctx);
|
||||
let devices = build_fleet_devices(
|
||||
vec![known],
|
||||
vec![offline_agent_device(
|
||||
"agent-a",
|
||||
"ip:192.168.1.2",
|
||||
None,
|
||||
Some("192.168.1.2"),
|
||||
20,
|
||||
)],
|
||||
&ctx,
|
||||
);
|
||||
|
||||
let expected_ip: IpAddr = "192.168.1.2".parse().unwrap();
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].device_key, "known:dev-1");
|
||||
assert_eq!(devices[0].display_name, "lda");
|
||||
assert!(devices[0].ips.contains(&"192.168.1.2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_result_maps_to_stored_observations() {
|
||||
let observations = inventory_result_to_observations(serde_json::json!({
|
||||
"kind": "inventory",
|
||||
"devices": [{
|
||||
"names": ["lda"],
|
||||
"ips": ["192.168.1.2"],
|
||||
"macs": ["aa:bb:cc:dd:ee:ff"],
|
||||
"presence": "likely_online"
|
||||
}]
|
||||
}))
|
||||
.expect("inventory should map");
|
||||
|
||||
assert_eq!(observations.len(), 1);
|
||||
assert_eq!(observations[0].kind, "inventory");
|
||||
assert_eq!(observations[0].action, "update");
|
||||
assert_eq!(observations[0].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_result_preserves_neighbor_failed_ip_as_remove() {
|
||||
let observations = inventory_result_to_observations(serde_json::json!({
|
||||
"kind": "inventory",
|
||||
"devices": [{
|
||||
"names": ["lda"],
|
||||
"ips": ["192.168.1.2", "192.168.1.3"],
|
||||
"macs": ["aa:bb:cc:dd:ee:ff"],
|
||||
"neighbors": [
|
||||
{
|
||||
"ip": "192.168.1.2",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"state": "FAILED"
|
||||
},
|
||||
{
|
||||
"ip": "192.168.1.3",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"state": "REACHABLE"
|
||||
}
|
||||
],
|
||||
"presence": "online"
|
||||
}]
|
||||
}))
|
||||
.expect("inventory should map");
|
||||
|
||||
assert_eq!(observations.len(), 2);
|
||||
let removed = observations
|
||||
.iter()
|
||||
.find(|observation| observation.ip.as_deref() == Some("192.168.1.2"))
|
||||
.expect("failed neighbor observation should exist");
|
||||
assert_eq!(removed.action, "remove");
|
||||
assert_eq!(removed.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
|
||||
let current = observations
|
||||
.iter()
|
||||
.find(|observation| observation.ip.as_deref() == Some("192.168.1.3"))
|
||||
.expect("reachable neighbor observation should exist");
|
||||
assert_eq!(current.action, "update");
|
||||
assert_eq!(current.mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_result_preserves_hook_observations_and_leases() {
|
||||
let observations = inventory_result_to_observations(serde_json::json!({
|
||||
"kind": "inventory",
|
||||
"devices": [{
|
||||
"names": ["lda"],
|
||||
"ips": ["192.168.1.2", "192.168.1.3"],
|
||||
"macs": ["aa:bb:cc:dd:ee:ff"],
|
||||
"observations": [{
|
||||
"kind": "neigh",
|
||||
"action": "remove",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"ip": "192.168.1.2"
|
||||
}],
|
||||
"leases": [{
|
||||
"expires_epoch": 1893456000_u64,
|
||||
"ip": "192.168.1.3",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"name": "lda"
|
||||
}],
|
||||
"presence": "likely_online"
|
||||
}]
|
||||
}))
|
||||
.expect("inventory should map");
|
||||
|
||||
assert_eq!(observations.len(), 2);
|
||||
assert!(observations.iter().any(|observation| {
|
||||
observation.action == "remove"
|
||||
&& observation.mac.as_deref() == Some("aa:bb:cc:dd:ee:ff")
|
||||
&& observation.ip.as_deref() == Some("192.168.1.2")
|
||||
}));
|
||||
assert!(observations.iter().any(|observation| {
|
||||
observation.action == "update"
|
||||
&& observation.mac.as_deref() == Some("aa:bb:cc:dd:ee:ff")
|
||||
&& observation.ip.as_deref() == Some("192.168.1.3")
|
||||
&& observation.hostname.as_deref() == Some("lda")
|
||||
}));
|
||||
assert!(devices[0].ips.contains(&expected_ip));
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use wakey_core::Presence;
|
||||
|
||||
use crate::api::commands::RelayCommandResponse;
|
||||
use crate::state::KnownDeviceSummary;
|
||||
use wakey_core::parse::mac;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ListFleetDevicesQuery {
|
||||
@@ -53,14 +58,15 @@ pub struct FleetDevice {
|
||||
pub display_name: String,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
pub pinned: bool,
|
||||
pub ips: Vec<String>,
|
||||
pub macs: Vec<String>,
|
||||
pub ips: Vec<IpAddr>,
|
||||
#[serde(with = "mac::vec_mac")]
|
||||
pub macs: Vec<MacAddr>,
|
||||
pub hostnames: Vec<String>,
|
||||
pub agents: Vec<FleetDeviceAgent>,
|
||||
pub sources: Vec<String>,
|
||||
pub first_seen_unix: Option<u64>,
|
||||
pub last_seen_unix: Option<u64>,
|
||||
pub presence: String,
|
||||
pub presence: Presence,
|
||||
pub route_candidates: Vec<FleetWakeRoute>,
|
||||
pub recommended_route: Option<FleetWakeRoute>,
|
||||
}
|
||||
@@ -79,8 +85,9 @@ pub struct FleetWakeRoute {
|
||||
pub agent_id: String,
|
||||
pub nickname: Option<String>,
|
||||
pub connected: bool,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
#[serde(with = "mac::option_mac")]
|
||||
pub mac: Option<MacAddr>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub source: String,
|
||||
pub last_seen_unix: u64,
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::ServerMessage;
|
||||
|
||||
use crate::api::json_error;
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadAgentObservationsRequest {
|
||||
pub agent_id: String,
|
||||
pub agent_token: String,
|
||||
#[serde(default)]
|
||||
pub observations: Vec<AgentObservationRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentObservationRequest {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UploadAgentObservationsResponse {
|
||||
pub accepted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestAgentObservationSyncResponse {
|
||||
pub agent_id: String,
|
||||
pub requested: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObservationsQuery {
|
||||
pub agent_id: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObservationHistoryQuery {
|
||||
pub agent_id: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub observation_key: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn upload_agent_observations(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UploadAgentObservationsRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
if !state
|
||||
.store
|
||||
.verify_agent_token(&req.agent_id, &req.agent_token)
|
||||
.await
|
||||
{
|
||||
return Err(json_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"agent_auth_rejected",
|
||||
"agent credentials rejected",
|
||||
));
|
||||
}
|
||||
|
||||
let mut by_kind: BTreeMap<String, Vec<AgentDeviceObservationInput>> = BTreeMap::new();
|
||||
for observation in req.observations {
|
||||
let kind = observation.kind.trim().to_ascii_lowercase();
|
||||
let entry = by_kind.entry(kind.clone()).or_default();
|
||||
entry.push(AgentDeviceObservationInput {
|
||||
kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
});
|
||||
}
|
||||
|
||||
let mut accepted = 0usize;
|
||||
for (kind, observations) in by_kind {
|
||||
match state
|
||||
.store
|
||||
.upsert_agent_observations_snapshot(&req.agent_id, &kind, observations)
|
||||
.await
|
||||
{
|
||||
Ok(written) => {
|
||||
accepted = accepted.saturating_add(written);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, agent_id = %req.agent_id, kind = %kind, "failed to upload agent observations");
|
||||
return Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"upload_observations_failed",
|
||||
&err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(UploadAgentObservationsResponse { accepted }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_agent_observations(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListObservationsQuery>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
|
||||
.await
|
||||
{
|
||||
Ok(observations) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
observations
|
||||
.into_iter()
|
||||
.map(agent_observation_response)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agent observations");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_observations_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_agent_observation_sync(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(agent_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let tx = {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.get(&agent_id).map(|session| session.tx.clone())
|
||||
};
|
||||
let Some(tx) = tx else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(RequestAgentObservationSyncResponse {
|
||||
agent_id,
|
||||
requested: false,
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
match tx.send(SessionEvent::Message(ServerMessage::SyncObservations)) {
|
||||
Ok(()) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(RequestAgentObservationSyncResponse {
|
||||
agent_id,
|
||||
requested: true,
|
||||
}),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to request agent observation sync");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_observation_sync_request_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListObservationHistoryQuery>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let kind = normalized_query_value(query.kind).map(|value| value.to_ascii_lowercase());
|
||||
let mac = normalized_query_value(query.mac).map(|value| value.to_ascii_lowercase());
|
||||
let ip = normalized_query_value(query.ip);
|
||||
let observation_key = normalized_query_value(query.observation_key);
|
||||
match state
|
||||
.store
|
||||
.list_agent_observation_events(
|
||||
query.agent_id.as_deref(),
|
||||
kind.as_deref(),
|
||||
mac.as_deref(),
|
||||
ip.as_deref(),
|
||||
observation_key.as_deref(),
|
||||
query.limit.unwrap_or(500),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
events
|
||||
.into_iter()
|
||||
.map(agent_observation_event_response)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agent observation history");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_observation_history_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_observation_response(
|
||||
observation: AgentDeviceObservationView,
|
||||
) -> AgentDeviceObservationView {
|
||||
observation
|
||||
}
|
||||
|
||||
fn agent_observation_event_response(
|
||||
event: AgentDeviceObservationEvent,
|
||||
) -> AgentDeviceObservationEvent {
|
||||
event
|
||||
}
|
||||
|
||||
fn normalized_query_value(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
Reference in new issue
Block a user