wulf-pulse/lib/services/rmm/executor.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- 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>
2026-05-03 07:13:18 -04:00

436 lines
14 KiB
TypeScript

/**
* Dispatch a single RMM Overshell execution.
*
* 1. Validate script_id against the in-code registry.
* 2. Resolve the target device.
* 3. Per-user 24h rate limit (50 executions). Records every decision in
* analyzer_cost_audit so admins see RMM activity alongside LLM activity.
* 4. Insert pending row in rmm_executions.
* 5. Resolve Overshell component_uid (discover-on-demand if cache empty).
* 6. Call client.runQuickJob — store the returned jobUid + flip to
* 'running'. The worker takes over from there.
* 7. Best-effort generic audit_log entry.
*/
import { randomBytes } from 'node:crypto';
import postgresClient from '@/lib/services/postgres-client';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { presignUpload } from '@/lib/services/b2/client';
import {
evaluateCost,
recordCostAuditDecision,
} from '@/lib/services/analyzer/cost-guard';
import { audit } from '@/lib/services/audit';
import {
resolveOvershellComponent,
resolveLogliftComponent,
} from './settings';
import { getScript } from './scripts';
import {
resolveAssetSelfTarget,
resolveSiteAnchorTarget,
} from './target-resolver';
import {
countUserExecutionsLast24h,
createPendingExecution,
markExecutionFailedToDispatch,
markExecutionRunning,
} from './persistence';
const RATE_LIMIT_PER_24H = 50;
export type ExecutionTarget =
| { type: 'site_anchor'; companyId: number | string }
| {
type: 'asset_self';
deviceUid: string;
hostname?: string | null;
companyId?: number | string | null;
assetType?: 'flexible_asset' | 'configuration';
assetId?: number | string;
};
export interface QueueExecutionInput {
scriptId: string;
target: ExecutionTarget;
performedByUserId: string | null;
triggeredByAuditId?: string | null;
}
export interface QueueExecutionResult {
executionId: string;
status: 'queued' | 'running' | 'failed';
error?: string;
}
export async function queueExecution(
input: QueueExecutionInput
): Promise<QueueExecutionResult> {
const script = getScript(input.scriptId);
if (!script) {
throw new Error(`Unknown script_id: ${input.scriptId}`);
}
if (script.target_type !== input.target.type) {
throw new Error(
`Script "${script.id}" expects target_type=${script.target_type} but caller passed ${input.target.type}`
);
}
// Resolve the target device.
let deviceUid: string;
let hostname: string | null;
let companyId: number | string | null;
let assetType: 'flexible_asset' | 'configuration' | null = null;
let assetId: number | string | null = null;
if (input.target.type === 'site_anchor') {
const resolved = await resolveSiteAnchorTarget(input.target.companyId);
if (!resolved) {
throw new Error(
`No Wulf Nurse Production endpoint registered in Datto RMM for company ${input.target.companyId}.`
);
}
deviceUid = resolved.device_uid;
hostname = resolved.hostname;
companyId = input.target.companyId;
} else {
const resolved = await resolveAssetSelfTarget(input.target.deviceUid);
deviceUid = resolved.device_uid;
hostname = resolved.hostname ?? input.target.hostname ?? null;
companyId = input.target.companyId ?? null;
assetType = input.target.assetType ?? null;
assetId = input.target.assetId ?? null;
}
// Rate-limit per user (24h rolling window).
if (input.performedByUserId) {
const recent = await countUserExecutionsLast24h(input.performedByUserId);
if (recent >= RATE_LIMIT_PER_24H) {
const evaluation = await evaluateCost({
userId: input.performedByUserId,
estimatedCost: 0,
confirmedCost: false,
});
await recordCostAuditDecision({
userId: input.performedByUserId,
action: 'rmm_execute',
evaluation: { ...evaluation, decision: 'blocked', decisionReason: `Rate-limited: ${recent} executions in last 24h (limit ${RATE_LIMIT_PER_24H})` },
context: { scriptId: input.scriptId, recent24h: recent, limit: RATE_LIMIT_PER_24H },
});
throw new Error(
`RMM execution rate limit reached (${recent}/${RATE_LIMIT_PER_24H} in 24h).`
);
}
// Approved decision logged for telemetry.
const evaluation = await evaluateCost({
userId: input.performedByUserId,
estimatedCost: 0,
confirmedCost: false,
});
await recordCostAuditDecision({
userId: input.performedByUserId,
action: 'rmm_execute',
evaluation,
context: { scriptId: input.scriptId, recent24h: recent, deviceUid, hostname },
});
}
// Fork on transport — b2_upload uses the LogLift component + webhook flow.
if (script.transport === 'b2_upload') {
return dispatchB2Upload({
script,
deviceUid,
hostname,
companyId,
assetType,
assetId,
performedByUserId: input.performedByUserId,
triggeredByAuditId: input.triggeredByAuditId ?? null,
});
}
// Resolve the Overshell component (discover-on-demand if cache empty).
let componentUid: string;
let variableName: string;
try {
const resolved = await resolveOvershellComponent();
componentUid = resolved.componentUid;
variableName = resolved.variableName;
} catch (err) {
throw new Error(
err instanceof Error
? err.message
: 'Could not resolve the Overshell component'
);
}
const jobName = `Pulse: ${script.name}`;
const variables = [{ name: variableName, value: script.body }];
// Insert pending row first.
const created = await createPendingExecution({
scriptId: script.id,
scriptVersion: script.version,
targetType: input.target.type,
targetDeviceUid: deviceUid,
targetHostname: hostname,
targetCompanyId: companyId,
triggeredByAuditId: input.triggeredByAuditId ?? null,
assetType,
assetId,
jobName,
variables,
performedByUserId: input.performedByUserId,
});
// Dispatch.
const client = getDattoRMMClient();
let jobUid: string | null = null;
try {
const resp = await client.runQuickJob(deviceUid, {
jobName,
jobComponent: {
componentUid,
variables,
},
});
// Datto's response is a {} on success per their convention; the job uid
// sometimes comes back in different shapes depending on tenant config.
// Accept either shape and persist whatever we can.
jobUid =
(resp?.uid as string | undefined) ??
(resp?.jobUid as string | undefined) ??
(resp?.id as string | undefined) ??
(resp?.job?.uid as string | undefined) ??
null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await markExecutionFailedToDispatch(created.id, message);
return { executionId: created.id, status: 'failed', error: message };
}
if (!jobUid) {
// Datto accepted the request but didn't return an id we can poll. Mark
// failed-to-dispatch so we don't leave the row dangling. The next run
// can be resubmitted.
const message =
'Datto RMM accepted the runQuickJob request but did not return a job uid.';
await markExecutionFailedToDispatch(created.id, message);
return { executionId: created.id, status: 'failed', error: message };
}
await markExecutionRunning(created.id, jobUid);
// Generic admin-visible audit entry.
await audit.log({
userId: input.performedByUserId ?? undefined,
action: 'rmm.execute',
resource: 'datto_device',
resourceId: deviceUid,
details: {
execution_id: created.id,
script_id: script.id,
target_type: input.target.type,
job_uid: jobUid,
hostname,
},
});
return { executionId: created.id, status: 'running' };
}
/**
* Dispatch a LogLift-style execution: the Datto component uploads gzipped
* evidence to B2 and POSTs a metadata webhook back to Pulse. The execution
* row stays `running` until the webhook lands (or the 5-minute timeout fires).
*
* Variables passed to the Datto component:
* RunId — correlation token; the webhook handler uses it to find
* this row.
* ClientId — Datto-side site uuid (informational; matches CS_PROFILE_UID).
* ObjectKey — full B2 object key the collector will PUT to.
* UploadUrl — pre-presigned PUT URL (30-min TTL); collector uploads
* the gzip directly to B2 with no presign-fetch round-trip.
* WebhookUrl — Pulse's /api/rmm/loglift/upload endpoint.
* WebhookSecret — OPENCLAW_API_KEY; collector sends it as x-openclaw-key.
*
* Optional IssueDescription / TicketNumber can be added later by extending
* QueueExecutionInput.
*/
async function dispatchB2Upload(args: {
script: NonNullable<ReturnType<typeof getScript>>;
deviceUid: string;
hostname: string | null;
companyId: number | string | null;
assetType: 'flexible_asset' | 'configuration' | null;
assetId: number | string | null;
performedByUserId: string | null;
triggeredByAuditId: string | null;
}): Promise<QueueExecutionResult> {
const {
script,
deviceUid,
hostname,
companyId,
assetType,
assetId,
performedByUserId,
triggeredByAuditId,
} = args;
// The collector needs the Datto site uuid (datto_rmm_sites.uid) — that's
// what folds into the B2 object key as ClientId.
const siteRes = await postgresClient.query<{ site_uid: string }>(
`SELECT s.uid AS site_uid
FROM datto_rmm_devices d
JOIN datto_rmm_sites s ON s.id = d.site_id
WHERE d.uid = $1
LIMIT 1`,
[deviceUid]
);
if (siteRes.rowCount === 0) {
throw new Error(
`Datto device ${deviceUid} has no associated site — cannot derive ClientId for LogLift.`
);
}
const clientId = siteRes.rows[0].site_uid;
// Resolve LogLift component (discover-on-demand if cache empty).
let logliftUid: string;
try {
const resolved = await resolveLogliftComponent();
logliftUid = resolved.componentUid;
} catch (err) {
throw new Error(
err instanceof Error ? err.message : 'Could not resolve the LogLift component'
);
}
const baseUrl = (process.env.BETTER_AUTH_URL ?? '').replace(/\/$/, '');
if (!baseUrl) {
throw new Error(
'BETTER_AUTH_URL must be set for LogLift dispatch (used as the webhook URL the collector POSTs to).'
);
}
const webhookUrl = `${baseUrl}/api/rmm/loglift/upload`;
const webhookSecret = process.env.OPENCLAW_API_KEY;
if (!webhookSecret) {
throw new Error(
'OPENCLAW_API_KEY must be set for LogLift dispatch (collector authenticates with it as x-openclaw-key).'
);
}
const runId = `pulse_${randomBytes(6).toString('hex')}_${Date.now()}`;
const jobName = `Pulse: ${script.name}`;
// Build the B2 object key + presigned PUT URL up-front. The collector
// uploads directly with no presign-fetch round-trip. Hostname must be
// present (asset_self LogLift dispatch always knows the target hostname).
if (!hostname) {
throw new Error(
`LogLift dispatch requires a hostname for device ${deviceUid} (used as the B2 object-key path component).`
);
}
const safeHostname = hostname.replace(/[^A-Za-z0-9_.-]/g, '_');
const ts = formatObjectKeyTimestamp(new Date());
const objectKey = `${clientId}/${safeHostname}/eventlogs_${ts}.json.gz`;
const uploadUrl = presignUpload(objectKey, 1800); // 30-min TTL
const variables = [
{ name: 'RunId', value: runId },
{ name: 'ClientId', value: clientId },
{ name: 'ObjectKey', value: objectKey },
{ name: 'UploadUrl', value: uploadUrl },
{ name: 'WebhookUrl', value: webhookUrl },
{ name: 'WebhookSecret', value: webhookSecret },
];
// Strip secrets/signed URLs before persisting — the row's `variables`
// column is admin-readable. ObjectKey is fine to keep; the presigned URL
// contains a SigV4 signature that lets anyone PUT to that key for 30 min.
const persistedVariables = variables.filter(
(v) => v.name !== 'WebhookSecret' && v.name !== 'UploadUrl'
);
const created = await createPendingExecution({
scriptId: script.id,
scriptVersion: script.version,
targetType: 'asset_self',
targetDeviceUid: deviceUid,
targetHostname: hostname,
targetCompanyId: companyId,
triggeredByAuditId,
assetType,
assetId,
jobName,
variables: persistedVariables,
performedByUserId,
transport: 'b2_upload',
runId,
});
const client = getDattoRMMClient();
let jobUid: string | null = null;
try {
const resp = await client.runQuickJob(deviceUid, {
jobName,
jobComponent: {
componentUid: logliftUid,
variables,
},
});
jobUid =
(resp?.uid as string | undefined) ??
(resp?.jobUid as string | undefined) ??
(resp?.id as string | undefined) ??
(resp?.job?.uid as string | undefined) ??
null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await markExecutionFailedToDispatch(created.id, message);
return { executionId: created.id, status: 'failed', error: message };
}
if (!jobUid) {
const message =
'Datto RMM accepted the runQuickJob request but did not return a job uid.';
await markExecutionFailedToDispatch(created.id, message);
return { executionId: created.id, status: 'failed', error: message };
}
await markExecutionRunning(created.id, jobUid);
await audit.log({
userId: performedByUserId ?? undefined,
action: 'rmm.loglift.dispatched',
resource: 'datto_device',
resourceId: deviceUid,
details: {
execution_id: created.id,
script_id: script.id,
run_id: runId,
client_id: clientId,
object_key: objectKey,
job_uid: jobUid,
hostname,
},
});
return { executionId: created.id, status: 'running' };
}
/**
* Format a Date as `YYYYMMDD_HHMMSS` (UTC). Matches the file portion of
* `OBJECT_KEY_REGEX` (`eventlogs_[0-9_]+\.json\.gz`).
*/
function formatObjectKeyTimestamp(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
const h = String(d.getUTCHours()).padStart(2, '0');
const mi = String(d.getUTCMinutes()).padStart(2, '0');
const s = String(d.getUTCSeconds()).padStart(2, '0');
return `${y}${m}${day}_${h}${mi}${s}`;
}
export const _EXECUTOR_INTERNALS = { RATE_LIMIT_PER_24H, formatObjectKeyTimestamp };