appspace/mdm.js
jmcqueen 025b70de56 Initial commit: Appspace + Webex alerting bot
Node/Express service that:
- Receives Appspace outbound webhooks, enriches with Workspace ONE MDM
  data (matched by serial), and posts Adaptive Card alerts to Webex.
- Runs a Webex bot in WebSocket mode with two commands:
    * `offline [filter]`  - lists currently offline / lost / failed
      Appspace devices, enriched with per-device MDM facts + console links.
    * `restart-offline [filter]` - sends WS1 SoftReset (reboot) to every
      currently-offline device that has a WS1 record. Capped at 50 per
      invocation with bounded concurrency to protect the WS1 API.

Notes on hardening already applied:
- In-flight promise coalescing in mdm.js and index.js so burst webhook
  traffic can't stampede the WS1 token / device-cache refresh or the
  Appspace token refresh.
- Structured logger that serializes Error instances (message, stack,
  code, axios response.status/data) instead of stringifying to "{}".
- Webex 7439-char message-limit handling: `offline` builds its body
  incrementally against a character budget and reports accurate
  "N more not shown" truncation.
- Uses string phrases for `framework.hears(...)` so the framework's
  `(^| )phrase($| )` wrapper handles group-space @mentions correctly,
  and a shared `extractFilterArg()` helper so filter parsing works
  identically in DMs and mentioned messages.

Config, Docker, smoke-test profile, and healthcheck included.
Secrets are managed via `.env` (gitignored); see `.env.example`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:43:53 -04:00

