Compare commits
13
Commits
v0.2.1
..
23656cd6d7
No files matched your search
@@ -16,6 +16,10 @@ cw = "c --target=x86_64-pc-windows-msvc"
|
||||
bw = "b --target=x86_64-pc-windows-msvc"
|
||||
tw = "t --target=x86_64-pc-windows-msvc"
|
||||
|
||||
check-all = "check --workspace --all-features --all-targets"
|
||||
clippy-all = "clippy --workspace --all-features --all-targets"
|
||||
fmt-all = "fmt --all"
|
||||
|
||||
# idk below
|
||||
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
||||
t = "test -- --nocapture --test-threads=1"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
SQLX_OFFLINE=true
|
||||
@@ -0,0 +1,184 @@
|
||||
# Plan: Observation Store And Sync V1
|
||||
|
||||
## Summary
|
||||
|
||||
Build device observation support in layers:
|
||||
|
||||
1. Local router observations are captured by `wakey observe ...` from OpenWrt hotplug.
|
||||
2. `wakey-linux` stores current observed DHCP/neigh state locally.
|
||||
3. `wakey-agent` forwards compact observations to the control plane.
|
||||
4. The control plane stores observations per agent and later joins them to known devices through `device_identifiers`.
|
||||
|
||||
This is separate from durable identity. Observations are facts an agent saw at a time; known devices are manual/user-approved identity records.
|
||||
|
||||
## Network/API Boundary
|
||||
|
||||
The control plane has two route classes:
|
||||
|
||||
- Public agent routes: intended to be reachable by agents.
|
||||
- Protected control routes: intended to sit behind Cloudflare Access/admin auth.
|
||||
|
||||
Observation upload belongs on the public agent route surface because routers/agents must call it:
|
||||
|
||||
```text
|
||||
POST /api/v1/agents/observations
|
||||
```
|
||||
|
||||
The endpoint must require normal agent authentication, using the same persistent `agent_id`/`agent_token` trust model as WebSocket auth. It must not be a Cloudflare Access-only admin endpoint.
|
||||
|
||||
Admin/UI read APIs belong under protected control routes:
|
||||
|
||||
```text
|
||||
GET /api/v1/control/observations
|
||||
GET /api/v1/control/devices
|
||||
```
|
||||
|
||||
## Step 1: Local Observation Store
|
||||
|
||||
Replace the narrow `/tmp/wakey_mac_names.json` cache with a local observation store owned by `wakey-linux`.
|
||||
|
||||
Minimum local tables/state:
|
||||
|
||||
```text
|
||||
observed_dhcp_clients:
|
||||
mac
|
||||
ip
|
||||
hostname
|
||||
first_seen_unix
|
||||
last_seen_unix
|
||||
last_action
|
||||
|
||||
observed_neighbors:
|
||||
key
|
||||
mac
|
||||
ip
|
||||
first_seen_unix
|
||||
last_seen_unix
|
||||
last_action
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Hotplug scripts stay minimal and call `wakey-agent observe ...`.
|
||||
- `wakey-agent observe ...` delegates to `wakey observe ...`.
|
||||
- `wakey observe dhcp ...` writes DHCP observations.
|
||||
- `wakey observe neigh ...` writes neighbor observations.
|
||||
- Existing `wakey leases` and `wakey inventory` still read live sources first, then enrich from the local observation store.
|
||||
- Do not make local queries depend exclusively on hotplug events yet.
|
||||
|
||||
## Step 2: Control-Plane Observation Tables
|
||||
|
||||
Add observation tables early so future API/UI work does not need another major state refactor.
|
||||
|
||||
Control-plane schema:
|
||||
|
||||
```text
|
||||
agent_device_observations:
|
||||
observation_key TEXT PRIMARY KEY
|
||||
agent_id TEXT NOT NULL
|
||||
kind TEXT NOT NULL
|
||||
mac TEXT
|
||||
ip TEXT
|
||||
hostname TEXT
|
||||
first_seen_unix INTEGER NOT NULL
|
||||
last_seen_unix INTEGER NOT NULL
|
||||
last_action TEXT NOT NULL
|
||||
|
||||
agent_device_observation_events:
|
||||
event_id TEXT PRIMARY KEY
|
||||
agent_id TEXT NOT NULL
|
||||
kind TEXT NOT NULL
|
||||
action TEXT NOT NULL
|
||||
mac TEXT
|
||||
ip TEXT
|
||||
hostname TEXT
|
||||
ts_unix INTEGER NOT NULL
|
||||
```
|
||||
|
||||
Keep `agent_device_observation_events` optional in behavior if needed, but create it in schema early. Current-state rows are the primary path; event history is for debugging/audit.
|
||||
|
||||
Indexes:
|
||||
|
||||
```text
|
||||
agent_device_observations(agent_id)
|
||||
agent_device_observations(mac)
|
||||
agent_device_observations(ip)
|
||||
agent_device_observations(hostname)
|
||||
agent_device_observations(last_seen_unix)
|
||||
agent_device_observation_events(agent_id, ts_unix)
|
||||
agent_device_observation_events(mac)
|
||||
```
|
||||
|
||||
## Step 3: Agent Upload
|
||||
|
||||
Add agent upload payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"observations": [
|
||||
{
|
||||
"kind": "dhcp",
|
||||
"action": "update",
|
||||
"mac": "04:7c:16:79:6d:ee",
|
||||
"ip": "192.168.100.94",
|
||||
"hostname": "lda",
|
||||
"first_seen_unix": 1770000000,
|
||||
"last_seen_unix": 1770000123
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Agent may send snapshots periodically and after local observe events.
|
||||
- Control plane upserts current-state rows by stable observation key.
|
||||
- Observation keys should be deterministic from `agent_id`, `kind`, and the best available identifier:
|
||||
- DHCP with MAC: `agent:{agent_id}:dhcp:mac:{mac}`
|
||||
- Neigh with MAC: `agent:{agent_id}:neigh:mac:{mac}`
|
||||
- Neigh without MAC: `agent:{agent_id}:neigh:ip:{ip}`
|
||||
- Uploads must not create known devices automatically.
|
||||
|
||||
## Step 4: Join Observations To Known Devices
|
||||
|
||||
Known devices already have durable IDs and manual identifiers:
|
||||
|
||||
```text
|
||||
known_devices
|
||||
device_identifiers
|
||||
```
|
||||
|
||||
Join rule:
|
||||
|
||||
```text
|
||||
agent_device_observations.mac
|
||||
-> device_identifiers(kind = 'mac', value = mac)
|
||||
-> known_devices.device_id
|
||||
```
|
||||
|
||||
Unknown observations are rows that do not match any manual `device_identifiers` row.
|
||||
|
||||
This enables:
|
||||
|
||||
- same known device observed by multiple agents;
|
||||
- same known device with multiple MACs;
|
||||
- UI flow to attach an unknown observed MAC to an existing known device;
|
||||
- wake flows that choose agent-local observed IP/MAC context for a known device.
|
||||
|
||||
## Step 5: UI/API Later
|
||||
|
||||
After storage and upload exist:
|
||||
|
||||
- show known devices with all matching observations grouped by agent;
|
||||
- show unknown observations;
|
||||
- add action: attach observation identifier to known device;
|
||||
- add action: create known device from observation;
|
||||
- add wake action from known device using a selected agent/observation.
|
||||
|
||||
## Defaults
|
||||
|
||||
- `wakey` remains the local router/debugging CLI.
|
||||
- `wakey-agent` is the sync bridge to the control plane.
|
||||
- `wakey-control-plane` is durable identity and multi-agent view.
|
||||
- Local observation store is not authoritative; live sources still matter.
|
||||
- Control-plane observations are not durable identity; only manual known-device identifiers are.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Plan: SQLx Query Macro Adoption
|
||||
|
||||
## Summary
|
||||
|
||||
Move control-plane SQL from unchecked `sqlx::query(...)` calls toward `sqlx::query!` / `query_as!` where the SQL shape is static.
|
||||
|
||||
This gives compile-time checking for table/column names and Rust value types. Dynamic filter builders can stay on `QueryBuilder`.
|
||||
|
||||
## Offline Setup
|
||||
|
||||
Normal builds should not need a live database:
|
||||
|
||||
```text
|
||||
SQLX_OFFLINE=true
|
||||
```
|
||||
|
||||
`.env` is ignored, so copy:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
SQLx query metadata should be generated into `.sqlx/` and committed.
|
||||
|
||||
Prepare metadata with:
|
||||
|
||||
```sh
|
||||
cargo install sqlx-cli --no-default-features --features sqlite
|
||||
./scripts/prepare_sqlx_sqlite.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- creates a temporary SQLite database;
|
||||
- runs `wakey-control-plane/migrations`;
|
||||
- runs `cargo sqlx prepare --workspace -- -p wakey-control-plane --all-targets --all-features`;
|
||||
- writes `.sqlx/`.
|
||||
|
||||
## Conversion Rules
|
||||
|
||||
Use `query!` / `query_as!` for static SQL:
|
||||
|
||||
- inserts;
|
||||
- deletes;
|
||||
- simple selects by primary key;
|
||||
- fixed update statements;
|
||||
- count queries.
|
||||
|
||||
Keep non-macro SQL for genuinely dynamic SQL:
|
||||
|
||||
- audit event filters built with `QueryBuilder`;
|
||||
- observation listing with optional filters, unless split into fixed branches;
|
||||
- any SQL where table/column names are intentionally generated.
|
||||
|
||||
## Test/CI
|
||||
|
||||
After query macros are introduced, CI should add:
|
||||
|
||||
```sh
|
||||
cargo install sqlx-cli --no-default-features --features sqlite
|
||||
./scripts/prepare_sqlx_sqlite.sh
|
||||
cargo sqlx prepare --check --workspace -- --all-targets --all-features
|
||||
```
|
||||
|
||||
Do not add the CI check until the first `.sqlx/` metadata is committed, otherwise it adds dependency install cost without catching anything.
|
||||
|
||||
## Notes
|
||||
|
||||
The SQLx docs say offline mode needs `cargo sqlx prepare` output checked into version control, and that `DATABASE_URL` takes precedence unless `SQLX_OFFLINE=true` is set. Keep `.env` local; commit `.env.example` and `.sqlx/`.
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# When using cargo-zigbuild for *-gnu targets, link against this glibc baseline (wider VPS compatibility).
|
||||
WAKEY_CC_GLIBC_VERSION: "2.28"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -23,7 +19,7 @@ jobs:
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf,x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
|
||||
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -35,14 +31,8 @@ jobs:
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Ensure toolchain and targets
|
||||
shell: pwsh
|
||||
run: |
|
||||
rustc -V
|
||||
cargo -V
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
- name: Install cross
|
||||
run: cargo install cross --locked
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -53,10 +43,9 @@ jobs:
|
||||
target
|
||||
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build (armv7-musl)
|
||||
shell: pwsh
|
||||
- name: Build (armv7-musl with cross)
|
||||
run: |
|
||||
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||
|
||||
- name: Build UI dist
|
||||
shell: bash
|
||||
@@ -66,29 +55,17 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Build control-plane (linux-gnu; zig + older glibc when available)
|
||||
- name: Build control-plane (linux-gnu)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
|
||||
GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
|
||||
|
||||
use_zig=false
|
||||
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
|
||||
use_zig=true
|
||||
fi
|
||||
# x86_64 builds natively; aarch64 needs cross
|
||||
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
|
||||
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
|
||||
|
||||
if [ "$use_zig" = true ]; then
|
||||
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
|
||||
for base in "${GNU_TARGETS[@]}"; do
|
||||
cargo zigbuild --release --target "${base}.${GLIBC_VER}" -p wakey-control-plane
|
||||
done
|
||||
else
|
||||
echo "zig or cargo zigbuild not available: using plain cargo build (binary needs glibc >= host)"
|
||||
for base in "${GNU_TARGETS[@]}"; do
|
||||
cargo build --release --target "$base" -p wakey-control-plane
|
||||
done
|
||||
fi
|
||||
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
|
||||
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
|
||||
|
||||
- name: Package rootfs tarball
|
||||
shell: pwsh
|
||||
|
||||
@@ -18,3 +18,5 @@ target/
|
||||
#.idea/
|
||||
|
||||
.env
|
||||
|
||||
.codex
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "020b33576bfc2d171a051f903d40ccc78c972fa9d5d983a5678f03abbc8b8488"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "07d7f283daba2742e5522abf99074171ed484c03f70138be611e0c4b17e311b8"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT 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": "0c47aade9d5c71d6c7e2023c6ff66c6ab4b3a0dd2b78af3a600dedc89da508f2"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) as \"count!: i64\" FROM enroll_tokens",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0ca58d0cbc25c59d2c956222d41c02eb0c76a135f29a56a7570b750f7378738d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)\n ON CONFLICT(agent_id) DO UPDATE SET nickname = excluded.nickname",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "181992e554afdd8f21bd5df71bfb7880a5a9de28af8cd0c766d8d4aa649b3c90"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n ORDER BY pinned DESC, display_name, device_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "device_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "created_at_unix",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "updated_at_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "notes",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "19b5fc20ffc99589bbaf11b94661f7c0dfcf80748c60f8f3289bb3296114635a"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "21e5986ad3e8efd3393f2bc4929ee79e664cbc2b55948c276d52ca2a6fdc6a48"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO known_devices\n (device_id, display_name, pinned, created_at_unix, updated_at_unix, notes)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2bff8b8b070f5b5dd946e754c5e15ea405f6c29baa4c2c31347f455227437ae2"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT 1 as \"ok!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "ok!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2c0dd5d9be90cc716da219c75c2d3b75dae8c0902c64ef5baa96c902f23abc5d"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE agent_id = ?1\n ORDER BY last_seen_unix DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "305f50ab2691e12c74e8a222ef12a16f14a65edd7b7c5400a196fd57a9788dcf"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mac, ip\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3b06698566cea17eed9d10a389ee12a95cee2db6c3d450dca7a3b2d08e8fed7b"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "409e1e7aa2d9bc46030001e5c522d7c2812b4583dceadfe5c535a866ac76489c"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) as \"count!: i64\" FROM enroll_tokens WHERE expires_at_unix <= ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5083fd5878c57f4d54e8b24ad0cef2771f7cac36afb7c370c65137fc0784f093"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agents WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "51cb6ec47368803af60114a39dd9e70ba9d31946bc39980c9e85dbc4e842a2c8"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "52d22161e936b39b57d119aab592426e88f60387ccba58888cc7d440012777ed"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "expires_at_unix",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "540f57604aa987b97930bb0bcfe440094d97b769fd758fd5cffc32f6d5716bb4"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n ORDER BY last_seen_unix DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5a0f37b5da194e432c795972d5b3bf67c2d2a00f379143d1ae5871ea790d1bf2"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "60c6631ace995d7ca109d1bee1d878c823edd40f15f8571b42e00680a120ad74"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM active_alerts",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "72afd48584f822116318c623f7dd42ddf4c82b337d8a3d52ed5973d52e08c163"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO device_identifiers\n (identifier_key, device_id, kind, value, created_at_unix)\n VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7dc7edbbe5539f89783cbccb0475e676cb6d369b676b0c5aaedf99854ffbd760"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT token as \"token!\", expires_at_unix FROM enroll_tokens ORDER BY expires_at_unix, token",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "token!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "expires_at_unix",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7f6c5c108953cd4eda8f4e1d4f4db6b3b88270d3d7087b2948362d0ea94ad246"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM agent_meta WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8bf8470846bd228d2ea9b243f70f946f002915b9b1983bce818abc0ae3dd97f0"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM enroll_tokens WHERE token = ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8f3499a2f461f738711b9942a9c9d6254c1f7ed0db54db8b3d65e10fa8dadfc9"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "event_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "observation_key!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "action!",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 11,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,\n last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE observation_key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "mac",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ip",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "first_seen_unix",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_action!",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92775bf2e3b9f772d108bb8d0cd1aebbe01357ed70dc2af7b50ee348e668b319"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agents.agent_id as \"agent_id!\", agent_meta.nickname\n FROM agents\n LEFT JOIN agent_meta ON agent_meta.agent_id = agents.agent_id\n ORDER BY agents.agent_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "nickname",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "977adb8bdfb9e031a738055fb81c02c6180ca9c37c90e39b3e4efb9168bd1944"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) as \"count!: i64\" FROM meta WHERE key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9f5d340530083d040794bed2a45b01522f57dc4132184dab0eb32d1b6811992d"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n WHERE device_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "device_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_name!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pinned",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "created_at_unix",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "updated_at_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "notes",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9fe9f41d6716305ffebc764a72a3aea11e4ba348fae664aa6ba4bffbb3a2bf34"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT device_id FROM device_identifiers WHERE identifier_key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "device_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a1bca2de4e1328fa066d5a8703ef5b8bda560b234b9f1e75093827efeac10eec"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT transition_id as \"transition_id!\", ts_unix, alert_id as \"alert_id!\",\n kind as \"kind!\", agent_id, from_status, to_status as \"to_status!\",\n message as \"message!\", metadata_json as \"metadata_json!\"\n FROM alert_transitions\n ORDER BY transition_key DESC\n LIMIT ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "transition_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "alert_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "from_status",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "to_status!",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "message!",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "metadata_json!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a34accb5d8ef1fc90e1029b808956c85fa7cb00c0e879f27bbb99b5a76b5e151"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_id as \"agent_id!\" FROM agents ORDER BY agent_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a5baac2d2f6dc38946ff15f6cba46ac085780e6521ba23a605a78b4b92a62073"
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT alert_id as \"alert_id!\", kind as \"kind!\", severity as \"severity!\",\n status as \"status!\", agent_id, message as \"message!\",\n value, threshold, last_seen_unix, metadata_json as \"metadata_json!\"\n FROM active_alerts",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "alert_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "severity!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "status!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "message!",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "threshold",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_seen_unix",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "metadata_json!",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b031d0fa0f9d0640555fc78b400b7bb951a3f013715fb5be51030c2be63e864d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM known_devices WHERE device_id = ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1c6150ef7bd67ce78f8934fc5d451b36f5bed834d39bdde01356e331c5e5ddb"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT value FROM meta WHERE key = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "value",
|
||||
"ordinal": 0,
|
||||
"type_info": "Blob"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b805f8dada38af1c79a7b3bbc063fd949bf81757705ace7173658de9f185868b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bee62978a2ce59095f9bc679258b5747e074a55d52c43b982ea019c79aff1479"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT transition_id as \"transition_id!\", ts_unix, alert_id as \"alert_id!\",\n kind as \"kind!\", agent_id, from_status, to_status as \"to_status!\",\n message as \"message!\", metadata_json as \"metadata_json!\"\n FROM alert_transitions\n WHERE ts_unix >= ?1\n ORDER BY transition_key DESC\n LIMIT ?2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "transition_id!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "ts_unix",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "alert_id!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "agent_id",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "from_status",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "to_status!",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "message!",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "metadata_json!",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ca117e134d45464a8930d0483ff8670d2798f38718c67f49adce06f74656bf2c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d2c2ce1761d22477164b3e5cfde9718877b3b472886ae1dc7e64dc36ad75b2ea"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT identifier_key as \"identifier_key!\", device_id as \"device_id!\",\n kind as \"kind!\", value as \"value!\", created_at_unix\n FROM device_identifiers\n WHERE device_id = ?1\n ORDER BY kind, value",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "identifier_key!",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "device_id!",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "kind!",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "value!",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "created_at_unix",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dd1d6ba80d6380cb07227eac0b90951c93f63dc4c89349cf1920405943e2093a"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT agent_token FROM agents WHERE agent_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "agent_token",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e0def02317412d21994af8d7770a9e717f4be97e022c0f2022983d59391c775b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM enroll_tokens WHERE expires_at_unix <= ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ee6d28dc501f6bb8cf1249ad03b82e3554f1167d5056f02a0cc823601f480e53"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) as \"count!: i64\" FROM known_devices WHERE device_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f18ab386ab1961e938ae7fec3e752bdd958118aa4ca81cab3421ac5827b04029"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO meta (key, value) VALUES (?1, ?2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ffed939cfeecca1d46bdf77b67aca2919b34d496e729ad08f87ac9782fb8e382"
|
||||
}
|
||||
Generated
+759
-7
File diff suppressed because it is too large.
Load diff
@@ -125,7 +125,7 @@ Example:
|
||||
data_dir = "/var/lib/wakey-control-plane"
|
||||
bind = "0.0.0.0:8080"
|
||||
public_url = "https://cp.example.com"
|
||||
state_file = "state.db"
|
||||
state_file = "state.sqlite3"
|
||||
pid_file = "wakey-control-plane.pid"
|
||||
ui_dist_dir = "/opt/wakey/ui/dist"
|
||||
command_timeout_ms = 30000
|
||||
@@ -140,9 +140,14 @@ json_logs = false
|
||||
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
|
||||
structured logs are emitted.
|
||||
|
||||
State is persisted in an embedded `sled` database (default
|
||||
`/var/lib/wakey-control-plane/state.db`).
|
||||
State is persisted in an embedded SQLite database (default
|
||||
`/var/lib/wakey-control-plane/state.sqlite3`).
|
||||
Relative paths in config are resolved under `data_dir`.
|
||||
Legacy sled state can be imported explicitly:
|
||||
|
||||
```sh
|
||||
wakey-control-plane import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /var/lib/wakey-control-plane/state.sqlite3
|
||||
```
|
||||
|
||||
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
||||
Expired tokens are rejected on enroll and can be garbage-collected periodically
|
||||
|
||||
@@ -68,8 +68,12 @@ try {
|
||||
$initDestTmp = "$User@${HostName}:$initTmp"
|
||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
||||
$localDhcpHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/dhcp/95-wakey'
|
||||
$localNeighHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/neigh/95-wakey'
|
||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||
$dhcpHotplugTmp = '/var/tmp/wakey.hotplug.dhcp.tmp'
|
||||
$neighHotplugTmp = '/var/tmp/wakey.hotplug.neigh.tmp'
|
||||
|
||||
# Push binaries
|
||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
@@ -79,6 +83,12 @@ try {
|
||||
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
||||
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
if (Test-Path $localDhcpHotplug) {
|
||||
Invoke-Scp -Local $localDhcpHotplug -Dest "$User@${HostName}:$dhcpHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
if (Test-Path $localNeighHotplug) {
|
||||
Invoke-Scp -Local $localNeighHotplug -Dest "$User@${HostName}:$neighHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
|
||||
# Push deploy helper if exists
|
||||
if (Test-Path $localDeploy) {
|
||||
@@ -103,6 +113,16 @@ if [ -f $initTmp ]; then
|
||||
fi
|
||||
"@
|
||||
})
|
||||
if [ -f $dhcpHotplugTmp ]; then
|
||||
sed -i "s/\r$//" $dhcpHotplugTmp 2>/dev/null || true
|
||||
mkdir -p /etc/hotplug.d/dhcp
|
||||
cp -f $dhcpHotplugTmp /etc/hotplug.d/dhcp/95-wakey
|
||||
fi
|
||||
if [ -f $neighHotplugTmp ]; then
|
||||
sed -i "s/\r$//" $neighHotplugTmp 2>/dev/null || true
|
||||
mkdir -p /etc/hotplug.d/neigh
|
||||
cp -f $neighHotplugTmp /etc/hotplug.d/neigh/95-wakey
|
||||
fi
|
||||
"@
|
||||
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
||||
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
|
||||
BIN="/root/.bin/wakey-agent"
|
||||
[ -x "$BIN" ] || BIN="$(command -v wakey-agent 2>/dev/null)"
|
||||
[ -n "$BIN" ] || exit 0
|
||||
[ -n "$ACTION" ] || exit 0
|
||||
[ -n "$MACADDR" ] || exit 0
|
||||
|
||||
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||
elif [ -n "$IPADDR" ]; then
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||
elif [ -n "$HOSTNAME" ]; then
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||
else
|
||||
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
BIN="/root/.bin/wakey-agent"
|
||||
[ -x "$BIN" ] || BIN="$(command -v wakey-agent 2>/dev/null)"
|
||||
[ -n "$BIN" ] || exit 0
|
||||
[ -n "$ACTION" ] || exit 0
|
||||
|
||||
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
|
||||
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||
elif [ -n "$MACADDR" ]; then
|
||||
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||
elif [ -n "$IPADDR" ]; then
|
||||
"$BIN" observe neigh --action "$ACTION" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||
else
|
||||
"$BIN" observe neigh --action "$ACTION" >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -71,6 +71,21 @@ Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
|
||||
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
# Copy OpenWrt hotplug hooks.
|
||||
$hotplugSrc = Join-Path $root 'scripts/hotplug/openwrt'
|
||||
if (Test-Path $hotplugSrc) {
|
||||
Get-ChildItem $hotplugSrc -Directory | ForEach-Object {
|
||||
$hotplugTypeDir = Join-Path $staging ("etc/hotplug.d/" + $_.Name)
|
||||
New-Item -ItemType Directory -Force -Path $hotplugTypeDir | Out-Null
|
||||
Get-ChildItem $_.FullName -File | ForEach-Object {
|
||||
$dest = Join-Path $hotplugTypeDir $_.Name
|
||||
$content = Get-Content -Raw -LiteralPath $_.FullName
|
||||
$content = $content -replace "`r`n", "`n"
|
||||
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Copy helper scripts intended for /etc/ldlda_help
|
||||
$helpSrc = Join-Path $root 'scripts/ldlda_help'
|
||||
if (Test-Path $helpSrc) {
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
|
||||
DATABASE_URL="sqlite://$DB"
|
||||
|
||||
if ! cargo sqlx --version >/dev/null 2>&1; then
|
||||
echo "cargo-sqlx is not installed." >&2
|
||||
echo "Install it with: cargo install sqlx-cli --no-default-features --features sqlite" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$DB" "$DB-shm" "$DB-wal"
|
||||
|
||||
cd "$ROOT"
|
||||
DATABASE_URL="$DATABASE_URL" cargo sqlx database create
|
||||
DATABASE_URL="$DATABASE_URL" cargo sqlx migrate run --source wakey-control-plane/migrations
|
||||
DATABASE_URL="$DATABASE_URL" cargo sqlx prepare --workspace -- -p wakey-control-plane --all-targets --all-features
|
||||
|
||||
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||
@@ -33,6 +33,8 @@ pub enum Command {
|
||||
Wake(WakeArgs),
|
||||
/// Show condensed network interface summaries.
|
||||
Devs(DevsArgs),
|
||||
/// Record local hotplug observations.
|
||||
Observe(ObserveArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -116,6 +118,42 @@ pub struct DevsArgs {
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: ObserveCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ObserveCommand {
|
||||
/// Observe a dnsmasq DHCP lease event.
|
||||
Dhcp(ObserveDhcpArgs),
|
||||
/// Observe a neighbor-table event.
|
||||
Neigh(ObserveNeighArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveDhcpArgs {
|
||||
#[arg(long)]
|
||||
pub action: String,
|
||||
#[arg(long)]
|
||||
pub mac: macaddr::MacAddr,
|
||||
#[arg(long)]
|
||||
pub ip: Option<IpAddr>,
|
||||
#[arg(long)]
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveNeighArgs {
|
||||
#[arg(long)]
|
||||
pub action: String,
|
||||
#[arg(long)]
|
||||
pub mac: Option<macaddr::MacAddr>,
|
||||
#[arg(long)]
|
||||
pub ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
pub fn init_tracing(verbose: u8) {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
|
||||
@@ -206,6 +244,23 @@ pub async fn run(cli: Cli) -> Result<()> {
|
||||
println!("{}", table::render_devs_table(&devs));
|
||||
}
|
||||
}
|
||||
Command::Observe(args) => match args.command {
|
||||
ObserveCommand::Dhcp(args) => {
|
||||
let changed = wakey::observe_dhcp_event(
|
||||
&args.action,
|
||||
args.mac,
|
||||
args.ip,
|
||||
args.hostname.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
println!("observed=dhcp changed={changed}");
|
||||
}
|
||||
ObserveCommand::Neigh(args) => {
|
||||
let changed =
|
||||
wakey::observe_neighbor_event(&args.action, args.mac, args.ip).await?;
|
||||
println!("observed=neigh changed={changed}");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
|
||||
pub mod service;
|
||||
pub mod utils;
|
||||
pub use wakey_linux;
|
||||
|
||||
pub use service::{
|
||||
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
|
||||
inventory, leases_without_state, merge_devices, resolve_devices, resolve_query,
|
||||
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||
};
|
||||
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -103,7 +103,7 @@ const fn presence_rank(presence: Presence) -> u8 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use wakey_core::{DhcpLease, InventoryQueryBuilder, NeighborState};
|
||||
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
|
||||
|
||||
fn sample_neighbors() -> Vec<NeighborEntry> {
|
||||
vec![NeighborEntry {
|
||||
@@ -154,4 +154,25 @@ mod tests {
|
||||
let out = merge_devices(sample_neighbors(), sample_leases(), &query);
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_uses_ip_observed_id_when_mac_is_absent() {
|
||||
let query = InventoryQueryBuilder::new().build();
|
||||
let out = merge_devices(
|
||||
vec![NeighborEntry {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
|
||||
dev: Some("br-lan".to_string()),
|
||||
mac: None,
|
||||
state: NeighborState::Stale,
|
||||
}],
|
||||
Vec::new(),
|
||||
&query,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(
|
||||
out[0].id,
|
||||
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { AuditPage } from "@/pages/AuditPage";
|
||||
import { CommandsPage } from "@/pages/CommandsPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { ObservationsPage } from "@/pages/ObservationsPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
@@ -151,6 +152,7 @@ export function App() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="observations" element={<ObservationsPage />} />
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
|
||||
+116
@@ -72,6 +72,56 @@ export type SetAgentNicknameResponse = {
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
export type DeviceIdentifier = {
|
||||
identifier_key: string;
|
||||
device_id: string;
|
||||
kind: string;
|
||||
value: string;
|
||||
created_at_unix: number;
|
||||
};
|
||||
|
||||
export type KnownDevice = {
|
||||
device_id: string;
|
||||
display_name: string;
|
||||
pinned: boolean;
|
||||
created_at_unix: number;
|
||||
updated_at_unix: number;
|
||||
notes: string | null;
|
||||
identifiers: DeviceIdentifier[];
|
||||
};
|
||||
|
||||
export type KnownDeviceSummary = {
|
||||
device_id: string;
|
||||
display_name: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
export type AgentDeviceObservation = {
|
||||
observation_key: string;
|
||||
agent_id: string;
|
||||
kind: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
first_seen_unix: number;
|
||||
last_seen_unix: number;
|
||||
last_action: string;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
export type AgentDeviceObservationEvent = {
|
||||
event_id: string;
|
||||
observation_key: string;
|
||||
agent_id: string;
|
||||
kind: string;
|
||||
action: string;
|
||||
mac: string | null;
|
||||
ip: string | null;
|
||||
hostname: string | null;
|
||||
ts_unix: number;
|
||||
known_device: KnownDeviceSummary | null;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
@@ -154,6 +204,72 @@ export function setAgentNickname(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchKnownDevices(): Promise<KnownDevice[]> {
|
||||
return request<KnownDevice[]>("/api/v1/control/devices");
|
||||
}
|
||||
|
||||
export function fetchObservations(opts?: {
|
||||
agentId?: string;
|
||||
limit?: number;
|
||||
}): Promise<AgentDeviceObservation[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<AgentDeviceObservation[]>(
|
||||
`/api/v1/control/observations?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchObservationHistory(opts?: {
|
||||
agentId?: string;
|
||||
kind?: string;
|
||||
mac?: string;
|
||||
ip?: string;
|
||||
observationKey?: string;
|
||||
limit?: number;
|
||||
}): Promise<AgentDeviceObservationEvent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.agentId) params.set("agent_id", opts.agentId);
|
||||
if (opts?.kind) params.set("kind", opts.kind);
|
||||
if (opts?.mac) params.set("mac", opts.mac);
|
||||
if (opts?.ip) params.set("ip", opts.ip);
|
||||
if (opts?.observationKey) params.set("observation_key", opts.observationKey);
|
||||
params.set("limit", String(opts?.limit ?? 500));
|
||||
return request<AgentDeviceObservationEvent[]>(
|
||||
`/api/v1/control/observations/history?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createKnownDevice(input: {
|
||||
display_name: string;
|
||||
pinned?: boolean;
|
||||
notes?: string | null;
|
||||
identifiers?: { kind: string; value: string }[];
|
||||
}): Promise<KnownDevice> {
|
||||
return request<KnownDevice>("/api/v1/control/devices", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
display_name: input.display_name,
|
||||
pinned: input.pinned ?? true,
|
||||
notes: input.notes ?? null,
|
||||
identifiers: input.identifiers ?? [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function attachObservationIdentifier(
|
||||
deviceId: string,
|
||||
observationKey: string,
|
||||
): Promise<KnownDevice> {
|
||||
return request<KnownDevice>(
|
||||
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/from-observation`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ observation_key: observationKey }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
agentId: string,
|
||||
kind: CommandKind,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
|
||||
const navItems = [
|
||||
["/", "Devices"],
|
||||
["/observations", "Observed"],
|
||||
["/commands", "Wake Tools"],
|
||||
["/dashboard", "Fleet Health"],
|
||||
["/agents", "Agents"],
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
attachObservationIdentifier,
|
||||
createKnownDevice,
|
||||
fetchKnownDevices,
|
||||
fetchObservations,
|
||||
type AgentDeviceObservation,
|
||||
type KnownDevice,
|
||||
} from "@/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type Filter = "all" | "unknown" | "known";
|
||||
|
||||
function observationLabel(observation: AgentDeviceObservation): string {
|
||||
return (
|
||||
observation.hostname?.trim() ||
|
||||
observation.mac?.trim() ||
|
||||
observation.ip?.trim() ||
|
||||
observation.observation_key
|
||||
);
|
||||
}
|
||||
|
||||
function formatSeen(tsUnix: number): string {
|
||||
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
|
||||
return new Date(tsUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function identifierSummary(device: KnownDevice): string {
|
||||
if (!device.identifiers.length) return "no identifiers";
|
||||
return device.identifiers
|
||||
.map((identifier) => `${identifier.kind}:${identifier.value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function ObservationsPage() {
|
||||
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
|
||||
[],
|
||||
);
|
||||
const [devices, setDevices] = useState<KnownDevice[]>([]);
|
||||
const [filter, setFilter] = useState<Filter>("unknown");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedDevices, setSelectedDevices] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [newNames, setNewNames] = useState<Record<string, string>>({});
|
||||
const [busyKey, setBusyKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextObservations, nextDevices] = await Promise.all([
|
||||
fetchObservations({ limit: 500 }),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
setObservations(nextObservations);
|
||||
setDevices(nextDevices);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return observations.filter((observation) => {
|
||||
if (filter === "known" && !observation.known_device) return false;
|
||||
if (filter === "unknown" && observation.known_device) return false;
|
||||
if (!q) return true;
|
||||
|
||||
const haystack = [
|
||||
observation.hostname,
|
||||
observation.mac,
|
||||
observation.ip,
|
||||
observation.agent_id,
|
||||
observation.kind,
|
||||
observation.last_action,
|
||||
observation.known_device?.display_name,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [filter, observations, query]);
|
||||
|
||||
async function attachExisting(observation: AgentDeviceObservation) {
|
||||
const deviceId = selectedDevices[observation.observation_key];
|
||||
if (!deviceId) return;
|
||||
setBusyKey(observation.observation_key);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const device = await attachObservationIdentifier(
|
||||
deviceId,
|
||||
observation.observation_key,
|
||||
);
|
||||
setStatus(
|
||||
`Attached ${observationLabel(observation)} to ${device.display_name}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAttach(observation: AgentDeviceObservation) {
|
||||
const displayName =
|
||||
newNames[observation.observation_key]?.trim() ||
|
||||
observationLabel(observation);
|
||||
if (!displayName.trim()) return;
|
||||
setBusyKey(observation.observation_key);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const device = await createKnownDevice({
|
||||
display_name: displayName,
|
||||
pinned: true,
|
||||
identifiers: [],
|
||||
});
|
||||
const updated = await attachObservationIdentifier(
|
||||
device.device_id,
|
||||
observation.observation_key,
|
||||
);
|
||||
setStatus(
|
||||
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle>Observed Devices</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Select
|
||||
value={filter}
|
||||
onValueChange={(value: Filter | null) => {
|
||||
if (value) setFilter(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<span>{filter}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectItem value="unknown">unknown</SelectItem>
|
||||
<SelectItem value="known">known</SelectItem>
|
||||
<SelectItem value="all">all</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm text-muted-foreground">
|
||||
<span>Search</span>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="hostname, mac, ip, agent, known device"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filtered.length} of {observations.length}
|
||||
</p>
|
||||
{status && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{status}
|
||||
</pre>
|
||||
)}
|
||||
{error && (
|
||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 overflow-x-auto">
|
||||
<div className="grid min-w-[68rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
|
||||
<span>Device</span>
|
||||
<span>Network</span>
|
||||
<span>Agent</span>
|
||||
<span>Seen</span>
|
||||
<span>Known Device</span>
|
||||
</div>
|
||||
|
||||
{filtered.map((observation) => {
|
||||
const busy = busyKey === observation.observation_key;
|
||||
const attachable = Boolean(observation.mac || observation.ip);
|
||||
return (
|
||||
<div
|
||||
key={observation.observation_key}
|
||||
className="grid min-w-[68rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{observationLabel(observation)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">{observation.kind}</Badge>
|
||||
<Badge variant="outline">{observation.last_action}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 text-muted-foreground">
|
||||
<div className="truncate" title={observation.ip ?? "-"}>
|
||||
{observation.ip ?? "-"}
|
||||
</div>
|
||||
<div className="truncate" title={observation.mac ?? "-"}>
|
||||
{observation.mac ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-muted-foreground">
|
||||
{observation.agent_id}
|
||||
</div>
|
||||
<div className="min-w-0 text-muted-foreground">
|
||||
<div className="truncate">
|
||||
{formatSeen(observation.last_seen_unix)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{observation.known_device ? (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary">known</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{observation.known_device.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{observation.known_device.pinned
|
||||
? "pinned"
|
||||
: "not pinned"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Select
|
||||
value={
|
||||
selectedDevices[observation.observation_key] ?? ""
|
||||
}
|
||||
onValueChange={(value: string | null) => {
|
||||
setSelectedDevices((prev) => ({
|
||||
...prev,
|
||||
[observation.observation_key]: value ?? "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 flex-1">
|
||||
<span className="truncate">
|
||||
{selectedDevices[observation.observation_key]
|
||||
? devices.find(
|
||||
(device) =>
|
||||
device.device_id ===
|
||||
selectedDevices[
|
||||
observation.observation_key
|
||||
],
|
||||
)?.display_name
|
||||
: "Attach to existing"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
className="max-w-[min(92vw,28rem)]"
|
||||
alignItemWithTrigger={false}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<SelectItem
|
||||
key={device.device_id}
|
||||
value={device.device_id}
|
||||
>
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate">
|
||||
{device.display_name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{identifierSummary(device)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
busy ||
|
||||
!attachable ||
|
||||
!selectedDevices[observation.observation_key]
|
||||
}
|
||||
onClick={() => void attachExisting(observation)}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<Input
|
||||
value={newNames[observation.observation_key] ?? ""}
|
||||
onChange={(event) =>
|
||||
setNewNames((prev) => ({
|
||||
...prev,
|
||||
[observation.observation_key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Create as ${observationLabel(observation)}`}
|
||||
disabled={busy || !attachable}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || !attachable}
|
||||
onClick={() => void createAndAttach(observation)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!filtered.length && (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
No observations found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@ pub struct Cli {
|
||||
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
|
||||
pub verbose: u8,
|
||||
|
||||
/// Path to the agent config file for commands that use agent config.
|
||||
#[arg(long, global = true)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
@@ -28,6 +32,11 @@ pub enum Command {
|
||||
InitConfig(InitConfigArgs),
|
||||
/// Reload a running agent daemon by sending SIGHUP.
|
||||
Reload(ReloadArgs),
|
||||
/// Upload local observations to the control plane once and exit.
|
||||
#[command(visible_alias = "sync")]
|
||||
SyncObservations(SyncObservationsArgs),
|
||||
/// Pass local hotplug observations through to the wakey CLI.
|
||||
Observe(ObserveArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -74,6 +83,10 @@ pub struct InitConfigArgs {
|
||||
#[arg(long)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Existing agent config to use as a base before applying explicit overrides.
|
||||
#[arg(long)]
|
||||
pub from_config: Option<PathBuf>,
|
||||
|
||||
/// Print config to stdout.
|
||||
#[arg(long, conflicts_with = "config")]
|
||||
pub stdout: bool,
|
||||
@@ -101,3 +114,50 @@ pub struct ReloadArgs {
|
||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||
pub pid_file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SyncObservationsArgs {
|
||||
/// Path to the agent config file.
|
||||
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||
pub config: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveArgs {
|
||||
/// Path to the agent config file. If present, local path settings are passed through.
|
||||
#[arg(long, global = true, default_value = config::DEFAULT_CONFIG_PATH)]
|
||||
pub config: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: ObserveCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ObserveCommand {
|
||||
/// Observe a dnsmasq DHCP lease event.
|
||||
Dhcp(ObserveDhcpArgs),
|
||||
/// Observe a neighbor-table event.
|
||||
Neigh(ObserveNeighArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveDhcpArgs {
|
||||
#[arg(long)]
|
||||
pub action: String,
|
||||
#[arg(long)]
|
||||
pub mac: String,
|
||||
#[arg(long)]
|
||||
pub ip: Option<String>,
|
||||
#[arg(long)]
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ObserveNeighArgs {
|
||||
#[arg(long)]
|
||||
pub action: String,
|
||||
#[arg(long)]
|
||||
pub mac: Option<String>,
|
||||
#[arg(long)]
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
@@ -4,6 +4,12 @@ use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
||||
const WAKEY_DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
||||
const WAKEY_MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||
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";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AgentConfig {
|
||||
@@ -14,6 +20,14 @@ pub struct AgentConfig {
|
||||
pub reconnect_base_ms: u64,
|
||||
#[serde(default = "default_reconnect_max_ms")]
|
||||
pub reconnect_max_ms: u64,
|
||||
#[serde(default = "default_observation_sync_interval_seconds")]
|
||||
pub observation_sync_interval_seconds: u64,
|
||||
#[serde(default = "default_dhcp_leases_path")]
|
||||
pub dhcp_leases_path: PathBuf,
|
||||
#[serde(default = "default_mac_name_cache_path")]
|
||||
pub mac_name_cache_path: PathBuf,
|
||||
#[serde(default = "default_observation_store_path")]
|
||||
pub observation_store_path: PathBuf,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AgentConfig {
|
||||
@@ -24,6 +38,13 @@ impl fmt::Debug for AgentConfig {
|
||||
.field("agent_token", &"<redacted>")
|
||||
.field("reconnect_base_ms", &self.reconnect_base_ms)
|
||||
.field("reconnect_max_ms", &self.reconnect_max_ms)
|
||||
.field(
|
||||
"observation_sync_interval_seconds",
|
||||
&self.observation_sync_interval_seconds,
|
||||
)
|
||||
.field("dhcp_leases_path", &self.dhcp_leases_path)
|
||||
.field("mac_name_cache_path", &self.mac_name_cache_path)
|
||||
.field("observation_store_path", &self.observation_store_path)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -36,6 +57,41 @@ const fn default_reconnect_max_ms() -> u64 {
|
||||
30_000
|
||||
}
|
||||
|
||||
const fn default_observation_sync_interval_seconds() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_dhcp_leases_path() -> PathBuf {
|
||||
DEFAULT_DHCP_LEASES_PATH.into()
|
||||
}
|
||||
|
||||
fn default_mac_name_cache_path() -> PathBuf {
|
||||
DEFAULT_MAC_NAME_CACHE_PATH.into()
|
||||
}
|
||||
|
||||
fn default_observation_store_path() -> PathBuf {
|
||||
DEFAULT_OBSERVATION_STORE_PATH.into()
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
pub fn local_path_envs(&self) -> Vec<(&'static str, &Path)> {
|
||||
vec![
|
||||
(WAKEY_DHCP_LEASES_ENV, self.dhcp_leases_path.as_path()),
|
||||
(WAKEY_MAC_NAME_CACHE_ENV, self.mac_name_cache_path.as_path()),
|
||||
(
|
||||
WAKEY_OBSERVATION_STORE_ENV,
|
||||
self.observation_store_path.as_path(),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config: &AgentConfig) {
|
||||
for (key, path) in config.local_path_envs() {
|
||||
cmd.env(key, path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
||||
@@ -118,12 +174,22 @@ mod tests {
|
||||
agent_token: "secret".into(),
|
||||
reconnect_base_ms: 123,
|
||||
reconnect_max_ms: 456,
|
||||
observation_sync_interval_seconds: 7,
|
||||
dhcp_leases_path: "/tmp/test-dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/test-names.json".into(),
|
||||
observation_store_path: "/tmp/test-observations.json".into(),
|
||||
};
|
||||
|
||||
save_config(&path, &config).expect("save");
|
||||
let loaded = load_config(&path).expect("load");
|
||||
assert_eq!(loaded, config);
|
||||
assert!(format!("{:?}", loaded).contains("<redacted>"));
|
||||
assert!(
|
||||
loaded
|
||||
.local_path_envs()
|
||||
.iter()
|
||||
.any(|(key, _)| *key == WAKEY_OBSERVATION_STORE_ENV)
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, info, instrument};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::protocol::{
|
||||
AgentCommand, CommandResult, DevsRequest, InventoryRequest, LeasesRequest, WakeRequest,
|
||||
};
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn dispatch_command(command: AgentCommand) -> Result<CommandResult> {
|
||||
pub async fn dispatch_command(
|
||||
command: AgentCommand,
|
||||
config: &AgentConfig,
|
||||
) -> Result<CommandResult> {
|
||||
let kind = command_kind(&command);
|
||||
info!(command = %kind, "dispatching command into local wakey services");
|
||||
match command {
|
||||
AgentCommand::Leases(req) => dispatch_leases(req).await,
|
||||
AgentCommand::Leases(req) => dispatch_leases(req, config).await,
|
||||
AgentCommand::Devs(req) => dispatch_devs(req).await,
|
||||
AgentCommand::Inventory(req) => dispatch_inventory(req).await,
|
||||
AgentCommand::Inventory(req) => dispatch_inventory(req, config).await,
|
||||
AgentCommand::Wake(req) => dispatch_wake(req).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_leases(req: LeasesRequest) -> Result<CommandResult> {
|
||||
let leases = wakey::get_leases(wakey_core::LeaseQuery {
|
||||
include_state: req.include_state,
|
||||
})
|
||||
async fn dispatch_leases(req: LeasesRequest, config: &AgentConfig) -> Result<CommandResult> {
|
||||
let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
|
||||
&config.dhcp_leases_path,
|
||||
&config.observation_store_path,
|
||||
&config.mac_name_cache_path,
|
||||
)
|
||||
.await?;
|
||||
let leases = if req.include_state {
|
||||
wakey::wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await
|
||||
} else {
|
||||
wakey::leases_without_state(leases)
|
||||
};
|
||||
debug!(
|
||||
rows = leases.len(),
|
||||
include_state = req.include_state,
|
||||
@@ -50,8 +61,18 @@ async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
|
||||
Ok(CommandResult::Devs { rows: devs })
|
||||
}
|
||||
|
||||
async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> {
|
||||
let inventory = wakey::inventory(req.into_inventory_query()).await?;
|
||||
async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Result<CommandResult> {
|
||||
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(
|
||||
&config.dhcp_leases_path,
|
||||
&config.observation_store_path,
|
||||
&config.mac_name_cache_path,
|
||||
)
|
||||
.await?;
|
||||
let inventory = wakey_core::DeviceInventory {
|
||||
devices: wakey::merge_devices(neighbors, wakey::leases_without_state(leases), &query),
|
||||
};
|
||||
debug!(
|
||||
rows = inventory.devices.len(),
|
||||
"dispatched inventory command"
|
||||
|
||||
@@ -60,6 +60,10 @@ pub async fn enroll(
|
||||
agent_token: payload.agent_token,
|
||||
reconnect_base_ms: 1_000,
|
||||
reconnect_max_ms: 30_000,
|
||||
observation_sync_interval_seconds: 60,
|
||||
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(),
|
||||
};
|
||||
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");
|
||||
|
||||
+113
-14
@@ -9,19 +9,26 @@ mod tracing;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command, InitConfigArgs};
|
||||
use cli::{Cli, Command, InitConfigArgs, ObserveCommand};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
tracing::init(cli.verbose);
|
||||
let global_config = cli.config.as_deref();
|
||||
|
||||
match cli.command {
|
||||
Command::Serve(args) => {
|
||||
Command::Serve(mut args) => {
|
||||
if let Some(config) = global_config {
|
||||
args.config = config.to_path_buf();
|
||||
}
|
||||
::tracing::info!("wakey-agent command: serve");
|
||||
serve::serve(args).await?
|
||||
}
|
||||
Command::Enroll(args) => {
|
||||
Command::Enroll(mut args) => {
|
||||
if let Some(config) = global_config {
|
||||
args.config = config.to_path_buf();
|
||||
}
|
||||
let resolved_server_url = if let Some(server_url) = args.server_url.as_deref() {
|
||||
server_url.to_string()
|
||||
} else {
|
||||
@@ -81,16 +88,96 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::InitConfig(args) => init_config(args)?,
|
||||
Command::InitConfig(mut args) => {
|
||||
if let Some(config) = global_config
|
||||
&& args.config.is_none()
|
||||
&& !args.stdout
|
||||
{
|
||||
args.config = Some(config.to_path_buf());
|
||||
}
|
||||
init_config(args)?
|
||||
}
|
||||
Command::Reload(args) => {
|
||||
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
|
||||
serve::reload_daemon(&args.pid_file)?
|
||||
}
|
||||
Command::SyncObservations(mut args) => {
|
||||
if let Some(config) = global_config {
|
||||
args.config = config.to_path_buf();
|
||||
}
|
||||
::tracing::info!(config = %args.config.display(), "wakey-agent command: sync-observations");
|
||||
let cfg = config::load_config(&args.config)?;
|
||||
let accepted = session::sync_observations_once(&cfg).await?;
|
||||
println!("observations_synced={accepted}");
|
||||
}
|
||||
Command::Observe(mut args) => {
|
||||
if let Some(config) = global_config {
|
||||
args.config = config.to_path_buf();
|
||||
}
|
||||
observe(args)?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn observe(args: cli::ObserveArgs) -> Result<()> {
|
||||
let mut cmd = std::process::Command::new(resolve_wakey_binary());
|
||||
cmd.arg("observe");
|
||||
if let Ok(config) = config::load_config(&args.config) {
|
||||
config::apply_local_path_env_to_command(&mut cmd, &config);
|
||||
}
|
||||
|
||||
match args.command {
|
||||
ObserveCommand::Dhcp(args) => {
|
||||
cmd.arg("dhcp")
|
||||
.arg("--action")
|
||||
.arg(args.action)
|
||||
.arg("--mac")
|
||||
.arg(args.mac);
|
||||
if let Some(ip) = args.ip {
|
||||
cmd.arg("--ip").arg(ip);
|
||||
}
|
||||
if let Some(hostname) = args.hostname {
|
||||
cmd.arg("--hostname").arg(hostname);
|
||||
}
|
||||
}
|
||||
ObserveCommand::Neigh(args) => {
|
||||
cmd.arg("neigh").arg("--action").arg(args.action);
|
||||
if let Some(mac) = args.mac {
|
||||
cmd.arg("--mac").arg(mac);
|
||||
}
|
||||
if let Some(ip) = args.ip {
|
||||
cmd.arg("--ip").arg(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = cmd.status()?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("wakey observe exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_wakey_binary() -> std::path::PathBuf {
|
||||
if let Ok(current) = std::env::current_exe()
|
||||
&& let Some(dir) = current.parent()
|
||||
{
|
||||
let sibling = dir.join("wakey");
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
|
||||
let root_bin = std::path::PathBuf::from("/root/.bin/wakey");
|
||||
if root_bin.exists() {
|
||||
return root_bin;
|
||||
}
|
||||
|
||||
"wakey".into()
|
||||
}
|
||||
|
||||
fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
if args.stdout && args.config.is_some() {
|
||||
anyhow::bail!("--stdout cannot be used with --config");
|
||||
@@ -106,20 +193,32 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let cfg = config::AgentConfig {
|
||||
server_url: args
|
||||
.server_url
|
||||
.unwrap_or_else(|| "https://wakey.ldlda.com".to_string()),
|
||||
agent_id: args
|
||||
.agent_id
|
||||
.unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()),
|
||||
agent_token: args
|
||||
.agent_token
|
||||
.unwrap_or_else(|| "REPLACE_ME_AGENT_TOKEN".to_string()),
|
||||
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,
|
||||
dhcp_leases_path: "/tmp/dhcp.leases".into(),
|
||||
mac_name_cache_path: "/tmp/wakey_mac_names.json".into(),
|
||||
observation_store_path: "/tmp/wakey_observations.json".into(),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(server_url) = args.server_url {
|
||||
cfg.server_url = server_url;
|
||||
}
|
||||
if let Some(agent_id) = args.agent_id {
|
||||
cfg.agent_id = agent_id;
|
||||
}
|
||||
if let Some(agent_token) = args.agent_token {
|
||||
cfg.agent_token = agent_token;
|
||||
}
|
||||
|
||||
if let Some(path) = &args.config {
|
||||
config::save_config(path, &cfg)?;
|
||||
println!("config={}", path.display());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Serialize;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
|
||||
@@ -84,8 +85,13 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
.await?;
|
||||
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let mut heartbeat = interval(Duration::from_secs(30));
|
||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
let mut observation_sync = interval(Duration::from_secs(
|
||||
config.observation_sync_interval_seconds.max(1),
|
||||
));
|
||||
observation_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -95,6 +101,11 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
}).await?;
|
||||
debug!(agent_id = %config.agent_id, "heartbeat sent");
|
||||
}
|
||||
_ = observation_sync.tick() => {
|
||||
if let Err(err) = send_agent_observations(&http_client, config).await {
|
||||
warn!(agent_id = %config.agent_id, error = %err, "failed to sync local observations");
|
||||
}
|
||||
}
|
||||
maybe_msg = source.next() => {
|
||||
let msg = match maybe_msg {
|
||||
Some(msg) => msg.context("websocket frame failed")?,
|
||||
@@ -108,7 +119,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(message) => {
|
||||
handle_server_message(&mut sink, message).await?;
|
||||
handle_server_message(config, &mut sink, message).await?;
|
||||
}
|
||||
Err(err) => {
|
||||
// Allow the server to introduce extra frame types without
|
||||
@@ -135,12 +146,88 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UploadAgentObservationsRequest {
|
||||
agent_id: String,
|
||||
agent_token: String,
|
||||
observations: Vec<AgentObservationRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AgentObservationRequest {
|
||||
kind: String,
|
||||
action: String,
|
||||
mac: Option<String>,
|
||||
ip: Option<IpAddr>,
|
||||
hostname: Option<String>,
|
||||
first_seen_unix: u64,
|
||||
last_seen_unix: u64,
|
||||
}
|
||||
|
||||
pub async fn sync_observations_once(config: &AgentConfig) -> Result<usize> {
|
||||
let client = reqwest::Client::new();
|
||||
send_agent_observations(&client, config).await
|
||||
}
|
||||
|
||||
async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig) -> Result<usize> {
|
||||
let observations =
|
||||
wakey::wakey_linux::dhcp::list_local_observations_from_path(&config.observation_store_path)
|
||||
.await
|
||||
.context("failed to read local observations")?;
|
||||
if observations.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let url = observations_url(&config.server_url)?;
|
||||
let payload = UploadAgentObservationsRequest {
|
||||
agent_id: config.agent_id.clone(),
|
||||
agent_token: config.agent_token.clone(),
|
||||
observations: observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentObservationRequest {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let response = client
|
||||
.post(url.clone())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to call observation endpoint {url}"))?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<unreadable error body>".to_string());
|
||||
anyhow::bail!("observation upload failed with {status}: {body}");
|
||||
}
|
||||
debug!(
|
||||
agent_id = %config.agent_id,
|
||||
observations = payload.observations.len(),
|
||||
"synced local observations"
|
||||
);
|
||||
Ok(payload.observations.len())
|
||||
}
|
||||
|
||||
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
|
||||
let cap = max_ms.max(current_ms);
|
||||
current_ms.saturating_mul(2).min(cap)
|
||||
}
|
||||
|
||||
async fn handle_server_message<S>(sink: &mut S, message: ServerMessage) -> Result<()>
|
||||
async fn handle_server_message<S>(
|
||||
config: &AgentConfig,
|
||||
sink: &mut S,
|
||||
message: ServerMessage,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
@@ -152,7 +239,7 @@ where
|
||||
} => {
|
||||
let kind = command_kind(&command);
|
||||
info!(request_id = %request_id, command = %kind, "received command from control-plane");
|
||||
match dispatch_command(command).await {
|
||||
match dispatch_command(command, config).await {
|
||||
Ok(result) => {
|
||||
info!(request_id = %request_id, command = %kind, "command execution completed");
|
||||
send_command_result(sink, request_id, kind, result).await?;
|
||||
@@ -273,6 +360,14 @@ pub fn websocket_url(server_url: &str) -> Result<url::Url> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn observations_url(server_url: &str) -> Result<url::Url> {
|
||||
let mut url = url::Url::parse(server_url).context("invalid server_url")?;
|
||||
url.set_path("/api/v1/agents/observations");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn dns_resolution_diagnostics(ws_url: &url::Url) -> Option<(u64, usize)> {
|
||||
let host = ws_url.host_str()?;
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
|
||||
@@ -20,6 +20,14 @@ reqwest = { version = "0.13", default-features = false, features = [
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sled = "0.34"
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"macros",
|
||||
"migrate",
|
||||
"uuid",
|
||||
"json",
|
||||
] }
|
||||
toml = "1.1"
|
||||
tokio = { version = "1", features = [
|
||||
"macros",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
CREATE TABLE meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value BLOB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE enroll_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
expires_at_unix INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
agent_token TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE agent_meta (
|
||||
agent_id TEXT PRIMARY KEY REFERENCES agents(agent_id) ON DELETE CASCADE,
|
||||
nickname TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE known_devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
pinned INTEGER NOT NULL,
|
||||
created_at_unix INTEGER NOT NULL,
|
||||
updated_at_unix INTEGER NOT NULL,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE device_identifiers (
|
||||
identifier_key TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL REFERENCES known_devices(device_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at_unix INTEGER NOT NULL,
|
||||
UNIQUE(kind, value)
|
||||
);
|
||||
|
||||
CREATE INDEX device_identifiers_device_id_idx ON device_identifiers(device_id);
|
||||
|
||||
CREATE TABLE agent_device_observations (
|
||||
observation_key TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
mac TEXT,
|
||||
ip TEXT,
|
||||
hostname TEXT,
|
||||
first_seen_unix INTEGER NOT NULL,
|
||||
last_seen_unix INTEGER NOT NULL,
|
||||
last_action TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX agent_device_observations_agent_id_idx ON agent_device_observations(agent_id);
|
||||
CREATE INDEX agent_device_observations_mac_idx ON agent_device_observations(mac);
|
||||
CREATE INDEX agent_device_observations_ip_idx ON agent_device_observations(ip);
|
||||
CREATE INDEX agent_device_observations_hostname_idx ON agent_device_observations(hostname);
|
||||
CREATE INDEX agent_device_observations_last_seen_unix_idx ON agent_device_observations(last_seen_unix);
|
||||
|
||||
CREATE TABLE agent_device_observation_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
mac TEXT,
|
||||
ip TEXT,
|
||||
hostname TEXT,
|
||||
ts_unix INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX agent_device_observation_events_agent_ts_idx ON agent_device_observation_events(agent_id, ts_unix);
|
||||
CREATE INDEX agent_device_observation_events_mac_idx ON agent_device_observation_events(mac);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
event_key TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
ts_unix INTEGER NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id TEXT,
|
||||
agent_id TEXT,
|
||||
request_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
message TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX audit_events_ts_unix_idx ON audit_events(ts_unix);
|
||||
CREATE INDEX audit_events_agent_id_idx ON audit_events(agent_id);
|
||||
CREATE INDEX audit_events_request_id_idx ON audit_events(request_id);
|
||||
CREATE INDEX audit_events_event_type_outcome_idx ON audit_events(event_type, outcome);
|
||||
|
||||
CREATE TABLE active_alerts (
|
||||
alert_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
agent_id TEXT,
|
||||
message TEXT NOT NULL,
|
||||
value INTEGER NOT NULL,
|
||||
threshold INTEGER NOT NULL,
|
||||
last_seen_unix INTEGER NOT NULL,
|
||||
metadata_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE alert_transitions (
|
||||
transition_key TEXT PRIMARY KEY,
|
||||
transition_id TEXT NOT NULL UNIQUE,
|
||||
ts_unix INTEGER NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
agent_id TEXT,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX alert_transitions_ts_unix_idx ON alert_transitions(ts_unix);
|
||||
@@ -7,7 +7,10 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::api::json_error;
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::AuditEventInput;
|
||||
use crate::state::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
AuditEventInput, DeviceIdentifierInput, KnownDeviceInput,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnrollRequest {
|
||||
@@ -72,6 +75,93 @@ pub struct StateStatsResponse {
|
||||
pub expired_enroll_token_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateKnownDeviceRequest {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub identifiers: Vec<DeviceIdentifierRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceIdentifierRequest {
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AttachObservationIdentifierRequest {
|
||||
pub observation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceResponse {
|
||||
pub device_id: String,
|
||||
pub display_name: String,
|
||||
pub pinned: bool,
|
||||
pub created_at_unix: u64,
|
||||
pub updated_at_unix: u64,
|
||||
pub notes: Option<String>,
|
||||
pub identifiers: Vec<DeviceIdentifierResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceIdentifierResponse {
|
||||
pub identifier_key: String,
|
||||
pub device_id: String,
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
pub created_at_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ForgetKnownDeviceResponse {
|
||||
pub device_id: String,
|
||||
pub forgotten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadAgentObservationsRequest {
|
||||
pub agent_id: String,
|
||||
pub agent_token: String,
|
||||
#[serde(default)]
|
||||
pub observations: Vec<AgentObservationRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentObservationRequest {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UploadAgentObservationsResponse {
|
||||
pub accepted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObservationsQuery {
|
||||
pub agent_id: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObservationHistoryQuery {
|
||||
pub agent_id: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub observation_key: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn healthz() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
@@ -407,6 +497,262 @@ pub async fn set_agent_nickname(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_known_device(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateKnownDeviceRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let input = KnownDeviceInput {
|
||||
display_name: req.display_name,
|
||||
pinned: req.pinned,
|
||||
notes: req.notes,
|
||||
identifiers: req
|
||||
.identifiers
|
||||
.into_iter()
|
||||
.map(|identifier| DeviceIdentifierInput {
|
||||
kind: identifier.kind,
|
||||
value: identifier.value,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
match state.store.create_known_device(input).await {
|
||||
Ok(device) => Ok((StatusCode::CREATED, Json(known_device_response(device)))),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to create known device");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"create_known_device_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_known_devices(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state.store.list_known_devices().await {
|
||||
Ok(devices) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
devices
|
||||
.into_iter()
|
||||
.map(known_device_response)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list known devices");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_known_devices_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forget_known_device(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state.store.forget_known_device(&device_id).await {
|
||||
Ok(forgotten) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(ForgetKnownDeviceResponse {
|
||||
device_id,
|
||||
forgotten,
|
||||
}),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to forget known device");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"forget_known_device_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn attach_device_identifier(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<DeviceIdentifierRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let input = DeviceIdentifierInput {
|
||||
kind: req.kind,
|
||||
value: req.value,
|
||||
};
|
||||
|
||||
match state
|
||||
.store
|
||||
.attach_device_identifier(&device_id, input)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to attach device identifier");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"attach_device_identifier_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn attach_observation_identifier(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<AttachObservationIdentifierRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.attach_observation_identifier(&device_id, &req.observation_key)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to attach observation identifier");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"attach_observation_identifier_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_agent_observations(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UploadAgentObservationsRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
if !state
|
||||
.store
|
||||
.verify_agent_token(&req.agent_id, &req.agent_token)
|
||||
.await
|
||||
{
|
||||
return Err(json_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"agent_auth_rejected",
|
||||
"agent credentials rejected",
|
||||
));
|
||||
}
|
||||
|
||||
let observations = req
|
||||
.observations
|
||||
.into_iter()
|
||||
.map(|observation| AgentDeviceObservationInput {
|
||||
kind: observation.kind,
|
||||
action: observation.action,
|
||||
mac: observation.mac,
|
||||
ip: observation.ip,
|
||||
hostname: observation.hostname,
|
||||
first_seen_unix: observation.first_seen_unix,
|
||||
last_seen_unix: observation.last_seen_unix,
|
||||
})
|
||||
.collect();
|
||||
|
||||
match state
|
||||
.store
|
||||
.upsert_agent_observations(&req.agent_id, observations)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(UploadAgentObservationsResponse { accepted }),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, agent_id = %req.agent_id, "failed to upload agent observations");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"upload_observations_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agent_observations(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListObservationsQuery>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
|
||||
.await
|
||||
{
|
||||
Ok(observations) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
observations
|
||||
.into_iter()
|
||||
.map(agent_observation_response) // no-op premium
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agent observations");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_observations_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListObservationHistoryQuery>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
let kind = normalized_query_value(query.kind).map(|value| value.to_ascii_lowercase());
|
||||
let mac = normalized_query_value(query.mac).map(|value| value.to_ascii_lowercase());
|
||||
let ip = normalized_query_value(query.ip);
|
||||
let observation_key = normalized_query_value(query.observation_key);
|
||||
match state
|
||||
.store
|
||||
.list_agent_observation_events(
|
||||
query.agent_id.as_deref(),
|
||||
kind.as_deref(),
|
||||
mac.as_deref(),
|
||||
ip.as_deref(),
|
||||
observation_key.as_deref(),
|
||||
query.limit.unwrap_or(500),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
events
|
||||
.into_iter()
|
||||
.map(agent_observation_event_response) // no-op premium
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agent observation history");
|
||||
Err(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_observation_history_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn state_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
@@ -431,3 +777,43 @@ pub async fn state_stats(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_observation_response(
|
||||
observation: AgentDeviceObservationView,
|
||||
) -> AgentDeviceObservationView {
|
||||
observation
|
||||
}
|
||||
|
||||
fn agent_observation_event_response(
|
||||
event: AgentDeviceObservationEvent,
|
||||
) -> AgentDeviceObservationEvent {
|
||||
event
|
||||
}
|
||||
|
||||
fn normalized_query_value(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
|
||||
KnownDeviceResponse {
|
||||
device_id: device.device_id,
|
||||
display_name: device.display_name,
|
||||
pinned: device.pinned,
|
||||
created_at_unix: device.created_at_unix,
|
||||
updated_at_unix: device.updated_at_unix,
|
||||
notes: device.notes,
|
||||
identifiers: device
|
||||
.identifiers
|
||||
.into_iter()
|
||||
.map(|identifier| DeviceIdentifierResponse {
|
||||
identifier_key: identifier.identifier_key,
|
||||
device_id: identifier.device_id,
|
||||
kind: identifier.kind,
|
||||
value: identifier.value,
|
||||
created_at_unix: identifier.created_at_unix,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ pub use audit::list_audit_events;
|
||||
pub use commands::{list_agents, run_command};
|
||||
pub use control::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent,
|
||||
revoke_enroll_token, set_agent_nickname, state_stats,
|
||||
StateStatsResponse, attach_device_identifier, attach_observation_identifier,
|
||||
create_known_device, enroll, forget_known_device, healthz, issue_enroll_token,
|
||||
list_agent_observation_history, list_agent_observations, list_enroll_tokens,
|
||||
list_known_devices, revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats,
|
||||
upload_agent_observations,
|
||||
};
|
||||
|
||||
pub fn json_error(
|
||||
|
||||
@@ -43,6 +43,8 @@ pub enum Command {
|
||||
RevokeAgent(RevokeAgentArgs),
|
||||
/// Print state backend stats.
|
||||
StateStats(StateStatsArgs),
|
||||
/// Import a legacy sled state directory into a SQLite state file.
|
||||
ImportSledState(ImportSledStateArgs),
|
||||
/// Send SIGHUP to an already-running daemon.
|
||||
Reload(ReloadArgs),
|
||||
}
|
||||
@@ -236,6 +238,18 @@ pub struct StateStatsArgs {
|
||||
pub target: AdminTargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ImportSledStateArgs {
|
||||
#[arg(long)]
|
||||
pub from_sled_state: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
pub to_state_file: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ReloadArgs {
|
||||
#[arg(long, default_value = DEFAULT_PID_FILE)]
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
let state_file_raw = args
|
||||
.state_file
|
||||
.clone()
|
||||
.unwrap_or_else(|| PathBuf::from("state.db"));
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
|
||||
let pid_file_raw = args
|
||||
|
||||
@@ -49,7 +49,7 @@ impl DaemonConfig {
|
||||
.state_file
|
||||
.clone()
|
||||
.or(file.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.db"));
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
|
||||
let command_timeout = Duration::from_millis(
|
||||
@@ -255,7 +255,7 @@ fn resolve_state_access(
|
||||
&data_dir,
|
||||
cli_state_file
|
||||
.or(file.state_file)
|
||||
.unwrap_or_else(|| PathBuf::from("state.db")),
|
||||
.unwrap_or_else(|| PathBuf::from("state.sqlite3")),
|
||||
);
|
||||
|
||||
let resolved_public_url = cli_public_url
|
||||
@@ -288,8 +288,11 @@ mod tests {
|
||||
fn resolve_path_joins_relative_path() {
|
||||
let out = resolve_path(
|
||||
Path::new("/var/lib/wakey-control-plane"),
|
||||
PathBuf::from("state.db"),
|
||||
PathBuf::from("state.sqlite3"),
|
||||
);
|
||||
assert_eq!(
|
||||
out,
|
||||
PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3")
|
||||
);
|
||||
assert_eq!(out, PathBuf::from("/var/lib/wakey-control-plane/state.db"));
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ async fn main() -> Result<()> {
|
||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||
runtime::state_stats(args).await
|
||||
}
|
||||
Command::ImportSledState(args) => {
|
||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||
runtime::import_sled_state(args).await
|
||||
}
|
||||
Command::Reload(args) => {
|
||||
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
|
||||
runtime::reload_daemon(&args.pid_file)
|
||||
|
||||
@@ -4,8 +4,8 @@ use anyhow::{Context, Result};
|
||||
|
||||
use crate::api;
|
||||
use crate::cli::{
|
||||
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs, RevokeEnrollTokenArgs,
|
||||
StateStatsArgs,
|
||||
ImportSledStateArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs,
|
||||
RevokeEnrollTokenArgs, StateStatsArgs,
|
||||
};
|
||||
use crate::config;
|
||||
use crate::state;
|
||||
@@ -276,3 +276,11 @@ pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn import_sled_state(args: ImportSledStateArgs) -> Result<()> {
|
||||
state::Store::import_sled_state(&args.from_sled_state, &args.to_state_file, args.force).await?;
|
||||
println!("from_sled_state={}", args.from_sled_state.display());
|
||||
println!("to_state_file={}", args.to_state_file.display());
|
||||
println!("imported=true");
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,7 +26,9 @@ use crate::ws;
|
||||
mod admin;
|
||||
mod process;
|
||||
pub use admin::revoke_agent;
|
||||
pub use admin::{issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats};
|
||||
pub use admin::{
|
||||
import_sled_state, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
|
||||
};
|
||||
pub use process::reload_daemon;
|
||||
use process::{remove_pid_file, write_pid_file};
|
||||
|
||||
@@ -62,12 +64,17 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
|
||||
|
||||
Router::new()
|
||||
.route("/ui", get(|| async { Redirect::temporary("/ui/") }))
|
||||
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
|
||||
.nest_service(
|
||||
"/ui/",
|
||||
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
|
||||
)
|
||||
.route("/healthz", get(api::healthz))
|
||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||
.route(
|
||||
"/api/v1/agents/observations",
|
||||
post(api::upload_agent_observations),
|
||||
)
|
||||
.route("/api/v1/agent/ws", get(ws::agent_ws))
|
||||
}
|
||||
|
||||
@@ -86,6 +93,30 @@ fn control_api_routes() -> Router<AppState> {
|
||||
axum::routing::delete(api::revoke_enroll_token),
|
||||
)
|
||||
.route("/api/v1/control/state-stats", get(api::state_stats))
|
||||
.route(
|
||||
"/api/v1/control/observations",
|
||||
get(api::list_agent_observations),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/observations/history",
|
||||
get(api::list_agent_observation_history),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices",
|
||||
get(api::list_known_devices).post(api::create_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}",
|
||||
axum::routing::delete(api::forget_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers",
|
||||
post(api::attach_device_identifier),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers/from-observation",
|
||||
post(api::attach_observation_identifier),
|
||||
)
|
||||
.route("/api/v1/control/audit/events", get(api::list_audit_events))
|
||||
.route("/api/v1/control/alerts", get(api::active_alerts))
|
||||
.route("/api/v1/control/alerts/history", get(api::alert_history))
|
||||
|
||||
@@ -2,4 +2,8 @@ mod store;
|
||||
mod types;
|
||||
|
||||
pub use store::Store;
|
||||
pub use types::{AlertState, AuditEvent, AuditEventFilter, AuditEventInput};
|
||||
pub use types::{
|
||||
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
|
||||
AlertState, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifierInput, KnownDevice,
|
||||
KnownDeviceInput,
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
pub async fn sync_alert_transitions(
|
||||
&self,
|
||||
current: &[AlertState],
|
||||
) -> Result<Vec<AlertTransition>> {
|
||||
let mut previous = std::collections::HashMap::<String, AlertState>::new();
|
||||
let rows = sqlx::query_as!(
|
||||
AlertStateRow,
|
||||
r#"SELECT alert_id as "alert_id!", kind as "kind!", severity as "severity!",
|
||||
status as "status!", agent_id, message as "message!",
|
||||
value, threshold, last_seen_unix, metadata_json as "metadata_json!"
|
||||
FROM active_alerts"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed iterating active_alerts table")?;
|
||||
for row in rows {
|
||||
let alert = alert_state_from_row(row).context("failed decoding active alert")?;
|
||||
previous.insert(alert.alert_id.clone(), alert);
|
||||
}
|
||||
|
||||
let mut current_map = std::collections::HashMap::<String, AlertState>::new();
|
||||
for alert in current {
|
||||
current_map.insert(alert.alert_id.clone(), alert.clone());
|
||||
}
|
||||
|
||||
let now = now_unix();
|
||||
let mut transitions = Vec::new();
|
||||
|
||||
for (alert_id, current_alert) in ¤t_map {
|
||||
if previous.contains_key(alert_id) {
|
||||
continue;
|
||||
}
|
||||
transitions.push(AlertTransition {
|
||||
transition_id: format!("atr-{}", Uuid::new_v4()),
|
||||
ts_unix: now,
|
||||
alert_id: current_alert.alert_id.clone(),
|
||||
kind: current_alert.kind.clone(),
|
||||
agent_id: current_alert.agent_id.clone(),
|
||||
from_status: None,
|
||||
to_status: "active".into(),
|
||||
message: current_alert.message.clone(),
|
||||
metadata: current_alert.metadata.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
for (alert_id, previous_alert) in &previous {
|
||||
if current_map.contains_key(alert_id) {
|
||||
continue;
|
||||
}
|
||||
transitions.push(AlertTransition {
|
||||
transition_id: format!("atr-{}", Uuid::new_v4()),
|
||||
ts_unix: now,
|
||||
alert_id: previous_alert.alert_id.clone(),
|
||||
kind: previous_alert.kind.clone(),
|
||||
agent_id: previous_alert.agent_id.clone(),
|
||||
from_status: Some("active".into()),
|
||||
to_status: "resolved".into(),
|
||||
message: format!("resolved alert {}", previous_alert.alert_id),
|
||||
metadata: previous_alert.metadata.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting alert transaction")?;
|
||||
sqlx::query!("DELETE FROM active_alerts")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed clearing active alert snapshot")?;
|
||||
|
||||
for alert in current {
|
||||
insert_active_alert(&mut tx, alert).await?;
|
||||
}
|
||||
|
||||
for transition in &transitions {
|
||||
let key = format!("{:020}:{}", transition.ts_unix, transition.transition_id);
|
||||
insert_alert_transition(&mut tx, &key, transition).await?;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing state db after alert sync")?;
|
||||
Ok(transitions)
|
||||
}
|
||||
|
||||
pub async fn list_alert_transitions(
|
||||
&self,
|
||||
since_unix: Option<u64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AlertTransition>> {
|
||||
let limit = limit.clamp(1, 500);
|
||||
let limit = i64::try_from(limit).context("alert limit overflow")?;
|
||||
let rows = if let Some(since) = since_unix {
|
||||
let since = i64::try_from(since).context("since_unix overflow")?;
|
||||
sqlx::query_as!(
|
||||
AlertTransitionRow,
|
||||
r#"SELECT transition_id as "transition_id!", ts_unix, alert_id as "alert_id!",
|
||||
kind as "kind!", agent_id, from_status, to_status as "to_status!",
|
||||
message as "message!", metadata_json as "metadata_json!"
|
||||
FROM alert_transitions
|
||||
WHERE ts_unix >= ?1
|
||||
ORDER BY transition_key DESC
|
||||
LIMIT ?2"#,
|
||||
since,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
AlertTransitionRow,
|
||||
r#"SELECT transition_id as "transition_id!", ts_unix, alert_id as "alert_id!",
|
||||
kind as "kind!", agent_id, from_status, to_status as "to_status!",
|
||||
message as "message!", metadata_json as "metadata_json!"
|
||||
FROM alert_transitions
|
||||
ORDER BY transition_key DESC
|
||||
LIMIT ?1"#,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.context("failed listing alert transitions")?;
|
||||
|
||||
rows.into_iter().map(alert_transition_from_row).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> {
|
||||
let event = AuditEvent {
|
||||
event_id: format!("evt-{}", Uuid::new_v4()),
|
||||
ts_unix: now_unix(),
|
||||
actor_type: input.actor_type,
|
||||
actor_id: input.actor_id,
|
||||
agent_id: input.agent_id,
|
||||
request_id: input.request_id,
|
||||
event_type: input.event_type,
|
||||
outcome: input.outcome,
|
||||
latency_ms: input.latency_ms,
|
||||
message: input.message,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
|
||||
let key = format!("{:020}:{}", event.ts_unix, event.event_id);
|
||||
let metadata_json =
|
||||
serde_json::to_string(&event.metadata).context("failed to encode audit metadata")?;
|
||||
let ts_unix = i64::try_from(event.ts_unix).context("audit timestamp overflow")?;
|
||||
let latency_ms = event
|
||||
.latency_ms
|
||||
.map(i64::try_from)
|
||||
.transpose()
|
||||
.context("audit latency overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO audit_events
|
||||
(event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,
|
||||
event_type, outcome, latency_ms, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
key,
|
||||
event.event_id,
|
||||
ts_unix,
|
||||
event.actor_type,
|
||||
event.actor_id,
|
||||
event.agent_id,
|
||||
event.request_id,
|
||||
event.event_type,
|
||||
event.outcome,
|
||||
latency_ms,
|
||||
event.message,
|
||||
metadata_json
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed persisting audit event")?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub async fn list_audit_events(&self, filter: AuditEventFilter) -> Result<Vec<AuditEvent>> {
|
||||
let limit = filter.limit.clamp(1, 500);
|
||||
let mut builder = sqlx::QueryBuilder::new(
|
||||
"SELECT event_id, ts_unix, actor_type, actor_id, agent_id, request_id,
|
||||
event_type, outcome, latency_ms, message, metadata_json
|
||||
FROM audit_events
|
||||
WHERE 1 = 1",
|
||||
);
|
||||
|
||||
if let Some(agent_id) = filter.agent_id.as_deref() {
|
||||
builder.push(" AND agent_id = ");
|
||||
builder.push_bind(agent_id);
|
||||
}
|
||||
if let Some(request_id) = filter.request_id.as_deref() {
|
||||
builder.push(" AND request_id = ");
|
||||
builder.push_bind(request_id);
|
||||
}
|
||||
if let Some(event_type) = filter.event_type.as_deref() {
|
||||
builder.push(" AND event_type = ");
|
||||
builder.push_bind(event_type);
|
||||
}
|
||||
if let Some(outcome) = filter.outcome.as_deref() {
|
||||
builder.push(" AND outcome = ");
|
||||
builder.push_bind(outcome);
|
||||
}
|
||||
if let Some(since_unix) = filter.since_unix {
|
||||
builder.push(" AND ts_unix >= ");
|
||||
builder.push_bind(i64::try_from(since_unix).context("since_unix overflow")?);
|
||||
}
|
||||
if let Some(until_unix) = filter.until_unix {
|
||||
builder.push(" AND ts_unix <= ");
|
||||
builder.push_bind(i64::try_from(until_unix).context("until_unix overflow")?);
|
||||
}
|
||||
builder.push(" ORDER BY event_key DESC LIMIT ");
|
||||
builder.push_bind(i64::try_from(limit).context("audit limit overflow")?);
|
||||
|
||||
let rows = builder
|
||||
.build()
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing audit events")?;
|
||||
|
||||
rows.into_iter().map(audit_event_from_row).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
pub async fn load_or_init(
|
||||
path: &Path,
|
||||
enroll_tokens: Vec<String>,
|
||||
seed_ttl: Duration,
|
||||
) -> Result<Self> {
|
||||
if path.is_dir() {
|
||||
anyhow::bail!(
|
||||
"state_file {} is a directory, which looks like a legacy sled store; run `wakey-control-plane import-sled-state --from-sled-state {} --to-state-file <sqlite-file>` and update state_file",
|
||||
path.display(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let db_path = path.to_path_buf();
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create state dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let pool = open_sqlite_pool(&db_path).await?;
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.with_context(|| format!("failed to migrate state db {}", db_path.display()))?;
|
||||
|
||||
let store = Self { db_path, pool };
|
||||
|
||||
store.ensure_schema_version().await?;
|
||||
store
|
||||
.seed_bootstrap_enroll_tokens(&enroll_tokens, seed_ttl)
|
||||
.await?;
|
||||
store.gc_expired_enroll_tokens_inner().await?;
|
||||
|
||||
let enroll_tokens = sql_count(&store.pool, "enroll_tokens").await?;
|
||||
let agents = sql_count(&store.pool, "agents").await?;
|
||||
let audit_events = sql_count(&store.pool, "audit_events").await?;
|
||||
info!(
|
||||
path = %store.db_path.display(),
|
||||
enroll_tokens,
|
||||
agents,
|
||||
audit_events,
|
||||
"control-plane store ready"
|
||||
);
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub async fn stats(&self) -> Result<StateStats> {
|
||||
let now = i64::try_from(now_unix()).context("current time does not fit SQLite integer")?;
|
||||
let enroll_token_count =
|
||||
sqlx::query_scalar!(r#"SELECT COUNT(*) as "count!: i64" FROM enroll_tokens"#)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed counting enroll tokens")?;
|
||||
let expired_enroll_token_count = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM enroll_tokens WHERE expires_at_unix <= ?1"#,
|
||||
now
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed counting expired enroll tokens")?;
|
||||
let agent_count = sqlx::query_scalar!(r#"SELECT COUNT(*) as "count!: i64" FROM agents"#)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed counting agents")?;
|
||||
|
||||
Ok(StateStats {
|
||||
db_path: self.db_path.clone(),
|
||||
schema_version: self.schema_version().await?,
|
||||
agent_count: usize::try_from(agent_count).context("agent count overflow")?,
|
||||
enroll_token_count: usize::try_from(enroll_token_count)
|
||||
.context("enroll token count overflow")?,
|
||||
expired_enroll_token_count: usize::try_from(expired_enroll_token_count)
|
||||
.context("expired enroll token count overflow")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub async fn gc_expired_enroll_tokens(&self) -> Result<u64> {
|
||||
self.gc_expired_enroll_tokens_inner().await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub async fn reload_from_disk(&self) -> Result<()> {
|
||||
sqlx::query!(r#"SELECT 1 as "ok!""#)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed validating SQLite state connection")?;
|
||||
info!(path = %self.db_path.display(), "reload requested; SQLite backend does not require in-memory reload");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_bootstrap_enroll_tokens(
|
||||
&self,
|
||||
enroll_tokens: &[String],
|
||||
seed_ttl: Duration,
|
||||
) -> Result<()> {
|
||||
for token in enroll_tokens {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let marker_key = seeded_enroll_token_key(token);
|
||||
let marker_exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM meta WHERE key = ?1"#,
|
||||
marker_key
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed reading bootstrap marker")?;
|
||||
if marker_exists > 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 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)",
|
||||
token,
|
||||
expires_at_i64
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed seeding enroll token")?;
|
||||
let marker_value = expires_at.to_le_bytes().to_vec();
|
||||
sqlx::query!(
|
||||
"INSERT INTO meta (key, value) VALUES (?1, ?2)",
|
||||
marker_key,
|
||||
marker_value
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed persisting bootstrap marker")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing bootstrap token transaction")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn gc_expired_enroll_tokens_inner(&self) -> Result<u64> {
|
||||
let now = i64::try_from(now_unix()).context("current time does not fit SQLite integer")?;
|
||||
let result = sqlx::query!("DELETE FROM enroll_tokens WHERE expires_at_unix <= ?1", now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed removing expired enroll tokens")?;
|
||||
let removed = result.rows_affected();
|
||||
if removed > 0 {
|
||||
info!(removed, "garbage-collected expired enroll tokens");
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
async fn ensure_schema_version(&self) -> Result<()> {
|
||||
match sqlx::query_scalar!("SELECT value FROM meta WHERE key = ?1", SCHEMA_VERSION_KEY)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.context("failed reading schema version")?
|
||||
{
|
||||
Some(raw) => {
|
||||
let schema =
|
||||
decode_schema(raw.as_ref()).context("failed decoding schema version")?;
|
||||
if schema != SCHEMA_VERSION {
|
||||
anyhow::bail!(
|
||||
"unsupported db schema version {}; expected {}",
|
||||
schema,
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let schema_version = SCHEMA_VERSION.to_le_bytes().to_vec();
|
||||
sqlx::query!(
|
||||
"INSERT INTO meta (key, value) VALUES (?1, ?2)",
|
||||
SCHEMA_VERSION_KEY,
|
||||
schema_version
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed writing schema version")?;
|
||||
info!(
|
||||
schema_version = SCHEMA_VERSION,
|
||||
"initialized state schema version"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schema_version(&self) -> Result<u32> {
|
||||
let raw = sqlx::query_scalar!("SELECT value FROM meta WHERE key = ?1", SCHEMA_VERSION_KEY)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed reading schema version")?;
|
||||
decode_schema(raw.as_ref()).context("failed decoding schema version")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::*;
|
||||
impl Store {
|
||||
pub async fn create_known_device(&self, input: KnownDeviceInput) -> Result<KnownDevice> {
|
||||
let display_name = normalize_required_text(&input.display_name, "display_name")?;
|
||||
let notes = normalize_optional_text(input.notes.as_deref());
|
||||
let identifiers = input
|
||||
.identifiers
|
||||
.into_iter()
|
||||
.map(normalize_device_identifier)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
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 pinned = if input.pinned { 1_i64 } else { 0_i64 };
|
||||
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO known_devices
|
||||
(device_id, display_name, pinned, created_at_unix, updated_at_unix, notes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
device_id,
|
||||
display_name,
|
||||
pinned,
|
||||
now_i64,
|
||||
now_i64,
|
||||
notes
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed persisting known device")?;
|
||||
|
||||
for identifier in &identifiers {
|
||||
insert_device_identifier_tx(&mut tx, &device_id, identifier, now).await?;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing known device transaction")?;
|
||||
self.get_known_device(&device_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("created known device disappeared"))
|
||||
}
|
||||
|
||||
pub async fn list_known_devices(&self) -> Result<Vec<KnownDevice>> {
|
||||
let rows = list_known_device_rows(&self.pool).await?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let device_id = known_device_row_device_id(&row).to_string();
|
||||
out.push(self.known_device_from_row(row, &device_id).await?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn get_known_device(&self, device_id: &str) -> Result<Option<KnownDevice>> {
|
||||
let row = get_known_device_row(&self.pool, device_id).await?;
|
||||
|
||||
match row {
|
||||
Some(row) => self.known_device_from_row(row, device_id).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forget_known_device(&self, device_id: &str) -> Result<bool> {
|
||||
let result = sqlx::query!("DELETE FROM known_devices WHERE device_id = ?1", device_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed deleting known device")?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn attach_device_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
input: DeviceIdentifierInput,
|
||||
) -> 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 exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM known_devices WHERE device_id = ?1"#,
|
||||
device_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.context("failed checking known device existence")?;
|
||||
if exists == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
insert_device_identifier_tx(&mut tx, device_id, &identifier, now).await?;
|
||||
let now_i64 = i64::try_from(now).context("known device timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
|
||||
now_i64,
|
||||
device_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed updating known device timestamp")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing device identifier transaction")?;
|
||||
self.get_known_device(device_id).await
|
||||
}
|
||||
|
||||
pub async fn attach_observation_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let observation = get_observation_identifier_row(&self.pool, observation_key).await?;
|
||||
|
||||
let Some(observation) = observation else {
|
||||
anyhow::bail!("observation not found");
|
||||
};
|
||||
let input = observation_identifier_to_input(observation)?;
|
||||
|
||||
self.attach_device_identifier(device_id, input).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub async fn lookup_known_device_by_identifier(
|
||||
&self,
|
||||
input: DeviceIdentifierInput,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let identifier = normalize_device_identifier(input)?;
|
||||
let identifier_key = normalized_identifier_key_owned(identifier);
|
||||
let device_id = sqlx::query_scalar!(
|
||||
"SELECT device_id FROM device_identifiers WHERE identifier_key = ?1",
|
||||
identifier_key
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.context("failed looking up known device identifier")?;
|
||||
match device_id {
|
||||
Some(device_id) => self.get_known_device(&device_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn known_device_from_row(
|
||||
&self,
|
||||
row: KnownDeviceRow,
|
||||
device_id: &str,
|
||||
) -> Result<KnownDevice> {
|
||||
let identifiers = self.list_device_identifiers(device_id).await?;
|
||||
known_device_from_row_and_identifiers(row, identifiers)
|
||||
}
|
||||
|
||||
async fn list_device_identifiers(&self, device_id: &str) -> Result<Vec<DeviceIdentifier>> {
|
||||
let rows = list_device_identifier_rows(&self.pool, device_id).await?;
|
||||
rows.into_iter().map(device_identifier_from_row).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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 expires_at_unix = sqlx::query_scalar!(
|
||||
"SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1",
|
||||
enroll_token
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.context("failed reading enroll token")?;
|
||||
|
||||
let Some(expires_at_unix) = expires_at_unix else {
|
||||
warn!("rejecting enroll attempt with invalid, expired, or consumed token");
|
||||
anyhow::bail!("invalid or already-used enroll token");
|
||||
};
|
||||
|
||||
let now = now_unix();
|
||||
if expires_at_unix as u64 <= now {
|
||||
sqlx::query!("DELETE FROM enroll_tokens WHERE token = ?1", enroll_token)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed removing expired enroll token")?;
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing expired enroll token removal")?;
|
||||
warn!(
|
||||
expires_at_unix,
|
||||
now_unix = now,
|
||||
"rejecting expired enroll token"
|
||||
);
|
||||
anyhow::bail!("enroll token has expired");
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM enroll_tokens WHERE token = ?1", enroll_token)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed consuming enroll token")?;
|
||||
|
||||
let agent_id = format!("agent-{}", Uuid::new_v4());
|
||||
let agent_token = format!("tok-{}", Uuid::new_v4());
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
|
||||
agent_id,
|
||||
agent_token
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed persisting agent credentials")?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing state db after enroll")?;
|
||||
info!(agent_id = %agent_id, "issued persistent agent credentials");
|
||||
|
||||
Ok(IssuedAgent {
|
||||
agent_id,
|
||||
agent_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn issue_enroll_token(&self, ttl: Duration) -> Result<IssuedEnrollToken> {
|
||||
let token = format!("enr-{}", Uuid::new_v4());
|
||||
let expires_at_unix = now_unix().saturating_add(ttl.as_secs().max(1));
|
||||
let expires_at_unix_i64 =
|
||||
i64::try_from(expires_at_unix).context("expiry does not fit SQLite integer")?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
token,
|
||||
expires_at_unix_i64
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed persisting enroll token")?;
|
||||
info!(expires_at_unix, "persisted new enroll token");
|
||||
Ok(IssuedEnrollToken {
|
||||
enroll_token: token,
|
||||
expires_at_unix,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_enroll_tokens(&self) -> Result<Vec<EnrollTokenInfo>> {
|
||||
let now = now_unix();
|
||||
let rows = list_enroll_token_rows(&self.pool).await?;
|
||||
rows.into_iter()
|
||||
.map(|row| enroll_token_info_from_row(row, now))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn revoke_enroll_token(&self, token: &str) -> Result<bool> {
|
||||
let result = sqlx::query!("DELETE FROM enroll_tokens WHERE token = ?1", token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed removing enroll token")?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn revoke_agent(&self, agent_id: &str) -> Result<bool> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting revoke transaction")?;
|
||||
let result = sqlx::query!("DELETE FROM agents WHERE agent_id = ?1", agent_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed removing agent credentials")?;
|
||||
if result.rows_affected() > 0 {
|
||||
sqlx::query!("DELETE FROM agent_meta WHERE agent_id = ?1", agent_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed removing agent metadata")?;
|
||||
}
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing db after agent revoke")?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn set_agent_nickname(&self, agent_id: &str, nickname: Option<&str>) -> Result<bool> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"SELECT COUNT(*) as "count!: i64" FROM agents WHERE agent_id = ?1"#,
|
||||
agent_id
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.context("failed checking agent existence")?;
|
||||
if exists == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let normalized = nickname.map(str::trim).filter(|v| !v.is_empty());
|
||||
if let Some(value) = normalized {
|
||||
sqlx::query!(
|
||||
"INSERT INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET nickname = excluded.nickname",
|
||||
agent_id,
|
||||
value
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed persisting agent nickname")?;
|
||||
} else {
|
||||
sqlx::query!("DELETE FROM agent_meta WHERE agent_id = ?1", agent_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed clearing agent nickname")?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn verify_agent_token(&self, agent_id: &str, token: &str) -> bool {
|
||||
match sqlx::query_scalar!(
|
||||
"SELECT agent_token FROM agents WHERE agent_id = ?1",
|
||||
agent_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(Some(value)) => value == token,
|
||||
Ok(None) => false,
|
||||
Err(err) => {
|
||||
warn!(error = %err, agent_id = %agent_id, "failed to read agent token from state db");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agents(&self) -> Vec<String> {
|
||||
match sqlx::query_scalar!(r#"SELECT agent_id as "agent_id!" FROM agents ORDER BY agent_id"#)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(out) => out,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agents from state db");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agents_with_nicknames(&self) -> Vec<(String, Option<String>)> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT agents.agent_id as "agent_id!", agent_meta.nickname
|
||||
FROM agents
|
||||
LEFT JOIN agent_meta ON agent_meta.agent_id = agents.agent_id
|
||||
ORDER BY agents.agent_id"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await;
|
||||
|
||||
match rows {
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let nickname = row
|
||||
.nickname
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
(row.agent_id, nickname)
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agents from state db");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) mod alerts_audit;
|
||||
pub(super) mod core;
|
||||
pub(super) mod legacy;
|
||||
pub(super) mod rows;
|
||||
@@ -0,0 +1,211 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::state::store) async fn insert_audit_event(
|
||||
pool: &SqlitePool,
|
||||
key: &str,
|
||||
event: &AuditEvent,
|
||||
) -> Result<()> {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&event.metadata).context("failed to encode audit metadata")?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO audit_events
|
||||
(event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,
|
||||
event_type, outcome, latency_ms, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&event.event_id)
|
||||
.bind(i64::try_from(event.ts_unix).context("audit timestamp overflow")?)
|
||||
.bind(&event.actor_type)
|
||||
.bind(&event.actor_id)
|
||||
.bind(&event.agent_id)
|
||||
.bind(&event.request_id)
|
||||
.bind(&event.event_type)
|
||||
.bind(&event.outcome)
|
||||
.bind(
|
||||
event
|
||||
.latency_ms
|
||||
.map(i64::try_from)
|
||||
.transpose()
|
||||
.context("audit latency overflow")?,
|
||||
)
|
||||
.bind(&event.message)
|
||||
.bind(metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed persisting audit event")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_active_alert(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
alert: &AlertState,
|
||||
) -> Result<()> {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&alert.metadata).context("failed to encode active alert metadata")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO active_alerts
|
||||
(alert_id, kind, severity, status, agent_id, message, value, threshold,
|
||||
last_seen_unix, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
)
|
||||
.bind(&alert.alert_id)
|
||||
.bind(&alert.kind)
|
||||
.bind(&alert.severity)
|
||||
.bind(&alert.status)
|
||||
.bind(&alert.agent_id)
|
||||
.bind(&alert.message)
|
||||
.bind(i64::try_from(alert.value).context("active alert value overflow")?)
|
||||
.bind(i64::try_from(alert.threshold).context("active alert threshold overflow")?)
|
||||
.bind(i64::try_from(alert.last_seen_unix).context("active alert timestamp overflow")?)
|
||||
.bind(metadata_json)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.context("failed writing active alert snapshot")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_active_alert_pool(
|
||||
pool: &SqlitePool,
|
||||
alert: &AlertState,
|
||||
) -> Result<()> {
|
||||
let metadata_json =
|
||||
serde_json::to_string(&alert.metadata).context("failed to encode active alert metadata")?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO active_alerts
|
||||
(alert_id, kind, severity, status, agent_id, message, value, threshold,
|
||||
last_seen_unix, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
)
|
||||
.bind(&alert.alert_id)
|
||||
.bind(&alert.kind)
|
||||
.bind(&alert.severity)
|
||||
.bind(&alert.status)
|
||||
.bind(&alert.agent_id)
|
||||
.bind(&alert.message)
|
||||
.bind(i64::try_from(alert.value).context("active alert value overflow")?)
|
||||
.bind(i64::try_from(alert.threshold).context("active alert threshold overflow")?)
|
||||
.bind(i64::try_from(alert.last_seen_unix).context("active alert timestamp overflow")?)
|
||||
.bind(metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed writing active alert snapshot")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_alert_transition(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
key: &str,
|
||||
transition: &AlertTransition,
|
||||
) -> Result<()> {
|
||||
let metadata_json = serde_json::to_string(&transition.metadata)
|
||||
.context("failed to encode alert transition metadata")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO alert_transitions
|
||||
(transition_key, transition_id, ts_unix, alert_id, kind, agent_id,
|
||||
from_status, to_status, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&transition.transition_id)
|
||||
.bind(i64::try_from(transition.ts_unix).context("alert timestamp overflow")?)
|
||||
.bind(&transition.alert_id)
|
||||
.bind(&transition.kind)
|
||||
.bind(&transition.agent_id)
|
||||
.bind(&transition.from_status)
|
||||
.bind(&transition.to_status)
|
||||
.bind(&transition.message)
|
||||
.bind(metadata_json)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.context("failed persisting alert transition")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_alert_transition_pool(
|
||||
pool: &SqlitePool,
|
||||
key: &str,
|
||||
transition: &AlertTransition,
|
||||
) -> Result<()> {
|
||||
let metadata_json = serde_json::to_string(&transition.metadata)
|
||||
.context("failed to encode alert transition metadata")?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO alert_transitions
|
||||
(transition_key, transition_id, ts_unix, alert_id, kind, agent_id,
|
||||
from_status, to_status, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&transition.transition_id)
|
||||
.bind(i64::try_from(transition.ts_unix).context("alert timestamp overflow")?)
|
||||
.bind(&transition.alert_id)
|
||||
.bind(&transition.kind)
|
||||
.bind(&transition.agent_id)
|
||||
.bind(&transition.from_status)
|
||||
.bind(&transition.to_status)
|
||||
.bind(&transition.message)
|
||||
.bind(metadata_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed persisting alert transition")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn audit_event_from_row(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
) -> Result<AuditEvent> {
|
||||
let ts_unix: i64 = row.try_get("ts_unix")?;
|
||||
let latency_ms: Option<i64> = row.try_get("latency_ms")?;
|
||||
let metadata_json: String = row.try_get("metadata_json")?;
|
||||
Ok(AuditEvent {
|
||||
event_id: row.try_get("event_id")?,
|
||||
ts_unix: u64::try_from(ts_unix).context("negative audit timestamp in state db")?,
|
||||
actor_type: row.try_get("actor_type")?,
|
||||
actor_id: row.try_get("actor_id")?,
|
||||
agent_id: row.try_get("agent_id")?,
|
||||
request_id: row.try_get("request_id")?,
|
||||
event_type: row.try_get("event_type")?,
|
||||
outcome: row.try_get("outcome")?,
|
||||
latency_ms: latency_ms
|
||||
.map(u64::try_from)
|
||||
.transpose()
|
||||
.context("negative audit latency in state db")?,
|
||||
message: row.try_get("message")?,
|
||||
metadata: serde_json::from_str(&metadata_json).context("failed decoding audit metadata")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn alert_state_from_row(row: AlertStateRow) -> Result<AlertState> {
|
||||
Ok(AlertState {
|
||||
alert_id: row.alert_id,
|
||||
kind: row.kind,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
agent_id: row.agent_id,
|
||||
message: row.message,
|
||||
value: u64::try_from(row.value).context("negative active alert value in state db")?,
|
||||
threshold: u64::try_from(row.threshold)
|
||||
.context("negative active alert threshold in state db")?,
|
||||
last_seen_unix: u64::try_from(row.last_seen_unix)
|
||||
.context("negative active alert timestamp in state db")?,
|
||||
metadata: serde_json::from_str(&row.metadata_json)
|
||||
.context("failed decoding active alert metadata")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn alert_transition_from_row(
|
||||
row: AlertTransitionRow,
|
||||
) -> Result<AlertTransition> {
|
||||
Ok(AlertTransition {
|
||||
transition_id: row.transition_id,
|
||||
ts_unix: u64::try_from(row.ts_unix).context("negative alert timestamp in state db")?,
|
||||
alert_id: row.alert_id,
|
||||
kind: row.kind,
|
||||
agent_id: row.agent_id,
|
||||
from_status: row.from_status,
|
||||
to_status: row.to_status,
|
||||
message: row.message,
|
||||
metadata: serde_json::from_str(&row.metadata_json)
|
||||
.context("failed decoding alert transition metadata")?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::state::store) async fn open_sqlite_pool(path: &Path) -> Result<SqlitePool> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal);
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.with_context(|| format!("failed to open SQLite state db {}", path.display()))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn sql_count(pool: &SqlitePool, table: &str) -> Result<i64> {
|
||||
let sql = format!("SELECT COUNT(*) FROM {table}");
|
||||
sqlx::query_scalar::<_, i64>(&sql)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.with_context(|| format!("failed counting {table}"))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn decode_expiry(raw: &[u8]) -> Result<u64> {
|
||||
if raw.len() != 8 {
|
||||
anyhow::bail!("invalid token expiry length {}", raw.len());
|
||||
}
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(raw);
|
||||
Ok(u64::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn decode_schema(raw: &[u8]) -> Result<u32> {
|
||||
if raw.len() != 4 {
|
||||
anyhow::bail!("invalid schema version length {}", raw.len());
|
||||
}
|
||||
let mut arr = [0u8; 4];
|
||||
arr.copy_from_slice(raw);
|
||||
Ok(u32::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn seeded_enroll_token_key(token: &str) -> String {
|
||||
format!("{SEEDED_ENROLL_TOKEN_PREFIX}{token}")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_required_text(value: &str, field: &str) -> Result<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
anyhow::bail!("{field} must not be empty");
|
||||
}
|
||||
Ok(normalized.to_string())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_device_identifier(
|
||||
input: DeviceIdentifierInput,
|
||||
) -> Result<NormalizedDeviceIdentifier> {
|
||||
let kind = normalize_required_text(&input.kind, "identifier kind")?.to_ascii_lowercase();
|
||||
let value = normalize_required_text(&input.value, "identifier value")?.to_ascii_lowercase();
|
||||
let identifier_key = format!("{kind}:{value}");
|
||||
Ok(NormalizedDeviceIdentifier {
|
||||
identifier_key,
|
||||
kind,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(in crate::state::store) fn normalized_identifier_key_owned(
|
||||
identifier: NormalizedDeviceIdentifier,
|
||||
) -> String {
|
||||
identifier.identifier_key
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn list_enroll_token_rows(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<Vec<EnrollTokenRow>> {
|
||||
sqlx::query_as!(
|
||||
EnrollTokenRow,
|
||||
r#"SELECT token as "token!", expires_at_unix FROM enroll_tokens ORDER BY expires_at_unix, token"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("failed listing enroll tokens")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn enroll_token_info_from_row(
|
||||
row: EnrollTokenRow,
|
||||
now: u64,
|
||||
) -> Result<EnrollTokenInfo> {
|
||||
let expires_at_unix =
|
||||
u64::try_from(row.expires_at_unix).context("negative token expiry in state db")?;
|
||||
Ok(EnrollTokenInfo {
|
||||
enroll_token: row.token,
|
||||
expires_at_unix,
|
||||
expired: expires_at_unix <= now,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn list_known_device_rows(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<Vec<KnownDeviceRow>> {
|
||||
sqlx::query_as!(
|
||||
KnownDeviceRow,
|
||||
r#"SELECT device_id as "device_id!", display_name as "display_name!",
|
||||
pinned, created_at_unix, updated_at_unix, notes
|
||||
FROM known_devices
|
||||
ORDER BY pinned DESC, display_name, device_id"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("failed listing known devices")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn get_known_device_row(
|
||||
pool: &SqlitePool,
|
||||
device_id: &str,
|
||||
) -> Result<Option<KnownDeviceRow>> {
|
||||
sqlx::query_as!(
|
||||
KnownDeviceRow,
|
||||
r#"SELECT device_id as "device_id!", display_name as "display_name!",
|
||||
pinned, created_at_unix, updated_at_unix, notes
|
||||
FROM known_devices
|
||||
WHERE device_id = ?1"#,
|
||||
device_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("failed reading known device")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn known_device_row_device_id(row: &KnownDeviceRow) -> &str {
|
||||
&row.device_id
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn known_device_from_row_and_identifiers(
|
||||
row: KnownDeviceRow,
|
||||
identifiers: Vec<DeviceIdentifier>,
|
||||
) -> Result<KnownDevice> {
|
||||
Ok(KnownDevice {
|
||||
device_id: row.device_id,
|
||||
display_name: row.display_name,
|
||||
pinned: row.pinned != 0,
|
||||
created_at_unix: u64::try_from(row.created_at_unix)
|
||||
.context("negative known device created timestamp in state db")?,
|
||||
updated_at_unix: u64::try_from(row.updated_at_unix)
|
||||
.context("negative known device updated timestamp in state db")?,
|
||||
notes: row.notes,
|
||||
identifiers,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn list_device_identifier_rows(
|
||||
pool: &SqlitePool,
|
||||
device_id: &str,
|
||||
) -> Result<Vec<DeviceIdentifierRow>> {
|
||||
sqlx::query_as!(
|
||||
DeviceIdentifierRow,
|
||||
r#"SELECT identifier_key as "identifier_key!", device_id as "device_id!",
|
||||
kind as "kind!", value as "value!", created_at_unix
|
||||
FROM device_identifiers
|
||||
WHERE device_id = ?1
|
||||
ORDER BY kind, value"#,
|
||||
device_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("failed listing device identifiers")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn get_observation_identifier_row(
|
||||
pool: &SqlitePool,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<ObservationIdentifierRow>> {
|
||||
sqlx::query_as!(
|
||||
ObservationIdentifierRow,
|
||||
r#"SELECT mac, ip
|
||||
FROM agent_device_observations
|
||||
WHERE observation_key = ?1"#,
|
||||
observation_key
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("failed reading observation identifier")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn get_observation_current_row(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<ObservationCurrentRow>> {
|
||||
sqlx::query_as!(
|
||||
ObservationCurrentRow,
|
||||
r#"SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,
|
||||
last_action as "last_action!"
|
||||
FROM agent_device_observations
|
||||
WHERE observation_key = ?1"#,
|
||||
observation_key
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.context("failed reading current observation")
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn observation_current_changed(
|
||||
current: Option<&ObservationCurrentRow>,
|
||||
next: &AgentDeviceObservation,
|
||||
first_seen_unix: i64,
|
||||
last_seen_unix: i64,
|
||||
) -> bool {
|
||||
let Some(current) = current else {
|
||||
return true;
|
||||
};
|
||||
current.mac != next.mac
|
||||
|| current.ip != next.ip
|
||||
|| current.hostname != next.hostname
|
||||
|| current.first_seen_unix != first_seen_unix
|
||||
|| current.last_seen_unix != last_seen_unix
|
||||
|| current.last_action != next.last_action
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn observation_identifier_to_input(
|
||||
observation: ObservationIdentifierRow,
|
||||
) -> Result<DeviceIdentifierInput> {
|
||||
observation
|
||||
.mac
|
||||
.map(|value| DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value,
|
||||
})
|
||||
.or_else(|| {
|
||||
observation.ip.map(|value| DeviceIdentifierInput {
|
||||
kind: "ip".into(),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("observation has no attachable mac or ip"))
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn normalize_agent_observation(
|
||||
agent_id: &str,
|
||||
input: AgentDeviceObservationInput,
|
||||
) -> Result<AgentDeviceObservation> {
|
||||
let kind = normalize_required_text(&input.kind, "observation kind")?.to_ascii_lowercase();
|
||||
let action = normalize_required_text(&input.action, "observation action")?.to_ascii_lowercase();
|
||||
let mac = normalize_optional_text(input.mac.as_deref()).map(|value| value.to_ascii_lowercase());
|
||||
let ip = normalize_optional_text(input.ip.as_deref());
|
||||
let hostname = normalize_optional_text(input.hostname.as_deref());
|
||||
let identifier = mac
|
||||
.as_ref()
|
||||
.map(|value| format!("mac:{value}"))
|
||||
.or_else(|| ip.as_ref().map(|value| format!("ip:{value}")))
|
||||
.ok_or_else(|| anyhow::anyhow!("observation requires mac or ip"))?;
|
||||
let observation_key = format!("agent:{agent_id}:{kind}:{identifier}");
|
||||
Ok(AgentDeviceObservation {
|
||||
observation_key,
|
||||
agent_id: agent_id.to_string(),
|
||||
kind,
|
||||
mac,
|
||||
ip,
|
||||
hostname,
|
||||
first_seen_unix: input.first_seen_unix,
|
||||
last_seen_unix: input.last_seen_unix,
|
||||
last_action: action,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn insert_device_identifier_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
device_id: &str,
|
||||
identifier: &NormalizedDeviceIdentifier,
|
||||
created_at_unix: u64,
|
||||
) -> Result<()> {
|
||||
let created_at_unix =
|
||||
i64::try_from(created_at_unix).context("device identifier timestamp overflow")?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO device_identifiers
|
||||
(identifier_key, device_id, kind, value, created_at_unix)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
identifier.identifier_key,
|
||||
device_id,
|
||||
identifier.kind,
|
||||
identifier.value,
|
||||
created_at_unix
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed attaching device identifier {} to {}",
|
||||
identifier.identifier_key, device_id
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn device_identifier_from_row(
|
||||
row: DeviceIdentifierRow,
|
||||
) -> Result<DeviceIdentifier> {
|
||||
Ok(DeviceIdentifier {
|
||||
identifier_key: row.identifier_key,
|
||||
device_id: row.device_id,
|
||||
kind: row.kind,
|
||||
value: row.value,
|
||||
created_at_unix: u64::try_from(row.created_at_unix)
|
||||
.context("negative device identifier timestamp in state db")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(in crate::state::store) fn agent_observation_from_row(
|
||||
row: AgentObservationRow,
|
||||
) -> Result<AgentDeviceObservation> {
|
||||
Ok(AgentDeviceObservation {
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
first_seen_unix: u64::try_from(row.first_seen_unix)
|
||||
.context("negative observation first_seen timestamp in state db")?,
|
||||
last_seen_unix: u64::try_from(row.last_seen_unix)
|
||||
.context("negative observation last_seen timestamp in state db")?,
|
||||
last_action: row.last_action,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn agent_observation_view_from_row(
|
||||
row: AgentObservationViewRow,
|
||||
) -> Result<AgentDeviceObservationView> {
|
||||
let known_device = match (row.device_id, row.display_name, row.pinned) {
|
||||
(Some(device_id), Some(display_name), Some(pinned)) => Some(KnownDeviceSummary {
|
||||
device_id,
|
||||
display_name,
|
||||
pinned: pinned != 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(AgentDeviceObservationView {
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
first_seen_unix: u64::try_from(row.first_seen_unix)
|
||||
.context("negative observation first_seen timestamp in state db")?,
|
||||
last_seen_unix: u64::try_from(row.last_seen_unix)
|
||||
.context("negative observation last_seen timestamp in state db")?,
|
||||
last_action: row.last_action,
|
||||
known_device,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::state::store) fn agent_observation_event_from_row(
|
||||
row: AgentObservationEventRow,
|
||||
) -> Result<AgentDeviceObservationEvent> {
|
||||
let known_device = match (row.device_id, row.display_name, row.pinned) {
|
||||
(Some(device_id), Some(display_name), Some(pinned)) => Some(KnownDeviceSummary {
|
||||
device_id,
|
||||
display_name,
|
||||
pinned: pinned != 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(AgentDeviceObservationEvent {
|
||||
event_id: row.event_id,
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
action: row.action,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
ts_unix: u64::try_from(row.ts_unix)
|
||||
.context("negative observation event timestamp in state db")?,
|
||||
known_device,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use super::*;
|
||||
|
||||
pub(in crate::state::store) async fn import_tree_raw(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
sled_tree: &str,
|
||||
sql_table: &str,
|
||||
key_col: &str,
|
||||
value_col: &str,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree(sled_tree)
|
||||
.with_context(|| format!("failed to open legacy {sled_tree} tree"))?;
|
||||
let sql =
|
||||
format!("INSERT OR REPLACE INTO {sql_table} ({key_col}, {value_col}) VALUES (?1, ?2)");
|
||||
for item in tree.iter() {
|
||||
let (key, value) =
|
||||
item.with_context(|| format!("failed reading legacy {sled_tree} tree"))?;
|
||||
let key = String::from_utf8(key.to_vec())
|
||||
.with_context(|| format!("legacy {sled_tree} key is not utf-8"))?;
|
||||
sqlx::query(&sql)
|
||||
.bind(key)
|
||||
.bind(value.to_vec())
|
||||
.execute(pool)
|
||||
.await
|
||||
.with_context(|| format!("failed importing legacy {sled_tree} row"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_enroll_tokens(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("enroll_tokens")
|
||||
.context("failed to open legacy enroll_tokens tree")?;
|
||||
for item in tree.iter() {
|
||||
let (token, expiry) = item.context("failed reading legacy enroll token")?;
|
||||
let token =
|
||||
String::from_utf8(token.to_vec()).context("legacy enroll token key is not utf-8")?;
|
||||
let expires_at_unix = decode_expiry(expiry.as_ref())?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(i64::try_from(expires_at_unix).context("legacy token expiry overflow")?)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy enroll token")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_agents(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("agents")
|
||||
.context("failed to open legacy agents tree")?;
|
||||
for item in tree.iter() {
|
||||
let (agent_id, agent_token) = item.context("failed reading legacy agent")?;
|
||||
let agent_id =
|
||||
String::from_utf8(agent_id.to_vec()).context("legacy agent id is not utf-8")?;
|
||||
let agent_token =
|
||||
String::from_utf8(agent_token.to_vec()).context("legacy agent token is not utf-8")?;
|
||||
sqlx::query("INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)")
|
||||
.bind(agent_id)
|
||||
.bind(agent_token)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy agent")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_agent_meta(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("agent_meta")
|
||||
.context("failed to open legacy agent_meta tree")?;
|
||||
for item in tree.iter() {
|
||||
let (agent_id, nickname) = item.context("failed reading legacy agent metadata")?;
|
||||
let agent_id =
|
||||
String::from_utf8(agent_id.to_vec()).context("legacy agent id is not utf-8")?;
|
||||
let nickname =
|
||||
String::from_utf8(nickname.to_vec()).context("legacy nickname is not utf-8")?;
|
||||
sqlx::query("INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)")
|
||||
.bind(agent_id)
|
||||
.bind(nickname)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("failed importing legacy agent metadata")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_audit_events(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("audit_events")
|
||||
.context("failed to open legacy audit_events tree")?;
|
||||
for item in tree.iter() {
|
||||
let (key, value) = item.context("failed reading legacy audit event")?;
|
||||
let key = String::from_utf8(key.to_vec()).context("legacy audit key is not utf-8")?;
|
||||
let event: AuditEvent =
|
||||
serde_json::from_slice(value.as_ref()).context("failed decoding legacy audit event")?;
|
||||
insert_audit_event(pool, &key, &event)
|
||||
.await
|
||||
.context("failed importing legacy audit event")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_active_alerts(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("active_alerts")
|
||||
.context("failed to open legacy active_alerts tree")?;
|
||||
for item in tree.iter() {
|
||||
let (_, value) = item.context("failed reading legacy active alert")?;
|
||||
let alert: AlertState = serde_json::from_slice(value.as_ref())
|
||||
.context("failed decoding legacy active alert")?;
|
||||
insert_active_alert_pool(pool, &alert)
|
||||
.await
|
||||
.context("failed importing legacy active alert")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::state::store) async fn import_alert_transitions(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
) -> Result<()> {
|
||||
let tree = legacy
|
||||
.open_tree("alert_transitions")
|
||||
.context("failed to open legacy alert_transitions tree")?;
|
||||
for item in tree.iter() {
|
||||
let (key, value) = item.context("failed reading legacy alert transition")?;
|
||||
let key =
|
||||
String::from_utf8(key.to_vec()).context("legacy alert transition key is not utf-8")?;
|
||||
let transition: AlertTransition = serde_json::from_slice(value.as_ref())
|
||||
.context("failed decoding legacy alert transition")?;
|
||||
insert_alert_transition_pool(pool, &key, &transition)
|
||||
.await
|
||||
.context("failed importing legacy alert transition")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
pub(in crate::state::store) struct NormalizedDeviceIdentifier {
|
||||
pub(in crate::state::store) identifier_key: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) value: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct EnrollTokenRow {
|
||||
pub(in crate::state::store) token: String,
|
||||
pub(in crate::state::store) expires_at_unix: i64,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct KnownDeviceRow {
|
||||
pub(in crate::state::store) device_id: String,
|
||||
pub(in crate::state::store) display_name: String,
|
||||
pub(in crate::state::store) pinned: i64,
|
||||
pub(in crate::state::store) created_at_unix: i64,
|
||||
pub(in crate::state::store) updated_at_unix: i64,
|
||||
pub(in crate::state::store) notes: Option<String>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct DeviceIdentifierRow {
|
||||
pub(in crate::state::store) identifier_key: String,
|
||||
pub(in crate::state::store) device_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) value: String,
|
||||
pub(in crate::state::store) created_at_unix: i64,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AgentObservationRow {
|
||||
pub(in crate::state::store) observation_key: String,
|
||||
pub(in crate::state::store) agent_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) first_seen_unix: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) last_action: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AgentObservationViewRow {
|
||||
pub(in crate::state::store) observation_key: String,
|
||||
pub(in crate::state::store) agent_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) first_seen_unix: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) last_action: String,
|
||||
pub(in crate::state::store) device_id: Option<String>,
|
||||
pub(in crate::state::store) display_name: Option<String>,
|
||||
pub(in crate::state::store) pinned: Option<i64>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AgentObservationEventRow {
|
||||
pub(in crate::state::store) event_id: String,
|
||||
pub(in crate::state::store) observation_key: String,
|
||||
pub(in crate::state::store) agent_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) action: String,
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) ts_unix: i64,
|
||||
pub(in crate::state::store) device_id: Option<String>,
|
||||
pub(in crate::state::store) display_name: Option<String>,
|
||||
pub(in crate::state::store) pinned: Option<i64>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct ObservationIdentifierRow {
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct ObservationCurrentRow {
|
||||
pub(in crate::state::store) mac: Option<String>,
|
||||
pub(in crate::state::store) ip: Option<String>,
|
||||
pub(in crate::state::store) hostname: Option<String>,
|
||||
pub(in crate::state::store) first_seen_unix: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) last_action: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AlertStateRow {
|
||||
pub(in crate::state::store) alert_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) severity: String,
|
||||
pub(in crate::state::store) status: String,
|
||||
pub(in crate::state::store) agent_id: Option<String>,
|
||||
pub(in crate::state::store) message: String,
|
||||
pub(in crate::state::store) value: i64,
|
||||
pub(in crate::state::store) threshold: i64,
|
||||
pub(in crate::state::store) last_seen_unix: i64,
|
||||
pub(in crate::state::store) metadata_json: String,
|
||||
}
|
||||
|
||||
pub(in crate::state::store) struct AlertTransitionRow {
|
||||
pub(in crate::state::store) transition_id: String,
|
||||
pub(in crate::state::store) ts_unix: i64,
|
||||
pub(in crate::state::store) alert_id: String,
|
||||
pub(in crate::state::store) kind: String,
|
||||
pub(in crate::state::store) agent_id: Option<String>,
|
||||
pub(in crate::state::store) from_status: Option<String>,
|
||||
pub(in crate::state::store) to_status: String,
|
||||
pub(in crate::state::store) message: String,
|
||||
pub(in crate::state::store) metadata_json: String,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
pub async fn import_sled_state(
|
||||
from_sled_state: &Path,
|
||||
to_state_file: &Path,
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
if !from_sled_state.is_dir() {
|
||||
anyhow::bail!(
|
||||
"legacy sled state {} does not exist or is not a directory",
|
||||
from_sled_state.display()
|
||||
);
|
||||
}
|
||||
if to_state_file.is_dir() {
|
||||
anyhow::bail!(
|
||||
"target state file {} is a directory",
|
||||
to_state_file.display()
|
||||
);
|
||||
}
|
||||
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()))?;
|
||||
}
|
||||
|
||||
let legacy = sled::open(from_sled_state).with_context(|| {
|
||||
format!(
|
||||
"failed to open legacy sled state {}",
|
||||
from_sled_state.display()
|
||||
)
|
||||
})?;
|
||||
let store = Store::load_or_init(to_state_file, Vec::new(), Duration::from_secs(1)).await?;
|
||||
|
||||
import_tree_raw(&store.pool, &legacy, "meta", "meta", "key", "value").await?;
|
||||
import_enroll_tokens(&store.pool, &legacy).await?;
|
||||
import_agents(&store.pool, &legacy).await?;
|
||||
import_agent_meta(&store.pool, &legacy).await?;
|
||||
import_audit_events(&store.pool, &legacy).await?;
|
||||
import_active_alerts(&store.pool, &legacy).await?;
|
||||
import_alert_transitions(&store.pool, &legacy).await?;
|
||||
|
||||
info!(
|
||||
from = %from_sled_state.display(),
|
||||
to = %to_state_file.display(),
|
||||
"imported legacy sled state into SQLite"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
pub async fn upsert_agent_observations(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
observations: Vec<AgentDeviceObservationInput>,
|
||||
) -> Result<usize> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.context("failed starting observation transaction")?;
|
||||
let mut written = 0usize;
|
||||
for observation in observations {
|
||||
let observation = normalize_agent_observation(agent_id, observation)?;
|
||||
let first_seen_unix = i64::try_from(observation.first_seen_unix)
|
||||
.context("observation first_seen overflow")?;
|
||||
let last_seen_unix = i64::try_from(observation.last_seen_unix)
|
||||
.context("observation last_seen overflow")?;
|
||||
let current = get_observation_current_row(&mut tx, &observation.observation_key)
|
||||
.await
|
||||
.context("failed checking existing observation")?;
|
||||
let append_event = observation_current_changed(
|
||||
current.as_ref(),
|
||||
&observation,
|
||||
first_seen_unix,
|
||||
last_seen_unix,
|
||||
);
|
||||
sqlx::query!(
|
||||
"INSERT INTO agent_device_observations
|
||||
(observation_key, agent_id, kind, mac, ip, hostname,
|
||||
first_seen_unix, last_seen_unix, last_action)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(observation_key) DO UPDATE SET
|
||||
mac = excluded.mac,
|
||||
ip = excluded.ip,
|
||||
hostname = excluded.hostname,
|
||||
first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),
|
||||
last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),
|
||||
last_action = excluded.last_action",
|
||||
observation.observation_key,
|
||||
observation.agent_id,
|
||||
observation.kind,
|
||||
observation.mac,
|
||||
observation.ip,
|
||||
observation.hostname,
|
||||
first_seen_unix,
|
||||
last_seen_unix,
|
||||
observation.last_action
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed upserting agent device observation")?;
|
||||
|
||||
if append_event {
|
||||
let event_id = format!("ode-{}", Uuid::new_v4());
|
||||
sqlx::query!(
|
||||
"INSERT INTO agent_device_observation_events
|
||||
(event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
event_id,
|
||||
observation.agent_id,
|
||||
observation.kind,
|
||||
observation.last_action,
|
||||
observation.mac,
|
||||
observation.ip,
|
||||
observation.hostname,
|
||||
last_seen_unix
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("failed appending agent device observation event")?;
|
||||
}
|
||||
written = written.saturating_add(1);
|
||||
}
|
||||
tx.commit()
|
||||
.await
|
||||
.context("failed committing observation transaction")?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub async fn list_agent_observations(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservation>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation limit overflow")?;
|
||||
let rows = if let Some(agent_id) = agent_id {
|
||||
sqlx::query_as!(
|
||||
AgentObservationRow,
|
||||
r#"SELECT observation_key as "observation_key!", agent_id as "agent_id!",
|
||||
kind as "kind!", mac, ip, hostname,
|
||||
first_seen_unix, last_seen_unix, last_action as "last_action!"
|
||||
FROM agent_device_observations
|
||||
WHERE agent_id = ?1
|
||||
ORDER BY last_seen_unix DESC
|
||||
LIMIT ?2"#,
|
||||
agent_id,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
AgentObservationRow,
|
||||
r#"SELECT observation_key as "observation_key!", agent_id as "agent_id!",
|
||||
kind as "kind!", mac, ip, hostname,
|
||||
first_seen_unix, last_seen_unix, last_action as "last_action!"
|
||||
FROM agent_device_observations
|
||||
ORDER BY last_seen_unix DESC
|
||||
LIMIT ?1"#,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.context("failed listing agent observations")?;
|
||||
rows.into_iter().map(agent_observation_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_views(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservationView>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation limit overflow")?;
|
||||
let rows = if let Some(agent_id) = agent_id {
|
||||
sqlx::query_as!(
|
||||
AgentObservationViewRow,
|
||||
r#"SELECT observations.observation_key as "observation_key!",
|
||||
observations.agent_id as "agent_id!",
|
||||
observations.kind as "kind!",
|
||||
observations.mac,
|
||||
observations.ip,
|
||||
observations.hostname,
|
||||
observations.first_seen_unix,
|
||||
observations.last_seen_unix,
|
||||
observations.last_action as "last_action!",
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observations observations
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac
|
||||
WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
WHERE observations.agent_id = ?1
|
||||
ORDER BY observations.last_seen_unix DESC
|
||||
LIMIT ?2"#,
|
||||
agent_id,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
AgentObservationViewRow,
|
||||
r#"SELECT observations.observation_key as "observation_key!",
|
||||
observations.agent_id as "agent_id!",
|
||||
observations.kind as "kind!",
|
||||
observations.mac,
|
||||
observations.ip,
|
||||
observations.hostname,
|
||||
observations.first_seen_unix,
|
||||
observations.last_seen_unix,
|
||||
observations.last_action as "last_action!",
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observations observations
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac
|
||||
WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
ORDER BY observations.last_seen_unix DESC
|
||||
LIMIT ?1"#,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.context("failed listing agent observation views")?;
|
||||
rows.into_iter()
|
||||
.map(agent_observation_view_from_row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_events(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
mac: Option<&str>,
|
||||
ip: Option<&str>,
|
||||
observation_key: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservationEvent>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation event limit overflow")?;
|
||||
let rows = sqlx::query_as!(
|
||||
AgentObservationEventRow,
|
||||
r#"SELECT events.event_id as "event_id!",
|
||||
('agent:' || events.agent_id || ':' || events.kind || ':' ||
|
||||
CASE
|
||||
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
|
||||
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
ELSE ''
|
||||
END) as "observation_key!",
|
||||
events.agent_id as "agent_id!",
|
||||
events.kind as "kind!",
|
||||
events.action as "action!",
|
||||
events.mac,
|
||||
events.ip,
|
||||
events.hostname,
|
||||
events.ts_unix,
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observation_events events
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
|
||||
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
WHERE (?1 IS NULL OR events.agent_id = ?1)
|
||||
AND (?2 IS NULL OR events.kind = ?2)
|
||||
AND (?3 IS NULL OR events.mac = ?3)
|
||||
AND (?4 IS NULL OR events.ip = ?4)
|
||||
AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||
|
||||
CASE
|
||||
WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac
|
||||
WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip
|
||||
ELSE ''
|
||||
END) = ?5)
|
||||
ORDER BY events.ts_unix DESC
|
||||
LIMIT ?6"#,
|
||||
agent_id,
|
||||
kind,
|
||||
mac,
|
||||
ip,
|
||||
observation_key,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed listing agent observation events")?;
|
||||
rows.into_iter()
|
||||
.map(agent_observation_event_from_row)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,99 @@ pub struct StateStats {
|
||||
pub expired_enroll_token_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownDevice {
|
||||
pub device_id: String,
|
||||
pub display_name: String,
|
||||
pub pinned: bool,
|
||||
pub created_at_unix: u64,
|
||||
pub updated_at_unix: u64,
|
||||
pub notes: Option<String>,
|
||||
pub identifiers: Vec<DeviceIdentifier>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceIdentifier {
|
||||
pub identifier_key: String,
|
||||
pub device_id: String,
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
pub created_at_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KnownDeviceInput {
|
||||
pub display_name: String,
|
||||
pub pinned: bool,
|
||||
pub notes: Option<String>,
|
||||
pub identifiers: Vec<DeviceIdentifierInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceIdentifierInput {
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservation {
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservationView {
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservationEvent {
|
||||
pub event_id: String,
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub ts_unix: u64,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceSummary {
|
||||
pub device_id: String,
|
||||
pub display_name: String,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceObservationInput {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub event_id: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use macaddr::MacAddr;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -32,11 +32,16 @@ impl From<NeighborState> for Presence {
|
||||
}
|
||||
}
|
||||
|
||||
/// MAC-first identifier for a device aggregate.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
|
||||
pub struct DeviceId {
|
||||
/// Observed identifier for a discovered device aggregate.
|
||||
///
|
||||
/// This is not a durable, user-approved identity. The control plane may attach
|
||||
/// many observed identifiers to one saved device.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum DeviceId {
|
||||
#[serde(with = "mac")]
|
||||
pub mac: MacAddr,
|
||||
Mac(MacAddr),
|
||||
Ip(IpAddr),
|
||||
}
|
||||
|
||||
/// Merged view of one discovered network identity.
|
||||
@@ -91,10 +96,16 @@ impl Device {
|
||||
}
|
||||
|
||||
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));
|
||||
Self {
|
||||
id: macs.first().copied().map(|mac| DeviceId { mac }),
|
||||
id,
|
||||
names: names.into_iter().map(|n| n.to_owned()).collect(),
|
||||
ips: ips.into_iter().collect(),
|
||||
ips,
|
||||
macs,
|
||||
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
|
||||
neighbors,
|
||||
|
||||
@@ -8,10 +8,14 @@ publish = ["gitea"]
|
||||
anyhow = "1"
|
||||
futures = "0"
|
||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "net", "rt", "sync"] }
|
||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0"}
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
|
||||
[dependencies.lda-ipjs]
|
||||
path = "../ipjs"
|
||||
registry = "gitea"
|
||||
|
||||
+409
-8
@@ -1,13 +1,67 @@
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wakey_core::{DhcpLease, DhcpLeaseWithState};
|
||||
|
||||
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||
const DEFAULT_DHCP_LEASES: &str = "/tmp/dhcp.leases";
|
||||
const DEFAULT_MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
|
||||
const DEFAULT_OBSERVATION_STORE: &str = "/tmp/wakey_observations.json";
|
||||
const DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
||||
const MAC_NAME_CACHE_ENV: &str = "WAKEY_MAC_NAME_CACHE";
|
||||
const OBSERVATION_STORE_ENV: &str = "WAKEY_OBSERVATION_STORE";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LocalObservationStore {
|
||||
#[serde(default)]
|
||||
pub dhcp_clients: std::collections::BTreeMap<String, ObservedDhcpClient>,
|
||||
#[serde(default)]
|
||||
pub neighbors: std::collections::BTreeMap<String, ObservedNeighbor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObservedDhcpClient {
|
||||
pub mac: String,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObservedNeighbor {
|
||||
pub key: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalDeviceObservation {
|
||||
pub kind: String,
|
||||
pub action: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
/// Load the MAC-to-name cache used to preserve useful names across lease churn.
|
||||
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
|
||||
load_mac_name_cache_from_path(mac_name_cache_path()).await
|
||||
}
|
||||
|
||||
pub async fn load_mac_name_cache_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> io::Result<std::collections::BTreeMap<String, String>> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||
Err(e) => Err(e),
|
||||
@@ -16,11 +70,212 @@ pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<Stri
|
||||
|
||||
/// Persist the MAC-to-name cache back to disk.
|
||||
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
|
||||
save_mac_name_cache_to_path(mac_name_cache_path(), map).await
|
||||
}
|
||||
|
||||
async fn save_mac_name_cache_to_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
map: &std::collections::BTreeMap<String, String>,
|
||||
) -> io::Result<()> {
|
||||
let s = serde_json::to_string(map).map_err(io::Error::other)?;
|
||||
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
|
||||
let _ = tokio::fs::write(path, s).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_observation_store() -> io::Result<LocalObservationStore> {
|
||||
match tokio::fs::read_to_string(observation_store_path()).await {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_observation_store_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> io::Result<LocalObservationStore> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_observation_store(store: &LocalObservationStore) -> io::Result<()> {
|
||||
let s = serde_json::to_string(store).map_err(io::Error::other)?;
|
||||
tokio::fs::write(observation_store_path(), s).await
|
||||
}
|
||||
|
||||
pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>> {
|
||||
let store = load_observation_store().await?;
|
||||
list_local_observations_from_store(store)
|
||||
}
|
||||
|
||||
pub async fn list_local_observations_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> io::Result<Vec<LocalDeviceObservation>> {
|
||||
let store = load_observation_store_from_path(path).await?;
|
||||
list_local_observations_from_store(store)
|
||||
}
|
||||
|
||||
fn list_local_observations_from_store(
|
||||
store: LocalObservationStore,
|
||||
) -> io::Result<Vec<LocalDeviceObservation>> {
|
||||
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
|
||||
out.extend(
|
||||
store
|
||||
.dhcp_clients
|
||||
.into_values()
|
||||
.map(|row| LocalDeviceObservation {
|
||||
kind: "dhcp".into(),
|
||||
action: row.last_action,
|
||||
mac: Some(row.mac),
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
first_seen_unix: row.first_seen_unix,
|
||||
last_seen_unix: row.last_seen_unix,
|
||||
}),
|
||||
);
|
||||
out.extend(
|
||||
store
|
||||
.neighbors
|
||||
.into_values()
|
||||
.map(|row| LocalDeviceObservation {
|
||||
kind: "neigh".into(),
|
||||
action: row.last_action,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: None,
|
||||
first_seen_unix: row.first_seen_unix,
|
||||
last_seen_unix: row.last_seen_unix,
|
||||
}),
|
||||
);
|
||||
out.sort_by(|a, b| {
|
||||
b.last_seen_unix
|
||||
.cmp(&a.last_seen_unix)
|
||||
.then(a.kind.cmp(&b.kind))
|
||||
.then(a.mac.cmp(&b.mac))
|
||||
.then(a.ip.cmp(&b.ip))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Observe a DHCP hotplug event and update the local MAC-to-name cache.
|
||||
pub async fn observe_dhcp_event(
|
||||
action: &str,
|
||||
mac: MacAddr,
|
||||
ip: Option<IpAddr>,
|
||||
hostname: Option<&str>,
|
||||
) -> io::Result<bool> {
|
||||
if !matches!(action, "add" | "update" | "old" | "remove") {
|
||||
// old not emitted by hotplug
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let hostname = hostname
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty() && *v != "*")
|
||||
.map(ToOwned::to_owned);
|
||||
let now = now_unix();
|
||||
let mac_s = mac.to_string().to_ascii_lowercase();
|
||||
|
||||
let mut store = load_observation_store().await.unwrap_or_default();
|
||||
let mut changed = false;
|
||||
store
|
||||
.dhcp_clients
|
||||
.entry(mac_s.clone())
|
||||
.and_modify(|row| {
|
||||
if row.ip != ip
|
||||
|| row.hostname != hostname
|
||||
|| row.last_action != action
|
||||
|| row.last_seen_unix != now
|
||||
{
|
||||
row.ip = ip;
|
||||
row.hostname = hostname.clone();
|
||||
row.last_action = action.to_string();
|
||||
row.last_seen_unix = now;
|
||||
changed = true;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
changed = true;
|
||||
ObservedDhcpClient {
|
||||
mac: mac_s.clone(),
|
||||
ip,
|
||||
hostname: hostname.clone(),
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
last_action: action.to_string(),
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
save_observation_store(&store).await?;
|
||||
}
|
||||
|
||||
if let Some(hostname) = hostname {
|
||||
let mut cache = load_mac_name_cache().await.unwrap_or_default();
|
||||
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
|
||||
cache.insert(mac_s, hostname);
|
||||
save_mac_name_cache(&cache).await?;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub async fn observe_neighbor_event(
|
||||
action: &str,
|
||||
mac: Option<MacAddr>,
|
||||
ip: Option<IpAddr>,
|
||||
) -> io::Result<bool> {
|
||||
if !matches!(action, "add" | "update" | "old" | "remove") {
|
||||
// update and remove not emitted by hotplug
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(key) = mac
|
||||
.map(|value| format!("mac:{}", value.to_string().to_ascii_lowercase()))
|
||||
.or_else(|| ip.map(|value| format!("ip:{}", value)))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let now = now_unix();
|
||||
let mac = mac.map(|value| value.to_string().to_ascii_lowercase());
|
||||
let mut store = load_observation_store().await.unwrap_or_default();
|
||||
let mut changed = false;
|
||||
store
|
||||
.neighbors
|
||||
.entry(key.clone())
|
||||
.and_modify(|row| {
|
||||
if row.mac != mac
|
||||
|| row.ip != ip
|
||||
|| row.last_action != action
|
||||
|| row.last_seen_unix != now
|
||||
{
|
||||
row.mac = mac.clone();
|
||||
row.ip = ip;
|
||||
row.last_action = action.to_string();
|
||||
row.last_seen_unix = now;
|
||||
changed = true;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
changed = true;
|
||||
ObservedNeighbor {
|
||||
key,
|
||||
mac,
|
||||
ip,
|
||||
first_seen_unix: now,
|
||||
last_seen_unix: now,
|
||||
last_action: action.to_string(),
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
save_observation_store(&store).await?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Parse one `dnsmasq`-style DHCP lease line.
|
||||
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
|
||||
let mut c = line.split_whitespace();
|
||||
@@ -36,9 +291,15 @@ pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read raw DHCP leases from `/tmp/dhcp.leases`.
|
||||
/// Read raw DHCP leases from the configured dnsmasq lease file.
|
||||
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
|
||||
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
|
||||
read_dhcp_leases_from_path(dhcp_leases_path()).await
|
||||
}
|
||||
|
||||
pub async fn read_dhcp_leases_from_path(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> io::Result<Vec<DhcpLease>> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(e),
|
||||
@@ -47,8 +308,27 @@ pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
|
||||
|
||||
/// Read DHCP leases and fill missing names from the MAC-name cache.
|
||||
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
|
||||
let leases = read_dhcp_leases().await?;
|
||||
let mut cache = load_mac_name_cache().await.unwrap_or_default();
|
||||
read_dhcp_leases_with_names_from_paths(
|
||||
dhcp_leases_path(),
|
||||
observation_store_path(),
|
||||
mac_name_cache_path(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn read_dhcp_leases_with_names_from_paths(
|
||||
leases_path: impl AsRef<std::path::Path>,
|
||||
observation_store_path: impl AsRef<std::path::Path>,
|
||||
mac_name_cache_path: impl AsRef<std::path::Path>,
|
||||
) -> io::Result<Vec<DhcpLease>> {
|
||||
let leases = read_dhcp_leases_from_path(leases_path).await?;
|
||||
let observations = load_observation_store_from_path(observation_store_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mac_name_cache_path = mac_name_cache_path.as_ref();
|
||||
let mut cache = load_mac_name_cache_from_path(mac_name_cache_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut changed = false;
|
||||
let mut leases_with_names = Vec::with_capacity(leases.len());
|
||||
for mut l in leases {
|
||||
@@ -58,17 +338,49 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
|
||||
cache.insert(mac_s, name.clone());
|
||||
changed = true;
|
||||
}
|
||||
} else if let Some(prev) = observations
|
||||
.dhcp_clients
|
||||
.get(&mac_s)
|
||||
.and_then(|row| row.hostname.as_ref())
|
||||
{
|
||||
l.name = Some(prev.clone());
|
||||
} else if let Some(prev) = cache.get(&mac_s) {
|
||||
l.name = Some(prev.clone());
|
||||
}
|
||||
leases_with_names.push(l);
|
||||
}
|
||||
if changed {
|
||||
let _ = save_mac_name_cache(&cache).await;
|
||||
let _ = save_mac_name_cache_to_path(mac_name_cache_path, &cache).await;
|
||||
}
|
||||
Ok(leases_with_names)
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn dhcp_leases_path() -> PathBuf {
|
||||
configured_path(DHCP_LEASES_ENV, DEFAULT_DHCP_LEASES)
|
||||
}
|
||||
|
||||
fn mac_name_cache_path() -> PathBuf {
|
||||
configured_path(MAC_NAME_CACHE_ENV, DEFAULT_MAC_NAME_CACHE)
|
||||
}
|
||||
|
||||
fn observation_store_path() -> PathBuf {
|
||||
configured_path(OBSERVATION_STORE_ENV, DEFAULT_OBSERVATION_STORE)
|
||||
}
|
||||
|
||||
fn configured_path(env_key: &str, default: &str) -> PathBuf {
|
||||
std::env::var_os(env_key)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(default))
|
||||
}
|
||||
|
||||
/// Enrich DHCP leases with the best currently known neighbor state per IP.
|
||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
||||
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
||||
@@ -97,3 +409,92 @@ pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLea
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
struct EnvGuard {
|
||||
keys: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: &std::path::Path) -> Self {
|
||||
let guard = Self { keys: vec![key] };
|
||||
// SAFETY: these tests are serialized and do not spawn work that reads these
|
||||
// environment variables outside the test body.
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
guard
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
for key in &self.keys {
|
||||
// SAFETY: these tests are serialized and do not spawn work that reads these
|
||||
// environment variables outside the test body.
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_file(name: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
std::env::temp_dir().join(format!("wakey-linux-{name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn dhcp_lease_file_path_can_be_overridden() {
|
||||
let path = temp_file("leases");
|
||||
let _guard = EnvGuard::set(DHCP_LEASES_ENV, &path);
|
||||
tokio::fs::write(&path, "1893456000 aa:bb:cc:dd:ee:ff 192.168.1.2 lda *\n")
|
||||
.await
|
||||
.expect("lease fixture should write");
|
||||
|
||||
let leases = read_dhcp_leases().await.expect("leases should read");
|
||||
assert_eq!(leases.len(), 1);
|
||||
assert_eq!(leases[0].name.as_deref(), Some("lda"));
|
||||
assert_eq!(leases[0].ip.to_string(), "192.168.1.2");
|
||||
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn observation_and_name_cache_paths_can_be_overridden() {
|
||||
let observation_path = temp_file("observations");
|
||||
let cache_path = temp_file("names");
|
||||
let _observation_guard = EnvGuard::set(OBSERVATION_STORE_ENV, &observation_path);
|
||||
let _cache_guard = EnvGuard::set(MAC_NAME_CACHE_ENV, &cache_path);
|
||||
|
||||
let changed = observe_dhcp_event(
|
||||
"add",
|
||||
"aa:bb:cc:dd:ee:ff".parse().expect("mac should parse"),
|
||||
Some("192.168.1.2".parse().expect("ip should parse")),
|
||||
Some("lda"),
|
||||
)
|
||||
.await
|
||||
.expect("observation should write");
|
||||
assert!(changed);
|
||||
|
||||
let store = load_observation_store()
|
||||
.await
|
||||
.expect("observation store should read");
|
||||
assert!(store.dhcp_clients.contains_key("aa:bb:cc:dd:ee:ff"));
|
||||
|
||||
let cache = load_mac_name_cache().await.expect("name cache should read");
|
||||
assert_eq!(cache.get("aa:bb:cc:dd:ee:ff"), Some(&"lda".to_string()));
|
||||
|
||||
let _ = tokio::fs::remove_file(observation_path).await;
|
||||
let _ = tokio::fs::remove_file(cache_path).await;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user