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>
This commit is contained in:
408
scripts/sync-sc-from-syncro.js
Normal file
408
scripts/sync-sc-from-syncro.js
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user