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
@@ -2,8 +2,9 @@ mod store;
|
||||
mod types;
|
||||
|
||||
pub use store::Store;
|
||||
#[cfg(test)]
|
||||
pub use types::AgentDeviceRow;
|
||||
pub use types::{
|
||||
AgentDeviceObservation, AgentDeviceObservationEvent, AgentDeviceObservationInput,
|
||||
AgentDeviceObservationView, AlertState, AuditEvent, AuditEventFilter, AuditEventInput,
|
||||
AgentDeviceWithChildren, AlertState, AuditEvent, AuditEventFilter, AuditEventInput,
|
||||
DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput, KnownDeviceSummary,
|
||||
};
|
||||
@@ -8,10 +8,9 @@ use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::types::{
|
||||
AgentDeviceObservation, AgentDeviceObservationEvent, AgentDeviceObservationInput,
|
||||
AgentDeviceObservationView, AlertState, AlertTransition, AuditEvent, AuditEventFilter,
|
||||
AuditEventInput, DeviceIdentifier, DeviceIdentifierInput, EnrollTokenInfo, IssuedAgent,
|
||||
IssuedEnrollToken, KnownDevice, KnownDeviceInput, KnownDeviceSummary, StateStats,
|
||||
AlertState, AlertTransition, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifier,
|
||||
DeviceIdentifierInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken, KnownDevice,
|
||||
KnownDeviceInput, StateStats,
|
||||
};
|
||||
|
||||
pub struct Store {
|
||||
@@ -21,8 +20,9 @@ pub struct Store {
|
||||
|
||||
const SCHEMA_VERSION_KEY: &str = "schema_version";
|
||||
const SEEDED_ENROLL_TOKEN_PREFIX: &str = "seeded_enroll_token:";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
pub(crate) mod agent_devices;
|
||||
mod alerts;
|
||||
mod audit;
|
||||
mod db;
|
||||
@@ -30,7 +30,6 @@ mod devices;
|
||||
mod enrollment;
|
||||
mod helpers;
|
||||
mod import_sled;
|
||||
mod observations;
|
||||
|
||||
use helpers::alerts_audit::*;
|
||||
use helpers::core::*;
|
||||
@@ -45,20 +44,7 @@ mod tests {
|
||||
use crate::state::{DeviceIdentifierInput, KnownDeviceInput};
|
||||
|
||||
use super::Store;
|
||||
|
||||
async fn make_store() -> (Store, std::path::PathBuf) {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("wakey-cp-store-test-{}", uuid::Uuid::new_v4()));
|
||||
let db_path = dir.join("state.sqlite3");
|
||||
let store = Store::load_or_init(&db_path, Vec::new(), Duration::from_secs(60))
|
||||
.await
|
||||
.expect("store should initialize");
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
fn cleanup_dir(path: &std::path::Path) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
use super::helpers::test_helpers::TestStore;
|
||||
|
||||
async fn insert_token(store: &Store, token: &str, expires_at_unix: u64) {
|
||||
sqlx::query(
|
||||
@@ -81,15 +67,16 @@ mod tests {
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(err.to_string().contains("legacy sled store"));
|
||||
cleanup_dir(&dir);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gc_removes_expired_tokens() {
|
||||
let (store, dir) = make_store().await;
|
||||
insert_token(&store, "enr-expired-gc-test", 1).await;
|
||||
let ts = TestStore::new().await;
|
||||
insert_token(ts.store(), "enr-expired-gc-test", 1).await;
|
||||
|
||||
let removed = store
|
||||
let removed = ts
|
||||
.store()
|
||||
.gc_expired_enroll_tokens()
|
||||
.await
|
||||
.expect("gc should succeed");
|
||||
@@ -98,19 +85,19 @@ mod tests {
|
||||
let exists =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1")
|
||||
.bind("enr-expired-gc-test")
|
||||
.fetch_one(&store.pool)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(exists, 0);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enroll_rejects_expired_token() {
|
||||
let (store, dir) = make_store().await;
|
||||
insert_token(&store, "enr-expired-enroll-test", 1).await;
|
||||
let ts = TestStore::new().await;
|
||||
insert_token(ts.store(), "enr-expired-enroll-test", 1).await;
|
||||
|
||||
let err = store
|
||||
let err = ts
|
||||
.store()
|
||||
.enroll("enr-expired-enroll-test")
|
||||
.await
|
||||
.expect_err("expired token should be rejected");
|
||||
@@ -119,119 +106,122 @@ mod tests {
|
||||
let exists =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1")
|
||||
.bind("enr-expired-enroll-test")
|
||||
.fetch_one(&store.pool)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(exists, 0);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_counts_agents_and_expired_tokens() {
|
||||
let (store, dir) = make_store().await;
|
||||
insert_token(&store, "enr-valid-test", i64::MAX as u64).await;
|
||||
let ts = TestStore::new().await;
|
||||
insert_token(ts.store(), "enr-valid-test", i64::MAX as u64).await;
|
||||
|
||||
let _issued = store
|
||||
let _issued = ts
|
||||
.store()
|
||||
.issue_enroll_token(Duration::from_secs(60))
|
||||
.await
|
||||
.expect("issue should succeed");
|
||||
|
||||
insert_token(&store, "enr-expired-stats-test", 1).await;
|
||||
insert_token(ts.store(), "enr-expired-stats-test", 1).await;
|
||||
|
||||
let issued_agent = store
|
||||
let issued_agent = ts
|
||||
.store()
|
||||
.enroll("enr-valid-test")
|
||||
.await
|
||||
.expect("enroll should succeed for valid token");
|
||||
assert!(!issued_agent.agent_id.is_empty());
|
||||
|
||||
let stats = store.stats().await.expect("stats should succeed");
|
||||
let stats = ts.store().stats().await.expect("stats should succeed");
|
||||
|
||||
assert_eq!(stats.agent_count, 1);
|
||||
assert_eq!(stats.enroll_token_count, 2);
|
||||
assert_eq!(stats.expired_enroll_token_count, 1);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_agent_removes_credentials() {
|
||||
let (store, dir) = make_store().await;
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
insert_token(&store, "enr-revoke-agent-test", i64::MAX as u64).await;
|
||||
insert_token(ts.store(), "enr-revoke-agent-test", i64::MAX as u64).await;
|
||||
|
||||
let issued = store
|
||||
let issued = ts
|
||||
.store()
|
||||
.enroll("enr-revoke-agent-test")
|
||||
.await
|
||||
.expect("enroll should succeed");
|
||||
|
||||
assert!(
|
||||
store
|
||||
ts.store()
|
||||
.verify_agent_token(&issued.agent_id, &issued.agent_token)
|
||||
.await
|
||||
);
|
||||
|
||||
let removed = store
|
||||
let removed = ts
|
||||
.store()
|
||||
.revoke_agent(&issued.agent_id)
|
||||
.await
|
||||
.expect("revoke should succeed");
|
||||
assert!(removed);
|
||||
assert!(
|
||||
!store
|
||||
!ts.store()
|
||||
.verify_agent_token(&issued.agent_id, &issued.agent_token)
|
||||
.await
|
||||
);
|
||||
|
||||
let removed_again = store
|
||||
let removed_again = ts
|
||||
.store()
|
||||
.revoke_agent(&issued.agent_id)
|
||||
.await
|
||||
.expect("second revoke should succeed");
|
||||
assert!(!removed_again);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nickname_set_and_clear_roundtrip() {
|
||||
let (store, dir) = make_store().await;
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
insert_token(&store, "enr-nickname-test", i64::MAX as u64).await;
|
||||
insert_token(ts.store(), "enr-nickname-test", i64::MAX as u64).await;
|
||||
|
||||
let issued = store
|
||||
let issued = ts
|
||||
.store()
|
||||
.enroll("enr-nickname-test")
|
||||
.await
|
||||
.expect("enroll should succeed");
|
||||
|
||||
let updated = store
|
||||
let updated = ts
|
||||
.store()
|
||||
.set_agent_nickname(&issued.agent_id, Some("kitchen-router"))
|
||||
.await
|
||||
.expect("nickname set should succeed");
|
||||
assert!(updated);
|
||||
|
||||
let listed = store.list_agents_with_nicknames().await;
|
||||
let listed = ts.store().list_agents_with_nicknames().await;
|
||||
assert!(listed.iter().any(|(id, name)| {
|
||||
id == &issued.agent_id && name.as_deref() == Some("kitchen-router")
|
||||
}));
|
||||
|
||||
let cleared = store
|
||||
let cleared = ts
|
||||
.store()
|
||||
.set_agent_nickname(&issued.agent_id, None)
|
||||
.await
|
||||
.expect("nickname clear should succeed");
|
||||
assert!(cleared);
|
||||
|
||||
let listed = store.list_agents_with_nicknames().await;
|
||||
let listed = ts.store().list_agents_with_nicknames().await;
|
||||
assert!(
|
||||
listed
|
||||
.iter()
|
||||
.any(|(id, name)| id == &issued.agent_id && name.is_none())
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_device_can_hold_multiple_manual_mac_identifiers() {
|
||||
let (store, dir) = make_store().await;
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let created = store
|
||||
let created = ts
|
||||
.store()
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
@@ -249,7 +239,8 @@ mod tests {
|
||||
assert_eq!(created.identifiers.len(), 1);
|
||||
assert_eq!(created.identifiers[0].value, "aa:bb:cc:dd:ee:01");
|
||||
|
||||
let updated = store
|
||||
let updated = ts
|
||||
.store()
|
||||
.attach_device_identifier(
|
||||
&created.device_id,
|
||||
DeviceIdentifierInput {
|
||||
@@ -269,7 +260,8 @@ mod tests {
|
||||
.any(|identifier| identifier.value == "aa:bb:cc:dd:ee:02")
|
||||
);
|
||||
|
||||
let matched = store
|
||||
let matched = ts
|
||||
.store()
|
||||
.lookup_known_device_by_identifier(DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:02".into(),
|
||||
@@ -278,14 +270,13 @@ mod tests {
|
||||
.expect("lookup should succeed")
|
||||
.expect("identifier should match");
|
||||
assert_eq!(matched.device_id, created.device_id);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_device_identifier_is_unique_across_devices() {
|
||||
let (store, dir) = make_store().await;
|
||||
let first = store
|
||||
let ts = TestStore::new().await;
|
||||
let first = ts
|
||||
.store()
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
@@ -297,7 +288,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("first device should create");
|
||||
let second = store
|
||||
let second = ts
|
||||
.store()
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "other".into(),
|
||||
pinned: false,
|
||||
@@ -307,7 +299,8 @@ mod tests {
|
||||
.await
|
||||
.expect("second device should create");
|
||||
|
||||
let err = store
|
||||
let err = ts
|
||||
.store()
|
||||
.attach_device_identifier(
|
||||
&second.device_id,
|
||||
DeviceIdentifierInput {
|
||||
@@ -322,7 +315,11 @@ mod tests {
|
||||
.contains("failed attaching device identifier")
|
||||
);
|
||||
|
||||
let listed = store.list_known_devices().await.expect("list should work");
|
||||
let listed = ts
|
||||
.store()
|
||||
.list_known_devices()
|
||||
.await
|
||||
.expect("list should work");
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert!(
|
||||
listed
|
||||
@@ -333,14 +330,13 @@ mod tests {
|
||||
.len()
|
||||
== 1
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_identifier_can_be_detached_manually() {
|
||||
let (store, dir) = make_store().await;
|
||||
let created = store
|
||||
let ts = TestStore::new().await;
|
||||
let created = ts
|
||||
.store()
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
@@ -359,7 +355,8 @@ mod tests {
|
||||
.await
|
||||
.expect("known device should create");
|
||||
|
||||
let updated = store
|
||||
let updated = ts
|
||||
.store()
|
||||
.detach_device_identifier(&created.device_id, "ip:192.168.1.2")
|
||||
.await
|
||||
.expect("identifier detach should succeed")
|
||||
@@ -371,7 +368,8 @@ mod tests {
|
||||
"mac:aa:bb:cc:dd:ee:ff"
|
||||
);
|
||||
|
||||
let unmatched = store
|
||||
let unmatched = ts
|
||||
.store()
|
||||
.lookup_known_device_by_identifier(DeviceIdentifierInput {
|
||||
kind: "ip".into(),
|
||||
value: "192.168.1.2".into(),
|
||||
@@ -379,14 +377,13 @@ mod tests {
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
assert!(unmatched.is_none());
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_known_devices_moves_identifiers_and_deletes_source() {
|
||||
let (store, dir) = make_store().await;
|
||||
let target = store
|
||||
let ts = TestStore::new().await;
|
||||
let target = ts
|
||||
.store()
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
@@ -398,7 +395,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("target should create");
|
||||
let source = store
|
||||
let source = ts
|
||||
.store()
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda duplicate".into(),
|
||||
pinned: false,
|
||||
@@ -411,7 +409,8 @@ mod tests {
|
||||
.await
|
||||
.expect("source should create");
|
||||
|
||||
let merged = store
|
||||
let merged = ts
|
||||
.store()
|
||||
.merge_known_devices(&target.device_id, &source.device_id)
|
||||
.await
|
||||
.expect("merge should succeed")
|
||||
@@ -426,362 +425,19 @@ mod tests {
|
||||
.any(|identifier| identifier.value == "aa:bb:cc:dd:ee:02")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
ts.store()
|
||||
.get_known_device(&source.device_id)
|
||||
.await
|
||||
.expect("source lookup should work")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_observations_upsert_current_state_and_events() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
let accepted = store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("observation upsert should succeed");
|
||||
assert_eq!(accepted, 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].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff"));
|
||||
assert_eq!(rows[0].hostname.as_deref(), Some("lda"));
|
||||
|
||||
let event_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agent_device_observation_events")
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.expect("event count should read");
|
||||
assert_eq!(event_count, 1);
|
||||
|
||||
let accepted = store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("duplicate observation upsert should succeed");
|
||||
assert_eq!(accepted, 1);
|
||||
|
||||
let event_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agent_device_observation_events")
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.expect("event count should read");
|
||||
assert_eq!(event_count, 1);
|
||||
|
||||
store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "remove".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 30,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("changed observation upsert should succeed");
|
||||
|
||||
let events = store
|
||||
.list_agent_observation_events(
|
||||
Some("agent-a"),
|
||||
None,
|
||||
Some("aa:bb:cc:dd:ee:ff"),
|
||||
None,
|
||||
None,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.expect("observation events should list");
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].action, "remove");
|
||||
assert_eq!(
|
||||
events[0].observation_key,
|
||||
"agent:agent-a:dhcp:mac:aa:bb:cc:dd:ee:ff"
|
||||
);
|
||||
|
||||
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]
|
||||
async fn agent_observation_views_include_matching_known_device() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
let device = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
notes: None,
|
||||
identifiers: vec![DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:ff".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("known device should create");
|
||||
|
||||
store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![
|
||||
crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
},
|
||||
crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("00:11:22:33:44:55".into()),
|
||||
ip: Some("192.168.1.11".into()),
|
||||
hostname: Some("guest".into()),
|
||||
first_seen_unix: 11,
|
||||
last_seen_unix: 21,
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("observation upsert should succeed");
|
||||
|
||||
let rows = store
|
||||
.list_agent_observation_views(Some("agent-a"), 10)
|
||||
.await
|
||||
.expect("observation views should list");
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
let known = rows
|
||||
.iter()
|
||||
.find(|row| row.mac.as_deref() == Some("aa:bb:cc:dd:ee:ff"))
|
||||
.expect("known observation should be present");
|
||||
let known_device = known
|
||||
.known_device
|
||||
.as_ref()
|
||||
.expect("known observation should join device");
|
||||
assert_eq!(known_device.device_id, device.device_id);
|
||||
assert_eq!(known_device.display_name, "lda");
|
||||
assert!(known_device.pinned);
|
||||
|
||||
let unknown = rows
|
||||
.iter()
|
||||
.find(|row| row.mac.as_deref() == Some("00:11:22:33:44:55"))
|
||||
.expect("unknown observation should be present");
|
||||
assert!(unknown.known_device.is_none());
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observation_identifier_can_be_attached_to_known_device() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
let device = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
notes: None,
|
||||
identifiers: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.expect("known device should create");
|
||||
|
||||
store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("observation upsert should succeed");
|
||||
|
||||
let observation = store
|
||||
.list_agent_observations(Some("agent-a"), 10)
|
||||
.await
|
||||
.expect("observations should list")
|
||||
.pop()
|
||||
.expect("observation should exist");
|
||||
|
||||
let updated = store
|
||||
.attach_observation_identifier(&device.device_id, &observation.observation_key)
|
||||
.await
|
||||
.expect("observation identifier should attach")
|
||||
.expect("device should exist");
|
||||
|
||||
assert_eq!(updated.identifiers.len(), 1);
|
||||
assert_eq!(updated.identifiers[0].kind, "mac");
|
||||
assert_eq!(updated.identifiers[0].value, "aa:bb:cc:dd:ee:ff");
|
||||
|
||||
let views = store
|
||||
.list_agent_observation_views(Some("agent-a"), 10)
|
||||
.await
|
||||
.expect("observation views should list");
|
||||
assert_eq!(
|
||||
views[0]
|
||||
.known_device
|
||||
.as_ref()
|
||||
.map(|device| device.device_id.as_str()),
|
||||
Some(device.device_id.as_str())
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_events_append_and_filter() {
|
||||
let (store, dir) = make_store().await;
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
store
|
||||
ts.store()
|
||||
.append_audit_event(crate::state::AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
@@ -796,7 +452,7 @@ mod tests {
|
||||
.await
|
||||
.expect("append first event should succeed");
|
||||
|
||||
store
|
||||
ts.store()
|
||||
.append_audit_event(crate::state::AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: Some("agent-2".into()),
|
||||
@@ -811,7 +467,8 @@ mod tests {
|
||||
.await
|
||||
.expect("append second event should succeed");
|
||||
|
||||
let all = store
|
||||
let all = ts
|
||||
.store()
|
||||
.list_audit_events(crate::state::AuditEventFilter {
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
@@ -820,7 +477,8 @@ mod tests {
|
||||
.expect("list all should succeed");
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
let filtered = store
|
||||
let filtered = ts
|
||||
.store()
|
||||
.list_audit_events(crate::state::AuditEventFilter {
|
||||
agent_id: Some("agent-1".into()),
|
||||
event_type: Some("command_result".into()),
|
||||
@@ -833,7 +491,8 @@ mod tests {
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].request_id.as_deref(), Some("req-1"));
|
||||
|
||||
let rejected = store
|
||||
let rejected = ts
|
||||
.store()
|
||||
.list_audit_events(crate::state::AuditEventFilter {
|
||||
outcome: Some("rejected".into()),
|
||||
limit: 10,
|
||||
@@ -843,12 +502,11 @@ mod tests {
|
||||
.expect("rejected list should succeed");
|
||||
assert_eq!(rejected.len(), 1);
|
||||
assert_eq!(rejected[0].latency_ms, None);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_transitions_track_open_and_resolve() {
|
||||
let (store, dir) = make_store().await;
|
||||
let ts = TestStore::new().await;
|
||||
let alert = crate::state::AlertState {
|
||||
alert_id: "agent_offline:agent-a".into(),
|
||||
kind: "agent_offline".into(),
|
||||
@@ -862,26 +520,28 @@ mod tests {
|
||||
metadata: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let opened = store
|
||||
let opened = ts
|
||||
.store()
|
||||
.sync_alert_transitions(std::slice::from_ref(&alert))
|
||||
.await
|
||||
.expect("open transition should succeed");
|
||||
assert_eq!(opened.len(), 1);
|
||||
assert_eq!(opened[0].to_status, "active");
|
||||
|
||||
let resolved = store
|
||||
let resolved = ts
|
||||
.store()
|
||||
.sync_alert_transitions(&[])
|
||||
.await
|
||||
.expect("resolve transition should succeed");
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].to_status, "resolved");
|
||||
|
||||
let history = store
|
||||
let history = ts
|
||||
.store()
|
||||
.list_alert_transitions(None, 10)
|
||||
.await
|
||||
.expect("history should load");
|
||||
assert!(history.len() >= 2);
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -923,7 +583,7 @@ mod tests {
|
||||
.contains("invalid or already-used enroll token")
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -984,6 +644,6 @@ mod tests {
|
||||
.any(|token| token.enroll_token == "enr-import-test")
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
use anyhow::{Context, Result};
|
||||
use wakey_core::{Device, DeviceId};
|
||||
|
||||
use super::Store;
|
||||
use super::helpers::core::*;
|
||||
use crate::state::types::*;
|
||||
|
||||
impl Store {
|
||||
/// Replace the complete device snapshot for an agent.
|
||||
///
|
||||
/// This is the only write path for agent device state. Every sync source
|
||||
/// (WebSocket snapshot, fleet refresh, HTTP upload) must go through here.
|
||||
pub async fn replace_agent_device_snapshot(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
devices: &[Device],
|
||||
) -> Result<usize> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting device snapshot transaction")?;
|
||||
|
||||
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
|
||||
let snapshot_time = now_unix();
|
||||
let snapshot_time_i64 = i64::try_from(snapshot_time).context("snapshot time overflow")?;
|
||||
|
||||
let existing_keys: Vec<String> =
|
||||
sqlx::query_scalar("SELECT device_key FROM agent_devices WHERE agent_id = ?1")
|
||||
.bind(agent_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.context("failed fetching existing keys")?;
|
||||
|
||||
for device in devices {
|
||||
let Some(device_id) = &device.id else {
|
||||
continue;
|
||||
};
|
||||
let device_key = device_key_from_id(device_id);
|
||||
incoming_keys.insert(device_key.clone());
|
||||
|
||||
let presence = presence_to_str(device.presence);
|
||||
let display_name: Option<String> = None;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT (agent_id, device_key) DO UPDATE SET
|
||||
presence = excluded.presence,
|
||||
last_seen_unix = excluded.last_seen_unix",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.bind(presence)
|
||||
.bind(&display_name)
|
||||
.bind(snapshot_time_i64)
|
||||
.bind(snapshot_time_i64)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed upserting agent device")?;
|
||||
|
||||
// Replace child rows.
|
||||
sqlx::query("DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2")
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting device macs")?;
|
||||
|
||||
for mac in &device.macs {
|
||||
let mac_str = mac.to_string().to_ascii_lowercase();
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_device_macs (agent_id, device_key, mac) VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.bind(&mac_str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed inserting device mac")?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2")
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting device ips")?;
|
||||
|
||||
for ip in &device.ips {
|
||||
let ip_str = ip.to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_device_ips (agent_id, device_key, ip) VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.bind(&ip_str)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed inserting device ip")?;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting device hostnames")?;
|
||||
|
||||
for hostname in &device.names {
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_device_hostnames (agent_id, device_key, hostname) VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.bind(hostname)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed inserting device hostname")?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2")
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed deleting device facts")?;
|
||||
|
||||
for observation in &device.observations {
|
||||
let fact_json =
|
||||
serde_json::to_string(observation).context("failed serializing fact")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_device_facts (agent_id, device_key, fact_json) VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(&device_key)
|
||||
.bind(&fact_json)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed inserting device fact")?;
|
||||
}
|
||||
}
|
||||
|
||||
for old_key in existing_keys {
|
||||
if !incoming_keys.contains(&old_key) {
|
||||
sqlx::query("DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2")
|
||||
.bind(agent_id)
|
||||
.bind(&old_key)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed pruning old agent device")?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing device snapshot")?;
|
||||
|
||||
Ok(incoming_keys.len())
|
||||
}
|
||||
|
||||
/// List all agent device rows with their child MAC/IP/hostname/fact rows.
|
||||
pub async fn list_agent_device_rows(&self) -> Result<Vec<AgentDeviceWithChildren>> {
|
||||
let devices = sqlx::query_as!(
|
||||
AgentDeviceRow,
|
||||
r#"SELECT agent_id, device_key, presence, display_name,
|
||||
first_seen_unix, last_seen_unix
|
||||
FROM agent_devices
|
||||
ORDER BY agent_id, device_key"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent devices")?;
|
||||
|
||||
let macs = sqlx::query_as!(
|
||||
AgentDeviceMacRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", mac as "mac!"
|
||||
FROM agent_device_macs"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device macs")?;
|
||||
|
||||
let ips = sqlx::query_as!(
|
||||
AgentDeviceIpRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", ip as "ip!"
|
||||
FROM agent_device_ips"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device ips")?;
|
||||
|
||||
let hostnames = sqlx::query_as!(
|
||||
AgentDeviceHostnameRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", hostname as "hostname!"
|
||||
FROM agent_device_hostnames"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device hostnames")?;
|
||||
|
||||
let facts = sqlx::query_as!(
|
||||
AgentDeviceFactRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||
FROM agent_device_facts"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device facts")?;
|
||||
|
||||
Ok(assemble_device_rows(devices, macs, ips, hostnames, facts))
|
||||
}
|
||||
|
||||
/// List agent device rows for a single agent.
|
||||
#[allow(dead_code)]
|
||||
pub async fn list_agent_device_rows_for_agent(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Result<Vec<AgentDeviceWithChildren>> {
|
||||
let devices = sqlx::query_as!(
|
||||
AgentDeviceRow,
|
||||
r#"SELECT agent_id, device_key, presence, display_name,
|
||||
first_seen_unix, last_seen_unix
|
||||
FROM agent_devices
|
||||
WHERE agent_id = ?1
|
||||
ORDER BY device_key"#,
|
||||
agent_id,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent devices for agent")?;
|
||||
|
||||
let macs = sqlx::query_as!(
|
||||
AgentDeviceMacRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", mac as "mac!"
|
||||
FROM agent_device_macs
|
||||
WHERE agent_id = ?1"#,
|
||||
agent_id,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device macs for agent")?;
|
||||
|
||||
let ips = sqlx::query_as!(
|
||||
AgentDeviceIpRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", ip as "ip!"
|
||||
FROM agent_device_ips
|
||||
WHERE agent_id = ?1"#,
|
||||
agent_id,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device ips for agent")?;
|
||||
|
||||
let hostnames = sqlx::query_as!(
|
||||
AgentDeviceHostnameRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", hostname as "hostname!"
|
||||
FROM agent_device_hostnames
|
||||
WHERE agent_id = ?1"#,
|
||||
agent_id,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device hostnames for agent")?;
|
||||
|
||||
let facts = sqlx::query_as!(
|
||||
AgentDeviceFactRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||
FROM agent_device_facts
|
||||
WHERE agent_id = ?1"#,
|
||||
agent_id,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent device facts for agent")?;
|
||||
|
||||
Ok(assemble_device_rows(devices, macs, ips, hostnames, facts))
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble_device_rows(
|
||||
devices: Vec<AgentDeviceRow>,
|
||||
macs: Vec<AgentDeviceMacRow>,
|
||||
ips: Vec<AgentDeviceIpRow>,
|
||||
hostnames: Vec<AgentDeviceHostnameRow>,
|
||||
facts: Vec<AgentDeviceFactRow>,
|
||||
) -> Vec<AgentDeviceWithChildren> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut mac_map: BTreeMap<(&str, &str), Vec<&AgentDeviceMacRow>> = BTreeMap::new();
|
||||
for row in &macs {
|
||||
mac_map
|
||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
||||
.or_default()
|
||||
.push(row);
|
||||
}
|
||||
let mut ip_map: BTreeMap<(&str, &str), Vec<&AgentDeviceIpRow>> = BTreeMap::new();
|
||||
for row in &ips {
|
||||
ip_map
|
||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
||||
.or_default()
|
||||
.push(row);
|
||||
}
|
||||
let mut hostname_map: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
|
||||
for row in &hostnames {
|
||||
hostname_map
|
||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
||||
.or_default()
|
||||
.push(row.hostname.clone());
|
||||
}
|
||||
let mut fact_map: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
|
||||
for row in &facts {
|
||||
fact_map
|
||||
.entry((row.agent_id.as_str(), row.device_key.as_str()))
|
||||
.or_default()
|
||||
.push(row.fact_json.clone());
|
||||
}
|
||||
|
||||
devices
|
||||
.into_iter()
|
||||
.map(|device| {
|
||||
let key = (device.agent_id.as_str(), device.device_key.as_str());
|
||||
let macs: Vec<macaddr::MacAddr> = mac_map
|
||||
.get(&key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|row| macaddr::MacAddr::try_from(*row).ok())
|
||||
.collect();
|
||||
let ips: Vec<std::net::IpAddr> = ip_map
|
||||
.get(&key)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|row| std::net::IpAddr::try_from(*row).ok())
|
||||
.collect();
|
||||
let hostnames = hostname_map.get(&key).cloned().unwrap_or_default();
|
||||
let facts = fact_map.get(&key).cloned().unwrap_or_default();
|
||||
AgentDeviceWithChildren {
|
||||
macs,
|
||||
ips,
|
||||
hostnames,
|
||||
facts,
|
||||
device,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn device_key_from_id(device_id: &DeviceId) -> String {
|
||||
match device_id {
|
||||
DeviceId::Mac(mac) => format!("mac:{}", mac.to_string().to_ascii_lowercase()),
|
||||
DeviceId::Ip(ip) => format!("ip:{ip}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn presence_to_str(presence: wakey_core::Presence) -> &'static str {
|
||||
match presence {
|
||||
wakey_core::Presence::Online => "online",
|
||||
wakey_core::Presence::LikelyOnline => "likely_online",
|
||||
wakey_core::Presence::Unknown => "unknown",
|
||||
wakey_core::Presence::Offline => "offline",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::helpers::test_helpers::TestStore;
|
||||
use std::time::Duration;
|
||||
use wakey_core::{
|
||||
Device, DeviceId, DeviceObservationFact, NeighborEntry, NeighborState, Presence,
|
||||
};
|
||||
|
||||
fn sample_device(mac: &str, ip: &str, name: &str) -> Device {
|
||||
Device {
|
||||
id: Some(DeviceId::Mac(mac.parse().expect("mac"))),
|
||||
names: vec![name.to_string()],
|
||||
ips: vec![ip.parse().expect("ip")],
|
||||
macs: vec![mac.parse().expect("mac")],
|
||||
interfaces: vec!["br-lan".to_string()],
|
||||
neighbors: vec![NeighborEntry {
|
||||
ip: ip.parse().expect("ip"),
|
||||
dev: Some("br-lan".to_string()),
|
||||
mac: Some(mac.parse().expect("mac")),
|
||||
state: NeighborState::Reachable,
|
||||
}],
|
||||
leases: vec![],
|
||||
observations: vec![],
|
||||
presence: Presence::Online,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_inserts_devices_and_children() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let devices = vec![sample_device(
|
||||
"aa:bb:cc:dd:ee:01",
|
||||
"192.168.1.10",
|
||||
"first-pc",
|
||||
)];
|
||||
let count = ts
|
||||
.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices)
|
||||
.await
|
||||
.expect("snapshot should succeed");
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let rows = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].device.device_key, "mac:aa:bb:cc:dd:ee:01");
|
||||
assert_eq!(rows[0].device.presence, "online");
|
||||
assert_eq!(
|
||||
rows[0].macs,
|
||||
vec!["aa:bb:cc:dd:ee:01".parse::<macaddr::MacAddr>().unwrap()]
|
||||
);
|
||||
assert_eq!(
|
||||
rows[0].ips,
|
||||
vec!["192.168.1.10".parse::<std::net::IpAddr>().unwrap()]
|
||||
);
|
||||
assert_eq!(rows[0].hostnames, vec!["first-pc"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_snapshot_prunes_missing_devices() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let first = vec![
|
||||
sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "first"),
|
||||
sample_device("aa:bb:cc:dd:ee:02", "192.168.1.11", "second"),
|
||||
];
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &first)
|
||||
.await
|
||||
.expect("first snapshot should succeed");
|
||||
|
||||
let second = vec![sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "first")];
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &second)
|
||||
.await
|
||||
.expect("second snapshot should succeed");
|
||||
|
||||
let rows = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].device.device_key, "mac:aa:bb:cc:dd:ee:01");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_snapshot_clears_devices() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let devices = vec![sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "first")];
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices)
|
||||
.await
|
||||
.expect("snapshot should succeed");
|
||||
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &[])
|
||||
.await
|
||||
.expect("empty snapshot should succeed");
|
||||
|
||||
let rows = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(rows.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_seen_survives_snapshot_update() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let devices = vec![sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "first")];
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices)
|
||||
.await
|
||||
.expect("first snapshot should succeed");
|
||||
|
||||
let first_seen = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed")[0]
|
||||
.device
|
||||
.first_seen_unix;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices)
|
||||
.await
|
||||
.expect("second snapshot should succeed");
|
||||
|
||||
let rows = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(rows[0].device.first_seen_unix, first_seen);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_does_not_prune_other_agents() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let devices_a = vec![sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "first")];
|
||||
let devices_b = vec![sample_device("aa:bb:cc:dd:ee:02", "192.168.1.11", "second")];
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices_a)
|
||||
.await
|
||||
.expect("snapshot a should succeed");
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-b", &devices_b)
|
||||
.await
|
||||
.expect("snapshot b should succeed");
|
||||
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &[])
|
||||
.await
|
||||
.expect("clear a should succeed");
|
||||
|
||||
let rows_b = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-b")
|
||||
.await
|
||||
.expect("list b should succeed");
|
||||
assert_eq!(rows_b.len(), 1);
|
||||
assert_eq!(rows_b[0].device.device_key, "mac:aa:bb:cc:dd:ee:02");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_without_id_is_skipped() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let devices = vec![Device {
|
||||
id: None,
|
||||
names: vec!["no-id".to_string()],
|
||||
ips: vec![],
|
||||
macs: vec![],
|
||||
interfaces: vec![],
|
||||
neighbors: vec![],
|
||||
leases: vec![],
|
||||
observations: vec![],
|
||||
presence: Presence::Unknown,
|
||||
}];
|
||||
let count = ts
|
||||
.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices)
|
||||
.await
|
||||
.expect("snapshot should succeed");
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let rows = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(rows.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn facts_are_stored_as_json() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let devices = vec![Device {
|
||||
id: Some(DeviceId::Mac("aa:bb:cc:dd:ee:01".parse().expect("mac"))),
|
||||
names: vec!["pc".to_string()],
|
||||
ips: vec!["192.168.1.10".parse().expect("ip")],
|
||||
macs: vec!["aa:bb:cc:dd:ee:01".parse().expect("mac")],
|
||||
interfaces: vec![],
|
||||
neighbors: vec![],
|
||||
leases: vec![],
|
||||
observations: vec![DeviceObservationFact {
|
||||
kind: "dhcp".to_string(),
|
||||
action: "update".to_string(),
|
||||
mac: Some("aa:bb:cc:dd:ee:01".parse().expect("mac")),
|
||||
ip: Some("192.168.1.10".parse().expect("ip")),
|
||||
hostname: Some("pc".to_string()),
|
||||
first_seen_unix: Some(10),
|
||||
last_seen_unix: Some(20),
|
||||
}],
|
||||
presence: Presence::LikelyOnline,
|
||||
}];
|
||||
ts.store()
|
||||
.replace_agent_device_snapshot("agent-a", &devices)
|
||||
.await
|
||||
.expect("snapshot should succeed");
|
||||
|
||||
let rows = ts
|
||||
.store()
|
||||
.list_agent_device_rows_for_agent("agent-a")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].facts.len(), 1);
|
||||
assert!(rows[0].facts[0].contains("dhcp"));
|
||||
}
|
||||
}
|
||||
@@ -181,21 +181,6 @@ impl Store {
|
||||
self.get_known_device(device_id).await
|
||||
}
|
||||
|
||||
pub async fn attach_observation_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let observation = get_observation_identifier_row(&self.pool, observation_key).await?;
|
||||
|
||||
let Some(observation) = observation else {
|
||||
anyhow::bail!("observation not found");
|
||||
};
|
||||
let input = observation_identifier_to_input(observation)?;
|
||||
|
||||
self.attach_device_identifier(device_id, input).await
|
||||
}
|
||||
|
||||
pub async fn detach_device_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
|
||||
@@ -4,3 +4,5 @@ pub(super) mod alerts_audit;
|
||||
pub(super) mod core;
|
||||
pub(super) mod legacy;
|
||||
pub(super) mod rows;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_helpers;
|
||||
@@ -1,159 +1,162 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::state::store) async fn insert_audit_event(
|
||||
pool: &SqlitePool,
|
||||
key: &str,
|
||||
event: &AuditEvent,
|
||||
) -> Result<()> {
|
||||
pub async fn insert_audit_event(pool: &SqlitePool, key: &str, event: &AuditEvent) -> Result<()> {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&event.metadata).context("failed to encode audit metadata")?;
|
||||
sqlx::query(
|
||||
let ts_unix = i64::try_from(event.ts_unix).context("audit timestamp overflow")?;
|
||||
let latency_ms = event
|
||||
.latency_ms
|
||||
.map(i64::try_from)
|
||||
.transpose()
|
||||
.context("audit latency overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO audit_events
|
||||
(event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,
|
||||
event_type, outcome, latency_ms, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
key,
|
||||
event.event_id,
|
||||
ts_unix,
|
||||
event.actor_type,
|
||||
event.actor_id,
|
||||
event.agent_id,
|
||||
event.request_id,
|
||||
event.event_type,
|
||||
event.outcome,
|
||||
latency_ms,
|
||||
event.message,
|
||||
metadata_json
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&event.event_id)
|
||||
.bind(i64::try_from(event.ts_unix).context("audit timestamp overflow")?)
|
||||
.bind(&event.actor_type)
|
||||
.bind(&event.actor_id)
|
||||
.bind(&event.agent_id)
|
||||
.bind(&event.request_id)
|
||||
.bind(&event.event_type)
|
||||
.bind(&event.outcome)
|
||||
.bind(
|
||||
event
|
||||
.latency_ms
|
||||
.map(i64::try_from)
|
||||
.transpose()
|
||||
.context("audit latency overflow")?,
|
||||
)
|
||||
.bind(&event.message)
|
||||
.bind(metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed persisting audit event")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_active_alert(
|
||||
pub async fn insert_active_alert(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
alert: &AlertState,
|
||||
) -> Result<()> {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&alert.metadata).context("failed to encode active alert metadata")?;
|
||||
sqlx::query(
|
||||
let alert_value = i64::try_from(alert.value).context("active alert value overflow")?;
|
||||
let alert_threshold =
|
||||
i64::try_from(alert.threshold).context("active alert threshold overflow")?;
|
||||
let last_seen_unix =
|
||||
i64::try_from(alert.last_seen_unix).context("active alert timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO active_alerts
|
||||
(alert_id, kind, severity, status, agent_id, message, value, threshold,
|
||||
last_seen_unix, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
alert.alert_id,
|
||||
alert.kind,
|
||||
alert.severity,
|
||||
alert.status,
|
||||
alert.agent_id,
|
||||
alert.message,
|
||||
alert_value,
|
||||
alert_threshold,
|
||||
last_seen_unix,
|
||||
metadata_json
|
||||
)
|
||||
.bind(&alert.alert_id)
|
||||
.bind(&alert.kind)
|
||||
.bind(&alert.severity)
|
||||
.bind(&alert.status)
|
||||
.bind(&alert.agent_id)
|
||||
.bind(&alert.message)
|
||||
.bind(i64::try_from(alert.value).context("active alert value overflow")?)
|
||||
.bind(i64::try_from(alert.threshold).context("active alert threshold overflow")?)
|
||||
.bind(i64::try_from(alert.last_seen_unix).context("active alert timestamp overflow")?)
|
||||
.bind(metadata_json)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.context("failed writing active alert snapshot")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_active_alert_pool(
|
||||
pool: &SqlitePool,
|
||||
alert: &AlertState,
|
||||
) -> Result<()> {
|
||||
pub async fn insert_active_alert_pool(pool: &SqlitePool, alert: &AlertState) -> Result<()> {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&alert.metadata).context("failed to encode active alert metadata")?;
|
||||
sqlx::query(
|
||||
let alert_value = i64::try_from(alert.value).context("active alert value overflow")?;
|
||||
let alert_threshold =
|
||||
i64::try_from(alert.threshold).context("active alert threshold overflow")?;
|
||||
let last_seen_unix =
|
||||
i64::try_from(alert.last_seen_unix).context("active alert timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO active_alerts
|
||||
(alert_id, kind, severity, status, agent_id, message, value, threshold,
|
||||
last_seen_unix, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
alert.alert_id,
|
||||
alert.kind,
|
||||
alert.severity,
|
||||
alert.status,
|
||||
alert.agent_id,
|
||||
alert.message,
|
||||
alert_value,
|
||||
alert_threshold,
|
||||
last_seen_unix,
|
||||
metadata_json
|
||||
)
|
||||
.bind(&alert.alert_id)
|
||||
.bind(&alert.kind)
|
||||
.bind(&alert.severity)
|
||||
.bind(&alert.status)
|
||||
.bind(&alert.agent_id)
|
||||
.bind(&alert.message)
|
||||
.bind(i64::try_from(alert.value).context("active alert value overflow")?)
|
||||
.bind(i64::try_from(alert.threshold).context("active alert threshold overflow")?)
|
||||
.bind(i64::try_from(alert.last_seen_unix).context("active alert timestamp overflow")?)
|
||||
.bind(metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed writing active alert snapshot")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_alert_transition(
|
||||
pub async fn insert_alert_transition(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
key: &str,
|
||||
transition: &AlertTransition,
|
||||
) -> Result<()> {
|
||||
let metadata_json = serde_json::to_string(&transition.metadata)
|
||||
.context("failed to encode alert transition metadata")?;
|
||||
sqlx::query(
|
||||
let ts_unix = i64::try_from(transition.ts_unix).context("alert timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO alert_transitions
|
||||
(transition_key, transition_id, ts_unix, alert_id, kind, agent_id,
|
||||
from_status, to_status, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
key,
|
||||
transition.transition_id,
|
||||
ts_unix,
|
||||
transition.alert_id,
|
||||
transition.kind,
|
||||
transition.agent_id,
|
||||
transition.from_status,
|
||||
transition.to_status,
|
||||
transition.message,
|
||||
metadata_json
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&transition.transition_id)
|
||||
.bind(i64::try_from(transition.ts_unix).context("alert timestamp overflow")?)
|
||||
.bind(&transition.alert_id)
|
||||
.bind(&transition.kind)
|
||||
.bind(&transition.agent_id)
|
||||
.bind(&transition.from_status)
|
||||
.bind(&transition.to_status)
|
||||
.bind(&transition.message)
|
||||
.bind(metadata_json)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.context("failed persisting alert transition")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_alert_transition_pool(
|
||||
pub async fn insert_alert_transition_pool(
|
||||
pool: &SqlitePool,
|
||||
key: &str,
|
||||
transition: &AlertTransition,
|
||||
) -> Result<()> {
|
||||
let metadata_json = serde_json::to_string(&transition.metadata)
|
||||
.context("failed to encode alert transition metadata")?;
|
||||
sqlx::query(
|
||||
let ts_unix = i64::try_from(transition.ts_unix).context("alert timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO alert_transitions
|
||||
(transition_key, transition_id, ts_unix, alert_id, kind, agent_id,
|
||||
from_status, to_status, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
key,
|
||||
transition.transition_id,
|
||||
ts_unix,
|
||||
transition.alert_id,
|
||||
transition.kind,
|
||||
transition.agent_id,
|
||||
transition.from_status,
|
||||
transition.to_status,
|
||||
transition.message,
|
||||
metadata_json
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&transition.transition_id)
|
||||
.bind(i64::try_from(transition.ts_unix).context("alert timestamp overflow")?)
|
||||
.bind(&transition.alert_id)
|
||||
.bind(&transition.kind)
|
||||
.bind(&transition.agent_id)
|
||||
.bind(&transition.from_status)
|
||||
.bind(&transition.to_status)
|
||||
.bind(&transition.message)
|
||||
.bind(metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed persisting alert transition")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn audit_event_from_row(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
) -> Result<AuditEvent> {
|
||||
pub fn audit_event_from_row(row: sqlx::sqlite::SqliteRow) -> Result<AuditEvent> {
|
||||
let ts_unix: i64 = row.try_get("ts_unix")?;
|
||||
let latency_ms: Option<i64> = row.try_get("latency_ms")?;
|
||||
let metadata_json: String = row.try_get("metadata_json")?;
|
||||
@@ -175,7 +178,7 @@ pub(in crate::state::store) fn audit_event_from_row(
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn alert_state_from_row(row: AlertStateRow) -> Result<AlertState> {
|
||||
pub fn alert_state_from_row(row: AlertStateRow) -> Result<AlertState> {
|
||||
Ok(AlertState {
|
||||
alert_id: row.alert_id,
|
||||
kind: row.kind,
|
||||
@@ -193,9 +196,7 @@ pub(in crate::state::store) fn alert_state_from_row(row: AlertStateRow) -> Resul
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn alert_transition_from_row(
|
||||
row: AlertTransitionRow,
|
||||
) -> Result<AlertTransition> {
|
||||
pub fn alert_transition_from_row(row: AlertTransitionRow) -> Result<AlertTransition> {
|
||||
Ok(AlertTransition {
|
||||
transition_id: row.transition_id,
|
||||
ts_unix: u64::try_from(row.ts_unix).context("negative alert timestamp in state db")?,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::state::store) async fn open_sqlite_pool(path: &Path) -> Result<SqlitePool> {
|
||||
pub async fn open_sqlite_pool(path: &Path) -> Result<SqlitePool> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
@@ -14,7 +14,7 @@ pub(in crate::state::store) async fn open_sqlite_pool(path: &Path) -> Result<Sql
|
||||
.with_context(|| format!("failed to open SQLite state db {}", path.display()))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn sql_count(pool: &SqlitePool, table: &str) -> Result<i64> {
|
||||
pub async fn sql_count(pool: &SqlitePool, table: &str) -> Result<i64> {
|
||||
let sql = format!("SELECT COUNT(*) FROM {table}");
|
||||
sqlx::query_scalar::<_, i64>(&sql)
|
||||
.fetch_one(pool)
|
||||
@@ -22,14 +22,14 @@ pub(in crate::state::store) async fn sql_count(pool: &SqlitePool, table: &str) -
|
||||
.with_context(|| format!("failed counting {table}"))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn now_unix() -> u64 {
|
||||
pub fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn decode_expiry(raw: &[u8]) -> Result<u64> {
|
||||
pub fn decode_expiry(raw: &[u8]) -> Result<u64> {
|
||||
if raw.len() != 8 {
|
||||
anyhow::bail!("invalid token expiry length {}", raw.len());
|
||||
}
|
||||
@@ -38,7 +38,7 @@ pub(in crate::state::store) fn decode_expiry(raw: &[u8]) -> Result<u64> {
|
||||
Ok(u64::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn decode_schema(raw: &[u8]) -> Result<u32> {
|
||||
pub fn decode_schema(raw: &[u8]) -> Result<u32> {
|
||||
if raw.len() != 4 {
|
||||
anyhow::bail!("invalid schema version length {}", raw.len());
|
||||
}
|
||||
@@ -47,11 +47,11 @@ pub(in crate::state::store) fn decode_schema(raw: &[u8]) -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn seeded_enroll_token_key(token: &str) -> String {
|
||||
pub fn seeded_enroll_token_key(token: &str) -> String {
|
||||
format!("{SEEDED_ENROLL_TOKEN_PREFIX}{token}")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_required_text(value: &str, field: &str) -> Result<String> {
|
||||
pub fn normalize_required_text(value: &str, field: &str) -> Result<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
anyhow::bail!("{field} must not be empty");
|
||||
@@ -59,14 +59,14 @@ pub(in crate::state::store) fn normalize_required_text(value: &str, field: &str)
|
||||
Ok(normalized.to_string())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
pub fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_device_identifier(
|
||||
pub fn normalize_device_identifier(
|
||||
input: DeviceIdentifierInput,
|
||||
) -> Result<NormalizedDeviceIdentifier> {
|
||||
let kind = normalize_required_text(&input.kind, "identifier kind")?.to_ascii_lowercase();
|
||||
@@ -80,15 +80,11 @@ pub(in crate::state::store) fn normalize_device_identifier(
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(in crate::state::store) fn normalized_identifier_key_owned(
|
||||
identifier: NormalizedDeviceIdentifier,
|
||||
) -> String {
|
||||
pub fn normalized_identifier_key_owned(identifier: NormalizedDeviceIdentifier) -> String {
|
||||
identifier.identifier_key
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn list_enroll_token_rows(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<Vec<EnrollTokenRow>> {
|
||||
pub async fn list_enroll_token_rows(pool: &SqlitePool) -> Result<Vec<EnrollTokenRow>> {
|
||||
sqlx::query_as!(
|
||||
EnrollTokenRow,
|
||||
r#"SELECT token as "token!", expires_at_unix FROM enroll_tokens ORDER BY expires_at_unix, token"#,
|
||||
@@ -98,10 +94,7 @@ pub(in crate::state::store) async fn list_enroll_token_rows(
|
||||
.context("failed listing enroll tokens")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn enroll_token_info_from_row(
|
||||
row: EnrollTokenRow,
|
||||
now: u64,
|
||||
) -> Result<EnrollTokenInfo> {
|
||||
pub fn enroll_token_info_from_row(row: EnrollTokenRow, now: u64) -> Result<EnrollTokenInfo> {
|
||||
let expires_at_unix =
|
||||
u64::try_from(row.expires_at_unix).context("negative token expiry in state db")?;
|
||||
Ok(EnrollTokenInfo {
|
||||
@@ -111,9 +104,7 @@ pub(in crate::state::store) fn enroll_token_info_from_row(
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn list_known_device_rows(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<Vec<KnownDeviceRow>> {
|
||||
pub async fn list_known_device_rows(pool: &SqlitePool) -> Result<Vec<KnownDeviceRow>> {
|
||||
sqlx::query_as!(
|
||||
KnownDeviceRow,
|
||||
r#"SELECT device_id as "device_id!", display_name as "display_name!",
|
||||
@@ -126,7 +117,7 @@ pub(in crate::state::store) async fn list_known_device_rows(
|
||||
.context("failed listing known devices")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn get_known_device_row(
|
||||
pub async fn get_known_device_row(
|
||||
pool: &SqlitePool,
|
||||
device_id: &str,
|
||||
) -> Result<Option<KnownDeviceRow>> {
|
||||
@@ -143,11 +134,11 @@ pub(in crate::state::store) async fn get_known_device_row(
|
||||
.context("failed reading known device")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn known_device_row_device_id(row: &KnownDeviceRow) -> &str {
|
||||
pub fn known_device_row_device_id(row: &KnownDeviceRow) -> &str {
|
||||
&row.device_id
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn known_device_from_row_and_identifiers(
|
||||
pub fn known_device_from_row_and_identifiers(
|
||||
row: KnownDeviceRow,
|
||||
identifiers: Vec<DeviceIdentifier>,
|
||||
) -> Result<KnownDevice> {
|
||||
@@ -164,7 +155,7 @@ pub(in crate::state::store) fn known_device_from_row_and_identifiers(
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn list_device_identifier_rows(
|
||||
pub async fn list_device_identifier_rows(
|
||||
pool: &SqlitePool,
|
||||
device_id: &str,
|
||||
) -> Result<Vec<DeviceIdentifierRow>> {
|
||||
@@ -182,109 +173,7 @@ pub(in crate::state::store) async fn list_device_identifier_rows(
|
||||
.context("failed listing device identifiers")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn get_observation_identifier_row(
|
||||
pool: &SqlitePool,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<ObservationIdentifierRow>> {
|
||||
sqlx::query_as!(
|
||||
ObservationIdentifierRow,
|
||||
r#"SELECT mac, ip
|
||||
FROM agent_device_observations
|
||||
WHERE observation_key = ?1"#,
|
||||
observation_key
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("failed reading observation identifier")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn get_observation_current_row(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<ObservationCurrentRow>> {
|
||||
sqlx::query_as!(
|
||||
ObservationCurrentRow,
|
||||
r#"SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,
|
||||
last_action as "last_action!"
|
||||
FROM agent_device_observations
|
||||
WHERE observation_key = ?1"#,
|
||||
observation_key
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.context("failed reading current observation")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn observation_current_changed(
|
||||
current: Option<&ObservationCurrentRow>,
|
||||
next: &AgentDeviceObservation,
|
||||
first_seen_unix: i64,
|
||||
last_seen_unix: i64,
|
||||
) -> bool {
|
||||
let Some(current) = current else {
|
||||
return true;
|
||||
};
|
||||
current.mac != next.mac
|
||||
|| current.ip != next.ip
|
||||
|| current.hostname != next.hostname
|
||||
|| current.first_seen_unix != first_seen_unix
|
||||
|| current.last_seen_unix != last_seen_unix
|
||||
|| current.last_action != next.last_action
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn observation_identifier_to_input(
|
||||
observation: ObservationIdentifierRow,
|
||||
) -> Result<DeviceIdentifierInput> {
|
||||
observation
|
||||
.mac
|
||||
.map(|value| DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value,
|
||||
})
|
||||
.or_else(|| {
|
||||
observation.ip.map(|value| DeviceIdentifierInput {
|
||||
kind: "ip".into(),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("observation has no attachable mac or ip"))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_agent_observation(
|
||||
agent_id: &str,
|
||||
input: AgentDeviceObservationInput,
|
||||
) -> Result<AgentDeviceObservation> {
|
||||
let kind = normalize_required_text(&input.kind, "observation kind")?.to_ascii_lowercase();
|
||||
let action = normalize_required_text(&input.action, "observation action")?.to_ascii_lowercase();
|
||||
let mac = normalize_optional_text(input.mac.as_deref()).map(|value| value.to_ascii_lowercase());
|
||||
let ip = normalize_optional_text(input.ip.as_deref());
|
||||
let hostname = normalize_optional_text(input.hostname.as_deref());
|
||||
let identifier = observation_identifier(&kind, mac.as_deref(), ip.as_deref())
|
||||
.ok_or_else(|| anyhow::anyhow!("observation requires mac or ip"))?;
|
||||
let observation_key = format!("agent:{agent_id}:{kind}:{identifier}");
|
||||
Ok(AgentDeviceObservation {
|
||||
observation_key,
|
||||
agent_id: agent_id.to_string(),
|
||||
kind,
|
||||
mac,
|
||||
ip,
|
||||
hostname,
|
||||
first_seen_unix: input.first_seen_unix,
|
||||
last_seen_unix: input.last_seen_unix,
|
||||
last_action: action,
|
||||
})
|
||||
}
|
||||
|
||||
fn observation_identifier(kind: &str, mac: Option<&str>, ip: Option<&str>) -> Option<String> {
|
||||
match (kind, mac, ip) {
|
||||
("neigh" | "inventory", Some(mac), Some(ip)) => Some(format!("mac:{mac}:ip:{ip}")),
|
||||
(_, Some(mac), _) => Some(format!("mac:{mac}")),
|
||||
(_, None, Some(ip)) => Some(format!("ip:{ip}")),
|
||||
(_, None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_device_identifier_tx(
|
||||
pub async fn insert_device_identifier_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
device_id: &str,
|
||||
identifier: &NormalizedDeviceIdentifier,
|
||||
@@ -313,9 +202,7 @@ pub(in crate::state::store) async fn insert_device_identifier_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn device_identifier_from_row(
|
||||
row: DeviceIdentifierRow,
|
||||
) -> Result<DeviceIdentifier> {
|
||||
pub fn device_identifier_from_row(row: DeviceIdentifierRow) -> Result<DeviceIdentifier> {
|
||||
Ok(DeviceIdentifier {
|
||||
identifier_key: row.identifier_key,
|
||||
device_id: row.device_id,
|
||||
@@ -325,75 +212,3 @@ pub(in crate::state::store) fn device_identifier_from_row(
|
||||
.context("negative device identifier timestamp in state db")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(in crate::state::store) fn agent_observation_from_row(
|
||||
row: AgentObservationRow,
|
||||
) -> Result<AgentDeviceObservation> {
|
||||
Ok(AgentDeviceObservation {
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
first_seen_unix: u64::try_from(row.first_seen_unix)
|
||||
.context("negative observation first_seen timestamp in state db")?,
|
||||
last_seen_unix: u64::try_from(row.last_seen_unix)
|
||||
.context("negative observation last_seen timestamp in state db")?,
|
||||
last_action: row.last_action,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn agent_observation_view_from_row(
|
||||
row: AgentObservationViewRow,
|
||||
) -> Result<AgentDeviceObservationView> {
|
||||
let known_device = match (row.device_id, row.display_name, row.pinned) {
|
||||
(Some(device_id), Some(display_name), Some(pinned)) => Some(KnownDeviceSummary {
|
||||
device_id,
|
||||
display_name,
|
||||
pinned: pinned != 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(AgentDeviceObservationView {
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
first_seen_unix: u64::try_from(row.first_seen_unix)
|
||||
.context("negative observation first_seen timestamp in state db")?,
|
||||
last_seen_unix: u64::try_from(row.last_seen_unix)
|
||||
.context("negative observation last_seen timestamp in state db")?,
|
||||
last_action: row.last_action,
|
||||
known_device,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn agent_observation_event_from_row(
|
||||
row: AgentObservationEventRow,
|
||||
) -> Result<AgentDeviceObservationEvent> {
|
||||
let known_device = match (row.device_id, row.display_name, row.pinned) {
|
||||
(Some(device_id), Some(display_name), Some(pinned)) => Some(KnownDeviceSummary {
|
||||
device_id,
|
||||
display_name,
|
||||
pinned: pinned != 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(AgentDeviceObservationEvent {
|
||||
event_id: row.event_id,
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
action: row.action,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
ts_unix: u64::try_from(row.ts_unix)
|
||||
.context("negative observation event timestamp in state db")?,
|
||||
known_device,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::state::store) async fn import_tree_raw(
|
||||
pub async fn import_tree_raw(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
sled_tree: &str,
|
||||
@@ -28,10 +28,7 @@ pub(in crate::state::store) async fn import_tree_raw(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_enroll_tokens(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
pub async fn import_enroll_tokens(pool: &SqlitePool, legacy: &sled::Db) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("enroll_tokens")
|
||||
.context("failed to open legacy enroll_tokens tree")?;
|
||||
@@ -39,12 +36,13 @@ pub(in crate::state::store) async fn import_enroll_tokens(
|
||||
let (token, expiry) = item.context("failed reading legacy enroll token")?;
|
||||
let token =
|
||||
String::from_utf8(token.to_vec()).context("legacy enroll token key is not utf-8")?;
|
||||
let expires_at_unix = decode_expiry(expiry.as_ref())?;
|
||||
sqlx::query(
|
||||
let expires_at_unix = i64::try_from(decode_expiry(expiry.as_ref())?)
|
||||
.context("legacy token expiry overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
token,
|
||||
expires_at_unix
|
||||
)
|
||||
.bind(token)
|
||||
.bind(i64::try_from(expires_at_unix).context("legacy token expiry overflow")?)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy enroll token")?;
|
||||
@@ -52,10 +50,7 @@ pub(in crate::state::store) async fn import_enroll_tokens(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_agents(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
pub async fn import_agents(pool: &SqlitePool, legacy: &sled::Db) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("agents")
|
||||
.context("failed to open legacy agents tree")?;
|
||||
@@ -65,20 +60,19 @@ pub(in crate::state::store) async fn import_agents(
|
||||
String::from_utf8(agent_id.to_vec()).context("legacy agent id is not utf-8")?;
|
||||
let agent_token =
|
||||
String::from_utf8(agent_token.to_vec()).context("legacy agent token is not utf-8")?;
|
||||
sqlx::query("INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)")
|
||||
.bind(agent_id)
|
||||
.bind(agent_token)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy agent")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||
agent_id,
|
||||
agent_token
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy agent")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_agent_meta(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
pub async fn import_agent_meta(pool: &SqlitePool, legacy: &sled::Db) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("agent_meta")
|
||||
.context("failed to open legacy agent_meta tree")?;
|
||||
@@ -88,20 +82,19 @@ pub(in crate::state::store) async fn import_agent_meta(
|
||||
String::from_utf8(agent_id.to_vec()).context("legacy agent id is not utf-8")?;
|
||||
let nickname =
|
||||
String::from_utf8(nickname.to_vec()).context("legacy nickname is not utf-8")?;
|
||||
sqlx::query("INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)")
|
||||
.bind(agent_id)
|
||||
.bind(nickname)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy agent metadata")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
|
||||
agent_id,
|
||||
nickname
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy agent metadata")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_audit_events(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
pub async fn import_audit_events(pool: &SqlitePool, legacy: &sled::Db) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("audit_events")
|
||||
.context("failed to open legacy audit_events tree")?;
|
||||
@@ -117,10 +110,7 @@ pub(in crate::state::store) async fn import_audit_events(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_active_alerts(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
pub async fn import_active_alerts(pool: &SqlitePool, legacy: &sled::Db) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("active_alerts")
|
||||
.context("failed to open legacy active_alerts tree")?;
|
||||
@@ -135,10 +125,7 @@ pub(in crate::state::store) async fn import_active_alerts(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_alert_transitions(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
pub async fn import_alert_transitions(pool: &SqlitePool, legacy: &sled::Db) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("alert_transitions")
|
||||
.context("failed to open legacy alert_transitions tree")?;
|
||||
|
||||
@@ -1,108 +1,52 @@
|
||||
pub(in crate::state::store) struct NormalizedDeviceIdentifier {
|
||||
pub(in crate::state::store) identifier_key: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) value: String,
|
||||
pub struct NormalizedDeviceIdentifier {
|
||||
pub identifier_key: String,
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct EnrollTokenRow {
|
||||
pub(in crate::state::store) token: String,
|
||||
pub(in crate::state::store) expires_at_unix: i64,
|
||||
pub struct EnrollTokenRow {
|
||||
pub token: String,
|
||||
pub expires_at_unix: i64,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct KnownDeviceRow {
|
||||
pub(in crate::state::store) device_id: String,
|
||||
pub(in crate::state::store) display_name: String,
|
||||
pub(in crate::state::store) pinned: i64,
|
||||
pub(in crate::state::store) created_at_unix: i64,
|
||||
pub(in crate::state::store) updated_at_unix: i64,
|
||||
pub(in crate::state::store) notes: Option<String>,
|
||||
pub struct KnownDeviceRow {
|
||||
pub device_id: String,
|
||||
pub display_name: String,
|
||||
pub pinned: i64,
|
||||
pub created_at_unix: i64,
|
||||
pub updated_at_unix: i64,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct DeviceIdentifierRow {
|
||||
pub(in crate::state::store) identifier_key: String,
|
||||
pub(in crate::state::store) device_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) value: String,
|
||||
pub(in crate::state::store) created_at_unix: i64,
|
||||
pub struct DeviceIdentifierRow {
|
||||
pub identifier_key: String,
|
||||
pub device_id: String,
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
pub created_at_unix: i64,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AgentObservationRow {
|
||||
pub(in crate::state::store) observation_key: String,
|
||||
pub(in crate::state::store) agent_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) first_seen_unix: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) last_action: String,
|
||||
pub struct AlertStateRow {
|
||||
pub alert_id: String,
|
||||
pub kind: String,
|
||||
pub severity: String,
|
||||
pub status: String,
|
||||
pub agent_id: Option<String>,
|
||||
pub message: String,
|
||||
pub value: i64,
|
||||
pub threshold: i64,
|
||||
pub last_seen_unix: i64,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AgentObservationViewRow {
|
||||
pub(in crate::state::store) observation_key: String,
|
||||
pub(in crate::state::store) agent_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) first_seen_unix: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) last_action: String,
|
||||
pub(in crate::state::store) device_id: Option<String>,
|
||||
pub(in crate::state::store) display_name: Option<String>,
|
||||
pub(in crate::state::store) pinned: Option<i64>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AgentObservationEventRow {
|
||||
pub(in crate::state::store) event_id: String,
|
||||
pub(in crate::state::store) observation_key: String,
|
||||
pub(in crate::state::store) agent_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) action: String,
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) ts_unix: i64,
|
||||
pub(in crate::state::store) device_id: Option<String>,
|
||||
pub(in crate::state::store) display_name: Option<String>,
|
||||
pub(in crate::state::store) pinned: Option<i64>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct ObservationIdentifierRow {
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct ObservationCurrentRow {
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) first_seen_unix: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) last_action: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AlertStateRow {
|
||||
pub(in crate::state::store) alert_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) severity: String,
|
||||
pub(in crate::state::store) status: String,
|
||||
pub(in crate::state::store) agent_id: Option<String>,
|
||||
pub(in crate::state::store) message: String,
|
||||
pub(in crate::state::store) value: i64,
|
||||
pub(in crate::state::store) threshold: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) metadata_json: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AlertTransitionRow {
|
||||
pub(in crate::state::store) transition_id: String,
|
||||
pub(in crate::state::store) ts_unix: i64,
|
||||
pub(in crate::state::store) alert_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) agent_id: Option<String>,
|
||||
pub(in crate::state::store) from_status: Option<String>,
|
||||
pub(in crate::state::store) to_status: String,
|
||||
pub(in crate::state::store) message: String,
|
||||
pub(in crate::state::store) metadata_json: String,
|
||||
pub struct AlertTransitionRow {
|
||||
pub transition_id: String,
|
||||
pub ts_unix: i64,
|
||||
pub alert_id: String,
|
||||
pub kind: String,
|
||||
pub agent_id: Option<String>,
|
||||
pub from_status: Option<String>,
|
||||
pub to_status: String,
|
||||
pub message: String,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::Store;
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct TestStore {
|
||||
store: Option<Store>,
|
||||
pub dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestStore {
|
||||
pub fn store(&self) -> &Store {
|
||||
self.store.as_ref().expect("store already taken")
|
||||
}
|
||||
|
||||
pub async fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("wakey-cp-test-{}", uuid::Uuid::new_v4()));
|
||||
let db_path = dir.join("state.sqlite3");
|
||||
let store = Store::load_or_init(&db_path, Vec::new(), Duration::from_secs(60))
|
||||
.await
|
||||
.expect("store should initialize");
|
||||
TestStore {
|
||||
store: Some(store),
|
||||
dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestStore {
|
||||
fn drop(&mut self) {
|
||||
self.store.take();
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
use std::{collections::BTreeSet, ops::DerefMut};
|
||||
|
||||
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 {
|
||||
pub async fn upsert_agent_observations(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
observations: Vec<AgentDeviceObservationInput>,
|
||||
) -> Result<usize> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting observation transaction")?;
|
||||
let mut written = 0usize;
|
||||
for observation in observations {
|
||||
let observation = normalize_agent_observation(agent_id, observation)?;
|
||||
upsert_observation_tx(&mut tx, &observation).await?;
|
||||
written = written.saturating_add(1);
|
||||
}
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing observation transaction")?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservation>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation limit overflow")?;
|
||||
let rows = if let Some(agent_id) = agent_id {
|
||||
sqlx::query_as!(
|
||||
AgentObservationRow,
|
||||
r#"SELECT observation_key as "observation_key!", agent_id as "agent_id!",
|
||||
kind as "kind!", mac, ip, hostname,
|
||||
first_seen_unix, last_seen_unix, last_action as "last_action!"
|
||||
FROM agent_device_observations
|
||||
WHERE agent_id = ?1
|
||||
ORDER BY last_seen_unix DESC
|
||||
LIMIT ?2"#,
|
||||
agent_id,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
AgentObservationRow,
|
||||
r#"SELECT observation_key as "observation_key!", agent_id as "agent_id!",
|
||||
kind as "kind!", mac, ip, hostname,
|
||||
first_seen_unix, last_seen_unix, last_action as "last_action!"
|
||||
FROM agent_device_observations
|
||||
ORDER BY last_seen_unix DESC
|
||||
LIMIT ?1"#,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.context("failed listing agent observations")?;
|
||||
rows.into_iter().map(agent_observation_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_views(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservationView>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation limit overflow")?;
|
||||
let rows = if let Some(agent_id) = agent_id {
|
||||
sqlx::query_as!(
|
||||
AgentObservationViewRow,
|
||||
r#"SELECT observations.observation_key as "observation_key!",
|
||||
observations.agent_id as "agent_id!",
|
||||
observations.kind as "kind!",
|
||||
observations.mac,
|
||||
observations.ip,
|
||||
observations.hostname,
|
||||
observations.first_seen_unix,
|
||||
observations.last_seen_unix,
|
||||
observations.last_action as "last_action!",
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observations observations
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac
|
||||
WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
WHERE observations.agent_id = ?1
|
||||
ORDER BY observations.last_seen_unix DESC
|
||||
LIMIT ?2"#,
|
||||
agent_id,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
AgentObservationViewRow,
|
||||
r#"SELECT observations.observation_key as "observation_key!",
|
||||
observations.agent_id as "agent_id!",
|
||||
observations.kind as "kind!",
|
||||
observations.mac,
|
||||
observations.ip,
|
||||
observations.hostname,
|
||||
observations.first_seen_unix,
|
||||
observations.last_seen_unix,
|
||||
observations.last_action as "last_action!",
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observations observations
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac
|
||||
WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
ORDER BY observations.last_seen_unix DESC
|
||||
LIMIT ?1"#,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.context("failed listing agent observation views")?;
|
||||
rows.into_iter()
|
||||
.map(agent_observation_view_from_row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_events(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
observation_key: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservationEvent>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation event limit overflow")?;
|
||||
let rows = sqlx::query_as!(
|
||||
AgentObservationEventRow,
|
||||
r#"SELECT events.event_id as "event_id!",
|
||||
('agent:' || events.agent_id || ':' || events.kind || ':' ||
|
||||
CASE
|
||||
WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL
|
||||
THEN 'mac:' || events.mac || ':ip:' || events.ip
|
||||
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
|
||||
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
ELSE ''
|
||||
END) as "observation_key!",
|
||||
events.agent_id as "agent_id!",
|
||||
events.kind as "kind!",
|
||||
events.action as "action!",
|
||||
events.mac,
|
||||
events.ip,
|
||||
events.hostname,
|
||||
events.ts_unix,
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observation_events events
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
|
||||
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
WHERE (?1 IS NULL OR events.agent_id = ?1)
|
||||
AND (?2 IS NULL OR events.kind = ?2)
|
||||
AND (?3 IS NULL OR events.mac = ?3)
|
||||
AND (?4 IS NULL OR events.ip = ?4)
|
||||
AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||
|
||||
CASE
|
||||
WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL
|
||||
THEN 'mac:' || events.mac || ':ip:' || events.ip
|
||||
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
|
||||
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
ELSE ''
|
||||
END) = ?5)
|
||||
ORDER BY events.ts_unix DESC
|
||||
LIMIT ?6"#,
|
||||
agent_id,
|
||||
kind,
|
||||
mac,
|
||||
ip,
|
||||
observation_key,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent observation events")?;
|
||||
rows.into_iter()
|
||||
.map(agent_observation_event_from_row)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wakey_core::Presence;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IssuedAgent {
|
||||
@@ -64,47 +66,6 @@ pub struct DeviceIdentifierInput {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservation {
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservationView {
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservationEvent {
|
||||
pub event_id: String,
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub ts_unix: u64,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceSummary {
|
||||
pub device_id: String,
|
||||
@@ -113,14 +74,81 @@ pub struct KnownDeviceSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceObservationInput {
|
||||
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,
|
||||
pub struct AgentDeviceRow {
|
||||
pub agent_id: String,
|
||||
pub device_key: String,
|
||||
pub presence: String,
|
||||
pub display_name: Option<String>,
|
||||
pub first_seen_unix: i64,
|
||||
pub last_seen_unix: i64,
|
||||
}
|
||||
|
||||
impl AgentDeviceRow {
|
||||
pub fn presence(&self) -> Presence {
|
||||
Presence::from(self.presence.as_str())
|
||||
}
|
||||
|
||||
pub fn first_seen(&self) -> u64 {
|
||||
self.first_seen_unix.max(0) as u64
|
||||
}
|
||||
|
||||
pub fn last_seen(&self) -> u64 {
|
||||
self.last_seen_unix.max(0) as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceMacRow {
|
||||
pub agent_id: String,
|
||||
pub device_key: String,
|
||||
pub mac: String,
|
||||
}
|
||||
|
||||
impl TryFrom<&AgentDeviceMacRow> for MacAddr {
|
||||
type Error = macaddr::ParseError;
|
||||
|
||||
fn try_from(row: &AgentDeviceMacRow) -> Result<Self, Self::Error> {
|
||||
row.mac.parse()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceIpRow {
|
||||
pub agent_id: String,
|
||||
pub device_key: String,
|
||||
pub ip: String,
|
||||
}
|
||||
|
||||
impl TryFrom<&AgentDeviceIpRow> for std::net::IpAddr {
|
||||
type Error = std::net::AddrParseError;
|
||||
|
||||
fn try_from(row: &AgentDeviceIpRow) -> Result<Self, Self::Error> {
|
||||
row.ip.parse()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceHostnameRow {
|
||||
pub agent_id: String,
|
||||
pub device_key: String,
|
||||
pub hostname: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceFactRow {
|
||||
pub agent_id: String,
|
||||
pub device_key: String,
|
||||
pub fact_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceWithChildren {
|
||||
pub device: AgentDeviceRow,
|
||||
pub macs: Vec<macaddr::MacAddr>,
|
||||
pub ips: Vec<std::net::IpAddr>,
|
||||
pub hostnames: Vec<String>,
|
||||
#[allow(dead_code)]
|
||||
pub facts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
Reference in new issue
Block a user