18 Commits
116 changed files with 8096 additions and 1334 deletions

No files matched your search

+7
View File
@@ -16,7 +16,14 @@ cw = "c --target=x86_64-pc-windows-msvc"
bw = "b --target=x86_64-pc-windows-msvc" bw = "b --target=x86_64-pc-windows-msvc"
tw = "t --target=x86_64-pc-windows-msvc" tw = "t --target=x86_64-pc-windows-msvc"
check-all = "check --workspace --all-features --all-targets"
clippy-all = "clippy --workspace --all-features --all-targets"
test-all = "test --workspace --all-features --all-targets"
fmt-all = "fmt --all"
# idk below # idk below
ldar = "r -qrp"
ldabr = "b -r --target=armv7-unknown-linux-musleabihf" ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1" t = "test -- --nocapture --test-threads=1"
tdebug = "test -- --nocapture --test-threads=1 --show-output" tdebug = "test -- --nocapture --test-threads=1 --show-output"
+1
View File
@@ -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.
+69
View File
@@ -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/`.
+11 -34
View File
@@ -7,10 +7,6 @@ on:
- "v*" - "v*"
workflow_dispatch: 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: permissions:
contents: write contents: write
@@ -23,7 +19,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: 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 - uses: pnpm/action-setup@v4
with: with:
@@ -35,14 +31,8 @@ jobs:
cache: pnpm cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml cache-dependency-path: ui/pnpm-lock.yaml
- name: Ensure toolchain and targets - name: Install cross
shell: pwsh run: cargo install cross --locked
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: Cache cargo - name: Cache cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -53,10 +43,9 @@ jobs:
target target
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }} key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl) - name: Build (armv7-musl with cross)
shell: pwsh
run: | 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 - name: Build UI dist
shell: bash shell: bash
@@ -66,29 +55,17 @@ jobs:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm build pnpm build
- name: Build control-plane (linux-gnu; zig + older glibc when available) - name: Build control-plane (linux-gnu)
shell: bash shell: bash
run: | run: |
set -euo pipefail 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 # x86_64 builds natively; aarch64 needs cross
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then echo "Building control-plane x86_64-unknown-linux-gnu (native)"
use_zig=true cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
fi
if [ "$use_zig" = true ]; then echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}" cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
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
- name: Package rootfs tarball - name: Package rootfs tarball
shell: pwsh shell: pwsh
+2
View File
@@ -18,3 +18,5 @@ target/
#.idea/ #.idea/
.env .env
.codex
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agents WHERE agent_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "51cb6ec47368803af60114a39dd9e70ba9d31946bc39980c9e85dbc4e842a2c8"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM active_alerts",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "72afd48584f822116318c623f7dd42ddf4c82b337d8a3d52ed5973d52e08c163"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_meta WHERE agent_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8bf8470846bd228d2ea9b243f70f946f002915b9b1983bce818abc0ae3dd97f0"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM enroll_tokens WHERE token = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8f3499a2f461f738711b9942a9c9d6254c1f7ed0db54db8b3d65e10fa8dadfc9"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM known_devices WHERE device_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b1c6150ef7bd67ce78f8934fc5d451b36f5bed834d39bdde01356e331c5e5ddb"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
+764 -12
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey" name = "wakey"
version = "0.2.1" version = "0.2.2"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+8 -3
View File
@@ -125,7 +125,7 @@ Example:
data_dir = "/var/lib/wakey-control-plane" data_dir = "/var/lib/wakey-control-plane"
bind = "0.0.0.0:8080" bind = "0.0.0.0:8080"
public_url = "https://cp.example.com" public_url = "https://cp.example.com"
state_file = "state.db" state_file = "state.sqlite3"
pid_file = "wakey-control-plane.pid" pid_file = "wakey-control-plane.pid"
ui_dist_dir = "/opt/wakey/ui/dist" ui_dist_dir = "/opt/wakey/ui/dist"
command_timeout_ms = 30000 command_timeout_ms = 30000
@@ -140,9 +140,14 @@ json_logs = false
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
structured logs are emitted. structured logs are emitted.
State is persisted in an embedded `sled` database (default State is persisted in an embedded SQLite database (default
`/var/lib/wakey-control-plane/state.db`). `/var/lib/wakey-control-plane/state.sqlite3`).
Relative paths in config are resolved under `data_dir`. 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`. Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
Expired tokens are rejected on enroll and can be garbage-collected periodically Expired tokens are rejected on enroll and can be garbage-collected periodically
+20
View File
@@ -68,8 +68,12 @@ try {
$initDestTmp = "$User@${HostName}:$initTmp" $initDestTmp = "$User@${HostName}:$initTmp"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh' $localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey' $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' $deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$deployPreferred = '/root/.bin/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 # Push binaries
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword 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)) { if (-not $SkipInitScript -and (Test-Path $localInit)) {
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword 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 # Push deploy helper if exists
if (Test-Path $localDeploy) { if (Test-Path $localDeploy) {
@@ -103,6 +113,16 @@ if [ -f $initTmp ]; then
fi 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",'')'" $remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" } if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
+17
View File
@@ -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
+16
View File
@@ -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
+15
View File
@@ -71,6 +71,21 @@ Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8 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 # Copy helper scripts intended for /etc/ldlda_help
$helpSrc = Join-Path $root 'scripts/ldlda_help' $helpSrc = Join-Path $root 'scripts/ldlda_help'
if (Test-Path $helpSrc) { if (Test-Path $helpSrc) {
+21
View File
@@ -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"
+66 -7
View File
@@ -25,7 +25,7 @@ pub struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Show merged device inventory rows. /// Show merged device inventory rows.
#[command(visible_alias = "status")] #[command(visible_alias = "status", alias = "inv")]
Inventory(InventoryArgs), Inventory(InventoryArgs),
/// Show DHCP leases, optionally enriched with current neighbor state. /// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs), Leases(LeasesArgs),
@@ -33,12 +33,17 @@ pub enum Command {
Wake(WakeArgs), Wake(WakeArgs),
/// Show condensed network interface summaries. /// Show condensed network interface summaries.
Devs(DevsArgs), Devs(DevsArgs),
/// Record local hotplug observations.
Observe(ObserveArgs),
} }
#[derive(Args)] #[derive(Args)]
pub struct LeasesArgs { pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP. /// Legacy: include neighbor state on lease rows. Prefer `wakey inventory`.
#[arg(long)] #[arg(
long,
help = "Deprecated: include neighbor state on lease rows. Prefer `wakey inventory`."
)]
pub include_state: bool, pub include_state: bool,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(long)]
@@ -116,6 +121,42 @@ pub struct DevsArgs {
pub json: bool, 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) { pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose))) .or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
@@ -163,10 +204,11 @@ pub async fn run(cli: Cli) -> Result<()> {
json = args.json, json = args.json,
"dispatching leases command" "dispatching leases command"
); );
let leases = wakey::get_leases(wakey_core::LeaseQuery { let leases = if args.include_state {
include_state: args.include_state, wakey::get_leases_with_neighbor_state().await?
}) } else {
.await?; wakey::get_leases().await?
};
if args.json { if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?); println!("{}", serde_json::to_string_pretty(&leases)?);
} else { } else {
@@ -206,6 +248,23 @@ pub async fn run(cli: Cli) -> Result<()> {
println!("{}", table::render_devs_table(&devs)); 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(()) Ok(())
+4 -1
View File
@@ -3,12 +3,15 @@
pub mod service; pub mod service;
pub mod utils; pub mod utils;
pub use wakey_linux;
pub use service::{ pub use service::{
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases, broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
inventory, leases_without_state, merge_devices, resolve_devices, resolve_query, get_leases_with_neighbor_state, inventory, leases_without_state, local_observation_to_fact,
merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
}; };
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+163 -15
View File
@@ -1,7 +1,8 @@
use anyhow::Result; use anyhow::Result;
use tracing::{debug, warn};
use wakey_core::{ use wakey_core::{
Device, DeviceInventory, DhcpLease, DhcpLeaseWithState, InventoryQuery, NeighborEntry, Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
Presence, Query, NeighborEntry, Presence, Query,
}; };
use crate::service::leases::get_leases; use crate::service::leases::get_leases;
@@ -20,13 +21,26 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
/// directly from raw Linux source rows. /// directly from raw Linux source rows.
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> { pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
let neighbors = wakey_linux::devices::query_neighbors(&query).await?; let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
let leases = get_leases(wakey_core::LeaseQuery { let leases = get_leases().await?;
include_state: false, let observations = match wakey_linux::dhcp::list_local_observations().await {
}) Ok(observations) => observations
.await?; .into_iter()
Ok(DeviceInventory { .filter_map(local_observation_to_fact)
devices: merge_devices(neighbors, leases, &query), .collect::<Vec<_>>(),
}) Err(err) => {
warn!(error = %err, "failed reading local hook observations for inventory");
Vec::new()
}
};
debug!(
neighbors = neighbors.len(),
leases = leases.len(),
observations = observations.len(),
"building device inventory"
);
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
debug!(devices = devices.len(), "merged device inventory");
Ok(DeviceInventory { devices })
} }
/// Merge raw neighbor entries and DHCP leases into device aggregates. /// Merge raw neighbor entries and DHCP leases into device aggregates.
@@ -37,26 +51,53 @@ pub fn merge_devices(
neighbors: Vec<NeighborEntry>, neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>, leases: Vec<DhcpLeaseWithState>,
query: &InventoryQuery, query: &InventoryQuery,
) -> Vec<Device> {
merge_devices_with_observations(neighbors, leases, Vec::new(), query)
}
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
/// aggregates.
pub fn merge_devices_with_observations(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
observations: Vec<DeviceObservationFact>,
query: &InventoryQuery,
) -> Vec<Device> { ) -> Vec<Device> {
use std::collections::BTreeMap; use std::collections::BTreeMap;
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new(); type DeviceParts = (
Vec<NeighborEntry>,
Vec<DhcpLease>,
Vec<DeviceObservationFact>,
);
let mut by_key: BTreeMap<String, DeviceParts> = BTreeMap::new();
for row in neighbors { for row in neighbors {
let key = row let key = row
.mac .mac
.map(|m| m.to_string()) .map(|m| m.to_string())
.unwrap_or_else(|| format!("ip:{}", row.ip)); .unwrap_or_else(|| format!("ip:{}", row.ip));
by_mac.entry(key).or_default().0.push(row); by_key.entry(key).or_default().0.push(row);
} }
for lease in leases { for lease in leases {
let key = lease.lease_line.mac.to_string(); let key = lease.lease_line.mac.to_string();
by_mac.entry(key).or_default().1.push(lease.lease_line); by_key.entry(key).or_default().1.push(lease.lease_line);
}
for observation in observations {
let key = observation
.mac
.map(|mac| mac.to_string())
.or_else(|| observation.ip.map(|ip| format!("ip:{ip}")))
.unwrap_or_else(|| "unknown".to_string());
by_key.entry(key).or_default().2.push(observation);
} }
let mut devices: Vec<Device> = by_mac let mut devices: Vec<Device> = by_key
.into_values() .into_values()
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases)) .map(|(neighbors, leases, observations)| {
Device::from_parts_with_observations(neighbors, leases, observations)
})
.collect(); .collect();
let mut texts = Vec::new(); let mut texts = Vec::new();
@@ -90,6 +131,42 @@ pub fn merge_devices(
devices devices
} }
pub fn local_observation_to_fact(
observation: wakey_linux::dhcp::LocalDeviceObservation,
) -> Option<DeviceObservationFact> {
let mac = match observation.mac.as_deref() {
Some(raw) => match raw.parse() {
Ok(mac) => Some(mac),
Err(err) => {
warn!(
mac = raw,
error = %err,
"ignoring invalid observed mac in inventory"
);
None
}
},
None => None,
};
if mac.is_none() && observation.ip.is_none() {
warn!(
kind = %observation.kind,
action = %observation.action,
"ignoring hook observation without mac or ip"
);
return None;
}
Some(DeviceObservationFact {
kind: observation.kind,
action: observation.action,
mac,
ip: observation.ip,
hostname: observation.hostname,
first_seen_unix: Some(observation.first_seen_unix),
last_seen_unix: Some(observation.last_seen_unix),
})
}
const fn presence_rank(presence: Presence) -> u8 { const fn presence_rank(presence: Presence) -> u8 {
match presence { match presence {
Presence::Online => 3, Presence::Online => 3,
@@ -103,7 +180,7 @@ const fn presence_rank(presence: Presence) -> u8 {
mod tests { mod tests {
use super::*; use super::*;
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DhcpLease, InventoryQueryBuilder, NeighborState}; use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
fn sample_neighbors() -> Vec<NeighborEntry> { fn sample_neighbors() -> Vec<NeighborEntry> {
vec![NeighborEntry { vec![NeighborEntry {
@@ -154,4 +231,75 @@ mod tests {
let out = merge_devices(sample_neighbors(), sample_leases(), &query); let out = merge_devices(sample_neighbors(), sample_leases(), &query);
assert_eq!(out.len(), 1); 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))))
);
}
#[test]
fn merge_devices_preserves_hook_observation_facts() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "dhcp".into(),
action: "update".into(),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
hostname: Some("lda".into()),
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].names, vec!["lda".to_string()]);
assert_eq!(
out[0].id,
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
);
assert_eq!(out[0].observations.len(), 1);
assert_eq!(out[0].presence, Presence::Unknown);
}
#[test]
fn merge_devices_marks_remove_only_observation_offline() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "neigh".into(),
action: "remove".into(),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
hostname: None,
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].presence, Presence::Offline);
}
} }
+12 -6
View File
@@ -1,16 +1,22 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery}; use wakey_core::{DhcpLease, DhcpLeaseWithState};
/// Read DHCP leases and optionally enrich them with current neighbor-state data. /// Read DHCP leases from dnsmasq plus remembered hook names.
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> { pub async fn get_leases() -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names() let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await .await
.context("failed to read DHCP leases")?; .context("failed to read DHCP leases")?;
if query.include_state {
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} else {
Ok(leases_without_state(leases)) Ok(leases_without_state(leases))
} }
/// Read DHCP leases and enrich them with current neighbor state.
///
/// Prefer inventory for device status; this exists for the legacy leases view.
pub async fn get_leases_with_neighbor_state() -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} }
/// Wrap raw DHCP leases in the current service output shape without neighbor state. /// Wrap raw DHCP leases in the current service output shape without neighbor state.
+5 -2
View File
@@ -5,8 +5,11 @@ pub mod query;
pub mod wake; pub mod wake;
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips}; pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
pub use inventory::{inventory, merge_devices, resolve_devices}; pub use inventory::{
pub use leases::{get_leases, leases_without_state}; inventory, local_observation_to_fact, merge_devices, merge_devices_with_observations,
resolve_devices,
};
pub use leases::{get_leases, get_leases_with_neighbor_state, leases_without_state};
pub use query::{resolve_query, resolve_selector}; pub use query::{resolve_query, resolve_selector};
pub use wake::{ pub use wake::{
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets, broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
+2
View File
@@ -20,6 +20,7 @@ import { AuditPage } from "@/pages/AuditPage";
import { CommandsPage } from "@/pages/CommandsPage"; import { CommandsPage } from "@/pages/CommandsPage";
import { DashboardPage } from "@/pages/DashboardPage"; import { DashboardPage } from "@/pages/DashboardPage";
import { DevicesPage } from "@/pages/DevicesPage"; import { DevicesPage } from "@/pages/DevicesPage";
import { ObservationsPage } from "@/pages/ObservationsPage";
import { TokensPage } from "@/pages/TokensPage"; import { TokensPage } from "@/pages/TokensPage";
type LoadState = "idle" | "loading" | "ready" | "error"; type LoadState = "idle" | "loading" | "ready" | "error";
@@ -151,6 +152,7 @@ export function App() {
/> />
} }
/> />
<Route path="observations" element={<ObservationsPage />} />
<Route <Route
path="agents" path="agents"
element={ element={
+117 -1
View File
@@ -72,6 +72,56 @@ export type SetAgentNicknameResponse = {
updated: boolean; 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> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
...init, ...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( export function runCommand(
agentId: string, agentId: string,
kind: CommandKind, kind: CommandKind,
@@ -177,7 +293,7 @@ function buildCommandPayload(
return { command: { kind: "devs", dev: null, up_only: false } }; return { command: { kind: "devs", dev: null, up_only: false } };
} }
if (kind === "leases") { if (kind === "leases") {
return { command: { kind: "leases", include_state: true } }; return { command: { kind: "leases", include_state: false } };
} }
if (kind === "inventory") { if (kind === "inventory") {
return { return {
+1
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
const navItems = [ const navItems = [
["/", "Devices"], ["/", "Devices"],
["/observations", "Observed"],
["/commands", "Wake Tools"], ["/commands", "Wake Tools"],
["/dashboard", "Fleet Health"], ["/dashboard", "Fleet Health"],
["/agents", "Agents"], ["/agents", "Agents"],
+1
View File
@@ -0,0 +1 @@
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
@@ -0,0 +1,117 @@
import { useEffect, useState } from "react";
import {
fetchObservationHistory,
type AgentDeviceObservation,
type AgentDeviceObservationEvent,
} from "@/api";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
formatSeen,
observationLabel,
observationStateLabel,
} from "@/pages/observations/utils";
type Props = {
observation: AgentDeviceObservation | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function ObservationHistoryDialog({
observation,
open,
onOpenChange,
}: Props) {
const [events, setEvents] = useState<AgentDeviceObservationEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open || !observation) return;
setLoading(true);
setError("");
fetchObservationHistory({
observationKey: observation.observation_key,
limit: 100,
})
.then(setEvents)
.catch((err) => {
setEvents([]);
setError(String(err));
})
.finally(() => setLoading(false));
}, [open, observation]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Observation History</DialogTitle>
<DialogDescription>
{observation ? observationLabel(observation) : "No observation selected"}
</DialogDescription>
</DialogHeader>
{error && (
<pre className="max-h-40 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
<div className="max-h-[60vh] overflow-auto rounded-md border">
<div className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b bg-muted/60 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Time</span>
<span>State</span>
<span>Source</span>
<span>Network</span>
<span>Known Device</span>
</div>
{events.map((event) => (
<div
key={event.event_id}
className="grid min-w-[44rem] grid-cols-[10rem_8rem_9rem_minmax(8rem,1fr)_minmax(8rem,1fr)] gap-2 border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="truncate text-muted-foreground">
{formatSeen(event.ts_unix)}
</span>
<span>
<Badge variant={event.action === "remove" ? "outline" : "secondary"}>
{observationStateLabel(event.action)}
</Badge>
</span>
<span className="flex min-w-0 gap-1">
<Badge variant="outline">{event.kind}</Badge>
<Badge variant="outline">{event.action}</Badge>
</span>
<span className="min-w-0 text-muted-foreground">
<span className="block truncate">{event.ip ?? "-"}</span>
<span className="block truncate">{event.mac ?? "-"}</span>
{event.hostname && (
<span className="block truncate">{event.hostname}</span>
)}
</span>
<span className="min-w-0 truncate">
{event.known_device?.display_name ?? "-"}
</span>
</div>
))}
{!events.length && (
<div className="px-3 py-4 text-sm text-muted-foreground">
{loading ? "Loading history..." : "No history found"}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,90 @@
import type { AgentDeviceObservation, KnownDevice } from "@/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { identifierSummary, observationLabel } from "@/pages/observations/utils";
type Props = {
observation: AgentDeviceObservation;
devices: KnownDevice[];
busy: boolean;
selectedDeviceId: string;
newName: string;
onSelectDevice: (deviceId: string) => void;
onNewNameChange: (name: string) => void;
onAttachExisting: () => void;
onCreateAndAttach: () => void;
};
export function ObservationIdentityActions({
observation,
devices,
busy,
selectedDeviceId,
newName,
onSelectDevice,
onNewNameChange,
onAttachExisting,
onCreateAndAttach,
}: Props) {
const attachable = Boolean(observation.mac || observation.ip);
const selectedDevice = devices.find(
(device) => device.device_id === selectedDeviceId,
);
return (
<div className="grid min-w-0 gap-2">
<div className="flex min-w-0 gap-2">
<Select
value={selectedDeviceId}
onValueChange={(value: string | null) => onSelectDevice(value ?? "")}
>
<SelectTrigger className="min-w-0 flex-1">
<span className="truncate">
{selectedDevice?.display_name ?? "Attach to existing"}
</span>
</SelectTrigger>
<SelectContent
className="max-w-[min(92vw,28rem)]"
alignItemWithTrigger={false}
>
{devices.map((device) => (
<SelectItem key={device.device_id} value={device.device_id}>
<span className="grid min-w-0 gap-0.5">
<span className="truncate">{device.display_name}</span>
<span className="truncate text-xs text-muted-foreground">
{identifierSummary(device)}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={busy || !attachable || !selectedDeviceId}
onClick={onAttachExisting}
>
Attach
</Button>
</div>
<div className="flex min-w-0 gap-2">
<Input
value={newName}
onChange={(event) => onNewNameChange(event.target.value)}
placeholder={`Create as ${observationLabel(observation)}`}
disabled={busy || !attachable}
/>
<Button size="sm" disabled={busy || !attachable} onClick={onCreateAndAttach}>
Create
</Button>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import type { AgentDeviceObservation, KnownDevice } from "@/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ObservationIdentityActions } from "@/pages/observations/ObservationIdentityActions";
import {
formatSeen,
observationLabel,
observationStateLabel,
} from "@/pages/observations/utils";
type Props = {
observations: AgentDeviceObservation[];
devices: KnownDevice[];
selectedDevices: Record<string, string>;
newNames: Record<string, string>;
busyKey: string;
onSelectDevice: (observationKey: string, deviceId: string) => void;
onNewNameChange: (observationKey: string, name: string) => void;
onAttachExisting: (observation: AgentDeviceObservation) => void;
onCreateAndAttach: (observation: AgentDeviceObservation) => void;
onOpenHistory: (observation: AgentDeviceObservation) => void;
};
export function ObservationTable({
observations,
devices,
selectedDevices,
newNames,
busyKey,
onSelectDevice,
onNewNameChange,
onAttachExisting,
onCreateAndAttach,
onOpenHistory,
}: Props) {
return (
<div className="grid gap-2 overflow-x-auto">
<div className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-muted/60 px-3 py-2 text-sm">
<span>Device</span>
<span>Network</span>
<span>Agent</span>
<span>Seen</span>
<span>Known Device</span>
<span>History</span>
</div>
{observations.map((observation) => {
const busy = busyKey === observation.observation_key;
return (
<div
key={observation.observation_key}
className="grid min-w-[76rem] grid-cols-[minmax(10rem,1.2fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)_minmax(9rem,0.8fr)_minmax(16rem,1.3fr)_7rem] gap-2 rounded-md border bg-card px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">
{observationLabel(observation)}
</div>
<div className="mt-1 flex flex-wrap gap-1 text-xs text-muted-foreground">
<Badge variant="outline">{observation.kind}</Badge>
<Badge
variant={
observation.last_action === "remove"
? "outline"
: "secondary"
}
>
{observationStateLabel(observation.last_action)}
</Badge>
<Badge variant="outline">{observation.last_action}</Badge>
</div>
</div>
<div className="min-w-0 text-muted-foreground">
<div className="truncate" title={observation.ip ?? "-"}>
{observation.ip ?? "-"}
</div>
<div className="truncate" title={observation.mac ?? "-"}>
{observation.mac ?? "-"}
</div>
</div>
<div className="min-w-0 truncate text-muted-foreground">
{observation.agent_id}
</div>
<div className="min-w-0 text-muted-foreground">
<div className="truncate">
{formatSeen(observation.last_seen_unix)}
</div>
</div>
<div className="min-w-0">
{observation.known_device ? (
<div className="grid gap-1">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary">known</Badge>
<span className="truncate font-medium">
{observation.known_device.display_name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{observation.known_device.pinned
? "pinned"
: "not pinned"}
</span>
</div>
) : (
<ObservationIdentityActions
observation={observation}
devices={devices}
busy={busy}
selectedDeviceId={
selectedDevices[observation.observation_key] ?? ""
}
newName={newNames[observation.observation_key] ?? ""}
onSelectDevice={(deviceId) =>
onSelectDevice(observation.observation_key, deviceId)
}
onNewNameChange={(name) =>
onNewNameChange(observation.observation_key, name)
}
onAttachExisting={() => onAttachExisting(observation)}
onCreateAndAttach={() => onCreateAndAttach(observation)}
/>
)}
</div>
<div className="flex items-start">
<Button
variant="outline"
size="sm"
onClick={() => onOpenHistory(observation)}
>
History
</Button>
</div>
</div>
);
})}
{!observations.length && (
<div className="px-1 py-2 text-sm text-muted-foreground">
No observations found
</div>
)}
</div>
);
}
@@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import {
attachObservationIdentifier,
createKnownDevice,
fetchKnownDevices,
fetchObservations,
type AgentDeviceObservation,
type KnownDevice,
} from "@/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { ObservationHistoryDialog } from "@/pages/observations/ObservationHistoryDialog";
import { ObservationTable } from "@/pages/observations/ObservationTable";
import {
observationLabel,
type ObservationFilter,
} from "@/pages/observations/utils";
export function ObservationsPage() {
const [observations, setObservations] = useState<AgentDeviceObservation[]>(
[],
);
const [devices, setDevices] = useState<KnownDevice[]>([]);
const [filter, setFilter] = useState<ObservationFilter>("all");
const [query, setQuery] = useState("");
const [selectedDevices, setSelectedDevices] = useState<
Record<string, string>
>({});
const [newNames, setNewNames] = useState<Record<string, string>>({});
const [busyKey, setBusyKey] = useState("");
const [historyObservation, setHistoryObservation] =
useState<AgentDeviceObservation | null>(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const [nextObservations, nextDevices] = await Promise.all([
fetchObservations({ limit: 500 }),
fetchKnownDevices(),
]);
setObservations(nextObservations);
setDevices(nextDevices);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return observations.filter((observation) => {
if (filter === "known" && !observation.known_device) return false;
if (filter === "unknown" && observation.known_device) return false;
if (!q) return true;
const haystack = [
observation.hostname,
observation.mac,
observation.ip,
observation.agent_id,
observation.kind,
observation.last_action,
observation.known_device?.display_name,
]
.filter((value): value is string => Boolean(value))
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}, [filter, observations, query]);
async function attachExisting(observation: AgentDeviceObservation) {
const deviceId = selectedDevices[observation.observation_key];
if (!deviceId) return;
setBusyKey(observation.observation_key);
setStatus("");
setError("");
try {
const device = await attachObservationIdentifier(
deviceId,
observation.observation_key,
);
setStatus(
`Attached ${observationLabel(observation)} to ${device.display_name}`,
);
await load();
} catch (err) {
setError(String(err));
} finally {
setBusyKey("");
}
}
async function createAndAttach(observation: AgentDeviceObservation) {
const displayName =
newNames[observation.observation_key]?.trim() ||
observationLabel(observation);
if (!displayName.trim()) return;
setBusyKey(observation.observation_key);
setStatus("");
setError("");
try {
const device = await createKnownDevice({
display_name: displayName,
pinned: true,
identifiers: [],
});
const updated = await attachObservationIdentifier(
device.device_id,
observation.observation_key,
);
setStatus(
`Created ${updated.display_name} and attached ${observationLabel(observation)}`,
);
await load();
} catch (err) {
setError(String(err));
} finally {
setBusyKey("");
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Observed Devices</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => void load()}
disabled={loading}
>
{loading ? "Refreshing..." : "Refresh"}
</Button>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)]">
<label className="grid gap-1 text-sm text-muted-foreground">
<span>Status</span>
<Select
value={filter}
onValueChange={(value: ObservationFilter | null) => {
if (value) setFilter(value);
}}
>
<SelectTrigger className="w-full">
<span>{filter}</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value="unknown">unknown</SelectItem>
<SelectItem value="known">known</SelectItem>
<SelectItem value="all">all</SelectItem>
</SelectContent>
</Select>
</label>
<label className="grid gap-1 text-sm text-muted-foreground">
<span>Search</span>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="hostname, mac, ip, agent, known device"
/>
</label>
</div>
<p className="text-sm text-muted-foreground">
Showing {filtered.length} of {observations.length}
</p>
{status && (
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 text-xs">
{status}
</pre>
)}
{error && (
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</pre>
)}
<ObservationTable
observations={filtered}
devices={devices}
selectedDevices={selectedDevices}
newNames={newNames}
busyKey={busyKey}
onSelectDevice={(observationKey, deviceId) =>
setSelectedDevices((prev) => ({
...prev,
[observationKey]: deviceId,
}))
}
onNewNameChange={(observationKey, name) =>
setNewNames((prev) => ({ ...prev, [observationKey]: name }))
}
onAttachExisting={(observation) => void attachExisting(observation)}
onCreateAndAttach={(observation) =>
void createAndAttach(observation)
}
onOpenHistory={setHistoryObservation}
/>
<ObservationHistoryDialog
observation={historyObservation}
open={Boolean(historyObservation)}
onOpenChange={(open) => {
if (!open) setHistoryObservation(null);
}}
/>
</CardContent>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { AgentDeviceObservation, KnownDevice } from "@/api";
export type ObservationFilter = "all" | "unknown" | "known";
export function observationLabel(observation: AgentDeviceObservation): string {
return (
observation.hostname?.trim() ||
observation.mac?.trim() ||
observation.ip?.trim() ||
observation.observation_key
);
}
export function formatSeen(tsUnix: number): string {
if (!Number.isFinite(tsUnix) || tsUnix <= 0) return "-";
return new Date(tsUnix * 1000).toLocaleString();
}
export function identifierSummary(device: KnownDevice): string {
if (!device.identifiers.length) return "no identifiers";
return device.identifiers
.map((identifier) => `${identifier.kind}:${identifier.value}`)
.join(", ");
}
export function observationStateLabel(action: string): string {
if (action === "remove") return "stale";
if (action === "add" || action === "update" || action === "old") {
return "present";
}
return action;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-agent" name = "wakey-agent"
version = "0.1.0" version = "0.1.2"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+60
View File
@@ -14,6 +14,10 @@ pub struct Cli {
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)] #[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8, 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)] #[command(subcommand)]
pub command: Command, pub command: Command,
} }
@@ -28,6 +32,11 @@ pub enum Command {
InitConfig(InitConfigArgs), InitConfig(InitConfigArgs),
/// Reload a running agent daemon by sending SIGHUP. /// Reload a running agent daemon by sending SIGHUP.
Reload(ReloadArgs), 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)] #[derive(Args)]
@@ -74,6 +83,10 @@ pub struct InitConfigArgs {
#[arg(long)] #[arg(long)]
pub config: Option<PathBuf>, 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. /// Print config to stdout.
#[arg(long, conflicts_with = "config")] #[arg(long, conflicts_with = "config")]
pub stdout: bool, pub stdout: bool,
@@ -101,3 +114,50 @@ pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)] #[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf, 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>,
}
+66
View File
@@ -4,6 +4,12 @@ use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml"; 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)] #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig { pub struct AgentConfig {
@@ -14,6 +20,14 @@ pub struct AgentConfig {
pub reconnect_base_ms: u64, pub reconnect_base_ms: u64,
#[serde(default = "default_reconnect_max_ms")] #[serde(default = "default_reconnect_max_ms")]
pub reconnect_max_ms: u64, 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 { impl fmt::Debug for AgentConfig {
@@ -24,6 +38,13 @@ impl fmt::Debug for AgentConfig {
.field("agent_token", &"<redacted>") .field("agent_token", &"<redacted>")
.field("reconnect_base_ms", &self.reconnect_base_ms) .field("reconnect_base_ms", &self.reconnect_base_ms)
.field("reconnect_max_ms", &self.reconnect_max_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() .finish()
} }
} }
@@ -36,6 +57,41 @@ const fn default_reconnect_max_ms() -> u64 {
30_000 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> { pub fn load_config(path: &Path) -> Result<AgentConfig> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read agent config {}", path.display()))?; .with_context(|| format!("failed to read agent config {}", path.display()))?;
@@ -118,12 +174,22 @@ mod tests {
agent_token: "secret".into(), agent_token: "secret".into(),
reconnect_base_ms: 123, reconnect_base_ms: 123,
reconnect_max_ms: 456, 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"); save_config(&path, &config).expect("save");
let loaded = load_config(&path).expect("load"); let loaded = load_config(&path).expect("load");
assert_eq!(loaded, config); assert_eq!(loaded, config);
assert!(format!("{:?}", loaded).contains("<redacted>")); 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_file(&path);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
+56 -10
View File
@@ -1,27 +1,38 @@
use anyhow::Result; use anyhow::Result;
use tracing::{debug, info, instrument}; use tracing::{debug, info, instrument, warn};
use crate::config::AgentConfig;
use crate::protocol::{ use crate::protocol::{
AgentCommand, CommandResult, DevsRequest, InventoryRequest, LeasesRequest, WakeRequest, AgentCommand, CommandResult, DevsRequest, InventoryRequest, LeasesRequest, WakeRequest,
}; };
#[instrument(skip_all)] #[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); let kind = command_kind(&command);
info!(command = %kind, "dispatching command into local wakey services"); info!(command = %kind, "dispatching command into local wakey services");
match command { 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::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, AgentCommand::Wake(req) => dispatch_wake(req).await,
} }
} }
async fn dispatch_leases(req: LeasesRequest) -> Result<CommandResult> { async fn dispatch_leases(req: LeasesRequest, config: &AgentConfig) -> Result<CommandResult> {
let leases = wakey::get_leases(wakey_core::LeaseQuery { let leases = wakey::wakey_linux::dhcp::read_dhcp_leases_with_names_from_paths(
include_state: req.include_state, &config.dhcp_leases_path,
}) &config.observation_store_path,
&config.mac_name_cache_path,
)
.await?; .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!( debug!(
rows = leases.len(), rows = leases.len(),
include_state = req.include_state, include_state = req.include_state,
@@ -50,8 +61,43 @@ async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
Ok(CommandResult::Devs { rows: devs }) Ok(CommandResult::Devs { rows: devs })
} }
async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> { async fn dispatch_inventory(req: InventoryRequest, config: &AgentConfig) -> Result<CommandResult> {
let inventory = wakey::inventory(req.into_inventory_query()).await?; 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 observations = match wakey::wakey_linux::dhcp::list_local_observations_from_path(
&config.observation_store_path,
)
.await
{
Ok(observations) => observations
.into_iter()
.filter_map(wakey::local_observation_to_fact)
.collect::<Vec<_>>(),
Err(err) => {
warn!(error = %err, "failed reading local hook observations for inventory command");
Vec::new()
}
};
debug!(
neighbors = neighbors.len(),
leases = leases.len(),
observations = observations.len(),
"dispatching inventory with merged sources"
);
let inventory = wakey_core::DeviceInventory {
devices: wakey::merge_devices_with_observations(
neighbors,
wakey::leases_without_state(leases),
observations,
&query,
),
};
debug!( debug!(
rows = inventory.devices.len(), rows = inventory.devices.len(),
"dispatched inventory command" "dispatched inventory command"
+4
View File
@@ -60,6 +60,10 @@ pub async fn enroll(
agent_token: payload.agent_token, agent_token: payload.agent_token,
reconnect_base_ms: 1_000, reconnect_base_ms: 1_000,
reconnect_max_ms: 30_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)?; let backup_path = save_config_with_backup(config_path, &config)?;
info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written"); info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written");
+113 -14
View File
@@ -9,19 +9,26 @@ mod tracing;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use cli::{Cli, Command, InitConfigArgs}; use cli::{Cli, Command, InitConfigArgs, ObserveCommand};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
tracing::init(cli.verbose); tracing::init(cli.verbose);
let global_config = cli.config.as_deref();
match cli.command { 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"); ::tracing::info!("wakey-agent command: serve");
serve::serve(args).await? 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() { let resolved_server_url = if let Some(server_url) = args.server_url.as_deref() {
server_url.to_string() server_url.to_string()
} else { } 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) => { Command::Reload(args) => {
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload"); ::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
serve::reload_daemon(&args.pid_file)? 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(()) 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<()> { fn init_config(args: InitConfigArgs) -> Result<()> {
if args.stdout && args.config.is_some() { if args.stdout && args.config.is_some() {
anyhow::bail!("--stdout cannot be used with --config"); anyhow::bail!("--stdout cannot be used with --config");
@@ -106,20 +193,32 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
); );
} }
let cfg = config::AgentConfig { let mut cfg = if let Some(from_config) = &args.from_config {
server_url: args config::load_config(from_config)?
.server_url } else {
.unwrap_or_else(|| "https://wakey.ldlda.com".to_string()), config::AgentConfig {
agent_id: args server_url: "https://wakey.ldlda.com".to_string(),
.agent_id agent_id: "REPLACE_ME_AGENT_ID".to_string(),
.unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()), agent_token: "REPLACE_ME_AGENT_TOKEN".to_string(),
agent_token: args
.agent_token
.unwrap_or_else(|| "REPLACE_ME_AGENT_TOKEN".to_string()),
reconnect_base_ms: 1_000, reconnect_base_ms: 1_000,
reconnect_max_ms: 30_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 { if let Some(path) = &args.config {
config::save_config(path, &cfg)?; config::save_config(path, &cfg)?;
println!("config={}", path.display()); println!("config={}", path.display());
+98 -3
View File
@@ -1,5 +1,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use std::net::IpAddr; use std::net::IpAddr;
use std::time::Instant; use std::time::Instant;
use tokio::time::{Duration, MissedTickBehavior, interval, sleep}; use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
@@ -84,8 +85,13 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
.await?; .await?;
info!(agent_id = %config.agent_id, "agent websocket session authenticated"); info!(agent_id = %config.agent_id, "agent websocket session authenticated");
let http_client = reqwest::Client::new();
let mut heartbeat = interval(Duration::from_secs(30)); let mut heartbeat = interval(Duration::from_secs(30));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip); 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 { loop {
tokio::select! { tokio::select! {
@@ -95,6 +101,11 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
}).await?; }).await?;
debug!(agent_id = %config.agent_id, "heartbeat sent"); 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() => { maybe_msg = source.next() => {
let msg = match maybe_msg { let msg = match maybe_msg {
Some(msg) => msg.context("websocket frame failed")?, Some(msg) => msg.context("websocket frame failed")?,
@@ -108,7 +119,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
Message::Text(text) => { Message::Text(text) => {
match serde_json::from_str::<ServerMessage>(&text) { match serde_json::from_str::<ServerMessage>(&text) {
Ok(message) => { Ok(message) => {
handle_server_message(&mut sink, message).await?; handle_server_message(config, &mut sink, message).await?;
} }
Err(err) => { Err(err) => {
// Allow the server to introduce extra frame types without // 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 { pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
let cap = max_ms.max(current_ms); let cap = max_ms.max(current_ms);
current_ms.saturating_mul(2).min(cap) 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 where
S: SinkExt<Message> + Unpin, S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static, <S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
@@ -152,7 +239,7 @@ where
} => { } => {
let kind = command_kind(&command); let kind = command_kind(&command);
info!(request_id = %request_id, command = %kind, "received command from control-plane"); 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) => { Ok(result) => {
info!(request_id = %request_id, command = %kind, "command execution completed"); info!(request_id = %request_id, command = %kind, "command execution completed");
send_command_result(sink, request_id, kind, result).await?; 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) 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)> { async fn dns_resolution_diagnostics(ws_url: &url::Url) -> Option<(u64, usize)> {
let host = ws_url.host_str()?; let host = ws_url.host_str()?;
if host.parse::<IpAddr>().is_ok() { if host.parse::<IpAddr>().is_ok() {
+9 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-control-plane" name = "wakey-control-plane"
version = "0.1.1" version = "0.1.2"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
@@ -20,6 +20,14 @@ reqwest = { version = "0.13", default-features = false, features = [
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sled = "0.34" sled = "0.34"
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"macros",
"migrate",
"uuid",
"json",
] }
toml = "1.1" toml = "1.1"
tokio = { version = "1", features = [ tokio = { version = "1", features = [
"macros", "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);
+17 -432
View File
@@ -1,433 +1,18 @@
use axum::Json; mod devices;
use axum::extract::{Path as AxumPath, Query, State}; mod enroll;
use axum::http::StatusCode; mod observations;
use axum::response::IntoResponse; mod stats;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::api::json_error; pub use devices::{
use crate::runtime::{AppState, SessionEvent}; attach_device_identifier, attach_observation_identifier, create_known_device,
use crate::state::AuditEventInput; forget_known_device, list_known_devices,
};
#[derive(Debug, Deserialize)] pub use enroll::{
pub struct EnrollRequest { EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
pub enroll_token: String, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, revoke_enroll_token,
} set_agent_nickname,
};
#[derive(Debug, Serialize)] pub use observations::{
pub struct EnrollResponse { list_agent_observation_history, list_agent_observations, upload_agent_observations,
pub agent_id: String, };
pub agent_token: String, pub use stats::{StateStatsResponse, state_stats};
pub server_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IssueEnrollTokenResponse {
pub enroll_token: String,
pub expires_at_unix: u64,
}
#[derive(Debug, Deserialize)]
pub struct IssueEnrollTokenQuery {
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnrollTokenStatus {
pub enroll_token: String,
pub expires_at_unix: u64,
pub expired: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeEnrollTokenResponse {
pub token: String,
pub revoked: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeAgentResponse {
pub agent_id: String,
pub revoked: bool,
}
#[derive(Debug, Deserialize)]
pub struct SetAgentNicknameRequest {
pub nickname: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetAgentNicknameResponse {
pub agent_id: String,
pub nickname: Option<String>,
pub updated: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StateStatsResponse {
pub db_path: String,
pub schema_version: u32,
pub agent_count: usize,
pub enroll_token_count: usize,
pub expired_enroll_token_count: usize,
}
pub async fn healthz() -> &'static str {
"ok"
}
pub async fn enroll(
State(state): State<AppState>,
Json(req): Json<EnrollRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.enroll(&req.enroll_token).await {
Ok(issued) => {
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: Some(issued.agent_id.clone()),
agent_id: Some(issued.agent_id.clone()),
request_id: None,
event_type: "agent_enroll".into(),
outcome: "ok".into(),
latency_ms: None,
message: "agent enrollment accepted".into(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %err, "failed to append audit event for enroll success");
}
Ok((
StatusCode::OK,
Json(EnrollResponse {
agent_id: issued.agent_id,
agent_token: issued.agent_token,
server_url: state.public_url,
}),
))
}
Err(err) => {
warn!(error = %err, "agent enrollment rejected");
if let Err(audit_err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "agent_enroll".into(),
outcome: "rejected".into(),
latency_ms: None,
message: err.to_string(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
}
Err(json_error(
StatusCode::UNAUTHORIZED,
"enrollment_rejected",
&err.to_string(),
))
}
}
}
pub async fn issue_enroll_token(
State(state): State<AppState>,
Query(query): Query<IssueEnrollTokenQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let ttl = std::time::Duration::from_secs(
query
.ttl_seconds
.unwrap_or(state.enroll_token_ttl.as_secs())
.max(1),
);
match state.store.issue_enroll_token(ttl).await {
Ok(issued) => {
info!(
expires_at_unix = issued.expires_at_unix,
"issued enroll token"
);
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_issue".into(),
outcome: "ok".into(),
latency_ms: None,
message: "issued enroll token".into(),
metadata: serde_json::json!({
"ttl_seconds": ttl.as_secs(),
"expires_at_unix": issued.expires_at_unix,
}),
})
.await
{
warn!(error = %err, "failed to append audit event for token issuance");
}
Ok((
StatusCode::OK,
Json(IssueEnrollTokenResponse {
enroll_token: issued.enroll_token,
expires_at_unix: issued.expires_at_unix,
}),
))
}
Err(err) => {
warn!(error = %err, "failed to issue enroll token");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"issue_enroll_token_failed",
&err.to_string(),
))
}
}
}
pub async fn list_enroll_tokens(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.list_enroll_tokens().await {
Ok(tokens) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_list".into(),
outcome: "ok".into(),
latency_ms: None,
message: "listed enroll tokens".into(),
metadata: serde_json::json!({
"count": tokens.len(),
}),
})
.await
{
warn!(error = %err, "failed to append audit event for token listing");
}
let body = tokens
.into_iter()
.map(|t| EnrollTokenStatus {
enroll_token: t.enroll_token,
expires_at_unix: t.expires_at_unix,
expired: t.expired,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(body)))
}
Err(err) => {
warn!(error = %err, "failed to list enroll tokens");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_enroll_tokens_failed",
&err.to_string(),
))
}
}
}
pub async fn revoke_enroll_token(
State(state): State<AppState>,
AxumPath(token): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.revoke_enroll_token(&token).await {
Ok(revoked) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_revoke".into(),
outcome: if revoked {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if revoked {
"revoked enroll token".into()
} else {
"enroll token not found".into()
},
metadata: serde_json::json!({ "token": token }),
})
.await
{
warn!(error = %err, "failed to append audit event for token revoke");
}
Ok((
StatusCode::OK,
Json(RevokeEnrollTokenResponse { token, revoked }),
))
}
Err(err) => {
warn!(error = %err, "failed to revoke enroll token");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"revoke_enroll_token_failed",
&err.to_string(),
))
}
}
}
pub async fn revoke_agent(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.revoke_agent(&agent_id).await {
Ok(revoked) => {
if revoked {
// Request a graceful websocket close, then remove session from active map.
if let Some(session) = state.sessions.write().await.remove(&agent_id) {
let _ = session.tx.send(SessionEvent::Close);
}
}
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: None,
event_type: "agent_revoke".into(),
outcome: if revoked {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if revoked {
"revoked agent credentials".into()
} else {
"agent credentials not found".into()
},
metadata: serde_json::json!({ "agent_id": agent_id }),
})
.await
{
warn!(error = %err, "failed to append audit event for agent revoke");
}
Ok((
StatusCode::OK,
Json(RevokeAgentResponse { agent_id, revoked }),
))
}
Err(err) => {
warn!(error = %err, "failed to revoke agent credentials");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"revoke_agent_failed",
&err.to_string(),
))
}
}
}
pub async fn set_agent_nickname(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
Json(req): Json<SetAgentNicknameRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let normalized = req
.nickname
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned);
match state
.store
.set_agent_nickname(&agent_id, normalized.as_deref())
.await
{
Ok(updated) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: None,
event_type: "agent_nickname_set".into(),
outcome: if updated {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if updated {
"updated agent nickname".into()
} else {
"agent not found for nickname update".into()
},
metadata: serde_json::json!({
"agent_id": agent_id,
"nickname": normalized,
}),
})
.await
{
warn!(error = %err, "failed to append audit event for nickname set");
}
Ok((
StatusCode::OK,
Json(SetAgentNicknameResponse {
agent_id,
nickname: normalized,
updated,
}),
))
}
Err(err) => {
warn!(error = %err, "failed to update agent nickname");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"set_agent_nickname_failed",
&err.to_string(),
))
}
}
}
pub async fn state_stats(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.stats().await {
Ok(stats) => Ok((
StatusCode::OK,
Json(StateStatsResponse {
db_path: stats.db_path.display().to_string(),
schema_version: stats.schema_version,
agent_count: stats.agent_count,
enroll_token_count: stats.enroll_token_count,
expired_enroll_token_count: stats.expired_enroll_token_count,
}),
)),
Err(err) => {
warn!(error = %err, "failed to read state stats");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"state_stats_failed",
&err.to_string(),
))
}
}
}
@@ -0,0 +1,216 @@
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::api::json_error;
use crate::runtime::AppState;
use crate::state::{DeviceIdentifierInput, KnownDeviceInput};
#[derive(Debug, Deserialize)]
pub struct CreateKnownDeviceRequest {
pub display_name: String,
#[serde(default)]
pub pinned: bool,
pub notes: Option<String>,
#[serde(default)]
pub identifiers: Vec<DeviceIdentifierRequest>,
}
#[derive(Debug, Deserialize)]
pub struct DeviceIdentifierRequest {
pub kind: String,
pub value: String,
}
#[derive(Debug, Deserialize)]
pub struct AttachObservationIdentifierRequest {
pub observation_key: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KnownDeviceResponse {
pub device_id: String,
pub display_name: String,
pub pinned: bool,
pub created_at_unix: u64,
pub updated_at_unix: u64,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifierResponse>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeviceIdentifierResponse {
pub identifier_key: String,
pub device_id: String,
pub kind: String,
pub value: String,
pub created_at_unix: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForgetKnownDeviceResponse {
pub device_id: String,
pub forgotten: bool,
}
pub async fn create_known_device(
State(state): State<AppState>,
Json(req): Json<CreateKnownDeviceRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let input = KnownDeviceInput {
display_name: req.display_name,
pinned: req.pinned,
notes: req.notes,
identifiers: req
.identifiers
.into_iter()
.map(|identifier| DeviceIdentifierInput {
kind: identifier.kind,
value: identifier.value,
})
.collect(),
};
match state.store.create_known_device(input).await {
Ok(device) => Ok((StatusCode::CREATED, Json(known_device_response(device)))),
Err(err) => {
warn!(error = %err, "failed to create known device");
Err(json_error(
StatusCode::BAD_REQUEST,
"create_known_device_failed",
&err.to_string(),
))
}
}
}
pub async fn list_known_devices(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.list_known_devices().await {
Ok(devices) => Ok((
StatusCode::OK,
Json(
devices
.into_iter()
.map(known_device_response)
.collect::<Vec<_>>(),
),
)),
Err(err) => {
warn!(error = %err, "failed to list known devices");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_known_devices_failed",
&err.to_string(),
))
}
}
}
pub async fn forget_known_device(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.forget_known_device(&device_id).await {
Ok(forgotten) => Ok((
StatusCode::OK,
Json(ForgetKnownDeviceResponse {
device_id,
forgotten,
}),
)),
Err(err) => {
warn!(error = %err, "failed to forget known device");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"forget_known_device_failed",
&err.to_string(),
))
}
}
}
pub async fn attach_device_identifier(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
Json(req): Json<DeviceIdentifierRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let input = DeviceIdentifierInput {
kind: req.kind,
value: req.value,
};
match state
.store
.attach_device_identifier(&device_id, input)
.await
{
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
Ok(None) => Err(json_error(
StatusCode::NOT_FOUND,
"known_device_not_found",
"known device not found",
)),
Err(err) => {
warn!(error = %err, "failed to attach device identifier");
Err(json_error(
StatusCode::BAD_REQUEST,
"attach_device_identifier_failed",
&err.to_string(),
))
}
}
}
pub async fn attach_observation_identifier(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
Json(req): Json<AttachObservationIdentifierRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state
.store
.attach_observation_identifier(&device_id, &req.observation_key)
.await
{
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
Ok(None) => Err(json_error(
StatusCode::NOT_FOUND,
"known_device_not_found",
"known device not found",
)),
Err(err) => {
warn!(error = %err, "failed to attach observation identifier");
Err(json_error(
StatusCode::BAD_REQUEST,
"attach_observation_identifier_failed",
&err.to_string(),
))
}
}
}
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
KnownDeviceResponse {
device_id: device.device_id,
display_name: device.display_name,
pinned: device.pinned,
created_at_unix: device.created_at_unix,
updated_at_unix: device.updated_at_unix,
notes: device.notes,
identifiers: device
.identifiers
.into_iter()
.map(|identifier| DeviceIdentifierResponse {
identifier_key: identifier.identifier_key,
device_id: identifier.device_id,
kind: identifier.kind,
value: identifier.value,
created_at_unix: identifier.created_at_unix,
})
.collect(),
}
}
@@ -0,0 +1,399 @@
use axum::Json;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::api::json_error;
use crate::runtime::{AppState, SessionEvent};
use crate::state::AuditEventInput;
#[derive(Debug, Deserialize)]
pub struct EnrollRequest {
pub enroll_token: String,
}
#[derive(Debug, Serialize)]
pub struct EnrollResponse {
pub agent_id: String,
pub agent_token: String,
pub server_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IssueEnrollTokenResponse {
pub enroll_token: String,
pub expires_at_unix: u64,
}
#[derive(Debug, Deserialize)]
pub struct IssueEnrollTokenQuery {
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnrollTokenStatus {
pub enroll_token: String,
pub expires_at_unix: u64,
pub expired: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeEnrollTokenResponse {
pub token: String,
pub revoked: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeAgentResponse {
pub agent_id: String,
pub revoked: bool,
}
#[derive(Debug, Deserialize)]
pub struct SetAgentNicknameRequest {
pub nickname: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetAgentNicknameResponse {
pub agent_id: String,
pub nickname: Option<String>,
pub updated: bool,
}
pub async fn healthz() -> &'static str {
"ok"
}
pub async fn enroll(
State(state): State<AppState>,
Json(req): Json<EnrollRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.enroll(&req.enroll_token).await {
Ok(issued) => {
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: Some(issued.agent_id.clone()),
agent_id: Some(issued.agent_id.clone()),
request_id: None,
event_type: "agent_enroll".into(),
outcome: "ok".into(),
latency_ms: None,
message: "agent enrollment accepted".into(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %err, "failed to append audit event for enroll success");
}
Ok((
StatusCode::OK,
Json(EnrollResponse {
agent_id: issued.agent_id,
agent_token: issued.agent_token,
server_url: state.public_url,
}),
))
}
Err(err) => {
warn!(error = %err, "agent enrollment rejected");
if let Err(audit_err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "agent".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "agent_enroll".into(),
outcome: "rejected".into(),
latency_ms: None,
message: err.to_string(),
metadata: serde_json::json!({}),
})
.await
{
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
}
Err(json_error(
StatusCode::UNAUTHORIZED,
"enrollment_rejected",
&err.to_string(),
))
}
}
}
pub async fn issue_enroll_token(
State(state): State<AppState>,
Query(query): Query<IssueEnrollTokenQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let ttl = std::time::Duration::from_secs(
query
.ttl_seconds
.unwrap_or(state.enroll_token_ttl.as_secs())
.max(1),
);
match state.store.issue_enroll_token(ttl).await {
Ok(issued) => {
info!(
expires_at_unix = issued.expires_at_unix,
"issued enroll token"
);
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_issue".into(),
outcome: "ok".into(),
latency_ms: None,
message: "issued enroll token".into(),
metadata: serde_json::json!({
"ttl_seconds": ttl.as_secs(),
"expires_at_unix": issued.expires_at_unix,
}),
})
.await
{
warn!(error = %err, "failed to append audit event for token issuance");
}
Ok((
StatusCode::OK,
Json(IssueEnrollTokenResponse {
enroll_token: issued.enroll_token,
expires_at_unix: issued.expires_at_unix,
}),
))
}
Err(err) => {
warn!(error = %err, "failed to issue enroll token");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"issue_enroll_token_failed",
&err.to_string(),
))
}
}
}
pub async fn list_enroll_tokens(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.list_enroll_tokens().await {
Ok(tokens) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_list".into(),
outcome: "ok".into(),
latency_ms: None,
message: "listed enroll tokens".into(),
metadata: serde_json::json!({
"count": tokens.len(),
}),
})
.await
{
warn!(error = %err, "failed to append audit event for token listing");
}
let body = tokens
.into_iter()
.map(|t| EnrollTokenStatus {
enroll_token: t.enroll_token,
expires_at_unix: t.expires_at_unix,
expired: t.expired,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(body)))
}
Err(err) => {
warn!(error = %err, "failed to list enroll tokens");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_enroll_tokens_failed",
&err.to_string(),
))
}
}
}
pub async fn revoke_enroll_token(
State(state): State<AppState>,
AxumPath(token): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.revoke_enroll_token(&token).await {
Ok(revoked) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: None,
request_id: None,
event_type: "enroll_token_revoke".into(),
outcome: if revoked {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if revoked {
"revoked enroll token".into()
} else {
"enroll token not found".into()
},
metadata: serde_json::json!({ "token": token }),
})
.await
{
warn!(error = %err, "failed to append audit event for token revoke");
}
Ok((
StatusCode::OK,
Json(RevokeEnrollTokenResponse { token, revoked }),
))
}
Err(err) => {
warn!(error = %err, "failed to revoke enroll token");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"revoke_enroll_token_failed",
&err.to_string(),
))
}
}
}
pub async fn revoke_agent(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.revoke_agent(&agent_id).await {
Ok(revoked) => {
if revoked {
// Request a graceful websocket close, then remove session from active map.
if let Some(session) = state.sessions.write().await.remove(&agent_id) {
let _ = session.tx.send(SessionEvent::Close);
}
}
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: None,
event_type: "agent_revoke".into(),
outcome: if revoked {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if revoked {
"revoked agent credentials".into()
} else {
"agent credentials not found".into()
},
metadata: serde_json::json!({ "agent_id": agent_id }),
})
.await
{
warn!(error = %err, "failed to append audit event for agent revoke");
}
Ok((
StatusCode::OK,
Json(RevokeAgentResponse { agent_id, revoked }),
))
}
Err(err) => {
warn!(error = %err, "failed to revoke agent credentials");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"revoke_agent_failed",
&err.to_string(),
))
}
}
}
pub async fn set_agent_nickname(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
Json(req): Json<SetAgentNicknameRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let normalized = req
.nickname
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned);
match state
.store
.set_agent_nickname(&agent_id, normalized.as_deref())
.await
{
Ok(updated) => {
if let Err(err) = state
.store
.append_audit_event(AuditEventInput {
actor_type: "admin_api".into(),
actor_id: None,
agent_id: Some(agent_id.clone()),
request_id: None,
event_type: "agent_nickname_set".into(),
outcome: if updated {
"ok".into()
} else {
"not_found".into()
},
latency_ms: None,
message: if updated {
"updated agent nickname".into()
} else {
"agent not found for nickname update".into()
},
metadata: serde_json::json!({
"agent_id": agent_id,
"nickname": normalized,
}),
})
.await
{
warn!(error = %err, "failed to append audit event for nickname set");
}
Ok((
StatusCode::OK,
Json(SetAgentNicknameResponse {
agent_id,
nickname: normalized,
updated,
}),
))
}
Err(err) => {
warn!(error = %err, "failed to update agent nickname");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"set_agent_nickname_failed",
&err.to_string(),
))
}
}
}
@@ -0,0 +1,189 @@
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use tracing::warn;
use crate::api::json_error;
use crate::runtime::AppState;
use crate::state::{
AgentDeviceObservationEvent, AgentDeviceObservationInput, AgentDeviceObservationView,
};
#[derive(Debug, Deserialize)]
pub struct UploadAgentObservationsRequest {
pub agent_id: String,
pub agent_token: String,
#[serde(default)]
pub observations: Vec<AgentObservationRequest>,
}
#[derive(Debug, Deserialize)]
pub struct AgentObservationRequest {
pub kind: String,
pub action: String,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct UploadAgentObservationsResponse {
pub accepted: usize,
}
#[derive(Debug, Deserialize)]
pub struct ListObservationsQuery {
pub agent_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct ListObservationHistoryQuery {
pub agent_id: Option<String>,
pub kind: Option<String>,
pub mac: Option<String>,
pub ip: Option<String>,
pub observation_key: Option<String>,
pub limit: Option<usize>,
}
pub async fn upload_agent_observations(
State(state): State<AppState>,
Json(req): Json<UploadAgentObservationsRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
if !state
.store
.verify_agent_token(&req.agent_id, &req.agent_token)
.await
{
return Err(json_error(
StatusCode::UNAUTHORIZED,
"agent_auth_rejected",
"agent credentials rejected",
));
}
let observations = req
.observations
.into_iter()
.map(|observation| AgentDeviceObservationInput {
kind: observation.kind,
action: observation.action,
mac: observation.mac,
ip: observation.ip,
hostname: observation.hostname,
first_seen_unix: observation.first_seen_unix,
last_seen_unix: observation.last_seen_unix,
})
.collect();
match state
.store
.upsert_agent_observations(&req.agent_id, observations)
.await
{
Ok(accepted) => Ok((
StatusCode::OK,
Json(UploadAgentObservationsResponse { accepted }),
)),
Err(err) => {
warn!(error = %err, agent_id = %req.agent_id, "failed to upload agent observations");
Err(json_error(
StatusCode::BAD_REQUEST,
"upload_observations_failed",
&err.to_string(),
))
}
}
}
pub async fn list_agent_observations(
State(state): State<AppState>,
Query(query): Query<ListObservationsQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state
.store
.list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
.await
{
Ok(observations) => Ok((
StatusCode::OK,
Json(
observations
.into_iter()
.map(agent_observation_response)
.collect::<Vec<_>>(),
),
)),
Err(err) => {
warn!(error = %err, "failed to list agent observations");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_observations_failed",
&err.to_string(),
))
}
}
}
pub async fn list_agent_observation_history(
State(state): State<AppState>,
Query(query): Query<ListObservationHistoryQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let kind = normalized_query_value(query.kind).map(|value| value.to_ascii_lowercase());
let mac = normalized_query_value(query.mac).map(|value| value.to_ascii_lowercase());
let ip = normalized_query_value(query.ip);
let observation_key = normalized_query_value(query.observation_key);
match state
.store
.list_agent_observation_events(
query.agent_id.as_deref(),
kind.as_deref(),
mac.as_deref(),
ip.as_deref(),
observation_key.as_deref(),
query.limit.unwrap_or(500),
)
.await
{
Ok(events) => Ok((
StatusCode::OK,
Json(
events
.into_iter()
.map(agent_observation_event_response)
.collect::<Vec<_>>(),
),
)),
Err(err) => {
warn!(error = %err, "failed to list agent observation history");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_observation_history_failed",
&err.to_string(),
))
}
}
}
fn agent_observation_response(
observation: AgentDeviceObservationView,
) -> AgentDeviceObservationView {
observation
}
fn agent_observation_event_response(
event: AgentDeviceObservationEvent,
) -> AgentDeviceObservationEvent {
event
}
fn normalized_query_value(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
@@ -0,0 +1,43 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::api::json_error;
use crate::runtime::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct StateStatsResponse {
pub db_path: String,
pub schema_version: u32,
pub agent_count: usize,
pub enroll_token_count: usize,
pub expired_enroll_token_count: usize,
}
pub async fn state_stats(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.stats().await {
Ok(stats) => Ok((
StatusCode::OK,
Json(StateStatsResponse {
db_path: stats.db_path.display().to_string(),
schema_version: stats.schema_version,
agent_count: stats.agent_count,
enroll_token_count: stats.enroll_token_count,
expired_enroll_token_count: stats.expired_enroll_token_count,
}),
)),
Err(err) => {
warn!(error = %err, "failed to read state stats");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"state_stats_failed",
&err.to_string(),
))
}
}
}
+5 -2
View File
@@ -11,8 +11,11 @@ pub use audit::list_audit_events;
pub use commands::{list_agents, run_command}; pub use commands::{list_agents, run_command};
pub use control::{ pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, StateStatsResponse, attach_device_identifier, attach_observation_identifier,
revoke_enroll_token, set_agent_nickname, state_stats, 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( pub fn json_error(
+14
View File
@@ -43,6 +43,8 @@ pub enum Command {
RevokeAgent(RevokeAgentArgs), RevokeAgent(RevokeAgentArgs),
/// Print state backend stats. /// Print state backend stats.
StateStats(StateStatsArgs), StateStats(StateStatsArgs),
/// Import a legacy sled state directory into a SQLite state file.
ImportSledState(ImportSledStateArgs),
/// Send SIGHUP to an already-running daemon. /// Send SIGHUP to an already-running daemon.
Reload(ReloadArgs), Reload(ReloadArgs),
} }
@@ -236,6 +238,18 @@ pub struct StateStatsArgs {
pub target: AdminTargetArgs, 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)] #[derive(Args)]
pub struct ReloadArgs { pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)] #[arg(long, default_value = DEFAULT_PID_FILE)]
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
let state_file_raw = args let state_file_raw = args
.state_file .state_file
.clone() .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 state_file = resolve_path(&data_dir, state_file_raw);
let pid_file_raw = args let pid_file_raw = args
+7 -4
View File
@@ -49,7 +49,7 @@ impl DaemonConfig {
.state_file .state_file
.clone() .clone()
.or(file.state_file) .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 state_file = resolve_path(&data_dir, state_file_raw);
let command_timeout = Duration::from_millis( let command_timeout = Duration::from_millis(
@@ -255,7 +255,7 @@ fn resolve_state_access(
&data_dir, &data_dir,
cli_state_file cli_state_file
.or(file.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 let resolved_public_url = cli_public_url
@@ -288,8 +288,11 @@ mod tests {
fn resolve_path_joins_relative_path() { fn resolve_path_joins_relative_path() {
let out = resolve_path( let out = resolve_path(
Path::new("/var/lib/wakey-control-plane"), 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"));
} }
} }
+4
View File
@@ -61,6 +61,10 @@ async fn main() -> Result<()> {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?; tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::state_stats(args).await runtime::state_stats(args).await
} }
Command::ImportSledState(args) => {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::import_sled_state(args).await
}
Command::Reload(args) => { Command::Reload(args) => {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?; tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::reload_daemon(&args.pid_file) runtime::reload_daemon(&args.pid_file)
+10 -2
View File
@@ -4,8 +4,8 @@ use anyhow::{Context, Result};
use crate::api; use crate::api;
use crate::cli::{ use crate::cli::{
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs, RevokeEnrollTokenArgs, ImportSledStateArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs,
StateStatsArgs, RevokeEnrollTokenArgs, StateStatsArgs,
}; };
use crate::config; use crate::config;
use crate::state; use crate::state;
@@ -276,3 +276,11 @@ pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
); );
Ok(()) 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(())
}
+32 -1
View File
@@ -26,7 +26,9 @@ use crate::ws;
mod admin; mod admin;
mod process; mod process;
pub use admin::revoke_agent; 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; pub use process::reload_daemon;
use process::{remove_pid_file, write_pid_file}; 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() Router::new()
.route("/ui", get(|| async { Redirect::temporary("/ui/") })) .route("/ui", get(|| async { Redirect::temporary("/ui/") }))
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
.nest_service( .nest_service(
"/ui/", "/ui/",
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))), get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
) )
.route("/healthz", get(api::healthz)) .route("/healthz", get(api::healthz))
.route("/api/v1/agents/enroll", post(api::enroll)) .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)) .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), axum::routing::delete(api::revoke_enroll_token),
) )
.route("/api/v1/control/state-stats", get(api::state_stats)) .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/audit/events", get(api::list_audit_events))
.route("/api/v1/control/alerts", get(api::active_alerts)) .route("/api/v1/control/alerts", get(api::active_alerts))
.route("/api/v1/control/alerts/history", get(api::alert_history)) .route("/api/v1/control/alerts/history", get(api::alert_history))
+5 -1
View File
@@ -2,4 +2,8 @@ mod store;
mod types; mod types;
pub use store::Store; 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 &current_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()
}
}
+205
View File
@@ -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()
}
}
}
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more