Repository navigation
Architecture
How the package is put together and what happens during a collection run.
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).
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
- Refuse to start unless
getuid() == 0. - Trim oversized
launchd-*.logcapture files, open the rolling log, and bootstrap swift-log with a file handler plus a console handler whose level comes from-v. - Build the configuration by merging defaults, the user plist, environment, the system plist, profile-forced keys and CLI overrides (see Configuration).
- Decide the module list: none for
--hello, else--run-module, else--run-modules, elseEnabledModules. - 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. - Read serial, OS version, model and architecture for the payload metadata. The
device id is
DeviceIdwhen set, otherwise a stable UUID derived by SHA-256 from the serial. - Derive events (below) and assemble a
UnifiedDevicePayload. - Write the payload to a new timestamped cache directory. A
--hellocheck-in is not cached. - Unless
--collect-only, POST it, retrying transient failures (below). On success, confirm the app-usage sessions so the watcher can retire them.
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.
| 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.
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.
Three kinds of event are generated alongside the module data.
-
OS update. The system module's
operatingSystem.versionis compared with/Library/Managed Reports/cache/previous_os_version.json, which is rewritten every run. A change emits anos_updateevent. -
Reboot. The kernel's own boot time, published at
systemDetails.bootTimefromsysctl kern.boottime, is compared withprevious_boot_time.jsonwith 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 asnow - uptimeinstead is what made whole labs appear to reboot together — uptime is sampled early in the run andnowat 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 withwarning_itemsandoperational_warnings. A removal is sent withaction: removeand the full list asremoved_items.
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.
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.
The runner works as root, so anything it launches runs as root too. Before any query it resolves an osquery binary, trying in order:
-
OsqueryPath(default/usr/local/bin/osqueryi). -
/usr/local/bin/osqueryi. -
/opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd, where the osquery pkg intends to put the real binary. - Any
osquery.app/Contents/MacOS/osquerydup to seven levels under/opt, skipping/opt/homebrew,/opt/localand/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:
-
OsqueryExtensionPath, if the file exists. -
/usr/local/reportmate/macadmins_extension.ext, where the pkg installs it with the root ownership osquery requires. -
extensions/macadmins_extension.extinside the SPM resource bundle, for development builds. -
ReportMate_ReportMate.bundle/Resources/extensions/macadmins_extension.extbeside 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": "…"}.
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.
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.
MIT licensed. — reportmate.app