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>
This commit is contained in:
2026-05-30 15:48:09 -07:00
parent abc55abb0b
commit abf499cb23
2 changed files with 242 additions and 0 deletions

View File

@@ -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

View 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 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.