Skip to content

Architecture

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

Architecture

How a run is put together, what it produces, and how it reaches the API.

Shape of the program

managedreportsrunner.exe is a .NET 10 console application. There is no service and no background loop — Windows Task Scheduler starts the process, it collects, transmits and exits.

Layer Where Responsibility
Entry point and CLI src/Program.cs Argument parsing, configuration layering, dependency injection, command handlers
Configuration src/Configuration/, src/Shared/ Settings layering, trusted-file check, credential store, typed settings. src/Shared/ is compiled into the app as well
Collection orchestration src/Services/ModularDataCollectionService.cs Runs modules, caches results, builds the unified payload
osquery src/Services/OsQueryService.cs, ModularOsQueryService.cs Loads module query packs, shells out to osqueryi, parses JSON
Module processors src/Services/Modules/ One class per module; turns raw results into typed models
Models src/Models/Modules/ The payload shape
Transport src/Services/ApiService.cs Gzip, identity headers, idempotency, retries
Support src/Services/ WMI helper, PowerShell runner, bounded child processes, EDID reader, hardware serial, management log survey, usage service, logging

A second project, usagetracker/, builds usagetracker.exe. A third, src/App/, builds the Managed Reports Runner App, the WinUI 3 front end that ships in every package for editing settings, starting a run and reading logs. It is not part of the collection path and is not in the solution file.

Every PowerShell, osquery and command-line child the runner starts goes through BoundedProcess, which reads both pipes from the start and kills the whole process tree when a time limit passes (two minutes unless the caller sets its own), so one stalled child cannot hold a run open past the scheduled task's 30-minute limit.

A run, step by step

  1. Parse -v shorthand into --verbose=N, and attach to the parent console if any verbose flag is present.
  2. Move any readable credential into the protected store, then build configuration from the command-line overrides, the registry keys, the environment and, when trusted, the YAML file. See Configuration.
  3. Configure Serilog and the DI container.
  4. Dispatch the command. For run with no module filter:
    • IDataCollectionService.CollectAndSendDataAsync checks the recorded LastRunTime. If the last run was inside MaxDataAgeMinutes and --force was not given, the run stops here.
    • ModularOsQueryService reads enabled-modules.json and each named modules\<id>.json, merging their queries into one pack. If a module file is missing from ProgramData it is restored from the copy in Program Files.
    • osqueryi.exe --json is invoked with the combined pack.
    • Each module processor receives the raw result rows plus the device id, and enriches them from WMI, the registry, PowerShell and event logs.
    • Each module's typed output is written to cache\<timestamp>\<module>.json.
    • The results are merged into one UnifiedDevicePayload and written to cache\<timestamp>\event.json.
    • The payload is POSTed to /api/v1/events.
    • On success, LastRunTime is updated in the registry.

--hello takes the shortest path: one osquery system_info query for the serial, UUID and computer name, and a POST of the envelope alone. Nothing is cached.

--run-module and --run-modules take a shorter path: only the named modules are processed, but they still produce one event.json and one HTTP request for the whole run. That is deliberate: a request per module would send a five-module sweep as five requests with five identical metadata envelopes.

The payload

event.json holds the payload that is POSTed, indented for reading. The exact request body of each attempt, before compression, is written to logs\<timestamp>\unified_payload_<serial>_attempt<n>.json. Property names are camelCase.

{
  "metadata": {
    "deviceId": "11111111-2222-4333-8444-555555555555",
    "serialNumber": "SERIAL123",
    "collectedAt": "2026-03-04T09:14:22Z",
    "clientVersion": "2026.03.04.0912",
    "platform": "Windows",
    "collectionType": "Full",
    "enabledModules": ["inventory", "system", "hardware"],
    "additional": {}
  },
  "events": [
    {
      "moduleId": "system",
      "eventType": "info",
      "message": "System module collected successfully",
      "timestamp": "2026-03-04T09:14:22Z",
      "details": {}
    }
  ],
  "inventory": {},
  "system": {},
  "hardware": {},
  "management": {},
  "installs": {},
  "security": {},
  "identity": {},
  "network": {},
  "peripherals": {},
  "applications": {}
}

In the request body, modules that did not run are omitted rather than sent empty. Every module object carries four fields from the common base: moduleId, moduleVersion, collectedAt and deviceId.

Each entry in events carries the moduleId it came from. That is what lets the API replace one run's events for a module with the next run's, so an error that a later run cleared does not stay on the dashboard.

moduleVersion is not a hand-maintained number. build.ps1 generates ModuleVersions.g.cs at build time by asking git for the commit date of that module's processor, models and query pack, formatted yyyy.MM.dd.HHmm. A module's version therefore changes exactly when its code changes.

Device identity

The serial number is the routing key, and the agent refuses to invent one. HardwareSerial takes the first usable value from, in order: osquery system_info.hardware_serial, osquery chassis_info.serial when the results include it, WMI Win32_BIOS.SerialNumber, then WMI Win32_SystemEnclosure.SerialNumber. The WMI step means a device still reports when osquery is missing or returns nothing. Placeholders are rejected: 0, System Serial Number, To be filled by O.E.M., Default string, None, Not Specified, and anything starting 00000000. If no usable serial is found, collection throws rather than falling back to the hostname. The inventory module also strips a few default Windows hostname prefixes, such as WIN- and DESKTOP-, when firmware puts one in front of the serial.

The deviceId is a hardware UUID, resolved in order: osquery system_info.uuid (rejecting the all-zero GUID), then HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, then a SHA-256 of the CPU id joined with the baseboard serial.

Transport

POST /api/v1/events, with:

Header Value
Content-Type application/json
Content-Encoding gzip, when compression is on
User-Agent ReportMate/<version>
X-API-Key ApiKey, if configured
X-Client-Passphrase Passphrase, if configured
X-Device-Serial The device serial
X-Device-Uuid The hardware UUID
X-Device-Name The device name, when known
X-Platform Windows
X-Client-Version Agent version
Idempotency-Key A UUIDv5 of the collection

The identity headers are mirrored deliberately: a body the server cannot parse still arrives with enough context to attribute the failure to a device.

The idempotency key is a UUIDv5 in the RFC 4122 URL namespace over the name reportmate:{serial}:{module}:{collectedAt}, with collectedAt formatted yyyy-MM-ddTHH:mm:ssZ and {module} set to unified for the unified payload. This is the same construction the macOS client uses. It is identical across the retries of one collection and different across collections, so a retry after a timeout the server actually processed cannot double-count usage rows.

Retries are governed by MaxRetryAttempts (default 3). Two response codes get special handling:

  • 400 — treated as "this server cannot read a compressed body". The client drops to uncompressed for the rest of the run and retries. This is what lets the client roll out ahead of the API rather than behind it.
  • 404 — treated as "the endpoint is not deployed yet". The run is reported as success and the payload stays cached.

Other endpoints the client uses: GET /api/v1/health for the connectivity check performed before a transmit, and POST /api/v1/device plus GET /api/v1/device/{deviceId} on the older device-registration path.

Caching and replay

Every collection writes a complete event.json before any HTTP request is made, so a failed transmission never loses the data. managedreportsrunner.exe transmit finds the newest timestamped cache directory, deserializes its event.json, tests /api/v1/health, and re-POSTs the payload whole. Because the idempotency key is derived from the payload's own collectedAt, a replay of an already-accepted payload is deduplicated server side.

Scheduling

There is no internal timer. Four scheduled tasks run as SYSTEM, plus one per-user task for the usage tracker. Their definitions come from module-schedules.json; see Installation for the table.

Clone this wiki locally