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>
This commit is contained in:
jmcqueen 2026-07-01 17:43:53 -04:00
commit 025b70de56
11 changed files with 13995 additions and 0 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
node_modules
npm-debug.log
.env
.env.dev
.env.example
.git
.gitignore
*.md
*.log
.DS_Store
coverage
.nyc_output
.vscode
.idea

40
.env.example Normal file
View file

@ -0,0 +1,40 @@
# ============================================
# Appspace + Webex Alerts Service
# Copy this file to .env (prod) or .env.dev and fill in real values.
# Never commit real secrets.
# ============================================
# ---- Server ----
PORT=3000
NODE_ENV=production # or development
PUBLIC_HOST=https://your-public-host.example.com # used in logs only
# ---- Webex Bot (required) ----
WEBEX_BOT_TOKEN=your-webex-bot-access-token
WEBEX_ROOM_ID=your-webex-room-id
# ---- Appspace Webhook (from Appspace Outbound Webhooks) ----
WEBHOOK_SECRET=your-shared-secret-for-appspace-webhooks # optional but recommended; checked via x-webhook-secret or x-secret header
# ---- Appspace API (refresh token flow) ----
APPSPACE_INSTANCE_URL=https://your-instance.cloud.appspace.com
APPSPACE_SUBJECT_ID=your-application-subject-id
APPSPACE_REFRESH_TOKEN=your-long-lived-refresh-token
APPSPACE_API_BASE_URL=https://api.cloud.appspace.com # or your regional API base
APPSPACE_CONSOLE_BASE_URL=https://app3.cloud.appspace.com
# (Legacy / query-offline.js still references this static token style)
APPSPACE_API_TOKEN=your-static-token-if-needed
APPSPACE_BASE_URL=https://api.cloud.appspace.com
# ---- Workspace ONE MDM (for enrichment) ----
WS1_BASE_URL=https://as1991.awmdm.com # your WS1 server URL (used for API calls in mdm.js)
WS1_CONSOLE_BASE_URL=https://cn1896.awmdm.com # console base for per-device links (MUST be the console hostname like cn1896, NOT the API hostname like as1896 — links will be broken otherwise)
WS1_CLIENT_ID=your-oauth-client-id
WS1_CLIENT_SECRET=your-oauth-client-secret
WS1_TENANT_CODE=your-tenant-code
# ---- Debugging (optional) ----
DEBUG=false # enables verbose per-request / per-lookup logs (mdm lookups, ignores, command receipts, etc.)
DEBUG_WEBHOOK=false # set to "true" to log *full* Appspace webhook JSON payloads (avoid in prod - may contain sensitive data)
LOG_FORMAT=json # set to "json" (or NODE_ENV=production) for structured JSON logs suitable for Docker log aggregation (Loki, CloudWatch, etc.)

45
.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# ============================================
# Secrets — NEVER commit real values
# ============================================
.env
.env.*
!.env.example
# Common credential / key patterns (defense in depth)
*.pem
*.key
*.crt
*.pfx
*.p12
credentials*
secrets*
# ============================================
# Node
# ============================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# ============================================
# Runtime / build artifacts
# ============================================
*.log
logs/
coverage/
.nyc_output/
dist/
build/
# ============================================
# Editor / OS
# ============================================
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
*.swo
*~

25
Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files first for better layer caching
COPY --chown=node:node package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy the rest of the application code
COPY --chown=node:node . .
# Production environment
ENV NODE_ENV=production
# Run as non-root user (node user is provided by the base image, uid 1000)
USER node
EXPOSE 3000
# Pure Node.js healthcheck - does not depend on wget, curl, or other binaries
# that may not be present in minimal Alpine images. Respects PORT env var.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD node -e 'const h=require("http");const p=process.env.PORT||3000;const r=h.get("http://localhost:"+p+"/health",res=>process.exit(res.statusCode==200?0:1));r.on("error",()=>process.exit(1));r.setTimeout(2000,()=>{r.destroy();process.exit(1)})' || exit 1
CMD ["npm", "start"]

116
README.md Normal file
View file

