collabSupport/services/vcProvisionService.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

469 lines
No EOL
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// services/vcProvisionService.js
// Supports /provision-vc (renamed from /vcprovision) and multiple organizations via optional org param.
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import fs from 'node:fs';
import axios from 'axios';
import digicert from '../integrations/digicert/DigiCertClient.js';
import webex from '../integrations/webex/WebexClient.js';
import { logger } from '../utils/logger.js';
const execAsync = promisify(exec);
const ROOT_CERTS_PATH = './storage/aeoroots.cer';
/**
* Resolve org identifier (partial name or ID) to full org ID.
* Fetches from /organizations if needed.
*/
async function resolveOrgId(identifier) {
if (!identifier) return null;
// If it looks like a full org ID, use as-is
if (identifier.startsWith('Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8')) {
return identifier;
}
logger('vc-provision', `Resolving org identifier: ${identifier}`);
const orgsResponse = await webex.request('GET', 'organizations');
const orgs = orgsResponse.items || [];
const lowerId = identifier.toLowerCase();
const match = orgs.find(org =>
org.id === identifier ||
org.displayName.toLowerCase().includes(lowerId)
);
if (!match) {
const available = orgs.map(o => `${o.displayName} (${o.id})`).join(', ');
throw new Error(`Organization not found for "${identifier}". Available: ${available}`);
}
logger('vc-provision', `Resolved org: ${match.displayName} (${match.id})`);
return match.id;
}
export async function provisionVideoDevice(bot, serialNumber, orgIdentifier = null) {
if (!serialNumber) throw new Error('Serial number is required');
const deviceName = serialNumber;
logger('vc-provision', `Starting full provisioning for ${deviceName}`);
try {
await bot.say('markdown', `🚀 Starting provisioning for device **${deviceName}**...\nThis may take 36 minutes.`);
// Resolve orgId if orgIdentifier provided (name or id)
let orgId = null;
if (orgIdentifier) {
orgId = await resolveOrgId(orgIdentifier);
await bot.say('markdown', `📍 Using organization: ${orgId}`);
}
// 1. Find device
await bot.say('markdown', '🔍 Finding device in Webex...');
const params = { serial: serialNumber };
if (orgId) params.orgId = orgId;
const devicesResponse = await webex.request('GET', 'devices', null, params);
if (!devicesResponse.items || devicesResponse.items.length === 0) {
throw new Error(`No device found with serial ${serialNumber}`);
}
if (devicesResponse.items.length > 1) {
throw new Error(`Multiple devices found with serial ${serialNumber}`);
}
const device = devicesResponse.items[0];
logger('vc-provision', `Found device: ${device.displayName || device.serial}`);
await bot.say('markdown', `✅ Device found: **${device.displayName || device.serial}**`);
// 2. Get location
await bot.say('markdown', '📍 Retrieving location details...');
const location = await webex.request('GET', `locations/${device.locationId}`);
// 3. Apply standard configuration FIRST
await bot.say('markdown', '⚙️ Applying standard configuration...');
await applyStandardConfiguration(bot, device.id);
// 4. Generate Private Key + CSR
await bot.say('markdown', '🔑 Generating private key and CSR...');
const { privateKeyPem, csrPem, commonName } = await generateCSR(device, location);
const csrBase64 = csrPem
.replace(/-----BEGIN CERTIFICATE REQUEST-----/g, '')
.replace(/-----END CERTIFICATE REQUEST-----/g, '')
.replace(/\r?\n/g, '')
.trim();
// 5. Enroll with DigiCert
await bot.say('markdown', '📡 Submitting CSR to DigiCert...');
const enrollResponse = await digicert.enrollCertificate(csrBase64, commonName, device.ip || '0.0.0.0');
const requestId = enrollResponse.request_id;
if (!requestId) throw new Error('No request_id returned from DigiCert');
// 6. Poll for certificate
await bot.say('markdown', '⏳ Waiting for certificate from DigiCert...');
const certPem = await pollAndPickupCertificate(bot, requestId);
// 7. Apply certificates
await bot.say('markdown', '📥 Installing certificate...');
await applyCertificatesToDevice(bot, device.id, privateKeyPem, certPem);
// 8. Add backdoor admin account
await bot.say('markdown', '🔑 Adding backdoor admin account (`monitor`)...');
await addBackdoorAdmin(bot, device.id);
// 9. Final reboot
await bot.say('markdown', '🔄 Rebooting device...');
await webex.request('POST', `xapi/command/SystemUnit.Boot`, {
deviceId: device.id,
arguments: { Action: 'Restart', Force: 'True' }
});
await bot.say('markdown',
`🎉 **Provisioning completed successfully!**\n\n` +
`**Device:** ${device.displayName || device.serial}\n` +
`**Common Name:** ${commonName}\n\n` +
`• Certificate installed and activated\n` +
`• Standard configuration applied\n` +
`• Backdoor admin account added\n` +
`• Device rebooted`
);
logger('vc-provision', `✅ Full provisioning completed for ${deviceName}`);
} catch (error) {
logger('vc-provision', `Provisioning failed: ${error.message}`, 'error');
await bot.say('markdown', `❌ **Provisioning failed**\n\n${error.message}`);
throw error;
}
}
// ==================== STANDARD CONFIGURATION WITH 429 HANDLING ====================
async function applyStandardConfiguration(bot, deviceId) {
const configSettings = [
{ path: "HttpClient.Mode", value: "On" },
{ path: "HttpClient.UseHttpProxy", value: "Off" },
{ path: "NetworkServices.Websocket", value: "FollowHTTPService" },
{ path: "Proximity.Services.CallControl", value: "Enabled" },
{ path: "RoomAnalytics.AmbientNoiseEstimation.Mode", value: "On" },
{ path: "RoomAnalytics.PeopleCountOutOfCall", value: "On" },
{ path: "RoomAnalytics.PeoplePresenceDetector", value: "On" },
{ path: "RoomAnalytics.ReverberationTime.Mode", value: "On" },
{ path: "Standby.Delay", value: 10 },
{ path: "Standby.Signage.Mode", value: "Off" },
{ path: "Standby.Signage.Url", value: "https://app.onfirstup.com/embed/9f3b4f1b-4acc-4076-aafe-42d09f3ed384" },
{ path: "Time.DateFormat", value: "MM_DD_YY" },
{ path: "Time.TimeFormat", value: "12H" },
{ path: "UserInterface.Features.Call.JoinGoogleMeet", value: "Auto" },
{ path: "UserInterface.Features.Call.JoinMicrosoftTeamsDirectGuestJoin", value: "Auto" },
{ path: "UserInterface.Features.Call.JoinWebex", value: "Auto" },
{ path: "UserInterface.Features.Call.JoinZoom", value: "Auto" },
{ path: "UserInterface.Theme.Name", value: "Night" },
{ path: "WebEngine.Mode", value: "On" },
{ path: "WebEngine.MinimumTLSVersion", value: "TLSv1.2" },
{ path: "WebRTC.Provider.MicrosoftTeams.CompatibilityMode", value: "On" },
// QoS
{ path: "Network[1].QoS.Diffserv.Audio", value: 46 },
{ path: "Network[1].QoS.Diffserv.Data", value: 34 },
{ path: "Network[1].QoS.Diffserv.Signalling", value: 24 },
{ path: "Network[1].QoS.Diffserv.Video", value: 34 },
];
let successCount = 0;
await bot.say('markdown', `⚙️ Applying **${configSettings.length}** configuration settings...`);
for (const setting of configSettings) {
let attempt = 0;
const maxRetries = 3;
while (attempt < maxRetries) {
try {
const patchOperation = {
op: "replace",
path: `${setting.path}/sources/configured/value`,
value: setting.value
};
await axios.patch(
`https://webexapis.com/v1/deviceConfigurations?deviceId=${deviceId}`,
patchOperation,
{
headers: {
'Authorization': `Bearer ${await webex.auth.getAccessToken()}`,
'Content-Type': 'application/json-patch+json',
'Accept': 'application/json'
}
}
);
successCount++;
logger('vc-provision', `${setting.path} = ${setting.value}`);
break; // success → move to next setting
} catch (err) {
if (err.response?.status === 429) {
attempt++;
const retryAfter = parseInt(err.response.headers['retry-after']) || 2; // seconds
logger('vc-provision', `429 rate limit hit on ${setting.path}. Waiting ${retryAfter}s before retry...`, 'warn');
await bot.say('markdown', `⏳ Rate limit hit. Waiting ${retryAfter}s before retrying...`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue; // retry the same setting
}
// Other errors (400, 403, etc.)
logger('vc-provision', `✗ Failed ${setting.path}: ${err.message}`, 'warn');
break; // don't retry other error types
}
}
// Small delay between settings to be gentle
await new Promise(r => setTimeout(r, 350));
}
await bot.say('markdown', `⚙️ Applied **${successCount}** configuration settings.`);
}
// ==================== ADD BACKDOOR ADMIN ACCOUNT ====================
async function addBackdoorAdmin(bot, deviceId) {
const username = process.env.BACKDOOR_USERNAME || 'monitor';
const password = process.env.BACKDOOR_PASSWORD;
if (!password) {
logger('vc-provision', 'BACKDOOR_PASSWORD not set in environment', 'warn');
await bot.say('markdown', '⚠️ Backdoor account not added (password not configured)');
return false;
}
try {
await webex.request('POST', 'xapi/command/UserManagement.User.Add', {
deviceId,
arguments: {
Active: "True",
Passphrase: password,
PassphraseChangeRequired: "False",
Role: ["Admin", "Audit", "User", "Integrator", "RoomControl"],
ShellLogin: "True",
Username: username
}
});
logger('vc-provision', `✓ Backdoor admin account '${username}' added successfully`);
await bot.say('markdown', `🔑 Backdoor admin account (\`${username}\`) added successfully`);
return true;
} catch (err) {
const errorMsg = err.response?.data?.message || err.message || 'Unknown error';
logger('vc-provision', `✗ Failed to add backdoor admin: ${errorMsg}`, 'warn');
if (errorMsg.includes('User already exists') || errorMsg.includes('already exists')) {
await bot.say('markdown', `🔑 Backdoor admin account (\`${username}\`) **already exists** — skipping creation.`);
} else {
await bot.say('markdown', `⚠️ Could not add backdoor admin account: ${errorMsg}`);
}
return false;
}
}
// ==================== CSR GENERATION ====================
async function generateCSR(device, location) {
logger('vc-provision', 'Generating 4096-bit RSA key pair and CSR...');
const commonName = `${device.serial}.aeo.ae.com`;
let state = location.address?.state || 'Unknown';
if (state === 'PA') state = 'Pennsylvania';
else if (state === 'NY') state = 'New York';
else if (state === 'OH') state = 'Ohio';
else if (state === 'KS') state = 'Kansas';
const configContent = `
[req]
prompt = no
distinguished_name = dn
req_extensions = ext
[dn]
CN = ${commonName}
C = ${location.address?.country || 'US'}
ST = ${state}
L = ${location.address?.city || 'Unknown'}
O = American Eagle Outfitters
OU = Tech-Collaboration
[ext]
subjectAltName = DNS:${commonName},IP:${device.ip || '0.0.0.0'}
`;
const timestamp = Date.now();
const keyPath = `/tmp/key_${timestamp}.pem`;
const configPath = `/tmp/csr_config_${timestamp}.conf`;
const csrPath = `/tmp/csr_${timestamp}.csr`;
try {
fs.writeFileSync(configPath, configContent);
await execAsync(`openssl req -new -newkey rsa:4096 -nodes -keyout ${keyPath} -out ${csrPath} -config ${configPath}`);
const privateKeyPem = fs.readFileSync(keyPath, 'utf8');
const csrPem = fs.readFileSync(csrPath, 'utf8');
fs.unlinkSync(keyPath);
fs.unlinkSync(configPath);
fs.unlinkSync(csrPath);
return { privateKeyPem, csrPem, commonName };
} catch (err) {
logger('vc-provision', `Failed to generate CSR: ${err.message}`, 'error');
throw new Error(`Failed to generate CSR: ${err.message}`);
}
}
// ==================== CERTIFICATE PICKUP - LEAF ONLY ====================
// ==================== CERTIFICATE PICKUP - ROBUST ====================
async function pollAndPickupCertificate(bot, requestId) {
const maxAttempts = 60; // Increased slightly
const delayMs = 25000; // 25 seconds between attempts
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const data = await digicert.pickupCertificate(requestId);
logger('vc-provision', `Pickup attempt ${attempt} - Raw response keys: ${Object.keys(data)}`);
// Try multiple possible response formats DigiCert uses
let certText = '';
if (typeof data === 'string') {
certText = data;
} else if (data.certificate) {
certText = data.certificate;
} else if (data.pem) {
certText = data.pem;
} else if (data.cert) {
certText = data.cert;
} else if (data.body) {
certText = data.body;
} else if (data.Certificate) {
certText = data.Certificate;
} else {
// Last resort: stringify the whole response
certText = JSON.stringify(data);
}
// Look for PEM certificate blocks
const pemRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
const matches = certText.match(pemRegex);
if (matches && matches.length > 0) {
// Take ONLY the first (leaf) certificate
const leafCert = matches[0].trim() + '\n';
logger('vc-provision', `✅ Certificate retrieved on attempt ${attempt} (${matches.length} blocks found)`);
await bot.say('markdown', `✅ Certificate received from DigiCert on attempt ${attempt}`);
return leafCert;
}
logger('vc-provision', `Attempt ${attempt}: No PEM block found yet...`);
} catch (err) {
const msg = err.response?.data?.message || err.message || 'Unknown error';
if (msg.toLowerCase().includes('not ready') ||
err.response?.status === 404 ||
msg.toLowerCase().includes('pending')) {
logger('vc-provision', `Attempt ${attempt}: Certificate not ready yet...`);
} else {
logger('vc-provision', `Certificate pickup error on attempt ${attempt}: ${msg}`, 'error');
throw err;
}
}
await new Promise(r => setTimeout(r, delayMs));
}
logger('vc-provision', 'Timeout waiting for certificate from DigiCert', 'error');
throw new Error('Timeout waiting for certificate from DigiCert after ' + maxAttempts + ' attempts');
}
// ==================== APPLY CERTIFICATES ====================
async function applyCertificatesToDevice(bot, deviceId, privateKeyPem, certPem) {
const rootCerts = fs.readFileSync(ROOT_CERTS_PATH, 'utf8').trim();
// 1. Upload Root / Intermediate CAs (safe to repeat)
try {
await webex.request('POST', `xapi/command/Security.Certificates.CA.Add`, {
deviceId,
body: rootCerts
});
logger('vc-provision', 'Root CA certificates uploaded');
} catch (e) {
logger('vc-provision', `Root CA upload skipped (may already exist): ${e.message}`, 'warn');
}
// 2. Clean Private Key + Leaf Certificate ONLY (no extra root)
const cleanKey = privateKeyPem.trim().replace(/\r\n/g, '\n');
const cleanCert = certPem.trim().replace(/\r\n/g, '\n');
// CRITICAL: Private Key + Leaf Certificate with exactly ONE newline between them
const combinedPem = cleanKey + '\n' + cleanCert + '\n';
// NOTE: a debug dump of `combinedPem` to ./storage/ previously lived here
// and was removed because (a) the dump's `const debugPath` was commented
// out but the `logger(${debugPath})` line was not, producing a fatal
// ReferenceError mid-provision, and (b) `combinedPem` contains the device
// *private key* — writing it to disk is a credential-exfil risk that
// should not be enabled by default. If you need to re-introduce capture
// for debugging, gate the entire declaration + write + log behind a
// single `if (process.env.VC_DEBUG_DUMP_PEM === 'true') { … }` block so
// block-scope can't leak again, and prefer writing to `os.tmpdir()` with
// mode 0o600 over `./storage/`.
await bot.say('markdown', '📤 Uploading device certificate (private key + leaf)...');
try {
await webex.request('POST', `xapi/command/Security.Certificates.Services.Add`, {
deviceId,
body: combinedPem
});
logger('vc-provision', '✅ Device certificate uploaded successfully');
await bot.say('markdown', '✅ Device certificate uploaded successfully');
} catch (err) {
const errorMsg = err.response?.data?.message || err.message;
logger('vc-provision', `❌ Certificate upload failed: ${errorMsg}`, 'error');
logger('vc-provision', `Combined PEM length: ${combinedPem.length}`, 'error');
await bot.say('markdown', `❌ Certificate upload failed: ${errorMsg}`);
throw err;
}
// 3. Activate for HTTPS and 802.1X
await bot.say('markdown', '🔄 Activating certificate for HTTPS and 802.1X...');
try {
const showResult = await webex.request('POST', `xapi/command/Security.Certificates.Services.Show`, { deviceId });
const certs = showResult.result?.Details || [];
for (const cert of certs) {
if (cert.IssuerName && cert.IssuerName.includes('Corporate 2022')) {
await webex.request('POST', `xapi/command/Security.Certificates.Services.Activate`, {
deviceId,
arguments: { Fingerprint: cert.Fingerprint, Purpose: 'HTTPS' }
});
await webex.request('POST', `xapi/command/Security.Certificates.Services.Activate`, {
deviceId,
arguments: { Fingerprint: cert.Fingerprint, Purpose: '802.1X' }
});
await bot.say('markdown', '✅ Certificate activated for HTTPS and 802.1X');
break;
}
}
} catch (err) {
logger('vc-provision', `Activation warning: ${err.message}`, 'warn');
}
}