diff --git a/.env.example b/.env.example index 8e4021f..75e0762 100644 --- a/.env.example +++ b/.env.example @@ -424,7 +424,8 @@ DIGICERT_SEAT_EMAIL=your-email@company.com # ----------------------------------------------------------------------------- # MDM / Workspace ONE (two instances) # ----------------------------------------------------------------------------- -# Standard / Store MDM +# Standard / Store MDM (also used by scripts/rebootStoreIpads.js SoftReset) +# OAuth client needs REST API Devices Execute for remote reboot. WS1_CLIENT_ID=... WS1_CLIENT_SECRET=... WS1_TENANT_CODE=... diff --git a/.gitignore b/.gitignore index 9534de0..a765ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ scripts/* !scripts/reclaimWebexHosts.js !scripts/removeAdvancedMessaging.js !scripts/findEmptyLocations.js +!scripts/rebootStoreIpads.js !scripts/prismaProbe.js !scripts/lib/ !scripts/lib/** diff --git a/integrations/mdm/client.js b/integrations/mdm/client.js index 07de869..5aa5a75 100644 --- a/integrations/mdm/client.js +++ b/integrations/mdm/client.js @@ -194,4 +194,67 @@ export async function getAVMDMDevices(storeNum) { logger('mdm:av', `MDM AV filter: ${allDevices.length} total → ${avDevices.length} AV devices`, 'debug'); return avDevices; } + +/** + * Send a SoftReset (reboot) command via Workspace ONE UEM. + * Mirrors appspace/mdm.js sendMDMRebootCommand — proven in production. + * + * @param {string|number} deviceId WS1 numeric Id (device.Id.Value), NOT serial + * @returns {Promise<{ success: boolean, status?: number, error?: string }>} + */ +export 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 (err) { + return { success: false, error: `Could not obtain MDM token: ${err.message}` }; + } + + try { + const resp = await doRequest(token); + return { success: true, status: resp.status }; + } catch (err) { + const status = err.response?.status; + if (status === 401 || status === 403) { + logger('mdm:reboot', `Auth failed on SoftReset for ${deviceId} — retrying once`, 'warn'); + try { + const retryResp = await doRequest(await getMDMToken()); + 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, + }; + } +} + export default mdmAxios; diff --git a/scripts/lib/mdmIpadFilter.js b/scripts/lib/mdmIpadFilter.js new file mode 100644 index 0000000..3a2e4ce --- /dev/null +++ b/scripts/lib/mdmIpadFilter.js @@ -0,0 +1,70 @@ +// scripts/lib/mdmIpadFilter.js +// Fleet filter for store iPads whose DeviceFriendlyName contains MRD, MR1, or CD. + +const NAME_PATTERN = /(MRD|MR1|CD)/i; +const IPAD_PATTERN = /iPad/i; + +/** + * @param {object} device raw WS1 search record + * @returns {boolean} + */ +export function isIpadDevice(device) { + if (!device) return false; + const platform = String(device.Platform || device.platform || ''); + const model = String(device.Model || device.model || ''); + const os = String(device.OperatingSystem || device.osVersion || ''); + return IPAD_PATTERN.test(platform) || IPAD_PATTERN.test(model) || /iPadOS/i.test(os); +} + +/** + * @param {string} friendlyName DeviceFriendlyName only (per operator requirement) + * @returns {boolean} + */ +export function matchesStoreIpadName(friendlyName) { + if (!friendlyName) return false; + return NAME_PATTERN.test(String(friendlyName)); +} + +/** + * @param {object} device raw WS1 search record + * @returns {boolean} + */ +export function isTargetStoreIpad(device) { + if (!device) return false; + const friendlyName = device.DeviceFriendlyName || ''; + return isIpadDevice(device) && matchesStoreIpadName(friendlyName); +} + +/** + * WS1 numeric device Id required for SoftReset (appspace pattern). + * @param {object} device + * @returns {string|number|null} + */ +export function mdmDeviceId(device) { + if (!device) return null; + return device.Id?.Value ?? device.Id ?? device.id ?? device.DeviceId ?? null; +} + +/** + * Extract store number from DeviceFriendlyName for reporting. + * @param {string} rawName + * @returns {string|null} + */ +export function extractStoreFromFriendlyName(rawName) { + if (!rawName) return null; + return ( + rawName.match(/(\d{6})/)?.[1] + || rawName.match(/(\d{5})/)?.[1] + || rawName.match(/\d{2,4}/)?.[0]?.padStart(5, '0') + || null + ); +} + +/** + * @param {object[]} devices + * @returns {object[]} + */ +export function filterTargetStoreIpads(devices) { + if (!Array.isArray(devices)) return []; + return devices.filter(isTargetStoreIpad); +} diff --git a/scripts/rebootStoreIpads.js b/scripts/rebootStoreIpads.js new file mode 100644 index 0000000..d2278e0 --- /dev/null +++ b/scripts/rebootStoreIpads.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * Bulk reboot store iPads (MRD / MR1 / CD in DeviceFriendlyName). + * + * Fleet-wide scan of Store Workspace ONE MDM, filter iPads by name, issue + * SoftReset (same API as appspace restart-offline). Default is dry-run. + * + * Usage: + * node scripts/rebootStoreIpads.js + * node scripts/rebootStoreIpads.js --execute --yes --report reboot-ipads.csv + * + * Auth: WS1_CLIENT_ID, WS1_CLIENT_SECRET, WS1_TENANT_CODE (Store MDM). + * Requires REST API Devices Execute on the OAuth client. + * + * Note: SoftReset only executes on Supervised (DEP) iOS/iPadOS devices. + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; + +import { + filterTargetStoreIpads, + mdmDeviceId, + extractStoreFromFriendlyName, +} from './lib/mdmIpadFilter.js'; + +const DEFAULT_CONCURRENCY = 3; +const MAX_FLEET_DEVICES = 25000; + +function parseArgs(argv) { + const out = { + execute: false, + yes: false, + concurrency: DEFAULT_CONCURRENCY, + limit: null, + report: null, + help: false, + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case '--execute': out.execute = true; break; + case '--yes': out.yes = true; break; + case '--concurrency': out.concurrency = Math.max(1, Number(next()) || DEFAULT_CONCURRENCY); break; + case '--limit': out.limit = Number(next()); break; + case '--report': out.report = next(); break; + case '-h': + case '--help': + out.help = true; + break; + default: + throw new Error(`Unknown argument: ${a}`); + } + } + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/rebootStoreIpads.js [options] + +Fleet-wide: scans Store WS1 MDM for iPads whose DeviceFriendlyName +contains MRD, MR1, or CD, then issues SoftReset (reboot). + +Options: + --execute Send reboot commands (default: dry-run only) + --yes Skip interactive confirm when --execute + --concurrency Parallel reboots (default: ${DEFAULT_CONCURRENCY}) + --limit Cap number of devices processed + --report Write audit CSV + -h, --help Show this help + +iOS/iPadOS devices must be Supervised (DEP) for SoftReset to execute. +`); +} + +function toCsvRow(cells) { + return cells.map((c) => { + const s = String(c ?? ''); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }).join(','); +} + +function summarizeDevice(device) { + const friendlyName = device.DeviceFriendlyName || '—'; + return { + storeNum: extractStoreFromFriendlyName(friendlyName) || '—', + friendlyName, + serial: device.SerialNumber || '—', + mdmId: mdmDeviceId(device), + model: device.Model || '—', + lastSeen: device.LastSeen || device.LastSystemSampleTime || '—', + raw: device, + }; +} + +async function confirmExecute(count) { + const rl = readline.createInterface({ input, output }); + try { + const answer = await rl.question( + `About to SoftReset ${count} iPad(s). Type YES to continue: `, + ); + return answer.trim().toUpperCase() === 'YES'; + } finally { + rl.close(); + } +} + +function printPreview(rows) { + console.log(`\nMatched ${rows.length} iPad(s):\n`); + const preview = rows.slice(0, 30); + for (const r of preview) { + console.log( + ` store=${r.storeNum} id=${r.mdmId} serial=${r.serial} name=${r.friendlyName}`, + ); + } + if (rows.length > preview.length) { + console.log(` ... and ${rows.length - preview.length} more`); + } +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printHelp(); + return; + } + + if (!process.env.WS1_CLIENT_ID || !process.env.WS1_CLIENT_SECRET || !process.env.WS1_TENANT_CODE) { + console.error('Missing WS1_CLIENT_ID, WS1_CLIENT_SECRET, or WS1_TENANT_CODE in environment.'); + process.exit(1); + } + + const { getMDMDevicesByPlatform, sendMDMRebootCommand } = await import('../integrations/mdm/client.js'); + const { runPool } = await import('./lib/webexBulk.js'); + + console.log('Fetching fleet devices from Store MDM (paginated)...'); + const allDevices = await getMDMDevicesByPlatform(null, MAX_FLEET_DEVICES); + console.log(`MDM returned ${allDevices.length} device(s).`); + + let matches = filterTargetStoreIpads(allDevices).map(summarizeDevice); + matches = matches.filter((m) => m.mdmId != null); + const skippedNoId = filterTargetStoreIpads(allDevices).length - matches.length; + + if (Number.isFinite(opts.limit) && opts.limit > 0) { + matches = matches.slice(0, opts.limit); + } + + if (skippedNoId > 0) { + console.warn(`Skipped ${skippedNoId} match(es) with no WS1 Id.Value.`); + } + + printPreview(matches); + + if (matches.length === 0) { + console.log('\nNo matching iPads found. Nothing to do.'); + return; + } + + if (!opts.execute) { + console.log('\nDry-run only — pass --execute to send SoftReset commands.'); + return; + } + + if (!opts.yes) { + const ok = await confirmExecute(matches.length); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + console.log(`\nSending SoftReset to ${matches.length} device(s) (concurrency=${opts.concurrency})...`); + + const results = await runPool(matches, opts.concurrency, async (row) => { + const reboot = await sendMDMRebootCommand(row.mdmId); + return { + ...row, + status: reboot.success ? 'sent' : 'failed', + httpStatus: reboot.status ?? '', + error: reboot.error ?? '', + }; + }); + + const sent = results.filter((r) => r.ok && r.value.status === 'sent').length; + const failed = results.filter((r) => !r.ok || r.value.status === 'failed').length; + + console.log(`\nDone: ${sent} sent, ${failed} failed.`); + + const reportRows = results.map((r, i) => { + if (r.ok) return r.value; + const base = matches[i]; + return { + ...base, + status: 'error', + httpStatus: '', + error: r.error?.message || String(r.error), + }; + }); + + for (const row of reportRows) { + if (row.status !== 'sent') { + console.log(` FAIL ${row.friendlyName} (${row.serial}): ${row.error || 'unknown'}`); + } + } + + if (opts.report) { + const header = ['store', 'friendlyName', 'serial', 'mdmId', 'model', 'lastSeen', 'status', 'httpStatus', 'error']; + const lines = [ + toCsvRow(header), + ...reportRows.map((r) => toCsvRow([ + r.storeNum, r.friendlyName, r.serial, r.mdmId, r.model, r.lastSeen, + r.status, r.httpStatus ?? '', r.error ?? '', + ])), + ]; + fs.writeFileSync(opts.report, `${lines.join('\n')}\n`); + console.log(`\nWrote ${opts.report}`); + } + + console.log('\nNote: WS1 queues SoftReset; iPadOS must be Supervised for reboot to execute.'); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/tests/rebootStoreIpads.filter.test.js b/tests/rebootStoreIpads.filter.test.js new file mode 100644 index 0000000..79e6597 --- /dev/null +++ b/tests/rebootStoreIpads.filter.test.js @@ -0,0 +1,71 @@ +// tests/rebootStoreIpads.filter.test.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + isIpadDevice, + matchesStoreIpadName, + isTargetStoreIpad, + filterTargetStoreIpads, + mdmDeviceId, + extractStoreFromFriendlyName, +} from '../scripts/lib/mdmIpadFilter.js'; + +test('matchesStoreIpadName matches MRD MR1 CD tokens', () => { + assert.equal(matchesStoreIpadName('Store 0782 MRD iPad'), true); + assert.equal(matchesStoreIpadName('0782-MR1-Fitting'), true); + assert.equal(matchesStoreIpadName('Store CD Queue iPad'), true); + assert.equal(matchesStoreIpadName('Store 0782 VW'), false); + assert.equal(matchesStoreIpadName(''), false); +}); + +test('isIpadDevice detects iPad platform/model', () => { + assert.equal(isIpadDevice({ Platform: 'Apple', Model: 'iPad (9th generation)' }), true); + assert.equal(isIpadDevice({ Platform: 'Apple', Model: 'Apple TV' }), false); +}); + +test('isTargetStoreIpad requires iPad and friendly name match', () => { + assert.equal(isTargetStoreIpad({ + DeviceFriendlyName: 'Store 0782 MRD iPad', + Platform: 'Apple', + Model: 'iPad Pro', + Id: { Value: 12345 }, + }), true); + assert.equal(isTargetStoreIpad({ + DeviceFriendlyName: 'Store 0782 MRD Apple TV', + Platform: 'Apple', + Model: 'Apple TV', + Id: { Value: 1 }, + }), false); + assert.equal(isTargetStoreIpad({ + DeviceFriendlyName: 'Store 0782 VW iPad', + Platform: 'Apple', + Model: 'iPad', + Id: { Value: 1 }, + }), false); +}); + +test('mdmDeviceId reads Id.Value', () => { + assert.equal(mdmDeviceId({ Id: { Value: 999 } }), 999); + assert.equal(mdmDeviceId({ id: 'abc' }), 'abc'); + assert.equal(mdmDeviceId({}), null); +}); + +test('extractStoreFromFriendlyName parses store numbers', () => { + assert.equal(extractStoreFromFriendlyName('Store 007821 MRD iPad'), '007821'); + assert.equal(extractStoreFromFriendlyName('0782-MR1-Fitting'), '0782'); + assert.equal(extractStoreFromFriendlyName('no digits'), null); +}); + +test('filterTargetStoreIpads returns only matches', () => { + const devices = [ + { DeviceFriendlyName: 'A MRD', Platform: 'Apple', Model: 'iPad', Id: { Value: 1 } }, + { DeviceFriendlyName: 'B VW', Platform: 'Apple', Model: 'iPad', Id: { Value: 2 } }, + { DeviceFriendlyName: 'C MR1', Platform: 'Apple', Model: 'iPad', Id: { Value: 3 } }, + ]; + const out = filterTargetStoreIpads(devices); + assert.equal(out.length, 2); + assert.equal(out[0].Id.Value, 1); + assert.equal(out[1].Id.Value, 3); +});