@ -0,0 +1,116 @@
# Appspace Webex Alerts
Lightweight Node/Express service that bridges **Appspace** device health events to **Cisco Webex** via Adaptive Cards, with optional enrichment from **Workspace ONE MDM**.
It also runs an interactive Webex bot (WebSocket mode) for on-demand queries.
## What it does
- Listens for Appspace outbound webhooks (`DEVICE.HEALTHSTATUS.*` and `DEVICE.UNREGISTERED`).
- Ignores PWA devices.
- Enriches alerts with current MDM data (model, OS version, compliance, last sample/seen timestamps in EDT).
- Posts formatted Adaptive Cards to a configured Webex room (with direct links to both consoles).
- Provides a Webex bot with commands:
- `offline [optional filter]` — snapshot of currently offline/lost/failed devices (client-side filter on name or type).
- `help`
## Requirements
- Node 20+
- Appspace instance with outbound webhook + Application refresh token
- Webex bot token + room ID
- (Optional but recommended) Workspace ONE MDM OAuth client for enrichment
## Quick Start (Docker - recommended)
1. Copy env:
```bash
cp .env.example .env
# or .env.dev for the dev profile
```
2. Fill in the required values (see `.env.example` for descriptions).
3. Run:
```bash
# Production profile
npm run docker:prod
# Development (with live reload + volume mount)
npm run docker:dev
# Smoke test (builds image + verifies /health responds "healthy" inside container)
npm run docker:smoke
```
**Port mapping notes**: The container always listens internally on port 3000 (hardened default). Host port 1889 is used for both dev (`docker compose --profile dev up -d app-dev`) and prod (`docker compose up -d`). The `PORT` env inside the container is forced to 3000 via compose. You cannot run both profiles at the same time due to the shared host port.
Direct:
```bash
npm install
npm start
```
Health check: `GET /health`
## Environment Variables
See `.env.example` for the full documented list.
Key ones:
- `WEBEX_BOT_TOKEN`, `WEBEX_ROOM_ID`
- `APPSPACE_INSTANCE_URL`, `APPSPACE_SUBJECT_ID`, `APPSPACE_REFRESH_TOKEN`, `APPSPACE_API_BASE_URL`
- `WS1_*` (for MDM enrichment)
- `WEBHOOK_SECRET` (recommended for the Appspace webhook)
- `DEBUG`, `DEBUG_WEBHOOK` (see below)
## Bot Commands
In the Webex space where the bot is added:
- `offline` — current problematic devices
- `offline tablet` — filter to devices whose name or type contains "tablet"
- `help`
The bot runs in WebSocket mode (no public webhook required).
## Debugging
- `DEBUG=true` — verbose logging for MDM lookups, ignored events, command handling, etc. (very useful in dev, noisy in prod).
- `DEBUG_WEBHOOK=true` — log the **full** incoming Appspace webhook payload (contains device details; do **not** leave on in production).
- `LOG_FORMAT=json` (or `NODE_ENV=production`) — output structured JSON logs (ideal for Docker/K8s log collectors).
You can also set `NODE_ENV=development` for similar verbose behavior.
## Production & Docker Notes
- **Graceful shutdown**: The service handles `SIGTERM` (used by `docker stop`, Kubernetes, etc.) and `SIGINT`. It will:
1. Stop the Webex WebSocket framework (important to avoid "excessive device registrations").
2. Close the HTTP server.
3. Exit cleanly. A hard timeout forces exit after ~8s.
- **Healthcheck**: `/health` returns 200 with basic status. Used by Docker and orchestrators.
- **Logging**: Logs go to stdout/stderr (12-factor / Docker friendly). Use `LOG_FORMAT=json` or `NODE_ENV=production` for structured JSON. Use `DEBUG=true` in non-prod for detail. Pipe to a collector (Loki, CloudWatch, etc.) as needed.
- **Secrets**: Never bake secrets into the image. Use:
- `env_file` for compose (dev/staging only)
- Docker secrets, Kubernetes Secrets, or a secrets manager (Vault, AWS Secrets Manager) for production.
- **Ports**: Container always listens on 3000 internally. Map host ports as needed (see docker-compose.yml).
- **Non-root**: Production image runs as the `node` user.
- **Resources**: In production, set CPU/memory limits in your orchestrator. The bot command does a full device list scan (limit 500) — monitor for large fleets.
## Architecture Notes
- Appspace token uses refresh token + cooldown + safety buffer.
- MDM uses 24h serial→ID cache + fresh detail lookup by ID on every alert (for up-to-date compliance/last-seen).
- WebSocket mode for the bot avoids restart rate limits.
- All enrichment is best-effort; alerts are never blocked by MDM or token issues.
## License / Support
Internal tool. Tweak as needed.
## TODO / Future
- Server-side filtering for the devices list when the Appspace API supports it reliably.
- Metrics / full structured JSON logging.
- Support for more Appspace event types.
- Multi-stage Dockerfile for even smaller prod images.

51
docker-compose.yml Normal file
View file

@ -0,0 +1,51 @@
services:
app-dev:
build: .
container_name: appspace-webex-dev
ports:
- "1889:3000" # host 1889 → container 3000 (app always listens on 3000 inside)
env_file: .env.dev
environment:
- PORT=3000 # force internal listen port (overrides any PORT in .env.dev for container)
volumes:
- .:/app
- /app/node_modules
command: npm run dev
user: root # dev bind mounts often require root for host uid/perms; prod image uses non-root
restart: unless-stopped
profiles: ["dev"]
app-prod:
build: .
container_name: appspace-webex-prod
ports:
- "1889:3000" # host 1889 → container 3000 (app always listens on 3000 inside)
env_file: .env
environment:
- PORT=3000 # force internal listen port (overrides any PORT in .env for container)
restart: unless-stopped
# No `profiles:` key on this service, so `docker compose up -d` (or `docker compose up -d app-prod`) starts it by default.
# Use `docker compose --profile dev up -d app-dev` for development (only starts the dev service).
# Inherits non-root USER node from Dockerfile for security hardening
# Dockerfile HEALTHCHECK is automatically used by Docker
# Smoke test service: starts the prod image with minimal dummy envs
# (required vars are validated at startup). Used by `npm run docker:smoke`
# to verify the container builds, starts, and /health responds.
smoke-test:
build: .
container_name: appspace-smoke-test
environment:
- PORT=3000
- NODE_ENV=production
# Dummy values for required env vars (validation happens early)
- WEBEX_BOT_TOKEN=smoke-test-bot-token
- WEBEX_ROOM_ID=smoke-test-room-id
- APPSPACE_INSTANCE_URL=https://smoke.example.com
- APPSPACE_SUBJECT_ID=smoke-subject
- APPSPACE_REFRESH_TOKEN=smoke-refresh-token
- APPSPACE_API_BASE_URL=https://smoke.example.com
- SMOKE_TEST=true
# No ports published; we use docker inspect for the built-in healthcheck status.
profiles: ["smoke"]
# Uses the hardened Dockerfile (non-root, healthcheck, etc.)

875
index.js Normal file
View file