304 lines
No EOL
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// mdm.js - Optimized: 24h cache + direct fresh lookup by Id.Value
const axios = require('axios');
const MDM_BASE_URL = process.env.WS1_BASE_URL || 'https://as1991.awmdm.com';
const TOKEN_URL = 'https://na.uemauth.workspaceone.com/connect/token';
// Logging configuration (duplicated small logger for independence)
const isVerbose = process.env.DEBUG === 'true' || process.env.NODE_ENV !== 'production';
const useJsonLogs = process.env.LOG_FORMAT === 'json' || process.env.NODE_ENV === 'production';
const logger = {
info: (msg, meta = {}) => log('info', msg, meta),
warn: (msg, meta = {}) => log('warn', msg, meta),
error: (msg, meta = {}) => log('error', msg, meta),
debug: (msg, meta = {}) => { if (isVerbose) log('debug', msg, meta); }
};
function normalizeMeta(meta) {
if (meta == null) return {};
if (meta instanceof Error) {
const out = { error: meta.message, errorName: meta.name, stack: meta.stack };
if (meta.code) out.code = meta.code;
if (meta.response) {
out.responseStatus = meta.response.status;
out.responseData = meta.response.data;
}
return out;
}
if (typeof meta !== 'object') return { value: meta };
return meta;
}
function log(level, msg, meta = {}) {
const timestamp = new Date().toISOString();
const normMeta = normalizeMeta(meta);
if (useJsonLogs) {
const entry = { timestamp, level, msg, ...normMeta };
Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k]);
console.log(JSON.stringify(entry));
} else {
const emoji = level === 'error' ? '❌' : level === 'warn' ? '⚠️' : level === 'debug' ? '🐛' : '';
const metaStr = Object.keys(normMeta).length ? ' ' + JSON.stringify(normMeta) : '';
const out = level === 'error' ? console.error : console.log;
out(`${emoji} ${msg}${metaStr}`);
}
}
let currentMDMToken = null;
let tokenExpiresAt = 0;
let deviceIdCache = new Map(); // serial → { id, ...basicInfo }
let lastFullCacheTime = 0;
// In-flight promise trackers to coalesce concurrent callers (prevents
// burst webhook traffic from triggering N parallel token fetches /
// cache refreshes when one would suffice).
let inFlightTokenPromise = null;
let inFlightCacheRefreshPromise = null;
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
function invalidateMDMToken() {
currentMDMToken = null;
tokenExpiresAt = 0;
}
async function getMDMToken(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && currentMDMToken && now < tokenExpiresAt) return currentMDMToken;
// If another caller is already fetching, wait on that same promise.
if (inFlightTokenPromise) return inFlightTokenPromise;
inFlightTokenPromise = (async () => {
logger.info('Fetching new Workspace ONE MDM token...');
const response = await axios.post(TOKEN_URL, new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.WS1_CLIENT_ID,
client_secret: process.env.WS1_CLIENT_SECRET,
}), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 20000
});
currentMDMToken = response.data.access_token;
tokenExpiresAt = Date.now() + (response.data.expires_in * 1000) - 60000;
logger.info('MDM token acquired');
return currentMDMToken;
})().finally(() => {
inFlightTokenPromise = null;
});
return inFlightTokenPromise;
}
// Full cache refresh every 24 hours (used only to map serial → Id)
async function refreshDeviceIdCache() {
const now = Date.now();
if (deviceIdCache.size > 0 && now - lastFullCacheTime < CACHE_DURATION_MS) {
return;
}
// Coalesce concurrent callers — a burst of webhooks should trigger one refresh, not N.
if (inFlightCacheRefreshPromise) return inFlightCacheRefreshPromise;
inFlightCacheRefreshPromise = (async () => {
logger.info('Refreshing device ID cache (24h cycle)...');
let token;
try {
token = await getMDMToken();
} catch (e) {
logger.error('Failed to obtain MDM token for cache refresh', { error: e.message });
return; // keep existing cache if any
}
const tempCache = new Map();
let page = 0;
const pageSize = 500;
let hasMore = true;
let success = false;
try {
while (hasMore) {
const response = await axios.get(`${MDM_BASE_URL}/api/mdm/devices/search`, {
params: { page, page_size: pageSize },
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': process.env.WS1_TENANT_CODE,
'Accept': 'application/json'
},
timeout: 30000
});
const pageDevices = response.data.Devices || [];
pageDevices.forEach(d => {
if (d.SerialNumber) {
tempCache.set(d.SerialNumber.toUpperCase().trim(), {
id: d.Id?.Value || d.id,
serial: d.SerialNumber,
basicInfo: d
});
}
});
logger.debug('Cache refresh page', { page, devices: pageDevices.length });
if (pageDevices.length < pageSize) hasMore = false;
else page++;
}
success = true;
} catch (err) {
logger.error('Device ID cache refresh failed (keeping previous cache)', { error: err.message });
// do not throw — callers (webhooks) should continue with stale-but-better-than-nothing data
}
if (success && tempCache.size > 0) {
deviceIdCache = tempCache; // atomic swap
lastFullCacheTime = Date.now();
logger.info('Device ID cache refreshed', { total: deviceIdCache.size });
}
})().finally(() => {
inFlightCacheRefreshPromise = null;
});
return inFlightCacheRefreshPromise;
}
// Get fresh device details by ID (always current status)
async function getFreshMDMDeviceById(deviceId) {
if (!deviceId) return null;
let token = await getMDMToken();
try {
const response = await axios.get(`${MDM_BASE_URL}/api/mdm/devices/${deviceId}`, {
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': process.env.WS1_TENANT_CODE,
'Accept': 'application/json'
},
timeout: 15000
});
return response.data;
} catch (err) {
const status = err.response?.status;
if (status === 401 || status === 403) {
logger.warn('MDM auth failed (401/403) — invalidating token and retrying once');
invalidateMDMToken();
token = await getMDMToken(true);
try {
const retryResp = await axios.get(`${MDM_BASE_URL}/api/mdm/devices/${deviceId}`, {
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': process.env.WS1_TENANT_CODE,
'Accept': 'application/json'
},
timeout: 15000
});
return retryResp.data;
} catch (retryErr) {
logger.warn('Retry also failed for deviceId', { deviceId, error: retryErr.message });
return null;
}
}
logger.warn('Failed to fetch fresh details for deviceId', { deviceId, error: err.message });
return null;
}
}
// Main lookup function
async function getMDMDeviceBySerial(serialNumber) {
if (!serialNumber) return null;
const normalized = serialNumber.toUpperCase().trim();
if (isVerbose) logger.debug('Looking up MDM device for serial', { serial: normalized });
// Ensure we have the ID cache
await refreshDeviceIdCache();
const cachedEntry = deviceIdCache.get(normalized);
if (!cachedEntry || !cachedEntry.id) {
if (isVerbose) logger.debug('No ID found for serial', { serial: normalized });
return null;
}
// Get fresh/current data using the ID
if (isVerbose) logger.debug('Fetching fresh status for deviceId', { deviceId: cachedEntry.id });
const freshDevice = await getFreshMDMDeviceById(cachedEntry.id);
if (freshDevice) {
if (isVerbose) logger.debug('Fresh MDM data retrieved', { serial: normalized });
return freshDevice;
}
// Fallback to cached basic info
if (isVerbose) logger.debug('Using cached basic info', { serial: normalized });
return cachedEntry.basicInfo;
}
// Send a SoftReset (reboot) command to a device in Workspace ONE UEM.
// `deviceId` is the WS1 numeric Id (e.g. mdmDevice.Id.Value), NOT the serial.
// Returns { success: boolean, status?: number, error?: string }.
// Note: For iOS, SoftReset is only honored when the device is Supervised (DEP-enrolled).
// Non-supervised iOS devices will surface a WS1 error in the returned message.
async function sendMDMRebootCommand(deviceId) {
if (!deviceId) {
return { success: false, error: 'No WS1 deviceId supplied' };
}
const url = `${MDM_BASE_URL}/api/mdm/devices/commands`;
const params = { command: 'SoftReset', searchBy: 'DeviceId', id: deviceId };
async function doRequest(token) {
return axios.post(url, null, {
params,
headers: {
Authorization: `Bearer ${token}`,
'aw-tenant-code': process.env.WS1_TENANT_CODE,
'Accept': 'application/json'
},
timeout: 20000
});
}
let token;
try {
token = await getMDMToken();
} catch (e) {
return { success: false, error: `Could not obtain MDM token: ${e.message}` };
}
try {
const resp = await doRequest(token);
return { success: true, status: resp.status };
} catch (err) {
const status = err.response?.status;
// One-shot retry on stale token
if (status === 401 || status === 403) {
logger.warn('MDM auth failed on reboot command — invalidating token and retrying once', { deviceId });
invalidateMDMToken();
try {
const freshToken = await getMDMToken(true);
const retryResp = await doRequest(freshToken);
return { success: true, status: retryResp.status };
} catch (retryErr) {
return {
success: false,
status: retryErr.response?.status,
error: retryErr.response?.data?.message || retryErr.response?.data?.errorCode || retryErr.message
};
}
}
return {
success: false,
status,
error: err.response?.data?.message || err.response?.data?.errorCode || err.message
};
}
}
module.exports = { getMDMDeviceBySerial, sendMDMRebootCommand };