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