diff --git a/config.json b/config.json index 00cd76a..f534b28 100644 --- a/config.json +++ b/config.json @@ -1,18 +1,18 @@ { "name": "ScriptTask", "shortName": "scripttask", - "version": "0.0.20", - "author": "Ryan Blenis", + "version": "0.0.21", + "author": "Ryan Blenis / Pablo Navarro", "description": "Script (PowerShell, BAT, Bash) runner for endpoints", "hasAdminPanel": false, - "homepage": "https://github.com/ryanblenis/MeshCentral-ScriptTask", - "changelogUrl": "https://raw.githubusercontent.com/ryanblenis/MeshCentral-ScriptTask/master/changelog.md", - "configUrl": "https://raw.githubusercontent.com/ryanblenis/MeshCentral-ScriptTask/master/config.json", - "downloadUrl": "https://github.com/ryanblenis/MeshCentral-ScriptTask/archive/master.zip", + "homepage": "https://github.com/panreyes/MeshCentral-ScriptTask", + "changelogUrl": "https://raw.githubusercontent.com/panreyes/MeshCentral-ScriptTask/master/changelog.md", + "configUrl": "https://raw.githubusercontent.com/panreyes/MeshCentral-ScriptTask/master/config.json", + "downloadUrl": "https://github.com/panreyes/MeshCentral-ScriptTask/archive/master.zip", "repository": { "type": "git", - "url": "https://github.com/ryanblenis/MeshCentral-ScriptTask.git" + "url": "https://github.com/panreyes/MeshCentral-ScriptTask.git" }, - "versionHistoryUrl": "https://api.github.com/repos/ryanblenis/MeshCentral-ScriptTask/tags", + "versionHistoryUrl": "https://api.github.com/repos/panreyes/MeshCentral-ScriptTask/tags", "meshCentralCompat": ">=1.1.35" -} \ No newline at end of file +} diff --git a/db.js b/db.js index 1d2cc60..ce23ced 100644 --- a/db.js +++ b/db.js @@ -9,30 +9,30 @@ var Datastore = null; var formatId = null; -module.exports.CreateDB = function(meshserver) { +module.exports.CreateDB = function (meshserver) { var obj = {}; var NEMongo = require(__dirname + '/nemongo.js'); module.paths.push(require('path').join(meshserver.parentpath, 'node_modules')); // we need to push the node_modules folder for nedb obj.dbVersion = 1; - + obj.initFunctions = function () { - obj.updateDBVersion = function(new_version) { - return obj.scriptFile.updateOne({type: "db_version"}, { $set: {version: new_version} }, {upsert: true}); + obj.updateDBVersion = function (new_version) { + return obj.scriptFile.updateOne({ type: "db_version" }, { $set: { version: new_version } }, { upsert: true }); }; - - obj.getDBVersion = function() { - return new Promise(function(resolve, reject) { - obj.scriptFile.find( { type: "db_version" } ).project( { _id: 0, version: 1 } ).toArray(function(err, vers){ + + obj.getDBVersion = function () { + return new Promise(function (resolve, reject) { + obj.scriptFile.find({ type: "db_version" }).project({ _id: 0, version: 1 }).toArray(function (err, vers) { if (vers.length == 0) resolve(1); else resolve(vers[0]['version']); }); }); }; - obj.addScript = function(name, content, path, filetype) { + obj.addScript = function (name, content, path, filetype) { if (path == null) path = "Shared" if (filetype == 'bash') content = content.split('\r\n').join('\n').split('\r').join('\n'); - var sObj = { + var sObj = { type: 'script', path: path, name: name, @@ -42,8 +42,8 @@ module.exports.CreateDB = function(meshserver) { }; return obj.scriptFile.insertOne(sObj); }; - - obj.addFolder = function(name, path) { + + obj.addFolder = function (name, path) { var sObj = { type: 'folder', path: path, @@ -51,11 +51,12 @@ module.exports.CreateDB = function(meshserver) { }; return obj.scriptFile.insertOne(sObj); }; - - obj.getScriptTree = function() { + + obj.getScriptTree = function () { return obj.scriptFile.find( - { type: - { $in: [ 'script', 'folder' ] } + { + type: + { $in: ['script', 'folder'] } } ).sort( { path: 1, type: 1, name: 1 } @@ -63,61 +64,62 @@ module.exports.CreateDB = function(meshserver) { { name: 1, path: 1, type: 1, filetype: 1 } ).toArray(); }; - - obj.update = function(id, args) { + + obj.update = function (id, args) { id = formatId(id); - if (args.type == 'script' && args.content !== null) { + if (args.type == 'script' && args.content !== null) { if (args.filetype == 'bash') { args.content = args.content = split('\r\n').join('\n').split('\r').join('\n'); } args.contentHash = require('crypto').createHash('sha384').update(args.content).digest('hex'); } - return obj.scriptFile.updateOne( { _id: id }, { $set: args } ); + return obj.scriptFile.updateOne({ _id: id }, { $set: args }); }; - obj.delete = function(id) { + obj.delete = function (id) { id = formatId(id); - return obj.scriptFile.deleteOne( { _id: id } ); + return obj.scriptFile.deleteOne({ _id: id }); }; - obj.deleteByPath = function(path) { - return obj.scriptFile.deleteMany( { path: path, type: { $in: ['script', 'folder'] } } ); + obj.deleteByPath = function (path) { + return obj.scriptFile.deleteMany({ path: path, type: { $in: ['script', 'folder'] } }); }; - obj.deleteSchedulesForScript = function(id) { + obj.deleteSchedulesForScript = function (id) { id = formatId(id); - return obj.scriptFile.deleteMany( { type: 'jobSchedule', scriptId: id } ); + return obj.scriptFile.deleteMany({ type: 'jobSchedule', scriptId: id }); }; - obj.getByPath = function(path) { - return obj.scriptFile.find( { type: { $in: [ 'script', 'folder' ] }, path: path }).toArray(); + obj.getByPath = function (path) { + return obj.scriptFile.find({ type: { $in: ['script', 'folder'] }, path: path }).toArray(); }; - obj.get = function(id) { - if (id == null || id == 'null') return new Promise(function(resolve, reject) { resolve([]); }); + obj.get = function (id) { + if (id == null || id == 'null') return new Promise(function (resolve, reject) { resolve([]); }); id = formatId(id); - return obj.scriptFile.find( { _id: id } ).toArray(); + return obj.scriptFile.find({ _id: id }).toArray(); }; - obj.addJob = function(passedObj) { - var nowTime = Math.floor(new Date() / 1000); - var defaultObj = { - type: 'job', - queueTime: nowTime, - dontQueueUntil: nowTime, - dispatchTime: null, - completeTime: null, - node: null, - scriptId: null, - scriptName: null, // in case the original reference is deleted in the future - replaceVars: null, - returnVal: null, - errorVal: null, - returnAct: null, - runBy: null, - jobSchedule: null - }; - var jObj = {...defaultObj, ...passedObj}; - - if (jObj.node == null || jObj.scriptId == null) { console.log('PLUGIN: SciptTask: Could not add job'); return false; } - - return obj.scriptFile.insertOne(jObj); + obj.addJob = function (passedObj) { + var nowTime = Math.floor(new Date() / 1000); + var defaultObj = { + type: 'job', + queueTime: nowTime, + dontQueueUntil: nowTime, + dispatchTime: null, + completeTime: null, + lastPing: null, + node: null, + scriptId: null, + scriptName: null, // in case the original reference is deleted in the future + replaceVars: null, + returnVal: null, + errorVal: null, + returnAct: null, + runBy: null, + jobSchedule: null + }; + var jObj = { ...defaultObj, ...passedObj }; + + if (jObj.node == null || jObj.scriptId == null) { console.log('PLUGIN: SciptTask: Could not add job'); return false; } + + return obj.scriptFile.insertOne(jObj); }; - obj.addJobSchedule = function(schedObj) { + obj.addJobSchedule = function (schedObj) { schedObj.type = 'jobSchedule'; if (schedObj.node == null || schedObj.scriptId == null) { console.log('PLUGIN: SciptTask: Could not add job schedule'); return false; } return obj.scriptFile.insertOne(schedObj); @@ -125,13 +127,13 @@ module.exports.CreateDB = function(meshserver) { obj.removeJobSchedule = function (id) { return obj.delete(id); }; - obj.getSchedulesDueForJob = function(scheduleId) { + obj.getSchedulesDueForJob = function (scheduleId) { var nowTime = Math.floor(new Date() / 1000); var scheduleIdLimiter = {}; if (scheduleId != null) { scheduleIdLimiter._id = scheduleId; } - return obj.scriptFile.find( { + return obj.scriptFile.find({ type: 'jobSchedule', // startAt: { $gte: nowTime }, $or: [ @@ -139,69 +141,84 @@ module.exports.CreateDB = function(meshserver) { { endAt: { $lte: nowTime } } ], $or: [ - { nextRun: null }, + { nextRun: null }, { nextRun: { $lte: (nowTime + 60) } } // check a minute into the future ], ...scheduleIdLimiter }).toArray(); }; - obj.deletePendingJobsForNode = function(node) { - return obj.scriptFile.deleteMany({ - type: 'job', + obj.deletePendingJobsForNode = function (node) { + return obj.scriptFile.deleteMany({ + type: 'job', node: node, completeTime: null, }); }; - obj.getPendingJobs = function(nodeScope) { - if (nodeScope == null || !Array.isArray(nodeScope)) { - return false; - } - // return jobs that has online nodes and queue time requirements have been met - return obj.scriptFile.find( { - type: 'job', - node: { $in: nodeScope }, - completeTime: null, - //dispatchTime: null, - $or: [ - { dontQueueUntil: null }, - { dontQueueUntil: { $lte: Math.floor(new Date() / 1000) } } - ] - }).toArray(); + obj.getPendingJobs = function (nodeScope) { + if (nodeScope == null || !Array.isArray(nodeScope)) { + return false; + } + // return jobs that has online nodes and queue time requirements have been met + return obj.scriptFile.find({ + type: 'job', + node: { $in: nodeScope }, + completeTime: null, + dispatchTime: null, + $or: [ + { dontQueueUntil: null }, + { dontQueueUntil: { $lte: Math.floor(new Date() / 1000) } } + ] + }).toArray(); }; - obj.getJobNodeHistory = function(nodeId) { - return obj.scriptFile.find( { - type: 'job', + obj.getStuckJobs = function (timeoutSeconds) { + var thresholdTime = Math.floor(new Date() / 1000) - timeoutSeconds; + return obj.scriptFile.find({ + type: 'job', + completeTime: null, + dispatchTime: { $ne: null }, + $or: [ + { lastPing: { $lt: thresholdTime } }, + { lastPing: null, dispatchTime: { $lt: thresholdTime } } + ] + }).toArray(); + }; + obj.getJobNodeHistory = function (nodeId) { + return obj.scriptFile.find({ + type: 'job', node: nodeId, }).sort({ queueTime: -1 }).limit(200).toArray(); }; - obj.getJobScriptHistory = function(scriptId) { - return obj.scriptFile.find( { - type: 'job', + obj.getJobScriptHistory = function (scriptId) { + return obj.scriptFile.find({ + type: 'job', scriptId: scriptId, }).sort({ completeTime: -1, queueTime: -1 }).limit(200).toArray(); }; - obj.updateScriptJobName = function(scriptId, scriptName) { - return obj.scriptFile.updateMany({ type: 'job', scriptId: scriptId }, { $set: { scriptName: scriptName } }); + obj.updateScriptJobName = function (scriptId, scriptName) { + return obj.scriptFile.updateMany({ type: 'job', scriptId: scriptId }, { $set: { scriptName: scriptName } }); }; - obj.getJobSchedulesForScript = function(scriptId) { - return obj.scriptFile.find( { type: 'jobSchedule', scriptId: scriptId } ).toArray(); + obj.getJobSchedulesForScript = function (scriptId) { + return obj.scriptFile.find({ type: 'jobSchedule', scriptId: scriptId }).toArray(); }; obj.getJobSchedulesForNode = function (nodeId) { - return obj.scriptFile.find( { type: 'jobSchedule', node: nodeId } ).toArray(); + return obj.scriptFile.find({ type: 'jobSchedule', node: nodeId }).toArray(); }; obj.getIncompleteJobsForSchedule = function (schedId) { - return obj.scriptFile.find( { type: 'job', jobSchedule: schedId, completeTime: null } ).toArray(); + return obj.scriptFile.find({ type: 'job', jobSchedule: schedId, completeTime: null }).toArray(); }; obj.deletePendingJobsForSchedule = function (schedId) { - return obj.scriptFile.deleteMany( { type: 'job', jobSchedule: schedId, completeTime: null } ); + return obj.scriptFile.deleteMany({ type: 'job', jobSchedule: schedId, completeTime: null }); }; - obj.deleteOldHistory = function() { + obj.deletePendingJobsForScript = function (scriptId) { + return obj.scriptFile.deleteMany({ type: 'job', scriptId: scriptId, completeTime: null }); + }; + obj.deleteOldHistory = function () { var nowTime = Math.floor(new Date() / 1000); var oldTime = nowTime - (86400 * 90); // 90 days - return obj.scriptFile.deleteMany( { type: 'job', completeTime: { $lte: oldTime } } ); + return obj.scriptFile.deleteMany({ type: 'job', completeTime: { $lte: oldTime } }); }; - obj.addVariable = function(name, scope, scopeTarget, value) { - var vObj = { + obj.addVariable = function (name, scope, scopeTarget, value) { + var vObj = { type: 'variable', name: name, scope: scope, @@ -210,27 +227,30 @@ module.exports.CreateDB = function(meshserver) { }; return obj.scriptFile.insertOne(vObj); }; - obj.getVariables = function(limiters) { + obj.getVariables = function (limiters) { if (limiters != null) { - var find = { + var find = { type: 'variable', name: { $in: limiters.names }, - $or: [ + $or: [ { scope: 'global' }, - { $and: [ - { scope: 'script' }, - { scopeTarget: limiters.scriptId } - ] + { + $and: [ + { scope: 'script' }, + { scopeTarget: limiters.scriptId } + ] }, - { $and: [ - { scope: 'mesh' }, - { scopeTarget: limiters.meshId } - ] + { + $and: [ + { scope: 'mesh' }, + { scopeTarget: limiters.meshId } + ] }, - { $and: [ - { scope: 'node' }, - { scopeTarget: limiters.nodeId } - ] + { + $and: [ + { scope: 'node' }, + { scopeTarget: limiters.nodeId } + ] } ] }; @@ -240,51 +260,71 @@ module.exports.CreateDB = function(meshserver) { return obj.scriptFile.find({ type: 'variable' }).sort({ name: 1 }).toArray(); } }; - obj.checkDefaults = function() { - obj.scriptFile.find( { type: 'folder', name: 'Shared', path: 'Shared' } ).toArray() - .then(found => { - if (found.length == 0) obj.addFolder('Shared', 'Shared'); - }) - .catch(e => { console.log('PLUGIN: ScriptTask: Default folder check failed. Error was: ', e); }); + obj.checkDefaults = function () { + obj.scriptFile.find({ type: 'folder', name: 'Shared', path: 'Shared' }).toArray() + .then(found => { + if (found.length == 0) obj.addFolder('Shared', 'Shared'); + }) + .catch(e => { console.log('PLUGIN: ScriptTask: Default folder check failed. Error was: ', e); }); }; - + obj.checkDefaults(); }; - + if (meshserver.args.mongodb) { // use MongDB - require('mongodb').MongoClient.connect(meshserver.args.mongodb, { useNewUrlParser: true, useUnifiedTopology: true }, function (err, client) { - if (err != null) { console.log("Unable to connect to database: " + err); process.exit(); return; } - - var dbname = 'meshcentral'; - if (meshserver.args.mongodbname) { dbname = meshserver.args.mongodbname; } - const db = client.db(dbname); - - obj.scriptFile = db.collection('plugin_scripttask'); - obj.scriptFile.indexes(function (err, indexes) { - // Check if we need to reset indexes - var indexesByName = {}, indexCount = 0; - for (var i in indexes) { indexesByName[indexes[i].name] = indexes[i]; indexCount++; } - if ((indexCount != 6) || (indexesByName['ScriptName1'] == null) || (indexesByName['ScriptPath1'] == null) || (indexesByName['JobTime1'] == null) || (indexesByName['JobNode1'] == null) || (indexesByName['JobScriptID1'] == null)) { - // Reset all indexes - console.log('Resetting plugin (ScriptTask) indexes...'); - obj.scriptFile.dropIndexes(function (err) { - obj.scriptFile.createIndex({ name: 1 }, { name: 'ScriptName1' }); - obj.scriptFile.createIndex({ path: 1 }, { name: 'ScriptPath1' }); - obj.scriptFile.createIndex({ queueTime: 1 }, { name: 'JobTime1' }); - obj.scriptFile.createIndex({ node: 1 }, { name: 'JobNode1' }); - obj.scriptFile.createIndex({ scriptId: 1 }, { name: 'JobScriptID1' }); - }); - } - }); - - - if (typeof require('mongodb').ObjectID == 'function') { - formatId = require('mongodb').ObjectID; - } else { - formatId = require('mongodb').ObjectId; - } - obj.initFunctions(); - }); + require('mongodb').MongoClient.connect(meshserver.args.mongodb, { useNewUrlParser: true, useUnifiedTopology: true }, function (err, client) { + if (err != null) { console.log("Unable to connect to database: " + err); process.exit(); return; } + + var dbname = 'meshcentral'; + if (meshserver.args.mongodbname) { dbname = meshserver.args.mongodbname; } + const db = client.db(dbname); + + obj.scriptFile = db.collection('plugin_scripttask'); + obj.scriptFile.indexes(function (err, indexes) { + // Check if we need to reset indexes + var indexesByName = {}, indexCount = 0; + for (var i in indexes) { indexesByName[indexes[i].name] = indexes[i]; indexCount++; } + if ((indexCount != 6) || (indexesByName['ScriptName1'] == null) || (indexesByName['ScriptPath1'] == null) || (indexesByName['JobTime1'] == null) || (indexesByName['JobNode1'] == null) || (indexesByName['JobScriptID1'] == null)) { + // Reset all indexes + console.log('Resetting plugin (ScriptTask) indexes...'); + obj.scriptFile.dropIndexes(function (err) { + obj.scriptFile.createIndex({ name: 1 }, { name: 'ScriptName1' }); + obj.scriptFile.createIndex({ path: 1 }, { name: 'ScriptPath1' }); + obj.scriptFile.createIndex({ queueTime: 1 }, { name: 'JobTime1' }); + obj.scriptFile.createIndex({ node: 1 }, { name: 'JobNode1' }); + obj.scriptFile.createIndex({ scriptId: 1 }, { name: 'JobScriptID1' }); + }); + } + }); + + + if (typeof require('mongodb').ObjectID == 'function') { + formatId = require('mongodb').ObjectID; + } else { + formatId = require('mongodb').ObjectId; + } + obj.initFunctions(); + }); + } else if (meshserver.args.mariadb) { // use MariaDB + var mariadb = null; + try { mariadb = require('mariadb'); } catch (e) { console.log('PLUGIN: ScriptTask: mariadb module is required but not found.'); } + if (mariadb != null) { + var NEMariaDB = require(__dirname + '/nemariadb.js'); + var m_options = meshserver.args.mariadb; + if (typeof m_options === 'string') { + try { + const urlToConfig = require('mariadb/lib/misc/url-to-config.js'); + m_options = urlToConfig(m_options); + } catch (e) { + // Fallback to letting createPool parse it directly if supported + } + } + if (meshserver.args.mariadbname && typeof m_options === 'object') m_options.database = meshserver.args.mariadbname; + var pool = mariadb.createPool(m_options); + obj.scriptFile = new NEMariaDB(pool); + formatId = function (id) { return id; }; + obj.initFunctions(); + } } else { // use NeDb try { Datastore = require('@seald-io/nedb'); } catch (ex) { } // This is the NeDB with Node 23 support. if (Datastore == null) { @@ -301,9 +341,9 @@ module.exports.CreateDB = function(meshserver) { obj.scriptFilex.ensureIndex({ fieldName: 'scriptId' }); } obj.scriptFile = new NEMongo(obj.scriptFilex); - formatId = function(id) { return id; }; + formatId = function (id) { return id; }; obj.initFunctions(); } - + return obj; } diff --git a/modules_meshcore/scripttask.js b/modules_meshcore/scripttask.js index 3143a4a..72ad77b 100644 --- a/modules_meshcore/scripttask.js +++ b/modules_meshcore/scripttask.js @@ -17,6 +17,32 @@ var debug_flag = false; var runningJobs = []; var runningJobPIDs = {}; +var jobPingTimer = null; + +function startJobPing() { + if (jobPingTimer == null) { + jobPingTimer = setInterval(function() { + if (runningJobs.length == 0) { + clearInterval(jobPingTimer); + jobPingTimer = null; + return; + } + runningJobs.forEach(function(jobId) { + try { + mesh.SendCommand({ + "action": "plugin", + "plugin": "scripttask", + "pluginaction": "jobRunningPing", + "jobId": jobId, + "sessionid": _sessionid, + "tag": "console" + }); + } catch(e){} + }); + }, 60000); // 1 minute + } +} + var dbg = function(str) { if (debug_flag !== true) return; var fs = require('fs'); @@ -150,8 +176,8 @@ function runPowerShell(sObj, jObj) { const fs = require('fs'); var rand = Math.random().toString(32).replace('0.', ''); - var oName = 'st' + rand + '.txt'; - var pName = 'st' + rand + '.ps1'; + var oName = 'tmp_st_' + rand + '.txt'; + var pName = 'tmp_st_' + rand + '.ps1'; var pwshout = '', pwsherr = '', cancontinue = false; try { fs.writeFileSync(pName, sObj.content); @@ -221,8 +247,8 @@ function runPowerShellNonWin(sObj, jObj) { dbg('Path chosen is: ' + path); path = path + '/'; - var oName = 'st' + rand + '.txt'; - var pName = 'st' + rand + '.ps1'; + var oName = 'tmp_st_' + rand + '.txt'; + var pName = 'tmp_st_' + rand + '.ps1'; var pwshout = '', pwsherr = '', cancontinue = false; try { var childp = require('child_process').execFile('/bin/sh', ['sh']); @@ -298,8 +324,8 @@ function runBat(sObj, jObj) { } const fs = require('fs'); var rand = Math.random().toString(32).replace('0.', ''); - var oName = 'st' + rand + '.txt'; - var pName = 'st' + rand + '.bat'; + var oName = 'tmp_st_' + rand + '.txt'; + var pName = 'tmp_st_' + rand + '.bat'; try { fs.writeFileSync(pName, sObj.content); var outstr = '', errstr = ''; @@ -372,8 +398,8 @@ function runBash(sObj, jObj) { //child.execFile(process.env['windir'] + '\\system32\\cmd.exe', ['/c', 'RunDll32.exe user32.dll,LockWorkStation'], { type: 1 }); var rand = Math.random().toString(32).replace('0.', ''); - var oName = 'st' + rand + '.txt'; - var pName = 'st' + rand + '.sh'; + var oName = 'tmp_st_' + rand + '.txt'; + var pName = 'tmp_st_' + rand + '.sh'; try { fs.writeFileSync(path + pName, sObj.content); var outstr = '', errstr = ''; @@ -460,6 +486,7 @@ function runScript(sObj, jObj) { sObj.content = sObj.content.replace(new RegExp('#(.*?)#', 'g'), 'VAR_NOT_FOUND'); } runningJobs.push(jObj.jobId); + startJobPing(); dbg('Running Script '+ sObj._id); switch (sObj.filetype) { case 'ps1': diff --git a/nemariadb.js b/nemariadb.js new file mode 100644 index 0000000..b48b89b --- /dev/null +++ b/nemariadb.js @@ -0,0 +1,486 @@ +/** +* @description MeshCentral database abstraction layer for MariaDB +* @author Ryan Blenis +* @copyright +* @license Apache-2.0 +* This is a simple abstraction layer for many commonly used DB calls. +* It routes requests between the legacy JSON table and a dedicated Jobs table. +*/ + +class NEMariaDB { + constructor(pool) { + this.pool = pool; + this._find = null; + this._proj = null; + this._limit = null; + this._sort = null; + + // initialize tables + this._initDB(); + + return this; + } + + _initDB() { + this.pool.query("CREATE TABLE IF NOT EXISTS plugin_scripttask (id VARCHAR(128) PRIMARY KEY, doc JSON)") + .catch(err => { console.log("PLUGIN: ScriptTask: Error creating database table", err); }); + + this.pool.query("CREATE TABLE IF NOT EXISTS plugin_scripttask_jobs (id VARCHAR(128) PRIMARY KEY, type VARCHAR(64) DEFAULT 'job', queueTime INT, dontQueueUntil INT, dispatchTime INT, completeTime INT, lastPing INT, node VARCHAR(256), scriptId VARCHAR(128), scriptName VARCHAR(512), replaceVars JSON, returnVal MEDIUMTEXT, errorVal TEXT, returnAct VARCHAR(256), runBy VARCHAR(256), jobSchedule VARCHAR(128))") + .catch(err => { console.log("PLUGIN: ScriptTask: Error creating jobs table", err); }); + } + + _escape(val) { + // Use the native secure pool escaper to serialize standard values. + // Handles \x00, \n, \r, \, ', ", and \x1a safely. + if (typeof val === 'object' && val !== null) { + return this.pool.escape(JSON.stringify(val)); + } + return this.pool.escape(val); + } + + _escapeCol(col) { + // Enclose in backticks and remove internal backticks to comprehensively protect column names + return '`' + col.replace(/`/g, '') + '`'; + } + + _escapeJsonPath(path) { + // Securely format JSON path keys by double quoting them inside the single-quoted string literal + return "'$.\"" + path.replace(/"/g, '\\"') + "\"'"; + } + + _buildWhereDoc(filter) { + if (!filter || Object.keys(filter).length === 0) return "1=1"; + var conditions = []; + for (var key in filter) { + if (key === '$or') { + var orConds = []; + for (var i in filter.$or) orConds.push("(" + this._buildWhereDoc(filter.$or[i]) + ")"); + conditions.push("(" + orConds.join(" OR ") + ")"); + } else if (key === '$and') { + var andConds = []; + for (var i in filter.$and) andConds.push("(" + this._buildWhereDoc(filter.$and[i]) + ")"); + conditions.push("(" + andConds.join(" AND ") + ")"); + } else { + var val = filter[key]; + var dbKey = key === '_id' ? '`id`' : `JSON_UNQUOTE(JSON_EXTRACT(\`doc\`, ${this._escapeJsonPath(key)}))`; + + if (val !== null && typeof val === 'object' && !Array.isArray(val)) { + for (var op in val) { + if (op === '$in') { + if (val.$in.length === 0) { + conditions.push('1=0'); + } else { + var inList = val.$in.map(v => this._escape(v)).join(","); + conditions.push(`${dbKey} IN (${inList})`); + } + } else if (op === '$gte') { + conditions.push(`${dbKey} >= ${this._escape(val.$gte)}`); + } else if (op === '$lte') { + conditions.push(`${dbKey} <= ${this._escape(val.$lte)}`); + } else if (op === '$gt') { + conditions.push(`${dbKey} > ${this._escape(val.$gt)}`); + } else if (op === '$lt') { + conditions.push(`${dbKey} < ${this._escape(val.$lt)}`); + } else if (op === '$ne') { + if (val.$ne === null) { + conditions.push(`(${dbKey} IS NOT NULL AND ${dbKey} != 'null')`); + } else { + conditions.push(`${dbKey} != ${this._escape(val.$ne)}`); + } + } + } + } else if (val === null) { + conditions.push(`(${dbKey} IS NULL OR ${dbKey} = 'null')`); + } else { + conditions.push(`${dbKey} = ${this._escape(val)}`); + } + } + } + return conditions.join(" AND "); + } + + _buildWhereJob(filter) { + if (!filter || Object.keys(filter).length === 0) return "1=1"; + var conditions = []; + for (var key in filter) { + if (key === '$or') { + var orConds = []; + for (var i in filter.$or) orConds.push("(" + this._buildWhereJob(filter.$or[i]) + ")"); + conditions.push("(" + orConds.join(" OR ") + ")"); + } else if (key === '$and') { + var andConds = []; + for (var i in filter.$and) andConds.push("(" + this._buildWhereJob(filter.$and[i]) + ")"); + conditions.push("(" + andConds.join(" AND ") + ")"); + } else { + var val = filter[key]; + var dbKey = key === '_id' ? '`id`' : this._escapeCol(key); + + if (val !== null && typeof val === 'object' && !Array.isArray(val)) { + for (var op in val) { + if (op === '$in') { + if (val.$in.length === 0) { + conditions.push('1=0'); + } else { + var inList = val.$in.map(v => this._escape(v)).join(","); + conditions.push(`${dbKey} IN (${inList})`); + } + } else if (op === '$gte') { + conditions.push(`${dbKey} >= ${this._escape(val.$gte)}`); + } else if (op === '$lte') { + conditions.push(`${dbKey} <= ${this._escape(val.$lte)}`); + } else if (op === '$gt') { + conditions.push(`${dbKey} > ${this._escape(val.$gt)}`); + } else if (op === '$lt') { + conditions.push(`${dbKey} < ${this._escape(val.$lt)}`); + } else if (op === '$ne') { + if (val.$ne === null) { + conditions.push(`${dbKey} IS NOT NULL`); + } else { + conditions.push(`${dbKey} != ${this._escape(val.$ne)}`); + } + } + } + } else if (val === null) { + conditions.push(`${dbKey} IS NULL`); + } else { + conditions.push(`${dbKey} = ${this._escape(val)}`); + } + } + } + return conditions.join(" AND "); + } + + find(args, proj) { + this._find = args; + this._proj = proj; + this._sort = null; + this._limit = null; + return this; + } + + project(args) { this._proj = args; return this; } + sort(args) { this._sort = args; return this; } + limit(limit) { this._limit = Number(limit) || null; return this; } + + _applyProjection(docs) { + if (!this._proj) return docs; + var keepFields = []; + var excludeFields = []; + for (var p in this._proj) { + if (this._proj[p] === 1) keepFields.push(p); + else if (this._proj[p] === 0) excludeFields.push(p); + } + var ret = []; + for (var doc of docs) { + var pDoc = {}; + if (keepFields.length > 0) { + for (var k of keepFields) { if (doc[k] !== undefined) pDoc[k] = doc[k]; } + if (excludeFields.indexOf('_id') === -1) pDoc._id = doc._id || doc.id; + ret.push(pDoc); + } else { + var nDoc = { ...doc }; + for (var k of excludeFields) delete nDoc[k]; + ret.push(nDoc); + } + } + return ret; + } + + toArray(callback) { + var self = this; + return new Promise(function (resolve, reject) { + var isJob = self._find && self._find.type === 'job'; + + var queryJob = () => { + var wJ = self._buildWhereJob(self._find); + var q = `SELECT * FROM \`plugin_scripttask_jobs\` WHERE ${wJ}`; + if (self._sort) { + var order = []; + for (var key in self._sort) order.push(`${key === '_id' ? '`id`' : self._escapeCol(key)} ${self._sort[key] === -1 ? 'DESC' : 'ASC'}`); + if (order.length > 0) q += " ORDER BY " + order.join(", "); + } + if (self._limit) q += ` LIMIT ${self._limit}`; + return self.pool.query(q).then(rows => { + var docs = []; + for (var r of rows) { + var it = { ...r }; + it._id = it.id; delete it.id; + for (var k in it) { + if (typeof it[k] === 'bigint') it[k] = Number(it[k]); + } + if (it.replaceVars && typeof it.replaceVars === 'string') { + try { it.replaceVars = JSON.parse(it.replaceVars); } catch (e) { } + } + docs.push(it); + } + return docs; + }); + }; + + var queryDoc = () => { + var wD = self._buildWhereDoc(self._find); + var q = `SELECT \`doc\` FROM \`plugin_scripttask\` WHERE ${wD}`; + if (self._sort) { + var order = []; + for (var key in self._sort) { + if (key === '_id') order.push(`\`id\` ${self._sort[key] === -1 ? 'DESC' : 'ASC'}`); + else order.push(`JSON_UNQUOTE(JSON_EXTRACT(\`doc\`, ${self._escapeJsonPath(key)})) ${self._sort[key] === -1 ? 'DESC' : 'ASC'}`); + } + if (order.length > 0) q += " ORDER BY " + order.join(", "); + } + if (self._limit) q += ` LIMIT ${self._limit}`; + return self.pool.query(q).then(rows => { + var docs = []; + for (var i = 0; i < rows.length; i++) { + var doc = typeof rows[i].doc === 'string' ? JSON.parse(rows[i].doc) : rows[i].doc; + docs.push(doc); + } + return docs; + }); + }; + + var handleResults = (docs) => { + docs = self._applyProjection(docs); + if (callback != null && typeof callback == 'function') callback(null, docs); + resolve(docs); + } + + if (isJob) return queryJob().then(handleResults).catch(reject); + if (self._find && self._find.type && self._find.type !== 'job') return queryDoc().then(handleResults).catch(reject); + + queryDoc().then(docs => { + if (docs.length > 0) return handleResults(docs); + return queryJob().then(handleResults); + }).catch(reject); + }); + } + + insertOne(args, options) { + var self = this; + return new Promise(function (resolve, reject) { + var id = args._id; + if (!id) { + id = require('crypto').randomBytes(12).toString('hex'); + args._id = id; + } + if (args.type === 'job') { + var cols = ['`id`']; + var qmarks = ['?']; + var vals = [id]; + for (var k in args) { + if (k === '_id' || k === 'id') continue; + cols.push(self._escapeCol(k)); + qmarks.push('?'); + var v = args[k]; + if (typeof v === 'object' && v !== null) v = JSON.stringify(v); + vals.push(v); + } + self.pool.query(`INSERT INTO \`plugin_scripttask_jobs\` (${cols.join(',')}) VALUES (${qmarks.join(',')})`, vals) + .then(res => resolve({ insertedId: id })) + .catch(reject); + } else { + var docStr = JSON.stringify(args); + self.pool.query("INSERT INTO \`plugin_scripttask\` (`id`, `doc`) VALUES (?, ?)", [id, docStr]) + .then(res => resolve({ insertedId: id })) + .catch(reject); + } + }); + } + + deleteOne(filter, options) { + var self = this; + return new Promise(function (resolve, reject) { + var isJob = filter.type === 'job'; + var isDoc = filter.type && filter.type !== 'job'; + var tryJob = () => self.pool.query(`DELETE FROM \`plugin_scripttask_jobs\` WHERE ${self._buildWhereJob(filter)} LIMIT 1`); + var tryDoc = () => self.pool.query(`DELETE FROM \`plugin_scripttask\` WHERE ${self._buildWhereDoc(filter)} LIMIT 1`); + + if (isJob) return tryJob().then(res => resolve({ deletedCount: res.affectedRows })).catch(reject); + if (isDoc) return tryDoc().then(res => resolve({ deletedCount: res.affectedRows })).catch(reject); + + var count = 0; + tryDoc().catch(err => { if (err.errno === 1054) return { affectedRows: 0 }; throw err; }) + .then(res => { + count += res.affectedRows; + return tryJob().catch(err => { if (err.errno === 1054) return { affectedRows: 0 }; throw err; }); + }) + .then(res => { + count += res.affectedRows; + resolve({ deletedCount: count }); + }) + .catch(reject); + }); + } + + deleteMany(filter, options) { + var self = this; + return new Promise(function (resolve, reject) { + var isJob = filter.type === 'job'; + var isDoc = filter.type && filter.type !== 'job'; + var tryJob = () => self.pool.query(`DELETE FROM \`plugin_scripttask_jobs\` WHERE ${self._buildWhereJob(filter)}`); + var tryDoc = () => self.pool.query(`DELETE FROM \`plugin_scripttask\` WHERE ${self._buildWhereDoc(filter)}`); + + if (isJob) return tryJob().then(res => resolve({ deletedCount: res.affectedRows })).catch(reject); + if (isDoc) return tryDoc().then(res => resolve({ deletedCount: res.affectedRows })).catch(reject); + + var count = 0; + tryDoc().catch(err => { if (err.errno === 1054) return { affectedRows: 0 }; throw err; }) + .then(res => { + count += res.affectedRows; + return tryJob().catch(err => { if (err.errno === 1054) return { affectedRows: 0 }; throw err; }); + }) + .then(res => { + count += res.affectedRows; + resolve({ deletedCount: count }); + }) + .catch(reject); + }); + } + + updateOne(filter, update, options) { + var self = this; + if (options == null) options = {}; + if (options.upsert == null) options.upsert = false; + + return new Promise(function (resolve, reject) { + var tryUpdateJob = () => { + var wJ = self._buildWhereJob(filter); + return self.pool.query(`SELECT \`id\` FROM \`plugin_scripttask_jobs\` WHERE ${wJ} LIMIT 1`) + .then(rows => { + if (rows.length === 0) return { matchedCount: 0, modifiedCount: 0 }; + var updates = [], vals = []; + var src = update.$set ? update.$set : update; + for (var k in src) { + if (k === '_id' || k === 'id') continue; + updates.push(`${self._escapeCol(k)} = ?`); + var v = src[k]; + if (typeof v === 'object' && v !== null) v = JSON.stringify(v); + vals.push(v); + } + if (updates.length > 0) { + vals.push(rows[0].id); + return self.pool.query(`UPDATE \`plugin_scripttask_jobs\` SET ${updates.join(', ')} WHERE \`id\` = ?`, vals) + .then(() => ({ matchedCount: 1, modifiedCount: 1, upsertedId: rows[0].id })); + } else { + return { matchedCount: 1, modifiedCount: 0 }; + } + }); + }; + + var tryUpdateDoc = () => { + var wD = self._buildWhereDoc(filter); + return self.pool.query(`SELECT \`id\`, \`doc\` FROM \`plugin_scripttask\` WHERE ${wD} LIMIT 1`) + .then(rows => { + if (rows.length === 0) return { matchedCount: 0, modifiedCount: 0 }; + var id = rows[0].id; + var doc = typeof rows[0].doc === 'string' ? JSON.parse(rows[0].doc) : rows[0].doc; + var modified = false; + if (update.$set) { + for (var k in update.$set) doc[k] = update.$set[k]; + modified = true; + } else { + doc = { ...doc, ...update }; + if (!doc._id) doc._id = id; + modified = true; + } + if (modified) { + return self.pool.query("UPDATE \`plugin_scripttask\` SET \`doc\` = ? WHERE \`id\` = ?", [JSON.stringify(doc), id]) + .then(() => ({ matchedCount: 1, modifiedCount: 1, upsertedId: id })); + } else { + return { matchedCount: 1, modifiedCount: 0 }; + } + }); + }; + + var isJob = filter.type === 'job'; + var isDoc = filter.type && filter.type !== 'job'; + + if (isJob) return tryUpdateJob().then(res => { + if (res.matchedCount === 0 && options.upsert) { + var newDoc = { ...filter, ...(update.$set || {}) }; + return self.insertOne(newDoc).then(r => ({ matchedCount: 0, modifiedCount: 1, upsertedId: r.insertedId })); + } + resolve(res); + }).catch(reject); + + if (isDoc) return tryUpdateDoc().then(res => { + if (res.matchedCount === 0 && options.upsert) { + var newDoc = { ...filter, ...(update.$set || {}) }; + return self.insertOne(newDoc).then(r => ({ matchedCount: 0, modifiedCount: 1, upsertedId: r.insertedId })); + } + resolve(res); + }).catch(reject); + + tryUpdateDoc().then(res => { + if (res.matchedCount > 0) return resolve(res); + return tryUpdateJob().then(res2 => { + if (res2.matchedCount === 0 && options.upsert) { + var newDoc = { ...filter, ...(update.$set || {}) }; // fallback to insert doc + return self.insertOne(newDoc).then(r => resolve({ matchedCount: 0, modifiedCount: 1, upsertedId: r.insertedId })); + } + resolve(res2); + }); + }).catch(reject); + }); + } + + updateMany(filter, update, options) { + var self = this; + if (options == null) options = {}; + if (options.upsert == null) options.upsert = false; + + return new Promise(function (resolve, reject) { + var tryUpdateJob = () => { + var wJ = self._buildWhereJob(filter); + return self.pool.query(`SELECT \`id\` FROM \`plugin_scripttask_jobs\` WHERE ${wJ}`) + .then(rows => { + if (rows.length === 0) return { matchedCount: 0, modifiedCount: 0 }; + var updatesQ = [], vals = []; + var src = update.$set ? update.$set : update; + for (var k in src) { + if (k === '_id' || k === 'id') continue; + updatesQ.push(`${self._escapeCol(k)} = ?`); + var v = src[k]; + if (typeof v === 'object' && v !== null) v = JSON.stringify(v); + vals.push(v); + } + if (updatesQ.length > 0) { + var proms = rows.map(r => self.pool.query(`UPDATE \`plugin_scripttask_jobs\` SET ${updatesQ.join(', ')} WHERE \`id\` = ?`, [...vals, r.id])); + return Promise.all(proms).then(() => ({ matchedCount: rows.length, modifiedCount: rows.length })); + } else { + return { matchedCount: rows.length, modifiedCount: 0 }; + } + }); + }; + + var tryUpdateDoc = () => { + var wD = self._buildWhereDoc(filter); + return self.pool.query(`SELECT \`id\`, \`doc\` FROM \`plugin_scripttask\` WHERE ${wD}`) + .then(rows => { + if (rows.length === 0) return { matchedCount: 0, modifiedCount: 0 }; + var proms = rows.map(r => { + var doc = typeof r.doc === 'string' ? JSON.parse(r.doc) : r.doc; + if (update.$set) { + for (var k in update.$set) doc[k] = update.$set[k]; + } else { + doc = { ...doc, ...update }; + if (!doc._id) doc._id = r.id; + } + return self.pool.query("UPDATE \`plugin_scripttask\` SET \`doc\` = ? WHERE \`id\` = ?", [JSON.stringify(doc), r.id]); + }); + return Promise.all(proms).then(() => ({ matchedCount: rows.length, modifiedCount: rows.length })); + }); + }; + + var isJob = filter.type === 'job'; + if (isJob) return tryUpdateJob().then(res => resolve(res)).catch(reject); + return tryUpdateDoc().then(res => resolve(res)).catch(reject); + }); + } + + indexes(callback) { if (callback != null && typeof callback == 'function') callback(null, []); } + dropIndexes(callback) { if (callback != null && typeof callback == 'function') callback(null); } + createIndex(args, options) { } +} + +module.exports = NEMariaDB; diff --git a/readme.md b/readme.md index a289b34..a21276e 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,5 @@ # MeshCentral-ScriptTask +# Forked to add support to MariaDB! A script running plugin for the [MeshCentral2](https://github.com/Ylianst/MeshCentral) Project. The plugin supports PowerShell, BAT, and Bash scripts. Windows, MacOS, and Linux endpoints are all supported. PowerShell can be run on any OS that has PowerShell installed, not just Windows. @@ -14,7 +15,7 @@ A script running plugin for the [MeshCentral2](https://github.com/Ylianst/MeshCe Restart your MeshCentral server after making this change. To install, simply add the plugin configuration URL when prompted: - `https://raw.githubusercontent.com/ryanblenis/MeshCentral-ScriptTask/master/config.json` + `https://raw.githubusercontent.com/panreyes/MeshCentral-ScriptTask/master/config.json` ## Features - Add scripts to a central store diff --git a/scripttask.js b/scripttask.js index 609e720..5001ad8 100644 --- a/scripttask.js +++ b/scripttask.js @@ -15,32 +15,33 @@ module.exports.scripttask = function (parent) { obj.intervalTimer = null; obj.debug = obj.meshServer.debug; obj.VIEWS = __dirname + '/views/'; - obj.exports = [ + obj.exports = [ 'onDeviceRefreshEnd', 'resizeContent', 'historyData', 'variableData', + 'newScriptTree', 'malix_triggerOption' ]; - - obj.malix_triggerOption = function(selectElem) { + + obj.malix_triggerOption = function (selectElem) { selectElem.options.add(new Option("ScriptTask - Run Script", "scripttask_runscript")); } - obj.malix_triggerFields_scripttask_runscript = function() { - + obj.malix_triggerFields_scripttask_runscript = function () { + } - obj.resetQueueTimer = function() { + obj.resetQueueTimer = function () { clearTimeout(obj.intervalTimer); obj.intervalTimer = setInterval(obj.queueRun, 1 * 60 * 1000); // every minute }; - - obj.server_startup = function() { - obj.meshServer.pluginHandler.scripttask_db = require (__dirname + '/db.js').CreateDB(obj.meshServer); + + obj.server_startup = function () { + obj.meshServer.pluginHandler.scripttask_db = require(__dirname + '/db.js').CreateDB(obj.meshServer); obj.db = obj.meshServer.pluginHandler.scripttask_db; obj.resetQueueTimer(); }; - - obj.onDeviceRefreshEnd = function() { + + obj.onDeviceRefreshEnd = function () { pluginHandler.registerPluginTab({ tabTitle: 'ScriptTask', tabId: 'pluginScriptTask' @@ -48,7 +49,7 @@ module.exports.scripttask = function (parent) { QA('pluginScriptTask', '