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