wulf-pulse/autotask-app/lib/services/datto-rmm-client.ts

341 lines
9.6 KiB
TypeScript
Raw Normal View History

import {
DattoRMMConfig,
DattoRMMDevice,
DattoRMMSite,
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
*/
async getAllDevices(): Promise<DattoRMMDevice[]> {
const response = await this.makeApiCall<any>(
'/account/devices',
{ method: 'GET' }
);
// The API returns devices in a 'devices' field
return response.devices || [];
}
/**
* 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)
*/
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 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 &&
Add Addigy API integration and Docker deployment with Redis caching - Implemented complete Addigy API v2 client with authentication via x-api-key - Added device and policy endpoints with automatic org ID resolution - Created field mapping from snake_case to Title Case for UI compatibility - Handles nested 'facts' response structure from Addigy devices API - Added comprehensive API documentation in ADDIGY_API_GUIDE.md - Multi-stage Dockerfile with optimized production build - Custom ports: App on 3100, Redis on 6380 (avoids conflicts) - Docker Compose orchestration with health checks - Standalone Next.js output for smaller container images - Non-root user execution for security - Implemented Redis caching layer for API responses - 5-minute TTL with graceful fallback if Redis unavailable - Cache key structure: service:entity:filter1:filter2 - Applied to Addigy devices endpoint with cache hit/miss logging - Fixed TypeScript strict mode errors for production builds - Added null safety checks with optional chaining throughout API routes - Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility - Fixed type assertions for dynamic API responses - Corrected Set<string> type mismatches in device comparison logic - Created DOCKER_README.md with complete deployment guide - Updated ADDIGY_API_GUIDE.md with real-world API patterns - Documented response structures, field mappings, and troubleshooting - Next.js 16.0.0 with Turbopack - Redis 7 with AOF persistence - Podman/Docker compatible - TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00
!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 [];
}
}
}