469 lines
13 KiB
TypeScript
469 lines
13 KiB
TypeScript
import {
|
|
DattoRMMConfig,
|
|
DattoRMMDevice,
|
|
DattoRMMSite,
|
|
DattoRMMAlert,
|
|
DattoRMMApiResponse,
|
|
DattoRMMError,
|
|
} from '@/lib/types/datto-rmm';
|
|
|
|
export class DattoRMMClient {
|
|
private config: DattoRMMConfig;
|
|
private accessToken: string | null = null;
|
|
private tokenExpiry: Date | null = null;
|
|
|
|
constructor(config: DattoRMMConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Get OAuth2 access token
|
|
* Datto RMM API v2 uses OAuth2 with password grant type
|
|
* The API Access Key is the username and API Secret Key is the password
|
|
*/
|
|
private async getAccessToken(): Promise<string> {
|
|
// Check if we have a valid token
|
|
if (this.accessToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
|
return this.accessToken;
|
|
}
|
|
|
|
// OAuth2 token endpoint
|
|
const authUrl = 'https://concord-api.centrastage.net/auth/oauth/token';
|
|
|
|
// According to Datto RMM API v2 docs:
|
|
// - Basic auth with client_id: "public-client" and client_secret: "public"
|
|
// - grant_type: "password"
|
|
// - username: Your API Access Key
|
|
// - password: Your API Secret Key
|
|
const clientCredentials = Buffer.from('public-client:public').toString('base64');
|
|
|
|
const formData = new URLSearchParams();
|
|
formData.append('grant_type', 'password');
|
|
formData.append('username', this.config.apiKey);
|
|
formData.append('password', this.config.apiSecret);
|
|
|
|
try {
|
|
console.log('Authenticating with Datto RMM API v2...');
|
|
const response = await fetch(authUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Basic ${clientCredentials}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
console.error('Datto RMM auth failed:', response.status, error);
|
|
throw new Error(`Authentication failed: ${response.status} - ${error}`);
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
let data;
|
|
try {
|
|
data = JSON.parse(responseText);
|
|
} catch (parseError) {
|
|
console.error('Failed to parse auth response. Response was:', responseText.substring(0, 500));
|
|
throw new Error('Invalid response from auth server - expected JSON but got HTML/text');
|
|
}
|
|
|
|
this.accessToken = data.access_token;
|
|
|
|
// Set token expiry (usually 1 hour, but we'll refresh after 50 minutes to be safe)
|
|
this.tokenExpiry = new Date(Date.now() + 50 * 60 * 1000);
|
|
|
|
console.log('Successfully authenticated with Datto RMM');
|
|
return this.accessToken as string;
|
|
} catch (error) {
|
|
console.error('Failed to get Datto RMM access token:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make an authenticated API request
|
|
*/
|
|
private async makeApiCall<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const token = await this.getAccessToken();
|
|
|
|
// Use the base URL directly with v2 API
|
|
const url = `https://concord-api.centrastage.net/api/v2${endpoint}`;
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`API call failed: ${response.status} ${response.statusText}`);
|
|
console.error('Error response:', errorText);
|
|
|
|
let errorMessage = `API Error: ${response.status}`;
|
|
|
|
try {
|
|
const errorData = JSON.parse(errorText) as DattoRMMError;
|
|
errorMessage = `${errorData.error}: ${errorData.message}`;
|
|
} catch {
|
|
errorMessage += ` - ${errorText.substring(0, 200)}`;
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
if (!responseText) {
|
|
return {} as T;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(responseText) as T;
|
|
} catch (error) {
|
|
console.error('Failed to parse response:', responseText);
|
|
throw new Error('Invalid JSON response from API');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all sites
|
|
*/
|
|
async getSites(): Promise<DattoRMMSite[]> {
|
|
const response = await this.makeApiCall<any>(
|
|
'/account/sites',
|
|
{ method: 'GET' }
|
|
);
|
|
|
|
// The API returns sites in a 'sites' field
|
|
return response.sites || [];
|
|
}
|
|
|
|
/**
|
|
* Get site by name (for matching with Autotask company)
|
|
*/
|
|
async getSiteByName(name: string): Promise<DattoRMMSite | null> {
|
|
const sites = await this.getSites();
|
|
const site = sites.find(s =>
|
|
s.name.toLowerCase() === name.toLowerCase() ||
|
|
s.name.toLowerCase().includes(name.toLowerCase())
|
|
);
|
|
|
|
return site || null;
|
|
}
|
|
|
|
/**
|
|
* Get all devices (with pagination support)
|
|
*/
|
|
async getAllDevices(): Promise<DattoRMMDevice[]> {
|
|
const allDevices: DattoRMMDevice[] = [];
|
|
let page = 1;
|
|
const pageSize = 250; // Datto RMM default page size
|
|
|
|
while (true) {
|
|
const response = await this.makeApiCall<any>(
|
|
`/account/devices?page=${page}&pageSize=${pageSize}`,
|
|
{ method: 'GET' }
|
|
);
|
|
|
|
const devices = response.devices || [];
|
|
allDevices.push(...devices);
|
|
|
|
console.log(`Fetched page ${page}: ${devices.length} devices (total: ${allDevices.length})`);
|
|
|
|
// If we got less than pageSize devices, we've reached the end
|
|
if (devices.length < pageSize) {
|
|
break;
|
|
}
|
|
|
|
page++;
|
|
}
|
|
|
|
console.log(`Total RMM devices fetched: ${allDevices.length}`);
|
|
return allDevices;
|
|
}
|
|
|
|
/**
|
|
* Get devices for a specific site
|
|
*/
|
|
async getDevicesBySite(siteUid: string): Promise<DattoRMMDevice[]> {
|
|
const response = await this.makeApiCall<any>(
|
|
`/site/${siteUid}/devices`,
|
|
{ method: 'GET' }
|
|
);
|
|
|
|
// The API returns devices in a 'devices' field
|
|
return response.devices || [];
|
|
}
|
|
|
|
/**
|
|
* Get devices by site name (matches with company name)
|
|
* @deprecated Use getDevicesByCompanyId with site mappings instead
|
|
*/
|
|
async getDevicesByCompanyName(companyName: string): Promise<DattoRMMDevice[]> {
|
|
// First, find the site that matches the company name
|
|
const site = await this.getSiteByName(companyName);
|
|
|
|
if (!site) {
|
|
console.warn(`No RMM site found for company: ${companyName}`);
|
|
return [];
|
|
}
|
|
|
|
// Then get devices for that site using the UID
|
|
return this.getDevicesBySite(site.uid);
|
|
}
|
|
|
|
/**
|
|
* Get devices for multiple sites (for multi-site support)
|
|
*/
|
|
async getDevicesForSites(siteUids: string[]): Promise<DattoRMMDevice[]> {
|
|
if (siteUids.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const allDevices: DattoRMMDevice[] = [];
|
|
|
|
// Fetch devices from all sites in parallel
|
|
const promises = siteUids.map(uid => this.getDevicesBySite(uid));
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
allDevices.push(...result.value);
|
|
} else {
|
|
console.error('Failed to fetch devices for a site:', result.reason);
|
|
}
|
|
}
|
|
|
|
return allDevices;
|
|
}
|
|
|
|
/**
|
|
* Get device by ID
|
|
*/
|
|
async getDeviceById(deviceId: string): Promise<DattoRMMDevice | null> {
|
|
try {
|
|
const response = await this.makeApiCall<{ item: DattoRMMDevice }>(
|
|
`/devices/${deviceId}`,
|
|
{ method: 'GET' }
|
|
);
|
|
|
|
return response.item || null;
|
|
} catch (error) {
|
|
console.error(`Failed to get device ${deviceId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search devices by various criteria
|
|
*/
|
|
async searchDevices(criteria: {
|
|
hostname?: string;
|
|
serialNumber?: string;
|
|
ipAddress?: string;
|
|
macAddress?: string;
|
|
}): Promise<DattoRMMDevice[]> {
|
|
const allDevices = await this.getAllDevices();
|
|
|
|
return allDevices.filter(device => {
|
|
if (criteria.hostname &&
|
|
!device.hostname.toLowerCase().includes(criteria.hostname.toLowerCase())) {
|
|
return false;
|
|
}
|
|
|
|
if (criteria.serialNumber &&
|
|
device.serialNumber !== criteria.serialNumber) {
|
|
return false;
|
|
}
|
|
|
|
if (criteria.ipAddress &&
|
|
device.intIpAddress !== criteria.ipAddress &&
|
|
device.extIpAddress !== criteria.ipAddress) {
|
|
return false;
|
|
}
|
|
|
|
if (criteria.macAddress &&
|
|
!device.macAddresses?.some(mac =>
|
|
mac.toLowerCase() === criteria.macAddress!.toLowerCase()
|
|
)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get device audit data with detailed hardware information
|
|
*/
|
|
async getDeviceAudit(deviceId: string | number): Promise<any> {
|
|
try {
|
|
const response = await this.makeApiCall<any>(
|
|
`/device/${deviceId}/auditdata`,
|
|
{ method: 'GET' }
|
|
);
|
|
|
|
return response;
|
|
} catch (error) {
|
|
console.error(`Failed to get audit for device ${deviceId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get device with audit data
|
|
*/
|
|
async getDeviceWithAudit(deviceId: string | number): Promise<DattoRMMDevice | null> {
|
|
try {
|
|
// Get basic device info
|
|
const device = await this.getDeviceById(deviceId.toString());
|
|
if (!device) return null;
|
|
|
|
// Try to get audit data for more details
|
|
const audit = await this.getDeviceAudit(deviceId);
|
|
if (audit) {
|
|
// Merge audit data into device
|
|
if (audit.bios) {
|
|
device.manufacturer = audit.bios.manufacturer || device.manufacturer;
|
|
device.model = audit.bios.model || device.model;
|
|
device.serialNumber = audit.bios.serialNumber || device.serialNumber;
|
|
}
|
|
|
|
if (audit.system) {
|
|
device.manufacturer = audit.system.manufacturer || device.manufacturer;
|
|
device.model = audit.system.model || device.model;
|
|
}
|
|
|
|
if (audit.processors && audit.processors.length > 0) {
|
|
device.cpuName = audit.processors[0].name;
|
|
device.cpuCores = audit.processors[0].cores;
|
|
}
|
|
|
|
if (audit.memory) {
|
|
device.memory = audit.memory.totalPhysicalMemory;
|
|
}
|
|
|
|
if (audit.disks && audit.disks.length > 0) {
|
|
// Sum up all disk sizes
|
|
device.diskSize = audit.disks.reduce((total: number, disk: any) =>
|
|
total + (disk.size || 0), 0
|
|
);
|
|
}
|
|
}
|
|
|
|
return device;
|
|
} catch (error) {
|
|
console.error(`Failed to get device with audit ${deviceId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get device alerts
|
|
*/
|
|
async getDeviceAlerts(deviceId: string): Promise<any[]> {
|
|
try {
|
|
const response = await this.makeApiCall<DattoRMMApiResponse<any>>(
|
|
`/devices/${deviceId}/alerts`,
|
|
{ method: 'GET' }
|
|
);
|
|
|
|
return response.items || [];
|
|
} catch (error) {
|
|
console.error(`Failed to get alerts for device ${deviceId}:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generic paginated fetch — follows nextPageUrl until exhausted
|
|
*/
|
|
private async fetchAllPages<T>(
|
|
endpoint: string,
|
|
dataKey: string,
|
|
pageSize = 250
|
|
): Promise<T[]> {
|
|
const allItems: T[] = [];
|
|
let url: string | null = `https://concord-api.centrastage.net/api/v2${endpoint}?pageSize=${pageSize}`;
|
|
|
|
while (url) {
|
|
const token = await this.getAccessToken();
|
|
const resp: Response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const errText = await resp.text();
|
|
throw new Error(`Datto RMM API ${resp.status}: ${errText.substring(0, 200)}`);
|
|
}
|
|
|
|
const body: any = await resp.json();
|
|
const items = body[dataKey] || [];
|
|
allItems.push(...items);
|
|
|
|
const nextUrl: string | null = body.pageDetails?.nextPageUrl ?? null;
|
|
console.log(`[DATTO-RMM] ${dataKey}: fetched ${allItems.length} (page had ${items.length})`);
|
|
|
|
url = nextUrl;
|
|
}
|
|
|
|
return allItems;
|
|
}
|
|
|
|
/**
|
|
* Get all sites (paginated)
|
|
*/
|
|
async getAllSites(): Promise<DattoRMMSite[]> {
|
|
return this.fetchAllPages<DattoRMMSite>('/account/sites', 'sites');
|
|
}
|
|
|
|
/**
|
|
* Get all open alerts (paginated)
|
|
*/
|
|
async getAllOpenAlerts(): Promise<DattoRMMAlert[]> {
|
|
return this.fetchAllPages<DattoRMMAlert>('/account/alerts/open', 'alerts');
|
|
}
|
|
|
|
/**
|
|
* Get all resolved alerts (paginated, recent only)
|
|
*/
|
|
async getRecentResolvedAlerts(maxPages = 4): Promise<DattoRMMAlert[]> {
|
|
const allAlerts: DattoRMMAlert[] = [];
|
|
let url: string | null = 'https://concord-api.centrastage.net/api/v2/account/alerts/resolved?pageSize=250';
|
|
let pages = 0;
|
|
|
|
while (url && pages < maxPages) {
|
|
const token = await this.getAccessToken();
|
|
const resp: Response = await fetch(url, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!resp.ok) break;
|
|
|
|
const body: any = await resp.json();
|
|
const alerts = body.alerts || [];
|
|
allAlerts.push(...alerts);
|
|
url = body.pageDetails?.nextPageUrl ?? null;
|
|
pages++;
|
|
console.log(`[DATTO-RMM] resolved alerts: fetched ${allAlerts.length} (page ${pages})`);
|
|
}
|
|
|
|
return allAlerts;
|
|
}
|
|
}
|