Skip to content

Architecture

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

Architecture

How the package is put together and what happens during a collection run.

Targets

Package.swift declares four executables and two libraries.

Target Product Role
ReportMate managedreportsrunner CLI collector. Owns configuration, all module processors, osquery integration and the API client
Watcher reportmate-appusage Persistent daemon recording application launches, focus and active time to SQLite
App ReportMateApp SwiftUI settings and run GUI
Helper ReportMateHelper Privileged XPC helper the GUI talks to
ReportMateXPC library Shared XPC protocol, constants and HelperPolicy (who may connect, which arguments and keys the helper accepts)
ReportMateLogging library Rolling file log handler and the launchd-capture trimmer

Dependencies: swift-argument-parser, swift-log, async-http-client, SQLite.swift and Yams. Three test targets cover them: ReportMateTests, ReportMateXPCTests and ReportMateLoggingTests (see Development).

Source layout

Sources/
  ReportMateClient.swift          CLI entry point, run orchestration, event detection
  Core/
    ReportMateCore.swift          Coordinator
    AppVersion.swift              Generated at build time
    Configuration/                ConfigurationManager, ReportMateConfiguration
    Services/                     APIClient, CacheService, BashService, ProcessRunner
    Utils/                        SystemUtils, ConsoleFormatter, GzipEncoder, BinaryTrust
  DataCollection/
    DataCollectionService.swift   Module coordination
    OSQueryService.swift          osquery + macadmins extension
    NetworkAddressInventory.swift Local and DNS-resolved addresses
    Modules/                      One processor per module, plus EDIDDisplay
    Services/                     ApplicationUsageService, ManagedLogSurvey, MunkiSessionReportReader
  Models/                         DeviceModels and per-module models
  App/  Helper/  Watcher/         GUI, XPC helper, usage watcher
  ReportMateLogging/              FileLogHandler, RollingLogFile, LaunchdLogTrimmer
  Resources/                      macadmins extension, PPPC profile
  Generated/                      ModuleVersions.swift, written by build.sh

A collection run

  1. Refuse to start unless getuid() == 0.
  2. Trim oversized launchd-*.log capture files, open the rolling log, and bootstrap swift-log with a file handler plus a console handler whose level comes from -v.
  3. Build the configuration by merging defaults, the user plist, environment, the system plist, profile-forced keys and CLI overrides (see Configuration).
  4. Decide the module list: none for --hello, else --run-module, else --run-modules, else EnabledModules.
  5. For each module in order, construct its processor and call collectData(), collecting the result under the module's name. A module that throws is logged and skipped; the run continues.
  6. Read serial, OS version, model and architecture for the payload metadata. The device id is DeviceId when set, otherwise a stable UUID derived by SHA-256 from the serial.
  7. Derive events (below) and assemble a UnifiedDevicePayload.
  8. Write the payload to a new timestamped cache directory. A --hello check-in is not cached.
  9. Unless --collect-only, POST it, retrying transient failures (below). On success, confirm the app-usage sessions so the watcher can retire them.

Payload

The body sent to POST {ApiUrl}/api/v1/events is a UnifiedDevicePayload: metadata, events, and one optional key per module (inventory, system, hardware, management, installs, profiles, security, network, displays, printers, applications, peripherals, identity).

{
  "metadata": {
    "deviceId": "00000000-0000-0000-0000-000000000001",
    "serialNumber": "SERIAL123",
    "collectedAt": "2026-01-15T09:31:10Z",
    "clientVersion": "2026.10.07.2024",
    "platform": "macOS",
    "collectionType": "Full",
    "enabledModules": ["inventory", "system"],
    "additional": {
      "deviceName": "mac-0001",
      "manufacturer": "Apple",
      "model": "Mac16,10",
      "osVersion": "26.0",
      "architecture": "arm64"
    }
  },
  "events": [
    {
      "moduleId": "collection",
      "eventType": "info",
      "message": "Inventory, System data reported",
      "timestamp": "2026-01-15T09:31:10Z",
      "details": { "collectionType": "Full", "moduleCount": "2", "modules": "inventory, system" }
    }
  ],
  "inventory": { "…": "…" },
  "system": { "…": "…" }
}

