11 Commits
62 changed files with 1808 additions and 522 deletions

No files matched your search

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
}
+128
View File
@@ -0,0 +1,128 @@
# Wakey
Wakey models devices discovered on a local network and the control-plane view used to wake and manage them across agents.
## Language
**Device**:
An operator-facing aggregate that represents one discovered network identity candidate.
_Avoid_: Host, machine, route
**Device Summary**:
The derived names, MACs, IPs, and presence shown on a device aggregate.
_Avoid_: Source of truth, route data
**Endpoint**:
One observed network contact point for a device, including the agent/source, optional MAC, optional IP, presence, and observation time.
_Avoid_: Raw observation, route, IP row
**Endpoint Source**:
The typed origin of an endpoint, such as neighbor data, DHCP lease data, or a hotplug hook.
_Avoid_: String kind, source label
**Observation Fact**:
Debug evidence copied from local observation inputs before it is interpreted into endpoints.
_Avoid_: Endpoint, identifier, source of truth
**Presence**:
The current reachability interpretation for a device or endpoint.
_Avoid_: Endpoint state, status
**Wake Route**:
An endpoint that has enough information and agent availability to attempt waking a device.
_Avoid_: Device, endpoint when not wakeable
**Known Device**:
A device identity manually curated by the operator.
_Avoid_: Auto-pinned device, saved observation
**Identifier**:
A manually approved MAC or IP value that links an observed device or endpoint to a known device.
_Avoid_: Observation key, route id
**Endpoint Key**:
The typed identity of endpoint evidence, based on source and optional MAC/IP.
_Avoid_: Stringly typed route id
**Agent Endpoint Key**:
An endpoint key scoped to the agent that observed it.
_Avoid_: Concatenated agent/source/MAC/IP string
**Agent**:
A fleet member that observes local network state and sends device snapshots to the control plane.
_Avoid_: Router when referring to the software actor
**Agent Host**:
The machine running an agent, including host identity and runtime metadata.
_Avoid_: Device when referring to the agent machine
**Agent Capability**:
Something an agent host can observe or do on the network.
_Avoid_: Router flag
**Interface Telemetry**:
Current operational measurements for a network interface visible to an agent.
_Avoid_: Endpoint state, device presence
## Relationships
- A **Device** has zero or more **Endpoints**.
- A **Device Summary** is derived from a **Device** and its **Endpoints**.
- Local source rows are grouped into **Devices** by endpoint-derived **DeviceId**, not by exact **Endpoint Key**.
- Within one complete local snapshot, MAC+IP endpoints may absorb IP-only endpoints with the same IP; this merge is not remembered across snapshots unless the operator creates an **Identifier**.
- An **Endpoint** belongs to exactly one **Agent**.
- An **Endpoint** has one **Endpoint Key**.
- An **Agent Endpoint Key** combines an **Agent** with an **Endpoint Key**.
- An **Endpoint** may have both MAC and IP, MAC only, or IP only.
- An **Endpoint** has exactly one **Endpoint Source**.
- An **Endpoint** has one **Presence**.
- An **Endpoint** may name the network interface where it was observed.
- An **Observation Fact** may explain why an **Endpoint** exists, but does not define identity or wakeability.
- A **Wake Route** is derived from exactly one **Endpoint**.
- A **Wake Route** may carry the interface from its source **Endpoint**.
- A **Known Device** has zero or more **Identifiers**.
- An **Identifier** can link observed **Devices** or **Endpoints** to one **Known Device**.
- An **Agent** runs on one **Agent Host**.
- An **Agent Capability** describes what an **Agent Host** can observe or do.
- **Interface Telemetry** describes an **Agent Host** interface, not a **Device Endpoint**.
## Example dialogue
> **Dev:** "This **Device** has three IPs. Which one should the UI wake?"
> **Domain expert:** "Do not infer that from the **Device** summary. Use the **Wake Routes** derived from its **Endpoints**."
## Flagged ambiguities
- "route" was used for both observed network contact points and wakeable paths — resolved: use **Endpoint** for observed contact points and **Wake Route** only when the endpoint can be used for wake.
- "device" was used for both an identity aggregate and each MAC/IP pair — resolved: use **Device** for the aggregate and **Endpoint** for each observed MAC/IP/source/state tuple.
- `Device.ips` and `Device.macs` are true summary values, but they are not authoritative for wake, freshness, or per-IP state decisions — resolved: use **Endpoint** for those decisions.
- MAC-only and IP-only observations are still **Endpoints** — resolved: **Wake Routes** require a MAC and a connected **Agent**, while IP is optional route detail.
- Endpoint-specific states such as "removed" or "stale" are not separate concepts yet — resolved: use **Presence** until a distinction appears that presence cannot express cleanly.
- Endpoint origin is not a free string — resolved: use **Endpoint Source** for domain logic and reserve raw strings for debug facts only.
- Local observation file rows map naturally to **Observation Facts** — resolved: keep them for debugging, but do not use them as the operational model.
- Offline **Endpoints** remain part of the API model — resolved: UI may collapse them from the primary view, but the API should still expose them for details, copy, and debugging.
- **Device Summary** IPs come only from concrete live-ish sources such as neighbor table rows and current DHCP lease rows — resolved: hotplug hook memory creates **Endpoints** and **Observation Facts**, but does not directly inflate summary IPs.
- Router-ness is not a boolean domain concept — resolved: model **Agent Capabilities** and **Agent Host** metadata instead.
- **Presence** ordering should treat **Unknown** as non-voting/no evidence, weaker than **Offline** — resolved: concrete offline evidence should not be overridden by unknown hook or DHCP ambiguity.
- **Device** presence is derived from all **Endpoints**, not only summary-eligible endpoints.
- Current DHCP leases are summary-eligible **Endpoints** with **Unknown** presence; they may support wake when a MAC is present, but they do not prove reachability.
- DHCP hook events, including removal, indicate **Unknown** because they describe lease churn rather than reachability.
- Neighbor table and neighbor hook events may indicate **Offline** when they report failed or removed reachability.
- **Presence** and wakeability are separate: an **Offline** endpoint may still produce a **Wake Route** if it has a MAC and a connected **Agent**.
- **Endpoint** does not store wakeability; wakeability is derived from endpoint data plus **Agent** runtime and capability context.
- Preferred **Wake Routes** are ordered by connected agent, wakeability, having an IP, presence quality, recency, then source quality.
- **Endpoint** evidence preserves source-specific rows; equivalent **Wake Routes** may ignore source when they target the same agent, MAC, and IP.
- The control plane stores **Endpoints** as structured data so fleet search, identity matching, and wake route selection do not depend on parsing opaque device JSON.
- Summary MAC/IP collections are derived from **Endpoints**; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
- API arrays should be domain-sorted by default; UI may sort top-level fleet rows for presentation, but route ranking and recommended route selection are backend-owned.
- The main fleet API embeds **Endpoints** in each **Device** response; a separate endpoint API is unnecessary until payload size or access patterns require it.
- The control plane stores current per-agent endpoint snapshots, not endpoint event history.
- Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement; default retention is 7 days unless configured otherwise.
- Live-source **Endpoints** use snapshot time as their observed time; hook-derived **Endpoints** use the hook row's recorded last-seen time.
- **DeviceId**, **Endpoint Key**, and **Agent Endpoint Key** are typed concepts; string keys are boundary serialization only.
- **DeviceId** is derived from a device's **Endpoints**: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent.
- Hostnames are endpoint evidence/display data, not part of **Endpoint Key** identity.
- Interface names are endpoint metadata, not part of **Endpoint Key** identity; multi-segment routers may require scoped endpoint identity later if the same source/MAC/IP has different meaning on different interfaces.
- An **Endpoint Key** must contain at least one network address value: MAC, IP, or both. Facts without MAC or IP remain **Observation Facts** only.
- **Identifiers** are source-independent ownership claims over MAC or permanent IP values, not claims over endpoint sources.
- IP **Identifiers** are explicit operator claims; Wakey does not infer whether an IP is permanent, static, or reserved.
Generated
+5 -5
View File
@@ -3598,7 +3598,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "wakey" name = "wakey"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -3619,7 +3619,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-agent" name = "wakey-agent"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3643,7 +3643,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-control-plane" name = "wakey-control-plane"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3672,7 +3672,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-core" name = "wakey-core"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"macaddr", "macaddr",
"serde", "serde",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]] [[package]]
name = "wakey-linux" name = "wakey-linux"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"futures", "futures",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey" name = "wakey"
version = "0.3.0" version = "0.3.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+57
View File
@@ -0,0 +1,57 @@
# lda rambling
currently the collection of fleetwakeroutes doesnt make much sense.
it goes deep. Read code, then this again.
## big fleet shows everything.
all the IPs, offline or not. I at least want to hide the offline/unknown IPs into a drawer somewhere. its too cluttered.
i do need offline IPs, because, a failed / dhcp-rotated IP may be of use. but what use? copying only. You cant even send shit to it, its FAILED.
so not all the IPs should be immediately invisible.
## big fleet DOESNT show everything
yea showing all the offline IP but only ONE online ip per mac for wake routes.
BRING This BACK. i need ALL the routes. Color coded (already has that with disabled wake button)
## big collect collects everything
at wakey, collection makes no sense; a device saves all IPs without saying which of them is not real.
oh you do have to search again for the observations because they are unknown ahh hints,
## logics are misleading
also IPs without macs are unknown, (should be OFFLINE? ips without macs? what do you think it is)
while macs without ips are offline (pulled straight from the Big Mac Name cache)
what is this ahh logic
## struct names are misleading
Big Device over here, collected (fresh AND old) From the router ITSELF. Is BY MAC.
but the Device struct was designed to take multiple MACs, Why. This is very misleading.
The migration was from Observations, which is a string fest, to Device, which
1. give up the very cool observations based design, which fundamentally is just tracking identifing pairs of (ip, mac, maybe hostname)
2. doesnt solve the confusing ahh mac-keyed Devices. Why is the deviceid optional? What!
## big code needs a rewrite
i think we need to have a very clear model. And such model is ALREADY PRESENT! fleet wake route MIGHT be the coolest thing ever, solves everything.
Should type instead of free string construction. For example. alot of the keys are currently freehanded. You need a struct with Display or ToString or a dedicated method.
hopefully those keys can have a helper contructor. HOPEFULLY NOONE PULLS INFO OUT OF THE STRING BY SLICING IT.
If you want to keep just device.macs, device.ips, you can, we need to collect the Wake routes! which goes deep,
but is reasonable, because there are two sources that give us identifiers, neighbors and observations. We flatten this to device, at the router its keyed by mac or failed ip.
At the fleet idfk, i dont control that part of the code; its all AI.
So, if we can collect wake targets directly from device. Saves a whole plane of problems. suddenly wakey-cc is like 6 million times slimmer, because this is a wakey or wakey-core problem.
@@ -0,0 +1,149 @@
# Core Endpoints and Wake Routes
Wakey will model observed network contact points as **Endpoints** in `wakey-core`, and derive **Wake Routes** from those endpoints instead of reconstructing routes from flattened device summary MAC/IP arrays. `Device.ips` and `Device.macs` remain true summary fields for operator display and compatibility, but endpoint data is authoritative for wake, freshness, source, per-IP state, and route selection.
**Status**: accepted
## Context
The previous model stored a `Device` aggregate with summary `macs`, `ips`, `presence`, and raw observation facts. The control plane then built fleet wake routes from combinations like first MAC plus first IP. That lost source, presence, interface, and MAC/IP-pair evidence, especially when hook memory contained removed IPs or when several sources described the same address differently.
## Decision
Add a typed `DeviceEndpoint` concept in `wakey-core`. A device snapshot sent by an agent contains devices with endpoints already attached. The control plane stores structured endpoint rows and derives fleet summaries and wake routes from those rows.
Endpoint shape:
```rust
DeviceEndpoint {
key: EndpointKey,
hostname: Option<String>,
interface: Option<String>,
presence: Presence,
first_seen_unix: Option<u64>,
last_seen_unix: Option<u64>,
}
EndpointKey {
source: EndpointSource,
mac: Option<MacAddr>,
ip: Option<IpAddr>,
}
AgentEndpointKey {
agent_id: String,
endpoint: EndpointKey,
}
```
`EndpointKey` must contain at least one of MAC or IP. Hostname and interface are endpoint evidence/metadata, not identity. `AgentEndpointKey` may use `serde(flatten)` for wire shape, but hash values are never durable storage keys. String keys are boundary serialization only.
## Source and Presence Rules
Endpoint sources are typed domain variants, not free strings:
```text
Neighbor
DhcpLease
HookNeighbor
HookDhcp
```
Presence answers “is this reachable?”, not “can this be woken?” The presence order is:
```text
Unknown < Offline < LikelyOnline < Online
```
Source mapping:
```text
Neighbor reachable/permanent -> Online
Neighbor stale -> LikelyOnline
Neighbor failed/remove-like evidence -> Offline
Other neighbor states -> Unknown
Current DHCP lease -> Unknown
Hook DHCP add/update/remove -> Unknown
Hook neighbor add/update/old -> LikelyOnline
Hook neighbor remove -> Offline
```
Current DHCP leases are summary-eligible and may support wake if they include a MAC, but they do not prove reachability. Hook memory creates endpoints and observation facts, but does not directly inflate summary IPs.
## Summary, Presence, and UI
`Device.ips` and `Device.macs` stay as summaries. Summary IPs come from concrete live-ish sources such as current neighbor rows and current DHCP lease rows. Hook-derived and offline IPs remain visible through endpoints but are collapsed out of primary UI summaries by default.
`Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it.
The main fleet API embeds endpoints inside each fleet device. API responses expose all endpoints, including offline and unknown endpoints. The UI may collapse offline/unknown endpoints in the primary row, but details must expose them for copy/debug/history.
## Wakeability and Wake Route Ordering
Wakeability is derived, not stored on `DeviceEndpoint`.
An endpoint is wakeable when:
```text
endpoint has a MAC
agent is connected
agent has wake capability
endpoint source is allowed for wake route derivation
```
For now, a connected wakey-agent is assumed to have wake capability. Presence does not gate wakeability: an `Offline` endpoint may still produce a wake route, because waking powered-off devices is the core use case.
Preferred wake route order:
```text
connected agent
wakeable route
has IP
presence quality
newest last_seen
source quality
```
Presence quality for route ranking follows:
```text
Online > LikelyOnline > Offline > Unknown
```
Source quality follows:
```text
Neighbor > DhcpLease > HookNeighbor > HookDhcp
```
Equivalent wake routes may ignore endpoint source when they target the same agent, MAC, and IP. Endpoint evidence preserves source-specific rows; route grouping may deduplicate equivalent targets.
## Device and Identity Rules
`DeviceId` remains the typed observed identity enum:
```rust
DeviceId::Mac(MacAddr)
DeviceId::Ip(IpAddr)
```
`DeviceId` is derived from endpoints: canonical MAC if any endpoint has a MAC, otherwise canonical IP, otherwise absent. Local source rows are grouped into devices by endpoint-derived `DeviceId`, not exact `EndpointKey`.
Within a single complete local snapshot, a MAC+IP endpoint may absorb an IP-only endpoint with the same IP. This merge is not remembered across snapshots unless the operator creates a known-device identifier.
Known-device identifiers are source-independent ownership claims over MAC or permanent IP values. Wakey does not infer whether an IP is permanent, static, or reserved; the UI should warn the operator when attaching an IP identifier.
## Control Plane Storage
The control plane stores current per-agent endpoint snapshots as structured columns, not opaque `Device` JSON and not endpoint event history. Summary MAC/IP collections are derived from endpoints; separate MAC/IP storage should be removed or represented as views rather than maintained as independent truth.
Agent-local hook memory is retained by local last-seen age policy, not by control-plane acknowledgement. The default retention is 7 days unless configured otherwise. Live-source endpoints use snapshot time as their observed time; hook-derived endpoints use the hook row's recorded last-seen time.
## Consequences
- Wake route construction moves away from `macs.first()` / `ips.first()` guessing.
- The agent/core layer owns source interpretation because it still has local context.
- The control plane can search, filter, match known identifiers, and rank wake routes from structured endpoint rows.
- UI can show clean summaries while keeping complete endpoint evidence in details.
- This preserves “wake an offline machine by MAC” without pretending that offline means not wakeable.
-74
View File
@@ -1,74 +0,0 @@
# Report: Device State Rewrite
## What changed
The control plane now stores **complete per-agent Device snapshots** instead of flat observation rows. The agent sends `Vec<Device>` (merged from DHCP, neighbors, hooks). The control plane upserts per agent, deletes stale, fleet view merges across agents at read time.
## Architecture (before → after)
**Before**: Agent → raw hook observations → control plane flattens into rows → re-merges at fleet read → presence recomputed from flat actions
**After**: Agent → inventory → `Vec<Device>` with correct presence → control plane stores as-is → fleet merge at read time from typed rows
## Schema
Old tables dropped: `agent_device_observations`, `agent_device_observation_events`, `agent_observation_snapshots`, `agent_observation_snapshot_keys`
New tables:
- `agent_devices` — per-agent device row (keyed by `DeviceId` serialization)
- `agent_device_macs` — MACs per device, indexed for cross-agent merge
- `agent_device_ips` — IPs per device, indexed for cross-agent merge
- `agent_device_hostnames` — hostnames per device
- `agent_device_facts` — debug/source material (JSON, not identity)
## Concrete types throughout
- `FleetDevice.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`, `presence`: `Presence`
- `FleetWakeRoute.mac`: `Option<MacAddr>`, `ip`: `Option<IpAddr>`
- `AgentDeviceWithChildren.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`
- Row types (`AgentDeviceMacRow`, `AgentDeviceIpRow`) stay `String` from SQLite, convert via `TryFrom` impls
- `AgentDeviceRow` has `.presence()`, `.first_seen()`, `.last_seen()` methods — no string matching in business code
- `Presence` from wakey-core: `Ord`, `PartialOrd`, `From<&str>`, `.as_str()`
## Rust features used
- `TryFrom<&AgentDeviceMacRow> for MacAddr`, `TryFrom<&AgentDeviceIpRow> for IpAddr`
- `From<&str> for Presence`
- `#[allow(dead_code)]` where appropriate (test-only methods, debug fields)
- Workspace dependencies for shared crates (`macaddr`, `serde`, `tokio`, etc.)
- Shared `TestStore` with `Drop` guard for clean test teardown
## What was removed
- `AgentObservation` struct (replaced by `Device`)
- `AgentDeviceObservation`, `AgentDeviceObservationView`, `AgentDeviceObservationEvent`, `AgentDeviceObservationInput` types
- `inventory_result_to_observations()` — the flatten-then-rebuild function
- `upload_agent_observations`, `list_agent_observations`, `list_agent_observation_history` API endpoints
- `gc_stale_observations()` — no longer needed
- `SyncObservations` CLI command from agent
- `prune_removed_observations_from_path` calls from agent session
- Observations page from UI
## What stayed
- Agent observation store (`/tmp/wakey_observations.json`) — agent-local memory, not forwarded
- Hotplug hooks — still update agent-local store, feed next inventory
- Known devices + identifiers — manual lifecycle, never auto-deleted
- Audit events, alerts — unchanged
- `observation_retention` config field — `#[allow(dead_code)]`, kept for config file compat
## Verification
```
cargo fmt — clean
cargo clippy-all — clean (0 warnings)
cargo test-all — 89 tests pass, 6 ignored (on-device only)
pnpm typecheck — clean
```
## Remaining
- UI cleanup: remove observations page nav entry, observation API types from `api.ts`
- Known device identifier UI controls (API exists, UI needs affordance)
- The `observation_retention` config field could be removed in a future cleanup
- `list_agent_device_rows_for_agent` is `#[allow(dead_code)]` — used in tests, could be `#[cfg(test)]`
+1 -1
View File
@@ -12,7 +12,7 @@ start_service() {
procd_open_instance procd_open_instance
procd_set_param command "$BIN" serve --config "$CONFIG" procd_set_param command "$BIN" serve --config "$CONFIG"
procd_set_param file "$CONFIG" procd_set_param file "$CONFIG"
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug # procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
procd_set_param respawn 5 1 0 procd_set_param respawn 5 1 0
procd_set_param stdout 1 procd_set_param stdout 1
procd_set_param stderr 1 procd_set_param stderr 1
+1
View File
@@ -13,6 +13,7 @@ pub use service::{
}; };
pub use wakey_linux::observations::{ pub use wakey_linux::observations::{
list_local_observations, observe_dhcp_event, observe_neighbor_event, list_local_observations, observe_dhcp_event, observe_neighbor_event,
prune_stale_observations_from_path,
}; };
#[cfg(test)] #[cfg(test)]
+59 -4
View File
@@ -77,7 +77,7 @@ pub fn merge_devices_with_observations(
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)); // key by mac or by FAILED ip
by_key.entry(key).or_default().0.push(row); by_key.entry(key).or_default().0.push(row);
} }
for lease in leases { for lease in leases {
@@ -99,7 +99,7 @@ pub fn merge_devices_with_observations(
Device::from_parts_with_observations(neighbors, leases, observations) Device::from_parts_with_observations(neighbors, leases, observations)
}) })
.collect(); .collect();
if !query.is_empty() {
let mut texts = Vec::new(); let mut texts = Vec::new();
let mut devs = Vec::new(); let mut devs = Vec::new();
let mut ips = Vec::new(); let mut ips = Vec::new();
@@ -123,6 +123,7 @@ pub fn merge_devices_with_observations(
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac))) && (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state))) && (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
}); });
}
devices.sort_by(|a, b| { devices.sort_by(|a, b| {
presence_rank(b.presence) presence_rank(b.presence)
.cmp(&presence_rank(a.presence)) .cmp(&presence_rank(a.presence))
@@ -180,7 +181,7 @@ const fn presence_rank(presence: Presence) -> u8 {
mod tests { mod tests {
use super::*; use super::*;
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState}; use wakey_core::{DeviceId, DhcpLease, EndpointSource, InventoryQueryBuilder, NeighborState};
fn sample_neighbors() -> Vec<NeighborEntry> { fn sample_neighbors() -> Vec<NeighborEntry> {
vec![NeighborEntry { vec![NeighborEntry {
@@ -278,11 +279,14 @@ mod tests {
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac"))) Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
); );
assert_eq!(out[0].observations.len(), 1); assert_eq!(out[0].observations.len(), 1);
assert_eq!(out[0].endpoints.len(), 1);
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
assert!(out[0].ips.is_empty());
assert_eq!(out[0].presence, Presence::Unknown); assert_eq!(out[0].presence, Presence::Unknown);
} }
#[test] #[test]
fn merge_devices_marks_remove_only_observation_offline() { fn merge_devices_marks_neigh_remove_observation_offline() {
let query = InventoryQueryBuilder::new().build(); let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations( let out = merge_devices_with_observations(
Vec::new(), Vec::new(),
@@ -301,5 +305,56 @@ mod tests {
assert_eq!(out.len(), 1); assert_eq!(out.len(), 1);
assert_eq!(out[0].presence, Presence::Offline); assert_eq!(out[0].presence, Presence::Offline);
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookNeighbor);
assert!(out[0].ips.is_empty());
}
#[test]
fn merge_devices_keeps_dhcp_remove_observation_unknown() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "dhcp".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::Unknown);
assert_eq!(out[0].endpoints[0].key.source, EndpointSource::HookDhcp);
assert!(out[0].ips.is_empty());
}
#[test]
fn merge_devices_uses_ip_id_for_ip_only_hook_endpoint_without_summary_ip() {
let query = InventoryQueryBuilder::new().build();
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 31));
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "neigh".into(),
action: "remove".into(),
mac: None,
ip: Some(ip),
hostname: None,
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, Some(DeviceId::Ip(ip)));
assert!(out[0].ips.is_empty());
assert_eq!(out[0].endpoints.len(), 1);
} }
} }
+14
View File
@@ -4,6 +4,20 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wakey Operator UI</title> <title>Wakey Operator UI</title>
<script>
(function () {
try {
var t = localStorage.getItem("wakey-ui-theme");
if (
t === "dark" ||
((t === "system" || !t) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
}
} catch (_) {}
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -144,6 +144,7 @@ export function App() {
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId} onSelectAgent={setSelectedAgentId}
onAfterWake={loadAll} onAfterWake={loadAll}
onRefresh={loadAll}
/> />
} }
/> />
+17
View File
@@ -103,6 +103,20 @@ export type FleetDeviceAgent = {
last_seen_unix: number; last_seen_unix: number;
}; };
export type FleetDeviceEndpoint = {
agent_id: string;
nickname?: string | null;
connected: boolean;
source: string;
mac: string | null;
ip: string | null;
hostname: string | null;
interface: string | null;
presence: string;
first_seen_unix: number | null;
last_seen_unix: number | null;
};
export type FleetWakeRoute = { export type FleetWakeRoute = {
route_id: string; route_id: string;
agent_id: string; agent_id: string;
@@ -111,7 +125,9 @@ export type FleetWakeRoute = {
mac: string | null; mac: string | null;
ip: string | null; ip: string | null;
hostname: string | null; hostname: string | null;
interface: string | null;
source: string; source: string;
presence: string;
last_seen_unix: number; last_seen_unix: number;
wakeable: boolean; wakeable: boolean;
}; };
@@ -126,6 +142,7 @@ export type FleetDevice = {
hostnames: string[]; hostnames: string[];
agents: FleetDeviceAgent[]; agents: FleetDeviceAgent[];
sources: string[]; sources: string[];
endpoints: FleetDeviceEndpoint[];
first_seen_unix: number | null; first_seen_unix: number | null;
last_seen_unix: number | null; last_seen_unix: number | null;
presence: string; presence: string;
+7 -1
View File
@@ -93,7 +93,7 @@ export function AppLayout() {
className={[ className={[
"sidebar", "sidebar",
collapsed ? "sidebar--collapsed" : "", collapsed ? "sidebar--collapsed" : "",
"fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-300 ease-in-out", "fixed inset-y-0 left-0 z-50 shadow-xl transition-all duration-100 ease-in-out",
"md:sticky md:top-0 md:z-30 md:shadow-none", "md:sticky md:top-0 md:z-30 md:shadow-none",
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0", collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
] ]
@@ -219,8 +219,14 @@ export function AppLayout() {
{/* Mobile backdrop */} {/* Mobile backdrop */}
{!collapsed && ( {!collapsed && (
<div <div
role="button"
tabIndex={0}
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200" className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
onClick={() => setCollapsed(true)} onClick={() => setCollapsed(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
}}
aria-label="Close menu"
/> />
)} )}
+20 -7
View File
@@ -72,7 +72,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="alert-severity"
className="grid gap-1 text-sm text-muted-foreground"
>
Severity Severity
<Select <Select
value={severity} value={severity}
@@ -80,7 +83,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
if (value) setSeverity(value); if (value) setSeverity(value);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="alert-severity" className="w-full">
<SelectValue placeholder="Severity" /> <SelectValue placeholder="Severity" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -93,7 +96,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</Select> </Select>
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="alert-status"
className="grid gap-1 text-sm text-muted-foreground"
>
Status Status
<Select <Select
value={status} value={status}
@@ -101,7 +107,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
if (value) setStatus(value); if (value) setStatus(value);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="alert-status" className="w-full">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -114,7 +120,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</Select> </Select>
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="alert-kind"
className="grid gap-1 text-sm text-muted-foreground"
>
Kind Kind
<Select <Select
value={kind} value={kind}
@@ -122,7 +131,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
if (value) setKind(value); if (value) setKind(value);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="alert-kind" className="w-full">
<SelectValue placeholder="Kind" /> <SelectValue placeholder="Kind" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -170,9 +179,13 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
</span> </span>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="alert-transition-search"
className="grid gap-1 text-sm text-muted-foreground"
>
Search Search
<Input <Input
id="alert-transition-search"
value={transitionQ} value={transitionQ}
onChange={(e) => setTransitionQ(e.target.value)} onChange={(e) => setTransitionQ(e.target.value)}
placeholder="kind, status, message, agent" placeholder="kind, status, message, agent"
+15 -5
View File
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="audit-event-type"
className="grid gap-1 text-sm text-muted-foreground"
>
Event type Event type
<Select <Select
value={eventType} value={eventType}
@@ -78,7 +81,7 @@ export function AuditPage({ events, onRefresh }: Props) {
if (value) setEventType(value); if (value) setEventType(value);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="audit-event-type" className="w-full">
<SelectValue placeholder="Event type" /> <SelectValue placeholder="Event type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
</Select> </Select>
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="audit-outcome"
className="grid gap-1 text-sm text-muted-foreground"
>
Outcome Outcome
<Select <Select
value={outcome} value={outcome}
@@ -99,7 +105,7 @@ export function AuditPage({ events, onRefresh }: Props) {
if (value) setOutcome(value); if (value) setOutcome(value);
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="audit-outcome" className="w-full">
<SelectValue placeholder="Outcome" /> <SelectValue placeholder="Outcome" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -112,9 +118,13 @@ export function AuditPage({ events, onRefresh }: Props) {
</Select> </Select>
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="audit-search"
className="grid gap-1 text-sm text-muted-foreground"
>
Search Search
<Input <Input
id="audit-search"
value={needle} value={needle}
onChange={(e) => setNeedle(e.target.value)} onChange={(e) => setNeedle(e.target.value)}
placeholder="message, agent, actor" placeholder="message, agent, actor"
+15 -5
View File
@@ -66,12 +66,15 @@ export function CommandsPage({
Command Runner Command Runner
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Debug tool — send raw commands to agents and inspect JSON responses Debug tool: send raw commands to agents and inspect JSON responses
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="grid gap-3" onSubmit={submit}> <form className="grid gap-3" onSubmit={submit}>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="cmd-agent"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Agent</span> <span>Agent</span>
<AgentSelector <AgentSelector
agents={agents} agents={agents}
@@ -80,13 +83,16 @@ export function CommandsPage({
/> />
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="cmd-kind"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Command</span> <span>Command</span>
<Select <Select
value={kind} value={kind}
onValueChange={(value) => setKind(value as CommandKind)} onValueChange={(value) => setKind(value as CommandKind)}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="cmd-kind" className="w-full">
<SelectValue placeholder="Pick command" /> <SelectValue placeholder="Pick command" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -98,9 +104,13 @@ export function CommandsPage({
</Select> </Select>
</label> </label>
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="cmd-query"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Query</span> <span>Query</span>
<Input <Input
id="cmd-query"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="optional" placeholder="optional"
+5 -5
View File
@@ -115,7 +115,7 @@ export function DashboardPage({
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Fleet Status */} {/* Fleet Status */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5"> <CardDescription className="flex items-center gap-1.5">
<Bot className="size-3.5" /> <Bot className="size-3.5" />
Fleet Status Fleet Status
@@ -152,7 +152,7 @@ export function DashboardPage({
{/* Active Alerts */} {/* Active Alerts */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5"> <CardDescription className="flex items-center gap-1.5">
<Bell className="size-3.5" /> <Bell className="size-3.5" />
Active Alerts Active Alerts
@@ -188,7 +188,7 @@ export function DashboardPage({
{/* Transitions */} {/* Transitions */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5"> <CardDescription className="flex items-center gap-1.5">
<Activity className="size-3.5" /> <Activity className="size-3.5" />
Transitions Transitions
@@ -206,7 +206,7 @@ export function DashboardPage({
{/* Token Health */} {/* Token Health */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between gap-y-0 pb-2">
<CardDescription className="flex items-center gap-1.5"> <CardDescription className="flex items-center gap-1.5">
<Key className="size-3.5" /> <Key className="size-3.5" />
Enroll Tokens Enroll Tokens
@@ -228,7 +228,7 @@ export function DashboardPage({
{/* Recent Activity Feed */} {/* Recent Activity Feed */}
<Card className="sm:col-span-2 lg:col-span-4"> <Card className="sm:col-span-2 lg:col-span-4">
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0"> <CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
<div> <div>
<CardTitle>Recent Activity</CardTitle> <CardTitle>Recent Activity</CardTitle>
<CardDescription className="mt-1"> <CardDescription className="mt-1">
+17 -6
View File
@@ -51,9 +51,10 @@ type Props = {
selectedAgentId: string; selectedAgentId: string;
onSelectAgent: (agentId: string) => void; onSelectAgent: (agentId: string) => void;
onAfterWake?: () => Promise<void>; onAfterWake?: () => Promise<void>;
onRefresh?: () => Promise<void>;
}; };
export function DevicesPage({ agents, onAfterWake }: Props) { export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
const [devices, setDevices] = useState<FleetDevice[]>([]); const [devices, setDevices] = useState<FleetDevice[]>([]);
const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]); const [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -137,8 +138,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
} catch { } catch {
const area = document.createElement("textarea"); const area = document.createElement("textarea");
area.value = text; area.value = text;
area.style.position = "fixed"; area.style.cssText = "position:fixed;opacity:0";
area.style.opacity = "0";
document.body.appendChild(area); document.body.appendChild(area);
area.select(); area.select();
document.execCommand("copy"); document.execCommand("copy");
@@ -201,7 +201,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => void loadFleet()} onClick={() => {
void loadFleet();
if (onRefresh) void onRefresh();
}}
disabled={loading} disabled={loading}
> >
<RefreshCw className="size-4" aria-hidden /> <RefreshCw className="size-4" aria-hidden />
@@ -222,11 +225,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]"> <div className="grid gap-2 lg:grid-cols-[minmax(16rem,1fr)_11rem_10rem_14rem_10rem]">
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="fleet-search"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Search</span> <span>Search</span>
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
<Input <Input
id="fleet-search"
className="pl-8" className="pl-8"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -246,7 +253,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
values={knownFilters} values={knownFilters}
onChange={(value) => setKnown(value as KnownFilter)} onChange={(value) => setKnown(value as KnownFilter)}
/> />
<label className="grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="fleet-agent"
className="grid gap-1 text-sm text-muted-foreground"
>
<span>Agent</span> <span>Agent</span>
<Select <Select
value={agentId} value={agentId}
@@ -334,6 +344,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
</Card> </Card>
<FleetDeviceDetailsDialog <FleetDeviceDetailsDialog
key={details?.device_key ?? "__none"}
device={details} device={details}
knownDevices={knownDevices} knownDevices={knownDevices}
open={Boolean(details)} open={Boolean(details)}
+6 -2
View File
@@ -118,7 +118,7 @@ export function TokensPage() {
return ( return (
<section className="grid gap-4 xl:grid-cols-2"> <section className="grid gap-4 xl:grid-cols-2">
<Card> <Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0"> <CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0">
<div> <div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Key className="size-5 text-primary" /> <Key className="size-5 text-primary" />
@@ -219,9 +219,13 @@ export function TokensPage() {
</Button> </Button>
</div> </div>
{useCustom && ( {useCustom && (
<label className="mt-2 grid gap-1 text-sm text-muted-foreground"> <label
htmlFor="token-custom-ttl"
className="mt-2 grid gap-1 text-sm text-muted-foreground"
>
<span>TTL (seconds)</span> <span>TTL (seconds)</span>
<Input <Input
id="token-custom-ttl"
type="number" type="number"
min={1} min={1}
value={customTtl} value={customTtl}
+13 -7
View File
@@ -101,7 +101,10 @@ export function WakeToolsPage({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<label className="grid gap-1.5 text-sm text-muted-foreground"> <label
htmlFor="wake-agent"
className="grid gap-1.5 text-sm text-muted-foreground"
>
<span>Agent</span> <span>Agent</span>
<AgentSelector <AgentSelector
agents={agents} agents={agents}
@@ -111,14 +114,17 @@ export function WakeToolsPage({
</label> </label>
<form onSubmit={handleWake} className="grid gap-3"> <form onSubmit={handleWake} className="grid gap-3">
<label className="grid gap-1.5 text-sm text-muted-foreground"> <label
htmlFor="wake-target"
className="grid gap-1.5 text-sm text-muted-foreground"
>
<span>Target</span> <span>Target</span>
<Input <Input
id="wake-target"
value={target} value={target}
onChange={(e) => setTarget(e.target.value)} onChange={(e) => setTarget(e.target.value)}
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff" placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
disabled={busy} disabled={busy}
autoFocus
/> />
</label> </label>
<Button <Button
@@ -134,7 +140,7 @@ export function WakeToolsPage({
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0"> <CardHeader className="flex flex-row items-center justify-between gap-2 gap-y-0">
<CardTitle>Recent Wakes</CardTitle> <CardTitle>Recent Wakes</CardTitle>
<Button <Button
variant="outline" variant="outline"
@@ -147,10 +153,10 @@ export function WakeToolsPage({
<CardContent> <CardContent>
{recentWakes.length > 0 ? ( {recentWakes.length > 0 ? (
<div className="grid gap-0"> <div className="grid gap-0">
{recentWakes.map((event, idx) => ( {recentWakes.map((event, i) => (
<div <div
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`} className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
key={`${event.ts}-${idx}`} key={`${event.ts}-${event.target}-${event.agentId}`}
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+87 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Minus, Plus, Zap } from "lucide-react"; import { Copy, Minus, Plus, Zap } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -8,6 +8,7 @@ import {
detachDeviceIdentifier, detachDeviceIdentifier,
mergeKnownDevice, mergeKnownDevice,
type FleetDevice, type FleetDevice,
type FleetDeviceEndpoint,
type KnownDevice, type KnownDevice,
} from "@/api"; } from "@/api";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -29,7 +30,12 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents"; import { DetailBlock, PresenceBadge } from "@/pages/fleet/FleetComponents";
import { agentLabel, identifiersFor, routeLabel } from "@/pages/fleet/utils"; import {
agentLabel,
formatSeen,
identifiersFor,
routeLabel,
} from "@/pages/fleet/utils";
type Props = { type Props = {
device: FleetDevice | null; device: FleetDevice | null;
@@ -52,24 +58,16 @@ export function FleetDeviceDetailsDialog({
onCopy, onCopy,
onChanged, onChanged,
}: Props) { }: Props) {
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState(device?.display_name ?? "");
const [targetDeviceId, setTargetDeviceId] = useState(""); const [targetDeviceId, setTargetDeviceId] = useState("");
const [routeId, setRouteId] = useState(""); const [routeId, setRouteId] = useState(
device?.recommended_route?.route_id ?? "",
);
const [addKind, setAddKind] = useState<"mac" | "ip">("mac"); const [addKind, setAddKind] = useState<"mac" | "ip">("mac");
const [addValue, setAddValue] = useState(""); const [addValue, setAddValue] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
useEffect(() => {
setDisplayName(device?.display_name ?? "");
setTargetDeviceId("");
setRouteId(device?.recommended_route?.route_id ?? "");
setAddKind("mac");
setAddValue("");
setError("");
setActionBusy(false);
}, [device]);
const rowIdentifiers = device ? identifiersFor(device) : []; const rowIdentifiers = device ? identifiersFor(device) : [];
// Resolve the full KnownDevice object if this fleet row is known // Resolve the full KnownDevice object if this fleet row is known
@@ -233,6 +231,30 @@ export function FleetDeviceDetailsDialog({
<Separator /> <Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Endpoints</h3>
<Badge variant="outline">{device.endpoints.length}</Badge>
</div>
{device.endpoints.length ? (
<div className="divide-y overflow-hidden rounded-md border">
{device.endpoints.map((endpoint, index) => (
<EndpointRow
key={`${endpoint.agent_id}:${endpoint.source}:${endpoint.mac ?? ""}:${endpoint.ip ?? ""}:${index}`}
endpoint={endpoint}
onCopy={onCopy}
/>
))}
</div>
) : (
<p className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
No endpoint evidence reported.
</p>
)}
</div>
<Separator />
{/* ── Wake route ── */} {/* ── Wake route ── */}
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -279,7 +301,7 @@ export function FleetDeviceDetailsDialog({
<span className="font-medium">{fullKnown.display_name}</span> <span className="font-medium">{fullKnown.display_name}</span>
{fullKnown.notes && ( {fullKnown.notes && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
— {fullKnown.notes} : {fullKnown.notes}
</span> </span>
)} )}
</div> </div>
@@ -521,3 +543,52 @@ export function FleetDeviceDetailsDialog({
</Dialog> </Dialog>
); );
} }
function EndpointRow({
endpoint,
onCopy,
}: {
endpoint: FleetDeviceEndpoint;
onCopy: (label: string, value: string) => void;
}) {
const target = [endpoint.mac, endpoint.ip].filter(Boolean).join(" / ");
const location = [endpoint.interface, endpoint.hostname]
.filter(Boolean)
.join(" / ");
return (
<div className="grid gap-2 p-3 text-sm sm:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<PresenceBadge presence={endpoint.presence} />
<Badge variant="secondary">{endpoint.source.replace("_", " ")}</Badge>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{agentLabel(endpoint.agent_id, endpoint.nickname)}
{endpoint.connected ? " (connected)" : " (offline)"}
</p>
</div>
<div className="min-w-0">
<p className="truncate font-mono text-xs">{target || "-"}</p>
<p className="truncate text-xs text-muted-foreground">
{location || "No interface or hostname"}
</p>
</div>
<div className="flex items-center justify-between gap-2 sm:justify-end">
<span className="text-xs text-muted-foreground">
{formatSeen(endpoint.last_seen_unix)}
</span>
{target && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onCopy("Endpoint", target)}
aria-label={`Copy endpoint ${target}`}
>
<Copy className="size-3.5" />
</Button>
)}
</div>
</div>
);
}
+4 -4
View File
@@ -14,8 +14,8 @@ export const presenceFilters = [
"all", "all",
"online", "online",
"likely_online", "likely_online",
"unknown",
"offline", "offline",
"unknown",
] as const; ] as const;
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"]; export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
export function presenceRank(presence: string): number { export function presenceRank(presence: string): number {
if (presence === "online") return 4; if (presence === "online") return 4;
if (presence === "likely_online") return 3; if (presence === "likely_online") return 3;
if (presence === "unknown") return 2; if (presence === "offline") return 2;
if (presence === "offline") return 1; if (presence === "unknown") return 1;
return 0; return 0;
} }
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
} }
export function routeLabel(route: FleetWakeRoute): string { export function routeLabel(route: FleetWakeRoute): string {
const status = route.connected ? "connected" : "offline"; const status = route.connected ? "connected" : "agent offline";
const target = route.mac const target = route.mac
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}` ? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
: (route.ip ?? "-"); : (route.ip ?? "-");
+2 -1
View File
@@ -135,7 +135,7 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground transition-colors duration-100 ease-in-out;
margin: 0; margin: 0;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -189,6 +189,7 @@
background-size: 100% 100%; background-size: 100% 100%;
overflow-y: auto; overflow-y: auto;
max-width: 100%; max-width: 100%;
transition: background 100ms ease-in-out;
} }
/* ── Sidebar internals ────────────────────────────────────────────────── */ /* ── Sidebar internals ────────────────────────────────────────────────── */
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-agent" name = "wakey-agent"
version = "0.3.0" version = "0.3.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+4
View File
@@ -98,6 +98,10 @@ pub struct InitConfigArgs {
#[arg(long)] #[arg(long)]
pub agent_token: Option<String>, pub agent_token: Option<String>,
/// Days to keep local hook observation rows since last seen. Zero disables pruning.
#[arg(long)]
pub observation_retention_days: Option<u64>,
/// Replace an existing config file. /// Replace an existing config file.
#[arg(long)] #[arg(long)]
pub force: bool, pub force: bool,
+44
View File
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock;
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml"; pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid"; pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid";
@@ -11,6 +12,7 @@ const WAKEY_OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases"; const DEFAULT_DHCP_LEASES_PATH: &str = "/tmp/dhcp.leases";
const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json"; const DEFAULT_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.json";
const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json"; const DEFAULT_OBSERVATION_STORE_PATH: &str = "/tmp/wakey_observations.json";
pub const DEFAULT_OBSERVATION_RETENTION_DAYS: u64 = 7;
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig { pub struct AgentConfig {
@@ -23,6 +25,8 @@ pub struct AgentConfig {
pub reconnect_max_ms: u64, pub reconnect_max_ms: u64,
#[serde(default = "default_observation_sync_interval_seconds")] #[serde(default = "default_observation_sync_interval_seconds")]
pub observation_sync_interval_seconds: u64, pub observation_sync_interval_seconds: u64,
#[serde(default = "default_observation_retention_days")]
pub observation_retention_days: u64,
#[serde(default = "default_pid_file")] #[serde(default = "default_pid_file")]
pub pid_file: PathBuf, pub pid_file: PathBuf,
#[serde(default = "default_dhcp_leases_path")] #[serde(default = "default_dhcp_leases_path")]
@@ -33,6 +37,20 @@ pub struct AgentConfig {
pub observation_store_path: PathBuf, pub observation_store_path: PathBuf,
} }
pub static DEFAULT_CONFIG: LazyLock<AgentConfig> = LazyLock::new(|| AgentConfig {
server_url: "https://wakey.ldlda.com".to_string(),
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
reconnect_base_ms: default_reconnect_base_ms(),
reconnect_max_ms: default_reconnect_max_ms(),
observation_sync_interval_seconds: default_observation_sync_interval_seconds(),
observation_retention_days: default_observation_retention_days(),
pid_file: default_pid_file(),
dhcp_leases_path: default_dhcp_leases_path(),
mac_name_cache_path: default_mac_name_cache_path(),
observation_store_path: default_observation_store_path(),
});
impl fmt::Debug for AgentConfig { impl fmt::Debug for AgentConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentConfig") f.debug_struct("AgentConfig")
@@ -45,6 +63,10 @@ impl fmt::Debug for AgentConfig {
"observation_sync_interval_seconds", "observation_sync_interval_seconds",
&self.observation_sync_interval_seconds, &self.observation_sync_interval_seconds,
) )
.field(
"observation_retention_days",
&self.observation_retention_days,
)
.field("pid_file", &self.pid_file) .field("pid_file", &self.pid_file)
.field("dhcp_leases_path", &self.dhcp_leases_path) .field("dhcp_leases_path", &self.dhcp_leases_path)
.field("mac_name_cache_path", &self.mac_name_cache_path) .field("mac_name_cache_path", &self.mac_name_cache_path)
@@ -65,6 +87,10 @@ const fn default_observation_sync_interval_seconds() -> u64 {
60 60
} }
const fn default_observation_retention_days() -> u64 {
DEFAULT_OBSERVATION_RETENTION_DAYS
}
fn default_pid_file() -> PathBuf { fn default_pid_file() -> PathBuf {
DEFAULT_PID_FILE.into() DEFAULT_PID_FILE.into()
} }
@@ -183,6 +209,7 @@ mod tests {
reconnect_base_ms: 123, reconnect_base_ms: 123,
reconnect_max_ms: 456, reconnect_max_ms: 456,
observation_sync_interval_seconds: 7, observation_sync_interval_seconds: 7,
observation_retention_days: 3,
pid_file: "/tmp/test-wakey-agent.pid".into(), pid_file: "/tmp/test-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/test-dhcp.leases".into(), dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
mac_name_cache_path: "/tmp/test-names.json".into(), mac_name_cache_path: "/tmp/test-names.json".into(),
@@ -202,4 +229,21 @@ mod tests {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn config_without_retention_uses_current_default() {
let config: AgentConfig = toml::from_str(
r#"
server_url = "https://example.com"
agent_id = "agent-1"
agent_token = "secret"
"#,
)
.expect("config should parse");
assert_eq!(
config.observation_retention_days,
DEFAULT_OBSERVATION_RETENTION_DAYS
);
}
} }
+24 -1
View File
@@ -64,6 +64,15 @@ async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
} }
async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Result<CommandResult> { async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Result<CommandResult> {
Ok(CommandResult::Inventory(
inventory_for_config(req, config).await?,
))
}
pub async fn inventory_for_config(
req: InventoryRequest,
config: &AgentConfig,
) -> Result<wakey_core::DeviceInventory> {
let query = req.into_inventory_query(); let query = req.into_inventory_query();
let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?; let neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths( let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
@@ -72,6 +81,20 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
&config.mac_name_cache_path, &config.mac_name_cache_path,
) )
.await?; .await?;
match wakey::wakey_linux::observations::prune_stale_observations_from_path(
&config.observation_store_path,
config.observation_retention_days,
)
.await
{
Ok(removed) if removed > 0 => {
debug!(removed, "pruned stale local hook observations");
}
Ok(_) => {}
Err(err) => {
warn!(error = %err, "failed pruning stale local hook observations");
}
}
let observations = match wakey::wakey_linux::observations::list_local_observations_from_path( let observations = match wakey::wakey_linux::observations::list_local_observations_from_path(
&config.observation_store_path, &config.observation_store_path,
) )
@@ -104,7 +127,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
rows = inventory.devices.len(), rows = inventory.devices.len(),
"dispatched inventory command" "dispatched inventory command"
); );
Ok(CommandResult::Inventory(inventory)) Ok(inventory)
} }
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> { async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
+9 -27
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::config::{AgentConfig, save_config_with_backup}; use crate::config::{AgentConfig, DEFAULT_CONFIG, save_config_with_backup};
pub struct EnrollOutcome { pub struct EnrollOutcome {
pub config: AgentConfig, pub config: AgentConfig,
@@ -55,32 +55,12 @@ pub async fn enroll(
.await .await
.context("failed to decode enrollment response")?; .context("failed to decode enrollment response")?;
let config = AgentConfig { let mut config = base_config
server_url: payload.server_url.unwrap_or(server_url), .cloned()
agent_id: payload.agent_id, .unwrap_or_else(|| (*DEFAULT_CONFIG).clone());
agent_token: payload.agent_token, config.server_url = payload.server_url.unwrap_or(server_url);
reconnect_base_ms: base_config config.agent_id = payload.agent_id;
.map(|config| config.reconnect_base_ms) config.agent_token = payload.agent_token;
.unwrap_or(1_000),
reconnect_max_ms: base_config
.map(|config| config.reconnect_max_ms)
.unwrap_or(30_000),
observation_sync_interval_seconds: base_config
.map(|config| config.observation_sync_interval_seconds)
.unwrap_or(60),
pid_file: base_config
.map(|config| config.pid_file.clone())
.unwrap_or_else(|| crate::config::DEFAULT_PID_FILE.into()),
dhcp_leases_path: base_config
.map(|config| config.dhcp_leases_path.clone())
.unwrap_or_else(|| "/tmp/dhcp.leases".into()),
mac_name_cache_path: base_config
.map(|config| config.mac_name_cache_path.clone())
.unwrap_or_else(|| "/tmp/wakey_mac_names.json".into()),
observation_store_path: base_config
.map(|config| config.observation_store_path.clone())
.unwrap_or_else(|| "/tmp/wakey_observations.json".into()),
};
let backup_path = save_config_with_backup(config_path, &config)?; let backup_path = save_config_with_backup(config_path, &config)?;
info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written"); info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written");
Ok(EnrollOutcome { Ok(EnrollOutcome {
@@ -166,6 +146,7 @@ mod tests {
reconnect_base_ms: 2_000, reconnect_base_ms: 2_000,
reconnect_max_ms: 60_000, reconnect_max_ms: 60_000,
observation_sync_interval_seconds: 30, observation_sync_interval_seconds: 30,
observation_retention_days: 11,
pid_file: "/tmp/custom-wakey-agent.pid".into(), pid_file: "/tmp/custom-wakey-agent.pid".into(),
dhcp_leases_path: "/tmp/custom-dhcp.leases".into(), dhcp_leases_path: "/tmp/custom-dhcp.leases".into(),
mac_name_cache_path: "/tmp/custom-names.json".into(), mac_name_cache_path: "/tmp/custom-names.json".into(),
@@ -180,6 +161,7 @@ mod tests {
assert_eq!(config.agent_id, "agent-123"); assert_eq!(config.agent_id, "agent-123");
assert_eq!(config.agent_token, "token-xyz"); assert_eq!(config.agent_token, "token-xyz");
assert_eq!(config.server_url, "https://control.example.com"); assert_eq!(config.server_url, "https://control.example.com");
assert_eq!(config.observation_retention_days, 11);
assert_eq!(config.pid_file, base_config.pid_file); assert_eq!(config.pid_file, base_config.pid_file);
assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path); assert_eq!(config.dhcp_leases_path, base_config.dhcp_leases_path);
assert!(outcome.backup_path.is_none()); assert!(outcome.backup_path.is_none());
+4 -12
View File
@@ -248,18 +248,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
let mut cfg = if let Some(from_config) = &args.from_config { let mut cfg = if let Some(from_config) = &args.from_config {
config::load_config(from_config)? config::load_config(from_config)?
} else { } else {
config::AgentConfig { (*config::DEFAULT_CONFIG).clone()
server_url: "https://wakey.ldlda.com".to_string(),
agent_id: "REPLACE_ME_AGENT_ID".to_string(),
agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
reconnect_base_ms: 1_000,
reconnect_max_ms: 30_000,
observation_sync_interval_seconds: 60,
pid_file: config::DEFAULT_PID_FILE.into(),
dhcp_leases_path: "/tmp/dhcp.leases".into(),
mac_name_cache_path: "/tmp/wakey_mac_names.json".into(),
observation_store_path: "/tmp/wakey_observations.json".into(),
}
}; };
if let Some(server_url) = args.server_url { if let Some(server_url) = args.server_url {
@@ -271,6 +260,9 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
if let Some(agent_token) = args.agent_token { if let Some(agent_token) = args.agent_token {
cfg.agent_token = agent_token; cfg.agent_token = agent_token;
} }
if let Some(days) = args.observation_retention_days {
cfg.observation_retention_days = days;
}
if let Some(path) = &args.config { if let Some(path) = &args.config {
config::save_config(path, &cfg)?; config::save_config(path, &cfg)?;
+10 -3
View File
@@ -7,7 +7,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{debug, error, info, info_span, warn}; use tracing::{debug, error, info, info_span, warn};
use crate::config::AgentConfig; use crate::config::AgentConfig;
use crate::dispatch::dispatch_command; use crate::dispatch::{dispatch_command, inventory_for_config};
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage}; use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
pub async fn run(config: AgentConfig) -> Result<()> { pub async fn run(config: AgentConfig) -> Result<()> {
@@ -150,8 +150,15 @@ where
S: SinkExt<Message> + Unpin, S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static, <S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{ {
let query = wakey_core::InventoryQueryBuilder::new().build(); let req = crate::protocol::InventoryRequest {
let inventory = wakey::inventory(query) query: None,
name: None,
ips: Vec::new(),
devs: Vec::new(),
nuds: Vec::new(),
macs: Vec::new(),
};
let inventory = inventory_for_config(req, config)
.await .await
.context("failed to run inventory for device snapshot")?; .context("failed to run inventory for device snapshot")?;
let count = inventory.devices.len(); let count = inventory.devices.len();
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-control-plane" name = "wakey-control-plane"
version = "0.3.0" version = "0.3.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
@@ -0,0 +1,22 @@
CREATE TABLE agent_device_endpoints (
agent_id TEXT NOT NULL,
device_key TEXT NOT NULL,
endpoint_key TEXT NOT NULL,
source TEXT NOT NULL,
mac TEXT,
ip TEXT,
hostname TEXT,
interface TEXT,
presence TEXT NOT NULL,
first_seen_unix INTEGER NOT NULL,
last_seen_unix INTEGER NOT NULL,
PRIMARY KEY (agent_id, device_key, endpoint_key),
FOREIGN KEY (agent_id, device_key)
REFERENCES agent_devices(agent_id, device_key) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX agent_device_endpoints_mac_idx
ON agent_device_endpoints(mac);
CREATE INDEX agent_device_endpoints_ip_idx
ON agent_device_endpoints(ip);
@@ -2,11 +2,13 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::net::IpAddr; use std::net::IpAddr;
use macaddr::MacAddr; use macaddr::MacAddr;
use wakey_core::Presence; use wakey_core::{EndpointSource, Presence};
use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary}; use crate::state::{AgentDeviceWithChildren, DeviceIdentifier, KnownDevice, KnownDeviceSummary};
use super::types::{FleetDevice, FleetDeviceAgent, FleetWakeRoute, ListFleetDevicesQuery}; use super::types::{
FleetDevice, FleetDeviceAgent, FleetDeviceEndpoint, FleetWakeRoute, ListFleetDevicesQuery,
};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct FleetBuildContext { pub(crate) struct FleetBuildContext {
@@ -31,6 +33,7 @@ struct FleetAccumulator {
hostnames: BTreeSet<String>, hostnames: BTreeSet<String>,
sources: BTreeSet<String>, sources: BTreeSet<String>,
agents: BTreeMap<String, FleetDeviceAgent>, agents: BTreeMap<String, FleetDeviceAgent>,
endpoints: Vec<FleetDeviceEndpoint>,
first_seen_unix: Option<u64>, first_seen_unix: Option<u64>,
last_seen_unix: Option<u64>, last_seen_unix: Option<u64>,
presence: Presence, presence: Presence,
@@ -49,6 +52,7 @@ impl Default for FleetAccumulator {
hostnames: BTreeSet::new(), hostnames: BTreeSet::new(),
sources: BTreeSet::new(), sources: BTreeSet::new(),
agents: BTreeMap::new(), agents: BTreeMap::new(),
endpoints: Vec::new(),
first_seen_unix: None, first_seen_unix: None,
last_seen_unix: None, last_seen_unix: None,
presence: Presence::Offline, presence: Presence::Offline,
@@ -199,6 +203,7 @@ fn add_agent_device_to_entry(
entry.pinned = summary.pinned; entry.pinned = summary.pinned;
entry.known_device = Some(summary); entry.known_device = Some(summary);
} }
if agent_device.endpoints.is_empty() {
for mac in &agent_device.macs { for mac in &agent_device.macs {
entry.macs.insert(*mac); entry.macs.insert(*mac);
} }
@@ -207,13 +212,24 @@ fn add_agent_device_to_entry(
entry.ips.insert(*ip); entry.ips.insert(*ip);
} }
} }
} else {
for endpoint in &agent_device.endpoints {
if let Some(mac) = endpoint.key.mac {
entry.macs.insert(mac);
}
if endpoint_ip_is_summary_eligible(endpoint.key.source, endpoint.presence)
&& let Some(ip) = endpoint.key.ip
{
entry.ips.insert(ip);
}
}
}
for hostname in &agent_device.hostnames { for hostname in &agent_device.hostnames {
if entry.display_name.is_none() { if entry.display_name.is_none() {
entry.display_name = Some(hostname.clone()); entry.display_name = Some(hostname.clone());
} }
entry.hostnames.insert(hostname.clone()); entry.hostnames.insert(hostname.clone());
} }
entry.sources.insert("device".to_string());
let first_seen = agent_device.device.first_seen(); let first_seen = agent_device.device.first_seen();
let last_seen = agent_device.device.last_seen(); let last_seen = agent_device.device.last_seen();
entry.first_seen_unix = Some( entry.first_seen_unix = Some(
@@ -255,13 +271,62 @@ fn add_agent_device_to_entry(
last_seen_unix: last_seen, last_seen_unix: last_seen,
}); });
if agent_device.endpoints.is_empty() {
entry.sources.insert("device".to_string());
}
for endpoint in &agent_device.endpoints {
let source = endpoint_source_label(endpoint.key.source).to_string();
entry.sources.insert(source.clone());
let endpoint_last_seen = endpoint.last_seen_unix.unwrap_or(last_seen);
let endpoint_first_seen = endpoint.first_seen_unix.or(Some(first_seen));
entry.endpoints.push(FleetDeviceEndpoint {
agent_id: agent_id.clone(),
nickname: status.nickname.clone(),
connected: status.connected,
source: source.clone(),
mac: endpoint.key.mac,
ip: endpoint.key.ip,
hostname: endpoint.hostname.clone(),
interface: endpoint.interface.clone(),
presence: endpoint.presence,
first_seen_unix: endpoint_first_seen,
last_seen_unix: Some(endpoint_last_seen),
});
let rid = route_id(
&agent_id,
endpoint.key.mac.as_ref(),
endpoint.key.ip.as_ref(),
);
let wakeable = status.connected && endpoint.key.mac.is_some();
insert_route(
entry,
FleetWakeRoute {
route_id: rid,
agent_id: agent_id.clone(),
nickname: status.nickname.clone(),
connected: status.connected,
mac: endpoint.key.mac,
ip: endpoint.key.ip,
hostname: endpoint.hostname.clone(),
interface: endpoint.interface.clone(),
source,
presence: endpoint.presence,
last_seen_unix: endpoint_last_seen,
wakeable,
},
);
}
if agent_device.endpoints.is_empty() && !agent_device.macs.is_empty() {
for mac in &agent_device.macs { for mac in &agent_device.macs {
let ip_for_mac = agent_device.ips.first().copied(); let ip_for_mac = agent_device.ips.first().copied();
let hostname_for_mac = agent_device.hostnames.first().cloned(); let hostname_for_mac = agent_device.hostnames.first().cloned();
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref(), "device"); let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref());
let wakeable = status.connected && !device_offline; let wakeable = status.connected;
entry.routes.insert( insert_route(
rid.clone(), entry,
FleetWakeRoute { FleetWakeRoute {
route_id: rid, route_id: rid,
agent_id: agent_id.clone(), agent_id: agent_id.clone(),
@@ -270,20 +335,24 @@ fn add_agent_device_to_entry(
mac: Some(*mac), mac: Some(*mac),
ip: ip_for_mac, ip: ip_for_mac,
hostname: hostname_for_mac, hostname: hostname_for_mac,
interface: None,
source: "device".to_string(), source: "device".to_string(),
presence: device_presence,
last_seen_unix: last_seen, last_seen_unix: last_seen,
wakeable, wakeable,
}, },
); );
} }
}
if agent_device.macs.is_empty() if agent_device.endpoints.is_empty()
&& agent_device.macs.is_empty()
&& let Some(ip) = agent_device.ips.first() && let Some(ip) = agent_device.ips.first()
{ {
let hostname = agent_device.hostnames.first().cloned(); let hostname = agent_device.hostnames.first().cloned();
let rid = route_id(&agent_id, None, Some(ip), "device"); let rid = route_id(&agent_id, None, Some(ip));
entry.routes.insert( insert_route(
rid.clone(), entry,
FleetWakeRoute { FleetWakeRoute {
route_id: rid, route_id: rid,
agent_id: agent_id.clone(), agent_id: agent_id.clone(),
@@ -292,7 +361,9 @@ fn add_agent_device_to_entry(
mac: None, mac: None,
ip: Some(*ip), ip: Some(*ip),
hostname, hostname,
interface: None,
source: "device".to_string(), source: "device".to_string(),
presence: device_presence,
last_seen_unix: last_seen, last_seen_unix: last_seen,
wakeable: false, wakeable: false,
}, },
@@ -321,19 +392,66 @@ fn device_known_device(
context: &FleetBuildContext, context: &FleetBuildContext,
) -> Option<KnownDeviceSummary> { ) -> Option<KnownDeviceSummary> {
agent_device agent_device
.macs .endpoints
.first() .iter()
.filter_map(|endpoint| endpoint.key.mac)
.map(|mac| format!("mac:{}", mac.to_string().to_ascii_lowercase())) .map(|mac| format!("mac:{}", mac.to_string().to_ascii_lowercase()))
.and_then(|key| context.identifier_map.get(&key).cloned()) .find_map(|key| context.identifier_map.get(&key).cloned())
.or_else(|| {
agent_device
.macs
.iter()
.map(|mac| format!("mac:{}", mac.to_string().to_ascii_lowercase()))
.find_map(|key| context.identifier_map.get(&key).cloned())
})
.or_else(|| {
agent_device
.endpoints
.iter()
.filter_map(|endpoint| endpoint.key.ip)
.map(|ip| format!("ip:{ip}"))
.find_map(|key| context.identifier_map.get(&key).cloned())
})
.or_else(|| { .or_else(|| {
agent_device agent_device
.ips .ips
.first() .iter()
.map(|ip| format!("ip:{ip}")) .map(|ip| format!("ip:{ip}"))
.and_then(|key| context.identifier_map.get(&key).cloned()) .find_map(|key| context.identifier_map.get(&key).cloned())
}) })
} }
fn endpoint_ip_is_summary_eligible(source: EndpointSource, presence: Presence) -> bool {
match source {
EndpointSource::DhcpLease => true,
EndpointSource::Neighbor => presence != Presence::Offline,
EndpointSource::HookNeighbor | EndpointSource::HookDhcp => false,
}
}
fn insert_route(entry: &mut FleetAccumulator, candidate: FleetWakeRoute) {
match entry.routes.entry(candidate.route_id.clone()) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert(candidate);
}
std::collections::btree_map::Entry::Occupied(mut slot) => {
if compare_route_quality(&candidate, slot.get()).is_gt() {
slot.insert(candidate);
}
}
}
}
fn compare_route_quality(a: &FleetWakeRoute, b: &FleetWakeRoute) -> std::cmp::Ordering {
a.connected
.cmp(&b.connected)
.then_with(|| a.wakeable.cmp(&b.wakeable))
.then_with(|| a.ip.is_some().cmp(&b.ip.is_some()))
.then_with(|| a.presence.cmp(&b.presence))
.then_with(|| a.last_seen_unix.cmp(&b.last_seen_unix))
.then_with(|| source_quality_rank(&a.source).cmp(&source_quality_rank(&b.source)))
}
pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary { pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
KnownDeviceSummary { KnownDeviceSummary {
device_id: device.device_id.clone(), device_id: device.device_id.clone(),
@@ -345,13 +463,8 @@ pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
impl FleetAccumulator { impl FleetAccumulator {
fn into_response(self) -> FleetDevice { fn into_response(self) -> FleetDevice {
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>(); let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
route_candidates.sort_by(|a, b| { route_candidates
b.connected .sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
.cmp(&a.connected)
.then_with(|| b.wakeable.cmp(&a.wakeable))
.then_with(|| b.last_seen_unix.cmp(&a.last_seen_unix))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
let recommended_route = route_candidates let recommended_route = route_candidates
.iter() .iter()
.find(|route| route.wakeable) .find(|route| route.wakeable)
@@ -373,6 +486,7 @@ impl FleetAccumulator {
hostnames: self.hostnames.into_iter().collect(), hostnames: self.hostnames.into_iter().collect(),
agents: self.agents.into_values().collect(), agents: self.agents.into_values().collect(),
sources: self.sources.into_iter().collect(), sources: self.sources.into_iter().collect(),
endpoints: self.endpoints,
first_seen_unix: self.first_seen_unix, first_seen_unix: self.first_seen_unix,
last_seen_unix: self.last_seen_unix, last_seen_unix: self.last_seen_unix,
presence: self.presence, presence: self.presence,
@@ -382,11 +496,29 @@ impl FleetAccumulator {
} }
} }
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>, source: &str) -> String { fn endpoint_source_label(source: EndpointSource) -> &'static str {
match source {
EndpointSource::Neighbor => "neighbor",
EndpointSource::DhcpLease => "dhcp_lease",
EndpointSource::HookNeighbor => "hook_neighbor",
EndpointSource::HookDhcp => "hook_dhcp",
}
}
fn source_quality_rank(source: &str) -> u8 {
match source {
"neighbor" => 4,
"dhcp_lease" => 3,
"hook_neighbor" => 2,
"hook_dhcp" => 1,
_ => 0,
}
}
fn route_id(agent_id: &str, mac: Option<&MacAddr>, ip: Option<&IpAddr>) -> String {
format!( format!(
"{}|{}|{}|{}", "{}|{}|{}",
agent_id, agent_id,
source,
mac.map(|m| m.to_string()).unwrap_or_default(), mac.map(|m| m.to_string()).unwrap_or_default(),
ip.map(|i| i.to_string()).unwrap_or_default() ip.map(|i| i.to_string()).unwrap_or_default()
) )
@@ -85,12 +85,9 @@ pub async fn refresh_fleet_devices(
}); });
continue; continue;
}; };
match serde_json::from_value::<Vec<wakey_core::Device>>( match serde_json::from_value::<wakey_core::DeviceInventory>(result)
result .map(|i| i.devices)
.get("devices") {
.cloned()
.unwrap_or(serde_json::Value::Array(vec![])),
) {
Ok(devices) => { Ok(devices) => {
match state match state
.store .store
@@ -276,6 +273,7 @@ async fn load_fleet_devices(
let mut devices = build_fleet_devices(known_devices, agent_devices, &context); let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
filter_fleet_devices(&mut devices, query); filter_fleet_devices(&mut devices, query);
let limit = query.limit.unwrap_or(500).clamp(1, 1000); let limit = query.limit.unwrap_or(500).clamp(1, 1000);
// this is going to be 10 million times harder if i decide to do this lazily
devices.truncate(limit); devices.truncate(limit);
Ok(devices) Ok(devices)
} }
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use macaddr::MacAddr; use macaddr::MacAddr;
use wakey_core::Presence; use wakey_core::{DeviceEndpoint, EndpointKey, EndpointSource, Presence};
use super::build::{ use super::build::{
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices, AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
@@ -54,6 +54,7 @@ fn agent_device(
.into_iter() .into_iter()
.collect(), .collect(),
hostnames: vec!["lda".to_string()], hostnames: vec!["lda".to_string()],
endpoints: vec![],
facts: vec![], facts: vec![],
} }
} }
@@ -83,6 +84,7 @@ fn offline_agent_device(
.into_iter() .into_iter()
.collect(), .collect(),
hostnames: vec!["lda".to_string()], hostnames: vec!["lda".to_string()],
endpoints: vec![],
facts: vec![], facts: vec![],
} }
} }
@@ -105,10 +107,42 @@ fn offline_ip_only_unknown(
macs: vec![], macs: vec![],
ips: vec![ip.parse().unwrap()], ips: vec![ip.parse().unwrap()],
hostnames: vec![], hostnames: vec![],
endpoints: vec![],
facts: vec![], facts: vec![],
} }
} }
fn endpoint(
source: EndpointSource,
mac: Option<&str>,
ip: Option<&str>,
last_seen_unix: u64,
) -> DeviceEndpoint {
endpoint_with_presence(source, mac, ip, Presence::LikelyOnline, last_seen_unix)
}
fn endpoint_with_presence(
source: EndpointSource,
mac: Option<&str>,
ip: Option<&str>,
presence: Presence,
last_seen_unix: u64,
) -> DeviceEndpoint {
DeviceEndpoint {
key: EndpointKey::new(
source,
mac.map(|m| m.parse::<MacAddr>().unwrap()),
ip.map(|i| i.parse::<IpAddr>().unwrap()),
)
.expect("endpoint key"),
hostname: Some("lda".into()),
interface: Some("br-lan".into()),
presence,
first_seen_unix: Some(1),
last_seen_unix: Some(last_seen_unix),
}
}
#[test] #[test]
fn fleet_grouping_combines_same_mac_across_agents() { fn fleet_grouping_combines_same_mac_across_agents() {
let devices = build_fleet_devices( let devices = build_fleet_devices(
@@ -145,6 +179,146 @@ fn fleet_grouping_combines_same_mac_across_agents() {
); );
} }
#[test]
fn fleet_routes_use_endpoint_mac_ip_pairs() {
let mac = "aa:bb:cc:dd:ee:ff";
let mut row = agent_device(
"agent-a",
"mac:aa:bb:cc:dd:ee:ff",
Some(mac),
Some("192.168.1.2"),
20,
);
row.endpoints = vec![
endpoint(
EndpointSource::HookNeighbor,
Some(mac),
Some("192.168.1.2"),
10,
),
endpoint(EndpointSource::Neighbor, Some(mac), Some("192.168.1.3"), 20),
];
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].endpoints.len(), 2);
assert_eq!(devices[0].route_candidates.len(), 2);
assert_eq!(
devices[0]
.recommended_route
.as_ref()
.and_then(|route| route.ip),
Some("192.168.1.3".parse().unwrap())
);
}
#[test]
fn fleet_summary_keeps_current_dhcp_ip_when_neighbor_endpoint_is_offline() {
let mac = "aa:bb:cc:dd:ee:ff";
let mut row = offline_agent_device(
"agent-a",
"mac:aa:bb:cc:dd:ee:ff",
Some(mac),
Some("192.168.1.99"),
20,
);
row.ips = vec![
"192.168.1.10".parse().unwrap(),
"192.168.1.99".parse().unwrap(),
];
row.endpoints = vec![
endpoint_with_presence(
EndpointSource::Neighbor,
Some(mac),
Some("192.168.1.99"),
Presence::Offline,
20,
),
endpoint_with_presence(
EndpointSource::DhcpLease,
Some(mac),
Some("192.168.1.10"),
Presence::Unknown,
20,
),
];
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
assert_eq!(devices.len(), 1);
assert_eq!(
devices[0].ips,
vec!["192.168.1.10".parse::<IpAddr>().unwrap()]
);
}
#[test]
fn known_ip_identifier_matches_endpoint_that_is_not_in_summary_ips() {
let known = KnownDevice {
device_id: "dev-1".into(),
display_name: "lda remembered".into(),
pinned: true,
created_at_unix: 1,
updated_at_unix: 1,
notes: None,
identifiers: vec![DeviceIdentifier {
identifier_key: "ip:192.168.1.99".into(),
device_id: "dev-1".into(),
kind: "ip".into(),
value: "192.168.1.99".into(),
created_at_unix: 1,
}],
};
let mut ctx = context(&["agent-a"]);
ctx.identifier_map
.insert("ip:192.168.1.99".into(), known_device_summary(&known));
let mut row = agent_device("agent-a", "ip:192.168.1.99", None, None, 20);
row.endpoints = vec![endpoint_with_presence(
EndpointSource::HookNeighbor,
None,
Some("192.168.1.99"),
Presence::Offline,
20,
)];
let devices = build_fleet_devices(vec![known], vec![row], &ctx);
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].device_key, "known:dev-1");
assert_eq!(devices[0].endpoints.len(), 1);
}
#[test]
fn equivalent_endpoint_routes_collapse_to_best_source() {
let mac = "aa:bb:cc:dd:ee:ff";
let ip = "192.168.1.2";
let mut row = agent_device("agent-a", "mac:aa:bb:cc:dd:ee:ff", Some(mac), Some(ip), 20);
row.endpoints = vec![
endpoint_with_presence(
EndpointSource::HookNeighbor,
Some(mac),
Some(ip),
Presence::LikelyOnline,
20,
),
endpoint_with_presence(
EndpointSource::Neighbor,
Some(mac),
Some(ip),
Presence::Online,
20,
),
];
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
assert_eq!(devices[0].endpoints.len(), 2);
assert_eq!(devices[0].route_candidates.len(), 1);
assert_eq!(devices[0].route_candidates[0].source, "neighbor");
assert_eq!(devices[0].route_candidates[0].presence, Presence::Online);
}
#[test] #[test]
fn known_device_with_two_macs_absorbs_both_device_groups() { fn known_device_with_two_macs_absorbs_both_device_groups() {
let known = KnownDevice { let known = KnownDevice {
@@ -242,7 +416,7 @@ fn offline_device_has_offline_presence() {
assert_eq!(devices.len(), 1); assert_eq!(devices.len(), 1);
assert!(devices[0].ips.is_empty()); assert!(devices[0].ips.is_empty());
assert_eq!(devices[0].presence, Presence::Offline); assert_eq!(devices[0].presence, Presence::Offline);
assert!(devices[0].recommended_route.is_none()); assert!(devices[0].recommended_route.is_some());
} }
#[test] #[test]
@@ -68,6 +68,7 @@ pub struct FleetDevice {
pub hostnames: Vec<String>, pub hostnames: Vec<String>,
pub agents: Vec<FleetDeviceAgent>, pub agents: Vec<FleetDeviceAgent>,
pub sources: Vec<String>, pub sources: Vec<String>,
pub endpoints: Vec<FleetDeviceEndpoint>,
pub first_seen_unix: Option<u64>, pub first_seen_unix: Option<u64>,
pub last_seen_unix: Option<u64>, pub last_seen_unix: Option<u64>,
pub presence: Presence, pub presence: Presence,
@@ -83,6 +84,22 @@ pub struct FleetDeviceAgent {
pub last_seen_unix: u64, pub last_seen_unix: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetDeviceEndpoint {
pub agent_id: String,
pub nickname: Option<String>,
pub connected: bool,
pub source: String,
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
pub ip: Option<IpAddr>,
pub hostname: Option<String>,
pub interface: Option<String>,
pub presence: Presence,
pub first_seen_unix: Option<u64>,
pub last_seen_unix: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetWakeRoute { pub struct FleetWakeRoute {
pub route_id: String, pub route_id: String,
@@ -93,7 +110,9 @@ pub struct FleetWakeRoute {
pub mac: Option<MacAddr>, pub mac: Option<MacAddr>,
pub ip: Option<IpAddr>, pub ip: Option<IpAddr>,
pub hostname: Option<String>, pub hostname: Option<String>,
pub interface: Option<String>,
pub source: String, pub source: String,
pub presence: Presence,
pub last_seen_unix: u64, pub last_seen_unix: u64,
pub wakeable: bool, pub wakeable: bool,
} }
+66 -14
View File
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
use crate::cli::{InitConfigArgs, ServeArgs}; use crate::cli::{InitConfigArgs, ServeArgs};
use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path}; use crate::config::resolve::{load_file_config, normalize_public_url, resolve_path};
use crate::config::types::{WritableConfig, WritableTelemetry}; use crate::config::types::{DEFAULT_CONFIG, WritableConfig, WritableTelemetry};
pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> { pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
if args.stdout && args.config_file.is_some() { if args.stdout && args.config_file.is_some() {
@@ -30,6 +30,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
Default::default() Default::default()
}; };
let base_telemetry = base.telemetry.unwrap_or_default(); let base_telemetry = base.telemetry.unwrap_or_default();
let defaults = &*DEFAULT_CONFIG;
let bind = match args.bind { let bind = match args.bind {
Some(bind) => bind, Some(bind) => bind,
@@ -43,7 +44,8 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
.unwrap_or_else(|| "<defaults>".to_string()) .unwrap_or_else(|| "<defaults>".to_string())
) )
})?, })?,
None => "0.0.0.0:8080" None => defaults
.bind
.parse() .parse()
.expect("static default bind should parse"), .expect("static default bind should parse"),
}, },
@@ -52,37 +54,38 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
args.public_url args.public_url
.as_deref() .as_deref()
.or(base.public_url.as_deref()) .or(base.public_url.as_deref())
.unwrap_or("http://127.0.0.1:8080"), .unwrap_or(&defaults.public_url),
); );
let data_dir = args let data_dir = args
.data_dir .data_dir
.clone() .clone()
.or(base.data_dir) .or(base.data_dir)
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR)); .unwrap_or_else(|| defaults.data_dir.clone());
let state_file_raw = args let state_file_raw = args
.state_file .state_file
.clone() .clone()
.or(base.state_file) .or(base.state_file)
.unwrap_or_else(|| PathBuf::from("state.sqlite3")); .unwrap_or_else(|| defaults.state_file.clone());
let state_file = resolve_path(&data_dir, state_file_raw); let state_file = resolve_path(&data_dir, state_file_raw);
let pid_file_raw = args let pid_file_raw = args
.pid_file .pid_file
.clone() .clone()
.or(base.pid_file) .or(base.pid_file)
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid")); .unwrap_or_else(|| defaults.pid_file.clone());
let pid_file = resolve_path(&data_dir, pid_file_raw); let pid_file = resolve_path(&data_dir, pid_file_raw);
let ui_dist_dir = args let ui_dist_dir = args
.ui_dist_dir .ui_dist_dir
.clone() .clone()
.or(base.ui_dist_dir) .or(base.ui_dist_dir)
.unwrap_or_else(|| PathBuf::from("ui/dist")); .unwrap_or_else(|| defaults.ui_dist_dir.clone());
let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() { let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() {
base.bootstrap_enroll_tokens.unwrap_or_default() base.bootstrap_enroll_tokens
.unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone())
} else { } else {
args.bootstrap_enroll_tokens.clone() args.bootstrap_enroll_tokens.clone()
}; };
@@ -95,17 +98,17 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
command_timeout_ms: args command_timeout_ms: args
.command_timeout_ms .command_timeout_ms
.or(base.command_timeout_ms) .or(base.command_timeout_ms)
.unwrap_or(30_000) .unwrap_or(defaults.command_timeout_ms)
.max(1), .max(1),
enroll_token_ttl_seconds: args enroll_token_ttl_seconds: args
.enroll_token_ttl_seconds .enroll_token_ttl_seconds
.or(base.enroll_token_ttl_seconds) .or(base.enroll_token_ttl_seconds)
.unwrap_or(86_400) .unwrap_or(defaults.enroll_token_ttl_seconds)
.max(1), .max(1),
observation_retention_seconds: args observation_retention_seconds: args
.observation_retention_seconds .observation_retention_seconds
.or(base.observation_retention_seconds) .or(base.observation_retention_seconds)
.unwrap_or(2_592_000), .unwrap_or(defaults.observation_retention_seconds),
pid_file, pid_file,
ui_dist_dir, ui_dist_dir,
bootstrap_enroll_tokens, bootstrap_enroll_tokens,
@@ -113,16 +116,17 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
otlp_endpoint: args otlp_endpoint: args
.telemetry_otlp_endpoint .telemetry_otlp_endpoint
.clone() .clone()
.or(base_telemetry.otlp_endpoint), .or(base_telemetry.otlp_endpoint)
.or_else(|| defaults.telemetry.otlp_endpoint.clone()),
service_name: args service_name: args
.telemetry_service_name .telemetry_service_name
.clone() .clone()
.or(base_telemetry.service_name) .or(base_telemetry.service_name)
.unwrap_or_else(|| "wakey-control-plane".to_string()), .unwrap_or_else(|| defaults.telemetry.service_name.clone()),
json_logs: args json_logs: args
.telemetry_json_logs .telemetry_json_logs
.or(base_telemetry.json_logs) .or(base_telemetry.json_logs)
.unwrap_or(false), .unwrap_or(defaults.telemetry.json_logs),
}, },
}; };
@@ -176,6 +180,7 @@ pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
mod tests { mod tests {
use super::write_init_config; use super::write_init_config;
use crate::cli::InitConfigArgs; use crate::cli::InitConfigArgs;
use crate::config::types::DEFAULT_CONFIG;
fn temp_test_dir(name: &str) -> std::path::PathBuf { fn temp_test_dir(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!( std::env::temp_dir().join(format!(
@@ -251,4 +256,51 @@ json_logs = true
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn init_config_renders_shared_default_template() {
let dir = temp_test_dir("defaults");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
let out = dir.join("out.toml");
write_init_config(&InitConfigArgs {
config_file: Some(out.clone()),
from_config: None,
stdout: false,
data_dir: None,
bind: None,
public_url: None,
state_file: None,
pid_file: None,
ui_dist_dir: None,
command_timeout_ms: None,
enroll_token_ttl_seconds: None,
observation_retention_seconds: None,
bootstrap_enroll_tokens: Vec::new(),
telemetry_otlp_endpoint: None,
telemetry_service_name: None,
telemetry_json_logs: None,
force: false,
})
.expect("write default config");
let rendered = std::fs::read_to_string(&out).expect("read default config");
let parsed: toml::Value = toml::from_str(&rendered).expect("parse default config");
let defaults = &*DEFAULT_CONFIG;
assert_eq!(
parsed["state_file"].as_str(),
defaults.data_dir.join(&defaults.state_file).to_str()
);
assert_eq!(
parsed["command_timeout_ms"].as_integer(),
Some(defaults.command_timeout_ms as i64)
);
assert_eq!(
parsed["telemetry"]["service_name"].as_str(),
Some(defaults.telemetry.service_name.as_str())
);
let _ = std::fs::remove_dir_all(&dir);
}
} }
+62 -16
View File
@@ -9,19 +9,20 @@ use crate::cli::{
RevokeEnrollTokenArgs, ServeArgs, StateStatsArgs, RevokeEnrollTokenArgs, ServeArgs, StateStatsArgs,
}; };
use crate::config::types::{ use crate::config::types::{
DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings, StateAccessSettings, DEFAULT_CONFIG, DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings,
TelemetryConfig, StateAccessSettings, TelemetryConfig,
}; };
impl DaemonConfig { impl DaemonConfig {
pub fn from_serve_args(args: &ServeArgs) -> Result<Self> { pub fn from_serve_args(args: &ServeArgs) -> Result<Self> {
let file = load_file_config(&args.config_file)?; let file = load_file_config(&args.config_file)?;
let defaults = &*DEFAULT_CONFIG;
let data_dir = args let data_dir = args
.data_dir .data_dir
.clone() .clone()
.or(file.data_dir.clone()) .or(file.data_dir.clone())
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR)); .unwrap_or_else(|| defaults.data_dir.clone());
let bind = match args.bind { let bind = match args.bind {
Some(bind) => bind, Some(bind) => bind,
@@ -32,7 +33,8 @@ impl DaemonConfig {
args.config_file.display() args.config_file.display()
) )
})?, })?,
None => "0.0.0.0:8080" None => defaults
.bind
.parse() .parse()
.expect("static default bind should parse"), .expect("static default bind should parse"),
}, },
@@ -42,34 +44,34 @@ impl DaemonConfig {
args.public_url args.public_url
.as_deref() .as_deref()
.or(file.public_url.as_deref()) .or(file.public_url.as_deref())
.unwrap_or("http://127.0.0.1:8080"), .unwrap_or(&defaults.public_url),
); );
let state_file_raw = args let state_file_raw = args
.state_file .state_file
.clone() .clone()
.or(file.state_file) .or(file.state_file)
.unwrap_or_else(|| PathBuf::from("state.sqlite3")); .unwrap_or_else(|| defaults.state_file.clone());
let state_file = resolve_path(&data_dir, state_file_raw); let state_file = resolve_path(&data_dir, state_file_raw);
let command_timeout = Duration::from_millis( let command_timeout = Duration::from_millis(
args.command_timeout_ms args.command_timeout_ms
.or(file.command_timeout_ms) .or(file.command_timeout_ms)
.unwrap_or(30_000) .unwrap_or(defaults.command_timeout_ms)
.max(1), .max(1),
); );
let enroll_token_ttl = Duration::from_secs( let enroll_token_ttl = Duration::from_secs(
args.enroll_token_ttl_seconds args.enroll_token_ttl_seconds
.or(file.enroll_token_ttl_seconds) .or(file.enroll_token_ttl_seconds)
.unwrap_or(86_400) .unwrap_or(defaults.enroll_token_ttl_seconds)
.max(1), .max(1),
); );
let observation_retention = Duration::from_secs( let observation_retention = Duration::from_secs(
args.observation_retention_seconds args.observation_retention_seconds
.or(file.observation_retention_seconds) .or(file.observation_retention_seconds)
.unwrap_or(2_592_000) .unwrap_or(defaults.observation_retention_seconds)
.max(1), .max(1),
); );
@@ -77,17 +79,18 @@ impl DaemonConfig {
.pid_file .pid_file
.clone() .clone()
.or(file.pid_file) .or(file.pid_file)
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid")); .unwrap_or_else(|| defaults.pid_file.clone());
let pid_file = resolve_path(&data_dir, pid_file_raw); let pid_file = resolve_path(&data_dir, pid_file_raw);
let ui_dist_dir = args let ui_dist_dir = args
.ui_dist_dir .ui_dist_dir
.clone() .clone()
.or(file.ui_dist_dir) .or(file.ui_dist_dir)
.unwrap_or_else(|| PathBuf::from("ui/dist")); .unwrap_or_else(|| defaults.ui_dist_dir.clone());
let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() { let bootstrap_enroll_tokens = if args.bootstrap_enroll_tokens.is_empty() {
file.bootstrap_enroll_tokens.unwrap_or_default() file.bootstrap_enroll_tokens
.unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone())
} else { } else {
args.bootstrap_enroll_tokens.clone() args.bootstrap_enroll_tokens.clone()
}; };
@@ -131,6 +134,7 @@ pub fn resolve_path(data_dir: &Path, candidate: PathBuf) -> PathBuf {
pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<IssueTokenSettings> { pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<IssueTokenSettings> {
let file = load_file_config(&args.config_file)?; let file = load_file_config(&args.config_file)?;
let defaults = &*DEFAULT_CONFIG;
let state = resolve_state_access( let state = resolve_state_access(
&args.config_file, &args.config_file,
args.data_dir.clone(), args.data_dir.clone(),
@@ -142,7 +146,7 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
let ttl = Duration::from_secs( let ttl = Duration::from_secs(
args.ttl_seconds args.ttl_seconds
.or(file.enroll_token_ttl_seconds) .or(file.enroll_token_ttl_seconds)
.unwrap_or(86_400) .unwrap_or(defaults.enroll_token_ttl_seconds)
.max(1), .max(1),
); );
@@ -254,16 +258,17 @@ fn resolve_state_access(
mode: &AdminTargetArgs, mode: &AdminTargetArgs,
) -> Result<StateAccessSettings> { ) -> Result<StateAccessSettings> {
let file = load_file_config(config_file)?; let file = load_file_config(config_file)?;
let defaults = &*DEFAULT_CONFIG;
let data_dir = cli_data_dir let data_dir = cli_data_dir
.or(file.data_dir) .or(file.data_dir)
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR)); .unwrap_or_else(|| defaults.data_dir.clone());
let state_file = resolve_path( let state_file = resolve_path(
&data_dir, &data_dir,
cli_state_file cli_state_file
.or(file.state_file) .or(file.state_file)
.unwrap_or_else(|| PathBuf::from("state.sqlite3")), .unwrap_or_else(|| defaults.state_file.clone()),
); );
let resolved_public_url = cli_public_url let resolved_public_url = cli_public_url
@@ -280,7 +285,9 @@ fn resolve_state_access(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{normalize_public_url, resolve_path}; use super::{DaemonConfig, normalize_public_url, resolve_path};
use crate::cli::ServeArgs;
use crate::config::types::DEFAULT_CONFIG;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
@@ -303,4 +310,43 @@ mod tests {
PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3") PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3")
); );
} }
#[test]
fn daemon_config_uses_shared_default_template() {
let config_file = std::env::temp_dir().join(format!(
"wakey-cc-missing-config-{}.toml",
std::process::id()
));
let _ = std::fs::remove_file(&config_file);
let resolved = DaemonConfig::from_serve_args(&ServeArgs {
data_dir: None,
bind: None,
public_url: None,
state_file: None,
bootstrap_enroll_tokens: Vec::new(),
command_timeout_ms: None,
enroll_token_ttl_seconds: None,
observation_retention_seconds: None,
pid_file: None,
ui_dist_dir: None,
config_file,
bootstrap_config: false,
})
.expect("resolve default config");
let defaults = &*DEFAULT_CONFIG;
assert_eq!(resolved.data_dir, defaults.data_dir);
assert_eq!(
resolved.state_file,
defaults.data_dir.join(&defaults.state_file)
);
assert_eq!(
resolved.command_timeout,
std::time::Duration::from_millis(defaults.command_timeout_ms)
);
assert_eq!(
resolved.telemetry.service_name,
defaults.telemetry.service_name
);
}
} }
+25 -5
View File
@@ -1,5 +1,6 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -29,10 +30,11 @@ pub struct TelemetryConfig {
impl Default for TelemetryConfig { impl Default for TelemetryConfig {
fn default() -> Self { fn default() -> Self {
let defaults = &DEFAULT_CONFIG.telemetry;
Self { Self {
otlp_endpoint: None, otlp_endpoint: defaults.otlp_endpoint.clone(),
service_name: "wakey-control-plane".to_string(), service_name: defaults.service_name.clone(),
json_logs: false, json_logs: defaults.json_logs,
} }
} }
} }
@@ -60,7 +62,7 @@ pub(crate) struct FileTelemetryConfig {
pub(crate) json_logs: Option<bool>, pub(crate) json_logs: Option<bool>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct WritableConfig { pub(crate) struct WritableConfig {
pub(crate) data_dir: PathBuf, pub(crate) data_dir: PathBuf,
pub(crate) bind: String, pub(crate) bind: String,
@@ -76,7 +78,7 @@ pub(crate) struct WritableConfig {
pub(crate) telemetry: WritableTelemetry, pub(crate) telemetry: WritableTelemetry,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct WritableTelemetry { pub(crate) struct WritableTelemetry {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub(crate) otlp_endpoint: Option<String>, pub(crate) otlp_endpoint: Option<String>,
@@ -84,6 +86,24 @@ pub(crate) struct WritableTelemetry {
pub(crate) json_logs: bool, pub(crate) json_logs: bool,
} }
pub(crate) static DEFAULT_CONFIG: LazyLock<WritableConfig> = LazyLock::new(|| WritableConfig {
data_dir: crate::cli::DEFAULT_DATA_DIR.into(),
bind: "0.0.0.0:8080".to_string(),
public_url: "http://127.0.0.1:8080".to_string(),
state_file: "state.sqlite3".into(),
command_timeout_ms: 30_000,
enroll_token_ttl_seconds: 86_400,
observation_retention_seconds: 2_592_000,
pid_file: "wakey-control-plane.pid".into(),
ui_dist_dir: "ui/dist".into(),
bootstrap_enroll_tokens: Vec::new(),
telemetry: WritableTelemetry {
otlp_endpoint: None,
service_name: "wakey-control-plane".to_string(),
json_logs: false,
},
});
pub struct IssueTokenSettings { pub struct IssueTokenSettings {
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub state_file: PathBuf, pub state_file: PathBuf,
+13 -2
View File
@@ -17,10 +17,21 @@ pub struct Store {
db_path: PathBuf, db_path: PathBuf,
pool: SqlitePool, pool: SqlitePool,
} }
impl Store {
/// Begins a new transaction with an IMMEDIATE write lock.
///
/// Use this for all transactions that perform writes to avoid SQLite upgrade deadlocks.
pub async fn begin_write(&self) -> Result<Transaction<'static, Sqlite>> {
self.pool
.begin_with("BEGIN IMMEDIATE")
.await
.context("failed to begin write transaction")
}
}
const SCHEMA_VERSION_KEY: &str = "schema_version"; pub(crate) const SCHEMA_VERSION_KEY: &str = "schema_version";
const SEEDED_ENROLL_TOKEN_PREFIX: &str = "seeded_enroll_token:"; const SEEDED_ENROLL_TOKEN_PREFIX: &str = "seeded_enroll_token:";
const SCHEMA_VERSION: u32 = 2; pub(crate) const SCHEMA_VERSION: u32 = 2;
pub(crate) mod agent_devices; pub(crate) mod agent_devices;
mod alerts; mod alerts;
@@ -1,5 +1,5 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use wakey_core::{Device, DeviceId}; use wakey_core::{Device, DeviceEndpoint, DeviceId, EndpointSource};
use super::Store; use super::Store;
use super::helpers::core::*; use super::helpers::core::*;
@@ -15,11 +15,7 @@ impl Store {
agent_id: &str, agent_id: &str,
devices: &[Device], devices: &[Device],
) -> Result<usize> { ) -> Result<usize> {
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting device snapshot transaction")?;
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len()); let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
let snapshot_time = now_unix(); let snapshot_time = now_unix();
@@ -112,6 +108,48 @@ impl Store {
.context("failed inserting device ips")?; .context("failed inserting device ips")?;
} }
sqlx::query(
"DELETE FROM agent_device_endpoints WHERE agent_id = ?1 AND device_key = ?2",
)
.bind(agent_id)
.bind(&device_key)
.execute(&mut *tx)
.await
.context("failed deleting device endpoints")?;
let endpoints = endpoints_for_storage(device);
if !endpoints.is_empty() {
let mut builder = sqlx::QueryBuilder::new(
"INSERT INTO agent_device_endpoints \
(agent_id, device_key, endpoint_key, source, mac, ip, hostname, interface, presence, first_seen_unix, last_seen_unix) ",
);
builder.push_values(endpoints, |mut b, endpoint| {
let first_seen = endpoint.first_seen_unix.unwrap_or(snapshot_time);
let last_seen = endpoint.last_seen_unix.unwrap_or(snapshot_time);
b.push_bind(agent_id)
.push_bind(device_key.clone())
.push_bind(endpoint_storage_key(&endpoint))
.push_bind(endpoint_source_to_str(endpoint.key.source))
.push_bind(
endpoint
.key
.mac
.map(|mac| mac.to_string().to_ascii_lowercase()),
)
.push_bind(endpoint.key.ip.map(|ip| ip.to_string()))
.push_bind(endpoint.hostname.clone())
.push_bind(endpoint.interface.clone())
.push_bind(presence_to_str(endpoint.presence))
.push_bind(i64::try_from(first_seen).unwrap_or(i64::MAX))
.push_bind(i64::try_from(last_seen).unwrap_or(i64::MAX));
});
builder
.build()
.execute(&mut *tx)
.await
.context("failed inserting device endpoints")?;
}
sqlx::query!( sqlx::query!(
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2", "DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
agent_id, agent_id,
@@ -227,6 +265,15 @@ impl Store {
.await .await
.context("failed listing agent device hostnames")?; .context("failed listing agent device hostnames")?;
let endpoints = sqlx::query_as::<_, AgentDeviceEndpointRow>(
r#"SELECT agent_id, device_key, endpoint_key, source, mac, ip,
hostname, interface, presence, first_seen_unix, last_seen_unix
FROM agent_device_endpoints"#,
)
.fetch_all(&self.pool)
.await
.context("failed listing agent device endpoints")?;
let facts = sqlx::query_as!( let facts = sqlx::query_as!(
AgentDeviceFactRow, AgentDeviceFactRow,
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!" r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
@@ -236,7 +283,9 @@ impl Store {
.await .await
.context("failed listing agent device facts")?; .context("failed listing agent device facts")?;
Ok(assemble_device_rows(devices, macs, ips, hostnames, facts)) Ok(assemble_device_rows(
devices, macs, ips, hostnames, endpoints, facts,
))
} }
/// List agent device rows for a single agent. /// List agent device rows for a single agent.
@@ -291,6 +340,17 @@ impl Store {
.await .await
.context("failed listing agent device hostnames for agent")?; .context("failed listing agent device hostnames for agent")?;
let endpoints = sqlx::query_as::<_, AgentDeviceEndpointRow>(
r#"SELECT agent_id, device_key, endpoint_key, source, mac, ip,
hostname, interface, presence, first_seen_unix, last_seen_unix
FROM agent_device_endpoints
WHERE agent_id = ?1"#,
)
.bind(agent_id)
.fetch_all(&self.pool)
.await
.context("failed listing agent device endpoints for agent")?;
let facts = sqlx::query_as!( let facts = sqlx::query_as!(
AgentDeviceFactRow, AgentDeviceFactRow,
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!" r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
@@ -302,7 +362,9 @@ impl Store {
.await .await
.context("failed listing agent device facts for agent")?; .context("failed listing agent device facts for agent")?;
Ok(assemble_device_rows(devices, macs, ips, hostnames, facts)) Ok(assemble_device_rows(
devices, macs, ips, hostnames, endpoints, facts,
))
} }
} }
@@ -311,6 +373,7 @@ fn assemble_device_rows(
macs: Vec<AgentDeviceMacRow>, macs: Vec<AgentDeviceMacRow>,
ips: Vec<AgentDeviceIpRow>, ips: Vec<AgentDeviceIpRow>,
hostnames: Vec<AgentDeviceHostnameRow>, hostnames: Vec<AgentDeviceHostnameRow>,
endpoints: Vec<AgentDeviceEndpointRow>,
facts: Vec<AgentDeviceFactRow>, facts: Vec<AgentDeviceFactRow>,
) -> Vec<AgentDeviceWithChildren> { ) -> Vec<AgentDeviceWithChildren> {
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -336,6 +399,15 @@ fn assemble_device_rows(
.or_default() .or_default()
.push(row.hostname.clone()); .push(row.hostname.clone());
} }
let mut endpoint_map: BTreeMap<(&str, &str), Vec<DeviceEndpoint>> = BTreeMap::new();
for row in &endpoints {
if let Some(endpoint) = row.to_endpoint() {
endpoint_map
.entry((row.agent_id.as_str(), row.device_key.as_str()))
.or_default()
.push(endpoint);
}
}
let mut fact_map: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new(); let mut fact_map: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
for row in &facts { for row in &facts {
fact_map fact_map
@@ -373,11 +445,13 @@ fn assemble_device_rows(
}) })
.collect(); .collect();
let hostnames = hostname_map.get(&key).cloned().unwrap_or_default(); let hostnames = hostname_map.get(&key).cloned().unwrap_or_default();
let endpoints = endpoint_map.get(&key).cloned().unwrap_or_default();
let facts = fact_map.get(&key).cloned().unwrap_or_default(); let facts = fact_map.get(&key).cloned().unwrap_or_default();
AgentDeviceWithChildren { AgentDeviceWithChildren {
macs, macs,
ips, ips,
hostnames, hostnames,
endpoints,
facts, facts,
device, device,
} }
@@ -401,6 +475,65 @@ fn presence_to_str(presence: wakey_core::Presence) -> &'static str {
} }
} }
fn endpoint_source_to_str(source: EndpointSource) -> &'static str {
match source {
EndpointSource::Neighbor => "neighbor",
EndpointSource::DhcpLease => "dhcp_lease",
EndpointSource::HookNeighbor => "hook_neighbor",
EndpointSource::HookDhcp => "hook_dhcp",
}
}
fn endpoint_storage_key(endpoint: &DeviceEndpoint) -> String {
format!(
"{}|{}|{}",
endpoint_source_to_str(endpoint.key.source),
endpoint
.key
.mac
.map(|mac| mac.to_string().to_ascii_lowercase())
.unwrap_or_default(),
endpoint.key.ip.map(|ip| ip.to_string()).unwrap_or_default()
)
}
fn endpoints_for_storage(device: &Device) -> Vec<DeviceEndpoint> {
if !device.endpoints.is_empty() {
return device.endpoints.clone();
}
let mut endpoints = Vec::new();
for neighbor in &device.neighbors {
endpoints.push(DeviceEndpoint {
key: wakey_core::EndpointKey {
source: EndpointSource::Neighbor,
mac: neighbor.mac,
ip: Some(neighbor.ip),
},
hostname: None,
interface: neighbor.dev.clone(),
presence: wakey_core::Presence::from(neighbor.state),
first_seen_unix: None,
last_seen_unix: None,
});
}
for lease in &device.leases {
endpoints.push(DeviceEndpoint {
key: wakey_core::EndpointKey {
source: EndpointSource::DhcpLease,
mac: Some(lease.mac),
ip: Some(lease.ip),
},
hostname: lease.name.clone(),
interface: None,
presence: wakey_core::Presence::Unknown,
first_seen_unix: None,
last_seen_unix: None,
});
}
endpoints
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::helpers::test_helpers::TestStore; use super::super::helpers::test_helpers::TestStore;
@@ -416,6 +549,7 @@ mod tests {
ips: vec![ip.parse().expect("ip")], ips: vec![ip.parse().expect("ip")],
macs: vec![mac.parse().expect("mac")], macs: vec![mac.parse().expect("mac")],
interfaces: vec!["br-lan".to_string()], interfaces: vec!["br-lan".to_string()],
endpoints: vec![],
neighbors: vec![NeighborEntry { neighbors: vec![NeighborEntry {
ip: ip.parse().expect("ip"), ip: ip.parse().expect("ip"),
dev: Some("br-lan".to_string()), dev: Some("br-lan".to_string()),
@@ -461,6 +595,15 @@ mod tests {
vec!["192.168.1.10".parse::<std::net::IpAddr>().unwrap()] vec!["192.168.1.10".parse::<std::net::IpAddr>().unwrap()]
); );
assert_eq!(rows[0].hostnames, vec!["first-pc"]); assert_eq!(rows[0].hostnames, vec!["first-pc"]);
assert_eq!(rows[0].endpoints.len(), 1);
assert_eq!(
rows[0].endpoints[0].key.source,
wakey_core::EndpointSource::Neighbor
);
assert_eq!(
rows[0].endpoints[0].key.ip,
Some("192.168.1.10".parse().expect("ip"))
);
} }
#[tokio::test] #[tokio::test]
@@ -586,6 +729,7 @@ mod tests {
ips: vec![], ips: vec![],
macs: vec![], macs: vec![],
interfaces: vec![], interfaces: vec![],
endpoints: vec![],
neighbors: vec![], neighbors: vec![],
leases: vec![], leases: vec![],
observations: vec![], observations: vec![],
@@ -616,6 +760,7 @@ mod tests {
ips: vec!["192.168.1.10".parse().expect("ip")], ips: vec!["192.168.1.10".parse().expect("ip")],
macs: vec!["aa:bb:cc:dd:ee:01".parse().expect("mac")], macs: vec!["aa:bb:cc:dd:ee:01".parse().expect("mac")],
interfaces: vec![], interfaces: vec![],
endpoints: vec![],
neighbors: vec![], neighbors: vec![],
leases: vec![], leases: vec![],
observations: vec![DeviceObservationFact { observations: vec![DeviceObservationFact {
@@ -1,3 +1,4 @@
use super::helpers::core::*;
use super::*; use super::*;
impl Store { impl Store {
@@ -5,6 +6,8 @@ impl Store {
&self, &self,
current: &[AlertState], current: &[AlertState],
) -> Result<Vec<AlertTransition>> { ) -> Result<Vec<AlertTransition>> {
let mut tx = self.begin_write().await?;
let mut previous = std::collections::HashMap::<String, AlertState>::new(); let mut previous = std::collections::HashMap::<String, AlertState>::new();
let rows = sqlx::query_as!( let rows = sqlx::query_as!(
AlertStateRow, AlertStateRow,
@@ -13,7 +16,7 @@ impl Store {
value, threshold, last_seen_unix, metadata_json as "metadata_json!" value, threshold, last_seen_unix, metadata_json as "metadata_json!"
FROM active_alerts"#, FROM active_alerts"#,
) )
.fetch_all(&self.pool) .fetch_all(&mut *tx)
.await .await
.context("failed iterating active_alerts table")?; .context("failed iterating active_alerts table")?;
for row in rows { for row in rows {
@@ -63,11 +66,6 @@ impl Store {
}); });
} }
let mut tx = self
.pool
.begin()
.await
.context("failed starting alert transaction")?;
sqlx::query!("DELETE FROM active_alerts") sqlx::query!("DELETE FROM active_alerts")
.execute(&mut *tx) .execute(&mut *tx)
.await .await
+1 -5
View File
@@ -115,11 +115,7 @@ impl Store {
} }
let expires_at = now_unix().saturating_add(seed_ttl.as_secs().max(1)); let expires_at = now_unix().saturating_add(seed_ttl.as_secs().max(1));
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting bootstrap token transaction")?;
let expires_at_i64 = i64::try_from(expires_at).context("token expiry overflow")?; let expires_at_i64 = i64::try_from(expires_at).context("token expiry overflow")?;
sqlx::query!( sqlx::query!(
"INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)", "INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
+4 -20
View File
@@ -11,11 +11,7 @@ impl Store {
let device_id = format!("dev-{}", Uuid::new_v4()); let device_id = format!("dev-{}", Uuid::new_v4());
let now = now_unix(); let now = now_unix();
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting known device transaction")?;
let pinned = if input.pinned { 1_i64 } else { 0_i64 }; let pinned = if input.pinned { 1_i64 } else { 0_i64 };
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?; let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
sqlx::query!( sqlx::query!(
@@ -84,11 +80,7 @@ impl Store {
let now = now_unix(); let now = now_unix();
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?; let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting known device merge transaction")?;
let target_exists = sqlx::query_scalar!( let target_exists = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#, r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
@@ -149,11 +141,7 @@ impl Store {
) -> Result<Option<KnownDevice>> { ) -> Result<Option<KnownDevice>> {
let identifier = normalize_device_identifier(input)?; let identifier = normalize_device_identifier(input)?;
let now = now_unix(); let now = now_unix();
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting device identifier transaction")?;
let exists = sqlx::query_scalar!( let exists = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#, r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
device_id device_id
@@ -188,11 +176,7 @@ impl Store {
) -> Result<Option<KnownDevice>> { ) -> Result<Option<KnownDevice>> {
let now = now_unix(); let now = now_unix();
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?; let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting device identifier detach transaction")?;
let exists = sqlx::query_scalar!( let exists = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#, r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
device_id device_id
@@ -2,11 +2,7 @@ use super::*;
impl Store { impl Store {
pub async fn enroll(&self, enroll_token: &str) -> Result<IssuedAgent> { pub async fn enroll(&self, enroll_token: &str) -> Result<IssuedAgent> {
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting enroll transaction")?;
let expires_at_unix = sqlx::query_scalar!( let expires_at_unix = sqlx::query_scalar!(
"SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1", "SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1",
enroll_token enroll_token
@@ -102,11 +98,7 @@ impl Store {
} }
pub async fn revoke_agent(&self, agent_id: &str) -> Result<bool> { pub async fn revoke_agent(&self, agent_id: &str) -> Result<bool> {
let mut tx = self let mut tx = self.begin_write().await?;
.pool
.begin()
.await
.context("failed starting revoke transaction")?;
let result = sqlx::query!("DELETE FROM agents WHERE agent_id = ?1", agent_id) let result = sqlx::query!("DELETE FROM agents WHERE agent_id = ?1", agent_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
@@ -1,4 +1,5 @@
use super::*; use super::*;
use tracing::{debug, info, warn};
impl Store { impl Store {
pub async fn migrate_sqlite_state( pub async fn migrate_sqlite_state(
@@ -12,71 +13,53 @@ impl Store {
from_sqlite_state.display() from_sqlite_state.display()
); );
} }
if to_state_file.is_dir() {
// 1. Safety check for destination
if to_state_file.exists() && !force {
anyhow::bail!( anyhow::bail!(
"target state file {} is a directory", "target state file {} already exists; use --force to overwrite",
to_state_file.display() to_state_file.display()
); );
} }
let is_same_file = match (
std::fs::canonicalize(from_sqlite_state),
std::fs::canonicalize(to_state_file),
) {
(Ok(from_canon), Ok(to_canon)) => from_canon == to_canon,
_ => from_sqlite_state == to_state_file,
};
let actual_from_path = if is_same_file { // 2. Setup temporary migration target
let mut bak = to_state_file.to_path_buf(); let mut temp_target = to_state_file.to_path_buf();
let mut file_name = bak.file_name().unwrap_or_default().to_os_string(); let mut temp_name = temp_target.file_name().unwrap_or_default().to_os_string();
file_name.push(".bak"); temp_name.push(".migration_tmp");
bak.set_file_name(file_name); temp_target.set_file_name(temp_name);
if bak.exists() { if temp_target.exists() {
if !force { std::fs::remove_file(&temp_target)
anyhow::bail!( .context("failed to clean up stale migration temp file")?;
"backup file {} already exists; re-run with --force to overwrite",
bak.display()
);
}
std::fs::remove_file(&bak).with_context(|| {
format!("failed to remove existing backup {}", bak.display())
})?;
} }
std::fs::rename(from_sqlite_state, &bak) // 3. Initialize fresh schema on the temp file
.with_context(|| "failed to rename legacy state for in-place migration")?; let store = Store::load_or_init(&temp_target, Vec::new(), Duration::from_secs(1)).await?;
bak
} else {
if to_state_file.exists()
&& to_state_file
.metadata()
.with_context(|| format!("failed to stat {}", to_state_file.display()))?
.len()
> 0
{
if !force {
anyhow::bail!(
"target SQLite state file {} already exists and is non-empty; re-run with --force to overwrite",
to_state_file.display()
);
}
std::fs::remove_file(to_state_file)
.with_context(|| format!("failed to remove {}", to_state_file.display()))?;
}
from_sqlite_state.to_path_buf()
};
let store = Store::load_or_init(to_state_file, Vec::new(), Duration::from_secs(1)).await?; // 4. Use a single connection for the entire migration
// ATTACH is connection-scoped, so we can't use the pool directly.
let mut conn = store
.pool
.acquire()
.await
.context("failed to acquire migration connection")?;
let from_path_str = actual_from_path let from_path_str = from_sqlite_state
.to_str() .to_str()
.ok_or_else(|| anyhow::anyhow!("invalid path"))?; .ok_or_else(|| anyhow::anyhow!("invalid legacy path"))?;
sqlx::query(&format!("ATTACH DATABASE '{}' AS legacy", from_path_str)) sqlx::query(&format!("ATTACH DATABASE '{}' AS legacy", from_path_str))
.execute(&store.pool) .execute(&mut *conn)
.await .await
.with_context(|| "failed to attach legacy database")?; .with_context(|| format!("failed to attach legacy database at {}", from_path_str))?;
let legacy_tables: Vec<String> =
sqlx::query_scalar("SELECT name FROM legacy.sqlite_master WHERE type='table'")
.fetch_all(&mut *conn)
.await
.context("failed to list tables in legacy database")?;
debug!(?legacy_tables, "found tables in legacy database");
let tables = [ let tables = [
"meta", "meta",
@@ -91,16 +74,48 @@ impl Store {
]; ];
for table in tables { for table in tables {
let q = format!("INSERT INTO {} SELECT * FROM legacy.{}", table, table); if !legacy_tables.contains(&table.to_string()) {
warn!(table, "table missing in legacy database; skipping");
continue;
}
let q = format!(
"INSERT OR IGNORE INTO {} SELECT * FROM legacy.{}",
table, table
);
sqlx::query(&q) sqlx::query(&q)
.execute(&store.pool) .execute(&mut *conn)
.await .await
.with_context(|| format!("failed to migrate table {}", table))?; .with_context(|| format!("failed to migrate table {}", table))?;
} }
sqlx::query("DETACH DATABASE legacy") sqlx::query("DETACH DATABASE legacy")
.execute(&store.pool) .execute(&mut *conn)
.await?; .await
.context("failed to detach legacy database")?;
// 5. Finalize: Force a checkpoint to roll up WAL, then close and move
sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
.execute(&mut *conn)
.await
.context("failed to checkpoint WAL before finalization")?;
// Explicitly drop the connection so the pool can close properly
drop(conn);
store.pool.close().await;
// Clean up any "ghost" WAL/SHM files at the destination to prevent corruption
let to_path_str = to_state_file.to_string_lossy();
let _ = std::fs::remove_file(format!("{}-wal", to_path_str));
let _ = std::fs::remove_file(format!("{}-shm", to_path_str));
std::fs::rename(&temp_target, to_state_file)
.context("failed to move migrated database into final location")?;
// Also clean up any leftover temp WAL/SHM files just in case
let temp_path_str = temp_target.to_string_lossy();
let _ = std::fs::remove_file(format!("{}-wal", temp_path_str));
let _ = std::fs::remove_file(format!("{}-shm", temp_path_str));
info!( info!(
from = %from_sqlite_state.display(), from = %from_sqlite_state.display(),
+75 -1
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use macaddr::MacAddr; use macaddr::MacAddr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use wakey_core::Presence; use wakey_core::{DeviceEndpoint, EndpointKey, EndpointSource, Presence};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuedAgent { pub struct IssuedAgent {
@@ -134,6 +134,66 @@ pub struct AgentDeviceHostnameRow {
pub hostname: String, pub hostname: String,
} }
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct AgentDeviceEndpointRow {
pub agent_id: String,
pub device_key: String,
pub endpoint_key: String,
pub source: String,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub interface: Option<String>,
pub presence: String,
pub first_seen_unix: i64,
pub last_seen_unix: i64,
}
impl AgentDeviceEndpointRow {
pub fn to_endpoint(&self) -> Option<DeviceEndpoint> {
let source = endpoint_source_from_str(&self.source)?;
let mac = match self.mac.as_deref() {
Some(raw) => match raw.parse() {
Ok(mac) => Some(mac),
Err(err) => {
tracing::warn!(
error = %err,
endpoint_key = %self.endpoint_key,
raw_mac = raw,
"failed to parse endpoint mac"
);
return None;
}
},
None => None,
};
let ip = match self.ip.as_deref() {
Some(raw) => match raw.parse() {
Ok(ip) => Some(ip),
Err(err) => {
tracing::warn!(
error = %err,
endpoint_key = %self.endpoint_key,
raw_ip = raw,
"failed to parse endpoint ip"
);
return None;
}
},
None => None,
};
let key = EndpointKey::new(source, mac, ip)?;
Some(DeviceEndpoint {
key,
hostname: self.hostname.clone(),
interface: self.interface.clone(),
presence: Presence::from(self.presence.as_str()),
first_seen_unix: Some(self.first_seen_unix.max(0) as u64),
last_seen_unix: Some(self.last_seen_unix.max(0) as u64),
})
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AgentDeviceFactRow { pub struct AgentDeviceFactRow {
pub agent_id: String, pub agent_id: String,
@@ -147,10 +207,24 @@ pub struct AgentDeviceWithChildren {
pub macs: Vec<macaddr::MacAddr>, pub macs: Vec<macaddr::MacAddr>,
pub ips: Vec<std::net::IpAddr>, pub ips: Vec<std::net::IpAddr>,
pub hostnames: Vec<String>, pub hostnames: Vec<String>,
pub endpoints: Vec<DeviceEndpoint>,
#[allow(dead_code)] #[allow(dead_code)]
pub facts: Vec<String>, pub facts: Vec<String>,
} }
fn endpoint_source_from_str(raw: &str) -> Option<EndpointSource> {
match raw {
"neighbor" => Some(EndpointSource::Neighbor),
"dhcp_lease" => Some(EndpointSource::DhcpLease),
"hook_neighbor" => Some(EndpointSource::HookNeighbor),
"hook_dhcp" => Some(EndpointSource::HookDhcp),
_ => {
tracing::warn!(source = raw, "unknown endpoint source");
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent { pub struct AuditEvent {
pub event_id: String, pub event_id: String,
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-core" name = "wakey-core"
version = "0.3.0" version = "0.3.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+135 -27
View File
@@ -8,16 +8,16 @@ use crate::parse::mac;
/// Product-level presence derived from raw neighbor state. /// Product-level presence derived from raw neighbor state.
/// ///
/// Variant order defines `Ord`: Offline < Unknown < LikelyOnline < Online. /// Variant order defines `Ord`: Unknown < Offline < LikelyOnline < Online.
/// `std::cmp::max` picks the most-online signal when merging. /// `std::cmp::max` picks the most-online signal when merging.
#[derive( #[derive(
Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize, Default,
)] )]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Presence { pub enum Presence {
Offline,
#[default] #[default]
Unknown, Unknown,
Offline,
LikelyOnline, LikelyOnline,
Online, Online,
} }
@@ -71,11 +71,80 @@ pub enum DeviceId {
Ip(IpAddr), Ip(IpAddr),
} }
/// Typed origin of an endpoint.
///
/// Raw source strings stay on `DeviceObservationFact` for debugging. Endpoint
/// source is the domain value used for summaries and wake-route ranking.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EndpointSource {
Neighbor,
DhcpLease,
HookNeighbor,
HookDhcp,
}
impl EndpointSource {
/// Whether this source contributes an IP to the device summary.
pub const fn summary_ip_eligible(self) -> bool {
matches!(self, Self::Neighbor | Self::DhcpLease)
}
/// Source quality used after reachability and recency when ranking routes.
pub const fn quality_rank(self) -> u8 {
match self {
Self::Neighbor => 4,
Self::DhcpLease => 3,
Self::HookNeighbor => 2,
Self::HookDhcp => 1,
}
}
}
/// Source-scoped identity for one observed network contact point.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct EndpointKey {
pub source: EndpointSource,
#[serde(with = "mac::option_mac", default)]
pub mac: Option<MacAddr>,
pub ip: Option<IpAddr>,
}
impl EndpointKey {
pub fn new(source: EndpointSource, mac: Option<MacAddr>, ip: Option<IpAddr>) -> Option<Self> {
if mac.is_none() && ip.is_none() {
return None;
}
Some(Self { source, mac, ip })
}
}
/// Agent-scoped endpoint key used by control-plane storage and APIs.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct AgentEndpointKey {
pub agent_id: String,
#[serde(flatten)]
pub endpoint: EndpointKey,
}
/// One interpreted network contact point for a device aggregate.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct DeviceEndpoint {
pub key: EndpointKey,
pub hostname: Option<String>,
pub interface: Option<String>,
pub presence: Presence,
pub first_seen_unix: Option<u64>,
pub last_seen_unix: Option<u64>,
}
/// One raw source fact used while building a device aggregate. /// One raw source fact used while building a device aggregate.
/// ///
/// These are intentionally source-shaped and non-durable. They preserve details /// 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 /// from hooks (arp, dhcp, etc) so higher layers can explain why a device looks
/// online, stale, or unknown without reverse-engineering flattened fields. /// online, stale, or unknown.
#[skip_serializing_none] #[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct DeviceObservationFact { pub struct DeviceObservationFact {
@@ -102,6 +171,8 @@ pub struct Device {
#[serde(with = "mac::vec_mac")] #[serde(with = "mac::vec_mac")]
pub macs: Vec<MacAddr>, pub macs: Vec<MacAddr>,
pub interfaces: Vec<String>, pub interfaces: Vec<String>,
#[serde(default)]
pub endpoints: Vec<DeviceEndpoint>,
pub neighbors: Vec<NeighborEntry>, pub neighbors: Vec<NeighborEntry>,
pub leases: Vec<DhcpLease>, pub leases: Vec<DhcpLease>,
pub observations: Vec<DeviceObservationFact>, pub observations: Vec<DeviceObservationFact>,
@@ -126,6 +197,7 @@ impl Device {
let mut ips = BTreeSet::new(); let mut ips = BTreeSet::new();
let mut macs = BTreeSet::new(); let mut macs = BTreeSet::new();
let mut interfaces = BTreeSet::new(); let mut interfaces = BTreeSet::new();
let mut endpoints = Vec::new();
let mut presence = Presence::Unknown; let mut presence = Presence::Unknown;
for lease in &leases { for lease in &leases {
@@ -134,6 +206,18 @@ impl Device {
if let Some(name) = lease.name.as_deref() { if let Some(name) = lease.name.as_deref() {
names.insert(name); names.insert(name);
} }
endpoints.push(DeviceEndpoint {
key: EndpointKey {
source: EndpointSource::DhcpLease,
mac: Some(lease.mac),
ip: Some(lease.ip),
},
hostname: lease.name.clone(),
interface: None,
presence: Presence::Unknown,
first_seen_unix: None,
last_seen_unix: None,
});
} }
for neighbor in &neighbors { for neighbor in &neighbors {
ips.insert(neighbor.ip); ips.insert(neighbor.ip);
@@ -143,46 +227,61 @@ impl Device {
if let Some(dev) = neighbor.dev.as_deref() { if let Some(dev) = neighbor.dev.as_deref() {
interfaces.insert(dev); interfaces.insert(dev);
} }
presence = std::cmp::max(presence, Presence::from(neighbor.state)); let endpoint_presence = Presence::from(neighbor.state);
endpoints.push(DeviceEndpoint {
key: EndpointKey {
source: EndpointSource::Neighbor,
mac: neighbor.mac,
ip: Some(neighbor.ip),
},
hostname: None,
interface: neighbor.dev.clone(),
presence: endpoint_presence,
first_seen_unix: None,
last_seen_unix: None,
});
presence = std::cmp::max(presence, endpoint_presence);
} }
let mut observed_non_remove = false;
for observation in &observations { for observation in &observations {
if let Some(name) = observation.hostname.as_deref() { if let Some(name) = observation.hostname.as_deref() {
names.insert(name); names.insert(name);
} }
if let Some(ip) = observation.ip {
ips.insert(ip);
}
if let Some(mac) = observation.mac { if let Some(mac) = observation.mac {
macs.insert(mac); macs.insert(mac);
} }
if observation.action != "remove" { if let Some(source) = observation_endpoint_source(observation)
observed_non_remove = true; && let Some(key) = EndpointKey::new(source, observation.mac, observation.ip)
}
presence = std::cmp::max(presence, observation_presence(observation));
}
if neighbors.is_empty()
&& leases.is_empty()
&& !observed_non_remove
&& !observations.is_empty()
{ {
presence = Presence::Offline; let endpoint_presence = observation_presence(observation);
endpoints.push(DeviceEndpoint {
key,
hostname: observation.hostname.clone(),
interface: None,
presence: endpoint_presence,
first_seen_unix: observation.first_seen_unix,
last_seen_unix: observation.last_seen_unix,
});
presence = std::cmp::max(presence, endpoint_presence);
}
} }
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();
let id = macs let id = macs.first().copied().map(DeviceId::Mac).or_else(|| {
.first() endpoints
.copied() .iter()
.map(DeviceId::Mac) .filter_map(|endpoint| endpoint.key.ip)
.or_else(|| ips.first().copied().map(DeviceId::Ip)); .min()
.map(DeviceId::Ip)
});
Self { Self {
id, id,
names: names.into_iter().map(|n| n.to_owned()).collect(), names: names.into_iter().map(|n| n.to_owned()).collect(),
ips, ips,
macs, macs,
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(), interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
endpoints,
neighbors, neighbors,
leases, leases,
observations, observations,
@@ -193,14 +292,23 @@ impl Device {
fn observation_presence(observation: &DeviceObservationFact) -> Presence { fn observation_presence(observation: &DeviceObservationFact) -> Presence {
match (observation.kind.as_str(), observation.action.as_str()) { match (observation.kind.as_str(), observation.action.as_str()) {
(_, "remove") => Presence::Offline, ("neigh", "remove") => Presence::Offline,
("neigh", "add" | "update" | "old") => Presence::LikelyOnline, ("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
_ => Presence::Unknown, _ => Presence::Unknown,
} }
} }
fn observation_endpoint_source(observation: &DeviceObservationFact) -> Option<EndpointSource> {
match observation.kind.as_str() {
"neigh" => Some(EndpointSource::HookNeighbor),
"dhcp" => Some(EndpointSource::HookDhcp),
_ => None,
}
}
/// Collection of merged discovered devices. /// Collection of merged discovered devices.
#[derive(Debug, Default, Clone, Serialize)] #[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct DeviceInventory { pub struct DeviceInventory {
#[serde(default)]
pub devices: Vec<Device>, pub devices: Vec<Device>,
} }
+4 -1
View File
@@ -5,7 +5,10 @@ mod neighbor;
mod query; mod query;
mod wake; mod wake;
pub use device::{Device, DeviceId, DeviceInventory, DeviceObservationFact, Presence}; pub use device::{
AgentEndpointKey, Device, DeviceEndpoint, DeviceId, DeviceInventory, DeviceObservationFact,
EndpointKey, EndpointSource, Presence,
};
#[allow(deprecated)] #[allow(deprecated)]
pub use dhcp::LeaseQuery; pub use dhcp::LeaseQuery;
pub use dhcp::{DhcpLease, DhcpLeaseWithState}; pub use dhcp::{DhcpLease, DhcpLeaseWithState};
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-linux" name = "wakey-linux"
version = "0.3.0" version = "0.3.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+92 -7
View File
@@ -161,6 +161,38 @@ pub async fn prune_removed_observations_from_path(path: impl AsRef<Path>) -> io:
Ok(removed) Ok(removed)
} }
pub async fn prune_stale_observations_from_path(
path: impl AsRef<Path>,
retention_days: u64,
) -> io::Result<usize> {
if retention_days == 0 {
return Ok(0);
}
let max_age_seconds = retention_days.saturating_mul(24 * 60 * 60);
prune_observations_older_than_from_path(path, now_unix().saturating_sub(max_age_seconds)).await
}
async fn prune_observations_older_than_from_path(
path: impl AsRef<Path>,
cutoff_unix: u64,
) -> io::Result<usize> {
let path = path.as_ref();
let mut store = load_observation_store_from_path(path).await?;
let before = store.dhcp_clients.len() + store.neighbors.len();
store
.dhcp_clients
.retain(|_, row| row.last_seen_unix >= cutoff_unix);
store
.neighbors
.retain(|_, row| row.last_seen_unix >= cutoff_unix);
let after = store.dhcp_clients.len() + store.neighbors.len();
let removed = before.saturating_sub(after);
if removed > 0 {
save_observation_store_to_path(path, &store).await?;
}
Ok(removed)
}
fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> { fn list_local_observations_from_store(store: LocalObservationStore) -> Vec<LocalDeviceObservation> {
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len()); let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
out.extend( out.extend(
@@ -235,11 +267,11 @@ pub async fn observe_dhcp_event(
row.hostname = hostname.clone(); row.hostname = hostname.clone();
row.last_action = action.to_string(); row.last_action = action.to_string();
row.last_seen_unix = now; row.last_seen_unix = now;
changed = true; changed = true; // update row
} }
}) })
.or_insert_with(|| { .or_insert_with(|| {
changed = true; changed = true; // insert new row
ObservedDhcpClient { ObservedDhcpClient {
mac: mac_s.clone(), mac: mac_s.clone(),
ip, ip,
@@ -258,7 +290,7 @@ pub async fn observe_dhcp_event(
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) { if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
cache.insert(mac_s, hostname); cache.insert(mac_s, hostname);
save_mac_name_cache(&cache).await?; save_mac_name_cache(&cache).await?;
changed = true; changed = true; // update mac -> name cache...
} }
} }
@@ -292,7 +324,7 @@ pub async fn observe_neighbor_event(
.find_map(|alias| store.neighbors.remove(alias)) .find_map(|alias| store.neighbors.remove(alias))
}) })
.unwrap_or_else(|| { .unwrap_or_else(|| {
changed = true; changed = true; // append
ObservedNeighbor { ObservedNeighbor {
key: key.clone(), key: key.clone(),
mac: mac.clone(), mac: mac.clone(),
@@ -304,7 +336,7 @@ pub async fn observe_neighbor_event(
}); });
for alias in alias_keys { for alias in alias_keys {
if store.neighbors.remove(&alias).is_some() { if store.neighbors.remove(&alias).is_some() {
changed = true; changed = true; // remove old alias
} }
} }
if row.key != key if row.key != key
@@ -318,7 +350,7 @@ pub async fn observe_neighbor_event(
row.ip = ip; row.ip = ip;
row.last_action = action.clone(); row.last_action = action.clone();
row.last_seen_unix = now; row.last_seen_unix = now;
changed = true; changed = true; // something changed
} }
store.neighbors.insert(key, row); store.neighbors.insert(key, row);
if let (Some(mac), Some(ip)) = (mac.as_deref(), ip) if let (Some(mac), Some(ip)) = (mac.as_deref(), ip)
@@ -387,6 +419,7 @@ fn mark_replaced_neighbor_ips_removed(
let Some(row_ip) = row.ip else { let Some(row_ip) = row.ip else {
continue; continue;
}; };
// found another ip for same mac, mark it as removed
if row.mac.as_deref() == Some(mac) if row.mac.as_deref() == Some(mac)
&& row_ip != current_ip && row_ip != current_ip
&& same_ip_family(row_ip, current_ip) && same_ip_family(row_ip, current_ip)
@@ -412,7 +445,7 @@ mod tests {
use serial_test::serial; use serial_test::serial;
struct EnvGuard { struct EnvGuard {
keys: Vec<&'static str>, keys: Vec<&'static str>, // vec of 1 key
} }
impl EnvGuard { impl EnvGuard {
@@ -576,4 +609,56 @@ mod tests {
let _ = tokio::fs::remove_file(observation_path).await; let _ = tokio::fs::remove_file(observation_path).await;
} }
#[tokio::test]
#[serial]
async fn stale_observation_prune_uses_last_seen_cutoff() {
let observation_path = temp_file("stale-observations");
let mut dhcp_clients = std::collections::BTreeMap::new();
dhcp_clients.insert(
"aa:bb:cc:dd:ee:01".to_string(),
ObservedDhcpClient {
mac: "aa:bb:cc:dd:ee:01".to_string(),
ip: Some("192.168.1.2".parse().expect("ip")),
hostname: Some("old".to_string()),
first_seen_unix: 1,
last_seen_unix: 10,
last_action: "remove".to_string(),
},
);
dhcp_clients.insert(
"aa:bb:cc:dd:ee:02".to_string(),
ObservedDhcpClient {
mac: "aa:bb:cc:dd:ee:02".to_string(),
ip: Some("192.168.1.3".parse().expect("ip")),
hostname: Some("new".to_string()),
first_seen_unix: 1,
last_seen_unix: 20,
last_action: "remove".to_string(),
},
);
let fixture = LocalObservationStore {
dhcp_clients,
neighbors: Default::default(),
};
tokio::fs::write(
&observation_path,
serde_json::to_string(&fixture).expect("fixture should serialize"),
)
.await
.expect("fixture should write");
let removed = prune_observations_older_than_from_path(&observation_path, 15)
.await
.expect("prune should succeed");
assert_eq!(removed, 1);
let store = load_observation_store_from_path(&observation_path)
.await
.expect("store should read");
assert!(!store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:01"));
assert!(store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:02"));
let _ = tokio::fs::remove_file(observation_path).await;
}
} }