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:
commit
025b70de56
11 changed files with 13995 additions and 0 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
40
.env.example
Normal 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
45
.gitignore
vendored
Normal 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
25
Dockerfile
Normal 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
116
README.md
Normal 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
51
docker-compose.yml
Normal 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
875
index.js
Normal 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
304
mdm.js
Normal 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
12372
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal 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
130
query-offline.js
Normal 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();
|
||||||
Loading…
Reference in a new issue