diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md index 19cfbc8..47137fa 100644 --- a/docs/FEATURE_ROADMAP.md +++ b/docs/FEATURE_ROADMAP.md @@ -47,6 +47,7 @@ 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)) - [ ] Programmatic session pre-create + viewer-token (integration contract) — P2 ## Security & Infrastructure diff --git a/docs/specs/SPEC-003-machine-inventory.md b/docs/specs/SPEC-003-machine-inventory.md new file mode 100644 index 0000000..88cbf92 --- /dev/null +++ b/docs/specs/SPEC-003-machine-inventory.md @@ -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` 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` (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.