Files
guru-connect/docs/specs/SPEC-003-machine-inventory.md
Mike Swanson abf499cb23 spec: add SPEC-003 full machine inventory in connection DB
Persist a complete per-machine device inventory on connect_machines
(OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured
server-side via trusted-proxy client_ip + private LAN IP + MAC, logged-on
user, idle, time zone, uptime, local-admin-present), refreshed each
AgentStatus and surfaced in the dashboard machine detail — ScreenConnect
"Guest Info" parity. Data layer for SPEC-002 Phase 2; closes the GC side
of the agent-IP gap (coord todo 7459428e). Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:48:09 -07:00

242 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 111 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:30107) 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:779835), 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:385386) 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.