- 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>
436 lines
14 KiB
TypeScript
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 };
|