- 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>
241 lines
7.5 KiB
TypeScript
241 lines
7.5 KiB
TypeScript
/**
|
|
* RMM Overshell poller. Runs in-process alongside the analyzer worker.
|
|
*
|
|
* For each row in `running` status whose timeout_at hasn't passed:
|
|
* 1. Call dattoClient.getJobResults(job_uid, target_device_uid).
|
|
* 2. If Datto says still-running → leave alone, retry next tick.
|
|
* 3. If Datto returns stdout/stderr → redact, run script.parseOutput,
|
|
* persist via markExecutionComplete (status complete | failed
|
|
* depending on exit_code).
|
|
* 4. Catch all errors so a single bad job doesn't kill the loop.
|
|
*
|
|
* Stale-row sweep: any row past timeout_at gets marked timeout regardless
|
|
* of whether Datto is responding. Stops the table from accumulating
|
|
* orphaned 'running' rows.
|
|
*/
|
|
|
|
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
|
import { redact } from '@/lib/services/analyzer/itglue-redact';
|
|
import { getScript } from './scripts';
|
|
import {
|
|
listRunningExecutions,
|
|
listTimedOutExecutions,
|
|
markExecutionComplete,
|
|
markExecutionTimeout,
|
|
} from './persistence';
|
|
import { audit } from '@/lib/services/audit';
|
|
|
|
const POLL_INTERVAL_MS = 5_000;
|
|
|
|
type DattoJobResults = {
|
|
jobStatus?: string;
|
|
status?: string;
|
|
// Datto returns per-target stdout/stderr in different shapes depending on
|
|
// tenant config; we accept both top-level and nested under
|
|
// results/results[0]/stdOut etc.
|
|
stdOut?: string | null;
|
|
stdErr?: string | null;
|
|
errorCode?: number | null;
|
|
results?: Array<{
|
|
deviceUid?: string;
|
|
stdOut?: string | null;
|
|
stdErr?: string | null;
|
|
errorCode?: number | null;
|
|
jobStatus?: string;
|
|
}>;
|
|
};
|
|
|
|
interface ExtractedResult {
|
|
done: boolean;
|
|
exitCode: number | null;
|
|
stdout: string | null;
|
|
stderr: string | null;
|
|
}
|
|
|
|
function extractResult(payload: DattoJobResults, deviceUid: string): ExtractedResult {
|
|
// Per-device result if available.
|
|
const perDevice = payload.results?.find((r) => r.deviceUid === deviceUid)
|
|
?? payload.results?.[0];
|
|
|
|
const candidate = perDevice ?? payload;
|
|
const status = (candidate.jobStatus ?? payload.jobStatus ?? payload.status ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
|
|
// Datto's terminal states: succeeded, failed, cancelled. Anything else =
|
|
// not done yet.
|
|
const done =
|
|
status === 'succeeded' ||
|
|
status === 'success' ||
|
|
status === 'failed' ||
|
|
status === 'cancelled' ||
|
|
status === 'canceled';
|
|
|
|
return {
|
|
done,
|
|
exitCode:
|
|
typeof candidate.errorCode === 'number'
|
|
? candidate.errorCode
|
|
: status === 'failed' || status === 'cancelled' || status === 'canceled'
|
|
? 1
|
|
: status === 'succeeded' || status === 'success'
|
|
? 0
|
|
: null,
|
|
stdout: candidate.stdOut ?? null,
|
|
stderr: candidate.stdErr ?? null,
|
|
};
|
|
}
|
|
|
|
class RmmOvershellWorker {
|
|
private timer: NodeJS.Timeout | null = null;
|
|
private running = false;
|
|
private inFlight = false;
|
|
|
|
async start(): Promise<void> {
|
|
if (this.running) return;
|
|
this.running = true;
|
|
console.log('[RMM-WORKER] starting; polling every 5s');
|
|
this.scheduleNextPoll(0);
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.running = false;
|
|
if (this.timer) {
|
|
clearTimeout(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
private scheduleNextPoll(delay: number): void {
|
|
if (!this.running) return;
|
|
this.timer = setTimeout(() => {
|
|
void this.poll();
|
|
}, delay);
|
|
}
|
|
|
|
private async poll(): Promise<void> {
|
|
if (!this.running) return;
|
|
if (this.inFlight) {
|
|
this.scheduleNextPoll(POLL_INTERVAL_MS);
|
|
return;
|
|
}
|
|
this.inFlight = true;
|
|
try {
|
|
// First — sweep any rows that timed out.
|
|
const timed = await listTimedOutExecutions();
|
|
for (const row of timed) {
|
|
try {
|
|
await markExecutionTimeout(row.id);
|
|
await audit.log({
|
|
userId: row.performedByUserId ?? undefined,
|
|
action: 'rmm.execute.timeout',
|
|
resource: 'datto_device',
|
|
resourceId: row.targetDeviceUid,
|
|
details: { execution_id: row.id, script_id: row.scriptId },
|
|
});
|
|
} catch (err) {
|
|
console.warn('[RMM-WORKER] timeout-mark failed:', err);
|
|
}
|
|
}
|
|
|
|
// Then poll the running ones. Skip b2_upload rows — those are flipped
|
|
// to complete by the LogLift webhook handler when the upload lands;
|
|
// there's no stdout to poll for. The 5-minute timeout sweep above
|
|
// still catches stuck rows.
|
|
const running = (await listRunningExecutions()).filter(
|
|
(r) => r.transport !== 'b2_upload'
|
|
);
|
|
if (running.length === 0) {
|
|
return;
|
|
}
|
|
const client = getDattoRMMClient();
|
|
for (const row of running) {
|
|
if (!row.jobUid) continue;
|
|
try {
|
|
const payload = (await client.getJobResults(
|
|
row.jobUid,
|
|
row.targetDeviceUid
|
|
)) as DattoJobResults;
|
|
const r = extractResult(payload, row.targetDeviceUid);
|
|
if (!r.done) continue; // still running, try next tick.
|
|
|
|
// Redact outputs before persistence — defensive against scripts
|
|
// that might dump credentials. The script library is curated, but
|
|
// belt + braces.
|
|
const redactedStdout = r.stdout
|
|
? (redact({ s: r.stdout }) as { s: string }).s
|
|
: null;
|
|
const redactedStderr = r.stderr
|
|
? (redact({ s: r.stderr }) as { s: string }).s
|
|
: null;
|
|
|
|
// Parse via the script's own parser. Failure is non-fatal — we
|
|
// still persist the raw output.
|
|
let parsed: unknown = null;
|
|
let parseError: string | null = null;
|
|
if (redactedStdout && r.exitCode === 0) {
|
|
const script = getScript(row.scriptId);
|
|
if (script) {
|
|
try {
|
|
parsed = script.parseOutput(redactedStdout);
|
|
} catch (err) {
|
|
parseError = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
await markExecutionComplete({
|
|
id: row.id,
|
|
exitCode: r.exitCode,
|
|
rawStdout: redactedStdout,
|
|
rawStderr: redactedStderr,
|
|
parsedEvidence: parsed,
|
|
parseError,
|
|
});
|
|
|
|
await audit.log({
|
|
userId: row.performedByUserId ?? undefined,
|
|
action: r.exitCode === 0 ? 'rmm.execute.complete' : 'rmm.execute.failed',
|
|
resource: 'datto_device',
|
|
resourceId: row.targetDeviceUid,
|
|
details: {
|
|
execution_id: row.id,
|
|
script_id: row.scriptId,
|
|
exit_code: r.exitCode,
|
|
parse_error: parseError,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.warn(
|
|
`[RMM-WORKER] poll error for execution ${row.id}:`,
|
|
err instanceof Error ? err.message : err
|
|
);
|
|
// Don't mark failed yet — could be transient. timeout_at will
|
|
// catch it eventually.
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[RMM-WORKER] poll loop error:', err);
|
|
} finally {
|
|
this.inFlight = false;
|
|
this.scheduleNextPoll(POLL_INTERVAL_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const rmmOvershellWorker = new RmmOvershellWorker();
|
|
|
|
function shouldAutoStart(): boolean {
|
|
if (typeof window !== 'undefined') return false;
|
|
if (process.env.VITEST === 'true') return false;
|
|
if (process.env.NODE_ENV === 'production') return true;
|
|
return process.env.RMM_WORKER_AUTOSTART === '1';
|
|
}
|
|
|
|
if (shouldAutoStart()) {
|
|
rmmOvershellWorker.start().catch((err) => {
|
|
console.error('[RMM-WORKER] failed to auto-start:', err);
|
|
});
|
|
}
|
|
|
|
export const _RMM_WORKER_INTERNALS = { POLL_INTERVAL_MS, extractResult };
|