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>
304 lines
No EOL
10 KiB
JavaScript
304 lines
No EOL
10 KiB
JavaScript
// 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 }; |