- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
LogLift Event-Log Pipeline (Phase 4.3)
End-to-end spec for the LogLift evidence path: a Datto RMM-deployed
PowerShell collector captures a Windows endpoint's event logs + system
context, gzips the JSON, uploads it to Backblaze B2, then POSTs metadata
to Pulse. Pulse downloads the gzip, slims it, persists it as
rmm_executions evidence, and (when the hostname uniquely matches an IT
Glue Configuration) auto-triggers an asset-first audit.
Why this exists
Phase 4.2 wired Datto RMM Overshell PowerShell evidence into the audit pipeline, but Overshell stdout is capped at ~50KB practical — too small for full event logs. Wulf already runs a richer evidence path through n8n: collector → B2 → n8n decompress + LLM → Telegram. Phase 4.3 makes Pulse the receiver instead of n8n so:
- LogLift evidence lands in the same
rmm_executionstable. - The audit pipeline's
rmm_evidencearm picks it up automatically. - Admins can dispatch a LogLift run from the Configuration page (Datto Quick Job into the registered LogLift component).
- Successful uploads matched to a unique IT Glue Configuration auto-fire an asset-first audit so documentation suggestions surface immediately.
Components
┌──────────────────────────┐ ┌────────────────────────┐
│ Windows endpoint │ │ Datto RMM │
│ ─ collector PowerShell │ ◀───── │ ─ LogLift component │
│ ─ gzip event logs │ │ (job dispatched │
│ ─ upload to B2 │ ────▶ │ by Pulse) │
│ ─ POST webhook to Pulse │ │ │
└──────────────────────────┘ └────────────────────────┘
│ ▲
│ B2 PUT │ runQuickJob
▼ │
┌──────────────────────────┐ ┌────────────────────────┐
│ Backblaze B2 │ ◀─SigV4─│ Pulse │
│ bucket: wulf-audits │ │ /api/rmm/loglift/ │
│ region: us-west-002 │ GET │ upload │
│ │ ────▶ │ ─ download + slim │
│ │ │ ─ persist evidence │
└──────────────────────────┘ │ ─ auto-audit (single │
│ match only) │
└────────────────────────┘
Object-key convention
{datto_site_uid}/{computer_name}/eventlogs_{YYYYMMDD_HHMMSS}.json.gz
Pulse rejects anything not matching:
^[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+/eventlogs_[0-9_]+\.json\.gz$
Webhook contract
POST /api/rmm/loglift/upload
Auth header: x-openclaw-key: <OPENCLAW_API_KEY> (constant-time match).
Request body:
{
"runId": "pulse_a1b2c3_1714672800000",
"clientId": "f7a8b9c0-…",
"computerName": "YNGHYNWNP01",
"deviceUid": "f7a8b9c0-… (optional)",
"summary": {
"totalEvents": 4012,
"criticalEvents": 0,
"errorCount": 23,
"warningCount": 187,
"timeRange": "Last 24 hours"
},
"objectKey": "f7a8b9c0/YNGHYNWNP01/eventlogs_20260502_120000.json.gz",
"collectedAt": "2026-05-02T12:00:03.124Z",
"rmmContext": {
"siteName": "Hynes — Youngstown",
"siteUid": "f7a8b9c0-…",
"accountUid": "…"
},
"issueDescription": "(optional, free-text)",
"ticketNumber": "T20260502.0019"
}
Response (200):
{
"executionId": "0193…",
"matched": {
"datto_site_id": 42,
"datto_device_uid": "f7a8b9c0-…",
"autotask_company_id": "29861375",
"configuration_id": "12345",
"configuration_single_match": true
},
"parsed": {
"total_events": 4012,
"critical_events": 0,
"error_count": 23,
"warning_count": 187,
"time_range": "Last 24 hours"
},
"auditId": "0193…"
}
Pulse-driven dispatch
Configuration page → "Run discovery" picker → LogLift entry. The picker
filters by target_type='asset_self'. Dispatch path:
- Resolve the device's Datto site uid (
datto_rmm_sites.uid) → use asClientId. - Resolve the LogLift component uid from
rmm_settings. If not cached, the executor callsdiscoverLogliftComponent()which scans the Datto API for components matching/loglift|eventlog/i. - Generate
runId = pulse_<hex8>_<ms>. - Insert
rmm_executionsrow withtransport='b2_upload',run_id,status='queued'. The row'svariablescolumn stores the non-secret variables (RunId, ClientId, WebhookUrl) — the secret is stripped before persistence. runQuickJobwith the LogLift component_uid + variables:RunId— webhook correlationClientId— Datto site uidWebhookUrl—${BETTER_AUTH_URL}/api/rmm/loglift/uploadWebhookSecret—OPENCLAW_API_KEY
- On Quick Job ack: flip to
running+ persistjob_uid. - Worker skips
b2_uploadrows during stdout-poll. Webhook is the completion event. The 5-minute timeout sweep still applies — stuck rows get markedtimeout.
Out-of-band (collector-driven) ingest
If the LogLift collector fires from its own schedule (e.g. n8n still
runs in parallel during cutover), the webhook receiver inserts a fresh
rmm_executions row with triggered_by_user_id=NULL,
status='running', then completes it in the same handler.
run_id has a unique index; replays of the same upload no-op cleanly.
Slim-evidence shape
Stored in rmm_executions.parsed_evidence (JSONB). The full gzip stays
in B2 forever (forensic replay via the presigned-GET helper):
{
"schema_version": 1,
"transport": "b2_upload",
"object_key": "…",
"collected_at": "…",
"rmm_context": {…},
"issue_description": "…",
"ticket_number": "…",
"webhook_summary": {…}, // raw counts from the webhook
"metadata": {…}, // from inside the gzip
"system_context": { // OS, hardware, disks, updates …
"OS": {…},
"Hardware": {…},
"Memory": {…},
"Disks": [{…}],
"RecentUpdates": [{…}],
"Uptime": "…",
"LastBoot": "…",
"PendingReboot": false,
"RebootReasons": []
},
"summary": { // from inside the gzip
"TotalEvents": 4012,
"CriticalEvents": 0,
"ByLevel": {"Error": 23, "Warning": 187},
"TimeRange": "Last 24 hours",
"TopEventIds": [{"Id": 7036, "Count": 145}]
},
"top_events": [ // top 100 by severity then recency
{"TimeCreated":"…", "LevelDisplayName":"Error", "Id":7036, "Source":"Service Control Manager", "Message":"…"}
],
"event_count_total": 4012,
"top_events_truncated": true
}
Output runs through redact() before persistence.
Auto-audit hook
When the resolved IT Glue Configuration is single-match (exactly
one row matches the company + hostname), Pulse fires
runAssetAudit({ assetType: 'configuration', assetId, generatedByUserId: null, provider: 'anthropic' }).
The new audit row appears on the Configuration's audit page with
triggered_by_user_id=null. Multi-match Configurations are logged but
skipped — auditing the wrong asset is worse than no audit.
audit_log actions:
| Action | Resource | When |
|---|---|---|
rmm.loglift.dispatched |
datto_device | Pulse-driven Quick Job accepted |
rmm.loglift.received |
datto_device | Webhook landed + evidence persisted |
rmm.loglift.matched |
itg_configuration | Configuration matched + single |
rmm.loglift.audit_triggered |
itg_configuration | Auto-audit completed |
Safety + cost guards
OBJECT_KEY_REGEX— path-traversal guard.- B2 download cap: 25MB hard.
- Decompress cap: refuse if gzip ISIZE > 100MB (zip-bomb defense), re-checked after inflation.
redact()runs on the slim payload before storage.- Auto-audit only on single-match Configurations.
analyzer_cost_auditrecords on the dispatch path (rate-limited the same as Overshell). Inbound webhooks are NOT rate-limited — the agent decides cadence; we trust the agent.- The collector's own B2 credentials never travel through Pulse. Pulse
uses its own
B2_KEY_ID/B2_APP_KEYto download.
Environment variables
Required for dispatch + receive:
| Var | Default | Notes |
|---|---|---|
B2_KEY_ID |
— | B2 application key id |
B2_APP_KEY |
— | B2 application key secret |
B2_BUCKET |
wulf-audits |
matches existing n8n config |
B2_REGION |
us-west-002 |
|
B2_ENDPOINT |
s3.us-west-002.backblazeb2.com |
no scheme |
OPENCLAW_API_KEY |
— | webhook auth + collector variable |
BETTER_AUTH_URL |
— | base URL the collector POSTs to |
Verification checklist
\d rmm_executionsshowstransport,evidence_object_key,run_id.B2 client.test.tspasses (SigV4 fixture + path-traversal rejection).- Webhook auth: missing/bad
x-openclaw-key→ 401; good key + valid body → 200. /admin/rmm-overshell→ "Re-discover LogLift" populatesrmm_settings.loglift_component_uid.- Configuration page → "Run discovery" → LogLift selection → row goes
running(transport='b2_upload',run_idset). - Webhook handler downloads from B2, flips row to
complete,parsed_evidencepopulated. - Single-match Configuration → new
itglue_asset_auditsrow withtriggered_by_user_id=null. - Asset-audit prompt's
=== LIVE RMM EVIDENCE ===block contains the slim LogLift payload (system_context + summary + top_events). - Non-admin direct webhook POST without the openclaw key returns 401.
Non-goals
- No Telegram summary / non-technical second LLM pass (notification, not data path).
- No replacement of the existing collector PowerShell. The Datto
component is registered by name; the dispatch path passes
RunId,ClientId,WebhookUrl,WebhookSecretas variables. - No bulk-replay of historical B2 objects. Manual replay can be added
later via an admin endpoint that takes an
objectKey. - No HMAC signature on the webhook body —
x-openclaw-keyis the auth boundary. Adding HMAC is a fast follow if we expand external integrations.