from-what now

This commit is contained in:
lda
2026-04-27 01:43:24 +07:00 Verified
parent d91b3dab77
commit 29f522e722
9 changed files with 564 additions and 12 deletions
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
"describe": {
"columns": [
{
"name": "observation_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
false,
false,
true,
false,
false
]
},
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT mac, ip\n FROM agent_device_observations\n WHERE observation_key = ?1",
"describe": {
"columns": [
{
"name": "mac",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true
]
},
"hash": "2a15f44a37465451a1b7a0f02d64af5278c6803879241d65520a89e45cb05d47"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?1",
"describe": {
"columns": [
{
"name": "observation_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
false,
false,
true,
true,
true
]
},
"hash": "409e1e7aa2d9bc46030001e5c522d7c2812b4583dceadfe5c535a866ac76489c"
}
+38 -4
View File
@@ -8,8 +8,8 @@ use tracing::{info, warn};
use crate::api::json_error; use crate::api::json_error;
use crate::runtime::{AppState, SessionEvent}; use crate::runtime::{AppState, SessionEvent};
use crate::state::{ use crate::state::{
AgentDeviceObservation, AgentDeviceObservationInput, AuditEventInput, DeviceIdentifierInput, AgentDeviceObservationInput, AgentDeviceObservationView, AuditEventInput,
KnownDeviceInput, DeviceIdentifierInput, KnownDeviceInput,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -91,6 +91,11 @@ pub struct DeviceIdentifierRequest {
pub value: String, pub value: String,
} }
#[derive(Debug, Deserialize)]
pub struct AttachObservationIdentifierRequest {
pub observation_key: String,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct KnownDeviceResponse { pub struct KnownDeviceResponse {
pub device_id: String, pub device_id: String,
@@ -592,6 +597,33 @@ pub async fn attach_device_identifier(
} }
} }
pub async fn attach_observation_identifier(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
Json(req): Json<AttachObservationIdentifierRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state
.store
.attach_observation_identifier(&device_id, &req.observation_key)
.await
{
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
Ok(None) => Err(json_error(
StatusCode::NOT_FOUND,
"known_device_not_found",
"known device not found",
)),
Err(err) => {
warn!(error = %err, "failed to attach observation identifier");
Err(json_error(
StatusCode::BAD_REQUEST,
"attach_observation_identifier_failed",
&err.to_string(),
))
}
}
}
pub async fn upload_agent_observations( pub async fn upload_agent_observations(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<UploadAgentObservationsRequest>, Json(req): Json<UploadAgentObservationsRequest>,
@@ -648,7 +680,7 @@ pub async fn list_agent_observations(
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state match state
.store .store
.list_agent_observations(query.agent_id.as_deref(), query.limit.unwrap_or(500)) .list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
.await .await
{ {
Ok(observations) => Ok(( Ok(observations) => Ok((
@@ -696,7 +728,9 @@ pub async fn state_stats(
} }
} }
fn agent_observation_response(observation: AgentDeviceObservation) -> AgentDeviceObservation { fn agent_observation_response(
observation: AgentDeviceObservationView,
) -> AgentDeviceObservationView {
observation observation
} }
+4 -3
View File
@@ -11,9 +11,10 @@ pub use audit::list_audit_events;
pub use commands::{list_agents, run_command}; pub use commands::{list_agents, run_command};
pub use control::{ pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, attach_device_identifier, create_known_device, enroll, forget_known_device, StateStatsResponse, attach_device_identifier, attach_observation_identifier,
healthz, issue_enroll_token, list_agent_observations, list_enroll_tokens, list_known_devices, create_known_device, enroll, forget_known_device, healthz, issue_enroll_token,
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, upload_agent_observations, list_agent_observations, list_enroll_tokens, list_known_devices, revoke_agent,
revoke_enroll_token, set_agent_nickname, state_stats, upload_agent_observations,
}; };
pub fn json_error( pub fn json_error(
+4
View File
@@ -109,6 +109,10 @@ fn control_api_routes() -> Router<AppState> {
"/api/v1/control/devices/{device_id}/identifiers", "/api/v1/control/devices/{device_id}/identifiers",
post(api::attach_device_identifier), post(api::attach_device_identifier),
) )
.route(
"/api/v1/control/devices/{device_id}/identifiers/from-observation",
post(api::attach_observation_identifier),
)
.route("/api/v1/control/audit/events", get(api::list_audit_events)) .route("/api/v1/control/audit/events", get(api::list_audit_events))
.route("/api/v1/control/alerts", get(api::active_alerts)) .route("/api/v1/control/alerts", get(api::active_alerts))
.route("/api/v1/control/alerts/history", get(api::alert_history)) .route("/api/v1/control/alerts/history", get(api::alert_history))
+2 -2
View File
@@ -3,6 +3,6 @@ mod types;
pub use store::Store; pub use store::Store;
pub use types::{ pub use types::{
AgentDeviceObservation, AgentDeviceObservationInput, AlertState, AuditEvent, AuditEventFilter, AgentDeviceObservationInput, AgentDeviceObservationView, AlertState, AuditEvent,
AuditEventInput, DeviceIdentifierInput, KnownDevice, KnownDeviceInput, AuditEventFilter, AuditEventInput, DeviceIdentifierInput, KnownDevice, KnownDeviceInput,
}; };
+297 -3
View File
@@ -8,9 +8,10 @@ use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::state::types::{ use crate::state::types::{
AgentDeviceObservation, AgentDeviceObservationInput, AlertState, AlertTransition, AuditEvent, AgentDeviceObservation, AgentDeviceObservationInput, AgentDeviceObservationView, AlertState,
AuditEventFilter, AuditEventInput, DeviceIdentifier, DeviceIdentifierInput, EnrollTokenInfo, AlertTransition, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifier,
IssuedAgent, IssuedEnrollToken, KnownDevice, KnownDeviceInput, StateStats, DeviceIdentifierInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken, KnownDevice,
KnownDeviceInput, KnownDeviceSummary, StateStats,
}; };
pub struct Store { pub struct Store {
@@ -531,6 +532,42 @@ impl Store {
self.get_known_device(device_id).await 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 = sqlx::query_as!(
ObservationIdentifierRow,
r#"SELECT mac, ip
FROM agent_device_observations
WHERE observation_key = ?1"#,
observation_key
)
.fetch_optional(&self.pool)
.await
.context("failed reading observation identifier")?;
let Some(observation) = observation else {
anyhow::bail!("observation not found");
};
let input = 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"))?;
self.attach_device_identifier(device_id, input).await
}
#[allow(unused)] // we'll get to this #[allow(unused)] // we'll get to this
pub async fn lookup_known_device_by_identifier( pub async fn lookup_known_device_by_identifier(
&self, &self,
@@ -618,6 +655,7 @@ impl Store {
Ok(written) Ok(written)
} }
#[cfg_attr(not(test), allow(dead_code))]
pub async fn list_agent_observations( pub async fn list_agent_observations(
&self, &self,
agent_id: Option<&str>, agent_id: Option<&str>,
@@ -658,6 +696,80 @@ impl Store {
rows.into_iter().map(agent_observation_from_row).collect() 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 append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> { pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> {
let event = AuditEvent { let event = AuditEvent {
event_id: format!("evt-{}", Uuid::new_v4()), event_id: format!("evt-{}", Uuid::new_v4()),
@@ -1056,6 +1168,7 @@ struct DeviceIdentifierRow {
created_at_unix: i64, created_at_unix: i64,
} }
#[cfg_attr(not(test), allow(dead_code))]
struct AgentObservationRow { struct AgentObservationRow {
observation_key: String, observation_key: String,
agent_id: String, agent_id: String,
@@ -1068,6 +1181,26 @@ struct AgentObservationRow {
last_action: String, last_action: String,
} }
struct AgentObservationViewRow {
observation_key: String,
agent_id: String,
kind: String,
mac: Option<String>,
ip: Option<String>,
hostname: Option<String>,
first_seen_unix: i64,
last_seen_unix: i64,
last_action: String,
device_id: Option<String>,
display_name: Option<String>,
pinned: Option<i64>,
}
struct ObservationIdentifierRow {
mac: Option<String>,
ip: Option<String>,
}
struct AlertStateRow { struct AlertStateRow {
alert_id: String, alert_id: String,
kind: String, kind: String,
@@ -1238,6 +1371,7 @@ fn device_identifier_from_row(row: DeviceIdentifierRow) -> Result<DeviceIdentifi
}) })
} }
#[cfg_attr(not(test), allow(dead_code))]
fn agent_observation_from_row(row: AgentObservationRow) -> Result<AgentDeviceObservation> { fn agent_observation_from_row(row: AgentObservationRow) -> Result<AgentDeviceObservation> {
Ok(AgentDeviceObservation { Ok(AgentDeviceObservation {
observation_key: row.observation_key, observation_key: row.observation_key,
@@ -1254,6 +1388,33 @@ fn agent_observation_from_row(row: AgentObservationRow) -> Result<AgentDeviceObs
}) })
} }
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,
})
}
async fn import_tree_raw( async fn import_tree_raw(
pool: &SqlitePool, pool: &SqlitePool,
legacy: &sled::Db, legacy: &sled::Db,
@@ -1919,6 +2080,139 @@ mod tests {
cleanup_dir(&dir); 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] #[tokio::test]
async fn audit_events_append_and_filter() { async fn audit_events_append_and_filter() {
let (store, dir) = make_store().await; let (store, dir) = make_store().await;
+21
View File
@@ -77,6 +77,27 @@ pub struct AgentDeviceObservation {
pub last_action: String, 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 KnownDeviceSummary {
pub device_id: String,
pub display_name: String,
pub pinned: bool,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AgentDeviceObservationInput { pub struct AgentDeviceObservationInput {
pub kind: String, pub kind: String,