Files
claudetools/scripts/sync-sc-from-syncro.js
Mike Swanson e34f51fe5d Session 2026-03-30: SOPS vault, SC-Syncro sync, Syncro scripts
- SOPS+age credential vault created (59 encrypted files, separate repo)
- Updated CLAUDE.md credential access to reference SOPS vault
- Updated memory for ACG-5070 (Windows 11, replaces CachyOS)
- SC-Syncro sync script: enriched 410 SC sessions with company/device data
- Syncro scripts: SC property updater, SC deployer, rogue SC killer
- Session log with full details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:38:38 -07:00

409 lines
14 KiB
JavaScript

#!/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<void>}
*/
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<Array<{scGuid: string, company: string, deviceName: string, customerId: number}>>}
*/
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<string[]|null>} 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);
});