#!/usr/bin/env node 'use strict'; const https = require('https'); const { execSync } = require('child_process'); const { URL } = require('url'); // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- const args = process.argv.slice(2); const FLAG_FORCE = args.includes('--force'); const FLAG_DRY_RUN = args.includes('--dry-run') || !FLAG_FORCE; const FLAG_VERBOSE = args.includes('--verbose'); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node sync-sc-from-syncro.js [--dry-run] [--force] [--verbose] Options: --dry-run List what would be updated without making SC API calls (default) --force Actually perform the ScreenConnect updates --verbose Show detailed output for each asset --help Show this help message Without --force, the script runs in dry-run mode.`); process.exit(0); } // --------------------------------------------------------------------------- // Credential loading via vault // --------------------------------------------------------------------------- const VAULT_PATH = process.env.VAULT_PATH || 'D:/vault'; function vaultGet(file, field) { try { const result = execSync( `bash "${VAULT_PATH}/scripts/vault.sh" get-field "${file}" "${field}"`, { encoding: 'utf8', timeout: 30000 } ); return result.trim(); } catch (err) { console.error(`[ERROR] Failed to read vault field "${field}" from "${file}": ${err.message}`); process.exit(1); } } let SYNCRO_API_KEY; let SC_SECRET; function loadCredentials() { console.log('Loading credentials from vault...'); SYNCRO_API_KEY = vaultGet('msp-tools/syncro.sops.yaml', 'credentials.credential'); SC_SECRET = vaultGet('msp-tools/screenconnect.sops.yaml', 'credentials.api_secret'); if (!SYNCRO_API_KEY) { console.error('[ERROR] Syncro API key is empty.'); process.exit(1); } if (!SC_SECRET) { console.error('[ERROR] ScreenConnect API secret is empty.'); process.exit(1); } console.log('Credentials loaded successfully.'); } // --------------------------------------------------------------------------- // HTTP helpers (built-in https module) // --------------------------------------------------------------------------- /** * Make an HTTPS request and return the parsed JSON response. * @param {object} options * @param {string} options.url - Full URL * @param {string} [options.method='GET'] - HTTP method * @param {object} [options.headers={}] - Request headers * @param {string|null} [options.body=null] - Request body (string) * @returns {Promise<{statusCode: number, data: any}>} */ function httpsRequest({ url, method = 'GET', headers = {}, body = null }) { return new Promise((resolve, reject) => { const parsed = new URL(url); const reqOptions = { hostname: parsed.hostname, port: parsed.port || 443, path: parsed.pathname + parsed.search, method, headers, }; const req = https.request(reqOptions, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); let data; try { data = JSON.parse(raw); } catch (_) { data = raw; } resolve({ statusCode: res.statusCode, data }); }); }); req.on('error', (err) => reject(err)); req.setTimeout(30000, () => { req.destroy(new Error('Request timed out')); }); if (body !== null) { const buf = Buffer.from(body, 'utf8'); req.setHeader('Content-Length', buf.length); req.write(buf); } req.end(); }); } /** * Sleep for a given number of milliseconds. * @param {number} ms * @returns {Promise} */ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // --------------------------------------------------------------------------- // Syncro API - paginate all assets // --------------------------------------------------------------------------- /** * Fetch all customer assets from Syncro, paginating until no more results. * @returns {Promise>} */ async function fetchAllSyncroAssets() { const baseUrl = 'https://computerguru.syncromsp.com/api/v1/customer_assets'; const perPage = 100; let page = 1; const results = []; let totalRaw = 0; console.log('Fetching Syncro assets...'); while (true) { const url = `${baseUrl}?page=${page}&per_page=${perPage}`; if (FLAG_VERBOSE) { console.log(` Fetching page ${page}...`); } let response; try { response = await httpsRequest({ url, method: 'GET', headers: { 'Authorization': SYNCRO_API_KEY, 'Accept': 'application/json', }, }); } catch (err) { console.error(`[ERROR] Syncro API request failed on page ${page}: ${err.message}`); process.exit(1); } if (response.statusCode !== 200) { console.error(`[ERROR] Syncro API returned status ${response.statusCode} on page ${page}.`); if (typeof response.data === 'string') { console.error(` Response: ${response.data.substring(0, 500)}`); } process.exit(1); } const assets = response.data.assets || response.data; if (!Array.isArray(assets) || assets.length === 0) { if (FLAG_VERBOSE) { console.log(` Page ${page} returned 0 assets, done paginating.`); } break; } totalRaw += assets.length; for (const asset of assets) { const props = asset.properties || {}; const scGuid = props['ScreenConnect GUID'] || ''; if (!scGuid) { continue; } const customer = asset.customer || {}; results.push({ scGuid, company: customer.business_then_name || '', deviceName: asset.name || 'Unknown', customerId: customer.id || 0, deviceType: (props.form_factor || '').replace(/^Physical /, ''), }); } if (FLAG_VERBOSE) { console.log(` Page ${page}: ${assets.length} assets (${results.length} with SC GUID so far)`); } page++; } console.log(`Fetched ${totalRaw} total assets, ${results.length} have ScreenConnect GUIDs.`); return { totalRaw, assets: results }; } // --------------------------------------------------------------------------- // ScreenConnect API - read session custom properties // --------------------------------------------------------------------------- /** * Read current session custom properties from ScreenConnect to check tagging. * Uses the same extension endpoint with GetSessionDetails or similar. * * Note: ScreenConnect does not have a clean REST API for reading session * properties by GUID through the extension endpoint. We attempt to read * session details. If reading fails or is unsupported, we return null * and let the caller decide whether to update. * * @param {string} scGuid * @returns {Promise} Array of custom property values, or null if unreadable */ async function readScSessionProperties(scGuid) { const url = 'https://computerguru.screenconnect.com/App_Extensions/2d558935-686a-4bd0-9991-07539f5fe749/Service.ashx/GetSessionDetails'; try { const response = await httpsRequest({ url, method: 'POST', headers: { 'CTRLAuthHeader': SC_SECRET, 'Content-Type': 'application/json', 'Origin': 'https://computerguru.screenconnect.com', }, body: JSON.stringify([scGuid]), }); if (response.statusCode === 200 && response.data) { // The response structure may vary; attempt to extract custom properties const session = response.data; if (Array.isArray(session.CustomPropertyValues)) { return session.CustomPropertyValues; } if (Array.isArray(session)) { const first = session[0]; if (first && Array.isArray(first.CustomPropertyValues)) { return first.CustomPropertyValues; } } } } catch (_) { // Reading failed - will attempt update anyway (unless --force is off) } return null; } // --------------------------------------------------------------------------- // ScreenConnect API - update session custom properties // --------------------------------------------------------------------------- /** * Update ScreenConnect session custom properties for a given GUID. * @param {string} scGuid * @param {string} company * @param {string} site * @returns {Promise<{success: boolean, error?: string}>} */ async function updateScSession(scGuid, company, deviceType) { const url = 'https://computerguru.screenconnect.com/App_Extensions/2d558935-686a-4bd0-9991-07539f5fe749/Service.ashx/UpdateSessionCustomProperties'; // CP1=Company, CP2=Site (blank), CP3=Department (blank), CP4=Device Type, CP5=Tag, CP6-8=blank const bodyPayload = [ scGuid, [company, '', '', deviceType, 'Syncro-Matched', '', '', ''], ]; try { const response = await httpsRequest({ url, method: 'POST', headers: { 'CTRLAuthHeader': SC_SECRET, 'Content-Type': 'application/json', 'Origin': 'https://computerguru.screenconnect.com', }, body: JSON.stringify(bodyPayload), }); if (response.statusCode >= 200 && response.statusCode < 300) { return { success: true }; } const detail = typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200); return { success: false, error: `HTTP ${response.statusCode}: ${detail}` }; } catch (err) { return { success: false, error: err.message }; } } // --------------------------------------------------------------------------- // Main processing // --------------------------------------------------------------------------- async function main() { console.log('=== SC-Syncro Session Sync ==='); console.log(`Mode: ${FLAG_FORCE ? 'LIVE (--force)' : 'DRY-RUN'}`); console.log(''); loadCredentials(); console.log(''); const { totalRaw, assets } = await fetchAllSyncroAssets(); console.log(''); const stats = { totalSyncroAssets: totalRaw, assetsWithScGuid: assets.length, alreadyTagged: 0, updated: 0, errors: 0, wouldUpdate: 0, }; for (let i = 0; i < assets.length; i++) { const asset = assets[i]; const shortGuid = asset.scGuid.length > 12 ? asset.scGuid.substring(0, 12) + '...' : asset.scGuid; // Check if already tagged const currentProps = await readScSessionProperties(asset.scGuid); if (currentProps !== null && currentProps.length >= 5 && currentProps[4] === 'Syncro-Matched') { stats.alreadyTagged++; if (FLAG_VERBOSE) { console.log(`[SKIP] ${asset.deviceName} (${shortGuid}) - already tagged`); } await sleep(100); continue; } if (FLAG_DRY_RUN && !FLAG_FORCE) { stats.wouldUpdate++; if (FLAG_VERBOSE) { console.log(`[DRY-RUN] ${asset.deviceName} (${shortGuid}) -> Company: "${asset.company}", Type: "${asset.deviceType}"`); } await sleep(100); continue; } // Perform the update const result = await updateScSession(asset.scGuid, asset.company, asset.deviceType); if (result.success) { stats.updated++; if (FLAG_VERBOSE) { console.log(`[UPDATE] ${asset.deviceName} (${shortGuid}) -> Company: "${asset.company}", Type: "${asset.deviceType}"`); } } else { stats.errors++; if (FLAG_VERBOSE) { console.log(`[ERROR] ${asset.deviceName} (${shortGuid}): ${result.error}`); } } // Rate limiting: 200ms between SC API calls await sleep(200); // Progress indicator for non-verbose mode if (!FLAG_VERBOSE && (i + 1) % 50 === 0) { console.log(` Progress: ${i + 1}/${assets.length} processed...`); } } // Print summary console.log(''); console.log('SC-Syncro Sync Results:'); console.log(` Total Syncro assets: ${stats.totalSyncroAssets}`); console.log(` Assets with SC GUID: ${stats.assetsWithScGuid}`); console.log(` Already tagged (skipped): ${stats.alreadyTagged}`); if (FLAG_DRY_RUN && !FLAG_FORCE) { console.log(` Would update: ${stats.wouldUpdate}`); console.log(''); console.log('Run with --force to apply updates.'); } else { console.log(` Updated: ${stats.updated}`); console.log(` Errors: ${stats.errors}`); } } main().catch((err) => { console.error(`[FATAL] Unhandled error: ${err.message}`); if (err.stack) { console.error(err.stack); } process.exit(1); });