Compare commits
5
Commits
23656cd6d7
...
fe6155da83
No files matched your search
@@ -16,11 +16,14 @@ cw = "c --target=x86_64-pc-windows-msvc"
|
|||||||
bw = "b --target=x86_64-pc-windows-msvc"
|
bw = "b --target=x86_64-pc-windows-msvc"
|
||||||
tw = "t --target=x86_64-pc-windows-msvc"
|
tw = "t --target=x86_64-pc-windows-msvc"
|
||||||
|
|
||||||
|
|
||||||
check-all = "check --workspace --all-features --all-targets"
|
check-all = "check --workspace --all-features --all-targets"
|
||||||
clippy-all = "clippy --workspace --all-features --all-targets"
|
clippy-all = "clippy --workspace --all-features --all-targets"
|
||||||
|
test-all = "test --workspace --all-features --all-targets"
|
||||||
fmt-all = "fmt --all"
|
fmt-all = "fmt --all"
|
||||||
|
|
||||||
# idk below
|
# idk below
|
||||||
|
ldar = "r -qrp"
|
||||||
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
||||||
t = "test -- --nocapture --test-threads=1"
|
t = "test -- --nocapture --test-threads=1"
|
||||||
tdebug = "test -- --nocapture --test-threads=1 --show-output"
|
tdebug = "test -- --nocapture --test-threads=1 --show-output"
|
||||||
|
|||||||
Generated
+5
-5
@@ -3701,7 +3701,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3722,7 +3722,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3746,7 +3746,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3774,7 +3774,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3785,7 +3785,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -25,7 +25,7 @@ pub struct Cli {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Show merged device inventory rows.
|
/// Show merged device inventory rows.
|
||||||
#[command(visible_alias = "status")]
|
#[command(visible_alias = "status", alias = "inv")]
|
||||||
Inventory(InventoryArgs),
|
Inventory(InventoryArgs),
|
||||||
/// Show DHCP leases, optionally enriched with current neighbor state.
|
/// Show DHCP leases, optionally enriched with current neighbor state.
|
||||||
Leases(LeasesArgs),
|
Leases(LeasesArgs),
|
||||||
@@ -39,8 +39,11 @@ pub enum Command {
|
|||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct LeasesArgs {
|
pub struct LeasesArgs {
|
||||||
/// Include best-known current neighbor state for each lease IP.
|
/// Legacy: include neighbor state on lease rows. Prefer `wakey inventory`.
|
||||||
#[arg(long)]
|
#[arg(
|
||||||
|
long,
|
||||||
|
help = "Deprecated: include neighbor state on lease rows. Prefer `wakey inventory`."
|
||||||
|
)]
|
||||||
pub include_state: bool,
|
pub include_state: bool,
|
||||||
/// Print machine-readable JSON instead of a table.
|
/// Print machine-readable JSON instead of a table.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -201,10 +204,11 @@ pub async fn run(cli: Cli) -> Result<()> {
|
|||||||
json = args.json,
|
json = args.json,
|
||||||
"dispatching leases command"
|
"dispatching leases command"
|
||||||
);
|
);
|
||||||
let leases = wakey::get_leases(wakey_core::LeaseQuery {
|
let leases = if args.include_state {
|
||||||
include_state: args.include_state,
|
wakey::get_leases_with_neighbor_state().await?
|
||||||
})
|
} else {
|
||||||
.await?;
|
wakey::get_leases().await?
|
||||||
|
};
|
||||||
if args.json {
|
if args.json {
|
||||||
println!("{}", serde_json::to_string_pretty(&leases)?);
|
println!("{}", serde_json::to_string_pretty(&leases)?);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@ pub use wakey_linux;
|
|||||||
|
|
||||||
pub use service::{
|
pub use service::{
|
||||||
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
|
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
|
||||||
inventory, leases_without_state, merge_devices, resolve_devices, resolve_query,
|
get_leases_with_neighbor_state, inventory, leases_without_state, local_observation_to_fact,
|
||||||
|
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
|
||||||
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||||
};
|
};
|
||||||
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
|
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
|
||||||
|
|||||||
+141
-14
@@ -1,7 +1,8 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use tracing::{debug, warn};
|
||||||
use wakey_core::{
|
use wakey_core::{
|
||||||
Device, DeviceInventory, DhcpLease, DhcpLeaseWithState, InventoryQuery, NeighborEntry,
|
Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
|
||||||
Presence, Query,
|
NeighborEntry, Presence, Query,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::service::leases::get_leases;
|
use crate::service::leases::get_leases;
|
||||||
@@ -20,13 +21,26 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
|||||||
/// directly from raw Linux source rows.
|
/// directly from raw Linux source rows.
|
||||||
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
||||||
let leases = get_leases(wakey_core::LeaseQuery {
|
let leases = get_leases().await?;
|
||||||
include_state: false,
|
let observations = match wakey_linux::dhcp::list_local_observations().await {
|
||||||
})
|
Ok(observations) => observations
|
||||||
.await?;
|
.into_iter()
|
||||||
Ok(DeviceInventory {
|
.filter_map(local_observation_to_fact)
|
||||||
devices: merge_devices(neighbors, leases, &query),
|
.collect::<Vec<_>>(),
|
||||||
})
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed reading local hook observations for inventory");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug!(
|
||||||
|
neighbors = neighbors.len(),
|
||||||
|
leases = leases.len(),
|
||||||
|
observations = observations.len(),
|
||||||
|
"building device inventory"
|
||||||
|
);
|
||||||
|
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
|
||||||
|
debug!(devices = devices.len(), "merged device inventory");
|
||||||
|
Ok(DeviceInventory { devices })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge raw neighbor entries and DHCP leases into device aggregates.
|
/// Merge raw neighbor entries and DHCP leases into device aggregates.
|
||||||
@@ -37,26 +51,53 @@ pub fn merge_devices(
|
|||||||
neighbors: Vec<NeighborEntry>,
|
neighbors: Vec<NeighborEntry>,
|
||||||
leases: Vec<DhcpLeaseWithState>,
|
leases: Vec<DhcpLeaseWithState>,
|
||||||
query: &InventoryQuery,
|
query: &InventoryQuery,
|
||||||
|
) -> Vec<Device> {
|
||||||
|
merge_devices_with_observations(neighbors, leases, Vec::new(), query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
|
||||||
|
/// aggregates.
|
||||||
|
pub fn merge_devices_with_observations(
|
||||||
|
neighbors: Vec<NeighborEntry>,
|
||||||
|
leases: Vec<DhcpLeaseWithState>,
|
||||||
|
observations: Vec<DeviceObservationFact>,
|
||||||
|
query: &InventoryQuery,
|
||||||
) -> Vec<Device> {
|
) -> Vec<Device> {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
|
type DeviceParts = (
|
||||||
|
Vec<NeighborEntry>,
|
||||||
|
Vec<DhcpLease>,
|
||||||
|
Vec<DeviceObservationFact>,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut by_key: BTreeMap<String, DeviceParts> = BTreeMap::new();
|
||||||
|
|
||||||
for row in neighbors {
|
for row in neighbors {
|
||||||
let key = row
|
let key = row
|
||||||
.mac
|
.mac
|
||||||
.map(|m| m.to_string())
|
.map(|m| m.to_string())
|
||||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
||||||
by_mac.entry(key).or_default().0.push(row);
|
by_key.entry(key).or_default().0.push(row);
|
||||||
}
|
}
|
||||||
for lease in leases {
|
for lease in leases {
|
||||||
let key = lease.lease_line.mac.to_string();
|
let key = lease.lease_line.mac.to_string();
|
||||||
by_mac.entry(key).or_default().1.push(lease.lease_line);
|
by_key.entry(key).or_default().1.push(lease.lease_line);
|
||||||
|
}
|
||||||
|
for observation in observations {
|
||||||
|
let key = observation
|
||||||
|
.mac
|
||||||
|
.map(|mac| mac.to_string())
|
||||||
|
.or_else(|| observation.ip.map(|ip| format!("ip:{ip}")))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
by_key.entry(key).or_default().2.push(observation);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut devices: Vec<Device> = by_mac
|
let mut devices: Vec<Device> = by_key
|
||||||
.into_values()
|
.into_values()
|
||||||
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
|
.map(|(neighbors, leases, observations)| {
|
||||||
|
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut texts = Vec::new();
|
let mut texts = Vec::new();
|
||||||
@@ -90,6 +131,42 @@ pub fn merge_devices(
|
|||||||
devices
|
devices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn local_observation_to_fact(
|
||||||
|
observation: wakey_linux::dhcp::LocalDeviceObservation,
|
||||||
|
) -> Option<DeviceObservationFact> {
|
||||||
|
let mac = match observation.mac.as_deref() {
|
||||||
|
Some(raw) => match raw.parse() {
|
||||||
|
Ok(mac) => Some(mac),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
mac = raw,
|
||||||
|
error = %err,
|
||||||
|
"ignoring invalid observed mac in inventory"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if mac.is_none() && observation.ip.is_none() {
|
||||||
|
warn!(
|
||||||
|
kind = %observation.kind,
|
||||||
|
action = %observation.action,
|
||||||
|
"ignoring hook observation without mac or ip"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DeviceObservationFact {
|
||||||
|
kind: observation.kind,
|
||||||
|
action: observation.action,
|
||||||
|
mac,
|
||||||
|
ip: observation.ip,
|
||||||
|
hostname: observation.hostname,
|
||||||
|
first_seen_unix: Some(observation.first_seen_unix),
|
||||||
|
last_seen_unix: Some(observation.last_seen_unix),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fn presence_rank(presence: Presence) -> u8 {
|
const fn presence_rank(presence: Presence) -> u8 {
|
||||||
match presence {
|
match presence {
|
||||||
Presence::Online => 3,
|
Presence::Online => 3,
|
||||||
@@ -175,4 +252,54 @@ mod tests {
|
|||||||
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
|
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_preserves_hook_observation_facts() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let out = merge_devices_with_observations(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec![DeviceObservationFact {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: "update".into(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||||
|
hostname: Some("lda".into()),
|
||||||
|
first_seen_unix: Some(10),
|
||||||
|
last_seen_unix: Some(20),
|
||||||
|
}],
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].names, vec!["lda".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
out[0].id,
|
||||||
|
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
||||||
|
);
|
||||||
|
assert_eq!(out[0].observations.len(), 1);
|
||||||
|
assert_eq!(out[0].presence, Presence::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_devices_marks_remove_only_observation_offline() {
|
||||||
|
let query = InventoryQueryBuilder::new().build();
|
||||||
|
let out = merge_devices_with_observations(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
vec![DeviceObservationFact {
|
||||||
|
kind: "neigh".into(),
|
||||||
|
action: "remove".into(),
|
||||||
|
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||||
|
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
|
||||||
|
hostname: None,
|
||||||
|
first_seen_unix: Some(10),
|
||||||
|
last_seen_unix: Some(20),
|
||||||
|
}],
|
||||||
|
&query,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].presence, Presence::Offline);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+12
-6
@@ -1,16 +1,22 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||||
|
|
||||||
/// Read DHCP leases and optionally enrich them with current neighbor-state data.
|
/// Read DHCP leases from dnsmasq plus remembered hook names.
|
||||||
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
|
pub async fn get_leases() -> Result<Vec<DhcpLeaseWithState>> {
|
||||||
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||||
.await
|
.await
|
||||||
.context("failed to read DHCP leases")?;
|
.context("failed to read DHCP leases")?;
|
||||||
if query.include_state {
|
|
||||||
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
|
||||||
} else {
|
|
||||||
Ok(leases_without_state(leases))
|
Ok(leases_without_state(leases))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read DHCP leases and enrich them with current neighbor state.
|
||||||
|
///
|
||||||
|
/// Prefer inventory for device status; this exists for the legacy leases view.
|
||||||
|
pub async fn get_leases_with_neighbor_state() -> Result<Vec<DhcpLeaseWithState>> {
|
||||||
|
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||||
|
.await
|
||||||
|
.context("failed to read DHCP leases")?;
|
||||||
|
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
||||||
|
|||||||
+5
-2
@@ -5,8 +5,11 @@ pub mod query;
|
|||||||
pub mod wake;
|
pub mod wake;
|
||||||
|
|
||||||
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
|
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
|
||||||
pub use inventory::{inventory, merge_devices, resolve_devices};
|
pub use inventory::{
|
||||||
pub use leases::{get_leases, leases_without_state};
|
inventory, local_observation_to_fact, merge_devices, merge_devices_with_observations,
|
||||||
|
resolve_devices,
|
||||||
|
};
|
||||||
|
pub use leases::{get_leases, get_leases_with_neighbor_state, leases_without_state};
|
||||||
pub use query::{resolve_query, resolve_selector};
|
pub use query::{resolve_query, resolve_selector};
|
||||||
pub use wake::{
|
pub use wake::{
|
||||||
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||||
|
|||||||
+1
-1
@@ -293,7 +293,7 @@ function buildCommandPayload(
|
|||||||
return { command: { kind: "devs", dev: null, up_only: false } };
|
return { command: { kind: "devs", dev: null, up_only: false } };
|
||||||
}
|
}
|
||||||
if (kind === "leases") {
|
if (kind === "leases") {
|
||||||
return { command: { kind: "leases", include_state: true } };
|
return { command: { kind: "leases", include_state: false } };
|
||||||
}
|
}
|
||||||
if (kind === "inventory") {
|
if (kind === "inventory") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,367 +1 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
|
||||||
|
|
||||||
import {
|
|
||||||
attachObservationIdentifier,
|
|
||||||
createKnownDevice,
|
|
||||||
fetchKnownDevices,
|
|
||||||
fetchObservations,
|
|
||||||
type AgentDeviceObservation,
|
|
||||||
type KnownDevice,
|
|
||||||
} from "@/api";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
type Filter = "all" | "unknown" | "known";
|
|
||||||
|
|
||||||
function observationLabel(observation: AgentDeviceObservation): string {
|
|
||||||
return (
|
|
||||||
observation.hostname?.trim() ||
|
|
||||||
observation.mac?.trim() ||
|
|
||||||
observation.ip?.trim() ||
|
|
||||||
observation.observation_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSeen(tsUnix: number): string {
|
|
||||||
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
|
||||||
return new Date(tsUnix * 1000).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function identifierSummary(device: KnownDevice): string {
|
|
||||||
if (!device.identifiers.length) return "no identifiers";
|
|
||||||
return device.identifiers
|
|
||||||
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObservationsPage() {
|
|
||||||
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
|
||||||
const [filter, setFilter] = useState<Filter>("unknown");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [selectedDevices, setSelectedDevices] = useState<
|
|
||||||
Record<string, string>
|
|
||||||
>({});
|
|
||||||
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
|
||||||
const [busyKey, setBusyKey] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const [nextObservations, nextDevices] = await Promise.all([
|
|
||||||
fetchObservations({ limit: 500 }),
|
|
||||||
fetchKnownDevices(),
|
|
||||||
]);
|
|
||||||
setObservations(nextObservations);
|
|
||||||
setDevices(nextDevices);
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return observations.filter((observation) => {
|
|
||||||
if (filter === "known" && !observation.known_device) return false;
|
|
||||||
if (filter === "unknown" && observation.known_device) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
|
|
||||||
const haystack = [
|
|
||||||
observation.hostname,
|
|
||||||
observation.mac,
|
|
||||||
observation.ip,
|
|
||||||
observation.agent_id,
|
|
||||||
observation.kind,
|
|
||||||
observation.last_action,
|
|
||||||
observation.known_device?.display_name,
|
|
||||||
]
|
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase();
|
|
||||||
return haystack.includes(q);
|
|
||||||
});
|
|
||||||
}, [filter, observations, query]);
|
|
||||||
|
|
||||||
async function attachExisting(observation: AgentDeviceObservation) {
|
|
||||||
const deviceId = selectedDevices[observation.observation_key];
|
|
||||||
if (!deviceId) return;
|
|
||||||
setBusyKey(observation.observation_key);
|
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const device = await attachObservationIdentifier(
|
|
||||||
deviceId,
|
|
||||||
observation.observation_key,
|
|
||||||
);
|
|
||||||
setStatus(
|
|
||||||
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
|
||||||
);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setBusyKey("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAndAttach(observation: AgentDeviceObservation) {
|
|
||||||
const displayName =
|
|
||||||
newNames[observation.observation_key]?.trim() ||
|
|
||||||
observationLabel(observation);
|
|
||||||
if (!displayName.trim()) return;
|
|
||||||
setBusyKey(observation.observation_key);
|
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const device = await createKnownDevice({
|
|
||||||
display_name: displayName,
|
|
||||||
pinned: true,
|
|
||||||
identifiers: [],
|
|
||||||
});
|
|
||||||
const updated = await attachObservationIdentifier(
|
|
||||||
device.device_id,
|
|
||||||
observation.observation_key,
|
|
||||||
);
|
|
||||||
setStatus(
|
|
||||||
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
|
||||||
);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err));
|
|
||||||
} finally {
|
|
||||||
setBusyKey("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
|
||||||
<CardTitle>Observed Devices</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void load()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Refreshing..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
|
||||||
<span>Status</span>
|
|
||||||
<Select
|
|
||||||
value={filter}
|
|
||||||
onValueChange={(value: Filter | null) => {
|
|
||||||
if (value) setFilter(value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<span>{filter}</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent alignItemWithTrigger={false}>
|
|
||||||
<SelectItem value="unknown">unknown</SelectItem>
|
|
||||||
<SelectItem value="known">known</SelectItem>
|
|
||||||
<SelectItem value="all">all</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
|
||||||
<span>Search</span>
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="hostname, mac, ip, agent, known device"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {filtered.length} of {observations.length}
|
|
||||||
</p>
|
|
||||||
{status && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
|
||||||
{status}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-2 overflow-x-auto">
|
|
||||||
<div className="grid min-w-[68rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
|
||||||
<span>Device</span>
|
|
||||||
<span>Network</span>
|
|
||||||
<span>Agent</span>
|
|
||||||
<span>Seen</span>
|
|
||||||
<span>Known Device</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filtered.map((observation) => {
|
|
||||||
const busy = busyKey === observation.observation_key;
|
|
||||||
const attachable = Boolean(observation.mac || observation.ip);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={observation.observation_key}
|
|
||||||
className="grid min-w-[68rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">
|
|
||||||
{observationLabel(observation)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
|
||||||
<Badge variant="outline">{observation.kind}</Badge>
|
|
||||||
<Badge variant="outline">{observation.last_action}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-muted-foreground">
|
|
||||||
<div className="truncate" title={observation.ip ?? "-"}>
|
|
||||||
{observation.ip ?? "-"}
|
|
||||||
</div>
|
|
||||||
<div className="truncate" title={observation.mac ?? "-"}>
|
|
||||||
{observation.mac ?? "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 truncate text-muted-foreground">
|
|
||||||
{observation.agent_id}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-muted-foreground">
|
|
||||||
<div className="truncate">
|
|
||||||
{formatSeen(observation.last_seen_unix)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
{observation.known_device ? (
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<Badge variant="secondary">known</Badge>
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{observation.known_device.display_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{observation.known_device.pinned
|
|
||||||
? "pinned"
|
|
||||||
: "not pinned"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid min-w-0 gap-2">
|
|
||||||
<div className="flex min-w-0 gap-2">
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
selectedDevices[observation.observation_key] ?? ""
|
|
||||||
}
|
|
||||||
onValueChange={(value: string | null) => {
|
|
||||||
setSelectedDevices((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[observation.observation_key]: value ?? "",
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="min-w-0 flex-1">
|
|
||||||
<span className="truncate">
|
|
||||||
{selectedDevices[observation.observation_key]
|
|
||||||
? devices.find(
|
|
||||||
(device) =>
|
|
||||||
device.device_id ===
|
|
||||||
selectedDevices[
|
|
||||||
observation.observation_key
|
|
||||||
],
|
|
||||||
)?.display_name
|
|
||||||
: "Attach to existing"}
|
|
||||||
</span>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent
|
|
||||||
className="max-w-[min(92vw,28rem)]"
|
|
||||||
alignItemWithTrigger={false}
|
|
||||||
>
|
|
||||||
{devices.map((device) => (
|
|
||||||
<SelectItem
|
|
||||||
key={device.device_id}
|
|
||||||
value={device.device_id}
|
|
||||||
>
|
|
||||||
<span className="grid min-w-0 gap-0.5">
|
|
||||||
<span className="truncate">
|
|
||||||
{device.display_name}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{identifierSummary(device)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={
|
|
||||||
busy ||
|
|
||||||
!attachable ||
|
|
||||||
!selectedDevices[observation.observation_key]
|
|
||||||
}
|
|
||||||
onClick={() => void attachExisting(observation)}
|
|
||||||
>
|
|
||||||
Attach
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 gap-2">
|
|
||||||
<Input
|
|
||||||
value={newNames[observation.observation_key] ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
setNewNames((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[observation.observation_key]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={`Create as ${observationLabel(observation)}`}
|
|
||||||
disabled={busy || !attachable}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={busy || !attachable}
|
|
||||||
onClick={() => void createAndAttach(observation)}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!filtered.length && (
|
|
||||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
|
||||||
No observations found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchObservationHistory,
|
||||||
|
type AgentDeviceObservation,
|
||||||
|
type AgentDeviceObservationEvent,
|
||||||
|
} from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
formatSeen,
|
||||||
|
observationLabel,
|
||||||
|
observationStateLabel,
|
||||||
|
} from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
observation: AgentDeviceObservation | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObservationHistoryDialog({
|
||||||
|
observation,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: Props) {
|
||||||
|
const [events, setEvents] = useState<AgentDeviceObservationEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !observation) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
fetchObservationHistory({
|
||||||
|
observationKey: observation.observation_key,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.then(setEvents)
|
||||||
|
.catch((err) => {
|
||||||
|
setEvents([]);
|
||||||
|
setError(String(err));
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open, observation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Observation History</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{observation ? observationLabel(observation) : "No observation selected"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-auto rounded-md border">
|
||||||
|
<div className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span>Time</span>
|
||||||
|
<span>State</span>
|
||||||
|
<span>Source</span>
|
||||||
|
<span>Network</span>
|
||||||
|
<span>Known Device</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.event_id}
|
||||||
|
className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{formatSeen(event.ts_unix)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Badge variant={event.action === "remove" ? "outline" : "secondary"}>
|
||||||
|
{observationStateLabel(event.action)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 gap-1">
|
||||||
|
<Badge variant="outline">{event.kind}</Badge>
|
||||||
|
<Badge variant="outline">{event.action}</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-muted-foreground">
|
||||||
|
<span className="block truncate">{event.ip ?? "-"}</span>
|
||||||
|
<span className="block truncate">{event.mac ?? "-"}</span>
|
||||||
|
{event.hostname && (
|
||||||
|
<span className="block truncate">{event.hostname}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{event.known_device?.display_name ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!events.length && (
|
||||||
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
{loading ? "Loading history..." : "No history found"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { identifierSummary, observationLabel } from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
observation: AgentDeviceObservation;
|
||||||
|
devices: KnownDevice[];
|
||||||
|
busy: boolean;
|
||||||
|
selectedDeviceId: string;
|
||||||
|
newName: string;
|
||||||
|
onSelectDevice: (deviceId: string) => void;
|
||||||
|
onNewNameChange: (name: string) => void;
|
||||||
|
onAttachExisting: () => void;
|
||||||
|
onCreateAndAttach: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObservationIdentityActions({
|
||||||
|
observation,
|
||||||
|
devices,
|
||||||
|
busy,
|
||||||
|
selectedDeviceId,
|
||||||
|
newName,
|
||||||
|
onSelectDevice,
|
||||||
|
onNewNameChange,
|
||||||
|
onAttachExisting,
|
||||||
|
onCreateAndAttach,
|
||||||
|
}: Props) {
|
||||||
|
const attachable = Boolean(observation.mac || observation.ip);
|
||||||
|
const selectedDevice = devices.find(
|
||||||
|
(device) => device.device_id === selectedDeviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-w-0 gap-2">
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedDeviceId}
|
||||||
|
onValueChange={(value: string | null) => onSelectDevice(value ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-0 flex-1">
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedDevice?.display_name ?? "Attach to existing"}
|
||||||
|
</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
className="max-w-[min(92vw,28rem)]"
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<SelectItem key={device.device_id} value={device.device_id}>
|
||||||
|
<span className="grid min-w-0 gap-0.5">
|
||||||
|
<span className="truncate">{device.display_name}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{identifierSummary(device)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || !attachable || !selectedDeviceId}
|
||||||
|
onClick={onAttachExisting}
|
||||||
|
>
|
||||||
|
Attach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 gap-2">
|
||||||
|
<Input
|
||||||
|
value={newName}
|
||||||
|
onChange={(event) => onNewNameChange(event.target.value)}
|
||||||
|
placeholder={`Create as ${observationLabel(observation)}`}
|
||||||
|
disabled={busy || !attachable}
|
||||||
|
/>
|
||||||
|
<Button size="sm" disabled={busy || !attachable} onClick={onCreateAndAttach}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ObservationIdentityActions } from "@/pages/observations/ObservationIdentityActions";
|
||||||
|
import {
|
||||||
|
formatSeen,
|
||||||
|
observationLabel,
|
||||||
|
observationStateLabel,
|
||||||
|
} from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
observations: AgentDeviceObservation[];
|
||||||
|
devices: KnownDevice[];
|
||||||
|
selectedDevices: Record<string, string>;
|
||||||
|
newNames: Record<string, string>;
|
||||||
|
busyKey: string;
|
||||||
|
onSelectDevice: (observationKey: string, deviceId: string) => void;
|
||||||
|
onNewNameChange: (observationKey: string, name: string) => void;
|
||||||
|
onAttachExisting: (observation: AgentDeviceObservation) => void;
|
||||||
|
onCreateAndAttach: (observation: AgentDeviceObservation) => void;
|
||||||
|
onOpenHistory: (observation: AgentDeviceObservation) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObservationTable({
|
||||||
|
observations,
|
||||||
|
devices,
|
||||||
|
selectedDevices,
|
||||||
|
newNames,
|
||||||
|
busyKey,
|
||||||
|
onSelectDevice,
|
||||||
|
onNewNameChange,
|
||||||
|
onAttachExisting,
|
||||||
|
onCreateAndAttach,
|
||||||
|
onOpenHistory,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 overflow-x-auto">
|
||||||
|
<div className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||||
|
<span>Device</span>
|
||||||
|
<span>Network</span>
|
||||||
|
<span>Agent</span>
|
||||||
|
<span>Seen</span>
|
||||||
|
<span>Known Device</span>
|
||||||
|
<span>History</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{observations.map((observation) => {
|
||||||
|
const busy = busyKey === observation.observation_key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={observation.observation_key}
|
||||||
|
className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{observationLabel(observation)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline">{observation.kind}</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
observation.last_action === "remove"
|
||||||
|
? "outline"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{observationStateLabel(observation.last_action)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{observation.last_action}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-muted-foreground">
|
||||||
|
<div className="truncate" title={observation.ip ?? "-"}>
|
||||||
|
{observation.ip ?? "-"}
|
||||||
|
</div>
|
||||||
|
<div className="truncate" title={observation.mac ?? "-"}>
|
||||||
|
{observation.mac ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{observation.agent_id}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-muted-foreground">
|
||||||
|
<div className="truncate">
|
||||||
|
{formatSeen(observation.last_seen_unix)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{observation.known_device ? (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Badge variant="secondary">known</Badge>
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{observation.known_device.display_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{observation.known_device.pinned
|
||||||
|
? "pinned"
|
||||||
|
: "not pinned"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ObservationIdentityActions
|
||||||
|
observation={observation}
|
||||||
|
devices={devices}
|
||||||
|
busy={busy}
|
||||||
|
selectedDeviceId={
|
||||||
|
selectedDevices[observation.observation_key] ?? ""
|
||||||
|
}
|
||||||
|
newName={newNames[observation.observation_key] ?? ""}
|
||||||
|
onSelectDevice={(deviceId) =>
|
||||||
|
onSelectDevice(observation.observation_key, deviceId)
|
||||||
|
}
|
||||||
|
onNewNameChange={(name) =>
|
||||||
|
onNewNameChange(observation.observation_key, name)
|
||||||
|
}
|
||||||
|
onAttachExisting={() => onAttachExisting(observation)}
|
||||||
|
onCreateAndAttach={() => onCreateAndAttach(observation)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenHistory(observation)}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!observations.length && (
|
||||||
|
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||||
|
No observations found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachObservationIdentifier,
|
||||||
|
createKnownDevice,
|
||||||
|
fetchKnownDevices,
|
||||||
|
fetchObservations,
|
||||||
|
type AgentDeviceObservation,
|
||||||
|
type KnownDevice,
|
||||||
|
} from "@/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ObservationHistoryDialog } from "@/pages/observations/ObservationHistoryDialog";
|
||||||
|
import { ObservationTable } from "@/pages/observations/ObservationTable";
|
||||||
|
import {
|
||||||
|
observationLabel,
|
||||||
|
type ObservationFilter,
|
||||||
|
} from "@/pages/observations/utils";
|
||||||
|
|
||||||
|
export function ObservationsPage() {
|
||||||
|
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
||||||
|
const [filter, setFilter] = useState<ObservationFilter>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
||||||
|
const [busyKey, setBusyKey] = useState("");
|
||||||
|
const [historyObservation, setHistoryObservation] =
|
||||||
|
useState<AgentDeviceObservation | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [nextObservations, nextDevices] = await Promise.all([
|
||||||
|
fetchObservations({ limit: 500 }),
|
||||||
|
fetchKnownDevices(),
|
||||||
|
]);
|
||||||
|
setObservations(nextObservations);
|
||||||
|
setDevices(nextDevices);
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return observations.filter((observation) => {
|
||||||
|
if (filter === "known" && !observation.known_device) return false;
|
||||||
|
if (filter === "unknown" && observation.known_device) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
|
||||||
|
const haystack = [
|
||||||
|
observation.hostname,
|
||||||
|
observation.mac,
|
||||||
|
observation.ip,
|
||||||
|
observation.agent_id,
|
||||||
|
observation.kind,
|
||||||
|
observation.last_action,
|
||||||
|
observation.known_device?.display_name,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
});
|
||||||
|
}, [filter, observations, query]);
|
||||||
|
|
||||||
|
async function attachExisting(observation: AgentDeviceObservation) {
|
||||||
|
const deviceId = selectedDevices[observation.observation_key];
|
||||||
|
if (!deviceId) return;
|
||||||
|
setBusyKey(observation.observation_key);
|
||||||
|
setStatus("");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const device = await attachObservationIdentifier(
|
||||||
|
deviceId,
|
||||||
|
observation.observation_key,
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndAttach(observation: AgentDeviceObservation) {
|
||||||
|
const displayName =
|
||||||
|
newNames[observation.observation_key]?.trim() ||
|
||||||
|
observationLabel(observation);
|
||||||
|
if (!displayName.trim()) return;
|
||||||
|
setBusyKey(observation.observation_key);
|
||||||
|
setStatus("");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const device = await createKnownDevice({
|
||||||
|
display_name: displayName,
|
||||||
|
pinned: true,
|
||||||
|
identifiers: [],
|
||||||
|
});
|
||||||
|
const updated = await attachObservationIdentifier(
|
||||||
|
device.device_id,
|
||||||
|
observation.observation_key,
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setBusyKey("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle>Observed Devices</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>Status</span>
|
||||||
|
<Select
|
||||||
|
value={filter}
|
||||||
|
onValueChange={(value: ObservationFilter | null) => {
|
||||||
|
if (value) setFilter(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<span>{filter}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent alignItemWithTrigger={false}>
|
||||||
|
<SelectItem value="unknown">unknown</SelectItem>
|
||||||
|
<SelectItem value="known">known</SelectItem>
|
||||||
|
<SelectItem value="all">all</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>Search</span>
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="hostname, mac, ip, agent, known device"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {filtered.length} of {observations.length}
|
||||||
|
</p>
|
||||||
|
{status && (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||||
|
{status}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ObservationTable
|
||||||
|
observations={filtered}
|
||||||
|
devices={devices}
|
||||||
|
selectedDevices={selectedDevices}
|
||||||
|
newNames={newNames}
|
||||||
|
busyKey={busyKey}
|
||||||
|
onSelectDevice={(observationKey, deviceId) =>
|
||||||
|
setSelectedDevices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[observationKey]: deviceId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onNewNameChange={(observationKey, name) =>
|
||||||
|
setNewNames((prev) => ({ ...prev, [observationKey]: name }))
|
||||||
|
}
|
||||||
|
onAttachExisting={(observation) => void attachExisting(observation)}
|
||||||
|
onCreateAndAttach={(observation) =>
|
||||||
|
void createAndAttach(observation)
|
||||||
|
}
|
||||||
|
onOpenHistory={setHistoryObservation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ObservationHistoryDialog
|
||||||
|
observation={historyObservation}
|
||||||
|
open={Boolean(historyObservation)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setHistoryObservation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { AgentDeviceObservation, KnownDevice } from "@/api";
|
||||||
|
|
||||||
|
export type ObservationFilter = "all" | "unknown" | "known";
|
||||||
|
|
||||||
|
export function observationLabel(observation: AgentDeviceObservation): string {
|
||||||
|
return (
|
||||||
|
observation.hostname?.trim() ||
|
||||||
|
observation.mac?.trim() ||
|
||||||
|
observation.ip?.trim() ||
|
||||||
|
observation.observation_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSeen(tsUnix: number): string {
|
||||||
|
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
||||||
|
return new Date(tsUnix * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifierSummary(device: KnownDevice): string {
|
||||||
|
if (!device.identifiers.length) return "no identifiers";
|
||||||
|
return device.identifiers
|
||||||
|
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observationStateLabel(action: string): string {
|
||||||
|
if (action === "remove") return "stale";
|
||||||
|
if (action === "add" || action === "update" || action === "old") {
|
||||||
|
return "present";
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{debug, info, instrument};
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
@@ -70,8 +70,33 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
|||||||
&config.mac_name_cache_path,
|
&config.mac_name_cache_path,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let observations = match wakey::wakey_linux::dhcp::list_local_observations_from_path(
|
||||||
|
&config.observation_store_path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(observations) => observations
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(wakey::local_observation_to_fact)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed reading local hook observations for inventory command");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
debug!(
|
||||||
|
neighbors = neighbors.len(),
|
||||||
|
leases = leases.len(),
|
||||||
|
observations = observations.len(),
|
||||||
|
"dispatching inventory with merged sources"
|
||||||
|
);
|
||||||
let inventory = wakey_core::DeviceInventory {
|
let inventory = wakey_core::DeviceInventory {
|
||||||
devices: wakey::merge_devices(neighbors, wakey::leases_without_state(leases), &query),
|
devices: wakey::merge_devices_with_observations(
|
||||||
|
neighbors,
|
||||||
|
wakey::leases_without_state(leases),
|
||||||
|
observations,
|
||||||
|
&query,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
debug!(
|
debug!(
|
||||||
rows = inventory.devices.len(),
|
rows = inventory.devices.len(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,819 +1,18 @@
|
|||||||
use axum::Json;
|
mod devices;
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
mod enroll;
|
||||||
use axum::http::StatusCode;
|
mod observations;
|
||||||
use axum::response::IntoResponse;
|
mod stats;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::api::json_error;
|
pub use devices::{
|
||||||
use crate::runtime::{AppState, SessionEvent};
|
attach_device_identifier, attach_observation_identifier, create_known_device,
|
||||||
use crate::state::{
|
forget_known_device, list_known_devices,
|
||||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
|
||||||
AuditEventInput, DeviceIdentifierInput, KnownDeviceInput,
|
|
||||||
};
|
};
|
||||||
|
pub use enroll::{
|
||||||
#[derive(Debug, Deserialize)]
|
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||||
pub struct EnrollRequest {
|
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, revoke_enroll_token,
|
||||||
pub enroll_token: String,
|
set_agent_nickname,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct EnrollResponse {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub agent_token: String,
|
|
||||||
pub server_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct IssueEnrollTokenResponse {
|
|
||||||
pub enroll_token: String,
|
|
||||||
pub expires_at_unix: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct IssueEnrollTokenQuery {
|
|
||||||
pub ttl_seconds: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct EnrollTokenStatus {
|
|
||||||
pub enroll_token: String,
|
|
||||||
pub expires_at_unix: u64,
|
|
||||||
pub expired: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct RevokeEnrollTokenResponse {
|
|
||||||
pub token: String,
|
|
||||||
pub revoked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct RevokeAgentResponse {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub revoked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct SetAgentNicknameRequest {
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct SetAgentNicknameResponse {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub nickname: Option<String>,
|
|
||||||
pub updated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct StateStatsResponse {
|
|
||||||
pub db_path: String,
|
|
||||||
pub schema_version: u32,
|
|
||||||
pub agent_count: usize,
|
|
||||||
pub enroll_token_count: usize,
|
|
||||||
pub expired_enroll_token_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct CreateKnownDeviceRequest {
|
|
||||||
pub display_name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pinned: bool,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub identifiers: Vec<DeviceIdentifierRequest>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct DeviceIdentifierRequest {
|
|
||||||
pub kind: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct AttachObservationIdentifierRequest {
|
|
||||||
pub observation_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct KnownDeviceResponse {
|
|
||||||
pub device_id: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub pinned: bool,
|
|
||||||
pub created_at_unix: u64,
|
|
||||||
pub updated_at_unix: u64,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub identifiers: Vec<DeviceIdentifierResponse>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct DeviceIdentifierResponse {
|
|
||||||
pub identifier_key: String,
|
|
||||||
pub device_id: String,
|
|
||||||
pub kind: String,
|
|
||||||
pub value: String,
|
|
||||||
pub created_at_unix: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ForgetKnownDeviceResponse {
|
|
||||||
pub device_id: String,
|
|
||||||
pub forgotten: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct UploadAgentObservationsRequest {
|
|
||||||
pub agent_id: String,
|
|
||||||
pub agent_token: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub observations: Vec<AgentObservationRequest>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct AgentObservationRequest {
|
|
||||||
pub kind: String,
|
|
||||||
pub action: String,
|
|
||||||
pub mac: Option<String>,
|
|
||||||
pub ip: Option<String>,
|
|
||||||
pub hostname: Option<String>,
|
|
||||||
pub first_seen_unix: u64,
|
|
||||||
pub last_seen_unix: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct UploadAgentObservationsResponse {
|
|
||||||
pub accepted: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ListObservationsQuery {
|
|
||||||
pub agent_id: Option<String>,
|
|
||||||
pub limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ListObservationHistoryQuery {
|
|
||||||
pub agent_id: Option<String>,
|
|
||||||
pub kind: Option<String>,
|
|
||||||
pub mac: Option<String>,
|
|
||||||
pub ip: Option<String>,
|
|
||||||
pub observation_key: Option<String>,
|
|
||||||
pub limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn healthz() -> &'static str {
|
|
||||||
"ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn enroll(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(req): Json<EnrollRequest>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.enroll(&req.enroll_token).await {
|
|
||||||
Ok(issued) => {
|
|
||||||
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
|
|
||||||
if let Err(err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "agent".into(),
|
|
||||||
actor_id: Some(issued.agent_id.clone()),
|
|
||||||
agent_id: Some(issued.agent_id.clone()),
|
|
||||||
request_id: None,
|
|
||||||
event_type: "agent_enroll".into(),
|
|
||||||
outcome: "ok".into(),
|
|
||||||
latency_ms: None,
|
|
||||||
message: "agent enrollment accepted".into(),
|
|
||||||
metadata: serde_json::json!({}),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %err, "failed to append audit event for enroll success");
|
|
||||||
}
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(EnrollResponse {
|
|
||||||
agent_id: issued.agent_id,
|
|
||||||
agent_token: issued.agent_token,
|
|
||||||
server_url: state.public_url,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "agent enrollment rejected");
|
|
||||||
if let Err(audit_err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "agent".into(),
|
|
||||||
actor_id: None,
|
|
||||||
agent_id: None,
|
|
||||||
request_id: None,
|
|
||||||
event_type: "agent_enroll".into(),
|
|
||||||
outcome: "rejected".into(),
|
|
||||||
latency_ms: None,
|
|
||||||
message: err.to_string(),
|
|
||||||
metadata: serde_json::json!({}),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
|
|
||||||
}
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"enrollment_rejected",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn issue_enroll_token(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<IssueEnrollTokenQuery>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let ttl = std::time::Duration::from_secs(
|
|
||||||
query
|
|
||||||
.ttl_seconds
|
|
||||||
.unwrap_or(state.enroll_token_ttl.as_secs())
|
|
||||||
.max(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
match state.store.issue_enroll_token(ttl).await {
|
|
||||||
Ok(issued) => {
|
|
||||||
info!(
|
|
||||||
expires_at_unix = issued.expires_at_unix,
|
|
||||||
"issued enroll token"
|
|
||||||
);
|
|
||||||
if let Err(err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "admin_api".into(),
|
|
||||||
actor_id: None,
|
|
||||||
agent_id: None,
|
|
||||||
request_id: None,
|
|
||||||
event_type: "enroll_token_issue".into(),
|
|
||||||
outcome: "ok".into(),
|
|
||||||
latency_ms: None,
|
|
||||||
message: "issued enroll token".into(),
|
|
||||||
metadata: serde_json::json!({
|
|
||||||
"ttl_seconds": ttl.as_secs(),
|
|
||||||
"expires_at_unix": issued.expires_at_unix,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %err, "failed to append audit event for token issuance");
|
|
||||||
}
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(IssueEnrollTokenResponse {
|
|
||||||
enroll_token: issued.enroll_token,
|
|
||||||
expires_at_unix: issued.expires_at_unix,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to issue enroll token");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"issue_enroll_token_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_enroll_tokens(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.list_enroll_tokens().await {
|
|
||||||
Ok(tokens) => {
|
|
||||||
if let Err(err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "admin_api".into(),
|
|
||||||
actor_id: None,
|
|
||||||
agent_id: None,
|
|
||||||
request_id: None,
|
|
||||||
event_type: "enroll_token_list".into(),
|
|
||||||
outcome: "ok".into(),
|
|
||||||
latency_ms: None,
|
|
||||||
message: "listed enroll tokens".into(),
|
|
||||||
metadata: serde_json::json!({
|
|
||||||
"count": tokens.len(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %err, "failed to append audit event for token listing");
|
|
||||||
}
|
|
||||||
let body = tokens
|
|
||||||
.into_iter()
|
|
||||||
.map(|t| EnrollTokenStatus {
|
|
||||||
enroll_token: t.enroll_token,
|
|
||||||
expires_at_unix: t.expires_at_unix,
|
|
||||||
expired: t.expired,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Ok((StatusCode::OK, Json(body)))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to list enroll tokens");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"list_enroll_tokens_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn revoke_enroll_token(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(token): AxumPath<String>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.revoke_enroll_token(&token).await {
|
|
||||||
Ok(revoked) => {
|
|
||||||
if let Err(err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "admin_api".into(),
|
|
||||||
actor_id: None,
|
|
||||||
agent_id: None,
|
|
||||||
request_id: None,
|
|
||||||
event_type: "enroll_token_revoke".into(),
|
|
||||||
outcome: if revoked {
|
|
||||||
"ok".into()
|
|
||||||
} else {
|
|
||||||
"not_found".into()
|
|
||||||
},
|
|
||||||
latency_ms: None,
|
|
||||||
message: if revoked {
|
|
||||||
"revoked enroll token".into()
|
|
||||||
} else {
|
|
||||||
"enroll token not found".into()
|
|
||||||
},
|
|
||||||
metadata: serde_json::json!({ "token": token }),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %err, "failed to append audit event for token revoke");
|
|
||||||
}
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(RevokeEnrollTokenResponse { token, revoked }),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to revoke enroll token");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"revoke_enroll_token_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn revoke_agent(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(agent_id): AxumPath<String>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.revoke_agent(&agent_id).await {
|
|
||||||
Ok(revoked) => {
|
|
||||||
if revoked {
|
|
||||||
// Request a graceful websocket close, then remove session from active map.
|
|
||||||
if let Some(session) = state.sessions.write().await.remove(&agent_id) {
|
|
||||||
let _ = session.tx.send(SessionEvent::Close);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "admin_api".into(),
|
|
||||||
actor_id: None,
|
|
||||||
agent_id: Some(agent_id.clone()),
|
|
||||||
request_id: None,
|
|
||||||
event_type: "agent_revoke".into(),
|
|
||||||
outcome: if revoked {
|
|
||||||
"ok".into()
|
|
||||||
} else {
|
|
||||||
"not_found".into()
|
|
||||||
},
|
|
||||||
latency_ms: None,
|
|
||||||
message: if revoked {
|
|
||||||
"revoked agent credentials".into()
|
|
||||||
} else {
|
|
||||||
"agent credentials not found".into()
|
|
||||||
},
|
|
||||||
metadata: serde_json::json!({ "agent_id": agent_id }),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %err, "failed to append audit event for agent revoke");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(RevokeAgentResponse { agent_id, revoked }),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to revoke agent credentials");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"revoke_agent_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_agent_nickname(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(agent_id): AxumPath<String>,
|
|
||||||
Json(req): Json<SetAgentNicknameRequest>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let normalized = req
|
|
||||||
.nickname
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
|
|
||||||
match state
|
|
||||||
.store
|
|
||||||
.set_agent_nickname(&agent_id, normalized.as_deref())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(updated) => {
|
|
||||||
if let Err(err) = state
|
|
||||||
.store
|
|
||||||
.append_audit_event(AuditEventInput {
|
|
||||||
actor_type: "admin_api".into(),
|
|
||||||
actor_id: None,
|
|
||||||
agent_id: Some(agent_id.clone()),
|
|
||||||
request_id: None,
|
|
||||||
event_type: "agent_nickname_set".into(),
|
|
||||||
outcome: if updated {
|
|
||||||
"ok".into()
|
|
||||||
} else {
|
|
||||||
"not_found".into()
|
|
||||||
},
|
|
||||||
latency_ms: None,
|
|
||||||
message: if updated {
|
|
||||||
"updated agent nickname".into()
|
|
||||||
} else {
|
|
||||||
"agent not found for nickname update".into()
|
|
||||||
},
|
|
||||||
metadata: serde_json::json!({
|
|
||||||
"agent_id": agent_id,
|
|
||||||
"nickname": normalized,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(error = %err, "failed to append audit event for nickname set");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(SetAgentNicknameResponse {
|
|
||||||
agent_id,
|
|
||||||
nickname: normalized,
|
|
||||||
updated,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to update agent nickname");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"set_agent_nickname_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_known_device(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(req): Json<CreateKnownDeviceRequest>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let input = KnownDeviceInput {
|
|
||||||
display_name: req.display_name,
|
|
||||||
pinned: req.pinned,
|
|
||||||
notes: req.notes,
|
|
||||||
identifiers: req
|
|
||||||
.identifiers
|
|
||||||
.into_iter()
|
|
||||||
.map(|identifier| DeviceIdentifierInput {
|
|
||||||
kind: identifier.kind,
|
|
||||||
value: identifier.value,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
|
pub use observations::{
|
||||||
match state.store.create_known_device(input).await {
|
list_agent_observation_history, list_agent_observations, upload_agent_observations,
|
||||||
Ok(device) => Ok((StatusCode::CREATED, Json(known_device_response(device)))),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to create known device");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"create_known_device_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_known_devices(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.list_known_devices().await {
|
|
||||||
Ok(devices) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(
|
|
||||||
devices
|
|
||||||
.into_iter()
|
|
||||||
.map(known_device_response)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to list known devices");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"list_known_devices_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn forget_known_device(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(device_id): AxumPath<String>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.forget_known_device(&device_id).await {
|
|
||||||
Ok(forgotten) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(ForgetKnownDeviceResponse {
|
|
||||||
device_id,
|
|
||||||
forgotten,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to forget known device");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"forget_known_device_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn attach_device_identifier(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(device_id): AxumPath<String>,
|
|
||||||
Json(req): Json<DeviceIdentifierRequest>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let input = DeviceIdentifierInput {
|
|
||||||
kind: req.kind,
|
|
||||||
value: req.value,
|
|
||||||
};
|
};
|
||||||
|
pub use stats::{StateStatsResponse, state_stats};
|
||||||
match state
|
|
||||||
.store
|
|
||||||
.attach_device_identifier(&device_id, input)
|
|
||||||
.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 device identifier");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"attach_device_identifier_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(req): Json<UploadAgentObservationsRequest>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
if !state
|
|
||||||
.store
|
|
||||||
.verify_agent_token(&req.agent_id, &req.agent_token)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Err(json_error(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"agent_auth_rejected",
|
|
||||||
"agent credentials rejected",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let observations = req
|
|
||||||
.observations
|
|
||||||
.into_iter()
|
|
||||||
.map(|observation| AgentDeviceObservationInput {
|
|
||||||
kind: observation.kind,
|
|
||||||
action: observation.action,
|
|
||||||
mac: observation.mac,
|
|
||||||
ip: observation.ip,
|
|
||||||
hostname: observation.hostname,
|
|
||||||
first_seen_unix: observation.first_seen_unix,
|
|
||||||
last_seen_unix: observation.last_seen_unix,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
match state
|
|
||||||
.store
|
|
||||||
.upsert_agent_observations(&req.agent_id, observations)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(accepted) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(UploadAgentObservationsResponse { accepted }),
|
|
||||||
)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, agent_id = %req.agent_id, "failed to upload agent observations");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"upload_observations_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_agent_observations(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<ListObservationsQuery>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state
|
|
||||||
.store
|
|
||||||
.list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(observations) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(
|
|
||||||
observations
|
|
||||||
.into_iter()
|
|
||||||
.map(agent_observation_response) // no-op premium
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to list agent observations");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"list_observations_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_agent_observation_history(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<ListObservationHistoryQuery>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
let kind = normalized_query_value(query.kind).map(|value| value.to_ascii_lowercase());
|
|
||||||
let mac = normalized_query_value(query.mac).map(|value| value.to_ascii_lowercase());
|
|
||||||
let ip = normalized_query_value(query.ip);
|
|
||||||
let observation_key = normalized_query_value(query.observation_key);
|
|
||||||
match state
|
|
||||||
.store
|
|
||||||
.list_agent_observation_events(
|
|
||||||
query.agent_id.as_deref(),
|
|
||||||
kind.as_deref(),
|
|
||||||
mac.as_deref(),
|
|
||||||
ip.as_deref(),
|
|
||||||
observation_key.as_deref(),
|
|
||||||
query.limit.unwrap_or(500),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(
|
|
||||||
events
|
|
||||||
.into_iter()
|
|
||||||
.map(agent_observation_event_response) // no-op premium
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to list agent observation history");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"list_observation_history_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn state_stats(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
|
||||||
match state.store.stats().await {
|
|
||||||
Ok(stats) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
Json(StateStatsResponse {
|
|
||||||
db_path: stats.db_path.display().to_string(),
|
|
||||||
schema_version: stats.schema_version,
|
|
||||||
agent_count: stats.agent_count,
|
|
||||||
enroll_token_count: stats.enroll_token_count,
|
|
||||||
expired_enroll_token_count: stats.expired_enroll_token_count,
|
|
||||||
}),
|
|
||||||
)),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to read state stats");
|
|
||||||
Err(json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"state_stats_failed",
|
|
||||||
&err.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_observation_response(
|
|
||||||
observation: AgentDeviceObservationView,
|
|
||||||
) -> AgentDeviceObservationView {
|
|
||||||
observation
|
|
||||||
}
|
|
||||||
|
|
||||||
fn agent_observation_event_response(
|
|
||||||
event: AgentDeviceObservationEvent,
|
|
||||||
) -> AgentDeviceObservationEvent {
|
|
||||||
event
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalized_query_value(value: Option<String>) -> Option<String> {
|
|
||||||
value
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
|
|
||||||
KnownDeviceResponse {
|
|
||||||
device_id: device.device_id,
|
|
||||||
display_name: device.display_name,
|
|
||||||
pinned: device.pinned,
|
|
||||||
created_at_unix: device.created_at_unix,
|
|
||||||
updated_at_unix: device.updated_at_unix,
|
|
||||||
notes: device.notes,
|
|
||||||
identifiers: device
|
|
||||||
.identifiers
|
|
||||||
.into_iter()
|
|
||||||
.map(|identifier| DeviceIdentifierResponse {
|
|
||||||
identifier_key: identifier.identifier_key,
|
|
||||||
device_id: identifier.device_id,
|
|
||||||
kind: identifier.kind,
|
|
||||||
value: identifier.value,
|
|
||||||
created_at_unix: identifier.created_at_unix,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path as AxumPath, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::api::json_error;
|
||||||
|
use crate::runtime::AppState;
|
||||||
|
use crate::state::{DeviceIdentifierInput, KnownDeviceInput};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateKnownDeviceRequest {
|
||||||
|
pub display_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub identifiers: Vec<DeviceIdentifierRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeviceIdentifierRequest {
|
||||||
|
pub kind: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AttachObservationIdentifierRequest {
|
||||||
|
pub observation_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KnownDeviceResponse {
|
||||||
|
pub device_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
pub updated_at_unix: u64,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub identifiers: Vec<DeviceIdentifierResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceIdentifierResponse {
|
||||||
|
pub identifier_key: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub value: String,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ForgetKnownDeviceResponse {
|
||||||
|
pub device_id: String,
|
||||||
|
pub forgotten: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_known_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<CreateKnownDeviceRequest>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let input = KnownDeviceInput {
|
||||||
|
display_name: req.display_name,
|
||||||
|
pinned: req.pinned,
|
||||||
|
notes: req.notes,
|
||||||
|
identifiers: req
|
||||||
|
.identifiers
|
||||||
|
.into_iter()
|
||||||
|
.map(|identifier| DeviceIdentifierInput {
|
||||||
|
kind: identifier.kind,
|
||||||
|
value: identifier.value,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.store.create_known_device(input).await {
|
||||||
|
Ok(device) => Ok((StatusCode::CREATED, Json(known_device_response(device)))),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to create known device");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"create_known_device_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_known_devices(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.list_known_devices().await {
|
||||||
|
Ok(devices) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(
|
||||||
|
devices
|
||||||
|
.into_iter()
|
||||||
|
.map(known_device_response)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to list known devices");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"list_known_devices_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forget_known_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(device_id): AxumPath<String>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.forget_known_device(&device_id).await {
|
||||||
|
Ok(forgotten) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(ForgetKnownDeviceResponse {
|
||||||
|
device_id,
|
||||||
|
forgotten,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to forget known device");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"forget_known_device_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn attach_device_identifier(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(device_id): AxumPath<String>,
|
||||||
|
Json(req): Json<DeviceIdentifierRequest>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let input = DeviceIdentifierInput {
|
||||||
|
kind: req.kind,
|
||||||
|
value: req.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.attach_device_identifier(&device_id, input)
|
||||||
|
.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 device identifier");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"attach_device_identifier_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
|
||||||
|
KnownDeviceResponse {
|
||||||
|
device_id: device.device_id,
|
||||||
|
display_name: device.display_name,
|
||||||
|
pinned: device.pinned,
|
||||||
|
created_at_unix: device.created_at_unix,
|
||||||
|
updated_at_unix: device.updated_at_unix,
|
||||||
|
notes: device.notes,
|
||||||
|
identifiers: device
|
||||||
|
.identifiers
|
||||||
|
.into_iter()
|
||||||
|
.map(|identifier| DeviceIdentifierResponse {
|
||||||
|
identifier_key: identifier.identifier_key,
|
||||||
|
device_id: identifier.device_id,
|
||||||
|
kind: identifier.kind,
|
||||||
|
value: identifier.value,
|
||||||
|
created_at_unix: identifier.created_at_unix,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path as AxumPath, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::api::json_error;
|
||||||
|
use crate::runtime::{AppState, SessionEvent};
|
||||||
|
use crate::state::AuditEventInput;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EnrollRequest {
|
||||||
|
pub enroll_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct EnrollResponse {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub agent_token: String,
|
||||||
|
pub server_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IssueEnrollTokenResponse {
|
||||||
|
pub enroll_token: String,
|
||||||
|
pub expires_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct IssueEnrollTokenQuery {
|
||||||
|
pub ttl_seconds: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct EnrollTokenStatus {
|
||||||
|
pub enroll_token: String,
|
||||||
|
pub expires_at_unix: u64,
|
||||||
|
pub expired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RevokeEnrollTokenResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RevokeAgentResponse {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetAgentNicknameRequest {
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SetAgentNicknameResponse {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
pub updated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn healthz() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enroll(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<EnrollRequest>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.enroll(&req.enroll_token).await {
|
||||||
|
Ok(issued) => {
|
||||||
|
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "agent".into(),
|
||||||
|
actor_id: Some(issued.agent_id.clone()),
|
||||||
|
agent_id: Some(issued.agent_id.clone()),
|
||||||
|
request_id: None,
|
||||||
|
event_type: "agent_enroll".into(),
|
||||||
|
outcome: "ok".into(),
|
||||||
|
latency_ms: None,
|
||||||
|
message: "agent enrollment accepted".into(),
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %err, "failed to append audit event for enroll success");
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(EnrollResponse {
|
||||||
|
agent_id: issued.agent_id,
|
||||||
|
agent_token: issued.agent_token,
|
||||||
|
server_url: state.public_url,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "agent enrollment rejected");
|
||||||
|
if let Err(audit_err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "agent".into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: None,
|
||||||
|
request_id: None,
|
||||||
|
event_type: "agent_enroll".into(),
|
||||||
|
outcome: "rejected".into(),
|
||||||
|
latency_ms: None,
|
||||||
|
message: err.to_string(),
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
|
||||||
|
}
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"enrollment_rejected",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_enroll_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<IssueEnrollTokenQuery>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let ttl = std::time::Duration::from_secs(
|
||||||
|
query
|
||||||
|
.ttl_seconds
|
||||||
|
.unwrap_or(state.enroll_token_ttl.as_secs())
|
||||||
|
.max(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
match state.store.issue_enroll_token(ttl).await {
|
||||||
|
Ok(issued) => {
|
||||||
|
info!(
|
||||||
|
expires_at_unix = issued.expires_at_unix,
|
||||||
|
"issued enroll token"
|
||||||
|
);
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "admin_api".into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: None,
|
||||||
|
request_id: None,
|
||||||
|
event_type: "enroll_token_issue".into(),
|
||||||
|
outcome: "ok".into(),
|
||||||
|
latency_ms: None,
|
||||||
|
message: "issued enroll token".into(),
|
||||||
|
metadata: serde_json::json!({
|
||||||
|
"ttl_seconds": ttl.as_secs(),
|
||||||
|
"expires_at_unix": issued.expires_at_unix,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %err, "failed to append audit event for token issuance");
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(IssueEnrollTokenResponse {
|
||||||
|
enroll_token: issued.enroll_token,
|
||||||
|
expires_at_unix: issued.expires_at_unix,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to issue enroll token");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"issue_enroll_token_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_enroll_tokens(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.list_enroll_tokens().await {
|
||||||
|
Ok(tokens) => {
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "admin_api".into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: None,
|
||||||
|
request_id: None,
|
||||||
|
event_type: "enroll_token_list".into(),
|
||||||
|
outcome: "ok".into(),
|
||||||
|
latency_ms: None,
|
||||||
|
message: "listed enroll tokens".into(),
|
||||||
|
metadata: serde_json::json!({
|
||||||
|
"count": tokens.len(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %err, "failed to append audit event for token listing");
|
||||||
|
}
|
||||||
|
let body = tokens
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| EnrollTokenStatus {
|
||||||
|
enroll_token: t.enroll_token,
|
||||||
|
expires_at_unix: t.expires_at_unix,
|
||||||
|
expired: t.expired,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Ok((StatusCode::OK, Json(body)))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to list enroll tokens");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"list_enroll_tokens_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_enroll_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(token): AxumPath<String>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.revoke_enroll_token(&token).await {
|
||||||
|
Ok(revoked) => {
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "admin_api".into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: None,
|
||||||
|
request_id: None,
|
||||||
|
event_type: "enroll_token_revoke".into(),
|
||||||
|
outcome: if revoked {
|
||||||
|
"ok".into()
|
||||||
|
} else {
|
||||||
|
"not_found".into()
|
||||||
|
},
|
||||||
|
latency_ms: None,
|
||||||
|
message: if revoked {
|
||||||
|
"revoked enroll token".into()
|
||||||
|
} else {
|
||||||
|
"enroll token not found".into()
|
||||||
|
},
|
||||||
|
metadata: serde_json::json!({ "token": token }),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %err, "failed to append audit event for token revoke");
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(RevokeEnrollTokenResponse { token, revoked }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to revoke enroll token");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"revoke_enroll_token_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_agent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(agent_id): AxumPath<String>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.revoke_agent(&agent_id).await {
|
||||||
|
Ok(revoked) => {
|
||||||
|
if revoked {
|
||||||
|
// Request a graceful websocket close, then remove session from active map.
|
||||||
|
if let Some(session) = state.sessions.write().await.remove(&agent_id) {
|
||||||
|
let _ = session.tx.send(SessionEvent::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "admin_api".into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: Some(agent_id.clone()),
|
||||||
|
request_id: None,
|
||||||
|
event_type: "agent_revoke".into(),
|
||||||
|
outcome: if revoked {
|
||||||
|
"ok".into()
|
||||||
|
} else {
|
||||||
|
"not_found".into()
|
||||||
|
},
|
||||||
|
latency_ms: None,
|
||||||
|
message: if revoked {
|
||||||
|
"revoked agent credentials".into()
|
||||||
|
} else {
|
||||||
|
"agent credentials not found".into()
|
||||||
|
},
|
||||||
|
metadata: serde_json::json!({ "agent_id": agent_id }),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %err, "failed to append audit event for agent revoke");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(RevokeAgentResponse { agent_id, revoked }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to revoke agent credentials");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"revoke_agent_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_agent_nickname(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(agent_id): AxumPath<String>,
|
||||||
|
Json(req): Json<SetAgentNicknameRequest>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let normalized = req
|
||||||
|
.nickname
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.set_agent_nickname(&agent_id, normalized.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(updated) => {
|
||||||
|
if let Err(err) = state
|
||||||
|
.store
|
||||||
|
.append_audit_event(AuditEventInput {
|
||||||
|
actor_type: "admin_api".into(),
|
||||||
|
actor_id: None,
|
||||||
|
agent_id: Some(agent_id.clone()),
|
||||||
|
request_id: None,
|
||||||
|
event_type: "agent_nickname_set".into(),
|
||||||
|
outcome: if updated {
|
||||||
|
"ok".into()
|
||||||
|
} else {
|
||||||
|
"not_found".into()
|
||||||
|
},
|
||||||
|
latency_ms: None,
|
||||||
|
message: if updated {
|
||||||
|
"updated agent nickname".into()
|
||||||
|
} else {
|
||||||
|
"agent not found for nickname update".into()
|
||||||
|
},
|
||||||
|
metadata: serde_json::json!({
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"nickname": normalized,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(error = %err, "failed to append audit event for nickname set");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(SetAgentNicknameResponse {
|
||||||
|
agent_id,
|
||||||
|
nickname: normalized,
|
||||||
|
updated,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to update agent nickname");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"set_agent_nickname_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::api::json_error;
|
||||||
|
use crate::runtime::AppState;
|
||||||
|
use crate::state::{
|
||||||
|
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UploadAgentObservationsRequest {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub agent_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub observations: Vec<AgentObservationRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AgentObservationRequest {
|
||||||
|
pub kind: String,
|
||||||
|
pub action: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub first_seen_unix: u64,
|
||||||
|
pub last_seen_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UploadAgentObservationsResponse {
|
||||||
|
pub accepted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListObservationsQuery {
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListObservationHistoryQuery {
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub observation_key: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_agent_observations(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<UploadAgentObservationsRequest>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
if !state
|
||||||
|
.store
|
||||||
|
.verify_agent_token(&req.agent_id, &req.agent_token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"agent_auth_rejected",
|
||||||
|
"agent credentials rejected",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let observations = req
|
||||||
|
.observations
|
||||||
|
.into_iter()
|
||||||
|
.map(|observation| AgentDeviceObservationInput {
|
||||||
|
kind: observation.kind,
|
||||||
|
action: observation.action,
|
||||||
|
mac: observation.mac,
|
||||||
|
ip: observation.ip,
|
||||||
|
hostname: observation.hostname,
|
||||||
|
first_seen_unix: observation.first_seen_unix,
|
||||||
|
last_seen_unix: observation.last_seen_unix,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.upsert_agent_observations(&req.agent_id, observations)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(accepted) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(UploadAgentObservationsResponse { accepted }),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, agent_id = %req.agent_id, "failed to upload agent observations");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"upload_observations_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_agent_observations(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListObservationsQuery>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(observations) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(
|
||||||
|
observations
|
||||||
|
.into_iter()
|
||||||
|
.map(agent_observation_response)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to list agent observations");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"list_observations_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_agent_observation_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<ListObservationHistoryQuery>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let kind = normalized_query_value(query.kind).map(|value| value.to_ascii_lowercase());
|
||||||
|
let mac = normalized_query_value(query.mac).map(|value| value.to_ascii_lowercase());
|
||||||
|
let ip = normalized_query_value(query.ip);
|
||||||
|
let observation_key = normalized_query_value(query.observation_key);
|
||||||
|
match state
|
||||||
|
.store
|
||||||
|
.list_agent_observation_events(
|
||||||
|
query.agent_id.as_deref(),
|
||||||
|
kind.as_deref(),
|
||||||
|
mac.as_deref(),
|
||||||
|
ip.as_deref(),
|
||||||
|
observation_key.as_deref(),
|
||||||
|
query.limit.unwrap_or(500),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(events) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.map(agent_observation_event_response)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to list agent observation history");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"list_observation_history_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_observation_response(
|
||||||
|
observation: AgentDeviceObservationView,
|
||||||
|
) -> AgentDeviceObservationView {
|
||||||
|
observation
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_observation_event_response(
|
||||||
|
event: AgentDeviceObservationEvent,
|
||||||
|
) -> AgentDeviceObservationEvent {
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_query_value(value: Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::api::json_error;
|
||||||
|
use crate::runtime::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct StateStatsResponse {
|
||||||
|
pub db_path: String,
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub agent_count: usize,
|
||||||
|
pub enroll_token_count: usize,
|
||||||
|
pub expired_enroll_token_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn state_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.store.stats().await {
|
||||||
|
Ok(stats) => Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(StateStatsResponse {
|
||||||
|
db_path: stats.db_path.display().to_string(),
|
||||||
|
schema_version: stats.schema_version,
|
||||||
|
agent_count: stats.agent_count,
|
||||||
|
enroll_token_count: stats.enroll_token_count,
|
||||||
|
expired_enroll_token_count: stats.expired_enroll_token_count,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to read state stats");
|
||||||
|
Err(json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"state_stats_failed",
|
||||||
|
&err.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ pub enum DeviceId {
|
|||||||
Ip(IpAddr),
|
Ip(IpAddr),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One raw source fact used while building a device aggregate.
|
||||||
|
///
|
||||||
|
/// These are intentionally source-shaped and non-durable. They preserve details
|
||||||
|
/// from hooks and live inventory so higher layers can explain why a device looks
|
||||||
|
/// online, stale, or unknown without reverse-engineering flattened fields.
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
|
||||||
|
pub struct DeviceObservationFact {
|
||||||
|
pub kind: String,
|
||||||
|
pub action: String,
|
||||||
|
#[serde(with = "mac::option_mac")]
|
||||||
|
pub mac: Option<MacAddr>,
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub first_seen_unix: Option<u64>,
|
||||||
|
pub last_seen_unix: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Merged view of one discovered network identity.
|
/// Merged view of one discovered network identity.
|
||||||
///
|
///
|
||||||
/// This aggregates facts from DHCP leases and neighbor-table rows into a more
|
/// This aggregates facts from DHCP leases and neighbor-table rows into a more
|
||||||
@@ -59,12 +77,22 @@ pub struct Device {
|
|||||||
pub interfaces: Vec<String>,
|
pub interfaces: Vec<String>,
|
||||||
pub neighbors: Vec<NeighborEntry>,
|
pub neighbors: Vec<NeighborEntry>,
|
||||||
pub leases: Vec<DhcpLease>,
|
pub leases: Vec<DhcpLease>,
|
||||||
|
pub observations: Vec<DeviceObservationFact>,
|
||||||
pub presence: Presence,
|
pub presence: Presence,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Device {
|
impl Device {
|
||||||
/// Merge raw neighbor and DHCP facts into one device aggregate.
|
/// Merge raw neighbor and DHCP facts into one device aggregate.
|
||||||
pub fn from_parts(neighbors: Vec<NeighborEntry>, leases: Vec<DhcpLease>) -> Self {
|
pub fn from_parts(neighbors: Vec<NeighborEntry>, leases: Vec<DhcpLease>) -> Self {
|
||||||
|
Self::from_parts_with_observations(neighbors, leases, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge raw neighbor, DHCP, and hook observation facts into one aggregate.
|
||||||
|
pub fn from_parts_with_observations(
|
||||||
|
neighbors: Vec<NeighborEntry>,
|
||||||
|
leases: Vec<DhcpLease>,
|
||||||
|
observations: Vec<DeviceObservationFact>,
|
||||||
|
) -> Self {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
let mut names = BTreeSet::new();
|
let mut names = BTreeSet::new();
|
||||||
@@ -94,6 +122,34 @@ impl Device {
|
|||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
|
let mut observed_non_remove = false;
|
||||||
|
for observation in &observations {
|
||||||
|
if let Some(name) = observation.hostname.as_deref() {
|
||||||
|
names.insert(name);
|
||||||
|
}
|
||||||
|
if let Some(ip) = observation.ip {
|
||||||
|
ips.insert(ip);
|
||||||
|
}
|
||||||
|
if let Some(mac) = observation.mac {
|
||||||
|
macs.insert(mac);
|
||||||
|
}
|
||||||
|
if observation.action != "remove" {
|
||||||
|
observed_non_remove = true;
|
||||||
|
}
|
||||||
|
presence = std::cmp::max(
|
||||||
|
presence_rank(presence),
|
||||||
|
presence_rank(observation_presence(observation)),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
if neighbors.is_empty()
|
||||||
|
&& leases.is_empty()
|
||||||
|
&& !observed_non_remove
|
||||||
|
&& !observations.is_empty()
|
||||||
|
{
|
||||||
|
presence = Presence::Offline;
|
||||||
|
}
|
||||||
|
|
||||||
let macs: Vec<MacAddr> = macs.into_iter().collect();
|
let macs: Vec<MacAddr> = macs.into_iter().collect();
|
||||||
let ips: Vec<IpAddr> = ips.into_iter().collect();
|
let ips: Vec<IpAddr> = ips.into_iter().collect();
|
||||||
@@ -110,11 +166,20 @@ impl Device {
|
|||||||
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
|
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
|
||||||
neighbors,
|
neighbors,
|
||||||
leases,
|
leases,
|
||||||
|
observations,
|
||||||
presence,
|
presence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observation_presence(observation: &DeviceObservationFact) -> Presence {
|
||||||
|
match (observation.kind.as_str(), observation.action.as_str()) {
|
||||||
|
(_, "remove") => Presence::Offline,
|
||||||
|
("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
|
||||||
|
_ => Presence::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fn presence_rank(presence: Presence) -> u8 {
|
const fn presence_rank(presence: Presence) -> u8 {
|
||||||
match presence {
|
match presence {
|
||||||
Presence::Online => 3,
|
Presence::Online => 3,
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ pub struct DhcpLeaseWithState {
|
|||||||
pub nud_state: Option<NeighborState>,
|
pub nud_state: Option<NeighborState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options for lease retrieval from the service layer.
|
/// Legacy options for lease retrieval from the service layer.
|
||||||
|
///
|
||||||
|
/// Prefer inventory for device state; lease rows are now best treated as a raw
|
||||||
|
/// dnsmasq snapshot.
|
||||||
|
#[deprecated(note = "prefer get_leases() or inventory; neighbor state belongs in inventory")]
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
pub struct LeaseQuery {
|
pub struct LeaseQuery {
|
||||||
|
#[deprecated(note = "prefer inventory for device state")]
|
||||||
pub include_state: bool,
|
pub include_state: bool,
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,10 @@ mod neighbor;
|
|||||||
mod query;
|
mod query;
|
||||||
mod wake;
|
mod wake;
|
||||||
|
|
||||||
pub use device::{Device, DeviceId, DeviceInventory, Presence};
|
pub use device::{Device, DeviceId, DeviceInventory, DeviceObservationFact, Presence};
|
||||||
pub use dhcp::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
#[allow(deprecated)]
|
||||||
|
pub use dhcp::LeaseQuery;
|
||||||
|
pub use dhcp::{DhcpLease, DhcpLeaseWithState};
|
||||||
pub use interface::{InterfaceAddr, InterfaceSummary};
|
pub use interface::{InterfaceAddr, InterfaceSummary};
|
||||||
pub use neighbor::{NeighborEntry, NeighborParseError, NeighborState, parse_neighbor_line};
|
pub use neighbor::{NeighborEntry, NeighborParseError, NeighborState, parse_neighbor_line};
|
||||||
pub use query::{InventoryQuery, InventoryQueryBuilder, NamePath, Query, QueryInput};
|
pub use query::{InventoryQuery, InventoryQueryBuilder, NamePath, Query, QueryInput};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
+18
-379
@@ -1,11 +1,20 @@
|
|||||||
use std::io::{self, ErrorKind};
|
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use macaddr::MacAddr;
|
mod leases;
|
||||||
use serde::{Deserialize, Serialize};
|
mod observations;
|
||||||
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
|
||||||
|
pub use leases::{
|
||||||
|
enrich_leases_with_nud_state, parse_dhcp_lease_line, read_dhcp_leases,
|
||||||
|
read_dhcp_leases_from_path, read_dhcp_leases_with_names,
|
||||||
|
read_dhcp_leases_with_names_from_paths,
|
||||||
|
};
|
||||||
|
pub use observations::{
|
||||||
|
LocalDeviceObservation, LocalObservationStore, ObservedDhcpClient, ObservedNeighbor,
|
||||||
|
list_local_observations, list_local_observations_from_path, load_mac_name_cache,
|
||||||
|
load_mac_name_cache_from_path, load_observation_store, load_observation_store_from_path,
|
||||||
|
observe_dhcp_event, observe_neighbor_event,
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_DHCP_LEASES: &str = "/tmp/dhcp.leases";
|
const DEFAULT_DHCP_LEASES: &str = "/tmp/dhcp.leases";
|
||||||
const DEFAULT_MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
const DEFAULT_MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||||
@@ -14,363 +23,22 @@ const DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
|||||||
const MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
const MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||||
const OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
const OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
pub(crate) fn now_unix() -> u64 {
|
||||||
pub struct LocalObservationStore {
|
|
||||||
#[serde(default)]
|
|
||||||
pub dhcp_clients: std::collections::BTreeMap<String, ObservedDhcpClient>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub neighbors: std::collections::BTreeMap<String, ObservedNeighbor>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ObservedDhcpClient {
|
|
||||||
pub mac: String,
|
|
||||||
pub ip: Option<IpAddr>,
|
|
||||||
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 ObservedNeighbor {
|
|
||||||
pub key: String,
|
|
||||||
pub mac: Option<String>,
|
|
||||||
pub ip: Option<IpAddr>,
|
|
||||||
pub first_seen_unix: u64,
|
|
||||||
pub last_seen_unix: u64,
|
|
||||||
pub last_action: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LocalDeviceObservation {
|
|
||||||
pub kind: String,
|
|
||||||
pub action: String,
|
|
||||||
pub mac: Option<String>,
|
|
||||||
pub ip: Option<IpAddr>,
|
|
||||||
pub hostname: Option<String>,
|
|
||||||
pub first_seen_unix: u64,
|
|
||||||
pub last_seen_unix: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the MAC-to-name cache used to preserve useful names across lease churn.
|
|
||||||
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
|
|
||||||
load_mac_name_cache_from_path(mac_name_cache_path()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_mac_name_cache_from_path(
|
|
||||||
path: impl AsRef<std::path::Path>,
|
|
||||||
) -> io::Result<std::collections::BTreeMap<String, String>> {
|
|
||||||
match tokio::fs::read_to_string(path).await {
|
|
||||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the MAC-to-name cache back to disk.
|
|
||||||
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
|
|
||||||
save_mac_name_cache_to_path(mac_name_cache_path(), map).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_mac_name_cache_to_path(
|
|
||||||
path: impl AsRef<std::path::Path>,
|
|
||||||
map: &std::collections::BTreeMap<String, String>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
|
||||||
let _ = tokio::fs::write(path, s).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_observation_store() -> io::Result<LocalObservationStore> {
|
|
||||||
match tokio::fs::read_to_string(observation_store_path()).await {
|
|
||||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_observation_store_from_path(
|
|
||||||
path: impl AsRef<std::path::Path>,
|
|
||||||
) -> io::Result<LocalObservationStore> {
|
|
||||||
match tokio::fs::read_to_string(path).await {
|
|
||||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_observation_store(store: &LocalObservationStore) -> io::Result<()> {
|
|
||||||
let s = serde_json::to_string(store).map_err(io::Error::other)?;
|
|
||||||
tokio::fs::write(observation_store_path(), s).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>> {
|
|
||||||
let store = load_observation_store().await?;
|
|
||||||
list_local_observations_from_store(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_local_observations_from_path(
|
|
||||||
path: impl AsRef<std::path::Path>,
|
|
||||||
) -> io::Result<Vec<LocalDeviceObservation>> {
|
|
||||||
let store = load_observation_store_from_path(path).await?;
|
|
||||||
list_local_observations_from_store(store)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_local_observations_from_store(
|
|
||||||
store: LocalObservationStore,
|
|
||||||
) -> io::Result<Vec<LocalDeviceObservation>> {
|
|
||||||
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
|
||||||
out.extend(
|
|
||||||
store
|
|
||||||
.dhcp_clients
|
|
||||||
.into_values()
|
|
||||||
.map(|row| LocalDeviceObservation {
|
|
||||||
kind: "dhcp".into(),
|
|
||||||
action: row.last_action,
|
|
||||||
mac: Some(row.mac),
|
|
||||||
ip: row.ip,
|
|
||||||
hostname: row.hostname,
|
|
||||||
first_seen_unix: row.first_seen_unix,
|
|
||||||
last_seen_unix: row.last_seen_unix,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
out.extend(
|
|
||||||
store
|
|
||||||
.neighbors
|
|
||||||
.into_values()
|
|
||||||
.map(|row| LocalDeviceObservation {
|
|
||||||
kind: "neigh".into(),
|
|
||||||
action: row.last_action,
|
|
||||||
mac: row.mac,
|
|
||||||
ip: row.ip,
|
|
||||||
hostname: None,
|
|
||||||
first_seen_unix: row.first_seen_unix,
|
|
||||||
last_seen_unix: row.last_seen_unix,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
out.sort_by(|a, b| {
|
|
||||||
b.last_seen_unix
|
|
||||||
.cmp(&a.last_seen_unix)
|
|
||||||
.then(a.kind.cmp(&b.kind))
|
|
||||||
.then(a.mac.cmp(&b.mac))
|
|
||||||
.then(a.ip.cmp(&b.ip))
|
|
||||||
});
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Observe a DHCP hotplug event and update the local MAC-to-name cache.
|
|
||||||
pub async fn observe_dhcp_event(
|
|
||||||
action: &str,
|
|
||||||
mac: MacAddr,
|
|
||||||
ip: Option<IpAddr>,
|
|
||||||
hostname: Option<&str>,
|
|
||||||
) -> io::Result<bool> {
|
|
||||||
if !matches!(action, "add" | "update" | "old" | "remove") {
|
|
||||||
// old not emitted by hotplug
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostname = hostname
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|v| !v.is_empty() && *v != "*")
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let now = now_unix();
|
|
||||||
let mac_s = mac.to_string().to_ascii_lowercase();
|
|
||||||
|
|
||||||
let mut store = load_observation_store().await.unwrap_or_default();
|
|
||||||
let mut changed = false;
|
|
||||||
store
|
|
||||||
.dhcp_clients
|
|
||||||
.entry(mac_s.clone())
|
|
||||||
.and_modify(|row| {
|
|
||||||
if row.ip != ip
|
|
||||||
|| row.hostname != hostname
|
|
||||||
|| row.last_action != action
|
|
||||||
|| row.last_seen_unix != now
|
|
||||||
{
|
|
||||||
row.ip = ip;
|
|
||||||
row.hostname = hostname.clone();
|
|
||||||
row.last_action = action.to_string();
|
|
||||||
row.last_seen_unix = now;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or_insert_with(|| {
|
|
||||||
changed = true;
|
|
||||||
ObservedDhcpClient {
|
|
||||||
mac: mac_s.clone(),
|
|
||||||
ip,
|
|
||||||
hostname: hostname.clone(),
|
|
||||||
first_seen_unix: now,
|
|
||||||
last_seen_unix: now,
|
|
||||||
last_action: action.to_string(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if changed {
|
|
||||||
save_observation_store(&store).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(hostname) = hostname {
|
|
||||||
let mut cache = load_mac_name_cache().await.unwrap_or_default();
|
|
||||||
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
|
|
||||||
cache.insert(mac_s, hostname);
|
|
||||||
save_mac_name_cache(&cache).await?;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn observe_neighbor_event(
|
|
||||||
action: &str,
|
|
||||||
mac: Option<MacAddr>,
|
|
||||||
ip: Option<IpAddr>,
|
|
||||||
) -> io::Result<bool> {
|
|
||||||
if !matches!(action, "add" | "update" | "old" | "remove") {
|
|
||||||
// update and remove not emitted by hotplug
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let Some(key) = mac
|
|
||||||
.map(|value| format!("mac:{}", value.to_string().to_ascii_lowercase()))
|
|
||||||
.or_else(|| ip.map(|value| format!("ip:{}", value)))
|
|
||||||
else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = now_unix();
|
|
||||||
let mac = mac.map(|value| value.to_string().to_ascii_lowercase());
|
|
||||||
let mut store = load_observation_store().await.unwrap_or_default();
|
|
||||||
let mut changed = false;
|
|
||||||
store
|
|
||||||
.neighbors
|
|
||||||
.entry(key.clone())
|
|
||||||
.and_modify(|row| {
|
|
||||||
if row.mac != mac
|
|
||||||
|| row.ip != ip
|
|
||||||
|| row.last_action != action
|
|
||||||
|| row.last_seen_unix != now
|
|
||||||
{
|
|
||||||
row.mac = mac.clone();
|
|
||||||
row.ip = ip;
|
|
||||||
row.last_action = action.to_string();
|
|
||||||
row.last_seen_unix = now;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or_insert_with(|| {
|
|
||||||
changed = true;
|
|
||||||
ObservedNeighbor {
|
|
||||||
key,
|
|
||||||
mac,
|
|
||||||
ip,
|
|
||||||
first_seen_unix: now,
|
|
||||||
last_seen_unix: now,
|
|
||||||
last_action: action.to_string(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if changed {
|
|
||||||
save_observation_store(&store).await?;
|
|
||||||
}
|
|
||||||
Ok(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse one `dnsmasq`-style DHCP lease line.
|
|
||||||
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
|
|
||||||
let mut c = line.split_whitespace();
|
|
||||||
let expires_epoch: u64 = c.next()?.parse().ok()?;
|
|
||||||
let mac = c.next()?.parse().ok()?;
|
|
||||||
let ip = c.next()?.parse().ok()?;
|
|
||||||
let name = c.next().filter(|c| *c != "*").map(str::to_string);
|
|
||||||
Some(DhcpLease {
|
|
||||||
expires_epoch,
|
|
||||||
ip,
|
|
||||||
mac,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read raw DHCP leases from the configured dnsmasq lease file.
|
|
||||||
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
|
|
||||||
read_dhcp_leases_from_path(dhcp_leases_path()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_dhcp_leases_from_path(
|
|
||||||
path: impl AsRef<std::path::Path>,
|
|
||||||
) -> io::Result<Vec<DhcpLease>> {
|
|
||||||
match tokio::fs::read_to_string(path).await {
|
|
||||||
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read DHCP leases and fill missing names from the MAC-name cache.
|
|
||||||
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
|
|
||||||
read_dhcp_leases_with_names_from_paths(
|
|
||||||
dhcp_leases_path(),
|
|
||||||
observation_store_path(),
|
|
||||||
mac_name_cache_path(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_dhcp_leases_with_names_from_paths(
|
|
||||||
leases_path: impl AsRef<std::path::Path>,
|
|
||||||
observation_store_path: impl AsRef<std::path::Path>,
|
|
||||||
mac_name_cache_path: impl AsRef<std::path::Path>,
|
|
||||||
) -> io::Result<Vec<DhcpLease>> {
|
|
||||||
let leases = read_dhcp_leases_from_path(leases_path).await?;
|
|
||||||
let observations = load_observation_store_from_path(observation_store_path)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mac_name_cache_path = mac_name_cache_path.as_ref();
|
|
||||||
let mut cache = load_mac_name_cache_from_path(mac_name_cache_path)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut changed = false;
|
|
||||||
let mut leases_with_names = Vec::with_capacity(leases.len());
|
|
||||||
for mut l in leases {
|
|
||||||
let mac_s = l.mac.to_string();
|
|
||||||
if let Some(ref name) = l.name {
|
|
||||||
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
|
|
||||||
cache.insert(mac_s, name.clone());
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
} else if let Some(prev) = observations
|
|
||||||
.dhcp_clients
|
|
||||||
.get(&mac_s)
|
|
||||||
.and_then(|row| row.hostname.as_ref())
|
|
||||||
{
|
|
||||||
l.name = Some(prev.clone());
|
|
||||||
} else if let Some(prev) = cache.get(&mac_s) {
|
|
||||||
l.name = Some(prev.clone());
|
|
||||||
}
|
|
||||||
leases_with_names.push(l);
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
let _ = save_mac_name_cache_to_path(mac_name_cache_path, &cache).await;
|
|
||||||
}
|
|
||||||
Ok(leases_with_names)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_unix() -> u64 {
|
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|duration| duration.as_secs())
|
.map(|duration| duration.as_secs())
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dhcp_leases_path() -> PathBuf {
|
pub(crate) fn dhcp_leases_path() -> PathBuf {
|
||||||
configured_path(DHCP_LEASES_ENV, DEFAULT_DHCP_LEASES)
|
configured_path(DHCP_LEASES_ENV, DEFAULT_DHCP_LEASES)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mac_name_cache_path() -> PathBuf {
|
pub(crate) fn mac_name_cache_path() -> PathBuf {
|
||||||
configured_path(MAC_NAME_CACHE_ENV, DEFAULT_MAC_NAME_CACHE)
|
configured_path(MAC_NAME_CACHE_ENV, DEFAULT_MAC_NAME_CACHE)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn observation_store_path() -> PathBuf {
|
pub(crate) fn observation_store_path() -> PathBuf {
|
||||||
configured_path(OBSERVATION_STORE_ENV, DEFAULT_OBSERVATION_STORE)
|
configured_path(OBSERVATION_STORE_ENV, DEFAULT_OBSERVATION_STORE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,35 +49,6 @@ fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
|||||||
.unwrap_or_else(|| PathBuf::from(default))
|
.unwrap_or_else(|| PathBuf::from(default))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enrich DHCP leases with the best currently known neighbor state per IP.
|
|
||||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
|
||||||
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
|
||||||
let mut map: std::collections::HashMap<IpAddr, wakey_core::NeighborState> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
if let Ok(rows) =
|
|
||||||
crate::devices::get_neighbors(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await
|
|
||||||
{
|
|
||||||
for row in rows {
|
|
||||||
let state = row.state;
|
|
||||||
let r = state.rank();
|
|
||||||
map.entry(row.ip)
|
|
||||||
.and_modify(|e| {
|
|
||||||
if r > e.rank() {
|
|
||||||
*e = state
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.or_insert(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
leases
|
|
||||||
.into_iter()
|
|
||||||
.map(|lease_line| DhcpLeaseWithState {
|
|
||||||
nud_state: map.get(&lease_line.ip).copied(),
|
|
||||||
lease_line,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||||
|
|
||||||
|
use super::{dhcp_leases_path, mac_name_cache_path, observation_store_path};
|
||||||
|
use crate::dhcp::observations::{
|
||||||
|
load_mac_name_cache_from_path, load_observation_store_from_path, save_mac_name_cache_to_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse one `dnsmasq`-style DHCP lease line.
|
||||||
|
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
|
||||||
|
let mut c = line.split_whitespace();
|
||||||
|
let expires_epoch: u64 = c.next()?.parse().ok()?;
|
||||||
|
let mac = c.next()?.parse().ok()?;
|
||||||
|
let ip = c.next()?.parse().ok()?;
|
||||||
|
let name = c.next().filter(|c| *c != "*").map(str::to_string);
|
||||||
|
Some(DhcpLease {
|
||||||
|
expires_epoch,
|
||||||
|
ip,
|
||||||
|
mac,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read raw DHCP leases from the configured dnsmasq lease file.
|
||||||
|
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
|
||||||
|
read_dhcp_leases_from_path(dhcp_leases_path()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_dhcp_leases_from_path(
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
) -> io::Result<Vec<DhcpLease>> {
|
||||||
|
match tokio::fs::read_to_string(path).await {
|
||||||
|
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read DHCP leases and fill missing names from the MAC-name cache.
|
||||||
|
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
|
||||||
|
read_dhcp_leases_with_names_from_paths(
|
||||||
|
dhcp_leases_path(),
|
||||||
|
observation_store_path(),
|
||||||
|
mac_name_cache_path(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_dhcp_leases_with_names_from_paths(
|
||||||
|
leases_path: impl AsRef<std::path::Path>,
|
||||||
|
observation_store_path: impl AsRef<std::path::Path>,
|
||||||
|
mac_name_cache_path: impl AsRef<std::path::Path>,
|
||||||
|
) -> io::Result<Vec<DhcpLease>> {
|
||||||
|
let leases = read_dhcp_leases_from_path(leases_path).await?;
|
||||||
|
let observations = load_observation_store_from_path(observation_store_path)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mac_name_cache_path = mac_name_cache_path.as_ref();
|
||||||
|
let mut cache = load_mac_name_cache_from_path(mac_name_cache_path)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut changed = false;
|
||||||
|
let mut leases_with_names = Vec::with_capacity(leases.len());
|
||||||
|
for mut l in leases {
|
||||||
|
let mac_s = l.mac.to_string();
|
||||||
|
if let Some(ref name) = l.name {
|
||||||
|
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
|
||||||
|
cache.insert(mac_s, name.clone());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if let Some(prev) = observations
|
||||||
|
.dhcp_clients
|
||||||
|
.get(&mac_s)
|
||||||
|
.and_then(|row| row.hostname.as_ref())
|
||||||
|
{
|
||||||
|
l.name = Some(prev.clone());
|
||||||
|
} else if let Some(prev) = cache.get(&mac_s) {
|
||||||
|
l.name = Some(prev.clone());
|
||||||
|
}
|
||||||
|
leases_with_names.push(l);
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let _ = save_mac_name_cache_to_path(mac_name_cache_path, &cache).await;
|
||||||
|
}
|
||||||
|
Ok(leases_with_names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrich DHCP leases with the best currently known neighbor state per IP.
|
||||||
|
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
||||||
|
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
||||||
|
let mut map: std::collections::HashMap<IpAddr, wakey_core::NeighborState> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
if let Ok(rows) =
|
||||||
|
crate::devices::get_neighbors(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await
|
||||||
|
{
|
||||||
|
for row in rows {
|
||||||
|
let state = row.state;
|
||||||
|
let r = state.rank();
|
||||||
|
map.entry(row.ip)
|
||||||
|
.and_modify(|e| {
|
||||||
|
if r > e.rank() {
|
||||||
|
*e = state
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leases
|
||||||
|
.into_iter()
|
||||||
|
.map(|lease_line| DhcpLeaseWithState {
|
||||||
|
nud_state: map.get(&lease_line.ip).copied(),
|
||||||
|
lease_line,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{mac_name_cache_path, now_unix, observation_store_path};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct LocalObservationStore {
|
||||||
|
#[serde(default)]
|
||||||
|
pub dhcp_clients: std::collections::BTreeMap<String, ObservedDhcpClient>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub neighbors: std::collections::BTreeMap<String, ObservedNeighbor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ObservedDhcpClient {
|
||||||
|
pub mac: String,
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
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 ObservedNeighbor {
|
||||||
|
pub key: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
pub first_seen_unix: u64,
|
||||||
|
pub last_seen_unix: u64,
|
||||||
|
pub last_action: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LocalDeviceObservation {
|
||||||
|
pub kind: String,
|
||||||
|
pub action: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub ip: Option<IpAddr>,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub first_seen_unix: u64,
|
||||||
|
pub last_seen_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the MAC-to-name cache used to preserve useful names across lease churn.
|
||||||
|
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||||
|
load_mac_name_cache_from_path(mac_name_cache_path()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_mac_name_cache_from_path(
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
) -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||||
|
match tokio::fs::read_to_string(path).await {
|
||||||
|
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the MAC-to-name cache back to disk.
|
||||||
|
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
|
||||||
|
save_mac_name_cache_to_path(mac_name_cache_path(), map).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn save_mac_name_cache_to_path(
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
map: &std::collections::BTreeMap<String, String>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
||||||
|
let _ = tokio::fs::write(path, s).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_observation_store() -> io::Result<LocalObservationStore> {
|
||||||
|
match tokio::fs::read_to_string(observation_store_path()).await {
|
||||||
|
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_observation_store_from_path(
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
) -> io::Result<LocalObservationStore> {
|
||||||
|
match tokio::fs::read_to_string(path).await {
|
||||||
|
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_observation_store(store: &LocalObservationStore) -> io::Result<()> {
|
||||||
|
let s = serde_json::to_string(store).map_err(io::Error::other)?;
|
||||||
|
tokio::fs::write(observation_store_path(), s).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>> {
|
||||||
|
let store = load_observation_store().await?;
|
||||||
|
Ok(list_local_observations_from_store(store))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_local_observations_from_path(
|
||||||
|
path: impl AsRef<std::path::Path>,
|
||||||
|
) -> io::Result<Vec<LocalDeviceObservation>> {
|
||||||
|
let store = load_observation_store_from_path(path).await?;
|
||||||
|
Ok(list_local_observations_from_store(store))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> {
|
||||||
|
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
||||||
|
out.extend(
|
||||||
|
store
|
||||||
|
.dhcp_clients
|
||||||
|
.into_values()
|
||||||
|
.map(|row| LocalDeviceObservation {
|
||||||
|
kind: "dhcp".into(),
|
||||||
|
action: row.last_action,
|
||||||
|
mac: Some(row.mac),
|
||||||
|
ip: row.ip,
|
||||||
|
hostname: row.hostname,
|
||||||
|
first_seen_unix: row.first_seen_unix,
|
||||||
|
last_seen_unix: row.last_seen_unix,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
out.extend(
|
||||||
|
store
|
||||||
|
.neighbors
|
||||||
|
.into_values()
|
||||||
|
.map(|row| LocalDeviceObservation {
|
||||||
|
kind: "neigh".into(),
|
||||||
|
action: row.last_action,
|
||||||
|
mac: row.mac,
|
||||||
|
ip: row.ip,
|
||||||
|
hostname: None,
|
||||||
|
first_seen_unix: row.first_seen_unix,
|
||||||
|
last_seen_unix: row.last_seen_unix,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
out.sort_by(|a, b| {
|
||||||
|
b.last_seen_unix
|
||||||
|
.cmp(&a.last_seen_unix)
|
||||||
|
.then(a.kind.cmp(&b.kind))
|
||||||
|
.then(a.mac.cmp(&b.mac))
|
||||||
|
.then(a.ip.cmp(&b.ip))
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a DHCP hotplug event and update the local MAC-to-name cache.
|
||||||
|
pub async fn observe_dhcp_event(
|
||||||
|
action: &str,
|
||||||
|
mac: MacAddr,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
hostname: Option<&str>,
|
||||||
|
) -> io::Result<bool> {
|
||||||
|
if !matches!(action, "add" | "update" | "old" | "remove") {
|
||||||
|
// old not emitted by hotplug
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostname = hostname
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty() && *v != "*")
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let now = now_unix();
|
||||||
|
let mac_s = mac.to_string().to_ascii_lowercase();
|
||||||
|
|
||||||
|
let mut store = load_observation_store().await.unwrap_or_default();
|
||||||
|
let mut changed = false;
|
||||||
|
store
|
||||||
|
.dhcp_clients
|
||||||
|
.entry(mac_s.clone())
|
||||||
|
.and_modify(|row| {
|
||||||
|
if row.ip != ip
|
||||||
|
|| row.hostname != hostname
|
||||||
|
|| row.last_action != action
|
||||||
|
|| row.last_seen_unix != now
|
||||||
|
{
|
||||||
|
row.ip = ip;
|
||||||
|
row.hostname = hostname.clone();
|
||||||
|
row.last_action = action.to_string();
|
||||||
|
row.last_seen_unix = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert_with(|| {
|
||||||
|
changed = true;
|
||||||
|
ObservedDhcpClient {
|
||||||
|
mac: mac_s.clone(),
|
||||||
|
ip,
|
||||||
|
hostname: hostname.clone(),
|
||||||
|
first_seen_unix: now,
|
||||||
|
last_seen_unix: now,
|
||||||
|
last_action: action.to_string(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
save_observation_store(&store).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(hostname) = hostname {
|
||||||
|
let mut cache = load_mac_name_cache().await.unwrap_or_default();
|
||||||
|
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
|
||||||
|
cache.insert(mac_s, hostname);
|
||||||
|
save_mac_name_cache(&cache).await?;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn observe_neighbor_event(
|
||||||
|
action: &str,
|
||||||
|
mac: Option<MacAddr>,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
) -> io::Result<bool> {
|
||||||
|
if !matches!(action, "add" | "update" | "old" | "remove") {
|
||||||
|
// update and remove not emitted by hotplug
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let Some(key) = mac
|
||||||
|
.map(|value| format!("mac:{}", value.to_string().to_ascii_lowercase()))
|
||||||
|
.or_else(|| ip.map(|value| format!("ip:{}", value)))
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = now_unix();
|
||||||
|
let mac = mac.map(|value| value.to_string().to_ascii_lowercase());
|
||||||
|
let mut store = load_observation_store().await.unwrap_or_default();
|
||||||
|
let mut changed = false;
|
||||||
|
store
|
||||||
|
.neighbors
|
||||||
|
.entry(key.clone())
|
||||||
|
.and_modify(|row| {
|
||||||
|
if row.mac != mac
|
||||||
|
|| row.ip != ip
|
||||||
|
|| row.last_action != action
|
||||||
|
|| row.last_seen_unix != now
|
||||||
|
{
|
||||||
|
row.mac = mac.clone();
|
||||||
|
row.ip = ip;
|
||||||
|
row.last_action = action.to_string();
|
||||||
|
row.last_seen_unix = now;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert_with(|| {
|
||||||
|
changed = true;
|
||||||
|
ObservedNeighbor {
|
||||||
|
key,
|
||||||
|
mac,
|
||||||
|
ip,
|
||||||
|
first_seen_unix: now,
|
||||||
|
last_seen_unix: now,
|
||||||
|
last_action: action.to_string(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
save_observation_store(&store).await?;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user