@ -0,0 +1,875 @@
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const Framework = require('webex-node-bot-framework');
const app = express();
const PORT = process.env.PORT || 3000;
// ======================
// STARTUP VALIDATION (fail fast on missing critical config)
// ======================
const REQUIRED_ENV = [
'WEBEX_BOT_TOKEN',
'WEBEX_ROOM_ID',
'APPSPACE_INSTANCE_URL',
'APPSPACE_SUBJECT_ID',
'APPSPACE_REFRESH_TOKEN',
'APPSPACE_API_BASE_URL'
];
const missing = REQUIRED_ENV.filter(k => !process.env[k]);
if (missing.length > 0) {
console.error('❌ Missing required environment variables:');
missing.forEach(k => console.error(` - ${k}`));
console.error(' See .env.example for details.');
process.exit(1);
}
// Logging configuration for Docker / production friendliness
const isVerbose = process.env.DEBUG === 'true' || process.env.NODE_ENV !== 'production';
const useJsonLogs = process.env.LOG_FORMAT === 'json' || process.env.NODE_ENV === 'production';
/**
* Minimal structured logger.
* - Human readable (with emojis) in dev
* - JSON lines when LOG_FORMAT=json or NODE_ENV=production (great for Docker log collectors)
*/
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); }
};
// Normalize whatever was passed as `meta` into a plain object suitable for JSON
// logging. Errors have no enumerable own properties, so JSON.stringify(err) ⇒ "{}".
// We pull out the useful bits (name/message/stack + axios response shape) so failures
// are actually visible in the log stream.
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 };
// Remove undefined
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}`);
}
}
// Will be assigned when the server starts listening
let server;
let framework;
// Graceful shutdown function (used by signals and crash handlers)
function shutdown(force = false) {
logger.info('🛑 Graceful shutdown initiated...');
const exit = (code = 0) => {
logger.info(`👋 Process exiting with code ${code}`);
process.exit(code);
};
// Stop the Webex framework (important for WebSocket mode to clean up device registration)
if (typeof framework !== 'undefined' && framework.stop) {
framework.stop().then(() => {
logger.info('✅ Webex framework stopped');
if (server) {
server.close(() => {
logger.info('✅ HTTP server closed');
exit(0);
});
} else {
exit(0);
}
}).catch((err) => {
logger.error('Error stopping framework:', err);
if (server) server.close(() => exit(1));
else exit(1);
});
} else if (server) {
server.close(() => {
logger.info('✅ HTTP server closed');
exit(0);
});
} else {
exit(0);
}
// Force exit after a timeout (Docker stop timeout is usually 10s)
if (!force) {
setTimeout(() => {
logger.error('⏱️ Graceful shutdown timed out. Forcing exit.');
exit(1);
}, 8000);
}
}
// Basic crash handlers for container environments (Docker/K8s will restart on non-zero exit)
// Uncaught is fatal -> shutdown.
// UnhandledRejection (e.g. from Webex framework with bad creds or transient issues) just log; don't kill the main server.
process.on('uncaughtException', (err) => {
logger.error('Uncaught Exception. Initiating shutdown...', err);
shutdown(true);
});
process.on('unhandledRejection', (reason) => {
// `reason` is most often an Error but can be anything; the logger normalizes it.
// If something rejected with a non-Error value, wrap it so we still get a stack-y view.
const payload = reason instanceof Error
? reason
: { reason: typeof reason === 'object' ? JSON.stringify(reason) : String(reason) };
logger.error('Unhandled Rejection (logged, continuing)...', payload);
// Do not call shutdown - keep the HTTP server running (e.g. Webex bot errors shouldn't kill webhook path)
});
app.use(bodyParser.json());
// ======================
// APPSPACE TOKEN MANAGEMENT (with cooldown + longer timeout)
// ======================
let currentAccessToken = null;
let tokenExpiresAt = 0;
let lastRefreshAttempt = 0;
let inFlightAppspaceTokenPromise = null;
function invalidateAppspaceToken() {
currentAccessToken = null;
tokenExpiresAt = 0;
}
async function getValidAccessToken() {
const now = Math.floor(Date.now() / 1000);
// Use cached token if still valid (2-minute safety buffer)
if (currentAccessToken && now < tokenExpiresAt - 120) {
return currentAccessToken;
}
// Coalesce concurrent callers (e.g. burst webhooks / bot commands at startup)
// so we never fire more than one refresh in parallel.
if (inFlightAppspaceTokenPromise) return inFlightAppspaceTokenPromise;
// Prevent hammering the endpoint (minimum 10 seconds between refresh attempts)
if (now - lastRefreshAttempt < 10) {
logger.debug('Token refresh attempted too recently — using last known token if available');
if (currentAccessToken) return currentAccessToken;
}
lastRefreshAttempt = now;
const instanceUrl = process.env.APPSPACE_INSTANCE_URL;
if (!instanceUrl) {
throw new Error('APPSPACE_INSTANCE_URL is not set in .env');
}
inFlightAppspaceTokenPromise = (async () => {
logger.info('Refreshing Appspace access token...');
try {
const response = await axios.post(`${instanceUrl}/api/v3/authorization/token`, {
subjectType: "Application",
subjectId: process.env.APPSPACE_SUBJECT_ID,
grantType: "refreshToken",
refreshToken: process.env.APPSPACE_REFRESH_TOKEN
}, {
headers: { 'Content-Type': 'application/json' },
timeout: 30000 // Increased to 30 seconds
});
const data = response.data;
currentAccessToken = data.accessToken;
tokenExpiresAt = Math.floor(Date.now() / 1000) + (data.expiresIn || 3600);
logger.info('Appspace access token refreshed successfully', { expiresIn: data.expiresIn || 3600 });
return currentAccessToken;
} catch (err) {
logger.error('Token refresh failed', { error: err.response?.data || err.message });
const status = err.response?.status;
if (status === 401 || status === 403) {
invalidateAppspaceToken();
}
if (err.code === 'ECONNABORTED') {
logger.warn('Request timed out. Appspace token endpoint may be rate-limited.');
}
throw new Error('Could not obtain valid Appspace access token. Try again in 30-60 seconds.');
}
})().finally(() => {
inFlightAppspaceTokenPromise = null;
});
return inFlightAppspaceTokenPromise;
}
// ======================
// UTILITIES
// ======================
/**
* Normalize and format MDM timestamps (LastSystemSampleTime, LastSeen, etc.)
* Handles missing 'Z' suffix from some WS1/Appspace responses and formats in EDT.
*/
function formatMdmTimestamp(ts) {
if (!ts) return 'Unknown';
let timestamp = ts.toString().trim();
// Some backends omit Z on what is effectively UTC; append if it looks like it needs it
if (!timestamp.endsWith('Z') && !timestamp.includes('+') && timestamp.includes('-')) {
timestamp += 'Z';
}
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
return timestamp;
}
return date.toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Extract a consistent set of MDM facts + Workspace ONE console link from a device record.
* Handles the quirky casing and nested Id.Value shape in WS1 responses.
* IMPORTANT: Use WS1_CONSOLE_BASE_URL (not WS1_BASE_URL) for the link base, as the API hostname (as....awmdm.com)
* is different from the console hostname (cn....awmdm.com) and will produce broken links.
*/
function buildMdmFactsAndLink(mdmDevice) {
if (!mdmDevice) {
return { mdmFacts: [], mdmConsoleLink: null };
}
const model = mdmDevice.Model || mdmDevice.model || 'Unknown';
const os = mdmDevice.OperatingSystem || 'Unknown';
const compliance = mdmDevice.ComplianceStatus || mdmDevice.complianceStatus || 'Unknown';
const lastSampleTime = formatMdmTimestamp(mdmDevice.LastSystemSampleTime);
const lastSeenDisplay = formatMdmTimestamp(mdmDevice.LastSeen);
const deviceId = mdmDevice.Id?.Value || mdmDevice.Uuid || mdmDevice.id || '';
let mdmConsoleLink = null;
if (deviceId) {
// Always use the console hostname (e.g. cn1896.awmdm.com), not the API hostname (e.g. as1896.awmdm.com)
// WS1_BASE_URL is the API server; WS1_CONSOLE_BASE_URL (or equivalent) is for the web UI links.
const ws1Base = process.env.WS1_CONSOLE_BASE_URL || 'https://cn1896.awmdm.com';
mdmConsoleLink = `${ws1Base}/AirWatch/#/AirWatch/Device/Details/Summary/${deviceId}`;
}
const mdmFacts = [];
if (model && model !== 'Unknown') mdmFacts.push({ "title": "Model", "value": model });
if (os && os !== 'Unknown') mdmFacts.push({ "title": "OS Version", "value": os });
if (compliance && compliance !== 'Unknown') mdmFacts.push({ "title": "Compliance", "value": compliance });
if (lastSampleTime && lastSampleTime !== 'Unknown') mdmFacts.push({ "title": "Last Sample (EDT)", "value": lastSampleTime });
if (lastSeenDisplay && lastSeenDisplay !== 'Unknown') mdmFacts.push({ "title": "Last Seen (EDT)", "value": lastSeenDisplay });
return { mdmFacts, mdmConsoleLink };
}
/**
* Construct the complete Adaptive Card payload for Webex.
* Keeps presentation logic out of the request handler.
*
* Note: Webex currently supports a maximum of Adaptive Cards 1.3
* (1.4+ is in the engineering backlog with no ETA as of 2025/2026).
* See: https://developer.webex.com/docs/buttons-and-cards
* Schema explorer: https://adaptivecards.io/explorer/
*/
function buildDeviceAlertCard({ eventKey, data, accentColor, emoji, appspaceLink, mdmFacts = [], mdmConsoleLink = null }) {
const adaptiveCard = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.3", // Webex currently supports a maximum of Adaptive Cards 1.3 (1.4+ is in backlog with no ETA)
"body": [
{
"type": "Container",
"style": accentColor,
"bleed": true,
"items": [
{ "type": "TextBlock", "text": `${emoji} Appspace Device Alert`, "weight": "bolder", "size": "medium", "wrap": true }
]
},
{
"type": "FactSet",
"facts": [
{ "title": "Event", "value": eventKey },
{ "title": "Device", "value": data.deviceName || 'Unknown' },
{ "title": "Location", "value": data.locationName || 'Unknown' },
{ "title": "Type", "value": data.deviceType || 'N/A' },
{ "title": "IP", "value": data.ipAddress || 'N/A' },
{ "title": "Serial", "value": data.serialNumber || 'N/A' }
],
"spacing": "Small"
}
],
"actions": [
{ "type": "Action.OpenUrl", "title": "🔗 Appspace Console", "url": appspaceLink }
]
};
if (mdmFacts.length > 0) {
adaptiveCard.body.push({
"type": "Container",
"separator": true,
"items": [
{ "type": "TextBlock", "text": "**MDM Status**", "weight": "bolder", "size": "medium", "wrap": true, "color": "Accent", "spacing": "Small" },
{ "type": "FactSet", "facts": mdmFacts }
]
});
if (mdmConsoleLink) {
adaptiveCard.actions.push({
"type": "Action.OpenUrl",
"title": "🔗 Workspace ONE Console",
"url": mdmConsoleLink
});
}
}
// If no MDM data, we intentionally omit any warning block to keep the card compact and focused on the alert.
return adaptiveCard;
}
/**
* Normalize health/status field from Appspace device objects.
*/
function getDeviceHealthStatus(device) {
if (!device) return '';
return (device.status || device.healthStatus || '').toString().toUpperCase().trim();
}
/**
* Returns true for devices that are offline/lost/failed.
* Shared logic for bot queries (and potentially future webhook use).
*/
function isProblemDevice(device) {
const status = getDeviceHealthStatus(device);
return ['OFFLINE', 'LOSTCOMMUNICATION', 'FAILED'].includes(status);
}
/**
* Build a clean Markdown table for offline device list.
* Much more reliable than fixed-width ASCII in Webex (and other clients).
*/
// ======================
// HEALTHCHECK
// ======================
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
environment: process.env.NODE_ENV || 'production',
timestamp: new Date().toISOString()
});
});
// ======================
// APPSPACE WEBHOOK → Alerts (now includes UNREGISTERED)
// ======================
const { getMDMDeviceBySerial, sendMDMRebootCommand } = require('./mdm');
/**
* Query Appspace for current problem devices (Offline / LostCommunication / Failed),
* optionally narrowed by a free-text filter that matches deviceType or name.
* Shared between `offline` and `restart-offline` so they always agree on what
* counts as offline.
*
* Returns { devices, offlineDevices, apiBaseUrl } where:
* - devices: raw page returned by Appspace (used to detect 500-row truncation)
* - offlineDevices: filtered list of problem devices matching filterArg
* - apiBaseUrl: resolved base URL (for diagnostic logging in callers)
*/
async function fetchOfflineDevices(filterArg = '') {
const apiBaseUrl = process.env.APPSPACE_API_BASE_URL || process.env.APPSPACE_INSTANCE_URL || 'https://api.cloud.appspace.com';
const accessToken = await getValidAccessToken();
const response = await axios.get(`${apiBaseUrl}/api/v3/devices`, {
headers: { Authorization: `Bearer ${accessToken}` },
params: {
limit: 500,
healthStatus: 'Offline,LostCommunication,Failed',
status: 'Offline,LostCommunication,Failed'
},
timeout: 15000
});
const devices = response.data?.items || response.data || [];
let offlineDevices = devices.filter(isProblemDevice);
if (filterArg) {
const search = filterArg.toLowerCase().trim();
offlineDevices = offlineDevices.filter(d => {
const type = (d.deviceType || '').toLowerCase();
const name = (d.name || d.deviceName || '').toLowerCase();
return type.includes(search) || name.includes(search);
});
}
return { devices, offlineDevices, apiBaseUrl };
}
app.post('/webhook', async (req, res) => {
const payload = req.body;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
if (WEBHOOK_SECRET) {
const receivedSecret = req.headers['x-webhook-secret'] || req.headers['x-secret'];
if (receivedSecret !== WEBHOOK_SECRET) {
logger.warn('Invalid secret header');
return res.status(401).send('Unauthorized');
}
}
const webhookType = payload.webhookType || '';
const data = payload.data || {};
// Log full payload only when debugging (can contain sensitive device/user data)
if (process.env.DEBUG_WEBHOOK === 'true' || process.env.NODE_ENV !== 'production') {
logger.debug('Appspace Webhook Received (full payload)', { payload });
} else {
logger.info('Appspace Webhook Received', { event: webhookType, device: data.deviceName || data.serialNumber || 'unknown' });
}
// ======================
// FILTER OUT PWA DEVICES
// ======================
if (data.deviceType === 'PWA' || data.deviceType?.toUpperCase() === 'PWA') {
if (isVerbose) logger.debug('Ignoring PWA device alert', { device: data.deviceName });
return res.status(200).send('Ignored PWA');
}
if (!webhookType.startsWith('DEVICE.HEALTHSTATUS.') && webhookType !== 'DEVICE.UNREGISTERED') {
if (isVerbose) logger.debug('Non-relevant event ignored');
return res.status(200).send('Ignored');
}
const eventKey = webhookType.replace('DEVICE.', '').replace('HEALTHSTATUS.', '').toUpperCase();
const isUnregistered = webhookType === 'DEVICE.UNREGISTERED';
const isProblem = isUnregistered || ['LOSTCOMMUNICATION', 'OFFLINE', 'FAILED'].includes(eventKey);
const accentColor = isProblem ? 'attention' : 'good';
const emoji = isUnregistered ? '🚫' : (isProblem ? '🔴' : '🟢');
const consoleBase = process.env.APPSPACE_CONSOLE_BASE_URL || 'https://app3.cloud.appspace.com';
const appspaceLink = `${consoleBase}/console/devices/details/overview?id=${data.deviceId}`;
// Enrich with Workspace ONE MDM (graceful; never blocks the alert)
const mdmDevice = await getMDMDeviceBySerial(data.serialNumber).catch(() => null);
const { mdmFacts, mdmConsoleLink } = buildMdmFactsAndLink(mdmDevice);
if (mdmDevice && mdmFacts.length > 0 && isVerbose) {
logger.debug('MDM data added', { device: data.deviceName });
}
const adaptiveCard = buildDeviceAlertCard({
eventKey,
data,
accentColor,
emoji,
appspaceLink,
mdmFacts,
mdmConsoleLink
});
try {
await axios.post('https://webexapis.com/v1/messages', {
roomId: process.env.WEBEX_ROOM_ID,
text: `${eventKey} on ${data.deviceName}`,
attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", content: adaptiveCard }]
}, {
headers: { Authorization: `Bearer ${process.env.WEBEX_BOT_TOKEN}`, 'Content-Type': 'application/json' }
});
logger.info(`${eventKey} enriched card sent to Webex`);
} catch (err) {
logger.error('Failed to send card', { error: err.response?.data || err.message });
}
res.status(200).send('OK');
});
// ======================
// WEBEX BOT FRAMEWORK SETUP — WEBSOCKET MODE
// ======================
// Using WebSocket mode (no webhookUrl) to avoid public endpoint + rate-limit issues on restarts.
// Skipped in smoke tests (dummy token would cause noisy unhandled rejections; we only need the HTTP server + health for the smoke).
if (process.env.SMOKE_TEST !== 'true') {
framework = new Framework({
token: process.env.WEBEX_BOT_TOKEN,
});
framework.start();
framework.on('initialized', () => {
logger.info('Webex Bot Framework initialized (WebSocket mode)');
});
framework.on('spawn', (bot, id, addedBy) => {
if (isVerbose) {
if (addedBy) {
logger.debug('Bot added to new space', { addedBy });
} else {
logger.debug('Bot loaded space', { title: bot.room?.title || 'Unknown' });
}
}
});
if (isVerbose) {
logger.debug('Webex Bot Framework starting in WebSocket mode...');
}
/**
* Extract the trailing filter portion of a command message, regardless of whether
* the message came from a DM (`offline ios`) or a group-space mention
* (`@appspace offline ios`).
*
* The framework sets `trigger.args = trigger.text.split(' ')`, and in group-space
* mentions `trigger.text` still has the bot's display name at the start. So we can't
* just `slice(1)` that would take everything after the display name (including
* the command word itself) and treat it as the filter.
*
* Strategy: find the command word (case-insensitively) in the args array, then
* everything after it is the filter.
*/
function extractFilterArg(trigger, commandWord) {
const args = trigger.args || [];
const idx = args.findIndex(a => (a || '').toLowerCase() === commandWord.toLowerCase());
if (idx === -1 || idx === args.length - 1) return '';
return args.slice(idx + 1).join(' ').toLowerCase().trim();
}
// NOTE: keep as a string phrase (NOT a regex). The framework compiles string
// phrases into `/(^| )<phrase>($| )/i`, which:
// - correctly matches after the bot's display name in group-space mentions
// (e.g. `@appspace offline ios` → trigger.text = "Appspace offline ios")
// - uses SPACE delimiters (not `\b`), so it will NOT match the substring
// "offline" inside "restart-offline" (preceded by `-`, not space).
// A regex like /^offline\b/i is tested directly against trigger.text and would
// fail on any mentioned message because the display name comes first.
framework.hears('offline', async (bot, trigger) => {
if (isVerbose) {
logger.debug('offline command received', { user: trigger.person?.displayName || 'Unknown' });
}
const filterArg = extractFilterArg(trigger, 'offline');
await bot.say({ markdown: '🔍 Querying current offline / lost devices from Appspace...' });
// Pre-resolve for diagnostic logging in the catch block.
let apiBaseUrl = process.env.APPSPACE_API_BASE_URL || process.env.APPSPACE_INSTANCE_URL || 'https://api.cloud.appspace.com';
try {
const fetched = await fetchOfflineDevices(filterArg);
const { devices, offlineDevices } = fetched;
apiBaseUrl = fetched.apiBaseUrl;
if (offlineDevices.length === 0) {
const msg = filterArg
? `✅ No **${filterArg}** devices are currently offline.`
: '✅ All devices are currently online or in sync.';
return bot.say({ markdown: msg });
}
const consoleBase = process.env.APPSPACE_CONSOLE_BASE_URL || 'https://app3.cloud.appspace.com';
const consoleUrl = `${consoleBase}/console/devices`;
// Enrich the devices we will display (cap at 30) with MDM + per-device Appspace links.
// Using a rich Markdown list (instead of table) so everything is consolidated per device
// and links render properly as clickable items.
const MAX_ROWS = 30;
const displayDevices = offlineDevices.slice(0, MAX_ROWS);
const enriched = await Promise.all(displayDevices.map(async (d) => {
const mdmDevice = await getMDMDeviceBySerial(d.serialNumber).catch(() => null);
let mdmInfo = '';
let mdmConsoleLink = null;
if (mdmDevice) {
const { mdmFacts, mdmConsoleLink: link } = buildMdmFactsAndLink(mdmDevice);
mdmConsoleLink = link;
if (mdmFacts.length > 0) {
mdmInfo = mdmFacts.map(f => `${f.title}: ${f.value}`).join(' | ');
}
}
const devId = d.deviceId || d.id || '';
const appspaceLink = devId ? `${consoleBase}/console/devices/details/overview?id=${devId}` : null;
return {
...d,
mdmInfo,
mdmConsoleLink,
appspaceLink
};
}));
// Group by location for better readability. Each device gets:
// - Name + basic Appspace info on first line (under bullet)
// - MDM info on second indented line
// - Links on third indented line
// No deep nesting; only bullet the device line, indent the rest.
const grouped = {};
enriched.forEach(e => {
const loc = e.locationName || 'Unknown Location';
if (!grouped[loc]) grouped[loc] = [];
grouped[loc].push(e);
});
// Webex caps a single message at 7439 characters before encryption.
// Build the body incrementally and stop appending devices once we approach the
// budget, leaving headroom for the header + console-link footer + a truncation note.
// The user can always click through to the full console list.
const WEBEX_BODY_BUDGET = 6500;
const filterNote = filterArg ? ` (filtered: ${filterArg})` : '';
const limitNote = devices.length >= 500 ? ' (results may be truncated — see console for full list)' : '';
let devicesList = '';
let renderedCount = 0;
let truncated = false;
outer: for (const loc of Object.keys(grouped).sort()) {
const locHeader = `**${loc}**\n`;
// If even the location header won't fit, stop entirely.
if (devicesList.length + locHeader.length > WEBEX_BODY_BUDGET) {
truncated = true;
break;
}
devicesList += locHeader;
for (const e of grouped[loc]) {
const name = (e.name || e.deviceName || 'Unknown');
const status = getDeviceHealthStatus(e) || 'Unknown';
const ip = e.ipAddress || 'N/A';
const type = e.deviceType || '—';
let entry = `- **${name}** (${status}) - IP: ${ip} Type: ${type}\n`;
entry += e.mdmInfo ? ` MDM: ${e.mdmInfo}\n` : ` No MDM data\n`;
let linksLine = '';
if (e.appspaceLink) linksLine += `[🔗 Appspace Console](${e.appspaceLink})`;
if (e.mdmConsoleLink) {
if (linksLine) linksLine += ' ';
linksLine += `[🔗 Workspace ONE Console](${e.mdmConsoleLink})`;
}
if (linksLine) entry += ` ${linksLine}\n`;
entry += '\n';
if (devicesList.length + entry.length > WEBEX_BODY_BUDGET) {
truncated = true;
break outer;
}
devicesList += entry;
renderedCount++;
}
}
// Truncation note covers both (a) enrichment cap (MAX_ROWS < total offline)
// and (b) character-budget truncation that stopped us mid-render.
const notShown = offlineDevices.length - renderedCount;
if (notShown > 0 || truncated) {
devicesList += `... (${notShown} more not shown, see full list in Appspace Console)\n`;
}
const message = `**📊 Offline Devices Snapshot (${offlineDevices.length})${filterNote}${limitNote}**\n\n` +
devicesList +
`[🔗 Open Devices in Appspace Console](${consoleUrl})`;
if (isVerbose) {
logger.debug(`Offline query returned ${offlineDevices.length} matching devices`);
}
await bot.say({ markdown: message });
} catch (err) {
const attemptedUrl = `${apiBaseUrl}/api/v3/devices`;
logger.error('Offline query failed', {
attemptedUrl,
error: err.message,
code: err.code
});
bot.say({ markdown: `⚠️ Failed to query offline devices.\n\n${err.message || 'Unknown error'}` });
}
});
// restart-offline [filter]
// For every currently-offline (Offline / LostCommunication / Failed) Appspace device
// whose serial maps to a Workspace ONE record, send a SoftReset (reboot) command.
// Hard-capped at MAX_RESTART_BATCH per invocation; use a filter to narrow further.
// Always re-queries Appspace at execution time, so devices that came back online
// since the user last looked will NOT be rebooted.
// See note on the 'offline' handler above: string phrases handle mentions
// correctly (framework wraps them as `(^| )restart-offline($| )/i`).
framework.hears('restart-offline', async (bot, trigger) => {
const MAX_RESTART_BATCH = 50;
const CONCURRENCY = 3;
const filterArg = extractFilterArg(trigger, 'restart-offline');
const userLabel = trigger.person?.emails?.[0] || trigger.person?.displayName || 'Unknown';
logger.info('restart-offline invoked', { user: userLabel, filter: filterArg || '(none)' });
await bot.say({ markdown: `🔁 Querying current offline devices${filterArg ? ` matching \`${filterArg}\`` : ''} from Appspace...` });
let offlineDevices;
try {
const fetched = await fetchOfflineDevices(filterArg);
offlineDevices = fetched.offlineDevices;
} catch (err) {
logger.error('restart-offline: Appspace query failed', err);
return bot.say({ markdown: `⚠️ Failed to query offline devices from Appspace.\n\n${err.message || 'Unknown error'}` });
}
if (offlineDevices.length === 0) {
return bot.say({ markdown: filterArg
? `✅ No **${filterArg}** devices are currently offline. Nothing to restart.`
: '✅ No devices are currently offline. Nothing to restart.' });
}
if (offlineDevices.length > MAX_RESTART_BATCH) {
return bot.say({ markdown:
`🛑 **${offlineDevices.length}** offline devices match — that exceeds the safety cap of **${MAX_RESTART_BATCH}** per invocation.\n\n` +
`Please narrow with a filter (e.g. \`restart-offline ios\`, \`restart-offline windows\`, or part of a device name) and try again.`
});
}
// Resolve each offline device to its WS1 numeric Id (needed for the SoftReset command).
const candidates = await Promise.all(offlineDevices.map(async (d) => {
const mdmDevice = await getMDMDeviceBySerial(d.serialNumber).catch(() => null);
return {
name: d.name || d.deviceName || 'Unknown',
location: d.locationName || 'Unknown',
serial: d.serialNumber || '(no serial)',
mdmId: mdmDevice?.Id?.Value || null
};
}));
const withMdm = candidates.filter(c => c.mdmId);
const withoutMdm = candidates.filter(c => !c.mdmId);
if (withMdm.length === 0) {
return bot.say({ markdown:
`⚠️ Found **${offlineDevices.length}** offline device(s), but none have a Workspace ONE record (matched by serial). Nothing to restart.`
});
}
await bot.say({ markdown:
`🔁 Sending **SoftReset** to **${withMdm.length}** device(s) via Workspace ONE...` +
(withoutMdm.length > 0 ? `\n_(${withoutMdm.length} offline device(s) have no WS1 record — skipping those.)_` : '')
});
// Bounded concurrency so we don't fire 50 reboots at WS1 in parallel.
const results = [];
for (let i = 0; i < withMdm.length; i += CONCURRENCY) {
const batch = withMdm.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(batch.map(async (c) => {
const r = await sendMDMRebootCommand(c.mdmId);
return { ...c, ...r };
}));
results.push(...batchResults);
}
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
logger.info('restart-offline executed', {
user: userLabel,
filter: filterArg || '(none)',
matched: offlineDevices.length,
withMdm: withMdm.length,
withoutMdm: withoutMdm.length,
succeeded: successes.length,
failed: failures.length
});
// Webex 7439-char limit applies here too. Keep the report compact.
const WEBEX_BODY_BUDGET = 6500;
let msg = `**🔁 Restart Complete**${filterArg ? ` _(filter: \`${filterArg}\`)_` : ''}\n\n` +
`- Offline matched: **${offlineDevices.length}**\n` +
`- Restart command sent: **${successes.length}**\n` +
(failures.length > 0 ? `- Failed: **${failures.length}**\n` : '') +
(withoutMdm.length > 0 ? `- Skipped (no WS1 record): **${withoutMdm.length}**\n` : '') +
`\n_Note: WS1 only queues the command; an iOS device must be Supervised for SoftReset to actually execute._\n`;
if (failures.length > 0) {
msg += `\n**Failures:**\n`;
let failureLines = '';
let shown = 0;
for (const f of failures) {
const line = `- ${f.name} (${f.serial}): ${f.error || `HTTP ${f.status || 'unknown'}`}\n`;
if (msg.length + failureLines.length + line.length > WEBEX_BODY_BUDGET) break;
failureLines += line;
shown++;
}
msg += failureLines;
if (shown < failures.length) {
msg += `- ... and ${failures.length - shown} more failure(s) (see service logs)\n`;
}
}
if (withoutMdm.length > 0 && msg.length < WEBEX_BODY_BUDGET - 500) {
msg += `\n**Skipped (no WS1 record):**\n`;
let skipLines = '';
let shown = 0;
for (const s of withoutMdm) {
const line = `- ${s.name} at ${s.location} (serial: ${s.serial})\n`;
if (msg.length + skipLines.length + line.length > WEBEX_BODY_BUDGET) break;
skipLines += line;
shown++;
}
msg += skipLines;
if (shown < withoutMdm.length) {
msg += `- ... and ${withoutMdm.length - shown} more (see service logs)\n`;
}
}
await bot.say({ markdown: msg });
});
framework.hears('help', (bot) => {
bot.say({ markdown:
'**Commands:**\n' +
'• `offline [filter]` — Current offline / Lost / Failed devices from Appspace (optional name/type filter, e.g. `offline ios`)\n' +
'• `restart-offline [filter]` — Send a SoftReset (reboot) via Workspace ONE to every currently offline device that has a WS1 record. Optional filter narrows the set. Capped at 50 per invocation.\n' +
'• `help` — This message\n\n' +
'See README for setup, Docker usage, and debug flags.'
});
});
}
server = app.listen(PORT, () => {
logger.info(`🚀 Server listening on port ${PORT}`);
logger.info(` Appspace webhook: POST /webhook`);
logger.info(` Webex bot: WebSocket mode (no public HTTP webhook registered)`);
if (process.env.PUBLIC_HOST) {
logger.info(` Public host: ${process.env.PUBLIC_HOST}`);
}
logger.info(' Press Ctrl+C or send SIGTERM for graceful shutdown (Docker-friendly)');
});
// Register signal handlers for graceful shutdown (critical for Docker and orchestrators)
process.on('SIGTERM', () => {
logger.info('📡 Received SIGTERM (Docker stop / orchestrator)');
shutdown();
});
process.on('SIGINT', () => {
logger.info('📡 Received SIGINT (Ctrl+C)');
shutdown();
});

304
mdm.js Normal file
View file

@ -0,0 +1,304 @@
// 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 };

12372
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "appspace-webex-alerts",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js",
"offline:legacy": "node query-offline.js",
"docker:dev": "docker compose --profile dev up app-dev",
"docker:prod": "docker compose up -d",
"docker:smoke": "echo '🚀 Running Docker smoke test (build + health check)...' && docker compose --profile smoke build smoke-test && docker compose --profile smoke up -d smoke-test && (for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do status=$(docker inspect --format='{{.State.Health.Status}}' appspace-smoke-test 2>/dev/null || echo 'none'); if [ \"$status\" = 'healthy' ]; then echo '✅ Smoke test PASSED: container healthcheck is healthy'; success=1; break; fi; sleep 1; done; if [ -z \"$success\" ]; then echo \"❌ Smoke test FAILED: final health status was $status\"; docker compose --profile smoke logs --tail=100 smoke-test; false; fi) ; docker compose --profile smoke down --remove-orphans smoke-test 2>/dev/null || true"
},
"dependencies": {
"axios": "^1.7.2",
"body-parser": "^1.20.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"webex-node-bot-framework": "^2.5.1"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

130
query-offline.js Normal file
View file

@ -0,0 +1,130 @@
/**
* DEPRECATED / LEGACY
*
* This standalone script is no longer the recommended way to query offline devices.
*
* Use the Webex bot command instead:
* In your Webex space: type `offline` (or `offline some-filter`)
*
* The main server (index.js) now provides a much better implementation:
* - Uses the Appspace refresh token flow (not static APPSPACE_API_TOKEN)
* - Shares token management with the webhook path
* - Has filtering, better table output, and MDM enrichment in other flows
*
* This file is kept only for emergency/audit use. It uses older env var conventions
* (APPSPACE_BASE_URL + APPSPACE_API_TOKEN) and performs a one-shot query then exits.
*
* Consider removing this file once the bot command has been validated in production.
*/
require('dotenv').config();
const axios = require('axios');
const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN;
const WEBEX_ROOM_ID = process.env.WEBEX_ROOM_ID;
const APPSPACE_API_TOKEN = process.env.APPSPACE_API_TOKEN; // Legacy static token
const APPSPACE_API_BASE_URL = process.env.APPSPACE_BASE_URL || 'https://api.cloud.appspace.com';
if (!WEBEX_BOT_TOKEN || !WEBEX_ROOM_ID || !APPSPACE_API_TOKEN) {
console.error('❌ Missing required env vars: WEBEX_BOT_TOKEN, WEBEX_ROOM_ID, APPSPACE_API_TOKEN');
process.exit(1);
}
console.warn('⚠️ Running DEPRECATED query-offline.js script. Prefer the "offline" command via the Webex bot.');
async function queryOfflineDevices() {
try {
console.log('🔍 Querying Appspace for offline / lost devices...');
// Example API call - adjust endpoint based on your exact API docs
// Common pattern: GET /devices with filters for health status
const response = await axios.get(`${APPSPACE_API_BASE_URL}/api/v3/devices`, {
headers: {
Authorization: `Bearer ${APPSPACE_API_TOKEN}`,
'Content-Type': 'application/json'
},
params: {
// Filter examples (test and refine in Postman first)
healthStatus: 'LostCommunication,Offline,Failed', // or use separate calls if needed
limit: 200,
// locationId: 'optional-filter',
// include: 'location,group'
}
});
const devices = response.data.items || response.data; // adjust based on actual response shape
const offlineDevices = devices.filter(d =>
['LostCommunication', 'Offline', 'Failed'].includes(d.healthStatus || d.status)
);
if (offlineDevices.length === 0) {
await sendToWebex('✅ **All devices are currently online or in sync.** No offline devices detected.');
console.log('✅ No offline devices');
return;
}
// Build a nice Adaptive Card summary
const facts = offlineDevices.map(d => ({
title: d.name || d.deviceName || 'Unknown',
value: `${d.healthStatus || d.status}${d.locationName || 'No location'} • IP: ${d.ipAddress || 'N/A'}`
}));
const adaptiveCard = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.3",
"body": [
{
"type": "Container",
"style": "attention",
"bleed": true,
"items": [
{ "type": "TextBlock", "text": `📊 Offline Devices Snapshot (${offlineDevices.length})`, "weight": "bolder", "size": "medium" }
]
},
{
"type": "FactSet",
"facts": facts
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "🔗 Open Devices in Appspace Console",
"url": "https://app3.cloud.appspace.com/console/#!/devices"
}
]
};
await sendToWebex(null, adaptiveCard);
console.log(`✅ Sent ${offlineDevices.length} offline devices to Webex`);
} catch (err) {
console.error('❌ Error querying offline devices:', err.response?.data || err.message);
await sendToWebex('⚠️ Failed to query offline devices. Check API token and console.');
}
}
async function sendToWebex(text, card = null) {
const payload = {
roomId: WEBEX_ROOM_ID,
text: text || 'Appspace Offline Devices Report'
};
if (card) {
payload.attachments = [{
contentType: "application/vnd.microsoft.card.adaptive",
content: card
}];
}
await axios.post('https://webexapis.com/v1/messages', payload, {
headers: {
Authorization: `Bearer ${WEBEX_BOT_TOKEN}`,
'Content-Type': 'application/json'
}
});
}
queryOfflineDevices();