A --hello check-in has the same envelope with no module keys, an empty enabledModules, and one event with the message Device checked in and collectionType: Hello. The API reads metadata.additional to name a device that has no module data yet.

APIClient.testConnectivity() targets GET {ApiUrl}/api/v1/health. It is not wired to any CLI flag.

Request headers

Header Value
Content-Type application/json
Content-Encoding gzip, when compression is on and succeeded
X-API-Key ApiKey, when configured
X-Client-Passphrase Passphrase, when configured
X-Device-Serial, X-Device-Uuid, X-Device-Name, X-Platform, X-Client-Version Copied from the payload
Idempotency-Key SHA-256 of serial|collectedAt

The identity headers duplicate what is already in the body because the server can only read the body when it arrives intact and parses. A truncated or aborted upload is otherwise rejected with no idea which machine sent it. Header values are stripped of non-printable characters and capped at 255 bytes, and an unusable value is skipped rather than allowed to cost the device its check-in.

The idempotency key is derived from what the payload is, not when it was sent, so every send of one collection carries the same key. The server accumulates usage deltas on conflict, so a payload that reaches it twice — an ordinary retry after a timeout, or a --transmit-only re-send of a payload that already landed — would otherwise book its usage twice.

Retries

A check-in is attempted up to MaxRetryAttempts times (default 3). A dropped connection, timeout, unreachable or unresolvable host, or an HTTP 408, 429, 500, 502, 503 or 504 is resent after 1 s, then 2 s, doubling, which is the same schedule as the Windows client. Failures the server would repeat, such as a rejected credential or an unparseable body, are returned on the first attempt. The idempotency key makes a resend safe even when the first attempt landed and only its response was lost.

Events

Three kinds of event are generated alongside the module data.

  • OS update. The system module's operatingSystem.version is compared with /Library/Managed Reports/cache/previous_os_version.json, which is rewritten every run. A change emits an os_update event.
  • Reboot. The kernel's own boot time, published at systemDetails.bootTime from sysctl kern.boottime, is compared with previous_boot_time.json with a 300-second tolerance. The tolerance exists because the kernel recalculates boot time whenever the clock is corrected, so an NTP sync shifts it by seconds; a real reboot moves it by at least the previous uptime. Deriving boot time as now - uptime instead is what made whole labs appear to reboot together — uptime is sampled early in the run and now at the end, so the derived instant slid forward by the length of each run.
  • Installs. Munki errors and warnings become per-item events, and a summary event names the non-installs modules that reported. When Munki writes structured session reports, events take the Windows shape: a success event per action with its items, an error event with failed_items, and a warning event with warning_items and operational_warnings. A removal is sent with action: remove and the full list as removed_items.

Caching

CacheService writes to /Library/Managed Reports/cache. Each run creates a YYYY-MM-DD-HHmmss directory holding event.json plus a <module>.json copy for each of hardware, system, security, network, applications, management, inventory, installs and identity (and displays or printers when requested by alias). peripherals data is only in event.json. The 24 most recent directories are kept — without the prune the cache grows without bound. --transmit-only re-sends the newest event.json.

last_collection.timestamp, previous_os_version.json, previous_boot_time.json and the hardware module's storage_analysis.json live in the base directory, outside the timestamped folders, so they survive pruning.

osquery

OSQueryService runs osquery as a child process. It knows which tables belong to the macadmins extension — network_quality, wifi_network, mdm, macos_profiles, filevault_users, pending_apple_updates, munki_info, munki_installs, sofa_security_release_info, sofa_unpatched_cves, authdb, alt_system_info, macadmins_unified_log, macos_rsr, crowdstrike_falcon, puppet_info, puppet_logs, puppet_state, puppet_facts, google_chrome_profiles, file_lines — and only pays the extension's startup cost for a query that references one.

