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:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 8
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "06c45563892b3d688153985aec411f588e80b5f17731f1fce97f9494a1155ffb"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 8
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_device_observations\n WHERE agent_id = ?1\n AND kind = ?2\n AND observation_key NOT IN (\n SELECT observation_key\n FROM agent_observation_snapshot_keys\n WHERE agent_id = ?1 AND kind = ?2\n )",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "273bcf6b604fbb48cad93630a1485590b1039149d1dd856db674dcc5584947d0"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_observation_snapshot_keys WHERE agent_id = ?1 AND kind = ?2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "29f24b83cf513758b6a546e86be8aa7ce4b1f66d799aabacaeac02ff84996d24"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 9
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "52d22161e936b39b57d119aab592426e88f60387ccba58888cc7d440012777ed"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_observation_snapshots\n (agent_id, kind, last_dump_unix)\n VALUES (?1, ?2, ?3)\n ON CONFLICT(agent_id, kind) DO UPDATE SET\n last_dump_unix = excluded.last_dump_unix",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8e7964dee0a2c21d377923d83df23454a104b68fc08f334c6e576bee273bc73b"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 9
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a47ba9ef887f67a7715bd919871ddd76f4ee09aa6ac0ee2f36d32c27d8a756d4"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_observation_snapshot_keys (agent_id, kind, observation_key)\n VALUES (?1, ?2, ?3)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "aebbe573b428c0aa5ea79d493868c8d9ba06d7a919d955d4c3b96c5fd4b96598"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_device_observations WHERE last_seen_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cf1c3bedcc9538d7af97e3e59b705b54dd95e7f219c2a8097187eb386d051e83"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_device_observation_events WHERE ts_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "edfcbcd3d530f59971e3e1c7c293321440dd9702ba6d6ac264fc9cf3d7cc7b5a"
|
||||||
|
}
|
||||||
+3
-1
@@ -11,7 +11,9 @@ pub use service::{
|
|||||||
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
|
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
|
||||||
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
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};
|
pub use wakey_linux::observations::{
|
||||||
|
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
|||||||
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
||||||
let leases = get_leases().await?;
|
let leases = get_leases().await?;
|
||||||
let observations = match wakey_linux::dhcp::list_local_observations().await {
|
let observations = match wakey_linux::observations::list_local_observations().await {
|
||||||
Ok(observations) => observations
|
Ok(observations) => observations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(local_observation_to_fact)
|
.filter_map(local_observation_to_fact)
|
||||||
@@ -132,7 +132,7 @@ pub fn merge_devices_with_observations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_observation_to_fact(
|
pub fn local_observation_to_fact(
|
||||||
observation: wakey_linux::dhcp::LocalDeviceObservation,
|
observation: wakey_linux::observations::LocalDeviceObservation,
|
||||||
) -> Option<DeviceObservationFact> {
|
) -> Option<DeviceObservationFact> {
|
||||||
let mac = match observation.mac.as_deref() {
|
let mac = match observation.mac.as_deref() {
|
||||||
Some(raw) => match raw.parse() {
|
Some(raw) => match raw.parse() {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
|||||||
&config.mac_name_cache_path,
|
&config.mac_name_cache_path,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let observations = match wakey::wakey_linux::dhcp::list_local_observations_from_path(
|
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||||
&config.observation_store_path,
|
&config.observation_store_path,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig)
|
|||||||
.unwrap_or_else(|_| "<unreadable error body>".to_string());
|
.unwrap_or_else(|_| "<unreadable error body>".to_string());
|
||||||
anyhow::bail!("observation upload failed with {status}: {body}");
|
anyhow::bail!("observation upload failed with {status}: {body}");
|
||||||
}
|
}
|
||||||
|
if let Err(err) = wakey::wakey_linux::observations::prune_removed_observations_from_path(
|
||||||
|
&config.observation_store_path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(agent_id = %config.agent_id, error = %err, "failed to prune removed observations after upload");
|
||||||
|
}
|
||||||
debug!(
|
debug!(
|
||||||
agent_id = %config.agent_id,
|
agent_id = %config.agent_id,
|
||||||
observations = payload.observations.len(),
|
observations = payload.observations.len(),
|
||||||
@@ -211,15 +218,23 @@ where
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if let Err(err) = wakey::wakey_linux::observations::prune_removed_observations_from_path(
|
||||||
|
&config.observation_store_path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(agent_id = %config.agent_id, error = %err, "failed to prune removed observations after websocket send");
|
||||||
|
}
|
||||||
debug!(agent_id = %config.agent_id, observations = count, "sent observations over websocket");
|
debug!(agent_id = %config.agent_id, observations = count, "sent observations over websocket");
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_agent_observations(config: &AgentConfig) -> Result<Vec<AgentObservation>> {
|
async fn load_agent_observations(config: &AgentConfig) -> Result<Vec<AgentObservation>> {
|
||||||
let observations =
|
let observations = wakey::wakey_linux::observations::list_local_observations_from_path(
|
||||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
&config.observation_store_path,
|
||||||
.await
|
)
|
||||||
.context("failed to read local observations")?;
|
.await
|
||||||
|
.context("failed to read local observations")?;
|
||||||
Ok(observations
|
Ok(observations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|observation| AgentObservation {
|
.map(|observation| AgentObservation {
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 8
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "06c45563892b3d688153985aec411f588e80b5f17731f1fce97f9494a1155ffb"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 8
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_device_observations\n WHERE agent_id = ?1\n AND kind = ?2\n AND observation_key NOT IN (\n SELECT observation_key\n FROM agent_observation_snapshot_keys\n WHERE agent_id = ?1 AND kind = ?2\n )",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "273bcf6b604fbb48cad93630a1485590b1039149d1dd856db674dcc5584947d0"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_observation_snapshot_keys WHERE agent_id = ?1 AND kind = ?2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "29f24b83cf513758b6a546e86be8aa7ce4b1f66d799aabacaeac02ff84996d24"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 9
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "52d22161e936b39b57d119aab592426e88f60387ccba58888cc7d440012777ed"
|
|
||||||
}
|
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_observation_snapshots\n (agent_id, kind, last_dump_unix)\n VALUES (?1, ?2, ?3)\n ON CONFLICT(agent_id, kind) DO UPDATE SET\n last_dump_unix = excluded.last_dump_unix",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8e7964dee0a2c21d377923d83df23454a104b68fc08f334c6e576bee273bc73b"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 9
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a47ba9ef887f67a7715bd919871ddd76f4ee09aa6ac0ee2f36d32c27d8a756d4"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO agent_observation_snapshot_keys (agent_id, kind, observation_key)\n VALUES (?1, ?2, ?3)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "aebbe573b428c0aa5ea79d493868c8d9ba06d7a919d955d4c3b96c5fd4b96598"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_device_observations WHERE last_seen_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cf1c3bedcc9538d7af97e3e59b705b54dd95e7f219c2a8097187eb386d051e83"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_device_observation_events WHERE ts_unix < ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "edfcbcd3d530f59971e3e1c7c293321440dd9702ba6d6ac264fc9cf3d7cc7b5a"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE agent_observation_snapshots (
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
last_dump_unix INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(agent_id, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE agent_observation_snapshot_keys (
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
observation_key TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(agent_id, kind, observation_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX agent_observation_snapshot_keys_observation_idx
|
||||||
|
ON agent_observation_snapshot_keys(observation_key);
|
||||||
@@ -90,7 +90,7 @@ pub async fn refresh_fleet_devices(
|
|||||||
match inventory_result_to_observations(result) {
|
match inventory_result_to_observations(result) {
|
||||||
Ok(observations) => match state
|
Ok(observations) => match state
|
||||||
.store
|
.store
|
||||||
.upsert_agent_observations(&agent_id, observations)
|
.upsert_agent_observations_snapshot(&agent_id, "inventory", observations)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(accepted) => {
|
Ok(accepted) => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
@@ -74,38 +76,46 @@ pub async fn upload_agent_observations(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let observations = req
|
let mut by_kind: BTreeMap<String, Vec<AgentDeviceObservationInput>> = BTreeMap::new();
|
||||||
.observations
|
for observation in req.observations {
|
||||||
.into_iter()
|
let kind = observation.kind.trim().to_ascii_lowercase();
|
||||||
.map(|observation| AgentDeviceObservationInput {
|
let entry = by_kind.entry(kind.clone()).or_default();
|
||||||
kind: observation.kind,
|
entry.push(AgentDeviceObservationInput {
|
||||||
|
kind,
|
||||||
action: observation.action,
|
action: observation.action,
|
||||||
mac: observation.mac,
|
mac: observation.mac,
|
||||||
ip: observation.ip,
|
ip: observation.ip,
|
||||||
hostname: observation.hostname,
|
hostname: observation.hostname,
|
||||||
first_seen_unix: observation.first_seen_unix,
|
first_seen_unix: observation.first_seen_unix,
|
||||||
last_seen_unix: observation.last_seen_unix,
|
last_seen_unix: observation.last_seen_unix,
|
||||||
})
|
});
|
||||||
.collect();
|
}
|
||||||
|
|
||||||
match state
|
let mut accepted = 0usize;
|
||||||
.store
|
for (kind, observations) in by_kind {
|
||||||
.upsert_agent_observations(&req.agent_id, observations)
|
match state
|
||||||
.await
|
.store
|
||||||
{
|
.upsert_agent_observations_snapshot(&req.agent_id, &kind, observations)
|
||||||
Ok(accepted) => Ok((
|
.await
|
||||||
StatusCode::OK,
|
{
|
||||||
Json(UploadAgentObservationsResponse { accepted }),
|
Ok(written) => {
|
||||||
)),
|
accepted = accepted.saturating_add(written);
|
||||||
Err(err) => {
|
}
|
||||||
warn!(error = %err, agent_id = %req.agent_id, "failed to upload agent observations");
|
Err(err) => {
|
||||||
Err(json_error(
|
warn!(error = %err, agent_id = %req.agent_id, kind = %kind, "failed to upload agent observations");
|
||||||
StatusCode::BAD_REQUEST,
|
return Err(json_error(
|
||||||
"upload_observations_failed",
|
StatusCode::BAD_REQUEST,
|
||||||
&err.to_string(),
|
"upload_observations_failed",
|
||||||
))
|
&err.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(UploadAgentObservationsResponse { accepted }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_agent_observations(
|
pub async fn list_agent_observations(
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ pub struct ServeArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub enroll_token_ttl_seconds: Option<u64>,
|
pub enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub observation_retention_seconds: Option<u64>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub pid_file: Option<PathBuf>,
|
pub pid_file: Option<PathBuf>,
|
||||||
|
|
||||||
@@ -126,6 +129,9 @@ pub struct InitConfigArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub enroll_token_ttl_seconds: Option<u64>,
|
pub enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
pub observation_retention_seconds: Option<u64>,
|
||||||
|
|
||||||
#[arg(long = "bootstrap-enroll-token", visible_alias = "enroll-token")]
|
#[arg(long = "bootstrap-enroll-token", visible_alias = "enroll-token")]
|
||||||
pub bootstrap_enroll_tokens: Vec<String>,
|
pub bootstrap_enroll_tokens: Vec<String>,
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
|||||||
.or(base.enroll_token_ttl_seconds)
|
.or(base.enroll_token_ttl_seconds)
|
||||||
.unwrap_or(86_400)
|
.unwrap_or(86_400)
|
||||||
.max(1),
|
.max(1),
|
||||||
|
observation_retention_seconds: args
|
||||||
|
.observation_retention_seconds
|
||||||
|
.or(base.observation_retention_seconds)
|
||||||
|
.unwrap_or(2_592_000),
|
||||||
pid_file,
|
pid_file,
|
||||||
ui_dist_dir,
|
ui_dist_dir,
|
||||||
bootstrap_enroll_tokens,
|
bootstrap_enroll_tokens,
|
||||||
@@ -155,6 +159,7 @@ pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
|
|||||||
ui_dist_dir: args.ui_dist_dir.clone(),
|
ui_dist_dir: args.ui_dist_dir.clone(),
|
||||||
command_timeout_ms: args.command_timeout_ms,
|
command_timeout_ms: args.command_timeout_ms,
|
||||||
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds,
|
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds,
|
||||||
|
observation_retention_seconds: args.observation_retention_seconds,
|
||||||
bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(),
|
bootstrap_enroll_tokens: args.bootstrap_enroll_tokens.clone(),
|
||||||
from_config: None,
|
from_config: None,
|
||||||
telemetry_otlp_endpoint: None,
|
telemetry_otlp_endpoint: None,
|
||||||
@@ -219,6 +224,7 @@ json_logs = true
|
|||||||
ui_dist_dir: None,
|
ui_dist_dir: None,
|
||||||
command_timeout_ms: Some(999),
|
command_timeout_ms: Some(999),
|
||||||
enroll_token_ttl_seconds: None,
|
enroll_token_ttl_seconds: None,
|
||||||
|
observation_retention_seconds: None,
|
||||||
bootstrap_enroll_tokens: Vec::new(),
|
bootstrap_enroll_tokens: Vec::new(),
|
||||||
telemetry_otlp_endpoint: None,
|
telemetry_otlp_endpoint: None,
|
||||||
telemetry_service_name: None,
|
telemetry_service_name: None,
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ impl DaemonConfig {
|
|||||||
.max(1),
|
.max(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let observation_retention = Duration::from_secs(
|
||||||
|
args.observation_retention_seconds
|
||||||
|
.or(file.observation_retention_seconds)
|
||||||
|
.unwrap_or(2_592_000),
|
||||||
|
);
|
||||||
|
|
||||||
let pid_file_raw = args
|
let pid_file_raw = args
|
||||||
.pid_file
|
.pid_file
|
||||||
.clone()
|
.clone()
|
||||||
@@ -94,6 +100,7 @@ impl DaemonConfig {
|
|||||||
state_file,
|
state_file,
|
||||||
command_timeout,
|
command_timeout,
|
||||||
enroll_token_ttl,
|
enroll_token_ttl,
|
||||||
|
observation_retention,
|
||||||
pid_file,
|
pid_file,
|
||||||
ui_dist_dir,
|
ui_dist_dir,
|
||||||
bootstrap_enroll_tokens,
|
bootstrap_enroll_tokens,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct DaemonConfig {
|
|||||||
pub state_file: PathBuf,
|
pub state_file: PathBuf,
|
||||||
pub command_timeout: Duration,
|
pub command_timeout: Duration,
|
||||||
pub enroll_token_ttl: Duration,
|
pub enroll_token_ttl: Duration,
|
||||||
|
pub observation_retention: Duration,
|
||||||
pub pid_file: PathBuf,
|
pub pid_file: PathBuf,
|
||||||
pub ui_dist_dir: PathBuf,
|
pub ui_dist_dir: PathBuf,
|
||||||
pub bootstrap_enroll_tokens: Vec<String>,
|
pub bootstrap_enroll_tokens: Vec<String>,
|
||||||
@@ -43,6 +44,7 @@ pub(crate) struct FileConfig {
|
|||||||
pub(crate) state_file: Option<PathBuf>,
|
pub(crate) state_file: Option<PathBuf>,
|
||||||
pub(crate) command_timeout_ms: Option<u64>,
|
pub(crate) command_timeout_ms: Option<u64>,
|
||||||
pub(crate) enroll_token_ttl_seconds: Option<u64>,
|
pub(crate) enroll_token_ttl_seconds: Option<u64>,
|
||||||
|
pub(crate) observation_retention_seconds: Option<u64>,
|
||||||
pub(crate) pid_file: Option<PathBuf>,
|
pub(crate) pid_file: Option<PathBuf>,
|
||||||
pub(crate) ui_dist_dir: Option<PathBuf>,
|
pub(crate) ui_dist_dir: Option<PathBuf>,
|
||||||
#[serde(alias = "enroll_tokens")]
|
#[serde(alias = "enroll_tokens")]
|
||||||
@@ -65,6 +67,7 @@ pub(crate) struct WritableConfig {
|
|||||||
pub(crate) state_file: PathBuf,
|
pub(crate) state_file: PathBuf,
|
||||||
pub(crate) command_timeout_ms: u64,
|
pub(crate) command_timeout_ms: u64,
|
||||||
pub(crate) enroll_token_ttl_seconds: u64,
|
pub(crate) enroll_token_ttl_seconds: u64,
|
||||||
|
pub(crate) observation_retention_seconds: u64,
|
||||||
pub(crate) pid_file: PathBuf,
|
pub(crate) pid_file: PathBuf,
|
||||||
pub(crate) ui_dist_dir: PathBuf,
|
pub(crate) ui_dist_dir: PathBuf,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
|||||||
@@ -226,6 +226,18 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Err(err) => warn!(error = %err, "periodic gc failed"),
|
Err(err) => warn!(error = %err, "periodic gc failed"),
|
||||||
}
|
}
|
||||||
|
match app_state
|
||||||
|
.store
|
||||||
|
.gc_stale_observations(daemon.observation_retention)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(removed) => {
|
||||||
|
if removed > 0 {
|
||||||
|
info!(removed, "periodic gc removed stale observations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => warn!(error = %err, "periodic observation gc failed"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
join = &mut server => {
|
join = &mut server => {
|
||||||
let _ = remove_pid_file(&daemon.pid_file);
|
let _ = remove_pid_file(&daemon.pid_file);
|
||||||
|
|||||||
@@ -533,6 +533,117 @@ mod tests {
|
|||||||
cleanup_dir(&dir);
|
cleanup_dir(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn observation_snapshot_prunes_missing_keys() {
|
||||||
|
let (store, dir) = make_store().await;
|
||||||
|
|
||||||
|
store
|
||||||
|
.upsert_agent_observations(
|
||||||
|
"agent-a",
|
||||||
|
vec![
|
||||||
|
crate::state::AgentDeviceObservationInput {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("AA:BB:CC:DD:EE:01".into()),
|
||||||
|
ip: Some("192.168.1.10".into()),
|
||||||
|
hostname: Some("first".into()),
|
||||||
|
first_seen_unix: 10,
|
||||||
|
last_seen_unix: 20,
|
||||||
|
},
|
||||||
|
crate::state::AgentDeviceObservationInput {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("AA:BB:CC:DD:EE:02".into()),
|
||||||
|
ip: Some("192.168.1.11".into()),
|
||||||
|
hostname: Some("second".into()),
|
||||||
|
first_seen_unix: 10,
|
||||||
|
last_seen_unix: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("initial observations should upsert");
|
||||||
|
|
||||||
|
store
|
||||||
|
.upsert_agent_observations_snapshot(
|
||||||
|
"agent-a",
|
||||||
|
"dhcp",
|
||||||
|
vec![crate::state::AgentDeviceObservationInput {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("AA:BB:CC:DD:EE:01".into()),
|
||||||
|
ip: Some("192.168.1.10".into()),
|
||||||
|
hostname: Some("first".into()),
|
||||||
|
first_seen_unix: 10,
|
||||||
|
last_seen_unix: 30,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("snapshot upsert should succeed");
|
||||||
|
|
||||||
|
let rows = store
|
||||||
|
.list_agent_observations(Some("agent-a"), 10)
|
||||||
|
.await
|
||||||
|
.expect("observations should list");
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].mac.as_deref(), Some("aa:bb:cc:dd:ee:01"));
|
||||||
|
|
||||||
|
cleanup_dir(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn observation_gc_removes_stale_rows() {
|
||||||
|
let (store, dir) = make_store().await;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("time should be monotonic")
|
||||||
|
.as_secs();
|
||||||
|
let old = now.saturating_sub(10);
|
||||||
|
|
||||||
|
store
|
||||||
|
.upsert_agent_observations(
|
||||||
|
"agent-a",
|
||||||
|
vec![
|
||||||
|
crate::state::AgentDeviceObservationInput {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("AA:BB:CC:DD:EE:10".into()),
|
||||||
|
ip: Some("192.168.1.20".into()),
|
||||||
|
hostname: Some("old".into()),
|
||||||
|
first_seen_unix: old,
|
||||||
|
last_seen_unix: old,
|
||||||
|
},
|
||||||
|
crate::state::AgentDeviceObservationInput {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("AA:BB:CC:DD:EE:11".into()),
|
||||||
|
ip: Some("192.168.1.21".into()),
|
||||||
|
hostname: Some("fresh".into()),
|
||||||
|
first_seen_unix: now,
|
||||||
|
last_seen_unix: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("observations should upsert");
|
||||||
|
|
||||||
|
let removed = store
|
||||||
|
.gc_stale_observations(Duration::from_secs(5))
|
||||||
|
.await
|
||||||
|
.expect("gc should succeed");
|
||||||
|
assert!(removed >= 1);
|
||||||
|
|
||||||
|
let rows = store
|
||||||
|
.list_agent_observations(Some("agent-a"), 10)
|
||||||
|
.await
|
||||||
|
.expect("observations should list");
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].hostname.as_deref(), Some("fresh"));
|
||||||
|
|
||||||
|
cleanup_dir(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn agent_observation_views_include_matching_known_device() {
|
async fn agent_observation_views_include_matching_known_device() {
|
||||||
let (store, dir) = make_store().await;
|
let (store, dir) = make_store().await;
|
||||||
|
|||||||
@@ -1,5 +1,134 @@
|
|||||||
|
use std::{collections::BTreeSet, ops::DerefMut};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
async fn upsert_observation_tx(
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
observation: &AgentDeviceObservation,
|
||||||
|
) -> Result<()> {
|
||||||
|
let first_seen_unix = i64::try_from(observation.first_seen_unix)
|
||||||
|
.context("observation first_seen overflow")?;
|
||||||
|
let last_seen_unix = i64::try_from(observation.last_seen_unix)
|
||||||
|
.context("observation last_seen overflow")?;
|
||||||
|
let current = get_observation_current_row(tx, &observation.observation_key)
|
||||||
|
.await
|
||||||
|
.context("failed checking existing observation")?;
|
||||||
|
let append_event = observation_current_changed(
|
||||||
|
current.as_ref(),
|
||||||
|
observation,
|
||||||
|
first_seen_unix,
|
||||||
|
last_seen_unix,
|
||||||
|
);
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agent_device_observations
|
||||||
|
(observation_key, agent_id, kind, mac, ip, hostname,
|
||||||
|
first_seen_unix, last_seen_unix, last_action)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
|
ON CONFLICT(observation_key) DO UPDATE SET
|
||||||
|
mac = excluded.mac,
|
||||||
|
ip = excluded.ip,
|
||||||
|
hostname = excluded.hostname,
|
||||||
|
first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),
|
||||||
|
last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),
|
||||||
|
last_action = excluded.last_action",
|
||||||
|
observation.observation_key,
|
||||||
|
observation.agent_id,
|
||||||
|
observation.kind,
|
||||||
|
observation.mac,
|
||||||
|
observation.ip,
|
||||||
|
observation.hostname,
|
||||||
|
first_seen_unix,
|
||||||
|
last_seen_unix,
|
||||||
|
observation.last_action
|
||||||
|
)
|
||||||
|
.execute(tx.deref_mut())
|
||||||
|
.await
|
||||||
|
.context("failed upserting agent device observation")?;
|
||||||
|
|
||||||
|
if append_event {
|
||||||
|
let event_id = format!("ode-{}", Uuid::new_v4());
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agent_device_observation_events
|
||||||
|
(event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
event_id,
|
||||||
|
observation.agent_id,
|
||||||
|
observation.kind,
|
||||||
|
observation.last_action,
|
||||||
|
observation.mac,
|
||||||
|
observation.ip,
|
||||||
|
observation.hostname,
|
||||||
|
last_seen_unix
|
||||||
|
)
|
||||||
|
.execute(tx.deref_mut())
|
||||||
|
.await
|
||||||
|
.context("failed appending agent device observation event")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_observation_snapshot(
|
||||||
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
|
agent_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
snapshot_unix: u64,
|
||||||
|
keys: &BTreeSet<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let snapshot_unix = i64::try_from(snapshot_unix).context("snapshot timestamp overflow")?;
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agent_observation_snapshots
|
||||||
|
(agent_id, kind, last_dump_unix)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(agent_id, kind) DO UPDATE SET
|
||||||
|
last_dump_unix = excluded.last_dump_unix",
|
||||||
|
agent_id,
|
||||||
|
kind,
|
||||||
|
snapshot_unix
|
||||||
|
)
|
||||||
|
.execute(tx.deref_mut())
|
||||||
|
.await
|
||||||
|
.context("failed upserting observation snapshot")?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_observation_snapshot_keys WHERE agent_id = ?1 AND kind = ?2",
|
||||||
|
agent_id,
|
||||||
|
kind
|
||||||
|
)
|
||||||
|
.execute(tx.deref_mut())
|
||||||
|
.await
|
||||||
|
.context("failed clearing observation snapshot keys")?;
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO agent_observation_snapshot_keys (agent_id, kind, observation_key)
|
||||||
|
VALUES (?1, ?2, ?3)",
|
||||||
|
agent_id,
|
||||||
|
kind,
|
||||||
|
key
|
||||||
|
)
|
||||||
|
.execute(tx.deref_mut())
|
||||||
|
.await
|
||||||
|
.context("failed inserting observation snapshot key")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_observations
|
||||||
|
WHERE agent_id = ?1
|
||||||
|
AND kind = ?2
|
||||||
|
AND observation_key NOT IN (
|
||||||
|
SELECT observation_key
|
||||||
|
FROM agent_observation_snapshot_keys
|
||||||
|
WHERE agent_id = ?1 AND kind = ?2
|
||||||
|
)",
|
||||||
|
agent_id,
|
||||||
|
kind
|
||||||
|
)
|
||||||
|
.execute(tx.deref_mut())
|
||||||
|
.await
|
||||||
|
.context("failed removing stale observation keys")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub async fn upsert_agent_observations(
|
pub async fn upsert_agent_observations(
|
||||||
&self,
|
&self,
|
||||||
@@ -14,64 +143,7 @@ impl Store {
|
|||||||
let mut written = 0usize;
|
let mut written = 0usize;
|
||||||
for observation in observations {
|
for observation in observations {
|
||||||
let observation = normalize_agent_observation(agent_id, observation)?;
|
let observation = normalize_agent_observation(agent_id, observation)?;
|
||||||
let first_seen_unix = i64::try_from(observation.first_seen_unix)
|
upsert_observation_tx(&mut tx, &observation).await?;
|
||||||
.context("observation first_seen overflow")?;
|
|
||||||
let last_seen_unix = i64::try_from(observation.last_seen_unix)
|
|
||||||
.context("observation last_seen overflow")?;
|
|
||||||
let current = get_observation_current_row(&mut tx, &observation.observation_key)
|
|
||||||
.await
|
|
||||||
.context("failed checking existing observation")?;
|
|
||||||
let append_event = observation_current_changed(
|
|
||||||
current.as_ref(),
|
|
||||||
&observation,
|
|
||||||
first_seen_unix,
|
|
||||||
last_seen_unix,
|
|
||||||
);
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO agent_device_observations
|
|
||||||
(observation_key, agent_id, kind, mac, ip, hostname,
|
|
||||||
first_seen_unix, last_seen_unix, last_action)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
||||||
ON CONFLICT(observation_key) DO UPDATE SET
|
|
||||||
mac = excluded.mac,
|
|
||||||
ip = excluded.ip,
|
|
||||||
hostname = excluded.hostname,
|
|
||||||
first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),
|
|
||||||
last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),
|
|
||||||
last_action = excluded.last_action",
|
|
||||||
observation.observation_key,
|
|
||||||
observation.agent_id,
|
|
||||||
observation.kind,
|
|
||||||
observation.mac,
|
|
||||||
observation.ip,
|
|
||||||
observation.hostname,
|
|
||||||
first_seen_unix,
|
|
||||||
last_seen_unix,
|
|
||||||
observation.last_action
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed upserting agent device observation")?;
|
|
||||||
|
|
||||||
if append_event {
|
|
||||||
let event_id = format!("ode-{}", Uuid::new_v4());
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO agent_device_observation_events
|
|
||||||
(event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
event_id,
|
|
||||||
observation.agent_id,
|
|
||||||
observation.kind,
|
|
||||||
observation.last_action,
|
|
||||||
observation.mac,
|
|
||||||
observation.ip,
|
|
||||||
observation.hostname,
|
|
||||||
last_seen_unix
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("failed appending agent device observation event")?;
|
|
||||||
}
|
|
||||||
written = written.saturating_add(1);
|
written = written.saturating_add(1);
|
||||||
}
|
}
|
||||||
tx.commit()
|
tx.commit()
|
||||||
@@ -80,7 +152,68 @@ impl Store {
|
|||||||
Ok(written)
|
Ok(written)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
pub async fn upsert_agent_observations_snapshot(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
observations: Vec<AgentDeviceObservationInput>,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.context("failed starting observation snapshot transaction")?;
|
||||||
|
let mut written = 0usize;
|
||||||
|
let mut snapshot_keys = BTreeSet::new();
|
||||||
|
let kind = normalize_required_text(kind, "observation kind")?.to_ascii_lowercase();
|
||||||
|
for observation in observations {
|
||||||
|
let observation = normalize_agent_observation(agent_id, observation)?;
|
||||||
|
if observation.kind != kind {
|
||||||
|
anyhow::bail!(
|
||||||
|
"observation kind mismatch: expected {kind} got {}",
|
||||||
|
observation.kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
snapshot_keys.insert(observation.observation_key.clone());
|
||||||
|
upsert_observation_tx(&mut tx, &observation).await?;
|
||||||
|
written = written.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot_unix = now_unix();
|
||||||
|
store_observation_snapshot(&mut tx, agent_id, &kind, snapshot_unix, &snapshot_keys)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.context("failed committing observation snapshot transaction")?;
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn gc_stale_observations(&self, retention: Duration) -> Result<u64> {
|
||||||
|
if retention.as_secs() == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let cutoff = now_unix().saturating_sub(retention.as_secs());
|
||||||
|
let cutoff = i64::try_from(cutoff).context("observation retention overflow")?;
|
||||||
|
let removed_observations = sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_observations WHERE last_seen_unix < ?1",
|
||||||
|
cutoff
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.context("failed removing stale observations")?
|
||||||
|
.rows_affected();
|
||||||
|
let removed_events = sqlx::query!(
|
||||||
|
"DELETE FROM agent_device_observation_events WHERE ts_unix < ?1",
|
||||||
|
cutoff
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.context("failed removing stale observation events")?
|
||||||
|
.rows_affected();
|
||||||
|
Ok(removed_observations.saturating_add(removed_events))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_agent_observations(
|
pub async fn list_agent_observations(
|
||||||
&self,
|
&self,
|
||||||
agent_id: Option<&str>,
|
agent_id: Option<&str>,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, info, info_span, warn};
|
use tracing::{debug, info, info_span, warn};
|
||||||
@@ -259,31 +260,40 @@ async fn process_agent_text(
|
|||||||
anyhow::bail!("observations for unauthenticated or mismatched agent");
|
anyhow::bail!("observations for unauthenticated or mismatched agent");
|
||||||
}
|
}
|
||||||
ensure_current_session(state, &agent_id, connection_id).await?;
|
ensure_current_session(state, &agent_id, connection_id).await?;
|
||||||
let inputs = observations
|
let mut by_kind: BTreeMap<String, Vec<crate::state::AgentDeviceObservationInput>> =
|
||||||
.into_iter()
|
BTreeMap::new();
|
||||||
.map(|observation| crate::state::AgentDeviceObservationInput {
|
for observation in observations {
|
||||||
kind: observation.kind,
|
let kind = observation.kind.trim().to_ascii_lowercase();
|
||||||
action: observation.action,
|
by_kind
|
||||||
mac: observation.mac,
|
.entry(kind.clone())
|
||||||
ip: observation.ip.map(|ip| ip.to_string()),
|
.or_default()
|
||||||
hostname: observation.hostname,
|
.push(crate::state::AgentDeviceObservationInput {
|
||||||
first_seen_unix: observation.first_seen_unix,
|
kind,
|
||||||
last_seen_unix: observation.last_seen_unix,
|
action: observation.action,
|
||||||
})
|
mac: observation.mac,
|
||||||
.collect();
|
ip: observation.ip.map(|ip| ip.to_string()),
|
||||||
match state
|
hostname: observation.hostname,
|
||||||
.store
|
first_seen_unix: observation.first_seen_unix,
|
||||||
.upsert_agent_observations(&agent_id, inputs)
|
last_seen_unix: observation.last_seen_unix,
|
||||||
.await
|
});
|
||||||
{
|
}
|
||||||
Ok(accepted) => {
|
let mut accepted = 0usize;
|
||||||
debug!(agent_id = %agent_id, accepted, "agent websocket observations accepted");
|
for (kind, inputs) in by_kind {
|
||||||
}
|
match state
|
||||||
Err(err) => {
|
.store
|
||||||
warn!(agent_id = %agent_id, error = %err, "failed to store websocket observations");
|
.upsert_agent_observations_snapshot(&agent_id, &kind, inputs)
|
||||||
anyhow::bail!("failed to store observations: {err}");
|
.await
|
||||||
|
{
|
||||||
|
Ok(written) => {
|
||||||
|
accepted = accepted.saturating_add(written);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(agent_id = %agent_id, error = %err, kind = %kind, "failed to store websocket observations");
|
||||||
|
anyhow::bail!("failed to store observations: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
debug!(agent_id = %agent_id, accepted, "agent websocket observations accepted");
|
||||||
}
|
}
|
||||||
IncomingClientMessage::Result { request_id, result } => {
|
IncomingClientMessage::Result { request_id, result } => {
|
||||||
let agent_id = authed_agent_id
|
let agent_id = authed_agent_id
|
||||||
|
|||||||
+1
-156
@@ -1,47 +1,20 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
mod leases;
|
mod leases;
|
||||||
mod observations;
|
|
||||||
|
|
||||||
pub use leases::{
|
pub use leases::{
|
||||||
enrich_leases_with_nud_state, parse_dhcp_lease_line, read_dhcp_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_from_path, read_dhcp_leases_with_names,
|
||||||
read_dhcp_leases_with_names_from_paths,
|
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_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 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 {
|
pub(crate) fn dhcp_leases_path() -> PathBuf {
|
||||||
configured_path(DHCP_LEASES_ENV, DEFAULT_DHCP_LEASES)
|
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 {
|
fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
||||||
std::env::var_os(env_key)
|
std::env::var_os(env_key)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -53,6 +26,7 @@ fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
struct EnvGuard {
|
struct EnvGuard {
|
||||||
keys: Vec<&'static str>,
|
keys: Vec<&'static str>,
|
||||||
@@ -107,133 +81,4 @@ mod tests {
|
|||||||
let _ = tokio::fs::remove_file(path).await;
|
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 wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||||
|
|
||||||
use super::{dhcp_leases_path, mac_name_cache_path, observation_store_path};
|
use super::dhcp_leases_path;
|
||||||
use crate::dhcp::observations::{
|
use crate::observations::{
|
||||||
load_mac_name_cache_from_path, load_observation_store_from_path, save_mac_name_cache_to_path,
|
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.
|
/// Parse one `dnsmasq`-style DHCP lease line.
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
pub mod dhcp;
|
pub mod dhcp;
|
||||||
|
pub mod observations;
|
||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|
||||||
pub use devices::*;
|
pub use devices::*;
|
||||||
pub use dhcp::*;
|
pub use dhcp::*;
|
||||||
|
pub use observations::*;
|
||||||
pub use wake::*;
|
pub use wake::*;
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
use macaddr::MacAddr;
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct LocalObservationStore {
|
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(
|
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>> {
|
) -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||||
match tokio::fs::read_to_string(path).await {
|
match tokio::fs::read_to_string(path).await {
|
||||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
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(
|
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>,
|
map: &std::collections::BTreeMap<String, String>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
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(
|
pub async fn load_observation_store_from_path(
|
||||||
path: impl AsRef<std::path::Path>,
|
path: impl AsRef<Path>,
|
||||||
) -> io::Result<LocalObservationStore> {
|
) -> io::Result<LocalObservationStore> {
|
||||||
match tokio::fs::read_to_string(path).await {
|
match tokio::fs::read_to_string(path).await {
|
||||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
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<()> {
|
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)?;
|
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>> {
|
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(
|
pub async fn list_local_observations_from_path(
|
||||||
path: impl AsRef<std::path::Path>,
|
path: impl AsRef<Path>,
|
||||||
) -> io::Result<Vec<LocalDeviceObservation>> {
|
) -> io::Result<Vec<LocalDeviceObservation>> {
|
||||||
let store = load_observation_store_from_path(path).await?;
|
let store = load_observation_store_from_path(path).await?;
|
||||||
Ok(list_local_observations_from_store(store))
|
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> {
|
fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> {
|
||||||
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
||||||
out.extend(
|
out.extend(
|
||||||
@@ -353,3 +405,175 @@ fn same_ip_family(a: IpAddr, b: IpAddr) -> bool {
|
|||||||
(IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))
|
(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