wulf-pulse/lib/services/rmm/target-resolver.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

192 lines
6 KiB
TypeScript

/**
* Resolve which Datto RMM device an RMM Overshell script should run against.
*
* For site-anchored scripts: pick the Wulf Nurse Production endpoint
* (hostname matching `LLLCCCWNPNN` — 6 letters + WNP + 2 digits) for the
* client. Prefer online devices and lowest numeric suffix (WNP01 over
* WNP02 etc.) so we hit the canonical endpoint deterministically.
*
* For asset-self: caller supplies the device_uid; we look up hostname /
* online state for telemetry only.
*/
import postgresClient from '@/lib/services/postgres-client';
export interface ResolvedTarget {
device_uid: string;
hostname: string | null;
online: boolean;
}
/**
* Wulf Nurse Production endpoint pattern.
* LLL = location code (3 letters)
* CCC = client code (3 letters)
* WNP = literal
* NN = numeric suffix
* e.g. YNGHYNWNP01 → Youngstown / Hynes / WNP / 01
*/
const WNP_REGEX = /^[A-Z]{3}[A-Z]{3}WNP\d{2}$/i;
interface SiteRow {
id: number;
name: string;
device_count: number;
}
interface DeviceRow {
uid: string;
hostname: string | null;
online: boolean;
site_id: number;
site_device_count: number;
}
/**
* Find every Datto RMM site that maps to the given Autotask company.
*
* Two paths because the autotask_company_id FK on datto_rmm_sites isn't
* always populated — many sites only carry the autotask_company_name. We
* match on either the FK or a case-insensitive name match against
* companies.company_name.
*
* Multi-site clients (e.g. Hynes Industries: Youngstown + Painesville +
* Kokomo) return multiple rows; the caller combines WNPs across all of
* them.
*/
async function findDattoSitesForCompany(
companyId: string | number
): Promise<SiteRow[]> {
const res = await postgresClient.query<SiteRow>(
`SELECT s.id, s.name, COALESCE(s.number_of_devices, 0) AS device_count
FROM datto_rmm_sites s
LEFT JOIN companies c ON c.id = $1::bigint
WHERE s.autotask_company_id = $1::bigint
OR (
c.company_name IS NOT NULL
AND s.autotask_company_name IS NOT NULL
AND LOWER(s.autotask_company_name) = LOWER(c.company_name)
)
ORDER BY device_count DESC, s.name`,
[companyId]
);
return res.rows;
}
async function findWnpDevicesForSites(siteIds: number[]): Promise<DeviceRow[]> {
if (siteIds.length === 0) return [];
const res = await postgresClient.query<DeviceRow>(
`SELECT d.uid, d.hostname, d.online, d.site_id,
COALESCE(s.number_of_devices, 0) AS site_device_count
FROM datto_rmm_devices d
JOIN datto_rmm_sites s ON s.id = d.site_id
WHERE d.site_id = ANY($1::int[])
AND d.hostname ~* '^[A-Z]{3}[A-Z]{3}WNP[0-9]{2}$'`,
[siteIds]
);
return res.rows.filter((r) => r.hostname && WNP_REGEX.test(r.hostname));
}
export async function resolveSiteAnchorTarget(
companyId: string | number
): Promise<ResolvedTarget | null> {
const sites = await findDattoSitesForCompany(companyId);
if (sites.length === 0) return null;
const devices = await findWnpDevicesForSites(sites.map((s) => s.id));
if (devices.length === 0) return null;
// Pick: online > offline; site with more devices (proxy for "main
// location"); lowest numeric suffix (WNP01 over WNP02).
devices.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
if (a.site_device_count !== b.site_device_count) {
return b.site_device_count - a.site_device_count;
}
const suffixA = parseInt((a.hostname ?? '').slice(-2), 10);
const suffixB = parseInt((b.hostname ?? '').slice(-2), 10);
if (Number.isNaN(suffixA) || Number.isNaN(suffixB)) return 0;
return suffixA - suffixB;
});
const pick = devices[0];
return {
device_uid: pick.uid,
hostname: pick.hostname,
online: pick.online,
};
}
/**
* Return ALL matching WNPs across every Datto site for the company, with
* their site context. The site-discovery page can show this so the user
* sees every available endpoint — Hynes has Youngstown, Kokomo,
* Painesville WNPs and they're all valid targets for some scripts.
*/
export async function listSiteAnchorTargets(
companyId: string | number
): Promise<
Array<
ResolvedTarget & { site_id: number; site_name: string; site_device_count: number }
>
> {
const sites = await findDattoSitesForCompany(companyId);
if (sites.length === 0) return [];
const devices = await findWnpDevicesForSites(sites.map((s) => s.id));
const siteNameById = new Map(sites.map((s) => [s.id, s.name]));
return devices
.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return b.site_device_count - a.site_device_count;
})
.map((d) => ({
device_uid: d.uid,
hostname: d.hostname,
online: d.online,
site_id: d.site_id,
site_name: siteNameById.get(d.site_id) ?? '',
site_device_count: d.site_device_count,
}));
}
export async function resolveAssetSelfTarget(
deviceUid: string
): Promise<ResolvedTarget> {
const res = await postgresClient.query<DeviceRow>(
`SELECT uid, hostname, online
FROM datto_rmm_devices
WHERE uid = $1
LIMIT 1`,
[deviceUid]
);
if (res.rowCount === 0) {
return { device_uid: deviceUid, hostname: null, online: false };
}
return {
device_uid: res.rows[0].uid,
hostname: res.rows[0].hostname,
online: res.rows[0].online,
};
}
/**
* Resolve a Datto device by hostname (case-insensitive). Used when the
* caller wants to target the audited Configuration's hostname rather than
* a known device_uid.
*/
export async function resolveDeviceByHostname(
hostname: string
): Promise<ResolvedTarget | null> {
const res = await postgresClient.query<DeviceRow>(
`SELECT uid, hostname, online
FROM datto_rmm_devices
WHERE LOWER(hostname) = LOWER($1)
LIMIT 1`,
[hostname]
);
if (res.rowCount === 0) return null;
return {
device_uid: res.rows[0].uid,
hostname: res.rows[0].hostname,
online: res.rows[0].online,
};
}
export const _TARGET_RESOLVER_INTERNALS = { WNP_REGEX };