/** * 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 { 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>; 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 { 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 };