7 Commits
73 changed files with 4634 additions and 440 deletions

No files matched your search

+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*"
workflow_dispatch:
env:
# When using cargo-zigbuild for *-gnu targets, link against this glibc baseline (wider VPS compatibility).
WAKEY_CC_GLIBC_VERSION: "2.28"
permissions:
contents: write
@@ -23,7 +19,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf,x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
- uses: pnpm/action-setup@v4
with:
@@ -35,14 +31,8 @@ jobs:
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Ensure toolchain and targets
shell: pwsh
run: |
rustc -V
cargo -V
rustup target add armv7-unknown-linux-musleabihf
rustup target add x86_64-unknown-linux-gnu
rustup target add aarch64-unknown-linux-gnu
- name: Install cross
run: cargo install cross --locked
- name: Cache cargo
uses: actions/cache@v4
@@ -53,10 +43,9 @@ jobs:
target
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl)
shell: pwsh
- name: Build (armv7-musl with cross)
run: |
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
- name: Build UI dist
shell: bash
@@ -66,29 +55,17 @@ jobs:
pnpm install --frozen-lockfile
pnpm build
- name: Build control-plane (linux-gnu; zig + older glibc when available)
- name: Build control-plane (linux-gnu)
shell: bash
run: |
set -euo pipefail
GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
use_zig=false
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
use_zig=true
fi
# x86_64 builds natively; aarch64 needs cross
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
if [ "$use_zig" = true ]; then
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
for base in "${GNU_TARGETS[@]}"; do
cargo zigbuild --release --target "${base}.${GLIBC_VER}" -p wakey-control-plane
done
else
echo "zig or cargo zigbuild not available: using plain cargo build (binary needs glibc >= host)"
for base in "${GNU_TARGETS[@]}"; do
cargo build --release --target "$base" -p wakey-control-plane
done
fi
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
- name: Package rootfs tarball
shell: pwsh
+2
View File
@@ -18,3 +18,5 @@ target/
#.idea/
.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 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,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": "15da60f9e43fa7098edf7c9fb5becfb518e9b30c5139fdb23283ed302e3da00f"
}
@@ -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,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,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": "2a7abe4d0ba22f991f490b45e2edcc980586116e92bac2c5394387b7052abe65"
}
@@ -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,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,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,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,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": "db255767f7f0aae53f685a88a6127a80b89601cdf1163881c5ec07160fbaeeab"
}
@@ -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 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": "f94cb699c0b4202bc5611799c931ed5d566b3215fcaebdf564e614a417317871"
}
@@ -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
+717 -7
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -125,7 +125,7 @@ Example:
data_dir = "/var/lib/wakey-control-plane"
bind = "0.0.0.0:8080"
public_url = "https://cp.example.com"
state_file = "state.db"
state_file = "state.sqlite3"
pid_file = "wakey-control-plane.pid"
ui_dist_dir = "/opt/wakey/ui/dist"
command_timeout_ms = 30000
@@ -140,9 +140,14 @@ json_logs = false
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
structured logs are emitted.
State is persisted in an embedded `sled` database (default
`/var/lib/wakey-control-plane/state.db`).
State is persisted in an embedded SQLite database (default
`/var/lib/wakey-control-plane/state.sqlite3`).
Relative paths in config are resolved under `data_dir`.
Legacy sled state can be imported explicitly:
```sh
wakey-control-plane import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /var/lib/wakey-control-plane/state.sqlite3
```
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
Expired tokens are rejected on enroll and can be garbage-collected periodically
+20
View File
@@ -68,8 +68,12 @@ try {
$initDestTmp = "$User@${HostName}:$initTmp"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
$localDhcpHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/dhcp/95-wakey'
$localNeighHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/neigh/95-wakey'
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
$dhcpHotplugTmp = '/var/tmp/wakey.hotplug.dhcp.tmp'
$neighHotplugTmp = '/var/tmp/wakey.hotplug.neigh.tmp'
# Push binaries
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
@@ -79,6 +83,12 @@ try {
if (-not $SkipInitScript -and (Test-Path $localInit)) {
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
if (Test-Path $localDhcpHotplug) {
Invoke-Scp -Local $localDhcpHotplug -Dest "$User@${HostName}:$dhcpHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
if (Test-Path $localNeighHotplug) {
Invoke-Scp -Local $localNeighHotplug -Dest "$User@${HostName}:$neighHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
# Push deploy helper if exists
if (Test-Path $localDeploy) {
@@ -103,6 +113,16 @@ if [ -f $initTmp ]; then
fi
"@
})
if [ -f $dhcpHotplugTmp ]; then
sed -i "s/\r$//" $dhcpHotplugTmp 2>/dev/null || true
mkdir -p /etc/hotplug.d/dhcp
cp -f $dhcpHotplugTmp /etc/hotplug.d/dhcp/95-wakey
fi
if [ -f $neighHotplugTmp ]; then
sed -i "s/\r$//" $neighHotplugTmp 2>/dev/null || true
mkdir -p /etc/hotplug.d/neigh
cp -f $neighHotplugTmp /etc/hotplug.d/neigh/95-wakey
fi
"@
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
+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
}
# Copy OpenWrt hotplug hooks.
$hotplugSrc = Join-Path $root 'scripts/hotplug/openwrt'
if (Test-Path $hotplugSrc) {
Get-ChildItem $hotplugSrc -Directory | ForEach-Object {
$hotplugTypeDir = Join-Path $staging ("etc/hotplug.d/" + $_.Name)
New-Item -ItemType Directory -Force -Path $hotplugTypeDir | Out-Null
Get-ChildItem $_.FullName -File | ForEach-Object {
$dest = Join-Path $hotplugTypeDir $_.Name
$content = Get-Content -Raw -LiteralPath $_.FullName
$content = $content -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
}
}
}
# Copy helper scripts intended for /etc/ldlda_help
$helpSrc = Join-Path $root 'scripts/ldlda_help'
if (Test-Path $helpSrc) {
+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"
+55
View File
@@ -33,6 +33,8 @@ pub enum Command {
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
/// Record local hotplug observations.
Observe(ObserveArgs),
}
#[derive(Args)]
@@ -116,6 +118,42 @@ pub struct DevsArgs {
pub json: bool,
}
#[derive(Args)]
pub struct ObserveArgs {
#[command(subcommand)]
pub command: ObserveCommand,
}
#[derive(Subcommand)]
pub enum ObserveCommand {
/// Observe a dnsmasq DHCP lease event.
Dhcp(ObserveDhcpArgs),
/// Observe a neighbor-table event.
Neigh(ObserveNeighArgs),
}
#[derive(Args)]
pub struct ObserveDhcpArgs {
#[arg(long)]
pub action: String,
#[arg(long)]
pub mac: macaddr::MacAddr,
#[arg(long)]
pub ip: Option<IpAddr>,
#[arg(long)]
pub hostname: Option<String>,
}
#[derive(Args)]
pub struct ObserveNeighArgs {
#[arg(long)]
pub action: String,
#[arg(long)]
pub mac: Option<macaddr::MacAddr>,
#[arg(long)]
pub ip: Option<IpAddr>,
}
pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
@@ -206,6 +244,23 @@ pub async fn run(cli: Cli) -> Result<()> {
println!("{}", table::render_devs_table(&devs));
}
}
Command::Observe(args) => match args.command {
ObserveCommand::Dhcp(args) => {
let changed = wakey::observe_dhcp_event(
&args.action,
args.mac,
args.ip,
args.hostname.as_deref(),
)
.await?;
println!("observed=dhcp changed={changed}");
}
ObserveCommand::Neigh(args) => {
let changed =
wakey::observe_neighbor_event(&args.action, args.mac, args.ip).await?;
println!("observed=neigh changed={changed}");
}
},
}
Ok(())
+1
View File
@@ -9,6 +9,7 @@ pub use service::{
inventory, leases_without_state, merge_devices, resolve_devices, resolve_query,
resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
};
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
#[cfg(test)]
mod tests {
+22 -1
View File
@@ -103,7 +103,7 @@ const fn presence_rank(presence: Presence) -> u8 {
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DhcpLease, InventoryQueryBuilder, NeighborState};
use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
fn sample_neighbors() -> Vec<NeighborEntry> {
vec![NeighborEntry {
@@ -154,4 +154,25 @@ mod tests {
let out = merge_devices(sample_neighbors(), sample_leases(), &query);
assert_eq!(out.len(), 1);
}
#[test]
fn merge_devices_uses_ip_observed_id_when_mac_is_absent() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices(
vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
dev: Some("br-lan".to_string()),
mac: None,
state: NeighborState::Stale,
}],
Vec::new(),
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].id,
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
);
}
}
+38
View File
@@ -28,6 +28,8 @@ pub enum Command {
InitConfig(InitConfigArgs),
/// Reload a running agent daemon by sending SIGHUP.
Reload(ReloadArgs),
/// Pass local hotplug observations through to the wakey CLI.
Observe(ObserveArgs),
}
#[derive(Args)]
@@ -101,3 +103,39 @@ pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
#[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: 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>,
}
+56 -1
View File
@@ -9,7 +9,7 @@ mod tracing;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Command, InitConfigArgs};
use cli::{Cli, Command, InitConfigArgs, ObserveCommand};
#[tokio::main]
async fn main() -> Result<()> {
@@ -86,11 +86,66 @@ async fn main() -> Result<()> {
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
serve::reload_daemon(&args.pid_file)?
}
Command::Observe(args) => observe(args)?,
}
Ok(())
}
fn observe(args: cli::ObserveArgs) -> Result<()> {
let mut cmd = std::process::Command::new(resolve_wakey_binary());
cmd.arg("observe");
match args.command {
ObserveCommand::Dhcp(args) => {
cmd.arg("dhcp")
.arg("--action")
.arg(args.action)
.arg("--mac")
.arg(args.mac);
if let Some(ip) = args.ip {
cmd.arg("--ip").arg(ip);
}
if let Some(hostname) = args.hostname {
cmd.arg("--hostname").arg(hostname);
}
}
ObserveCommand::Neigh(args) => {
cmd.arg("neigh").arg("--action").arg(args.action);
if let Some(mac) = args.mac {
cmd.arg("--mac").arg(mac);
}
if let Some(ip) = args.ip {
cmd.arg("--ip").arg(ip);
}
}
}
let status = cmd.status()?;
if !status.success() {
anyhow::bail!("wakey observe exited with {status}");
}
Ok(())
}
fn resolve_wakey_binary() -> std::path::PathBuf {
if let Ok(current) = std::env::current_exe()
&& let Some(dir) = current.parent()
{
let sibling = dir.join("wakey");
if sibling.exists() {
return sibling;
}
}
let root_bin = std::path::PathBuf::from("/root/.bin/wakey");
if root_bin.exists() {
return root_bin;
}
"wakey".into()
}
fn init_config(args: InitConfigArgs) -> Result<()> {
if args.stdout && args.config.is_some() {
anyhow::bail!("--stdout cannot be used with --config");
+83
View File
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use std::net::IpAddr;
use std::time::Instant;
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
@@ -84,8 +85,11 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
.await?;
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
let http_client = reqwest::Client::new();
let mut heartbeat = interval(Duration::from_secs(30));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut observation_sync = interval(Duration::from_secs(60));
observation_sync.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
@@ -95,6 +99,11 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
}).await?;
debug!(agent_id = %config.agent_id, "heartbeat sent");
}
_ = observation_sync.tick() => {
if let Err(err) = send_agent_observations(&http_client, config).await {
warn!(agent_id = %config.agent_id, error = %err, "failed to sync local observations");
}
}
maybe_msg = source.next() => {
let msg = match maybe_msg {
Some(msg) => msg.context("websocket frame failed")?,
@@ -135,6 +144,72 @@ 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,
}
async fn send_agent_observations(client: &reqwest::Client, config: &AgentConfig) -> Result<()> {
let observations = wakey::list_local_observations()
.await
.context("failed to read local observations")?;
if observations.is_empty() {
return Ok(());
}
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(())
}
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
let cap = max_ms.max(current_ms);
current_ms.saturating_mul(2).min(cap)
@@ -273,6 +348,14 @@ pub fn websocket_url(server_url: &str) -> Result<url::Url> {
Ok(url)
}
pub fn observations_url(server_url: &str) -> Result<url::Url> {
let mut url = url::Url::parse(server_url).context("invalid server_url")?;
url.set_path("/api/v1/agents/observations");
url.set_query(None);
url.set_fragment(None);
Ok(url)
}
async fn dns_resolution_diagnostics(ws_url: &url::Url) -> Option<(u64, usize)> {
let host = ws_url.host_str()?;
if host.parse::<IpAddr>().is_ok() {
+8
View File
@@ -20,6 +20,14 @@ reqwest = { version = "0.13", default-features = false, features = [
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sled = "0.34"
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"macros",
"migrate",
"uuid",
"json",
] }
toml = "1.1"
tokio = { version = "1", features = [
"macros",
@@ -0,0 +1,119 @@
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);
CREATE TABLE enroll_tokens (
token TEXT PRIMARY KEY,
expires_at_unix INTEGER NOT NULL
);
CREATE TABLE agents (
agent_id TEXT PRIMARY KEY,
agent_token TEXT NOT NULL
);
CREATE TABLE agent_meta (
agent_id TEXT PRIMARY KEY REFERENCES agents(agent_id) ON DELETE CASCADE,
nickname TEXT
);
CREATE TABLE known_devices (
device_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
pinned INTEGER NOT NULL,
created_at_unix INTEGER NOT NULL,
updated_at_unix INTEGER NOT NULL,
notes TEXT
);
CREATE TABLE device_identifiers (
identifier_key TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES known_devices(device_id) ON DELETE CASCADE,
kind TEXT NOT NULL,
value TEXT NOT NULL,
created_at_unix INTEGER NOT NULL,
UNIQUE(kind, value)
);
CREATE INDEX device_identifiers_device_id_idx ON device_identifiers(device_id);
CREATE TABLE agent_device_observations (
observation_key TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
kind TEXT NOT NULL,
mac TEXT,
ip TEXT,
hostname TEXT,
first_seen_unix INTEGER NOT NULL,
last_seen_unix INTEGER NOT NULL,
last_action TEXT NOT NULL
);
CREATE INDEX agent_device_observations_agent_id_idx ON agent_device_observations(agent_id);
CREATE INDEX agent_device_observations_mac_idx ON agent_device_observations(mac);
CREATE INDEX agent_device_observations_ip_idx ON agent_device_observations(ip);
CREATE INDEX agent_device_observations_hostname_idx ON agent_device_observations(hostname);
CREATE INDEX agent_device_observations_last_seen_unix_idx ON agent_device_observations(last_seen_unix);
CREATE TABLE agent_device_observation_events (
event_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
kind TEXT NOT NULL,
action TEXT NOT NULL,
mac TEXT,
ip TEXT,
hostname TEXT,
ts_unix INTEGER NOT NULL
);
CREATE INDEX agent_device_observation_events_agent_ts_idx ON agent_device_observation_events(agent_id, ts_unix);
CREATE INDEX agent_device_observation_events_mac_idx ON agent_device_observation_events(mac);
CREATE TABLE audit_events (
event_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
ts_unix INTEGER NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
agent_id TEXT,
request_id TEXT,
event_type TEXT NOT NULL,
outcome TEXT NOT NULL,
latency_ms INTEGER,
message TEXT NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE INDEX audit_events_ts_unix_idx ON audit_events(ts_unix);
CREATE INDEX audit_events_agent_id_idx ON audit_events(agent_id);
CREATE INDEX audit_events_request_id_idx ON audit_events(request_id);
CREATE INDEX audit_events_event_type_outcome_idx ON audit_events(event_type, outcome);
CREATE TABLE active_alerts (
alert_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL,
agent_id TEXT,
message TEXT NOT NULL,
value INTEGER NOT NULL,
threshold INTEGER NOT NULL,
last_seen_unix INTEGER NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE TABLE alert_transitions (
transition_key TEXT PRIMARY KEY,
transition_id TEXT NOT NULL UNIQUE,
ts_unix INTEGER NOT NULL,
alert_id TEXT NOT NULL,
kind TEXT NOT NULL,
agent_id TEXT,
from_status TEXT,
to_status TEXT NOT NULL,
message TEXT NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE INDEX alert_transitions_ts_unix_idx ON alert_transitions(ts_unix);
+291 -1
View File
@@ -7,7 +7,10 @@ use tracing::{info, warn};
use crate::api::json_error;
use crate::runtime::{AppState, SessionEvent};
use crate::state::AuditEventInput;
use crate::state::{
AgentDeviceObservation, AgentDeviceObservationInput, AuditEventInput, DeviceIdentifierInput,
KnownDeviceInput,
};
#[derive(Debug, Deserialize)]
pub struct EnrollRequest {
@@ -72,6 +75,78 @@ pub struct StateStatsResponse {
pub expired_enroll_token_count: usize,
}
#[derive(Debug, Deserialize)]
pub struct CreateKnownDeviceRequest {
pub display_name: String,
#[serde(default)]
pub pinned: bool,
pub notes: Option<String>,
#[serde(default)]
pub identifiers: Vec<DeviceIdentifierRequest>,
}
#[derive(Debug, Deserialize)]
pub struct DeviceIdentifierRequest {
pub kind: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KnownDeviceResponse {
pub device_id: String,
pub display_name: String,
pub pinned: bool,
pub created_at_unix: u64,
pub updated_at_unix: u64,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifierResponse>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeviceIdentifierResponse {
pub identifier_key: String,
pub device_id: String,
pub kind: String,
pub value: String,
pub created_at_unix: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForgetKnownDeviceResponse {
pub device_id: String,
pub forgotten: bool,
}
#[derive(Debug, Deserialize)]
pub struct UploadAgentObservationsRequest {
pub agent_id: String,
pub agent_token: String,
#[serde(default)]
pub observations: Vec<AgentObservationRequest>,
}
#[derive(Debug, Deserialize)]
pub struct AgentObservationRequest {
pub kind: String,
pub action: String,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadAgentObservationsResponse {
pub accepted: usize,
}
#[derive(Debug, Deserialize)]
pub struct ListObservationsQuery {
pub agent_id: Option<String>,
pub limit: Option<usize>,
}
pub async fn healthz() -> &'static str {
"ok"
}
@@ -407,6 +482,195 @@ pub async fn set_agent_nickname(
}
}
pub async fn create_known_device(
State(state): State<AppState>,
Json(req): Json<CreateKnownDeviceRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let input = KnownDeviceInput {
display_name: req.display_name,
pinned: req.pinned,
notes: req.notes,
identifiers: req
.identifiers
.into_iter()
.map(|identifier| DeviceIdentifierInput {
kind: identifier.kind,
value: identifier.value,
})
.collect(),
};
match state.store.create_known_device(input).await {
Ok(device) => Ok((StatusCode::CREATED, Json(known_device_response(device)))),
Err(err) => {
warn!(error = %err, "failed to create known device");
Err(json_error(
StatusCode::BAD_REQUEST,
"create_known_device_failed",
&err.to_string(),
))
}
}
}
pub async fn list_known_devices(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.list_known_devices().await {
Ok(devices) => Ok((
StatusCode::OK,
Json(
devices
.into_iter()
.map(known_device_response)
.collect::<Vec<_>>(),
),
)),
Err(err) => {
warn!(error = %err, "failed to list known devices");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_known_devices_failed",
&err.to_string(),
))
}
}
}
pub async fn forget_known_device(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.forget_known_device(&device_id).await {
Ok(forgotten) => Ok((
StatusCode::OK,
Json(ForgetKnownDeviceResponse {
device_id,
forgotten,
}),
)),
Err(err) => {
warn!(error = %err, "failed to forget known device");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"forget_known_device_failed",
&err.to_string(),
))
}
}
}
pub async fn attach_device_identifier(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
Json(req): Json<DeviceIdentifierRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let input = DeviceIdentifierInput {
kind: req.kind,
value: req.value,
};
match state
.store
.attach_device_identifier(&device_id, input)
.await
{
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
Ok(None) => Err(json_error(
StatusCode::NOT_FOUND,
"known_device_not_found",
"known device not found",
)),
Err(err) => {
warn!(error = %err, "failed to attach device identifier");
Err(json_error(
StatusCode::BAD_REQUEST,
"attach_device_identifier_failed",
&err.to_string(),
))
}
}
}
pub async fn 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_observations(query.agent_id.as_deref(), query.limit.unwrap_or(500))
.await
{
Ok(observations) => Ok((
StatusCode::OK,
Json(
observations
.into_iter()
.map(agent_observation_response) // no-op premium
.collect::<Vec<_>>(),
),
)),
Err(err) => {
warn!(error = %err, "failed to list agent observations");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_observations_failed",
&err.to_string(),
))
}
}
}
pub async fn state_stats(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
@@ -431,3 +695,29 @@ pub async fn state_stats(
}
}
}
fn agent_observation_response(observation: AgentDeviceObservation) -> AgentDeviceObservation {
observation
}
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(),
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ pub use audit::list_audit_events;
pub use commands::{list_agents, run_command};
pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent,
revoke_enroll_token, set_agent_nickname, state_stats,
StateStatsResponse, attach_device_identifier, create_known_device, enroll, forget_known_device,
healthz, issue_enroll_token, 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(
+14
View File
@@ -43,6 +43,8 @@ pub enum Command {
RevokeAgent(RevokeAgentArgs),
/// Print state backend stats.
StateStats(StateStatsArgs),
/// Import a legacy sled state directory into a SQLite state file.
ImportSledState(ImportSledStateArgs),
/// Send SIGHUP to an already-running daemon.
Reload(ReloadArgs),
}
@@ -236,6 +238,18 @@ pub struct StateStatsArgs {
pub target: AdminTargetArgs,
}
#[derive(Args)]
pub struct ImportSledStateArgs {
#[arg(long)]
pub from_sled_state: PathBuf,
#[arg(long)]
pub to_state_file: PathBuf,
#[arg(long)]
pub force: bool,
}
#[derive(Args)]
pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)]
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
let state_file_raw = args
.state_file
.clone()
.unwrap_or_else(|| PathBuf::from("state.db"));
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
let state_file = resolve_path(&data_dir, state_file_raw);
let pid_file_raw = args
+7 -4
View File
@@ -49,7 +49,7 @@ impl DaemonConfig {
.state_file
.clone()
.or(file.state_file)
.unwrap_or_else(|| PathBuf::from("state.db"));
.unwrap_or_else(|| PathBuf::from("state.sqlite3"));
let state_file = resolve_path(&data_dir, state_file_raw);
let command_timeout = Duration::from_millis(
@@ -255,7 +255,7 @@ fn resolve_state_access(
&data_dir,
cli_state_file
.or(file.state_file)
.unwrap_or_else(|| PathBuf::from("state.db")),
.unwrap_or_else(|| PathBuf::from("state.sqlite3")),
);
let resolved_public_url = cli_public_url
@@ -288,8 +288,11 @@ mod tests {
fn resolve_path_joins_relative_path() {
let out = resolve_path(
Path::new("/var/lib/wakey-control-plane"),
PathBuf::from("state.db"),
PathBuf::from("state.sqlite3"),
);
assert_eq!(
out,
PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3")
);
assert_eq!(out, PathBuf::from("/var/lib/wakey-control-plane/state.db"));
}
}
+4
View File
@@ -61,6 +61,10 @@ async fn main() -> Result<()> {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::state_stats(args).await
}
Command::ImportSledState(args) => {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::import_sled_state(args).await
}
Command::Reload(args) => {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::reload_daemon(&args.pid_file)
+10 -2
View File
@@ -4,8 +4,8 @@ use anyhow::{Context, Result};
use crate::api;
use crate::cli::{
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs, RevokeEnrollTokenArgs,
StateStatsArgs,
ImportSledStateArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs,
RevokeEnrollTokenArgs, StateStatsArgs,
};
use crate::config;
use crate::state;
@@ -276,3 +276,11 @@ pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
);
Ok(())
}
pub async fn import_sled_state(args: ImportSledStateArgs) -> Result<()> {
state::Store::import_sled_state(&args.from_sled_state, &args.to_state_file, args.force).await?;
println!("from_sled_state={}", args.from_sled_state.display());
println!("to_state_file={}", args.to_state_file.display());
println!("imported=true");
Ok(())
}
+24 -1
View File
@@ -26,7 +26,9 @@ use crate::ws;
mod admin;
mod process;
pub use admin::revoke_agent;
pub use admin::{issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats};
pub use admin::{
import_sled_state, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
};
pub use process::reload_daemon;
use process::{remove_pid_file, write_pid_file};
@@ -62,12 +64,17 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
Router::new()
.route("/ui", get(|| async { Redirect::temporary("/ui/") }))
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
.nest_service(
"/ui/",
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
)
.route("/healthz", get(api::healthz))
.route("/api/v1/agents/enroll", post(api::enroll))
.route(
"/api/v1/agents/observations",
post(api::upload_agent_observations),
)
.route("/api/v1/agent/ws", get(ws::agent_ws))
}
@@ -86,6 +93,22 @@ fn control_api_routes() -> Router<AppState> {
axum::routing::delete(api::revoke_enroll_token),
)
.route("/api/v1/control/state-stats", get(api::state_stats))
.route(
"/api/v1/control/observations",
get(api::list_agent_observations),
)
.route(
"/api/v1/control/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/audit/events", get(api::list_audit_events))
.route("/api/v1/control/alerts", get(api::active_alerts))
.route("/api/v1/control/alerts/history", get(api::alert_history))
+4 -1
View File
@@ -2,4 +2,7 @@ mod store;
mod types;
pub use store::Store;
pub use types::{AlertState, AuditEvent, AuditEventFilter, AuditEventInput};
pub use types::{
AgentDeviceObservation, AgentDeviceObservationInput, AlertState, AuditEvent, AuditEventFilter,
AuditEventInput, DeviceIdentifierInput, KnownDevice, KnownDeviceInput,
};
File diff suppressed because it is too large. Load diff
+58
View File
@@ -30,6 +30,64 @@ pub struct StateStats {
pub expired_enroll_token_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnownDevice {
pub device_id: String,
pub display_name: String,
pub pinned: bool,
pub created_at_unix: u64,
pub updated_at_unix: u64,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceIdentifier {
pub identifier_key: String,
pub device_id: String,
pub kind: String,
pub value: String,
pub created_at_unix: u64,
}
#[derive(Debug, Clone)]
pub struct KnownDeviceInput {
pub display_name: String,
pub pinned: bool,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifierInput>,
}
#[derive(Debug, Clone)]
pub struct DeviceIdentifierInput {
pub kind: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDeviceObservation {
pub observation_key: String,
pub agent_id: String,
pub kind: String,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub last_action: String,
}
#[derive(Debug, Clone)]
pub struct AgentDeviceObservationInput {
pub kind: String,
pub action: String,
pub mac: Option<String>,
pub ip: Option<String>,
pub hostname: Option<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub event_id: String,
+18 -7
View File
@@ -1,5 +1,5 @@
use macaddr::MacAddr;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::net::IpAddr;
@@ -32,11 +32,16 @@ impl From<NeighborState> for Presence {
}
}
/// MAC-first identifier for a device aggregate.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
pub struct DeviceId {
/// Observed identifier for a discovered device aggregate.
///
/// This is not a durable, user-approved identity. The control plane may attach
/// many observed identifiers to one saved device.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum DeviceId {
#[serde(with = "mac")]
pub mac: MacAddr,
Mac(MacAddr),
Ip(IpAddr),
}
/// Merged view of one discovered network identity.
@@ -91,10 +96,16 @@ impl Device {
}
let macs: Vec<MacAddr> = macs.into_iter().collect();
let ips: Vec<IpAddr> = ips.into_iter().collect();
let id = macs
.first()
.copied()
.map(DeviceId::Mac)
.or_else(|| ips.first().copied().map(DeviceId::Ip));
Self {
id: macs.first().copied().map(|mac| DeviceId { mac }),
id,
names: names.into_iter().map(|n| n.to_owned()).collect(),
ips: ips.into_iter().collect(),
ips,
macs,
interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
neighbors,
+1
View File
@@ -8,6 +8,7 @@ publish = ["gitea"]
anyhow = "1"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "net", "rt", "sync"] }
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0"}
+226
View File
@@ -1,9 +1,52 @@
use std::io::{self, ErrorKind};
use std::net::IpAddr;
use std::time::{SystemTime, UNIX_EPOCH};
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use wakey_core::{DhcpLease, DhcpLeaseWithState};
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
const OBSERVATION_STORE: &str = "/tmp/wakey_observations.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LocalObservationStore {
#[serde(default)]
pub dhcp_clients: std::collections::BTreeMap<String, ObservedDhcpClient>,
#[serde(default)]
pub neighbors: std::collections::BTreeMap<String, ObservedNeighbor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservedDhcpClient {
pub mac: String,
pub ip: Option<IpAddr>,
pub hostname: Option<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub last_action: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservedNeighbor {
pub key: String,
pub mac: Option<String>,
pub ip: Option<IpAddr>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
pub last_action: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalDeviceObservation {
pub kind: String,
pub action: String,
pub mac: Option<String>,
pub ip: Option<IpAddr>,
pub hostname: Option<String>,
pub first_seen_unix: u64,
pub last_seen_unix: u64,
}
/// Load the MAC-to-name cache used to preserve useful names across lease churn.
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
@@ -21,6 +64,175 @@ async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -
Ok(())
}
pub async fn load_observation_store() -> io::Result<LocalObservationStore> {
match tokio::fs::read_to_string(OBSERVATION_STORE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
async fn save_observation_store(store: &LocalObservationStore) -> io::Result<()> {
let s = serde_json::to_string(store).map_err(io::Error::other)?;
tokio::fs::write(OBSERVATION_STORE, s).await
}
pub async fn list_local_observations() -> io::Result<Vec<LocalDeviceObservation>> {
let store = load_observation_store().await?;
let mut out = Vec::with_capacity(store.dhcp_clients.len() + store.neighbors.len());
out.extend(
store
.dhcp_clients
.into_values()
.map(|row| LocalDeviceObservation {
kind: "dhcp".into(),
action: row.last_action,
mac: Some(row.mac),
ip: row.ip,
hostname: row.hostname,
first_seen_unix: row.first_seen_unix,
last_seen_unix: row.last_seen_unix,
}),
);
out.extend(
store
.neighbors
.into_values()
.map(|row| LocalDeviceObservation {
kind: "neigh".into(),
action: row.last_action,
mac: row.mac,
ip: row.ip,
hostname: None,
first_seen_unix: row.first_seen_unix,
last_seen_unix: row.last_seen_unix,
}),
);
out.sort_by(|a, b| {
b.last_seen_unix
.cmp(&a.last_seen_unix)
.then(a.kind.cmp(&b.kind))
.then(a.mac.cmp(&b.mac))
.then(a.ip.cmp(&b.ip))
});
Ok(out)
}
/// Observe a DHCP hotplug event and update the local MAC-to-name cache.
pub async fn observe_dhcp_event(
action: &str,
mac: MacAddr,
ip: Option<IpAddr>,
hostname: Option<&str>,
) -> io::Result<bool> {
if !matches!(action, "add" | "update" | "old" | "remove") {
return Ok(false);
}
let hostname = hostname
.map(str::trim)
.filter(|v| !v.is_empty() && *v != "*")
.map(ToOwned::to_owned);
let now = now_unix();
let mac_s = mac.to_string();
let mut store = load_observation_store().await.unwrap_or_default();
let mut changed = false;
store
.dhcp_clients
.entry(mac_s.clone())
.and_modify(|row| {
if row.ip != ip
|| row.hostname != hostname
|| row.last_action != action
|| row.last_seen_unix != now
{
row.ip = ip;
row.hostname = hostname.clone();
row.last_action = action.to_string();
row.last_seen_unix = now;
changed = true;
}
})
.or_insert_with(|| {
changed = true;
ObservedDhcpClient {
mac: mac_s.clone(),
ip,
hostname: hostname.clone(),
first_seen_unix: now,
last_seen_unix: now,
last_action: action.to_string(),
}
});
if changed {
save_observation_store(&store).await?;
}
if let Some(hostname) = hostname {
let mut cache = load_mac_name_cache().await.unwrap_or_default();
if cache.get(&mac_s).map(|v| v != &hostname).unwrap_or(true) {
cache.insert(mac_s, hostname);
save_mac_name_cache(&cache).await?;
changed = true;
}
}
Ok(changed)
}
pub async fn observe_neighbor_event(
action: &str,
mac: Option<MacAddr>,
ip: Option<IpAddr>,
) -> io::Result<bool> {
if !matches!(action, "add" | "update" | "old" | "remove") {
return Ok(false);
}
let Some(key) = mac
.map(|value| format!("mac:{}", value))
.or_else(|| ip.map(|value| format!("ip:{}", value)))
else {
return Ok(false);
};
let now = now_unix();
let mac = mac.map(|value| value.to_string());
let mut store = load_observation_store().await.unwrap_or_default();
let mut changed = false;
store
.neighbors
.entry(key.clone())
.and_modify(|row| {
if row.mac != mac
|| row.ip != ip
|| row.last_action != action
|| row.last_seen_unix != now
{
row.mac = mac.clone();
row.ip = ip;
row.last_action = action.to_string();
row.last_seen_unix = now;
changed = true;
}
})
.or_insert_with(|| {
changed = true;
ObservedNeighbor {
key,
mac,
ip,
first_seen_unix: now,
last_seen_unix: now,
last_action: action.to_string(),
}
});
if changed {
save_observation_store(&store).await?;
}
Ok(changed)
}
/// Parse one `dnsmasq`-style DHCP lease line.
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
let mut c = line.split_whitespace();
@@ -48,6 +260,7 @@ pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
/// Read DHCP leases and fill missing names from the MAC-name cache.
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
let leases = read_dhcp_leases().await?;
let observations = load_observation_store().await.unwrap_or_default();
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
@@ -58,6 +271,12 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = observations
.dhcp_clients
.get(&mac_s)
.and_then(|row| row.hostname.as_ref())
{
l.name = Some(prev.clone());
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
@@ -69,6 +288,13 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
Ok(leases_with_names)
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
/// Enrich DHCP leases with the best currently known neighbor state per IP.
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();