Compare commits
8 Commits
abc55abb0b
...
7ab87384a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ab87384a7 | |||
| 65eff5cf50 | |||
| 008d2bf30b | |||
| 0eb38520ed | |||
| cdc182f0fb | |||
| f8bd4d1dab | |||
| ee900c6395 | |||
| abf499cb23 |
@@ -47,6 +47,14 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
|
||||
|
||||
- [x] JWT auth, Argon2id passwords, rate limiting, security headers
|
||||
- [x] Sessions / machines / support-codes / events
|
||||
- [ ] **Full machine inventory in the connection DB** — P2 — persist per-machine device inventory (OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured server-side + private LAN IP + MAC, logged-on user, idle, time zone, uptime, local-admin) on `connect_machines`, refreshed each `AgentStatus`, shown in the dashboard machine detail (ScreenConnect "Guest Info" parity). Data layer for SPEC-002 Phase 2; closes GC side of agent-IP gap (todo 7459428e). ([SPEC-003](specs/SPEC-003-machine-inventory.md))
|
||||
- [ ] **Stable machine identity + session lifecycle reaping + operator removal** — P1 — give the agent a deterministic machine-derived `machine_uid` (Windows `MachineGuid`-based) so the same box can't register duplicates (root cause: `agent_id` is a config-file random UUID that a portable/misconfigured run regenerates each launch); key registration on it; add TTL reaping + same-machine supersede as defense-in-depth; and admin-gated per-row + multi-select bulk removal of stale sessions/units. Identity must be bound to the per-machine agent key (spoof guard). Fixes ghost-session accumulation seen on the live console (15 sessions / 0 live, ~10 orphans for one machine). ([SPEC-004](specs/SPEC-004-session-lifecycle-and-removal.md))
|
||||
- [ ] **Machines list view — dual connection indicators + rich rows** — P2 — ScreenConnect "Access"-list parity: per-row Host/Guest two-segment connection bar (Guest=agent online, Host=viewer connected, with names + durations) and rich inline metadata (company, site, device type, tags, logged-on user + idle, client version in red when outdated). Server-enriches `/api/machines` with live session state + SPEC-003 inventory. ([SPEC-005](specs/SPEC-005-machines-list-view-parity.md))
|
||||
- [ ] Machines "by Company" tree nav with per-company counts — P3 — left-nav grouping sidebar (screenshot parity). Follow-up sub-item of SPEC-005.
|
||||
- [ ] **Universal machine search ("everything is searchable")** — P2 — server-side `?q=` on `/api/machines` matching case-insensitive substring across ALL attributes (OS, logged-on user, external/private IP, company, site, tag, serial, MAC, version, …), pg_trgm GIN-indexed; multi-term AND + optional field-scoped syntax (`os:`, `user:`, `ip:`). Replaces the hostname-only client filter. Depends on SPEC-003 (attrs must be persisted). ([SPEC-006](specs/SPEC-006-universal-machine-search.md))
|
||||
- [ ] **Managed-agent installer builder ("Build Installer")** — P2 — dashboard wizard to build a pre-labeled persistent-agent installer (Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL / Send Link, reusing the existing embed-config download path; adds department + device_type to EmbeddedConfig/AgentStatus so labels persist at install time. Pairs with revocable per-machine keys; signature-vs-appended-config is the key open question. ([SPEC-007](specs/SPEC-007-managed-agent-installer-builder.md))
|
||||
- [ ] **Valuable error messages (structured errors + no silent swallows)** — P2 — one structured API error envelope with stable codes + a correlation id that also lands in the logs; contextual tracing on server/agent; sweep the 37 `let _ =` swallows (the pattern that hid the migration-005 bug); dashboard surfaces the real cause + id instead of a generic line. ([SPEC-008](specs/SPEC-008-valuable-error-messages.md))
|
||||
- [ ] **Feature-rich, fully-documented management API** — P2 — everything the console can do, callable by API: OpenAPI 3.x generated from code (utoipa) + browsable docs at `/api/docs`, long-lived revocable scoped API tokens (PAT-style, distinct from the 24h JWT + agent keys), an API-completeness gap audit, and consistent pagination/error conventions. Distinct from the ADR-001 RMM integration contract. ([SPEC-009](specs/SPEC-009-feature-rich-documented-api.md))
|
||||
- [ ] Programmatic session pre-create + viewer-token (integration contract) — P2
|
||||
|
||||
## Security & Infrastructure
|
||||
|
||||
241
docs/specs/SPEC-003-machine-inventory.md
Normal file
241
docs/specs/SPEC-003-machine-inventory.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# SPEC-003: Full Machine Inventory in the Connection Database
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Large
|
||||
|
||||
## Overview
|
||||
|
||||
Persist a complete per-machine device inventory on `connect_machines` — refreshed
|
||||
from the agent on each status cycle and stamped server-side with the real external
|
||||
IP — and surface it in the dashboard machine-detail view, bringing GuruConnect to
|
||||
parity with ScreenConnect's "Guest Info" panel. Today a machine record holds only
|
||||
`hostname`, `os_version` (literally `std::env::consts::OS` → `"windows"`),
|
||||
`is_elevated`, `organization`, `site`, `tags`, and timestamps; a technician cannot
|
||||
answer "what is this box, who's on it, what's its WAN/LAN IP, what's its MAC" from
|
||||
GuruConnect at all. Success = every connected agent shows hostname, company, site,
|
||||
logged-on user, idle time, OS (full string + locale + install date), CPU, memory,
|
||||
manufacturer/model, serial, **external WAN IP**, **private LAN IP**, **MAC**, client
|
||||
version, time zone, uptime, and local-admin-present in the machine detail, kept
|
||||
current as the agent reconnects.
|
||||
|
||||
Reference example (ScreenConnect Guest Info — `DESKTOP-VRBQ6LM`, Pavon / Curves):
|
||||
Network Address `174.78.94.186`, Private Network Address `192.168.1.128`, MAC
|
||||
`04:42:1A:0C:8C:A6`, Client Version `26.1.24.9579`, TZ `(UTC-07:00) Arizona`, Uptime
|
||||
`33d 4h`, Local Admin Present `Yes`, Logged On User `DESKTOP-VRBQ6LM\GeoVision`, OS
|
||||
`Microsoft Windows 11 Pro (10.0.26200) en-US`, OS install `3/15/2025`, CPU `i7-11700
|
||||
(16 virtual) X64`, Mem `5260 / 16140 MB`, Mfr/Model `ASUS System Product Name`.
|
||||
|
||||
> This is the data-layer companion to **SPEC-002 Phase 2**, which builds the
|
||||
> "machines inventory/history/delete" dashboard surface (SPEC-002 §"Phase 2"). SPEC-003
|
||||
> defines *what data exists and how it is collected*; SPEC-002 renders it. It also
|
||||
> closes the GuruConnect side of the GuruRMM agent-IP gap (coord todo `7459428e`):
|
||||
> GuruRMM has **no** IP fields at all, whereas GC already computes the client IP at
|
||||
> connect time (`utils::ip_extract::client_ip`) and merely fails to store it.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- New nullable inventory columns on `connect_machines` (migration 008).
|
||||
- A `DeviceInventory` protobuf message reported by the agent (Windows collection via
|
||||
WMI/Win32 APIs), carried on `AgentStatus` and written through to the machine row.
|
||||
- **Server-side** capture of the external IP from the already-extracted
|
||||
trusted-proxy `client_ip` (never trust an agent-reported WAN IP).
|
||||
- Extend `MachineInfo` API DTO + `GET /api/machines` / `GET /api/machines/:agent_id`
|
||||
to return the inventory.
|
||||
- Dashboard machine-detail rendering of the inventory (the Guest-Info-style panel).
|
||||
- Derived/display-only fields composed from existing tables: Hosts/Guests Connected
|
||||
and "Guest Last Connected" from `connect_sessions` history.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- macOS / Linux inventory collection — the agent is Windows-only today (cross-platform
|
||||
agents are roadmap "Future Considerations" P3). The collector is structured so
|
||||
non-Windows builds report `None`/empty without breaking the wire or schema.
|
||||
- Editable/operator-authored fields (Department, Device Type, custom Attributes as
|
||||
free-text the tech sets). v1 stores `department`/`device_type` as **agent-reported
|
||||
or null**; operator-editable metadata is a follow-up (needs a dashboard write path
|
||||
+ audit).
|
||||
- Live polling/push of inventory between status cycles, scheduled re-inventory jobs,
|
||||
and historical inventory diffing — v1 is "latest snapshot, refreshed on agent status".
|
||||
- Pending Activity / screenshot thumbnail (separate concerns; screenshot is SPEC-002
|
||||
viewer work).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Agent (`agent/src/`)
|
||||
- New module `agent/src/inventory/mod.rs` — `collect() -> DeviceInventory`. Windows
|
||||
implementation gathers:
|
||||
- **OS**: `Win32_OperatingSystem` (Caption, Version, OSLanguage→locale, InstallDate,
|
||||
FreePhysicalMemory, TotalVisibleMemorySize).
|
||||
- **System**: `Win32_ComputerSystem` (Manufacturer, Model, Domain/Workgroup,
|
||||
UserName→logged-on user, TotalPhysicalMemory).
|
||||
- **CPU**: `Win32_Processor` (Name, NumberOfLogicalProcessors, Architecture).
|
||||
- **Serial**: `Win32_BIOS.SerialNumber` (fallback `Win32_SystemEnclosure`).
|
||||
- **Network**: `GetAdaptersAddresses` (iphlpapi) for primary LAN IPv4 + MAC of the
|
||||
active adapter (skip loopback/virtual).
|
||||
- **Idle time**: `GetLastInputInfo` (must run in the interactive session — see Open
|
||||
Questions; SYSTEM service sees its own idle, so query via the same session path
|
||||
the agent already uses for capture, or report `None` when unavailable).
|
||||
- **Local admin present**: enumerate the local `Administrators` group for any
|
||||
enabled non-builtin member (`NetLocalGroupGetMembers`).
|
||||
- **Time zone**: `GetDynamicTimeZoneInformation`.
|
||||
- Uptime already available (`AgentStatus.uptime_secs`).
|
||||
- Win7 SP1+ safe: all of the above are available on Win7/Server 2008 R2. Prefer the
|
||||
`wmi` crate (pure-Rust over COM, no runtime redistributable — satisfies the
|
||||
"statically linked, no .NET/VC++" constraint) or direct `windows`-crate calls.
|
||||
- Collection cadence: inventory is near-static, so collect once at session start and
|
||||
re-collect every ~15 min (or on a `display_count`/IP change), **not** every
|
||||
heartbeat. Carry it on `AgentStatus.inventory` only when present; the lightweight
|
||||
status tick stays cheap.
|
||||
- Replace the bogus `os_version: std::env::consts::OS` with the real OS caption (keep
|
||||
`os_version` for back-compat; populate the richer `os_name` from inventory).
|
||||
|
||||
### Protobuf (`proto/guruconnect.proto`)
|
||||
- Add a `DeviceInventory` message and attach it to `AgentStatus` at the next free
|
||||
field number **12** (fields 1–11 are taken; `supports_h264 = 11`):
|
||||
|
||||
```proto
|
||||
message DeviceInventory {
|
||||
string os_name = 1; // "Microsoft Windows 11 Pro (10.0.26200)"
|
||||
string os_locale = 2; // "en-US"
|
||||
int64 os_install_unix = 3; // OS install time, unix secs
|
||||
string machine_domain = 4; // "WORKGROUP" or AD domain
|
||||
string logged_on_user = 5; // "DESKTOP-VRBQ6LM\\GeoVision"
|
||||
int64 idle_secs = 6; // -1 = unknown
|
||||
string cpu_model = 7;
|
||||
int32 cpu_logical = 8;
|
||||
string cpu_arch = 9; // "X64"
|
||||
int64 mem_total_mb = 10;
|
||||
int64 mem_available_mb = 11;
|
||||
string manufacturer = 12;
|
||||
string model = 13;
|
||||
string serial_number = 14;
|
||||
string machine_description = 15;
|
||||
string private_ip = 16; // LAN IPv4 of the active adapter
|
||||
string mac_address = 17;
|
||||
string time_zone = 18; // "(UTC-07:00) Arizona"
|
||||
bool local_admin_present = 19;
|
||||
string department = 20; // agent-config-reported (optional)
|
||||
string device_type = 21; // agent-reported (optional)
|
||||
}
|
||||
|
||||
message AgentStatus {
|
||||
// ... existing fields 1-11 ...
|
||||
DeviceInventory inventory = 12; // present periodically, not every tick
|
||||
}
|
||||
```
|
||||
|
||||
- External IP is **not** an agent-reported field (deliberately) — see Security.
|
||||
|
||||
### DB schema (`server/migrations/008_machine_inventory.sql`)
|
||||
- `ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS ...` — all nullable, no
|
||||
`NOT NULL` (consistent with the table's existing NULL-tolerant reality and the
|
||||
manual `FromRow`):
|
||||
`os_name TEXT, os_locale TEXT, os_install_at TIMESTAMPTZ, machine_domain TEXT,
|
||||
logged_on_user TEXT, idle_secs BIGINT, cpu_model TEXT, cpu_logical INT,
|
||||
cpu_arch TEXT, mem_total_mb BIGINT, mem_available_mb BIGINT, manufacturer TEXT,
|
||||
model TEXT, serial_number TEXT, machine_description TEXT, external_ip INET,
|
||||
private_ip INET, mac_address TEXT, time_zone TEXT, uptime_secs BIGINT,
|
||||
local_admin_present BOOLEAN, department TEXT, device_type TEXT,
|
||||
inventory_updated_at TIMESTAMPTZ`.
|
||||
- Idempotent `ADD COLUMN IF NOT EXISTS`, applied by `sqlx::migrate!()` on startup —
|
||||
**never** pre-applied via psql (see `.claude/standards/gururmm/sqlx-migrations.md`,
|
||||
and the 005→007 lesson: do not rely on `IF NOT EXISTS` to add constraints later).
|
||||
- Client Version reuses the existing version column written by
|
||||
`db::releases::update_machine_version` — do not duplicate.
|
||||
|
||||
### Server (`server/src/`)
|
||||
- `db/machines.rs`:
|
||||
- Extend the `Machine` struct + manual `FromRow` (machines.rs:30–107) with the new
|
||||
columns, each `Option<T>` read NULL-tolerantly (same pattern as `organization`).
|
||||
- New `update_machine_inventory(pool, agent_id, &DeviceInventory)` (sibling of
|
||||
`update_machine_metadata`, machines.rs:213) — `COALESCE` each field so a partial
|
||||
snapshot never nulls a previously-known value; set `inventory_updated_at = NOW()`.
|
||||
- New `update_machine_external_ip(pool, agent_id, IpAddr)`.
|
||||
- `relay/mod.rs`:
|
||||
- At connect/upsert (mod.rs:591, where `client_ip` is in scope) call
|
||||
`update_machine_external_ip(agent_id, client_ip)` so the WAN IP is stamped from the
|
||||
trusted-proxy-derived address.
|
||||
- In the `AgentStatus` handler (mod.rs:779–835), when `status.inventory` is present,
|
||||
call `update_machine_inventory(...)` alongside the existing
|
||||
`update_machine_metadata(...)` write.
|
||||
- `api/mod.rs`: extend `MachineInfo` (mod.rs:117) + `From<Machine>` (mod.rs:130) with
|
||||
the inventory fields (ISO-8601 for timestamps, IPs as strings). `GET /api/machines`,
|
||||
`GET /api/machines/:agent_id` (main.rs:385–386) return them unchanged in shape
|
||||
beyond the added fields. Compose Hosts/Guests-connected + "Guest Last Connected"
|
||||
from `db::sessions` for the detail endpoint.
|
||||
|
||||
### Dashboard (`dashboard/src/`)
|
||||
- Extend the `Machine` type (`dashboard/src/api/types.ts`) and machine-detail view
|
||||
with a Guest-Info-style inventory panel (Session / Device / Network groupings).
|
||||
Coordinate with SPEC-002 Phase 2 so this is one panel, not two.
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `proto/guruconnect.proto` (~line 303, `AgentStatus`);
|
||||
`agent/src/inventory/mod.rs` (new) + wire into `agent/src/session/mod.rs:236`
|
||||
(`send_status`); `server/migrations/008_machine_inventory.sql` (new);
|
||||
`server/src/db/machines.rs:30,101,213`; `server/src/relay/mod.rs:591,824`;
|
||||
`server/src/api/mod.rs:117,130`; `dashboard/src/api/types.ts`,
|
||||
`dashboard/src/api/machines.ts`, machine-detail component.
|
||||
- Key structs/messages: `DeviceInventory` (proto), `Machine` (db), `MachineInfo` (api).
|
||||
- `idle_secs = -1`, empty strings, and absent adapters all map to `NULL`/unknown in
|
||||
the DB — display as "—", never as `0`/`false` masquerading as real data.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **External IP is server-authoritative.** Capture it only from
|
||||
`utils::ip_extract::client_ip` (trusted-proxy-aware; ignores client-supplied
|
||||
`X-Forwarded-For`/`X-Real-IP` from untrusted peers — see the module doc). An
|
||||
agent-reported WAN IP would be spoofable and is intentionally not modeled.
|
||||
- Auth unchanged: inventory flows over the already-authenticated agent WS (support
|
||||
code or per-machine key) and is served only on JWT-authenticated machine endpoints.
|
||||
- `serial_number`, `logged_on_user`, `machine_domain`, and local-admin presence are
|
||||
mildly sensitive; they are admin-dashboard-only (no new unauthenticated surface).
|
||||
- Input validation: treat all agent-reported strings as untrusted — length-cap each
|
||||
field server-side before persisting (defense against a compromised/rogue agent
|
||||
bloating the row); `bytea`/oversized values rejected.
|
||||
- Audit: no new audit events required, but an inventory write is `debug!`-logged with
|
||||
agent_id (no PII beyond what the admin already sees).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** `inventory::collect()` on Windows returns populated fields on a real box;
|
||||
non-Windows returns all-`None`. `update_machine_inventory` COALESCE semantics
|
||||
(partial snapshot preserves prior values). `MachineInfo` serialization round-trip.
|
||||
- **Integration:** agent → relay → DB: connect an agent, assert `connect_machines`
|
||||
gains `external_ip` (matching the test's `client_ip`) at connect and the full
|
||||
inventory after the first `AgentStatus.inventory`; `GET /api/machines/:id` returns
|
||||
it. Verify a status tick *without* inventory does not null existing values.
|
||||
- **Manual:** enroll the Pavon/Raiders or a lab box, confirm the dashboard detail
|
||||
matches the real machine (cross-check WAN IP against the box's actual egress IP,
|
||||
LAN IP/MAC against `ipconfig /all`).
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Large.** The bulk is the Windows inventory collector (WMI/Win32, Win7-safe)
|
||||
and the dashboard panel; the proto/DB/relay/API wiring is mechanical and follows
|
||||
the existing `organization`/`site`/`tags` precedent end-to-end.
|
||||
- **Depends on:** nothing blocking. Reuses existing `client_ip` extraction and the
|
||||
`AgentStatus` pipeline.
|
||||
- **Unblocks / aligns with:** SPEC-002 Phase 2 dashboard "machines inventory" surface;
|
||||
resolves the GC side of coord todo `7459428e` (agent IP tracking).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Idle time as a SYSTEM service.** `GetLastInputInfo` returns input idle for the
|
||||
*calling* session; a SYSTEM-context agent may not see the interactive user's idle.
|
||||
Does the agent already have an interactive-session path (it injects input/captures
|
||||
the active console) we can reuse, or do we report `idle_secs = -1` when running
|
||||
headless? Resolve during planning.
|
||||
2. **Multiple NICs.** Store only the primary active adapter's IP/MAC (v1), or all
|
||||
adapters (`TEXT[]`)? ScreenConnect shows one; v1 proposes one. Revisit if multi-homed
|
||||
boxes matter.
|
||||
3. **Operator-editable Department / Device Type / Attributes.** v1 treats these as
|
||||
agent-reported/null. Confirm whether the dashboard should also let a tech set them
|
||||
(adds a write endpoint + audit) now or as a follow-up spec.
|
||||
4. **Re-inventory cadence.** 15 min fixed vs. change-triggered vs. configurable —
|
||||
pick a default; 15 min proposed.
|
||||
231
docs/specs/SPEC-004-session-lifecycle-and-removal.md
Normal file
231
docs/specs/SPEC-004-session-lifecycle-and-removal.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# SPEC-004: Stable Machine Identity, Session Lifecycle Reaping, and Operator Removal
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P1
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Medium
|
||||
|
||||
## Overview
|
||||
|
||||
Stop orphaned managed sessions from accumulating in the Operator Console, and give
|
||||
operators a first-class way to remove stale sessions/units — per-row **and** in bulk
|
||||
(multi-select mass delete). Today the Sessions view can show many dead rows that look
|
||||
live, and the only per-row action is "End", which applies to a *live* session and does
|
||||
nothing for an already-dead one — so junk just piles up with no way to clear it.
|
||||
The durable fix is at registration: the **same machine must resolve to one stable
|
||||
identity** so a repeated execution cannot mint duplicates in the first place; reaping
|
||||
and manual removal then become defense-in-depth and cleanup, not the primary mechanism.
|
||||
Success = (a) the same machine, run repeatedly (even from a portable/misconfigured
|
||||
copy), registers to **one** record/session — no duplicates; (b) reconnecting/offline
|
||||
persistent agents no longer leave behind retained ghost sessions; and (c) an admin can
|
||||
select one or many session rows (and stale machine rows) and remove them from the
|
||||
console.
|
||||
|
||||
**Observed (live console, 2026-05-30):** the Sessions view listed **15 sessions, 0
|
||||
live**, of which ~10 were duplicate `MANAGED` rows for a single machine
|
||||
(`DESKTOP-I66IM5Q` / Pavon-Raiders), each a distinct session UUID, all
|
||||
`NOT REQUIRED` consent, no viewers, no duration, all "37 minutes ago". That machine had
|
||||
just been cleaned of a misbehaving GuruConnect *client* that was reconnecting in a loop
|
||||
— that reconnect storm is what produced the orphans, which is exactly why this needs
|
||||
**both** a lifecycle fix and a manual-removal control.
|
||||
|
||||
## Root cause (confirmed in code)
|
||||
|
||||
The Sessions list is served from the **in-memory `SessionManager`**, not the database:
|
||||
`GET /api/sessions` → `list_sessions` (`main.rs:636`) → `state.sessions.list_sessions()`
|
||||
(`session/mod.rs:584`). Three compounding defects let ghosts accumulate there:
|
||||
|
||||
0. **Machine identity is a config-file random UUID, not machine-derived.** The agent's
|
||||
`agent_id` is a random UUID minted by `generate_agent_id()` (`agent/src/config.rs:90`)
|
||||
on first run and persisted **only in the agent config file** (`config.rs:331`), or
|
||||
taken from `GURUCONNECT_AGENT_ID` (`config.rs:366`). A portable or misconfigured
|
||||
execution that cannot locate/write that config — e.g. the Pavon desktop launcher
|
||||
`guruconnect-pavon-raidersreef.exe` run repeatedly from a user Desktop — regenerates
|
||||
a **fresh** `agent_id` every launch. Because identity is not derived from the machine,
|
||||
the same physical box presents as N different agents. The DB upsert
|
||||
(`upsert_machine`, `ON CONFLICT (agent_id)`) and the session-reuse map both key on
|
||||
this id, so an unstable id defeats *both* dedupe layers at the source.
|
||||
1. **Reconnect-reuse is keyed on that `agent_id`.** `register_agent`
|
||||
(`session/mod.rs:169`) reuses an existing session only when
|
||||
`self.agents.get(&agent_id)` resolves to an `is_online == false` session. With a new
|
||||
`agent_id` per launch (defect 0) the lookup misses and a **brand-new persistent
|
||||
session** is created each time. The `agents` map holds only one session per agent_id,
|
||||
so prior sessions become unreferenced yet remain in the `sessions` map.
|
||||
2. **Persistent sessions are never reaped.** On disconnect, only *support* sessions are
|
||||
removed entirely; persistent/managed sessions are deliberately retained
|
||||
(`session/mod.rs:519–542`) and there is **no TTL sweep**. An offline managed session
|
||||
therefore lives in memory indefinitely, displayed alongside genuinely-live ones.
|
||||
|
||||
Net effect: N reconnects with unstable identity → N retained, never-expiring managed
|
||||
sessions, none of which the UI can clear.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- **Stable, machine-derived identity (the primary fix):**
|
||||
- The agent computes a deterministic `machine_uid` from durable machine identifiers —
|
||||
primary source the Windows `MachineGuid`
|
||||
(`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`), optionally folded with a stable
|
||||
hardware id (board/BIOS serial) — hashed to a stable string. It is **recomputable**:
|
||||
a lost/absent config self-heals to the *same* id rather than minting a new random
|
||||
one. Persist a cached copy, but never depend on the config file for correctness.
|
||||
- Registration keys on `machine_uid`: `upsert_machine`’s `ON CONFLICT` and the
|
||||
in-memory `agents`/session-reuse map both use it, so the same box converges to **one**
|
||||
machine record and **one** managed session no matter how many times it executes.
|
||||
- Carry `machine_uid` in the agent connect handshake (`transport/websocket.rs:40`
|
||||
query params) / `AgentStatus`; keep the legacy random `agent_id` only as a
|
||||
migration fallback.
|
||||
- **Lifecycle reaping (defense-in-depth):**
|
||||
- Periodic background sweep that removes persistent sessions whose agent has been
|
||||
offline (`is_online == false`) longer than a TTL (default 10 min, configurable),
|
||||
using the existing `last_heartbeat_instant`.
|
||||
- On agent reconnect, **supersede** prior retained sessions for the same machine
|
||||
(dedupe by `hostname`/machine identity, not only exact `agent_id`) so a fresh
|
||||
agent_id cannot strand the old session.
|
||||
- On socket drop for a persistent agent, mark the session offline *and* eligible for
|
||||
the sweep (DB `end_session` already fires at `relay/mod.rs:892`; align in-memory
|
||||
state with it).
|
||||
- **Manual removal API (admin-gated, audited):**
|
||||
- `DELETE /api/sessions/:id?purge=true` — remove the in-memory session record
|
||||
(`SessionManager::remove_session`, `session/mod.rs:548`) **and** soft-delete the DB
|
||||
row. Distinguish from the existing `disconnect_session` (which ends a *live*
|
||||
session) — purge works on dead rows.
|
||||
- `POST /api/sessions/bulk` (or `DELETE` with a body) taking `{ ids: [...] , action:
|
||||
"purge" | "end" }` for mass delete / bulk-end.
|
||||
- Same stale-removal for the Machines view: extend the existing
|
||||
`DELETE /api/machines/:agent_id` (`main.rs:387`) usage with a bulk variant for
|
||||
stale units.
|
||||
- **Dashboard UX:**
|
||||
- Per-row **Remove** action on `SessionsPage.tsx` for non-live rows (alongside the
|
||||
existing End in `EndSessionDialog.tsx`).
|
||||
- Multi-select checkboxes + a bulk-action bar (Select all / mass Remove / bulk End)
|
||||
on the Sessions view; mirror on `MachinesPage.tsx` for stale units.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- Surviving a full machine **reimage/clone** with the same identity. `MachineGuid`
|
||||
regenerates on sysprep/reimage and is duplicated by naive disk clones, so a reimaged
|
||||
box legitimately becomes a new `machine_uid` (and a clone collision is caught by the
|
||||
auth binding below). Cross-reimage identity continuity is out of scope for v1.
|
||||
- Replacing the shared `AGENT_API_KEY` with per-machine agent keys — tracked separately
|
||||
(roadmap GuruRMM-Integration). SPEC-004 *assumes* that binding for its threat model
|
||||
(see Security) and degrades safely without it, but does not implement it.
|
||||
- "Mass edit" beyond bulk-end / bulk-remove (e.g. bulk tagging/renaming) — the request
|
||||
mentioned it; deferred unless a concrete edit field is identified.
|
||||
- Hard-deleting DB session history — v1 soft-deletes (`deleted_at`) to preserve the
|
||||
audit trail (CLAUDE.md DB conventions).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Agent (`agent/src/`):** new `identity` module computes `machine_uid` deterministically
|
||||
(Windows `MachineGuid` primary; non-Windows fallback to a stable persisted UUID).
|
||||
Replace/augment `generate_agent_id()` (`config.rs:90`) so the effective id is the
|
||||
machine-derived value, with the config-file value used only as a cache. Send
|
||||
`machine_uid` in the connect query string (`transport/websocket.rs:40`) and on
|
||||
`AgentStatus`.
|
||||
- **Relay-server (`server/src/session/mod.rs`):** key `register_agent` and the
|
||||
`agents` map on `machine_uid` so the same machine reuses one session; add
|
||||
`reap_stale_persistent(ttl)` to `SessionManager` + a periodic task (e.g. every 60 s)
|
||||
from server startup; supersede any prior same-machine sessions on reconnect; add a
|
||||
`purge`-style removal the API can call for dead rows.
|
||||
- **DB (`server/src/db/sessions.rs` + migration `008`/`009`):** add
|
||||
`deleted_at TIMESTAMPTZ` to `connect_sessions`; add `purge_session` (soft-delete) and
|
||||
a bulk variant; `get_recent_sessions`/list queries filter `deleted_at IS NULL`.
|
||||
Idempotent `ADD COLUMN IF NOT EXISTS`, applied by `sqlx::migrate!()` on startup —
|
||||
never pre-applied via psql (see the 005→007 lesson).
|
||||
- **API (`server/src/main.rs`, `server/src/api/`):** new purge + bulk routes, all behind
|
||||
the existing `AuthenticatedUser`/admin guard; emit audit `events` rows.
|
||||
- **Dashboard (`dashboard/src/features/sessions/`, `.../machines/`):** selection state,
|
||||
bulk-action bar, Remove confirmation reusing the `EndSessionDialog`/
|
||||
`DeleteMachineDialog` patterns; `dashboard/src/api/sessions.ts` gains
|
||||
`purgeSession` / `bulkSessions`.
|
||||
- **Protobuf:** add `machine_uid` to the agent identity carried on `AgentStatus` (the
|
||||
connect handshake passes it as a query param; mirroring it on `AgentStatus` lets the
|
||||
server reconcile mid-session). Otherwise server/dashboard only.
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `agent/src/identity/` (new — `machine_uid` derivation),
|
||||
`agent/src/config.rs:90,366` (effective id = machine-derived),
|
||||
`agent/src/transport/websocket.rs:40` (send `machine_uid`);
|
||||
`server/src/session/mod.rs:169,519,548,584` (key on `machine_uid`; reuse/reap/remove);
|
||||
`server/src/relay/mod.rs:584,591` (registration path);
|
||||
`server/src/main.rs:376–388,636,661` (routes + handlers); `server/src/db/sessions.rs`
|
||||
(purge + bulk + `deleted_at` filtering); `server/migrations/` (new migration for
|
||||
`deleted_at`); `dashboard/src/features/sessions/SessionsPage.tsx`,
|
||||
`EndSessionDialog.tsx`, `dashboard/src/api/sessions.ts`;
|
||||
`dashboard/src/features/machines/MachinesPage.tsx`.
|
||||
- Keep the in-memory list authoritative for "live"; treat purge as: remove in-memory +
|
||||
soft-delete DB. A reaped/purged session must vanish from `list_sessions()` output.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Identity is not authorization.** A client-asserted `machine_uid` is self-reported
|
||||
and therefore spoofable — on its own, agent A could claim agent B's `machine_uid` to
|
||||
bind to (and hijack) B's session and machine record. The `machine_uid` must be
|
||||
**bound to the agent's authenticated credential**: the server accepts a given
|
||||
`machine_uid` only from a connection authenticated by that machine's own agent key
|
||||
(or, for a brand-new machine, first-seen trust-on-first-use that pins the uid↔key
|
||||
pair). This is why per-machine agent keys (roadmap) are the natural companion; until
|
||||
they ship, the shared `AGENT_API_KEY` means `machine_uid` is a *correctness* improvement
|
||||
(dedupe) but not yet a *trust* boundary — call this out so it isn't mistaken for one.
|
||||
A clone collision (two boxes, same `MachineGuid`) surfaces here as two agents claiming
|
||||
one uid and is resolved by the key binding, not by the uid alone.
|
||||
- All purge/bulk endpoints require an authenticated admin (`AuthenticatedUser`, same
|
||||
guard as `list_sessions`); never expose removal unauthenticated.
|
||||
- Audit every removal to the `events` table (who, which session/machine, when, count
|
||||
for bulk) — soft-delete + audit, not silent hard-delete.
|
||||
- Validate/limit bulk request size (cap N per call) to avoid a single call sweeping the
|
||||
whole fleet by accident or abuse.
|
||||
- Reaping must not end a session that is merely briefly offline (TTL guards against
|
||||
flapping); never reap an `is_online` or viewer-attached session.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** `machine_uid` derivation is deterministic — same machine inputs yield the
|
||||
same uid across runs, and an absent config recomputes the same value (no fresh random
|
||||
id). `register_agent` for the same `machine_uid` reuses/supersedes the prior session
|
||||
(no duplicate retained) even when the legacy `agent_id` differs.
|
||||
`reap_stale_persistent` removes offline-past-TTL persistent sessions and spares
|
||||
online/within-TTL ones. `purge_session` soft-deletes and filters out of list queries.
|
||||
- **Integration:** simulate a reconnect storm (M connects, **varying `agent_id` but the
|
||||
same `machine_uid`**, as the Pavon launcher did) → assert `list_sessions()` converges
|
||||
to one live session and `connect_machines` holds one row, not M. A spoof attempt
|
||||
(uid X presented on a connection not authenticated for X) is rejected/not bound. Purge
|
||||
a dead session via API → gone from list + `deleted_at` set + audit row written. Bulk
|
||||
purge of K ids removes exactly K.
|
||||
- **Manual:** on the live console, reproduce against the Pavon machines, confirm the
|
||||
ghost rows can be multi-selected and removed and do not reappear after the sweep.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Medium.** Reaping + supersede + purge/bulk + dashboard follow existing
|
||||
patterns; the migration is trivial. The added agent-side `machine_uid` derivation and
|
||||
threading it through the handshake/registration is the main new surface (bumps this
|
||||
toward the upper end of Medium).
|
||||
- **Depends on:** nothing blocking. **Pairs with** per-machine agent keys (roadmap) for
|
||||
the full trust boundary on `machine_uid` — see Security; SPEC-004 degrades safely
|
||||
without them.
|
||||
- **Unblocks:** a trustworthy Sessions/Machines view (dead rows no longer masquerade as
|
||||
live; one machine = one record/session), and complements SPEC-002 Phase 2's dashboard
|
||||
hardening of the same surfaces.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Reap TTL default** — 10 min proposed; confirm. Should it differ for managed vs.
|
||||
support sessions?
|
||||
2. **`machine_uid` source mix** — `MachineGuid` alone, or folded with board/BIOS serial?
|
||||
`MachineGuid` is stable and present everywhere but regenerates on sysprep and is
|
||||
cloneable; adding a hardware serial reduces clone collisions but churns on hardware
|
||||
swaps. Pick the recipe (proposed: `MachineGuid` primary, hashed).
|
||||
3. **uid↔key binding model** — trust-on-first-use pinning of `machine_uid` to the agent
|
||||
key, vs. requiring per-machine keys before honoring a uid. What's the interim policy
|
||||
while the shared `AGENT_API_KEY` is still in use?
|
||||
4. **Migration of existing rows** — legacy random-`agent_id` machine/session rows: let
|
||||
them age out via the reaper + manual purge, or run a one-time reconcile that maps
|
||||
known hosts to their new `machine_uid`? (Proposed: age-out + purge; no risky backfill.)
|
||||
5. **Purge vs. keep history** — soft-delete (proposed) keeps `connect_sessions` history
|
||||
for audit while hiding it from the console; confirm operators don't expect a hard
|
||||
purge.
|
||||
6. **Bulk-action cap** — what's a sane max N per bulk call (e.g. 100)?
|
||||
153
docs/specs/SPEC-005-machines-list-view-parity.md
Normal file
153
docs/specs/SPEC-005-machines-list-view-parity.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# SPEC-005: Machines List View — Dual Connection Indicators + Rich Rows
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Medium
|
||||
|
||||
## Overview
|
||||
|
||||
Bring the Operator Console Machines list to ScreenConnect "Access"-list parity: each row
|
||||
gains (1) **dual connection indicators** — a two-segment bar showing Host and Guest
|
||||
connection state at a glance — and (2) **rich inline metadata** (company, site, device
|
||||
type, tags, logged-on user + idle, client version) instead of today's bare
|
||||
status-dot / hostname / OS / last-seen table. An operator should be able to scan the
|
||||
list and instantly see which machines are online, which have a tech actively connected,
|
||||
who's logged in, and which agents are running stale clients — without opening the detail
|
||||
drawer. Success = the list conveys the same at-a-glance operational state as the
|
||||
reference console.
|
||||
|
||||
**Reference (screenshot 2026-05-30, "Arizona Computer Guru" company):** `GURU-BEAST-ROG`
|
||||
shows a green **Host** segment ("Computer Guru - SysAdmin") *and* a green **Guest**
|
||||
segment ("Guest - 9h 21m"); most rows show only a green "Guest - Xh Ym" (agent online,
|
||||
no tech connected); `ACG-TECH-01L` shows neither (offline). Rows carry "Company: …",
|
||||
"Site: …", "Device Type: …", "Tag …", "User: … (Idle …)", and "Client Version: …" (in
|
||||
red when out of date).
|
||||
|
||||
> Terminology: the requester's "dial" was a typo for **dual** — two indicators
|
||||
> (Host + Guest), not a dial gauge.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- **Dual connection indicators per row:**
|
||||
- **Guest segment** (monitor icon): green when the endpoint agent is connected
|
||||
(`SessionInfo.is_online == true`); label "Guest - <duration online>"; grey when
|
||||
offline.
|
||||
- **Host segment** (person icon): green when a viewer/operator is actively connected
|
||||
(`SessionInfo.viewer_count > 0`); label = the viewer name(s)
|
||||
(`SessionInfo.viewers[].viewer_name`) + duration since join; grey when none.
|
||||
- Both states come from **live** session state, refreshed on the existing dashboard
|
||||
poll/refresh.
|
||||
- **Rich inline row metadata** (from SPEC-003 inventory + existing columns): Company
|
||||
(`organization`), Site (`site`), Device Type (`device_type`), Tag(s) (`tags`),
|
||||
Logged-On User + Idle (`logged_on_user`, `idle_secs`), and **Client Version**
|
||||
(`agent_version`) rendered in a warning/red style when the agent build is **outdated**
|
||||
(below the latest stable `releases.version`, or below `min_version` → stronger
|
||||
"mandatory update" emphasis).
|
||||
- **Server enrichment:** extend the machines list response so each machine carries its
|
||||
live Host/Guest state and the inventory fields in one payload (avoid N client-side
|
||||
joins). See Architecture.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- The left-nav **"All Machines by Company" tree** with per-company counts (the
|
||||
screenshot's grouping sidebar) — captured as a follow-up sub-item in the roadmap;
|
||||
v1 may ship simple client-side grouping/sort by company but not the full tree nav.
|
||||
- Editable inline fields (rename, set device type/tag from the row) — display only in
|
||||
v1; editing is a separate concern (and overlaps SPEC-003's operator-editable note).
|
||||
- The per-row action menu beyond what exists (Join/Control/keys/remove already covered
|
||||
by other specs).
|
||||
- Native viewer changes — dashboard only.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Data sources today are split:** `GET /api/machines` (`list_machines`,
|
||||
`main.rs:636`) returns `MachineInfo` from the **DB** (`connect_machines`), while live
|
||||
online/viewer state lives in the **in-memory `SessionManager`** and is exposed by
|
||||
`GET /api/sessions` (`SessionInfo`: `is_online`, `viewer_count`, `viewers`,
|
||||
`agent_name`, `started_at`). The list view needs both.
|
||||
- **Relay-server (preferred):** enrich the machines list with live state server-side —
|
||||
join each `connect_machines` row (by `agent_id`/`machine_uid`) to its current
|
||||
`SessionManager` session, and add to `MachineInfo` (`api/mod.rs:117`): `is_online`,
|
||||
`viewer_count`, `viewers` (names), `online_since`, `host_since`, plus the SPEC-003
|
||||
inventory fields (`device_type`, `logged_on_user`, `idle_secs`, `agent_version`).
|
||||
Compute `client_outdated` / `client_update_mandatory` by comparing `agent_version` to
|
||||
the latest stable / `min_version` from `db::releases` (`releases.rs`). One request,
|
||||
fully-rendered rows.
|
||||
- Alternative (lighter, more chatter): dashboard fetches `/api/machines` +
|
||||
`/api/sessions` and joins client-side by agent_id. Acceptable for v1 if server
|
||||
enrichment is deferred, but the join + outdated-version logic then lives in TS.
|
||||
- **Dashboard:** replace the plain `DataTable` columns in
|
||||
`dashboard/src/features/machines/MachinesPage.tsx` with a richer row: a
|
||||
`ConnectionIndicator` component (Host/Guest two-segment bar) + a metadata block.
|
||||
Reuse `StatusDot`/`machineTone` semantics. New `dashboard/src/api/types.ts` fields
|
||||
mirror the enriched `MachineInfo`.
|
||||
- **Protobuf / DB:** none new — relies on SPEC-003's inventory columns and existing
|
||||
session state. (`online_since`/`host_since` may need a timestamp the SessionManager
|
||||
isn't tracking yet — see Open Questions.)
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `server/src/api/mod.rs:117,130` (enrich `MachineInfo` + `From`);
|
||||
`server/src/main.rs:636` (`list_machines` — join live `SessionManager` state, compute
|
||||
outdated flag via `db::releases`); `server/src/session/mod.rs` (expose a
|
||||
by-agent_id/`machine_uid` lookup + `online_since`/`host_since` if added);
|
||||
`dashboard/src/features/machines/MachinesPage.tsx` (row layout),
|
||||
new `dashboard/src/features/machines/ConnectionIndicator.tsx`,
|
||||
`dashboard/src/api/types.ts` / `machines.ts` (new fields), `machines`/`status` CSS.
|
||||
- Key logic: Guest green = `is_online`; Host green = `viewer_count > 0` (label from
|
||||
`viewers`); client-version tone = compare to latest stable release (red if below;
|
||||
stronger if below `min_version`).
|
||||
|
||||
## Security considerations
|
||||
|
||||
- No new endpoints beyond enriching an already admin-authenticated list
|
||||
(`AuthenticatedUser` guard on `list_machines`); no new unauthenticated surface.
|
||||
- `logged_on_user`, viewer names, and inventory are already admin-only data — same
|
||||
trust level as the existing detail drawer; the list merely surfaces it earlier.
|
||||
- Treat agent-reported strings (user, version, device type) as untrusted display data —
|
||||
length-cap/escape in the dashboard (no HTML injection via a rogue agent's reported
|
||||
fields).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** Host/Guest indicator state derives correctly (online+no viewer = Guest only;
|
||||
online+viewer = both; offline = neither). `client_outdated` flag true when
|
||||
`agent_version` < latest stable and when < `min_version`.
|
||||
- **Integration:** a machine with a live viewer returns `viewer_count>0` + viewer name in
|
||||
the enriched list payload; an offline machine returns `is_online=false`; the list joins
|
||||
DB machines to live sessions without dropping rows that have no session.
|
||||
- **Manual:** against the live console, confirm a row with an active Control session shows
|
||||
the green Host segment with the tech's name, agent-online rows show the green Guest
|
||||
segment + duration, and an out-of-date agent shows the client version in red.
|
||||
Cross-check a few rows against the reference screenshot's layout.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Medium.** Most effort is the dashboard row redesign + the
|
||||
`ConnectionIndicator` component and the server-side machines/sessions join; the data
|
||||
largely exists.
|
||||
- **Depends on:** **SPEC-003** (inventory fields: device_type, logged_on_user,
|
||||
idle_secs, agent_version) for the rich metadata. Live Host/Guest state needs nothing
|
||||
new. Reads cleanest **after SPEC-004** (so the list isn't padded with duplicate ghost
|
||||
machines) and dovetails with **SPEC-002 Phase 2**'s dashboard work — coordinate so this
|
||||
is the machines-list increment of that surface, not a parallel rebuild.
|
||||
- **Unblocks:** at-a-glance fleet operability; foundation for the company-tree nav
|
||||
follow-up.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **`online_since` / `host_since` timestamps.** The dual-indicator durations ("Guest -
|
||||
9h 21m", host join time) need an "online since" and "viewer joined at" the
|
||||
`SessionManager` may not track today (`started_at` is session creation, not last
|
||||
went-online). Add these timestamps, or approximate from `started_at`/`last_heartbeat`?
|
||||
2. **Enrich server-side vs. join in the dashboard.** Preferred is one enriched
|
||||
`/api/machines` payload; confirm we don't want to keep the machines/sessions split and
|
||||
join in TS (simpler server, chattier client).
|
||||
3. **"Outdated" definition.** Below latest *stable* release, below `min_version`
|
||||
(mandatory), or a configurable lag (e.g. > N versions behind)? Proposed: red if below
|
||||
latest stable, stronger emphasis if below `min_version`.
|
||||
4. **Company grouping in v1.** Ship simple sort/group-by-company now, or defer all
|
||||
grouping to the company-tree follow-up?
|
||||
145
docs/specs/SPEC-006-universal-machine-search.md
Normal file
145
docs/specs/SPEC-006-universal-machine-search.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# SPEC-006: Universal Machine Search ("Everything Is Searchable")
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Medium
|
||||
|
||||
## Overview
|
||||
|
||||
Give the Operator Console a single search box that matches a query against **any**
|
||||
attribute of any guest machine and returns all matches — OS, logged-on user, IP, company,
|
||||
site, tag, serial, MAC, version, anything — exactly like ScreenConnect. Today the
|
||||
dashboard filters only `hostname`/`agent_id` client-side (`MachinesPage.tsx:46`), which
|
||||
is useless across a ~900-machine fleet. Success = typing "windows 11" finds every Win11
|
||||
box, "fred" finds every machine where Fred is/was logged on, and "98.97" finds the
|
||||
machine at that IP — server-side, fast, case-insensitive substring, across the whole
|
||||
fleet.
|
||||
|
||||
**Examples (user-stated):** search an OS string → all machines with that OS; a username →
|
||||
all machines with that logged-on user; an IP (external or private) → the matching
|
||||
machine(s).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- **Server-side multi-attribute search** on `GET /api/machines?q=<text>` (extend
|
||||
`list_machines`), default = **case-insensitive substring, match-any-field** across:
|
||||
`hostname`, `agent_id`/`machine_uid`, `organization` (company), `site`, `department`,
|
||||
`device_type`, `tags`, `logged_on_user`, `os_name`/`os_version` (+locale),
|
||||
`agent_version` (client version), `manufacturer`, `model`, `serial_number`,
|
||||
`machine_description`, `external_ip`, `private_ip`, `mac_address`, `status`.
|
||||
- **Multi-term AND:** space-separated terms all must match (each term may match a
|
||||
different field) — "windows fred" = Win machine where Fred is logged on.
|
||||
- **Performance:** a Postgres index that supports substring search across all columns
|
||||
(see Architecture) so search stays fast at fleet scale.
|
||||
- **IP/array handling:** `external_ip`/`private_ip` (INET, from SPEC-003) matched as text
|
||||
for partial-IP queries ("98.97"); `tags` (TEXT[]) matched per-element.
|
||||
- Wire the dashboard search box to the server query (debounced), replacing the
|
||||
hostname-only client filter; render results through the SPEC-005 list view.
|
||||
|
||||
### Nice-to-have (v1 if cheap, else fast-follow)
|
||||
|
||||
- **Field-scoped syntax** like ScreenConnect: `os:windows`, `user:fred`, `ip:98.97`,
|
||||
`company:"arizona computer"`, `tag:winter`, `version:23.9` — a known-prefix restricts
|
||||
the match to that column; unprefixed terms stay match-any-field; quotes group a phrase.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- Saved searches / smart groups / search-driven "session groups" (ScreenConnect's
|
||||
`+ Create Session Group`) — separate feature.
|
||||
- Boolean OR / NOT / regex / fuzzy ranking — v1 is AND-of-substring; relevance ranking
|
||||
deferred.
|
||||
- Searching **live-only** state not persisted to the DB (current viewer/host connection)
|
||||
— exposed as a secondary structured filter (online/offline), not free-text, since it
|
||||
lives in the in-memory `SessionManager`, not `connect_machines`.
|
||||
- Full event/session-history search — this spec is machine search.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Relay-server / DB (the core):** add search to `db::machines` and `list_machines`
|
||||
(`main.rs:636`). Query shape:
|
||||
- Maintain a searchable-text representation of each `connect_machines` row and index it
|
||||
for substring search. Two viable approaches (decide in planning):
|
||||
1. **`pg_trgm` GIN index** over a concatenated expression
|
||||
(`lower(hostname||' '||coalesce(organization,'')||' '||…||' '||host(external_ip)||…)`).
|
||||
Supports `ILIKE '%term%'` on arbitrary substrings with index acceleration. Simple,
|
||||
matches ScreenConnect's substring semantics best.
|
||||
2. **Generated `search_text` column** (maintained on write / via the same path as
|
||||
`update_machine_metadata`/`update_machine_inventory`) + `pg_trgm` GIN on it.
|
||||
Slightly more plumbing, cleaner query, easier to weight later.
|
||||
- `tags TEXT[]` folded into the text via `array_to_string(tags,' ')`; INET via
|
||||
`host(external_ip)`/`host(private_ip)`.
|
||||
- Field-scoped terms compile to a targeted `col ILIKE '%v%'` (or `= ANY(tags)` for
|
||||
`tag:`); unscoped terms to the concatenated-text match; all terms AND-ed.
|
||||
- **API:** `list_machines` gains `q` (and reuses the SPEC-005 enriched `MachineInfo`
|
||||
payload so results render fully). Same `AuthenticatedUser` admin guard.
|
||||
- **Dashboard:** the existing search input drives `listMachines({ q })` (debounced ~250ms)
|
||||
in `dashboard/src/api/machines.ts`; remove the hostname-only client filter in
|
||||
`MachinesPage.tsx`. Show result count + "no matches" empty state.
|
||||
- **Protobuf:** none — server/dashboard/DB only. Searchability of inventory fields is
|
||||
entirely dependent on SPEC-003 persisting them.
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `server/migrations/` (new migration: `CREATE EXTENSION IF NOT EXISTS
|
||||
pg_trgm;` + the GIN index / optional `search_text` column — idempotent, startup-applied
|
||||
by `sqlx::migrate!()`, never pre-applied via psql); `server/src/db/machines.rs`
|
||||
(`search_machines(q)` or `get_all_machines` gaining a filter; parameterized, never
|
||||
string-interpolated); `server/src/main.rs:636` (`list_machines` reads `q`);
|
||||
`dashboard/src/features/machines/MachinesPage.tsx:46` (drop local filter, call server),
|
||||
`dashboard/src/api/machines.ts` (q param).
|
||||
- Key logic: tokenize on whitespace (respecting quotes), classify each token as
|
||||
scoped/unscoped, build a parameterized `WHERE term1 AND term2 …`. Cap query length and
|
||||
term count.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **SQL injection:** all terms are bound parameters (`$n`), never concatenated into SQL.
|
||||
Column list for scoped search is a fixed allowlist — a `field:` prefix can only map to a
|
||||
known column, never arbitrary SQL.
|
||||
- Admin-authenticated only (`AuthenticatedUser`), same as the existing machines list — no
|
||||
new unauthenticated surface; search exposes nothing the list doesn't already.
|
||||
- **DoS guard:** cap query length, number of terms, and result page size; `pg_trgm` keeps
|
||||
worst-case substring scans index-backed so a broad query can't table-scan 900+ rows
|
||||
unindexed.
|
||||
- Treat the query as untrusted text; the response is the same admin-only machine data.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** tokenizer (quotes, multi-term, scoped vs. unscoped); query builder emits
|
||||
parameterized SQL with the right AND structure; scoped `field:` maps only to allowlisted
|
||||
columns (unknown prefix → treated as literal text, not an error/injection).
|
||||
- **Integration (seeded DB):** "windows 11" returns all Win11 rows; a username returns
|
||||
rows with that `logged_on_user`; a partial IP returns rows whose `external_ip`/
|
||||
`private_ip` contains it; a tag value returns tagged rows; multi-term ANDs correctly;
|
||||
empty `q` returns the full list unchanged.
|
||||
- **Performance:** with ~1k seeded rows, a broad substring query uses the GIN index
|
||||
(EXPLAIN shows index scan, not seq scan) and returns within target latency.
|
||||
- **Manual:** on the live console, reproduce the user's three examples (OS, username, IP).
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Medium.** The query builder + index is the bulk; the API/dashboard wiring is
|
||||
small. Field-scoped syntax adds a little parsing.
|
||||
- **Depends on:** **SPEC-003** — the inventory attributes (OS detail, user, IP, MAC,
|
||||
serial, version, device type) must be **persisted** on `connect_machines` to be
|
||||
searchable; without it, search covers only the handful of existing columns. Reuses
|
||||
**SPEC-005**'s enriched payload to render results; benefits from **SPEC-004** dedup so
|
||||
results aren't padded with ghost duplicates. Migration orders after SPEC-003/004's.
|
||||
- **Unblocks:** fast fleet triage ("who's on this IP / running this OS / logged in as X"),
|
||||
and the saved-search / smart-group follow-up.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Index strategy** — concatenated-expression `pg_trgm` GIN vs. a maintained
|
||||
`search_text` column. Proposed: start with the expression index (less write plumbing);
|
||||
move to `search_text` if weighting/ranking is wanted later.
|
||||
2. **Field-scoped syntax in v1 or fast-follow?** Default match-any-field covers the stated
|
||||
use cases; scoped syntax is polish.
|
||||
3. **Result cap / pagination** — return all matches, or page (limit/offset)? At ~1k rows a
|
||||
cap with "N more" may suffice; confirm.
|
||||
4. **Include live online/host state as a filter** — free-text can't reach in-memory state;
|
||||
offer `status:online` as a structured filter that the server resolves against the
|
||||
SessionManager, or keep search DB-only in v1?
|
||||
162
docs/specs/SPEC-007-managed-agent-installer-builder.md
Normal file
162
docs/specs/SPEC-007-managed-agent-installer-builder.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# SPEC-007: Managed-Agent Installer Builder ("Build Installer")
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Medium
|
||||
|
||||
## Overview
|
||||
|
||||
Add a dashboard **"Build Installer"** wizard that produces a pre-labeled
|
||||
managed/persistent (unattended) agent installer — the ScreenConnect "Build Installer"
|
||||
flow. The operator picks a naming strategy and fills Company, Site, Department, Device
|
||||
Type, and Tag, chooses the platform/type, and gets the installer via **Download**, **Copy
|
||||
URL**, or **Send Link**. Once installed, the agent enrolls and appears in the machines
|
||||
list already carrying those labels (so SPEC-003 inventory / SPEC-005 rows / SPEC-006
|
||||
search are populated at install time, not only agent-detected). Success = a tech can
|
||||
build and hand off a labeled permanent-agent installer entirely from the console, no CLI
|
||||
or manual query-string crafting.
|
||||
|
||||
**Reference (screenshot 2026-05-30):** modal "Build Installer" with fields **Name** (Use
|
||||
Machine Name / custom), **Company**, **Site**, **Department**, **Device Type**, **Tag**,
|
||||
**Type** (Windows .exe), and share actions **Send Link · Copy URL · Download**.
|
||||
|
||||
## What already exists (and what's missing)
|
||||
|
||||
The embed-config build path is **already implemented**: `server/src/api/downloads.rs`
|
||||
appends an `EmbeddedConfig` (magic marker `GURUCONFIG` + length + JSON) to the base
|
||||
`static/downloads/guruconnect.exe`; `AgentDownloadParams` already accepts `company`,
|
||||
`site`, `tags`, `api_key`; the agent reads it back at `agent/src/config.rs:223`
|
||||
(`read_embedded_config`). The downloads routes are public links (`main.rs:425`).
|
||||
|
||||
Missing for the ScreenConnect-parity builder:
|
||||
- **No dashboard UI** to drive it (endpoints exist, no wizard).
|
||||
- **Department + Device Type** are not in `EmbeddedConfig` (downloads.rs:21 / config.rs:20),
|
||||
not in `AgentStatus`, not persisted on `connect_machines`.
|
||||
- **Name strategy** ("Use Machine Name" vs. custom) isn't modeled.
|
||||
- **Share actions** beyond Download: **Copy URL** and **Send Link** don't exist.
|
||||
- **Key selection** — which agent key the installer embeds (shared `AGENT_API_KEY` vs. a
|
||||
per-machine/site key) isn't surfaced.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- **Dashboard "Build Installer" modal** (triggered from the Machines page, e.g. a
|
||||
`Build +` action): fields Name (Use Machine Name | custom), Company, Site, Department,
|
||||
Device Type, Tag(s), Type (platform); validates and calls the build endpoint.
|
||||
- **Extend `EmbeddedConfig`** (`downloads.rs` + `agent/src/config.rs`) and
|
||||
`AgentDownloadParams` with `department` and `device_type`; agent maps them through to
|
||||
`AgentStatus` (proto) so they persist on `connect_machines` (SPEC-003 columns) the same
|
||||
way `organization`/`site`/`tags` do today.
|
||||
- **Name strategy:** `Use Machine Name` (agent uses live hostname — current default) or a
|
||||
custom override embedded in the config (`hostname_override` already exists,
|
||||
`config.rs:63`).
|
||||
- **Share actions:** **Download** (exists), **Copy URL** (returns the parameterized
|
||||
public download URL for clipboard), **Send Link** (email the download URL — see deps).
|
||||
- **Type/platform selector:** Windows (.exe) in v1, with the dropdown structured to add
|
||||
macOS/Linux later (those agents are roadmap P3 — show only available types).
|
||||
- **Key selection:** embed the correct agent key for enrollment — default the
|
||||
shared/site key; ready to use a per-machine key when that lands (SPEC-004/roadmap).
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- Attended **support-code** session creation — that's a different flow (support codes
|
||||
already exist); this builder is for **managed/persistent** agents only.
|
||||
- macOS/Linux installer artifacts — UI is forward-compatible but only Windows .exe ships.
|
||||
- MSI/MSP packaging, GPO/Intune deployment bundles — `.exe` only in v1.
|
||||
- Code-signing changes — the base binary signing is SPEC-001/ADR-002; the builder just
|
||||
appends config to the already-signed base (note: appending after signing invalidates
|
||||
the Authenticode signature — see Open Questions).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Dashboard (`dashboard/src/features/machines/`):** new `BuildInstallerModal.tsx` +
|
||||
`dashboard/src/api/installer.ts`; opens from the Machines page. Builds the request,
|
||||
shows Download/Copy URL/Send Link. Admin-only.
|
||||
- **Relay-server (`server/src/api/downloads.rs`):** extend `EmbeddedConfig` +
|
||||
`AgentDownloadParams` with `department`, `device_type`, and a `name`/`hostname_override`
|
||||
field; the existing append-config build path is reused. Add a small endpoint to return
|
||||
the **built URL** (for Copy URL) and a **Send Link** action (POST that emails the URL).
|
||||
- **Agent (`agent/src/config.rs`):** add `department`, `device_type` to `EmbeddedConfig`
|
||||
and `Config`; thread into `send_status` (`agent/src/session/mod.rs:236`) on
|
||||
`AgentStatus`.
|
||||
- **Protobuf (`proto/guruconnect.proto`):** add `department` and `device_type` to
|
||||
`AgentStatus` (or to SPEC-003's `DeviceInventory`) so the relay can persist them. Pick
|
||||
one home and keep it consistent with SPEC-003.
|
||||
- **DB:** `connect_machines.department` / `device_type` columns come from SPEC-003; no new
|
||||
migration here if SPEC-003 lands first (else add them).
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `server/src/api/downloads.rs:21,40` (EmbeddedConfig +
|
||||
AgentDownloadParams: `department`, `device_type`, `name`/override); `server/src/main.rs`
|
||||
(Copy-URL / Send-Link routes if added; existing download routes reused);
|
||||
`agent/src/config.rs:20,59` (Config + EmbeddedConfig fields);
|
||||
`agent/src/session/mod.rs:236` (`AgentStatus` population);
|
||||
`proto/guruconnect.proto` (AgentStatus/DeviceInventory fields);
|
||||
`dashboard/src/features/machines/BuildInstallerModal.tsx` (new),
|
||||
`dashboard/src/api/installer.ts` (new).
|
||||
- The build URL is the existing public download endpoint with query params
|
||||
(`company`, `site`, `tags`, `department`, `device_type`, `name`, key) — Copy URL just
|
||||
returns that string; Download streams the appended binary; Send Link emails it.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Embedded key exposure.** The installer embeds an agent enrollment key in a public,
|
||||
user-shareable artifact/URL. Prefer a **per-machine or per-site key** over the shared
|
||||
`AGENT_API_KEY` so a leaked installer can be revoked without re-keying the fleet; at
|
||||
minimum make the embedded key revocable. This is the strongest reason to pair with
|
||||
per-machine agent keys (SPEC-004 Security / roadmap).
|
||||
- **Build endpoint auth.** Building/Copy-URL/Send-Link must be **admin-authenticated**
|
||||
(`AuthenticatedUser`) even though the resulting *download* link is public; do not let an
|
||||
unauthenticated caller mint installers with arbitrary embedded keys.
|
||||
- **Input validation.** Company/Site/Department/Device Type/Tag/Name are embedded as JSON
|
||||
and later shown in the console and reported on `AgentStatus` — length-cap and sanitize
|
||||
(no injection into the embedded JSON, no oversized config blob; the agent already
|
||||
length-checks the embedded blob, `config.rs:230`).
|
||||
- **Send Link.** Validate the recipient; rate-limit; the email contains a key-bearing URL,
|
||||
so treat it as a credential delivery (audit who built/sent what).
|
||||
- **Signature note.** Appending config after Authenticode signing breaks the signature
|
||||
(Open Questions) — a security/UX consideration for SmartScreen, not just cosmetic.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** `EmbeddedConfig` round-trips `department`/`device_type` (write → append →
|
||||
`read_embedded_config` → equal); URL builder encodes all fields + key correctly.
|
||||
- **Integration:** build an installer with all fields set → download → embedded config
|
||||
parses → (simulated) agent registers and `connect_machines` shows the embedded company/
|
||||
site/department/device_type/tags. Copy URL returns a URL that downloads the same. Build
|
||||
endpoints reject unauthenticated callers.
|
||||
- **Manual:** from the console, build a labeled installer, install on a test box, confirm
|
||||
it appears in Access pre-labeled; verify Copy URL and (if shipped) Send Link.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Medium.** The build/append path exists; bulk of the work is the dashboard wizard,
|
||||
the `department`/`device_type` plumbing (agent + proto + config), and Send-Link email.
|
||||
- **Depends on:** **SPEC-003** for the `department`/`device_type` persistence columns
|
||||
(and it feeds SPEC-003/005/006 by populating those fields at install time). **Pairs
|
||||
with** per-machine agent keys (SPEC-004/roadmap) for a revocable embedded key. **Send
|
||||
Link** needs an outbound email/SMTP capability the server may not have yet — gate that
|
||||
sub-feature on it (Copy URL + Download have no such dep).
|
||||
- **Unblocks:** self-service managed-agent onboarding from the console; pre-labeled fleet
|
||||
data feeding inventory, list view, and search.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Authenticode signature vs. appended config.** Appending the config blob after the
|
||||
base `.exe` is signed invalidates the signature (SmartScreen warnings). Options: sign
|
||||
*after* config injection per-build (needs signing in the build path — heavier, ties to
|
||||
ADR-002/SPEC-001), embed config in a signature-preserving way (e.g. a signed wrapper /
|
||||
resource section), or accept unsigned per-build installers. Decide in planning — this
|
||||
is the biggest design question.
|
||||
2. **Embedded key model** — shared `AGENT_API_KEY`, per-site key, or per-machine key
|
||||
minted at build time? Strongly prefer revocable per-site/per-machine.
|
||||
3. **Send Link transport** — does the server have/want SMTP? If not, ship Download + Copy
|
||||
URL in v1 and defer Send Link (or use a `mailto:` prefilled link as a stopgap).
|
||||
4. **Name strategy storage** — custom name via existing `hostname_override`
|
||||
(`config.rs:63`) vs. a separate display-name field that doesn't change the reported
|
||||
hostname. Which does the console treat as authoritative?
|
||||
5. **Platform dropdown** — hide unavailable platforms, or show disabled "coming soon"
|
||||
entries for macOS/Linux?
|
||||
152
docs/specs/SPEC-008-valuable-error-messages.md
Normal file
152
docs/specs/SPEC-008-valuable-error-messages.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# SPEC-008: Valuable Error Messages (Structured Errors + No Silent Swallows)
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Large (phaseable)
|
||||
|
||||
## Overview
|
||||
|
||||
Make every error in GuruConnect carry information a human can act on: a single structured
|
||||
API error envelope with a stable code and a **correlation id** that also appears in the
|
||||
server logs, consistent contextual logging on the server and agent, an end to silent
|
||||
error swallowing, and a dashboard that shows the *real* cause (with the id to quote to
|
||||
support) instead of a hardcoded generic line. Success = given any error a tech sees, they
|
||||
(or we) can determine what failed and why — and a user-reported "it broke" comes with a
|
||||
correlation id that pulls the exact server log line.
|
||||
|
||||
This is a cross-cutting quality initiative. **A parallel feature request governs GuruRMM**
|
||||
(both are Rust/Axum); keep the conventions consistent where practical, but this spec
|
||||
governs GuruConnect only.
|
||||
|
||||
## Current state (researched — this is the problem)
|
||||
|
||||
- **Inconsistent API error shapes.** Bare `(StatusCode, &str)` tuples
|
||||
(`server/src/api/mod.rs:101,106`), a per-file `ErrorResponse` struct
|
||||
(`server/src/api/auth.rs:36`), and **two different JSON envelopes** in the wild —
|
||||
`{error}` and `{detail, error_code, status_code}` — which the dashboard already has to
|
||||
union to cope (`dashboard/src/api/client.ts:48-52`). No single error type.
|
||||
- **No correlation/request id.** Only `TraceLayer::new_for_http()`
|
||||
(`server/src/main.rs:471`); nothing ties a response a user saw to a log line.
|
||||
- **37 `let _ =` swallows** in `server/src/` — many on fallible DB ops:
|
||||
`create_session` (`relay/mod.rs:594`), `end_session` (`:686`), `log_event`
|
||||
(`:132,191,303,604`), `mark_machine_offline` (`:687`),
|
||||
`link_session_to_code` (`:640`). This is the exact pattern that hid the **migration-005
|
||||
missing-columns bug** — `update_machine_metadata` failed silently for an unknown period.
|
||||
- **Generic dashboard message.** `MachinesPage.tsx:202` shows "The GuruConnect server did
|
||||
not respond. Check the relay status, then retry." regardless of cause (auth expired? 500?
|
||||
network? CORS?), even though `ApiError` (`client.ts:12`) already carries status + code.
|
||||
- **Thin agent errors.** The GuruConnect-client auth-failure-loop incident (2026-05-30)
|
||||
produced no useful local signal about *why* auth failed or *which* server it was hitting.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- **One structured API error type.** A server `AppError` enum (`thiserror`) with a single
|
||||
`IntoResponse` producing one envelope:
|
||||
`{ error_code: string, message: string, detail?: object, correlation_id: string }` +
|
||||
consistent HTTP status mapping per variant. Replace the ad-hoc tuples and per-file
|
||||
`ErrorResponse`; converge the two envelopes to one.
|
||||
- **Correlation-id middleware.** Generate (or accept inbound `x-request-id`) a request id,
|
||||
put it on the tracing span, echo it in the response header **and** in every `AppError`
|
||||
body, so a reported id greps directly to the log.
|
||||
- **Logging convention.** Errors logged via `tracing` include the operation + relevant
|
||||
identifiers (`agent_id`/`machine_uid`/`session_id`) + the underlying error chain
|
||||
(`anyhow` context / `thiserror` source) — never a bare "failed". Use `#[instrument]`
|
||||
span fields so context is attached automatically.
|
||||
- **Kill silent swallows.** Sweep all `let _ = <fallible>()`: convert to
|
||||
`if let Err(e) = … { warn!/error!(error = %e, op = "…", id = …) }`. Where fire-and-forget
|
||||
is genuinely correct (e.g. best-effort socket close on an already-dead connection),
|
||||
keep it but add a one-line comment stating why. A clippy lint / CI grep guards against
|
||||
new bare `let _ =` on `Result`.
|
||||
- **Dashboard surfaces the real error.** Render `ApiError.code` + `message` +
|
||||
`correlation_id` ("quote this to support: …"); distinguish network-down (fetch threw)
|
||||
vs auth-expired (401 → re-login) vs server-500. Remove the hardcoded generic at
|
||||
`MachinesPage.tsx:202`; reuse one error component across pages.
|
||||
- **Agent error detail.** Connection/auth/capture failures log *what* failed and *why* —
|
||||
the target server URL, the relay's stated reason, the auth mode attempted — so a
|
||||
misbehaving agent is self-diagnosing (directly addresses the auth-loop incident).
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- Centralized log aggregation / external error tracking (Sentry-style) — local structured
|
||||
logs + correlation id only in v1.
|
||||
- User-facing localized error catalog / i18n.
|
||||
- Changing protobuf-level agent↔relay error frames beyond adding context to existing
|
||||
disconnect reasons (a deeper protocol error model is a later item).
|
||||
- Rewriting business logic — this is error *plumbing/observability*, not behavior change.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Relay-server:** new `server/src/error.rs` (`AppError` + `IntoResponse` + status map);
|
||||
handlers return `Result<T, AppError>`; correlation-id middleware in
|
||||
`server/src/main.rs` (layer beside `TraceLayer`, :471). `relay/mod.rs` swallows fixed in
|
||||
place with context logging.
|
||||
- **Agent:** `anyhow` context added at error boundaries in `agent/src/transport/` and
|
||||
`agent/src/session/`; connection/auth failures `error!`-logged with server URL + reason.
|
||||
- **Dashboard:** `dashboard/src/api/client.ts` keeps/extends `ApiError` to carry
|
||||
`correlation_id`; a shared `ErrorBanner`/`ErrorState` component; pages stop hardcoding
|
||||
messages.
|
||||
- **Protobuf/DB:** none required (existing `Disconnect.reason` string can carry richer
|
||||
text; no schema change).
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `server/src/error.rs` (new); `server/src/api/*.rs` (adopt `AppError`,
|
||||
drop `ErrorResponse`/tuple returns — `mod.rs:101,106`, `auth.rs:36`); `server/src/main.rs`
|
||||
(correlation-id middleware, :471); `server/src/relay/mod.rs` (swallow sweep — :132,191,
|
||||
303,594,604,640,686,687,695, …); `agent/src/transport/*`, `agent/src/session/mod.rs`
|
||||
(error context); `dashboard/src/api/client.ts` (correlation id), shared error component,
|
||||
`dashboard/src/features/machines/MachinesPage.tsx:202` (use it).
|
||||
- Stable `error_code` values are an enum (e.g. `AUTH_EXPIRED`, `MACHINE_NOT_FOUND`,
|
||||
`DB_UNAVAILABLE`, `VALIDATION_FAILED`) — documented and reused, not free-text.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Don't leak internals to clients.** The structured body carries a safe `message` + code;
|
||||
the full error chain / SQL / stack stays in the **server log** keyed by correlation id.
|
||||
A client gets "DB_UNAVAILABLE (id abc123)", not the connection string.
|
||||
- Correlation id is a random opaque token (no embedded PII/sequence that leaks volume).
|
||||
- Auth errors stay deliberately coarse to the client (e.g. don't reveal whether a username
|
||||
exists), while the *log* records the specific reason — valuable internally, safe
|
||||
externally.
|
||||
- The swallow sweep must not start logging secrets (api keys, tokens) that were previously
|
||||
silently dropped — scrub identifiers, never log key material.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** each `AppError` variant maps to the right status + code + envelope;
|
||||
correlation id present on every error body and matches the response header.
|
||||
- **Integration:** a handler error returns the structured envelope with a correlation id
|
||||
that appears in the captured log; an inbound `x-request-id` is honored end-to-end. A
|
||||
forced DB failure on a former `let _ =` path now emits a `warn!/error!` (assert via log
|
||||
capture) instead of vanishing.
|
||||
- **Dashboard:** 401 renders "session expired / re-login"; 500 renders code + correlation
|
||||
id; fetch-throw renders "can't reach server" — three distinct states, not one generic.
|
||||
- **CI guard:** lint/grep fails the build on a new bare `let _ =` over a `Result` in
|
||||
`server/`.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Large but phaseable.** Phase 1 (highest value): `AppError` + correlation-id
|
||||
middleware + the `let _ =` swallow sweep. Phase 2: dashboard error rendering. Phase 3:
|
||||
agent error context. Each ships independently.
|
||||
- **Depends on:** nothing blocking. Synergizes with **SPEC-004** (the agent-auth-loop
|
||||
diagnosis would have been trivial with agent error detail) and improves every other spec's
|
||||
debuggability.
|
||||
- **Unblocks:** faster incident triage across the product; a real correlation-id workflow
|
||||
for support.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Envelope migration.** Converge to the single new envelope and update the dashboard
|
||||
union in lockstep, or keep accepting both for a deprecation window? (Dashboard already
|
||||
unions both — low risk to converge.)
|
||||
2. **`error_code` taxonomy.** Agree the initial enum of stable codes and their HTTP status
|
||||
mapping.
|
||||
3. **Correlation id source.** Generate server-side always, or trust an inbound
|
||||
`x-request-id` from the dashboard/NPM when present (and from where is it trusted)?
|
||||
4. **Swallow policy granularity.** Blanket "log all `let _ =` on Result", or an allowlist
|
||||
for the genuinely best-effort socket sends? Proposed: log all DB/state ops; comment +
|
||||
keep best-effort socket closes.
|
||||
136
docs/specs/SPEC-009-feature-rich-documented-api.md
Normal file
136
docs/specs/SPEC-009-feature-rich-documented-api.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# SPEC-009: Feature-Rich, Fully-Documented Management API
|
||||
|
||||
**Status:** Proposed
|
||||
**Priority:** P2
|
||||
**Requested By:** Mike (2026-05-30)
|
||||
**Estimated Effort:** Large
|
||||
|
||||
## Overview
|
||||
|
||||
Make GuruConnect fully operable by API: nearly anything a tech can do in the console
|
||||
should be callable programmatically, against a **documented, discoverable, versioned**
|
||||
REST API with first-class auth for automation. Today the surface is decent but
|
||||
**undocumented** (no OpenAPI/Swagger), authable only by a 24h dashboard JWT or agent
|
||||
keys (no automation tokens), and has UI-only gaps. Success = an OpenAPI spec + browsable
|
||||
docs cover every public endpoint, a scripted client can authenticate with a long-lived
|
||||
revocable token and perform every management action the dashboard exposes, and new
|
||||
endpoints are documented by construction.
|
||||
|
||||
This is the GuruConnect half of a cross-product request; the GuruRMM side is
|
||||
**guru-rmm SPEC-019**. Keep auth model, error envelope, pagination, and doc conventions
|
||||
aligned across both Rust/Axum products.
|
||||
|
||||
## Current state (researched)
|
||||
|
||||
- **No API documentation tooling** — no `utoipa`/`openapi`/Swagger/Redoc anywhere
|
||||
(`server/Cargo.toml`, `server/src/`). The contract lives only in `main.rs` route
|
||||
registrations and handler code.
|
||||
- **Reasonable but partial surface:** auth, users (CRUD + client access), support codes,
|
||||
sessions (list/get/disconnect/viewer-token), machines (list/get/delete/history/keys),
|
||||
releases (CRUD), downloads, version. Several actions are dashboard-only or being added
|
||||
piecemeal (session purge/bulk → SPEC-004, machine search → SPEC-006, installer build →
|
||||
SPEC-007).
|
||||
- **Auth is dashboard/agent-shaped, not automation-shaped:** JWT bearer (24h,
|
||||
`server/src/auth/mod.rs:101`) for humans + agent/site keys for agents. **No long-lived,
|
||||
revocable, scoped API token** for external scripts/integrations.
|
||||
- **No stable error contract** until SPEC-008 lands (two envelopes today).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included in v1
|
||||
|
||||
- **API completeness audit + gap-fill.** Enumerate every console action; ensure each has a
|
||||
documented public endpoint. Fill gaps (e.g. anything currently done only via in-memory
|
||||
state or dashboard-special handlers). The in-flight specs (SPEC-004 purge/bulk, SPEC-006
|
||||
search, SPEC-007 installer build) register under this umbrella.
|
||||
- **OpenAPI 3.x, generated from code.** Adopt `utoipa` (Axum-native: derive schemas from
|
||||
handler/DTO annotations) → serve `GET /api/openapi.json` + interactive docs
|
||||
(Swagger UI or Redoc) at `/api/docs`. Document auth, every endpoint, request/response
|
||||
schemas, the SPEC-008 error envelope, and examples. Docs generated by construction so
|
||||
they can't drift.
|
||||
- **Programmatic API tokens.** Long-lived, **revocable, scoped** user/integration tokens
|
||||
(PAT-style) distinct from the 24h dashboard JWT and from agent keys: create/list/revoke
|
||||
in the dashboard, hashed at rest, last-used tracking, optional expiry, scopes
|
||||
(read-only vs. read-write, resource-scoped). Accepted as `Authorization: Bearer` on all
|
||||
`/api/*` like the JWT.
|
||||
- **Consistency conventions:** uniform pagination (`limit`/`offset` or cursor) + filtering
|
||||
+ sorting on list endpoints; consistent resource naming; documented HTTP status usage
|
||||
(pairs with SPEC-008's structured errors).
|
||||
- **Versioning policy** for the public API (header or path), and a documented relationship
|
||||
to the ADR-001 `/api/integration/v1/` GuruRMM contract (that narrow, semver'd contract
|
||||
stays authoritative for its surface; this is the broader operator API).
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- GraphQL or gRPC public API — REST + OpenAPI only.
|
||||
- SDK/client-library generation — OpenAPI enables it later; not built here.
|
||||
- Webhooks/event subscriptions — separate feature.
|
||||
- Re-speccing the integration contract — owned by `specs/native-remote-control/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Relay-server:** add `utoipa` derives to handler DTOs (`server/src/api/*`), an
|
||||
`ApiDoc` aggregator, and routes for `openapi.json` + docs UI (`server/src/main.rs`). New
|
||||
`api_tokens` module + middleware that accepts either a JWT or a valid API token
|
||||
(`server/src/auth/`). Gap endpoints added to the relevant `api/*` modules.
|
||||
- **DB:** `connect_api_tokens` table (id, user_id, name, token_hash, scopes, created_at,
|
||||
last_used_at, expires_at, revoked_at) + migration. Mirrors the existing
|
||||
`connect_agent_keys`/`machine_keys` hashing pattern.
|
||||
- **Dashboard:** a "API Tokens" settings page (create → show-once → list/revoke) and a
|
||||
link to `/api/docs`.
|
||||
- **Protobuf:** none.
|
||||
|
||||
## Implementation details
|
||||
|
||||
- Files to touch: `server/Cargo.toml` (`utoipa`, `utoipa-swagger-ui`/`utoipa-redoc`);
|
||||
`server/src/api/*.rs` (DTO `#[derive(ToSchema)]` + `#[utoipa::path]` annotations);
|
||||
`server/src/main.rs` (docs routes; token-aware auth layer);
|
||||
`server/src/auth/` (API-token verification beside JWT, `mod.rs:101`);
|
||||
`server/src/db/api_tokens.rs` (new) + migration `connect_api_tokens`;
|
||||
dashboard API-tokens settings page.
|
||||
- Reuse the agent-key hashing/verification approach for token storage; never store plaintext.
|
||||
|
||||
## Security considerations
|
||||
|
||||
- **Tokens are credentials:** hash at rest, show plaintext once, support revoke + expiry +
|
||||
scopes; log creation/revocation to `events`; rate-limit token auth like login.
|
||||
- **Scope enforcement:** read-only tokens cannot mutate; resource-scoped tokens limited to
|
||||
their resources; admin-only actions require an admin-scoped token.
|
||||
- **Docs exposure:** `/api/docs` describes the API but must not leak secrets or be a
|
||||
CSRF/SSRF vector; gate write-trying "try it out" behind auth; consider auth-gating the
|
||||
docs UI in production.
|
||||
- **No internal leakage** in documented error examples (pairs with SPEC-008).
|
||||
- **Auth uniformity:** the token path goes through the same guards as JWT so no endpoint is
|
||||
accidentally left token-bypassable.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit:** OpenAPI spec builds and validates; every registered route appears in it (a test
|
||||
that diffs the router against the spec catches undocumented endpoints). Token verify
|
||||
(valid/expired/revoked/wrong-scope).
|
||||
- **Integration:** a scripted client authenticates with an API token and performs a full
|
||||
CRUD lifecycle on machines/sessions/users/codes; read-only token is denied writes; the
|
||||
route↔spec parity test fails if a new endpoint lacks annotation.
|
||||
- **Manual:** open `/api/docs`, exercise representative calls with a created token; confirm
|
||||
examples match real responses.
|
||||
|
||||
## Effort estimate & dependencies
|
||||
|
||||
- **Size: Large.** Annotating the full surface for OpenAPI + the API-token subsystem +
|
||||
the gap audit is broad, though each handler annotation is mechanical.
|
||||
- **Depends on:** **SPEC-008** for the documented error envelope (document it once, right).
|
||||
Composes with SPEC-004/006/007 (their new endpoints land documented). Independent of the
|
||||
ADR-001 integration contract but should cross-reference it.
|
||||
- **Unblocks:** automation/scripting against GuruConnect, future SDKs, and third-party
|
||||
integrations; a self-documenting API that stays current.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Docs UI in production** — public, or auth-gated? Proposed: spec JSON public, "try it
|
||||
out" + UI auth-gated.
|
||||
2. **Token scopes granularity** — coarse (read/write/admin) for v1 vs. per-resource scopes?
|
||||
Proposed: coarse first, structure for finer later.
|
||||
3. **Versioning mechanism** — path (`/api/v1`) vs. header; and do we retrofit existing
|
||||
`/api/*` as v1 or leave unversioned with v1 as an alias?
|
||||
4. **utoipa vs. hand-maintained spec** — confirm utoipa (codegen, drift-proof) over a
|
||||
hand-written YAML.
|
||||
Reference in New Issue
Block a user