Finding a trusted osquery

The runner works as root, so anything it launches runs as root too. Before any query it resolves an osquery binary, trying in order:

  1. OsqueryPath (default /usr/local/bin/osqueryi).
  2. /usr/local/bin/osqueryi.
  3. /opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd, where the osquery pkg intends to put the real binary.
  4. Any osquery.app/Contents/MacOS/osqueryd up to seven levels under /opt, skipping /opt/homebrew, /opt/local and /opt/MacPorts. This is where a copy ends up when Installer relocated osquery.app onto another tool's bundle, leaving the standard link dangling.

A candidate counts only when its symlinks resolve to a regular executable whose whole directory chain is root-owned and not group- or world-writable, and when it carries osquery's own Developer ID signature (io.osquery.agent, Team ID 3522FA9PXF). The resolved real path is what runs, never the link. The app bundle's osqueryd is started with -S so it acts as the shell. A one-off availability probe is bounded at 10 seconds and cached for the run. With no trusted binary, osquery is never launched and every module uses its shell fallback.

The extension binary is resolved in this order, and the first candidate that passes the same path checks wins:

  1. OsqueryExtensionPath, if the file exists.
  2. /usr/local/reportmate/macadmins_extension.ext, where the pkg installs it with the root ownership osquery requires.
  3. extensions/macadmins_extension.ext inside the SPM resource bundle, for development builds.
  4. ReportMate_ReportMate.bundle/Resources/extensions/macadmins_extension.ext beside the executable, also for development builds.

When the runner itself is signed, the extension must also be signed with the runner's Team ID, which is what build.sh --sign does to the bundled copy. There is no working-directory lookup. Extension queries launch osquery directly with an argument array (--json --extension <path> --extensions_timeout 15) and pass the SQL on stdin, so no shell parses paths or query text.

Every query races against a timer. On expiry the child gets SIGTERM, then SIGKILL after two seconds, and OSQueryError.timeout is thrown so one misbehaving table cannot block the rest of a module.

BaseModuleProcessor.executeWithFallback(osquery:bash:) tries osquery first and falls back to the shell command if osquery is unavailable, throws, or returns no rows. A single osquery row is returned as a dictionary; multiple rows are wrapped as {"items": [...]}. Shell output is parsed as JSON where possible and otherwise returned as {"output": "…"}.

GUI and privileged helper

ReportMateApp is a SwiftUI app with Prefs, Run and Logs tabs. It reads the system plist directly (rather than through cfprefsd, so it sees the same values the daemons do) and sends every privileged action to ReportMateHelper over XPC on the Mach service com.github.reportmate.helper. The helper runs as root under its own LaunchDaemon.

The helper only serves the Managed Reports Runner app signed by its own Team ID: each connection gets the code requirement anchor apple generic and identifier "com.github.reportmate" and certificate leaf[subject.OU] = "<helper Team ID>", checked by the system on every message. An unsigned or ad-hoc helper has no Team ID and rejects every connection. It runs the CLI only with -v, -vv, -vvv and --run-modules <list>, and only when the CLI and every folder above it are real, root-owned and not writable by anyone but root (group write is tolerated for wheel and admin). It writes only the preference keys the Prefs tab edits, and never one a configuration profile manages. Details are on Managed Reports Runner App.

Application usage watcher

reportmate-appusage runs continuously under launchd with KeepAlive, subscribing to NSWorkspace launch and termination notifications and ticking periodically to attribute foreground and active time. It writes to /Library/Managed Reports/appusage.sqlite (WAL mode), one row per session with duration, foreground seconds, active seconds, and a reporting watermark of what the server has already accepted. The applications module reads it; sessions are retired only after the API confirms delivery.

"Active" means the app held OS focus and there was system input within the preceding 300 seconds. "Foreground" is focus regardless of input.

Clone this wiki locally