Compare commits
11
Commits
up
..
59fc4ffdc1
No files matched your search
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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
@@ -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
@@ -3598,7 +3598,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3619,7 +3619,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3643,7 +3643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3672,7 +3672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3684,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)]`
|
||||
@@ -12,7 +12,7 @@ start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$BIN" serve --config "$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 stdout 1
|
||||
procd_set_param stderr 1
|
||||
|
||||
@@ -13,6 +13,7 @@ pub use service::{
|
||||
};
|
||||
pub use wakey_linux::observations::{
|
||||
list_local_observations, observe_dhcp_event, observe_neighbor_event,
|
||||
prune_stale_observations_from_path,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+76
-21
@@ -77,7 +77,7 @@ pub fn merge_devices_with_observations(
|
||||
let key = row
|
||||
.mac
|
||||
.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);
|
||||
}
|
||||
for lease in leases {
|
||||
@@ -99,30 +99,31 @@ pub fn merge_devices_with_observations(
|
||||
Device::from_parts_with_observations(neighbors, leases, observations)
|
||||
})
|
||||
.collect();
|
||||
if !query.is_empty() {
|
||||
let mut texts = Vec::new();
|
||||
let mut devs = Vec::new();
|
||||
let mut ips = Vec::new();
|
||||
let mut macs = Vec::new();
|
||||
let mut nuds = Vec::new();
|
||||
|
||||
let mut texts = Vec::new();
|
||||
let mut devs = Vec::new();
|
||||
let mut ips = Vec::new();
|
||||
let mut macs = Vec::new();
|
||||
let mut nuds = Vec::new();
|
||||
for term in query {
|
||||
match term {
|
||||
Query::Text(v) => texts.push(v.as_str()),
|
||||
Query::Interface(v) => devs.push(v.as_str()),
|
||||
Query::Ip(v) => ips.push(*v),
|
||||
Query::Mac(v) => macs.push(*v),
|
||||
Query::NeighborState(v) => nuds.push(*v),
|
||||
};
|
||||
}
|
||||
|
||||
for term in query {
|
||||
match term {
|
||||
Query::Text(v) => texts.push(v.as_str()),
|
||||
Query::Interface(v) => devs.push(v.as_str()),
|
||||
Query::Ip(v) => ips.push(*v),
|
||||
Query::Mac(v) => macs.push(*v),
|
||||
Query::NeighborState(v) => nuds.push(*v),
|
||||
};
|
||||
}
|
||||
|
||||
devices.retain(|device| {
|
||||
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
|
||||
devices.retain(|device| {
|
||||
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
|
||||
&& (devs.is_empty() || device.interfaces.iter().any(|i| devs.contains(&i.as_str()))) // same pattern as iter().any
|
||||
&& (ips.is_empty() || device.ips.iter().any(|ip| ips.contains(ip)))
|
||||
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
|
||||
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
|
||||
});
|
||||
});
|
||||
}
|
||||
devices.sort_by(|a, b| {
|
||||
presence_rank(b.presence)
|
||||
.cmp(&presence_rank(a.presence))
|
||||
@@ -180,7 +181,7 @@ const fn presence_rank(presence: Presence) -> u8 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
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> {
|
||||
vec![NeighborEntry {
|
||||
@@ -278,11 +279,14 @@ mod tests {
|
||||
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_marks_remove_only_observation_offline() {
|
||||
fn merge_devices_marks_neigh_remove_observation_offline() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let out = merge_devices_with_observations(
|
||||
Vec::new(),
|
||||
@@ -301,5 +305,56 @@ mod tests {
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,20 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -144,6 +144,7 @@ export function App() {
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onAfterWake={loadAll}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -103,6 +103,20 @@ export type FleetDeviceAgent = {
|
||||
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 = {
|
||||
route_id: string;
|
||||
agent_id: string;
|
||||
@@ -111,7 +125,9 @@ export type FleetWakeRoute = {
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
interface: string | null;
|
||||
source: string;
|
||||
presence: string;
|
||||
last_seen_unix: number;
|
||||
wakeable: boolean;
|
||||
};
|
||||
@@ -126,6 +142,7 @@ export type FleetDevice = {
|
||||
hostnames: string[];
|
||||
agents: FleetDeviceAgent[];
|
||||
sources: string[];
|
||||
endpoints: FleetDeviceEndpoint[];
|
||||
first_seen_unix: number | null;
|
||||
last_seen_unix: number | null;
|
||||
presence: string;
|
||||
|
||||
@@ -93,7 +93,7 @@ export function AppLayout() {
|
||||
className={[
|
||||
"sidebar",
|
||||
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",
|
||||
collapsed ? "-translate-x-full md:translate-x-0" : "translate-x-0",
|
||||
]
|
||||
@@ -219,8 +219,14 @@ export function AppLayout() {
|
||||
{/* Mobile backdrop */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden animate-in fade-in duration-200"
|
||||
onClick={() => setCollapsed(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setCollapsed(true);
|
||||
}}
|
||||
aria-label="Close menu"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-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
|
||||
<Select
|
||||
value={severity}
|
||||
@@ -80,7 +83,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setSeverity(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-severity" className="w-full">
|
||||
<SelectValue placeholder="Severity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -93,7 +96,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</Select>
|
||||
</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
|
||||
<Select
|
||||
value={status}
|
||||
@@ -101,7 +107,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setStatus(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-status" className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -114,7 +120,10 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</Select>
|
||||
</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
|
||||
<Select
|
||||
value={kind}
|
||||
@@ -122,7 +131,7 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
if (value) setKind(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="alert-kind" className="w-full">
|
||||
<SelectValue placeholder="Kind" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -170,9 +179,13 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
</span>
|
||||
</CardHeader>
|
||||
<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
|
||||
<Input
|
||||
id="alert-transition-search"
|
||||
value={transitionQ}
|
||||
onChange={(e) => setTransitionQ(e.target.value)}
|
||||
placeholder="kind, status, message, agent"
|
||||
|
||||
@@ -70,7 +70,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
|
||||
<CardContent className="space-y-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
|
||||
<Select
|
||||
value={eventType}
|
||||
@@ -78,7 +81,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
if (value) setEventType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="audit-event-type" className="w-full">
|
||||
<SelectValue placeholder="Event type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -91,7 +94,10 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
</Select>
|
||||
</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
|
||||
<Select
|
||||
value={outcome}
|
||||
@@ -99,7 +105,7 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
if (value) setOutcome(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="audit-outcome" className="w-full">
|
||||
<SelectValue placeholder="Outcome" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -112,9 +118,13 @@ export function AuditPage({ events, onRefresh }: Props) {
|
||||
</Select>
|
||||
</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
|
||||
<Input
|
||||
id="audit-search"
|
||||
value={needle}
|
||||
onChange={(e) => setNeedle(e.target.value)}
|
||||
placeholder="message, agent, actor"
|
||||
|
||||
@@ -66,12 +66,15 @@ export function CommandsPage({
|
||||
Command Runner
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Debug tool — send raw commands to agents and inspect JSON responses
|
||||
Debug tool: send raw commands to agents and inspect JSON responses
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
@@ -80,13 +83,16 @@ export function CommandsPage({
|
||||
/>
|
||||
</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>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => setKind(value as CommandKind)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger id="cmd-kind" className="w-full">
|
||||
<SelectValue placeholder="Pick command" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -98,9 +104,13 @@ export function CommandsPage({
|
||||
</Select>
|
||||
</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>
|
||||
<Input
|
||||
id="cmd-query"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="optional"
|
||||
|
||||
@@ -115,7 +115,7 @@ export function DashboardPage({
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Fleet Status */}
|
||||
<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">
|
||||
<Bot className="size-3.5" />
|
||||
Fleet Status
|
||||
@@ -152,7 +152,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Active Alerts */}
|
||||
<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">
|
||||
<Bell className="size-3.5" />
|
||||
Active Alerts
|
||||
@@ -188,7 +188,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Transitions */}
|
||||
<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">
|
||||
<Activity className="size-3.5" />
|
||||
Transitions
|
||||
@@ -206,7 +206,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Token Health */}
|
||||
<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">
|
||||
<Key className="size-3.5" />
|
||||
Enroll Tokens
|
||||
@@ -228,7 +228,7 @@ export function DashboardPage({
|
||||
|
||||
{/* Recent Activity Feed */}
|
||||
<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>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
|
||||
@@ -51,9 +51,10 @@ type Props = {
|
||||
selectedAgentId: string;
|
||||
onSelectAgent: (agentId: string) => 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 [knownDevices, setKnownDevices] = useState<KnownDevice[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -137,8 +138,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
} catch {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.style.position = "fixed";
|
||||
area.style.opacity = "0";
|
||||
area.style.cssText = "position:fixed;opacity:0";
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
document.execCommand("copy");
|
||||
@@ -201,7 +201,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadFleet()}
|
||||
onClick={() => {
|
||||
void loadFleet();
|
||||
if (onRefresh) void onRefresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
@@ -222,11 +225,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<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>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="fleet-search"
|
||||
className="pl-8"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -246,7 +253,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
values={knownFilters}
|
||||
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>
|
||||
<Select
|
||||
value={agentId}
|
||||
@@ -334,6 +344,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
||||
</Card>
|
||||
|
||||
<FleetDeviceDetailsDialog
|
||||
key={details?.device_key ?? "__none"}
|
||||
device={details}
|
||||
knownDevices={knownDevices}
|
||||
open={Boolean(details)}
|
||||
|
||||
@@ -118,7 +118,7 @@ export function TokensPage() {
|
||||
return (
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<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>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="size-5 text-primary" />
|
||||
@@ -219,9 +219,13 @@ export function TokensPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{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>
|
||||
<Input
|
||||
id="token-custom-ttl"
|
||||
type="number"
|
||||
min={1}
|
||||
value={customTtl}
|
||||
|
||||
@@ -101,7 +101,10 @@ export function WakeToolsPage({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
@@ -111,14 +114,17 @@ export function WakeToolsPage({
|
||||
</label>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
id="wake-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="bedroom-pc, 192.168.1.100, or aa:bb:cc:dd:ee:ff"
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
@@ -134,7 +140,7 @@ export function WakeToolsPage({
|
||||
</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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -147,10 +153,10 @@ export function WakeToolsPage({
|
||||
<CardContent>
|
||||
{recentWakes.length > 0 ? (
|
||||
<div className="grid gap-0">
|
||||
{recentWakes.map((event, idx) => (
|
||||
{recentWakes.map((event, i) => (
|
||||
<div
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${idx < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${idx}`}
|
||||
className={`flex items-start justify-between gap-3 py-2.5 ${i < recentWakes.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={`${event.ts}-${event.target}-${event.agentId}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Minus, Plus, Zap } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Copy, Minus, Plus, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
detachDeviceIdentifier,
|
||||
mergeKnownDevice,
|
||||
type FleetDevice,
|
||||
type FleetDeviceEndpoint,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -29,7 +30,12 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
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 = {
|
||||
device: FleetDevice | null;
|
||||
@@ -52,24 +58,16 @@ export function FleetDeviceDetailsDialog({
|
||||
onCopy,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [displayName, setDisplayName] = useState(device?.display_name ?? "");
|
||||
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 [addValue, setAddValue] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
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) : [];
|
||||
|
||||
// Resolve the full KnownDevice object if this fleet row is known
|
||||
@@ -233,6 +231,30 @@ export function FleetDeviceDetailsDialog({
|
||||
|
||||
<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 ── */}
|
||||
<div className="grid 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>
|
||||
{fullKnown.notes && (
|
||||
<span className="text-muted-foreground">
|
||||
— {fullKnown.notes}
|
||||
: {fullKnown.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -521,3 +543,52 @@ export function FleetDeviceDetailsDialog({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ export const presenceFilters = [
|
||||
"all",
|
||||
"online",
|
||||
"likely_online",
|
||||
"unknown",
|
||||
"offline",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
export const knownFilters: KnownFilter[] = ["all", "known", "unknown"];
|
||||
@@ -40,8 +40,8 @@ export function formatSeen(value: number | null): string {
|
||||
export function presenceRank(presence: string): number {
|
||||
if (presence === "online") return 4;
|
||||
if (presence === "likely_online") return 3;
|
||||
if (presence === "unknown") return 2;
|
||||
if (presence === "offline") return 1;
|
||||
if (presence === "offline") return 2;
|
||||
if (presence === "unknown") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function agentLabel(agentId: string, nickname?: string | null): string {
|
||||
}
|
||||
|
||||
export function routeLabel(route: FleetWakeRoute): string {
|
||||
const status = route.connected ? "connected" : "offline";
|
||||
const status = route.connected ? "connected" : "agent offline";
|
||||
const target = route.mac
|
||||
? `${route.mac}${route.ip ? ` / ${route.ip}` : ""}`
|
||||
: (route.ip ?? "-");
|
||||
|
||||
+2
-1
@@ -135,7 +135,7 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground transition-colors duration-100 ease-in-out;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -189,6 +189,7 @@
|
||||
background-size: 100% 100%;
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
transition: background 100ms ease-in-out;
|
||||
}
|
||||
|
||||
/* ── Sidebar internals ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
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.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
||||
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_MAC_NAME_CACHE_PATH: &str = "/tmp/wakey_mac_names.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)]
|
||||
pub struct AgentConfig {
|
||||
@@ -23,6 +25,8 @@ pub struct AgentConfig {
|
||||
pub reconnect_max_ms: u64,
|
||||
#[serde(default = "default_observation_sync_interval_seconds")]
|
||||
pub observation_sync_interval_seconds: u64,
|
||||
#[serde(default = "default_observation_retention_days")]
|
||||
pub observation_retention_days: u64,
|
||||
#[serde(default = "default_pid_file")]
|
||||
pub pid_file: PathBuf,
|
||||
#[serde(default = "default_dhcp_leases_path")]
|
||||
@@ -33,6 +37,20 @@ pub struct AgentConfig {
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("AgentConfig")
|
||||
@@ -45,6 +63,10 @@ impl fmt::Debug for AgentConfig {
|
||||
"observation_sync_interval_seconds",
|
||||
&self.observation_sync_interval_seconds,
|
||||
)
|
||||
.field(
|
||||
"observation_retention_days",
|
||||
&self.observation_retention_days,
|
||||
)
|
||||
.field("pid_file", &self.pid_file)
|
||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||
@@ -65,6 +87,10 @@ const fn default_observation_sync_interval_seconds() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
const fn default_observation_retention_days() -> u64 {
|
||||
DEFAULT_OBSERVATION_RETENTION_DAYS
|
||||
}
|
||||
|
||||
fn default_pid_file() -> PathBuf {
|
||||
DEFAULT_PID_FILE.into()
|
||||
}
|
||||
@@ -183,6 +209,7 @@ mod tests {
|
||||
reconnect_base_ms: 123,
|
||||
reconnect_max_ms: 456,
|
||||
observation_sync_interval_seconds: 7,
|
||||
observation_retention_days: 3,
|
||||
pid_file: "/tmp/test-wakey-agent.pid".into(),
|
||||
dhcp_leases_path: "/tmp/test-dhcp.leases".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_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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,15 @@ async fn dispatch_devs(req: DevsRequest) -> 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 neighbors = wakey::wakey_linux::devices::query_neighbors(&query).await?;
|
||||
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,
|
||||
)
|
||||
.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(
|
||||
&config.observation_store_path,
|
||||
)
|
||||
@@ -104,7 +127,7 @@ async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Resu
|
||||
rows = inventory.devices.len(),
|
||||
"dispatched inventory command"
|
||||
);
|
||||
Ok(CommandResult::Inventory(inventory))
|
||||
Ok(inventory)
|
||||
}
|
||||
|
||||
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
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 config: AgentConfig,
|
||||
@@ -55,32 +55,12 @@ pub async fn enroll(
|
||||
.await
|
||||
.context("failed to decode enrollment response")?;
|
||||
|
||||
let config = AgentConfig {
|
||||
server_url: payload.server_url.unwrap_or(server_url),
|
||||
agent_id: payload.agent_id,
|
||||
agent_token: payload.agent_token,
|
||||
reconnect_base_ms: base_config
|
||||
.map(|config| config.reconnect_base_ms)
|
||||
.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 mut config = base_config
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (*DEFAULT_CONFIG).clone());
|
||||
config.server_url = payload.server_url.unwrap_or(server_url);
|
||||
config.agent_id = payload.agent_id;
|
||||
config.agent_token = payload.agent_token;
|
||||
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");
|
||||
Ok(EnrollOutcome {
|
||||
@@ -166,6 +146,7 @@ mod tests {
|
||||
reconnect_base_ms: 2_000,
|
||||
reconnect_max_ms: 60_000,
|
||||
observation_sync_interval_seconds: 30,
|
||||
observation_retention_days: 11,
|
||||
pid_file: "/tmp/custom-wakey-agent.pid".into(),
|
||||
dhcp_leases_path: "/tmp/custom-dhcp.leases".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_token, "token-xyz");
|
||||
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.dhcp_leases_path, base_config.dhcp_leases_path);
|
||||
assert!(outcome.backup_path.is_none());
|
||||
|
||||
+4
-12
@@ -248,18 +248,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
let mut cfg = if let Some(from_config) = &args.from_config {
|
||||
config::load_config(from_config)?
|
||||
} else {
|
||||
config::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: 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(),
|
||||
}
|
||||
(*config::DEFAULT_CONFIG).clone()
|
||||
};
|
||||
|
||||
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 {
|
||||
cfg.agent_token = agent_token;
|
||||
}
|
||||
if let Some(days) = args.observation_retention_days {
|
||||
cfg.observation_retention_days = days;
|
||||
}
|
||||
|
||||
if let Some(path) = &args.config {
|
||||
config::save_config(path, &cfg)?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{debug, error, info, info_span, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::dispatch::dispatch_command;
|
||||
use crate::dispatch::{dispatch_command, inventory_for_config};
|
||||
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||
|
||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
@@ -150,8 +150,15 @@ where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let query = wakey_core::InventoryQueryBuilder::new().build();
|
||||
let inventory = wakey::inventory(query)
|
||||
let req = crate::protocol::InventoryRequest {
|
||||
query: None,
|
||||
name: None,
|
||||
ips: Vec::new(),
|
||||
devs: Vec::new(),
|
||||
nuds: Vec::new(),
|
||||
macs: Vec::new(),
|
||||
};
|
||||
let inventory = inventory_for_config(req, config)
|
||||
.await
|
||||
.context("failed to run inventory for device snapshot")?;
|
||||
let count = inventory.devices.len();
|
||||
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
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 macaddr::MacAddr;
|
||||
use wakey_core::Presence;
|
||||
use wakey_core::{EndpointSource, Presence};
|
||||
|
||||
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)]
|
||||
pub(crate) struct FleetBuildContext {
|
||||
@@ -31,6 +33,7 @@ struct FleetAccumulator {
|
||||
hostnames: BTreeSet<String>,
|
||||
sources: BTreeSet<String>,
|
||||
agents: BTreeMap<String, FleetDeviceAgent>,
|
||||
endpoints: Vec<FleetDeviceEndpoint>,
|
||||
first_seen_unix: Option<u64>,
|
||||
last_seen_unix: Option<u64>,
|
||||
presence: Presence,
|
||||
@@ -49,6 +52,7 @@ impl Default for FleetAccumulator {
|
||||
hostnames: BTreeSet::new(),
|
||||
sources: BTreeSet::new(),
|
||||
agents: BTreeMap::new(),
|
||||
endpoints: Vec::new(),
|
||||
first_seen_unix: None,
|
||||
last_seen_unix: None,
|
||||
presence: Presence::Offline,
|
||||
@@ -199,12 +203,25 @@ fn add_agent_device_to_entry(
|
||||
entry.pinned = summary.pinned;
|
||||
entry.known_device = Some(summary);
|
||||
}
|
||||
for mac in &agent_device.macs {
|
||||
entry.macs.insert(*mac);
|
||||
}
|
||||
if !device_offline {
|
||||
for ip in &agent_device.ips {
|
||||
entry.ips.insert(*ip);
|
||||
if agent_device.endpoints.is_empty() {
|
||||
for mac in &agent_device.macs {
|
||||
entry.macs.insert(*mac);
|
||||
}
|
||||
if !device_offline {
|
||||
for ip in &agent_device.ips {
|
||||
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 {
|
||||
@@ -213,7 +230,6 @@ fn add_agent_device_to_entry(
|
||||
}
|
||||
entry.hostnames.insert(hostname.clone());
|
||||
}
|
||||
entry.sources.insert("device".to_string());
|
||||
let first_seen = agent_device.device.first_seen();
|
||||
let last_seen = agent_device.device.last_seen();
|
||||
entry.first_seen_unix = Some(
|
||||
@@ -255,35 +271,88 @@ fn add_agent_device_to_entry(
|
||||
last_seen_unix: last_seen,
|
||||
});
|
||||
|
||||
for mac in &agent_device.macs {
|
||||
let ip_for_mac = agent_device.ips.first().copied();
|
||||
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref(), "device");
|
||||
let wakeable = status.connected && !device_offline;
|
||||
entry.routes.insert(
|
||||
rid.clone(),
|
||||
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: Some(*mac),
|
||||
ip: ip_for_mac,
|
||||
hostname: hostname_for_mac,
|
||||
source: "device".to_string(),
|
||||
last_seen_unix: last_seen,
|
||||
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.macs.is_empty()
|
||||
if agent_device.endpoints.is_empty() && !agent_device.macs.is_empty() {
|
||||
for mac in &agent_device.macs {
|
||||
let ip_for_mac = agent_device.ips.first().copied();
|
||||
let hostname_for_mac = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, Some(mac), ip_for_mac.as_ref());
|
||||
let wakeable = status.connected;
|
||||
insert_route(
|
||||
entry,
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
nickname: status.nickname.clone(),
|
||||
connected: status.connected,
|
||||
mac: Some(*mac),
|
||||
ip: ip_for_mac,
|
||||
hostname: hostname_for_mac,
|
||||
interface: None,
|
||||
source: "device".to_string(),
|
||||
presence: device_presence,
|
||||
last_seen_unix: last_seen,
|
||||
wakeable,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if agent_device.endpoints.is_empty()
|
||||
&& agent_device.macs.is_empty()
|
||||
&& let Some(ip) = agent_device.ips.first()
|
||||
{
|
||||
let hostname = agent_device.hostnames.first().cloned();
|
||||
let rid = route_id(&agent_id, None, Some(ip), "device");
|
||||
entry.routes.insert(
|
||||
rid.clone(),
|
||||
let rid = route_id(&agent_id, None, Some(ip));
|
||||
insert_route(
|
||||
entry,
|
||||
FleetWakeRoute {
|
||||
route_id: rid,
|
||||
agent_id: agent_id.clone(),
|
||||
@@ -292,7 +361,9 @@ fn add_agent_device_to_entry(
|
||||
mac: None,
|
||||
ip: Some(*ip),
|
||||
hostname,
|
||||
interface: None,
|
||||
source: "device".to_string(),
|
||||
presence: device_presence,
|
||||
last_seen_unix: last_seen,
|
||||
wakeable: false,
|
||||
},
|
||||
@@ -321,19 +392,66 @@ fn device_known_device(
|
||||
context: &FleetBuildContext,
|
||||
) -> Option<KnownDeviceSummary> {
|
||||
agent_device
|
||||
.macs
|
||||
.first()
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| endpoint.key.mac)
|
||||
.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(|| {
|
||||
agent_device
|
||||
.ips
|
||||
.first()
|
||||
.iter()
|
||||
.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 {
|
||||
KnownDeviceSummary {
|
||||
device_id: device.device_id.clone(),
|
||||
@@ -345,13 +463,8 @@ pub(crate) fn known_device_summary(device: &KnownDevice) -> KnownDeviceSummary {
|
||||
impl FleetAccumulator {
|
||||
fn into_response(self) -> FleetDevice {
|
||||
let mut route_candidates = self.routes.into_values().collect::<Vec<_>>();
|
||||
route_candidates.sort_by(|a, b| {
|
||||
b.connected
|
||||
.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))
|
||||
});
|
||||
route_candidates
|
||||
.sort_by(|a, b| compare_route_quality(b, a).then_with(|| a.agent_id.cmp(&b.agent_id)));
|
||||
let recommended_route = route_candidates
|
||||
.iter()
|
||||
.find(|route| route.wakeable)
|
||||
@@ -373,6 +486,7 @@ impl FleetAccumulator {
|
||||
hostnames: self.hostnames.into_iter().collect(),
|
||||
agents: self.agents.into_values().collect(),
|
||||
sources: self.sources.into_iter().collect(),
|
||||
endpoints: self.endpoints,
|
||||
first_seen_unix: self.first_seen_unix,
|
||||
last_seen_unix: self.last_seen_unix,
|
||||
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!(
|
||||
"{}|{}|{}|{}",
|
||||
"{}|{}|{}",
|
||||
agent_id,
|
||||
source,
|
||||
mac.map(|m| m.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;
|
||||
};
|
||||
match serde_json::from_value::<Vec<wakey_core::Device>>(
|
||||
result
|
||||
.get("devices")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Array(vec![])),
|
||||
) {
|
||||
match serde_json::from_value::<wakey_core::DeviceInventory>(result)
|
||||
.map(|i| i.devices)
|
||||
{
|
||||
Ok(devices) => {
|
||||
match state
|
||||
.store
|
||||
@@ -276,6 +273,7 @@ async fn load_fleet_devices(
|
||||
let mut devices = build_fleet_devices(known_devices, agent_devices, &context);
|
||||
filter_fleet_devices(&mut devices, query);
|
||||
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);
|
||||
Ok(devices)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use wakey_core::Presence;
|
||||
use wakey_core::{DeviceEndpoint, EndpointKey, EndpointSource, Presence};
|
||||
|
||||
use super::build::{
|
||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||
@@ -54,6 +54,7 @@ fn agent_device(
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hostnames: vec!["lda".to_string()],
|
||||
endpoints: vec![],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
@@ -83,6 +84,7 @@ fn offline_agent_device(
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hostnames: vec!["lda".to_string()],
|
||||
endpoints: vec![],
|
||||
facts: vec![],
|
||||
}
|
||||
}
|
||||
@@ -105,10 +107,42 @@ fn offline_ip_only_unknown(
|
||||
macs: vec![],
|
||||
ips: vec![ip.parse().unwrap()],
|
||||
hostnames: vec![],
|
||||
endpoints: 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]
|
||||
fn fleet_grouping_combines_same_mac_across_agents() {
|
||||
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]
|
||||
fn known_device_with_two_macs_absorbs_both_device_groups() {
|
||||
let known = KnownDevice {
|
||||
@@ -242,7 +416,7 @@ fn offline_device_has_offline_presence() {
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert!(devices[0].ips.is_empty());
|
||||
assert_eq!(devices[0].presence, Presence::Offline);
|
||||
assert!(devices[0].recommended_route.is_none());
|
||||
assert!(devices[0].recommended_route.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -68,6 +68,7 @@ pub struct FleetDevice {
|
||||
pub hostnames: Vec<String>,
|
||||
pub agents: Vec<FleetDeviceAgent>,
|
||||
pub sources: Vec<String>,
|
||||
pub endpoints: Vec<FleetDeviceEndpoint>,
|
||||
pub first_seen_unix: Option<u64>,
|
||||
pub last_seen_unix: Option<u64>,
|
||||
pub presence: Presence,
|
||||
@@ -83,6 +84,22 @@ pub struct FleetDeviceAgent {
|
||||
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)]
|
||||
pub struct FleetWakeRoute {
|
||||
pub route_id: String,
|
||||
@@ -93,7 +110,9 @@ pub struct FleetWakeRoute {
|
||||
pub mac: Option<MacAddr>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub interface: Option<String>,
|
||||
pub source: String,
|
||||
pub presence: Presence,
|
||||
pub last_seen_unix: u64,
|
||||
pub wakeable: bool,
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::{InitConfigArgs, ServeArgs};
|
||||
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>> {
|
||||
if args.stdout && args.config_file.is_some() {
|
||||
@@ -30,6 +30,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
Default::default()
|
||||
};
|
||||
let base_telemetry = base.telemetry.unwrap_or_default();
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
let bind = match args.bind {
|
||||
Some(bind) => bind,
|
||||
@@ -43,7 +44,8 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
.unwrap_or_else(|| "<defaults>".to_string())
|
||||
)
|
||||
})?,
|
||||
None => "0.0.0.0:8080"
|
||||
None => defaults
|
||||
.bind
|
||||
.parse()
|
||||
.expect("static default bind should parse"),
|
||||
},
|
||||
@@ -52,37 +54,38 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
args.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
|
||||
.data_dir
|
||||
.clone()
|
||||
.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
|
||||
.state_file
|
||||
.clone()
|
||||
.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 pid_file_raw = args
|
||||
.pid_file
|
||||
.clone()
|
||||
.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 ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
.clone()
|
||||
.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() {
|
||||
base.bootstrap_enroll_tokens.unwrap_or_default()
|
||||
base.bootstrap_enroll_tokens
|
||||
.unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone())
|
||||
} else {
|
||||
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
|
||||
.or(base.command_timeout_ms)
|
||||
.unwrap_or(30_000)
|
||||
.unwrap_or(defaults.command_timeout_ms)
|
||||
.max(1),
|
||||
enroll_token_ttl_seconds: args
|
||||
.enroll_token_ttl_seconds
|
||||
.or(base.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
||||
.max(1),
|
||||
observation_retention_seconds: args
|
||||
.observation_retention_seconds
|
||||
.or(base.observation_retention_seconds)
|
||||
.unwrap_or(2_592_000),
|
||||
.unwrap_or(defaults.observation_retention_seconds),
|
||||
pid_file,
|
||||
ui_dist_dir,
|
||||
bootstrap_enroll_tokens,
|
||||
@@ -113,16 +116,17 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
otlp_endpoint: args
|
||||
.telemetry_otlp_endpoint
|
||||
.clone()
|
||||
.or(base_telemetry.otlp_endpoint),
|
||||
.or(base_telemetry.otlp_endpoint)
|
||||
.or_else(|| defaults.telemetry.otlp_endpoint.clone()),
|
||||
service_name: args
|
||||
.telemetry_service_name
|
||||
.clone()
|
||||
.or(base_telemetry.service_name)
|
||||
.unwrap_or_else(|| "wakey-control-plane".to_string()),
|
||||
.unwrap_or_else(|| defaults.telemetry.service_name.clone()),
|
||||
json_logs: args
|
||||
.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 {
|
||||
use super::write_init_config;
|
||||
use crate::cli::InitConfigArgs;
|
||||
use crate::config::types::DEFAULT_CONFIG;
|
||||
|
||||
fn temp_test_dir(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
@@ -251,4 +256,51 @@ json_logs = true
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,20 @@ use crate::cli::{
|
||||
RevokeEnrollTokenArgs, ServeArgs, StateStatsArgs,
|
||||
};
|
||||
use crate::config::types::{
|
||||
DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings, StateAccessSettings,
|
||||
TelemetryConfig,
|
||||
DEFAULT_CONFIG, DaemonConfig, FileConfig, FileTelemetryConfig, IssueTokenSettings,
|
||||
StateAccessSettings, TelemetryConfig,
|
||||
};
|
||||
|
||||
impl DaemonConfig {
|
||||
pub fn from_serve_args(args: &ServeArgs) -> Result<Self> {
|
||||
let file = load_file_config(&args.config_file)?;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
let data_dir = args
|
||||
.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 {
|
||||
Some(bind) => bind,
|
||||
@@ -32,7 +33,8 @@ impl DaemonConfig {
|
||||
args.config_file.display()
|
||||
)
|
||||
})?,
|
||||
None => "0.0.0.0:8080"
|
||||
None => defaults
|
||||
.bind
|
||||
.parse()
|
||||
.expect("static default bind should parse"),
|
||||
},
|
||||
@@ -42,34 +44,34 @@ impl DaemonConfig {
|
||||
args.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
|
||||
.state_file
|
||||
.clone()
|
||||
.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 command_timeout = Duration::from_millis(
|
||||
args.command_timeout_ms
|
||||
.or(file.command_timeout_ms)
|
||||
.unwrap_or(30_000)
|
||||
.unwrap_or(defaults.command_timeout_ms)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
let enroll_token_ttl = Duration::from_secs(
|
||||
args.enroll_token_ttl_seconds
|
||||
.or(file.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
let observation_retention = Duration::from_secs(
|
||||
args.observation_retention_seconds
|
||||
.or(file.observation_retention_seconds)
|
||||
.unwrap_or(2_592_000)
|
||||
.unwrap_or(defaults.observation_retention_seconds)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
@@ -77,17 +79,18 @@ impl DaemonConfig {
|
||||
.pid_file
|
||||
.clone()
|
||||
.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 ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
.clone()
|
||||
.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() {
|
||||
file.bootstrap_enroll_tokens.unwrap_or_default()
|
||||
file.bootstrap_enroll_tokens
|
||||
.unwrap_or_else(|| defaults.bootstrap_enroll_tokens.clone())
|
||||
} else {
|
||||
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> {
|
||||
let file = load_file_config(&args.config_file)?;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
let state = resolve_state_access(
|
||||
&args.config_file,
|
||||
args.data_dir.clone(),
|
||||
@@ -142,7 +146,7 @@ pub fn resolve_issue_token_settings(args: &IssueEnrollTokenArgs) -> Result<Issue
|
||||
let ttl = Duration::from_secs(
|
||||
args.ttl_seconds
|
||||
.or(file.enroll_token_ttl_seconds)
|
||||
.unwrap_or(86_400)
|
||||
.unwrap_or(defaults.enroll_token_ttl_seconds)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
@@ -254,16 +258,17 @@ fn resolve_state_access(
|
||||
mode: &AdminTargetArgs,
|
||||
) -> Result<StateAccessSettings> {
|
||||
let file = load_file_config(config_file)?;
|
||||
let defaults = &*DEFAULT_CONFIG;
|
||||
|
||||
let data_dir = cli_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(
|
||||
&data_dir,
|
||||
cli_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
|
||||
@@ -280,7 +285,9 @@ fn resolve_state_access(
|
||||
|
||||
#[cfg(test)]
|
||||
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::PathBuf;
|
||||
|
||||
@@ -303,4 +310,43 @@ mod tests {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -29,10 +30,11 @@ pub struct TelemetryConfig {
|
||||
|
||||
impl Default for TelemetryConfig {
|
||||
fn default() -> Self {
|
||||
let defaults = &DEFAULT_CONFIG.telemetry;
|
||||
Self {
|
||||
otlp_endpoint: None,
|
||||
service_name: "wakey-control-plane".to_string(),
|
||||
json_logs: false,
|
||||
otlp_endpoint: defaults.otlp_endpoint.clone(),
|
||||
service_name: defaults.service_name.clone(),
|
||||
json_logs: defaults.json_logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +62,7 @@ pub(crate) struct FileTelemetryConfig {
|
||||
pub(crate) json_logs: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WritableConfig {
|
||||
pub(crate) data_dir: PathBuf,
|
||||
pub(crate) bind: String,
|
||||
@@ -76,7 +78,7 @@ pub(crate) struct WritableConfig {
|
||||
pub(crate) telemetry: WritableTelemetry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct WritableTelemetry {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) otlp_endpoint: Option<String>,
|
||||
@@ -84,6 +86,24 @@ pub(crate) struct WritableTelemetry {
|
||||
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 data_dir: PathBuf,
|
||||
pub state_file: PathBuf,
|
||||
|
||||
@@ -17,10 +17,21 @@ pub struct Store {
|
||||
db_path: PathBuf,
|
||||
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 SCHEMA_VERSION: u32 = 2;
|
||||
pub(crate) const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
pub(crate) mod agent_devices;
|
||||
mod alerts;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Context, Result};
|
||||
use wakey_core::{Device, DeviceId};
|
||||
use wakey_core::{Device, DeviceEndpoint, DeviceId, EndpointSource};
|
||||
|
||||
use super::Store;
|
||||
use super::helpers::core::*;
|
||||
@@ -15,11 +15,7 @@ impl Store {
|
||||
agent_id: &str,
|
||||
devices: &[Device],
|
||||
) -> Result<usize> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting device snapshot transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
|
||||
let mut incoming_keys = std::collections::HashSet::with_capacity(devices.len());
|
||||
let snapshot_time = now_unix();
|
||||
@@ -112,6 +108,48 @@ impl Store {
|
||||
.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!(
|
||||
"DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
|
||||
agent_id,
|
||||
@@ -227,6 +265,15 @@ impl Store {
|
||||
.await
|
||||
.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!(
|
||||
AgentDeviceFactRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||
@@ -236,7 +283,9 @@ impl Store {
|
||||
.await
|
||||
.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.
|
||||
@@ -291,6 +340,17 @@ impl Store {
|
||||
.await
|
||||
.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!(
|
||||
AgentDeviceFactRow,
|
||||
r#"SELECT agent_id as "agent_id!", device_key as "device_key!", fact_json as "fact_json!"
|
||||
@@ -302,7 +362,9 @@ impl Store {
|
||||
.await
|
||||
.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>,
|
||||
ips: Vec<AgentDeviceIpRow>,
|
||||
hostnames: Vec<AgentDeviceHostnameRow>,
|
||||
endpoints: Vec<AgentDeviceEndpointRow>,
|
||||
facts: Vec<AgentDeviceFactRow>,
|
||||
) -> Vec<AgentDeviceWithChildren> {
|
||||
use std::collections::BTreeMap;
|
||||
@@ -336,6 +399,15 @@ fn assemble_device_rows(
|
||||
.or_default()
|
||||
.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();
|
||||
for row in &facts {
|
||||
fact_map
|
||||
@@ -373,11 +445,13 @@ fn assemble_device_rows(
|
||||
})
|
||||
.collect();
|
||||
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();
|
||||
AgentDeviceWithChildren {
|
||||
macs,
|
||||
ips,
|
||||
hostnames,
|
||||
endpoints,
|
||||
facts,
|
||||
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)]
|
||||
mod tests {
|
||||
use super::super::helpers::test_helpers::TestStore;
|
||||
@@ -416,6 +549,7 @@ mod tests {
|
||||
ips: vec![ip.parse().expect("ip")],
|
||||
macs: vec![mac.parse().expect("mac")],
|
||||
interfaces: vec!["br-lan".to_string()],
|
||||
endpoints: vec![],
|
||||
neighbors: vec![NeighborEntry {
|
||||
ip: ip.parse().expect("ip"),
|
||||
dev: Some("br-lan".to_string()),
|
||||
@@ -461,6 +595,15 @@ mod tests {
|
||||
vec!["192.168.1.10".parse::<std::net::IpAddr>().unwrap()]
|
||||
);
|
||||
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]
|
||||
@@ -586,6 +729,7 @@ mod tests {
|
||||
ips: vec![],
|
||||
macs: vec![],
|
||||
interfaces: vec![],
|
||||
endpoints: vec![],
|
||||
neighbors: vec![],
|
||||
leases: vec![],
|
||||
observations: vec![],
|
||||
@@ -616,6 +760,7 @@ mod tests {
|
||||
ips: vec!["192.168.1.10".parse().expect("ip")],
|
||||
macs: vec!["aa:bb:cc:dd:ee:01".parse().expect("mac")],
|
||||
interfaces: vec![],
|
||||
endpoints: vec![],
|
||||
neighbors: vec![],
|
||||
leases: vec![],
|
||||
observations: vec![DeviceObservationFact {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::helpers::core::*;
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
@@ -5,6 +6,8 @@ impl Store {
|
||||
&self,
|
||||
current: &[AlertState],
|
||||
) -> Result<Vec<AlertTransition>> {
|
||||
let mut tx = self.begin_write().await?;
|
||||
|
||||
let mut previous = std::collections::HashMap::<String, AlertState>::new();
|
||||
let rows = sqlx::query_as!(
|
||||
AlertStateRow,
|
||||
@@ -13,7 +16,7 @@ impl Store {
|
||||
value, threshold, last_seen_unix, metadata_json as "metadata_json!"
|
||||
FROM active_alerts"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.context("failed iterating active_alerts table")?;
|
||||
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")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
|
||||
@@ -115,11 +115,7 @@ impl Store {
|
||||
}
|
||||
|
||||
let expires_at = now_unix().saturating_add(seed_ttl.as_secs().max(1));
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting bootstrap token transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
let expires_at_i64 = i64::try_from(expires_at).context("token expiry overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
|
||||
@@ -11,11 +11,7 @@ impl Store {
|
||||
let device_id = format!("dev-{}", Uuid::new_v4());
|
||||
let now = now_unix();
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting known device transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
let pinned = if input.pinned { 1_i64 } else { 0_i64 };
|
||||
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
@@ -84,11 +80,7 @@ impl Store {
|
||||
|
||||
let now = now_unix();
|
||||
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting known device merge transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
|
||||
let target_exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
@@ -149,11 +141,7 @@ impl Store {
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let identifier = normalize_device_identifier(input)?;
|
||||
let now = now_unix();
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting device identifier transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
device_id
|
||||
@@ -188,11 +176,7 @@ impl Store {
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let now = now_unix();
|
||||
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting device identifier detach transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
device_id
|
||||
|
||||
@@ -2,11 +2,7 @@ use super::*;
|
||||
|
||||
impl Store {
|
||||
pub async fn enroll(&self, enroll_token: &str) -> Result<IssuedAgent> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting enroll transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
let expires_at_unix = sqlx::query_scalar!(
|
||||
"SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1",
|
||||
enroll_token
|
||||
@@ -102,11 +98,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub async fn revoke_agent(&self, agent_id: &str) -> Result<bool> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting revoke transaction")?;
|
||||
let mut tx = self.begin_write().await?;
|
||||
let result = sqlx::query!("DELETE FROM agents WHERE agent_id = ?1", agent_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
impl Store {
|
||||
pub async fn migrate_sqlite_state(
|
||||
@@ -12,71 +13,53 @@ impl Store {
|
||||
from_sqlite_state.display()
|
||||
);
|
||||
}
|
||||
if to_state_file.is_dir() {
|
||||
|
||||
// 1. Safety check for destination
|
||||
if to_state_file.exists() && !force {
|
||||
anyhow::bail!(
|
||||
"target state file {} is a directory",
|
||||
"target state file {} already exists; use --force to overwrite",
|
||||
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 {
|
||||
let mut bak = to_state_file.to_path_buf();
|
||||
let mut file_name = bak.file_name().unwrap_or_default().to_os_string();
|
||||
file_name.push(".bak");
|
||||
bak.set_file_name(file_name);
|
||||
// 2. Setup temporary migration target
|
||||
let mut temp_target = to_state_file.to_path_buf();
|
||||
let mut temp_name = temp_target.file_name().unwrap_or_default().to_os_string();
|
||||
temp_name.push(".migration_tmp");
|
||||
temp_target.set_file_name(temp_name);
|
||||
|
||||
if bak.exists() {
|
||||
if !force {
|
||||
anyhow::bail!(
|
||||
"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())
|
||||
})?;
|
||||
}
|
||||
if temp_target.exists() {
|
||||
std::fs::remove_file(&temp_target)
|
||||
.context("failed to clean up stale migration temp file")?;
|
||||
}
|
||||
|
||||
std::fs::rename(from_sqlite_state, &bak)
|
||||
.with_context(|| "failed to rename legacy state for in-place migration")?;
|
||||
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()
|
||||
};
|
||||
// 3. Initialize fresh schema on the temp file
|
||||
let store = Store::load_or_init(&temp_target, Vec::new(), Duration::from_secs(1)).await?;
|
||||
|
||||
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()
|
||||
.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))
|
||||
.execute(&store.pool)
|
||||
.execute(&mut *conn)
|
||||
.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 = [
|
||||
"meta",
|
||||
@@ -91,16 +74,48 @@ impl Store {
|
||||
];
|
||||
|
||||
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)
|
||||
.execute(&store.pool)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.with_context(|| format!("failed to migrate table {}", table))?;
|
||||
}
|
||||
|
||||
sqlx::query("DETACH DATABASE legacy")
|
||||
.execute(&store.pool)
|
||||
.await?;
|
||||
.execute(&mut *conn)
|
||||
.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!(
|
||||
from = %from_sqlite_state.display(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wakey_core::Presence;
|
||||
use wakey_core::{DeviceEndpoint, EndpointKey, EndpointSource, Presence};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IssuedAgent {
|
||||
@@ -134,6 +134,66 @@ pub struct AgentDeviceHostnameRow {
|
||||
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)]
|
||||
pub struct AgentDeviceFactRow {
|
||||
pub agent_id: String,
|
||||
@@ -147,10 +207,24 @@ pub struct AgentDeviceWithChildren {
|
||||
pub macs: Vec<macaddr::MacAddr>,
|
||||
pub ips: Vec<std::net::IpAddr>,
|
||||
pub hostnames: Vec<String>,
|
||||
pub endpoints: Vec<DeviceEndpoint>,
|
||||
#[allow(dead_code)]
|
||||
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)]
|
||||
pub struct AuditEvent {
|
||||
pub event_id: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
+135
-27
@@ -8,16 +8,16 @@ use crate::parse::mac;
|
||||
|
||||
/// 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.
|
||||
#[derive(
|
||||
Debug, PartialEq, Eq, Clone, Copy, Hash, Ord, PartialOrd, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Presence {
|
||||
Offline,
|
||||
#[default]
|
||||
Unknown,
|
||||
Offline,
|
||||
LikelyOnline,
|
||||
Online,
|
||||
}
|
||||
@@ -71,11 +71,80 @@ pub enum DeviceId {
|
||||
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.
|
||||
///
|
||||
/// These are intentionally source-shaped and non-durable. They preserve details
|
||||
/// from hooks and live inventory so higher layers can explain why a device looks
|
||||
/// online, stale, or unknown without reverse-engineering flattened fields.
|
||||
/// from hooks (arp, dhcp, etc) so higher layers can explain why a device looks
|
||||
/// online, stale, or unknown.
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct DeviceObservationFact {
|
||||
@@ -102,6 +171,8 @@ pub struct Device {
|
||||
#[serde(with = "mac::vec_mac")]
|
||||
pub macs: Vec<MacAddr>,
|
||||
pub interfaces: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub endpoints: Vec<DeviceEndpoint>,
|
||||
pub neighbors: Vec<NeighborEntry>,
|
||||
pub leases: Vec<DhcpLease>,
|
||||
pub observations: Vec<DeviceObservationFact>,
|
||||
@@ -126,6 +197,7 @@ impl Device {
|
||||
let mut ips = BTreeSet::new();
|
||||
let mut macs = BTreeSet::new();
|
||||
let mut interfaces = BTreeSet::new();
|
||||
let mut endpoints = Vec::new();
|
||||
let mut presence = Presence::Unknown;
|
||||
|
||||
for lease in &leases {
|
||||
@@ -134,6 +206,18 @@ impl Device {
|
||||
if let Some(name) = lease.name.as_deref() {
|
||||
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 {
|
||||
ips.insert(neighbor.ip);
|
||||
@@ -143,46 +227,61 @@ impl Device {
|
||||
if let Some(dev) = neighbor.dev.as_deref() {
|
||||
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 {
|
||||
if let Some(name) = observation.hostname.as_deref() {
|
||||
names.insert(name);
|
||||
}
|
||||
if let Some(ip) = observation.ip {
|
||||
ips.insert(ip);
|
||||
}
|
||||
if let Some(mac) = observation.mac {
|
||||
macs.insert(mac);
|
||||
}
|
||||
if observation.action != "remove" {
|
||||
observed_non_remove = true;
|
||||
if let Some(source) = observation_endpoint_source(observation)
|
||||
&& let Some(key) = EndpointKey::new(source, observation.mac, observation.ip)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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 macs: Vec<MacAddr> = macs.into_iter().collect();
|
||||
let ips: Vec<IpAddr> = ips.into_iter().collect();
|
||||
let id = macs
|
||||
.first()
|
||||
.copied()
|
||||
.map(DeviceId::Mac)
|
||||
.or_else(|| ips.first().copied().map(DeviceId::Ip));
|
||||
let id = macs.first().copied().map(DeviceId::Mac).or_else(|| {
|
||||
endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| endpoint.key.ip)
|
||||
.min()
|
||||
.map(DeviceId::Ip)
|
||||
});
|
||||
Self {
|
||||
id,
|
||||
names: names.into_iter().map(|n| n.to_owned()).collect(),
|
||||
ips,
|
||||
macs,
|
||||
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
|
||||
endpoints,
|
||||
neighbors,
|
||||
leases,
|
||||
observations,
|
||||
@@ -193,14 +292,23 @@ impl Device {
|
||||
|
||||
fn observation_presence(observation: &DeviceObservationFact) -> Presence {
|
||||
match (observation.kind.as_str(), observation.action.as_str()) {
|
||||
(_, "remove") => Presence::Offline,
|
||||
("neigh", "remove") => Presence::Offline,
|
||||
("neigh", "add" | "update" | "old") => Presence::LikelyOnline,
|
||||
_ => 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.
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInventory {
|
||||
#[serde(default)]
|
||||
pub devices: Vec<Device>,
|
||||
}
|
||||
@@ -5,7 +5,10 @@ mod neighbor;
|
||||
mod query;
|
||||
mod wake;
|
||||
|
||||
pub use device::{Device, DeviceId, DeviceInventory, DeviceObservationFact, Presence};
|
||||
pub use device::{
|
||||
AgentEndpointKey, Device, DeviceEndpoint, DeviceId, DeviceInventory, DeviceObservationFact,
|
||||
EndpointKey, EndpointSource, Presence,
|
||||
};
|
||||
#[allow(deprecated)]
|
||||
pub use dhcp::LeaseQuery;
|
||||
pub use dhcp::{DhcpLease, DhcpLeaseWithState};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -161,6 +161,38 @@ pub async fn prune_removed_observations_from_path(path: impl AsRef<Path>) -> io:
|
||||
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> {
|
||||
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
||||
out.extend(
|
||||
@@ -235,11 +267,11 @@ pub async fn observe_dhcp_event(
|
||||
row.hostname = hostname.clone();
|
||||
row.last_action = action.to_string();
|
||||
row.last_seen_unix = now;
|
||||
changed = true;
|
||||
changed = true; // update row
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
changed = true;
|
||||
changed = true; // insert new row
|
||||
ObservedDhcpClient {
|
||||
mac: mac_s.clone(),
|
||||
ip,
|
||||
@@ -258,7 +290,7 @@ pub async fn observe_dhcp_event(
|
||||
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
|
||||
cache.insert(mac_s, hostname);
|
||||
save_mac_name_cache(&cache).await?;
|
||||
changed = true;
|
||||
changed = true; // update mac -> name cache...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +324,7 @@ pub async fn observe_neighbor_event(
|
||||
.find_map(|alias| store.neighbors.remove(alias))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
changed = true;
|
||||
changed = true; // append
|
||||
ObservedNeighbor {
|
||||
key: key.clone(),
|
||||
mac: mac.clone(),
|
||||
@@ -304,7 +336,7 @@ pub async fn observe_neighbor_event(
|
||||
});
|
||||
for alias in alias_keys {
|
||||
if store.neighbors.remove(&alias).is_some() {
|
||||
changed = true;
|
||||
changed = true; // remove old alias
|
||||
}
|
||||
}
|
||||
if row.key != key
|
||||
@@ -318,7 +350,7 @@ pub async fn observe_neighbor_event(
|
||||
row.ip = ip;
|
||||
row.last_action = action.clone();
|
||||
row.last_seen_unix = now;
|
||||
changed = true;
|
||||
changed = true; // something changed
|
||||
}
|
||||
store.neighbors.insert(key, row);
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
// found another ip for same mac, mark it as removed
|
||||
if row.mac.as_deref() == Some(mac)
|
||||
&& row_ip != current_ip
|
||||
&& same_ip_family(row_ip, current_ip)
|
||||
@@ -412,7 +445,7 @@ mod tests {
|
||||
use serial_test::serial;
|
||||
|
||||
struct EnvGuard {
|
||||
keys: Vec<&'static str>,
|
||||
keys: Vec<&'static str>, // vec of 1 key
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
@@ -576,4 +609,56 @@ mod tests {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user