5 Commits
31 changed files with 2162 additions and 1607 deletions

No files matched your search

+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+32
View File
@@ -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 -1
View File
@@ -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"]
+27 -2
View File
@@ -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 -1
View File
@@ -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"]
+14 -815
View File
@@ -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 -1
View File
@@ -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"]
+65
View File
@@ -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,
+6 -1
View File
@@ -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,
} }
+4 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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::*;
+117
View File
@@ -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()
}
+267
View File
@@ -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)
}